mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
Rebase custom-agents onto main (1/4): agent-launch host + shared/CLI
Host-owned agent-launch subsystem, orchestration U6 identity/forget, resolver #7862 Windows-shell + Hermes native-query parity, shared launch contracts, preload/relay/CLI surfaces. One rebase landing split for review; only the branch tip is expected to build/test green. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -488,6 +488,72 @@ describe('orchestration dispatch coordinator handle', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration dispatch Forget + raw read CLI handlers (W-T2)', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
})
|
||||
|
||||
const invoke = (key: string, flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS[key]({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
it('dispatch-forget invokes dispatchForget with the task and expected failure id', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten' }
|
||||
})
|
||||
|
||||
await invoke(
|
||||
'orchestration dispatch-forget',
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['expected-failure-id', 'fail-1']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', {
|
||||
task: 'task_1',
|
||||
expectedFailureId: 'fail-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatch-forget omits expectedFailureId when the flag is absent', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten' }
|
||||
})
|
||||
|
||||
await invoke(
|
||||
'orchestration dispatch-forget',
|
||||
new Map<string, string | boolean>([['task', 'task_1']])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', {
|
||||
task: 'task_1',
|
||||
expectedFailureId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('dispatch-show --raw reads the un-projected status via dispatchShowRaw (never the projected read)', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten', agent_launch_failure: null }
|
||||
})
|
||||
|
||||
await invoke(
|
||||
'orchestration dispatch-show',
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['raw', true]
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.dispatchShowRaw', { task: 'task_1' })
|
||||
expect(callMock).not.toHaveBeenCalledWith('orchestration.dispatchShow', expect.anything())
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-create caller handle', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
|
||||
@@ -41,6 +41,28 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
|
||||
|
||||
export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
|
||||
if (flags.has('raw')) {
|
||||
const result = await client.call<{
|
||||
dispatch: {
|
||||
id: string
|
||||
task_id: string
|
||||
status: string
|
||||
agent_launch_failure: string | null
|
||||
} | null
|
||||
}>('orchestration.dispatchShowRaw', {
|
||||
task: getRequiredStringFlag(flags, 'task')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (!value.dispatch) {
|
||||
return 'No dispatch context found.'
|
||||
}
|
||||
const failure = value.dispatch.agent_launch_failure
|
||||
? ` failure=${value.dispatch.agent_launch_failure}`
|
||||
: ''
|
||||
return `${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]${failure}`
|
||||
})
|
||||
return
|
||||
}
|
||||
const showPreamble = flags.has('preamble') ? true : undefined
|
||||
// Why: a preview must embed the same real coordinator handle as an actual dispatch.
|
||||
const from = showPreamble
|
||||
@@ -66,6 +88,21 @@ export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record<string, CommandH
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration dispatch-forget': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string } | null
|
||||
}>('orchestration.dispatchForget', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
expectedFailureId: getOptionalStringFlag(flags, 'expected-failure-id')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (!value.dispatch) {
|
||||
return 'No dispatch context found.'
|
||||
}
|
||||
return `Forgot dispatch ${value.dispatch.id} task=${value.dispatch.task_id} [${value.dispatch.status}]. Task is blocked; retry with: orca orchestration task-update --id ${value.dispatch.task_id} --status ready`
|
||||
})
|
||||
},
|
||||
|
||||
'orchestration coordinator-start': async () => {
|
||||
throw new RuntimeClientError(
|
||||
'orchestration_migration_required',
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeRpcSuccess } from '../runtime-client'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import type {
|
||||
CreatedRuntimeWorktreeCreateResult,
|
||||
RuntimeWorktreeCreateResult
|
||||
} from '../../shared/runtime-types'
|
||||
import { buildWorktree } from '../test-fixtures'
|
||||
import {
|
||||
getWorktreeCreateAgentLaunch,
|
||||
handleWorktreeCreatePreRejection,
|
||||
printWorktreeCreateResult,
|
||||
type AgentLaunchSource
|
||||
} from './worktree-create-agent-launch'
|
||||
|
||||
type Flags = Map<string, string | boolean>
|
||||
|
||||
function flags(entries: Record<string, string | boolean>): Flags {
|
||||
return new Map(Object.entries(entries))
|
||||
}
|
||||
|
||||
function envelope(
|
||||
result: RuntimeWorktreeCreateResult
|
||||
): RuntimeRpcSuccess<RuntimeWorktreeCreateResult> {
|
||||
return { id: 'req_create', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function createdWorktree(
|
||||
agentLaunchResult?: CreatedRuntimeWorktreeCreateResult['agentLaunchResult']
|
||||
): CreatedRuntimeWorktreeCreateResult {
|
||||
return {
|
||||
worktree: buildWorktree(
|
||||
'/tmp/repo/feature',
|
||||
'feature',
|
||||
'abc',
|
||||
'repo-1'
|
||||
) as unknown as CreatedRuntimeWorktreeCreateResult['worktree'],
|
||||
lineage: null,
|
||||
warnings: [],
|
||||
...(agentLaunchResult ? { agentLaunchResult } : {})
|
||||
}
|
||||
}
|
||||
|
||||
const LAUNCHED = {
|
||||
status: 'launched' as const,
|
||||
receipt: {
|
||||
requestedAgent: 'codex' as const,
|
||||
baseAgent: 'codex' as const,
|
||||
notices: [],
|
||||
launchToken: 'tok-1',
|
||||
catalogRevision: 1,
|
||||
telemetry: { agentKind: 'codex' as const, usedCustomAgent: false }
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = 0
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getWorktreeCreateAgentLaunch', () => {
|
||||
it('maps --agent <id> to an explicit agent selection carrying the prompt', () => {
|
||||
const launch = getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: 'do it' }))
|
||||
expect(launch).toEqual({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: 'codex' },
|
||||
allowEmptyPromptLaunch: true,
|
||||
prompt: 'do it'
|
||||
},
|
||||
source: { via: 'flag', id: 'codex' }
|
||||
})
|
||||
})
|
||||
|
||||
it('maps a bare --agent to the stored default selection', () => {
|
||||
const launch = getWorktreeCreateAgentLaunch(flags({ agent: true }))
|
||||
expect(launch).toEqual({
|
||||
request: { selection: { kind: 'default' }, allowEmptyPromptLaunch: true },
|
||||
source: { via: 'default' }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when no agent is requested', () => {
|
||||
expect(getWorktreeCreateAgentLaunch(flags({ name: 'feature' }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects --prompt without --agent before any RPC', () => {
|
||||
expect(() => getWorktreeCreateAgentLaunch(flags({ prompt: 'hi' }))).toThrow(
|
||||
'--prompt requires --agent'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a valueless --prompt', () => {
|
||||
expect(() => getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: true }))).toThrow(
|
||||
'Missing value for --prompt'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps an explicit empty --prompt as an empty draft', () => {
|
||||
const launch = getWorktreeCreateAgentLaunch(flags({ agent: 'codex', prompt: '' }))
|
||||
expect(launch?.request).toEqual({
|
||||
selection: { kind: 'agent', agent: 'codex' },
|
||||
allowEmptyPromptLaunch: true,
|
||||
prompt: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a malformed agent id as invalid_argument', () => {
|
||||
try {
|
||||
getWorktreeCreateAgentLaunch(flags({ agent: 'not a real agent!!' }))
|
||||
expect.unreachable('should have thrown')
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect((error as RuntimeClientError).code).toBe('invalid_argument')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const FLAG_SOURCE: AgentLaunchSource = { via: 'flag', id: 'ghost' }
|
||||
const DEFAULT_SOURCE: AgentLaunchSource = { via: 'default' }
|
||||
|
||||
describe('handleWorktreeCreatePreRejection', () => {
|
||||
it('returns the created arm unchanged when the worktree was created', () => {
|
||||
const created = createdWorktree(LAUNCHED)
|
||||
const result = handleWorktreeCreatePreRejection(envelope(created), FLAG_SOURCE, false)
|
||||
expect(result).toBe(created)
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('prints the stable code and human line to stderr and exits non-zero on a failed rejection', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const response = envelope({
|
||||
created: false,
|
||||
agentLaunchResult: { status: 'failed', failure: { code: 'unknown_agent' } }
|
||||
})
|
||||
|
||||
const result = handleWorktreeCreatePreRejection(response, FLAG_SOURCE, false)
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(errSpy.mock.calls[0][0]).toBe('unknown_agent')
|
||||
expect(errSpy.mock.calls[1][0]).toContain('ghost')
|
||||
expect(errSpy.mock.calls[1][0]).toContain('--agent')
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('names the stored default agent for a default-sourced rejection', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const response = envelope({
|
||||
created: false,
|
||||
agentLaunchResult: {
|
||||
status: 'failed',
|
||||
failure: { code: 'base_agent_disabled', baseAgent: 'codex' }
|
||||
}
|
||||
})
|
||||
|
||||
handleWorktreeCreatePreRejection(response, DEFAULT_SOURCE, false)
|
||||
|
||||
expect(errSpy.mock.calls[0][0]).toBe('base_agent_disabled')
|
||||
expect(errSpy.mock.calls[1][0]).toContain('stored default')
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('surfaces a request-error rejection code and exits non-zero', () => {
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const response = envelope({
|
||||
created: false,
|
||||
agentLaunchResult: { status: 'rejected', requestError: { code: 'idempotency_conflict' } }
|
||||
})
|
||||
|
||||
handleWorktreeCreatePreRejection(response, FLAG_SOURCE, false)
|
||||
|
||||
expect(errSpy.mock.calls[0][0]).toBe('idempotency_conflict')
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('prints the typed rejection envelope in JSON mode without a stderr line', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const response = envelope({
|
||||
created: false,
|
||||
agentLaunchResult: { status: 'failed', failure: { code: 'unknown_agent' } }
|
||||
})
|
||||
|
||||
handleWorktreeCreatePreRejection(response, FLAG_SOURCE, true)
|
||||
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain('unknown_agent')
|
||||
expect(errSpy).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('printWorktreeCreateResult', () => {
|
||||
it('prints the created worktree and leaves the exit code clean on a launched result', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const created = createdWorktree(LAUNCHED)
|
||||
|
||||
printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, false)
|
||||
|
||||
expect(logSpy).toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('prints the retained worktree then the stderr contract and exits non-zero on a post-create failure', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const created = createdWorktree({
|
||||
status: 'failed',
|
||||
failure: {
|
||||
code: 'missing_variable',
|
||||
version: 1,
|
||||
failureId: 'f1',
|
||||
intent: 'cli',
|
||||
occurredAt: 0,
|
||||
variable: 'worktreePath'
|
||||
}
|
||||
})
|
||||
|
||||
printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, false)
|
||||
|
||||
// Stable post-create output: the retained worktree prints on stdout first.
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain('/tmp/repo/feature')
|
||||
expect(errSpy.mock.calls[0][0]).toBe('missing_variable')
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the failure inside the JSON envelope without a stderr line', () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const created = createdWorktree({
|
||||
status: 'failed',
|
||||
failure: {
|
||||
code: 'spawn_failed',
|
||||
version: 1,
|
||||
failureId: 'f2',
|
||||
intent: 'cli',
|
||||
occurredAt: 0
|
||||
}
|
||||
})
|
||||
|
||||
printWorktreeCreateResult(envelope(created), created, FLAG_SOURCE, true)
|
||||
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain('spawn_failed')
|
||||
expect(errSpy).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,196 @@
|
||||
// CLI worktree-create agent launch: parse --agent/--prompt into the one host-
|
||||
// atomic `agentLaunch` request and consume the typed result union. The CLI is
|
||||
// FAIL-FAST — it never assembles a command and never falls back to a base agent.
|
||||
// A pre-create rejection or a post-create failure prints a stable machine code
|
||||
// plus a client-safe human line to stderr and exits non-zero; a post-create
|
||||
// failure still prints the retained worktree on stdout first (stable output).
|
||||
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchFailureCode,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type {
|
||||
CreatedRuntimeWorktreeCreateResult,
|
||||
RuntimeWorktreeCreateResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { WorktreeAgentLaunchRejection } from '../../shared/types'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { RuntimeClientError, type RuntimeRpcSuccess } from '../runtime-client'
|
||||
import { formatWorktreeShow, printResult } from '../format'
|
||||
|
||||
type Flags = Map<string, string | boolean>
|
||||
|
||||
/** How the agent identity was chosen, named in the fail-fast stderr line. */
|
||||
export type AgentLaunchSource = { via: 'flag'; id: string } | { via: 'default' }
|
||||
|
||||
export type WorktreeCreateAgentLaunch = {
|
||||
request: AgentLaunchSpawnRequest
|
||||
source: AgentLaunchSource
|
||||
}
|
||||
|
||||
function getPromptText(flags: Flags): string | undefined {
|
||||
if (!flags.has('prompt')) {
|
||||
return undefined
|
||||
}
|
||||
// An explicit --prompt may be empty, but a valueless --prompt is an error.
|
||||
const value = flags.get('prompt')
|
||||
if (typeof value !== 'string') {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing value for --prompt')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function buildRequest(
|
||||
selection: AgentLaunchSpawnRequest['selection'],
|
||||
prompt: string | undefined
|
||||
): AgentLaunchSpawnRequest {
|
||||
return {
|
||||
selection,
|
||||
// The CLI always launches the requested agent, prompt or not.
|
||||
allowEmptyPromptLaunch: true,
|
||||
...(prompt !== undefined ? { prompt } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the host-atomic agentLaunch request from --agent/--prompt. A bare
|
||||
* --agent (no value) selects the stored default; --agent <id> names an agent.
|
||||
* The host resolves identity and fails fast — the CLI sends no command/env. */
|
||||
export function getWorktreeCreateAgentLaunch(flags: Flags): WorktreeCreateAgentLaunch | undefined {
|
||||
if (!flags.has('agent')) {
|
||||
if (flags.has('prompt')) {
|
||||
throw new RuntimeClientError('invalid_argument', '--prompt requires --agent')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
const prompt = getPromptText(flags)
|
||||
const value = flags.get('agent')
|
||||
if (value === true) {
|
||||
return { request: buildRequest({ kind: 'default' }, prompt), source: { via: 'default' } }
|
||||
}
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
if (!isTuiAgent(value)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${value}"`)
|
||||
}
|
||||
return {
|
||||
request: buildRequest({ kind: 'agent', agent: value }, prompt),
|
||||
source: { via: 'flag', id: value }
|
||||
}
|
||||
}
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing value for --agent')
|
||||
}
|
||||
|
||||
// Client-safe reasons: never reference argv, env keys/values, paths, or labels.
|
||||
const FAILURE_REASONS: Record<AgentLaunchFailureCode, string> = {
|
||||
unknown_agent: 'the agent no longer exists',
|
||||
no_agent_selected: 'no agent is selected — set a default or pass --agent <id>',
|
||||
agent_definition_needs_repair: 'the agent is not fully configured (finish it in Settings)',
|
||||
custom_agent_disabled: 'the agent is turned off (enable it in Settings)',
|
||||
agent_configuration_changed: 'the agent configuration changed (review it in Settings)',
|
||||
base_agent_disabled: 'the underlying agent is turned off (enable it in Settings)',
|
||||
base_agent_unavailable: 'the underlying agent is not available on this host',
|
||||
missing_variable: 'the workspace path could not be resolved for this launch',
|
||||
missing_target_home: 'the home directory on the target host could not be resolved',
|
||||
invalid_command_override: 'the command override is invalid (fix it in Settings)',
|
||||
invalid_agent_args: 'the launch arguments are invalid (fix them in Settings)',
|
||||
invalid_agent_env: 'the launch environment is invalid (fix it in Settings)',
|
||||
secure_env_transport_unavailable: 'the environment cannot be sent securely to the remote host',
|
||||
launch_command_too_long: 'the launch command is too long to run',
|
||||
invalid_launch_snapshot: 'the saved launch details are no longer valid',
|
||||
trust_preflight_failed: 'workspace trust could not be confirmed',
|
||||
spawn_failed: 'the agent could not be started',
|
||||
launch_state_unknown: 'the launch status is unknown',
|
||||
launch_capacity_exceeded: 'too many agent launches are in progress'
|
||||
}
|
||||
|
||||
const REQUEST_ERROR_REASONS: Record<AgentLaunchRequestError['code'], string> = {
|
||||
idempotency_conflict: 'this launch is already in progress',
|
||||
stale_agent_launch_failure: 'this launch was already resolved',
|
||||
untrusted_reference: 'the launch source could not be verified'
|
||||
}
|
||||
|
||||
function describeSource(source: AgentLaunchSource, requestedAgent: string | undefined): string {
|
||||
if (source.via === 'flag') {
|
||||
return `agent "${source.id}" requested via --agent`
|
||||
}
|
||||
return requestedAgent
|
||||
? `the stored default agent "${requestedAgent}"`
|
||||
: 'the stored default agent'
|
||||
}
|
||||
|
||||
function failureHumanLine(failure: AgentLaunchFailure, source: AgentLaunchSource): string {
|
||||
return `Could not launch ${describeSource(source, failure.requestedAgent)}: ${FAILURE_REASONS[failure.code]}.`
|
||||
}
|
||||
|
||||
function rejectionParts(
|
||||
rejection: WorktreeAgentLaunchRejection,
|
||||
source: AgentLaunchSource
|
||||
): { code: string; human: string } {
|
||||
if (rejection.status === 'failed') {
|
||||
return { code: rejection.failure.code, human: failureHumanLine(rejection.failure, source) }
|
||||
}
|
||||
const requested = source.via === 'flag' ? source.id : 'the stored default agent'
|
||||
return {
|
||||
code: rejection.requestError.code,
|
||||
human: `Could not launch ${
|
||||
source.via === 'flag' ? `agent "${requested}"` : requested
|
||||
}: ${REQUEST_ERROR_REASONS[rejection.requestError.code]}.`
|
||||
}
|
||||
}
|
||||
|
||||
function printAgentLaunchStderr(code: string, human: string): void {
|
||||
// Plan contract: stable machine-readable code on line 1, human line on line 2.
|
||||
console.error(code)
|
||||
console.error(human)
|
||||
}
|
||||
|
||||
/** Handle a pre-create rejection (`created: false`). Prints the typed rejection
|
||||
* (JSON envelope) or the stderr contract (human), sets a non-zero exit, and
|
||||
* returns null. Otherwise returns the created arm for normal printing. */
|
||||
export function handleWorktreeCreatePreRejection(
|
||||
response: RuntimeRpcSuccess<RuntimeWorktreeCreateResult>,
|
||||
source: AgentLaunchSource | undefined,
|
||||
json: boolean
|
||||
): CreatedRuntimeWorktreeCreateResult | null {
|
||||
const result = response.result
|
||||
if (result.created !== false) {
|
||||
return result
|
||||
}
|
||||
if (json) {
|
||||
printResult(response, true, () => '')
|
||||
} else if (source) {
|
||||
const { code, human } = rejectionParts(result.agentLaunchResult, source)
|
||||
printAgentLaunchStderr(code, human)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return null
|
||||
}
|
||||
|
||||
/** Print the created worktree (stable output on stdout) and, when the post-create
|
||||
* launch failed, emit the fail-fast stderr contract and set a non-zero exit. The
|
||||
* workspace is retained either way. */
|
||||
export function printWorktreeCreateResult(
|
||||
response: RuntimeRpcSuccess<RuntimeWorktreeCreateResult>,
|
||||
created: CreatedRuntimeWorktreeCreateResult,
|
||||
source: AgentLaunchSource | undefined,
|
||||
json: boolean
|
||||
): void {
|
||||
const createdResponse: RuntimeRpcSuccess<CreatedRuntimeWorktreeCreateResult> = {
|
||||
...response,
|
||||
result: created
|
||||
}
|
||||
printResult(createdResponse, json, formatWorktreeShow)
|
||||
if (created.agentLaunchResult?.status !== 'failed') {
|
||||
return
|
||||
}
|
||||
// JSON already carries the failure in the printed envelope; only the human
|
||||
// surface needs the stderr contract. Either way the exit is non-zero.
|
||||
if (!json && source) {
|
||||
printAgentLaunchStderr(
|
||||
created.agentLaunchResult.failure.code,
|
||||
failureHumanLine(created.agentLaunchResult.failure, source)
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RuntimeWorktreeCreateResult } from '../../shared/runtime-types'
|
||||
import type { CreatedRuntimeWorktreeCreateResult } from '../../shared/runtime-types'
|
||||
|
||||
function getLineageSourceLabel(source: string): string {
|
||||
switch (source) {
|
||||
@@ -19,7 +19,10 @@ function getLineageSourceLabel(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
|
||||
export function printLineageSummary(
|
||||
result: CreatedRuntimeWorktreeCreateResult,
|
||||
json: boolean
|
||||
): void {
|
||||
if (json) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type {
|
||||
RuntimeWorktreeCreateResult,
|
||||
RuntimeWorktreeListResult,
|
||||
RuntimeWorktreePsResult,
|
||||
RuntimeWorktreeRecord,
|
||||
RuntimeWorktreeCreateResult,
|
||||
RuntimeWorktreeRemoveResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
getRequiredWorktreeSelector,
|
||||
resolveCurrentWorktreeSelector
|
||||
} from '../selectors'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
|
||||
import { printLineageSummary } from './worktree-lineage-summary'
|
||||
import {
|
||||
@@ -33,6 +32,11 @@ import {
|
||||
resolveCreateParentSelector
|
||||
} from './worktree-create-parent-selector'
|
||||
import { getOptionalLinearIssueLinkFlag } from './worktree-linear-issue-link'
|
||||
import {
|
||||
getWorktreeCreateAgentLaunch,
|
||||
handleWorktreeCreatePreRejection,
|
||||
printWorktreeCreateResult
|
||||
} from './worktree-create-agent-launch'
|
||||
|
||||
type HookWarningResult = {
|
||||
warning?: string
|
||||
@@ -101,20 +105,6 @@ function getPresentStringFlag(
|
||||
throw new RuntimeClientError('invalid_argument', `Missing value for --${name}`)
|
||||
}
|
||||
|
||||
function getOptionalStartupAgent(flags: Map<string, string | boolean>): string | undefined {
|
||||
const agent = getPresentStringFlag(flags, 'agent')
|
||||
if (agent === undefined) {
|
||||
if (flags.has('prompt')) {
|
||||
throw new RuntimeClientError('invalid_argument', '--prompt requires --agent')
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (!isTuiAgent(agent)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Unknown TUI agent "${agent}"`)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
function getOptionalSetupDecision(
|
||||
flags: Map<string, string | boolean>
|
||||
): 'run' | 'skip' | 'inherit' | undefined {
|
||||
@@ -206,7 +196,7 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
const explicitParent = await resolveCreateParentSelector(flags, cwd, client)
|
||||
const explicitParentWorktree = explicitParent.parentWorktree
|
||||
const explicitParentWorkspace = explicitParent.parentWorkspace
|
||||
const startupAgent = getOptionalStartupAgent(flags)
|
||||
const agentLaunch = getWorktreeCreateAgentLaunch(flags)
|
||||
const setupDecision = getOptionalSetupDecision(flags)
|
||||
const noParent = flags.get('no-parent') === true
|
||||
const envParentWorkspace =
|
||||
@@ -229,13 +219,13 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
}
|
||||
const linearIssueLink = getOptionalLinearIssueLinkFlag(flags, 'linear-issue')
|
||||
const activate = flags.get('activate') === true || flags.get('run-hooks') === true
|
||||
const name = getRequiredStringFlag(flags, 'name')
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
const activate =
|
||||
flags.get('activate') === true || flags.get('run-hooks') === true || Boolean(agentLaunch)
|
||||
// The host resolves the agentLaunch identity and fails fast on the `cli`
|
||||
// column; the CLI consumes the created / pre-create-rejection result union.
|
||||
const response = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
repo: await getCreateRepoSelector(flags, cwdParentWorktree, client),
|
||||
name,
|
||||
displayName: name,
|
||||
displayNameKind: 'user',
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
...linearIssueLink,
|
||||
@@ -255,16 +245,15 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: marks the workspace as CLI-created so the sidebar can badge and
|
||||
// filter it. Sent on every `worktree create` — hand-typed or agent-run.
|
||||
cliProvenanceRequest: callerTerminalHandle ? { callerTerminalHandle } : {},
|
||||
...(startupAgent
|
||||
? {
|
||||
startupAgent,
|
||||
startupPrompt: getPresentStringFlag(flags, 'prompt', { allowEmpty: true }) ?? ''
|
||||
}
|
||||
: {})
|
||||
...(agentLaunch ? { agentLaunch: agentLaunch.request } : {})
|
||||
})
|
||||
printHookWarning(result.result, json)
|
||||
printLineageSummary(result.result, json)
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
const created = handleWorktreeCreatePreRejection(response, agentLaunch?.source, json)
|
||||
if (!created) {
|
||||
return
|
||||
}
|
||||
printHookWarning(created, json)
|
||||
printLineageSummary(created, json)
|
||||
printWorktreeCreateResult(response, created, agentLaunch?.source, json)
|
||||
},
|
||||
'worktree set': async ({ flags, client, cwd, json }) => {
|
||||
assertParentWorktreeFlagsCompatible(flags)
|
||||
|
||||
@@ -86,7 +86,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'create'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent <id>] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
'orca worktree create --name <name> [--repo <selector>|--project <id> [--host <host-id>]|--project-host-setup <id>] [--agent [<id>]] [--prompt <text>] [--setup run|skip|inherit] [--base-branch <ref>] [--issue <number>] [--linear-issue <identifier-or-url>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'repo',
|
||||
@@ -116,7 +116,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Use --no-parent when the new worktree should be independent of the current context.',
|
||||
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.',
|
||||
'By default this creates the worktree and its first terminal without switching the active Orca view.',
|
||||
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
|
||||
'Pass --agent <id> to launch that agent in the first terminal, or a bare --agent to launch your default agent; --prompt sends initial work to the agent.',
|
||||
'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.',
|
||||
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
|
||||
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
|
||||
|
||||
@@ -192,8 +192,19 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'dispatch-show'],
|
||||
summary: 'Show dispatch context for a task',
|
||||
usage:
|
||||
'orca orchestration dispatch-show --task <task_id> [--preamble] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from']
|
||||
'orca orchestration dispatch-show --task <task_id> [--preamble] [--raw] [--from <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'raw', 'from'],
|
||||
notes: ['--raw shows the un-projected status (e.g. forgotten) and its launch failure.']
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'dispatch-forget'],
|
||||
summary: 'Forget a dispatch stranded in an unknown launch state',
|
||||
usage:
|
||||
'orca orchestration dispatch-forget --task <task_id> [--expected-failure-id <id>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'task', 'expected-failure-id'],
|
||||
notes: [
|
||||
'The task returns to blocked; retry with: orca orchestration task-update --id <task_id> --status ready.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['orchestration', 'ask'],
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// Built-in agent override mutation: persists per-agent command/args/env overrides
|
||||
// for a shipped built-in. Built-in prefixes keep multi-token wrapper
|
||||
// compatibility, so only control characters and hard bounds are save-rejected.
|
||||
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot'
|
||||
import { validateCustomAgentEnv } from '../../shared/custom-tui-agents'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { fieldError, type AgentCatalogMutationApplication } from './agent-catalog-draft-validation'
|
||||
|
||||
type UpdateBuiltInMutation = Extract<
|
||||
AgentCatalogMutationRequest['mutation'],
|
||||
{ kind: 'update-built-in' }
|
||||
>
|
||||
|
||||
export function applyUpdateBuiltIn(
|
||||
mutation: UpdateBuiltInMutation,
|
||||
settings: GlobalSettings,
|
||||
newRevision: number
|
||||
): AgentCatalogMutationApplication {
|
||||
if (!isBuiltInTuiAgent(mutation.agent)) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
// Built-in prefixes keep multi-token wrapper compatibility, so only
|
||||
// control characters and bounds are save-rejected here; operator tokens
|
||||
// fail at launch with a repairable error instead of being reinterpreted.
|
||||
const override = mutation.changes.commandOverride
|
||||
if (override !== null && override !== undefined) {
|
||||
if (override.length > 4096) {
|
||||
return { ok: false, code: 'invalid_agent_field', field: 'commandOverride', reason: 'bounds' }
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
|
||||
if (/[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(override)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: 'commandOverride',
|
||||
reason: 'control_char'
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof mutation.changes.args === 'string' && mutation.changes.args.length > 8192) {
|
||||
return { ok: false, code: 'invalid_agent_field', field: 'args', reason: 'bounds' }
|
||||
}
|
||||
const envIssues = validateCustomAgentEnv(mutation.changes.env)
|
||||
// Built-in env keeps the shipped permissive shape except hard safety
|
||||
// bounds; reserved/prototype checks still apply to new writes.
|
||||
const blocking = envIssues.find(
|
||||
(issue) =>
|
||||
issue.reason === 'prototype_key' ||
|
||||
issue.reason === 'control_char' ||
|
||||
issue.reason === 'env_total_bounds' ||
|
||||
issue.reason === 'bounds'
|
||||
)
|
||||
if (blocking) {
|
||||
return fieldError(blocking)
|
||||
}
|
||||
const agent = mutation.agent
|
||||
const nextCmdOverrides = { ...settings.agentCmdOverrides }
|
||||
if (override === null || override === undefined || override.trim().length === 0) {
|
||||
delete nextCmdOverrides[agent]
|
||||
} else {
|
||||
nextCmdOverrides[agent] = override
|
||||
}
|
||||
const nextArgs = { ...settings.agentDefaultArgs }
|
||||
if (mutation.changes.args.trim().length === 0) {
|
||||
delete nextArgs[agent]
|
||||
} else {
|
||||
nextArgs[agent] = mutation.changes.args
|
||||
}
|
||||
const nextEnv = { ...settings.agentDefaultEnv }
|
||||
if (Object.keys(mutation.changes.env).length === 0) {
|
||||
delete nextEnv[agent]
|
||||
} else {
|
||||
nextEnv[agent] = { ...mutation.changes.env }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
patch: {
|
||||
agentCmdOverrides: nextCmdOverrides,
|
||||
agentDefaultArgs: nextArgs,
|
||||
agentDefaultEnv: nextEnv,
|
||||
agentCatalogRevision: newRevision
|
||||
},
|
||||
newRevision,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// Draft validation and definition building for agent-catalog mutations: field
|
||||
// checks, label-collision rules, tombstone pruning, and per-agent cache cleanup.
|
||||
// Pure helpers shared by the mutation engine and repair mutations.
|
||||
|
||||
import type {
|
||||
BuiltInTuiAgent,
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
DeletedCustomTuiAgent,
|
||||
GlobalSettings,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot'
|
||||
import {
|
||||
canonicalizeCommandOverride,
|
||||
isBuiltInAgentLabelKey,
|
||||
normalizeAgentLabelKey,
|
||||
normalizeAgentLabelText,
|
||||
validateAgentArgs,
|
||||
validateAgentLabel,
|
||||
validateCommandOverride,
|
||||
validateCustomAgentEnv,
|
||||
type AgentCatalog,
|
||||
type AgentFieldIssue
|
||||
} from '../../shared/custom-tui-agents'
|
||||
import { canonicalizeAgentArgsLineEndings } from '../../shared/agent-args-tokenizer'
|
||||
|
||||
export type AgentCatalogMutationError = {
|
||||
ok: false
|
||||
code:
|
||||
| 'catalog_revision_conflict'
|
||||
| 'duplicate_agent_label'
|
||||
| 'invalid_agent_field'
|
||||
| 'stale_agent_repair_token'
|
||||
| 'agent_catalog_local_payload_too_large'
|
||||
| 'agent_catalog_payload_too_large'
|
||||
field?: 'label' | 'commandOverride' | 'args' | 'env'
|
||||
reason?:
|
||||
| 'empty'
|
||||
| 'bounds'
|
||||
| 'reserved_name'
|
||||
| 'prototype_key'
|
||||
| 'case_collision'
|
||||
| 'control_char'
|
||||
| 'unterminated_quote'
|
||||
| 'quoted_line_break'
|
||||
| 'shell_operator'
|
||||
| 'platform_ambiguous'
|
||||
| 'duplicate_id'
|
||||
| 'identity_mismatch'
|
||||
| 'env_total_bounds'
|
||||
envEntryIndex?: number
|
||||
}
|
||||
|
||||
export type AgentCatalogMutationApplication =
|
||||
| {
|
||||
ok: true
|
||||
/** Applied in one store write; includes the bumped catalog revision. */
|
||||
patch: Partial<GlobalSettings>
|
||||
newRevision: number
|
||||
mintedId?: CustomTuiAgentId
|
||||
prunedTombstoneIds: CustomTuiAgentId[]
|
||||
}
|
||||
| AgentCatalogMutationError
|
||||
|
||||
export type TombstoneReferenceCount = number | 'unknown'
|
||||
|
||||
export function fieldError(issue: AgentFieldIssue): AgentCatalogMutationError {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: issue.field === 'identity' || issue.field === 'baseAgent' ? undefined : issue.field,
|
||||
reason: issue.reason,
|
||||
...(issue.envEntryIndex !== undefined ? { envEntryIndex: issue.envEntryIndex } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function validateDraft(draft: CustomAgentDraft): AgentCatalogMutationError | null {
|
||||
const labelIssue = validateAgentLabel(draft.label)
|
||||
if (labelIssue) {
|
||||
return fieldError(labelIssue)
|
||||
}
|
||||
if (draft.commandOverride !== null && draft.commandOverride !== undefined) {
|
||||
const commandIssue = validateCommandOverride(draft.commandOverride)
|
||||
if (commandIssue) {
|
||||
return fieldError(commandIssue)
|
||||
}
|
||||
}
|
||||
const argsIssue = validateAgentArgs(draft.args)
|
||||
if (argsIssue) {
|
||||
return fieldError(argsIssue)
|
||||
}
|
||||
const envIssues = validateCustomAgentEnv(draft.env)
|
||||
if (envIssues.length > 0) {
|
||||
return fieldError(envIssues[0])
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function draftToDefinition(
|
||||
id: CustomTuiAgentId,
|
||||
baseAgent: BuiltInTuiAgent,
|
||||
draft: CustomAgentDraft
|
||||
): CustomTuiAgent {
|
||||
const env: Record<string, string> = Object.create(null) as Record<string, string>
|
||||
for (const [key, value] of Object.entries(draft.env)) {
|
||||
env[key] = value
|
||||
}
|
||||
const commandOverride =
|
||||
draft.commandOverride === null || draft.commandOverride === undefined
|
||||
? undefined
|
||||
: canonicalizeCommandOverride(draft.commandOverride)
|
||||
return {
|
||||
id,
|
||||
baseAgent,
|
||||
label: normalizeAgentLabelText(draft.label),
|
||||
...(commandOverride ? { commandOverride } : {}),
|
||||
args: canonicalizeAgentArgsLineEndings(draft.args),
|
||||
env,
|
||||
syncEnv: draft.syncEnv === true
|
||||
}
|
||||
}
|
||||
|
||||
/** Labels reserved against the new/edited label: built-in canonical names, live
|
||||
* custom labels (excluding the row being edited), and referenced tombstones. */
|
||||
export function labelCollides(
|
||||
candidateKey: string,
|
||||
catalog: AgentCatalog,
|
||||
retainedTombstones: readonly DeletedCustomTuiAgent[],
|
||||
excludeId?: CustomTuiAgentId
|
||||
): boolean {
|
||||
if (isBuiltInAgentLabelKey(candidateKey)) {
|
||||
return true
|
||||
}
|
||||
for (const agent of catalog.liveCustomAgents) {
|
||||
if (agent.id !== excludeId && normalizeAgentLabelKey(agent.label) === candidateKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (const tombstone of retainedTombstones) {
|
||||
if (normalizeAgentLabelKey(tombstone.label) === candidateKey) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Conservative unreferenced-tombstone prune: authoritative zero references
|
||||
* frees the tombstone (and its label); 'unknown' retains. */
|
||||
export function pruneTombstones(
|
||||
tombstones: readonly DeletedCustomTuiAgent[],
|
||||
countReferences: (id: CustomTuiAgentId) => TombstoneReferenceCount
|
||||
): { retained: DeletedCustomTuiAgent[]; prunedIds: CustomTuiAgentId[] } {
|
||||
const retained: DeletedCustomTuiAgent[] = []
|
||||
const prunedIds: CustomTuiAgentId[] = []
|
||||
for (const tombstone of tombstones) {
|
||||
const count = countReferences(tombstone.id)
|
||||
if (count === 0) {
|
||||
prunedIds.push(tombstone.id)
|
||||
} else {
|
||||
retained.push(tombstone)
|
||||
}
|
||||
}
|
||||
return { retained, prunedIds }
|
||||
}
|
||||
|
||||
type AgentKeyedCacheHolder = {
|
||||
selectedModelByAgent?: Partial<Record<TuiAgent, string>>
|
||||
selectedModelByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, string>>>>
|
||||
discoveredModelsByAgent?: Partial<Record<TuiAgent, unknown>>
|
||||
discoveredModelsByAgentByHost?: Partial<Record<string, Partial<Record<TuiAgent, unknown>>>>
|
||||
}
|
||||
|
||||
function stripAgentKeysFromHolder(holder: AgentKeyedCacheHolder, id: CustomTuiAgentId): boolean {
|
||||
let changed = false
|
||||
for (const flat of [holder.selectedModelByAgent, holder.discoveredModelsByAgent]) {
|
||||
if (flat && id in flat) {
|
||||
delete flat[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
for (const byHost of [holder.selectedModelByAgentByHost, holder.discoveredModelsByAgentByHost]) {
|
||||
if (!byHost) {
|
||||
continue
|
||||
}
|
||||
for (const host of Object.keys(byHost)) {
|
||||
const byAgent = byHost[host]
|
||||
if (byAgent && id in byAgent) {
|
||||
delete byAgent[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// Ids are never reused, so per-agent model/discovery caches keyed by the deleted
|
||||
// id are removed in the same settings write instead of lingering forever.
|
||||
export function stripAgentKeyedModelCaches(
|
||||
settings: GlobalSettings,
|
||||
id: CustomTuiAgentId
|
||||
): Partial<GlobalSettings> {
|
||||
const patch: Partial<GlobalSettings> = {}
|
||||
if (settings.sourceControlAi) {
|
||||
const next = structuredClone(settings.sourceControlAi)
|
||||
let changed = stripAgentKeysFromHolder(next, id)
|
||||
for (const choice of Object.values(next.modelOverridesByOperation ?? {})) {
|
||||
if (choice && stripAgentKeysFromHolder(choice, id)) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
patch.sourceControlAi = next
|
||||
}
|
||||
}
|
||||
if (settings.commitMessageAi) {
|
||||
const next = structuredClone(settings.commitMessageAi)
|
||||
if (stripAgentKeysFromHolder(next, id)) {
|
||||
patch.commitMessageAi = next
|
||||
}
|
||||
}
|
||||
return patch
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// End-to-end forward-rollback fixture on a DISPOSABLE profile (plan §1021-1028,
|
||||
// oracle 39). The unit migration tests own field mapping; this fixture proves the
|
||||
// operational contract: migrate v0→v1, exercise a v1 reference through the real
|
||||
// resolver, confirm a forward-rollback build still resolves saved identities, and
|
||||
// restore the pinned pre-v1 backup as the only supported downgrade. Never touches
|
||||
// user data — every path is under a fresh mkdtemp dir removed in afterEach.
|
||||
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { CustomTuiAgentId, GlobalSettings } from '../../shared/types'
|
||||
import {
|
||||
AGENT_CATALOG_SCHEMA_VERSION,
|
||||
createPinnedPreV1Backup,
|
||||
migrateAgentCatalogSchema,
|
||||
pinnedPreV1BackupPath
|
||||
} from './agent-catalog-schema-migration'
|
||||
import { resolveAgentLaunch } from './resolve-agent-launch'
|
||||
import {
|
||||
catalogOf,
|
||||
customAgent,
|
||||
customId,
|
||||
requestOf,
|
||||
settingsOf
|
||||
} from './agent-launch-test-catalog'
|
||||
|
||||
let dir: string
|
||||
let dataFile: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-forward-rollback-'))
|
||||
dataFile = join(dir, 'orca-settings.json')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** A v0 profile: no schema version, shipped legacy `defaultTuiAgent: null` (Auto),
|
||||
* and one saved custom identity that must keep resolving across the migration. */
|
||||
function writeV0Profile(): {
|
||||
v0Raw: string
|
||||
v0Settings: Partial<GlobalSettings>
|
||||
customId: CustomTuiAgentId
|
||||
} {
|
||||
const custom = customAgent({
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'Prod Codex',
|
||||
args: '--model o3'
|
||||
})
|
||||
const v0Settings: Partial<GlobalSettings> = {
|
||||
customTuiAgents: [custom],
|
||||
deletedCustomTuiAgents: [],
|
||||
defaultTuiAgent: null
|
||||
}
|
||||
const v0Raw = JSON.stringify(v0Settings, null, 2)
|
||||
writeFileSync(dataFile, v0Raw, { mode: 0o600 })
|
||||
return { v0Raw, v0Settings, customId: custom.id }
|
||||
}
|
||||
|
||||
function savedIdentityResolves(settings: Partial<GlobalSettings>, id: CustomTuiAgentId): boolean {
|
||||
const catalog = catalogOf({ customTuiAgents: settings.customTuiAgents ?? [] })
|
||||
return resolveAgentLaunch(
|
||||
requestOf({ selection: { kind: 'agent', agent: id } }),
|
||||
catalog,
|
||||
settingsOf()
|
||||
).ok
|
||||
}
|
||||
|
||||
describe('agent catalog forward-rollback fixture (disposable profile)', () => {
|
||||
it('migrates v0→v1, pins a same-permission backup, and resolves a v1 reference', () => {
|
||||
const { v0Raw, v0Settings, customId: savedId } = writeV0Profile()
|
||||
const before = statSync(dataFile).mode & 0o777
|
||||
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: v0Settings,
|
||||
preV1RawContents: v0Raw,
|
||||
createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw)
|
||||
})
|
||||
|
||||
expect(outcome.didMigrate).toBe(true)
|
||||
expect(outcome.backupError).toBeUndefined()
|
||||
expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(AGENT_CATALOG_SCHEMA_VERSION)
|
||||
expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto')
|
||||
|
||||
// The pinned backup is a byte-exact, same-permission copy of the pre-v1 file.
|
||||
const backupFile = pinnedPreV1BackupPath(dataFile)
|
||||
expect(existsSync(backupFile)).toBe(true)
|
||||
expect(readFileSync(backupFile, 'utf8')).toBe(v0Raw)
|
||||
expect(statSync(backupFile).mode & 0o777).toBe(before)
|
||||
|
||||
// Exercise a v1 reference: the migrated custom identity resolves.
|
||||
const migrated: Partial<GlobalSettings> = { ...v0Settings, ...outcome.settingsPatch }
|
||||
expect(savedIdentityResolves(migrated, savedId)).toBe(true)
|
||||
})
|
||||
|
||||
it('a forward-rollback build keeps resolving already-saved identities (v1 stays a no-op)', () => {
|
||||
const { v0Raw, v0Settings, customId: savedId } = writeV0Profile()
|
||||
const first = migrateAgentCatalogSchema({
|
||||
settings: v0Settings,
|
||||
preV1RawContents: v0Raw,
|
||||
createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw)
|
||||
})
|
||||
const migrated: Partial<GlobalSettings> = { ...v0Settings, ...first.settingsPatch }
|
||||
const v1Raw = JSON.stringify(migrated, null, 2)
|
||||
writeFileSync(dataFile, v1Raw, { mode: 0o600 })
|
||||
|
||||
// A forward-rollback build re-loads the v1 file: migration is a no-op (already
|
||||
// stamped), and it never disables identity resolution for saved defaults/agents.
|
||||
const v1Settings = JSON.parse(readFileSync(dataFile, 'utf8')) as Partial<GlobalSettings>
|
||||
const reload = migrateAgentCatalogSchema({
|
||||
settings: v1Settings,
|
||||
preV1RawContents: v1Raw,
|
||||
createBackup: () => createPinnedPreV1Backup(dataFile, v1Raw)
|
||||
})
|
||||
expect(reload.didMigrate).toBe(false)
|
||||
expect(savedIdentityResolves(v1Settings, savedId)).toBe(true)
|
||||
})
|
||||
|
||||
it('restores the pinned pre-v1 backup as the only supported downgrade (discards v1 metadata)', () => {
|
||||
const { v0Raw, v0Settings } = writeV0Profile()
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: v0Settings,
|
||||
preV1RawContents: v0Raw,
|
||||
createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw)
|
||||
})
|
||||
// Simulate the v1 write plus later v1-only metadata beyond the backup point.
|
||||
const migrated: Partial<GlobalSettings> = {
|
||||
...v0Settings,
|
||||
...outcome.settingsPatch,
|
||||
agentReferenceRevision: 7
|
||||
}
|
||||
writeFileSync(dataFile, JSON.stringify(migrated, null, 2), { mode: 0o600 })
|
||||
|
||||
// Explicit user downgrade = restore the pinned backup over the data file.
|
||||
const backupRaw = readFileSync(pinnedPreV1BackupPath(dataFile), 'utf8')
|
||||
writeFileSync(dataFile, backupRaw, { mode: 0o600 })
|
||||
|
||||
// Byte-identical pre-v1 state; the post-backup v1 metadata is intentionally gone.
|
||||
expect(readFileSync(dataFile, 'utf8')).toBe(v0Raw)
|
||||
const restored = JSON.parse(backupRaw) as Partial<GlobalSettings>
|
||||
expect(restored.agentCatalogSchemaVersion).toBeUndefined()
|
||||
expect(restored.agentReferenceRevision).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a crash after backup but before the v1 write leaves a complete, restorable v0 file (never half-migrated)', () => {
|
||||
const { v0Raw, v0Settings } = writeV0Profile()
|
||||
// The backup is created BEFORE any v1 write, so a crash mid-migration finds the
|
||||
// complete old file plus a usable backup — the oracle-39 boundary guarantee.
|
||||
const backup = createPinnedPreV1Backup(dataFile, v0Raw)
|
||||
expect(backup).toEqual({ ok: true, created: true })
|
||||
|
||||
// Crash: no v1 patch is written. On-disk data file is still the complete v0.
|
||||
expect(readFileSync(dataFile, 'utf8')).toBe(v0Raw)
|
||||
const parsed = JSON.parse(readFileSync(dataFile, 'utf8')) as Partial<GlobalSettings>
|
||||
expect(parsed.agentCatalogSchemaVersion).toBeUndefined()
|
||||
|
||||
// And the backup independently restores a complete v0 file.
|
||||
expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf8')).toBe(v0Raw)
|
||||
|
||||
// A restart re-runs the migration cleanly from the intact v0 state.
|
||||
const retry = migrateAgentCatalogSchema({
|
||||
settings: v0Settings,
|
||||
preV1RawContents: v0Raw,
|
||||
createBackup: () => createPinnedPreV1Backup(dataFile, v0Raw)
|
||||
})
|
||||
expect(retry.didMigrate).toBe(true)
|
||||
expect(retry.backupError).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
// Lifecycle mutations for custom agents: create, duplicate, update-custom,
|
||||
// delete, set-enabled, and set-default. Each returns one atomic settings patch
|
||||
// and performs no write on failure.
|
||||
|
||||
import type {
|
||||
BuiltInTuiAgent,
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
DeletedCustomTuiAgent,
|
||||
GlobalSettings,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot'
|
||||
import {
|
||||
isCustomTuiAgentId,
|
||||
mintCustomTuiAgentId,
|
||||
normalizeAgentLabelKey,
|
||||
type AgentCatalog
|
||||
} from '../../shared/custom-tui-agents'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
draftToDefinition,
|
||||
labelCollides,
|
||||
pruneTombstones,
|
||||
stripAgentKeyedModelCaches,
|
||||
validateDraft,
|
||||
type AgentCatalogMutationApplication
|
||||
} from './agent-catalog-draft-validation'
|
||||
import {
|
||||
isLegacyAgentPrefixPlatformAmbiguous,
|
||||
tokenizeLegacyAgentPrefix
|
||||
} from '../../shared/legacy-agent-prefix-tokenizer'
|
||||
import type { ApplyAgentCatalogMutationArgs, MutationContext } from './agent-catalog-mutations'
|
||||
|
||||
export function applyCreate(
|
||||
baseAgent: BuiltInTuiAgent,
|
||||
draft: CustomAgentDraft,
|
||||
context: MutationContext
|
||||
): AgentCatalogMutationApplication {
|
||||
if (!isBuiltInTuiAgent(baseAgent)) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const draftError = validateDraft(draft)
|
||||
if (draftError) {
|
||||
return draftError
|
||||
}
|
||||
// Prune before label validation so a freed tombstone label can be reused.
|
||||
const { retained, prunedIds } = pruneTombstones(
|
||||
context.persistedTombstones,
|
||||
context.args.countTombstoneReferences
|
||||
)
|
||||
const candidateKey = normalizeAgentLabelKey(draft.label)
|
||||
if (labelCollides(candidateKey, context.catalog, retained)) {
|
||||
return { ok: false, code: 'duplicate_agent_label', field: 'label' }
|
||||
}
|
||||
const id = mintCustomTuiAgentId(baseAgent)
|
||||
const definition = draftToDefinition(id, baseAgent, draft)
|
||||
return {
|
||||
ok: true,
|
||||
patch: {
|
||||
customTuiAgents: [...context.persistedLive, definition] as CustomTuiAgent[],
|
||||
deletedCustomTuiAgents: retained,
|
||||
agentCatalogRevision: context.newRevision
|
||||
},
|
||||
newRevision: context.newRevision,
|
||||
mintedId: id,
|
||||
prunedTombstoneIds: prunedIds
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDuplicate(
|
||||
sourceAgent: TuiAgent,
|
||||
label: string,
|
||||
context: MutationContext
|
||||
): AgentCatalogMutationApplication {
|
||||
const settings = context.args.settings
|
||||
let baseAgent: BuiltInTuiAgent
|
||||
let draft: CustomAgentDraft
|
||||
if (isBuiltInTuiAgent(sourceAgent)) {
|
||||
baseAgent = sourceAgent
|
||||
const prefix = settings.agentCmdOverrides?.[sourceAgent]
|
||||
let commandOverride: string | null = null
|
||||
let prefixArgs = ''
|
||||
if (typeof prefix === 'string' && prefix.trim().length > 0) {
|
||||
// Main repeats the cross-shell equivalence gate even when the dialog was
|
||||
// bypassed: an ambiguous raw prefix must not be split by guessing one
|
||||
// platform's grammar.
|
||||
if (isLegacyAgentPrefixPlatformAmbiguous(prefix)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: 'commandOverride',
|
||||
reason: 'platform_ambiguous'
|
||||
}
|
||||
}
|
||||
// Ambiguity is excluded, so every grammar agrees — posix serves. A uniform
|
||||
// tokenize failure (operator/control/unterminated) surfaces for repair
|
||||
// instead of being split.
|
||||
const tokenized = tokenizeLegacyAgentPrefix(prefix, 'posix')
|
||||
if (!tokenized.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: 'commandOverride',
|
||||
reason: tokenized.reason
|
||||
}
|
||||
}
|
||||
commandOverride = tokenized.tokens[0] ?? null
|
||||
prefixArgs = tokenized.tokens.slice(1).join(' ')
|
||||
}
|
||||
const userArgs = settings.agentDefaultArgs?.[sourceAgent] ?? ''
|
||||
const combinedArgs = [prefixArgs, userArgs].filter((part) => part.length > 0).join(' ')
|
||||
draft = {
|
||||
label,
|
||||
commandOverride,
|
||||
args: combinedArgs,
|
||||
env: { ...settings.agentDefaultEnv?.[sourceAgent] },
|
||||
syncEnv: false
|
||||
}
|
||||
} else {
|
||||
// Duplicate requires a live source at the expected revision — never a
|
||||
// tombstone (deleted config is unrecoverable by design).
|
||||
const source = context.catalog.liveById.get(sourceAgent as CustomTuiAgentId)
|
||||
if (!source) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
baseAgent = source.baseAgent
|
||||
draft = {
|
||||
label,
|
||||
commandOverride: source.commandOverride ?? null,
|
||||
args: source.args,
|
||||
env: { ...source.env },
|
||||
// Duplicate always resets paired-launch env opt-in to off.
|
||||
syncEnv: false
|
||||
}
|
||||
}
|
||||
// A duplicate of a disabled live custom stays enabled: the new id is not in
|
||||
// disabledTuiAgents and the user re-disables explicitly if wanted.
|
||||
return applyCreate(baseAgent, draft, context)
|
||||
}
|
||||
|
||||
export function applyUpdateCustom(
|
||||
id: CustomTuiAgentId,
|
||||
changes: CustomAgentDraft,
|
||||
context: MutationContext
|
||||
): AgentCatalogMutationApplication {
|
||||
const { args, catalog, persistedLive, persistedTombstones, newRevision } = context
|
||||
const existing = catalog.liveById.get(id)
|
||||
const repairRow = catalog.repairRequiredById.get(id)
|
||||
if (!existing && !repairRow) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const baseAgent = existing?.baseAgent ?? repairRow?.baseAgent
|
||||
if (!baseAgent) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const draftError = validateDraft(changes)
|
||||
if (draftError) {
|
||||
return draftError
|
||||
}
|
||||
const candidateKey = normalizeAgentLabelKey(changes.label)
|
||||
const retained = persistedTombstones.filter(
|
||||
(tombstone) => args.countTombstoneReferences(tombstone.id) !== 0
|
||||
)
|
||||
if (labelCollides(candidateKey, catalog, retained, id)) {
|
||||
return { ok: false, code: 'duplicate_agent_label', field: 'label' }
|
||||
}
|
||||
const nextDefinition = draftToDefinition(id, baseAgent, changes)
|
||||
// Updates preserve the row's physical index (creation-order authority).
|
||||
const nextLive = persistedLive.map((row) => {
|
||||
const rowId = (row as { id?: unknown })?.id
|
||||
return rowId === id ? nextDefinition : row
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
patch: { customTuiAgents: nextLive as CustomTuiAgent[], agentCatalogRevision: newRevision },
|
||||
newRevision,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
|
||||
export function applyDelete(
|
||||
id: CustomTuiAgentId,
|
||||
onDefault: 'keep' | 'base' | 'auto' | 'clear',
|
||||
context: MutationContext
|
||||
): AgentCatalogMutationApplication {
|
||||
const { catalog, args } = context
|
||||
const existing = catalog.liveById.get(id) ?? null
|
||||
const repairRow = catalog.repairRequiredById.get(id) ?? null
|
||||
if (!existing && !repairRow) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const baseAgent = existing?.baseAgent ?? repairRow?.baseAgent
|
||||
const label = existing?.label ?? repairRow?.label ?? ''
|
||||
if (!baseAgent) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
// Tombstone before removing the live entry so a crash between the two can
|
||||
// only over-retain, never resurrect or orphan references.
|
||||
const tombstone: DeletedCustomTuiAgent = {
|
||||
id,
|
||||
baseAgent,
|
||||
label,
|
||||
deletedAt: Date.now()
|
||||
}
|
||||
const nextTombstones = [
|
||||
...context.persistedTombstones.filter((entry) => entry.id !== id),
|
||||
tombstone
|
||||
]
|
||||
const nextLive = context.persistedLive.filter((row) => (row as { id?: unknown })?.id !== id)
|
||||
const nextDisabled = (args.settings.disabledTuiAgents ?? []).filter((entry) => entry !== id)
|
||||
|
||||
const patch: Partial<GlobalSettings> = {
|
||||
customTuiAgents: nextLive as CustomTuiAgent[],
|
||||
deletedCustomTuiAgents: nextTombstones,
|
||||
disabledTuiAgents: nextDisabled,
|
||||
agentCatalogRevision: context.newRevision,
|
||||
...stripAgentKeyedModelCaches(args.settings, id)
|
||||
}
|
||||
|
||||
if (args.settings.defaultTuiAgent === id) {
|
||||
switch (onDefault) {
|
||||
case 'keep':
|
||||
break
|
||||
case 'base':
|
||||
// Rebinding to the base requires the base to be currently enabled;
|
||||
// otherwise fall through to clear so the default never lands disabled.
|
||||
patch.defaultTuiAgent = catalog.disabledAgents.has(baseAgent) ? null : baseAgent
|
||||
break
|
||||
case 'auto':
|
||||
patch.defaultTuiAgent = 'auto'
|
||||
break
|
||||
case 'clear':
|
||||
patch.defaultTuiAgent = null
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
patch,
|
||||
newRevision: context.newRevision,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
|
||||
export function applySetEnabled(
|
||||
agent: TuiAgent,
|
||||
enabled: boolean,
|
||||
context: { args: ApplyAgentCatalogMutationArgs; catalog: AgentCatalog; newRevision: number }
|
||||
): AgentCatalogMutationApplication {
|
||||
const { catalog, args } = context
|
||||
const known =
|
||||
isBuiltInTuiAgent(agent) ||
|
||||
(isCustomTuiAgentId(agent) &&
|
||||
(catalog.liveById.has(agent) || catalog.repairRequiredById.has(agent)))
|
||||
if (!known) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const current = args.settings.disabledTuiAgents ?? []
|
||||
const without = current.filter((entry) => entry !== agent)
|
||||
const nextDisabled = enabled ? without : [...without, agent]
|
||||
const patch: Partial<GlobalSettings> = {
|
||||
disabledTuiAgents: nextDisabled,
|
||||
agentCatalogRevision: context.newRevision
|
||||
}
|
||||
if (!enabled && isBuiltInTuiAgent(agent)) {
|
||||
// Disabling a base repairs a base/derivative default to null in the same
|
||||
// write: no fallback is launchable under a disabled base. Auto stays Auto.
|
||||
const currentDefault = args.settings.defaultTuiAgent
|
||||
if (currentDefault === agent) {
|
||||
patch.defaultTuiAgent = null
|
||||
} else if (isCustomTuiAgentId(currentDefault ?? undefined)) {
|
||||
const identity =
|
||||
catalog.liveById.get(currentDefault as CustomTuiAgentId) ??
|
||||
catalog.tombstonesById.get(currentDefault as CustomTuiAgentId) ??
|
||||
catalog.repairRequiredById.get(currentDefault as CustomTuiAgentId)
|
||||
if (identity && 'baseAgent' in identity && identity.baseAgent === agent) {
|
||||
patch.defaultTuiAgent = null
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, patch, newRevision: context.newRevision, prunedTombstoneIds: [] }
|
||||
}
|
||||
|
||||
export function applySetDefault(
|
||||
target: TuiAgent | 'auto' | 'blank',
|
||||
catalog: AgentCatalog,
|
||||
newRevision: number
|
||||
): AgentCatalogMutationApplication {
|
||||
if (target !== 'auto' && target !== 'blank') {
|
||||
const identity = isBuiltInTuiAgent(target)
|
||||
? target
|
||||
: catalog.liveById.get(target)
|
||||
? target
|
||||
: null
|
||||
if (!identity) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const base = isBuiltInTuiAgent(target)
|
||||
? target
|
||||
: catalog.liveById.get(target as CustomTuiAgentId)?.baseAgent
|
||||
if (
|
||||
catalog.disabledAgents.has(target) ||
|
||||
(base !== undefined && catalog.disabledAgents.has(base))
|
||||
) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
patch: { defaultTuiAgent: target, agentCatalogRevision: newRevision },
|
||||
newRevision,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
DeletedCustomTuiAgent,
|
||||
GlobalSettings
|
||||
} from '../../shared/types'
|
||||
import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot'
|
||||
import {
|
||||
AgentCatalogRepairTokenRegistry,
|
||||
applyAgentCatalogMutation,
|
||||
type ApplyAgentCatalogMutationArgs
|
||||
} from './agent-catalog-mutations'
|
||||
|
||||
const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd'
|
||||
const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321'
|
||||
|
||||
function customId(base: string, uuid = UUID_A): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${uuid}` as CustomTuiAgentId
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<CustomTuiAgent> = {}): CustomTuiAgent {
|
||||
return {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'My Codex',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function draft(overrides: Partial<CustomAgentDraft> = {}): CustomAgentDraft {
|
||||
return {
|
||||
label: 'New Agent',
|
||||
commandOverride: null,
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
defaultTuiAgent: 'auto',
|
||||
disabledTuiAgents: [],
|
||||
customTuiAgents: [],
|
||||
deletedCustomTuiAgents: [],
|
||||
agentCatalogRevision: 5,
|
||||
agentCmdOverrides: {},
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
function apply(
|
||||
overrides: Partial<ApplyAgentCatalogMutationArgs> & {
|
||||
mutation: ApplyAgentCatalogMutationArgs['request']['mutation']
|
||||
expectedRevision?: number
|
||||
}
|
||||
) {
|
||||
const { mutation, expectedRevision, ...rest } = overrides
|
||||
return applyAgentCatalogMutation({
|
||||
settings: settingsWith(),
|
||||
currentRevision: 5,
|
||||
repairTokens: new AgentCatalogRepairTokenRegistry(),
|
||||
countTombstoneReferences: () => 0,
|
||||
...rest,
|
||||
request: { expectedRevision: expectedRevision ?? 5, mutation }
|
||||
})
|
||||
}
|
||||
|
||||
describe('revision gating', () => {
|
||||
it('rejects a stale expectedRevision without writing', () => {
|
||||
const result = apply({
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: draft() },
|
||||
expectedRevision: 4
|
||||
})
|
||||
expect(result).toEqual({ ok: false, code: 'catalog_revision_conflict' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('create', () => {
|
||||
it('mints a canonical id and appends in creation order', () => {
|
||||
const existing = liveAgent({ label: 'Existing' })
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [existing] }),
|
||||
mutation: { kind: 'create', baseAgent: 'claude', draft: draft() }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.newRevision).toBe(6)
|
||||
const live = result.patch.customTuiAgents ?? []
|
||||
expect(live).toHaveLength(2)
|
||||
expect(live[0].label).toBe('Existing')
|
||||
expect(live[1].id).toBe(result.mintedId)
|
||||
expect(live[1].baseAgent).toBe('claude')
|
||||
expect(result.patch.agentCatalogRevision).toBe(6)
|
||||
})
|
||||
|
||||
it('rejects invalid drafts with field/reason metadata', () => {
|
||||
const cases: {
|
||||
draft: CustomAgentDraft
|
||||
field: string
|
||||
reason: string
|
||||
envEntryIndex?: number
|
||||
}[] = [
|
||||
{ draft: draft({ label: '' }), field: 'label', reason: 'empty' },
|
||||
{ draft: draft({ label: 'x'.repeat(81) }), field: 'label', reason: 'bounds' },
|
||||
{
|
||||
draft: draft({ commandOverride: 'codex && evil' }),
|
||||
field: 'commandOverride',
|
||||
reason: 'shell_operator'
|
||||
},
|
||||
{
|
||||
draft: draft({ commandOverride: '"unclosed' }),
|
||||
field: 'commandOverride',
|
||||
reason: 'unterminated_quote'
|
||||
},
|
||||
{ draft: draft({ args: '"a\nb"' }), field: 'args', reason: 'quoted_line_break' },
|
||||
{ draft: draft({ args: '"open' }), field: 'args', reason: 'unterminated_quote' },
|
||||
{ draft: draft({ args: 'x'.repeat(8193) }), field: 'args', reason: 'bounds' },
|
||||
{
|
||||
draft: draft({ env: { ORCA_EVIL: 'x' } }),
|
||||
field: 'env',
|
||||
reason: 'reserved_name',
|
||||
envEntryIndex: 0
|
||||
},
|
||||
{
|
||||
draft: draft({ env: JSON.parse('{"__proto__": "x"}') as Record<string, string> }),
|
||||
field: 'env',
|
||||
reason: 'prototype_key',
|
||||
envEntryIndex: 0
|
||||
},
|
||||
{
|
||||
draft: draft({ env: { Path: 'a', PATH: 'b' } }),
|
||||
field: 'env',
|
||||
reason: 'case_collision',
|
||||
envEntryIndex: 1
|
||||
}
|
||||
]
|
||||
for (const testCase of cases) {
|
||||
const result = apply({
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: testCase.draft }
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
continue
|
||||
}
|
||||
expect(result.code).toBe('invalid_agent_field')
|
||||
expect(result.field).toBe(testCase.field)
|
||||
expect(result.reason).toBe(testCase.reason)
|
||||
if (testCase.envEntryIndex !== undefined) {
|
||||
expect(result.envEntryIndex).toBe(testCase.envEntryIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects the 16 KiB aggregate env bound', () => {
|
||||
const env: Record<string, string> = {}
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
env[`K${i}`] = 'v'.repeat(4000)
|
||||
}
|
||||
const result = apply({
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ env }) }
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: 'env',
|
||||
reason: 'env_total_bounds'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts multiline args (the editor is real, not cosmetic)', () => {
|
||||
const result = apply({
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'codex',
|
||||
draft: draft({ args: '--model x\n--safe "two words"' })
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes CRLF to LF on save', () => {
|
||||
const result = apply({
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'codex',
|
||||
draft: draft({ args: '--a\r\n--b' })
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents?.[0].args).toBe('--a\n--b')
|
||||
})
|
||||
|
||||
it('rejects label collisions with built-in canonical names, live labels, and referenced tombstones', () => {
|
||||
const live = liveAgent({ label: 'Mine' })
|
||||
const tombstone: DeletedCustomTuiAgent = {
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Kept Name',
|
||||
deletedAt: 1
|
||||
}
|
||||
const settings = settingsWith({
|
||||
customTuiAgents: [live],
|
||||
deletedCustomTuiAgents: [tombstone]
|
||||
})
|
||||
for (const label of ['Codex', ' codex ', 'MINE', 'kept name']) {
|
||||
const result = apply({
|
||||
settings,
|
||||
countTombstoneReferences: () => 1,
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label }) }
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, code: 'duplicate_agent_label' })
|
||||
}
|
||||
})
|
||||
|
||||
it('prunes unreferenced tombstones before label validation, freeing the name', () => {
|
||||
const tombstone: DeletedCustomTuiAgent = {
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Freed Name',
|
||||
deletedAt: 1
|
||||
}
|
||||
const result = apply({
|
||||
settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }),
|
||||
countTombstoneReferences: () => 0,
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label: 'Freed Name' }) }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.prunedTombstoneIds).toEqual([tombstone.id])
|
||||
expect(result.patch.deletedCustomTuiAgents).toEqual([])
|
||||
})
|
||||
|
||||
it('retains tombstones when a reference scan is unknown', () => {
|
||||
const tombstone: DeletedCustomTuiAgent = {
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Retained Name',
|
||||
deletedAt: 1
|
||||
}
|
||||
const result = apply({
|
||||
settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }),
|
||||
countTombstoneReferences: () => 'unknown',
|
||||
mutation: { kind: 'create', baseAgent: 'codex', draft: draft({ label: 'Retained Name' }) }
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, code: 'duplicate_agent_label' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('duplicates a disabled live custom into an enabled copy with syncEnv false', () => {
|
||||
const source = liveAgent({
|
||||
label: 'Source',
|
||||
commandOverride: '/opt/codex',
|
||||
args: '--model x',
|
||||
env: { FOO: 'bar' },
|
||||
syncEnv: true
|
||||
})
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
customTuiAgents: [source],
|
||||
disabledTuiAgents: [source.id]
|
||||
}),
|
||||
mutation: { kind: 'duplicate', sourceAgent: source.id, label: 'Copy' }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const copy = result.patch.customTuiAgents?.find((agent) => agent.id === result.mintedId)
|
||||
expect(copy).toMatchObject({
|
||||
label: 'Copy',
|
||||
baseAgent: 'codex',
|
||||
commandOverride: '/opt/codex',
|
||||
args: '--model x',
|
||||
env: { FOO: 'bar' },
|
||||
syncEnv: false
|
||||
})
|
||||
// Enabled copy: the disabled list is untouched (the new id is not added).
|
||||
expect(result.patch.disabledTuiAgents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never duplicates from a tombstone', () => {
|
||||
const tombstone: DeletedCustomTuiAgent = {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'Gone',
|
||||
deletedAt: 1
|
||||
}
|
||||
const result = apply({
|
||||
settings: settingsWith({ deletedCustomTuiAgents: [tombstone] }),
|
||||
mutation: { kind: 'duplicate', sourceAgent: tombstone.id, label: 'Copy' }
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' })
|
||||
})
|
||||
|
||||
it('splits an unambiguous multi-token built-in prefix into executable + prepended args', () => {
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
agentCmdOverrides: { codex: '/opt/wrap codex-real --fast' },
|
||||
agentDefaultArgs: { codex: '--user-arg' }
|
||||
}),
|
||||
mutation: { kind: 'duplicate', sourceAgent: 'codex', label: 'Wrapped' }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const copy = result.patch.customTuiAgents?.[0]
|
||||
expect(copy?.commandOverride).toBe('/opt/wrap')
|
||||
expect(copy?.args).toBe('codex-real --fast --user-arg')
|
||||
})
|
||||
|
||||
it('rejects a platform-ambiguous built-in prefix instead of guessing a grammar', () => {
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
agentCmdOverrides: { codex: 'C:\\tools\\wrap.exe codex' }
|
||||
}),
|
||||
mutation: { kind: 'duplicate', sourceAgent: 'codex', label: 'Wrapped' }
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: 'invalid_agent_field',
|
||||
field: 'commandOverride',
|
||||
reason: 'platform_ambiguous'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-custom', () => {
|
||||
it('updates in place, preserving physical index', () => {
|
||||
const first = liveAgent({ id: customId('codex', UUID_A), label: 'First' })
|
||||
const second = liveAgent({
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Second'
|
||||
})
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [first, second] }),
|
||||
mutation: {
|
||||
kind: 'update-custom',
|
||||
id: first.id,
|
||||
changes: {
|
||||
label: 'First Renamed',
|
||||
commandOverride: null,
|
||||
args: '--new',
|
||||
env: { A: '1' },
|
||||
syncEnv: true
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const live = result.patch.customTuiAgents ?? []
|
||||
expect(live[0]).toMatchObject({ id: first.id, label: 'First Renamed', syncEnv: true })
|
||||
expect(live[1]).toMatchObject({ id: second.id, label: 'Second' })
|
||||
})
|
||||
|
||||
it('repairs a valid-unique-id repair-required row through update-custom', () => {
|
||||
const broken = { ...liveAgent(), label: '' }
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [broken] }),
|
||||
mutation: {
|
||||
kind: 'update-custom',
|
||||
id: broken.id,
|
||||
changes: { label: 'Fixed', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents?.[0]).toMatchObject({ id: broken.id, label: 'Fixed' })
|
||||
})
|
||||
|
||||
it('rejects updates for unknown ids', () => {
|
||||
const result = apply({
|
||||
mutation: {
|
||||
kind: 'update-custom',
|
||||
id: customId('codex', UUID_B),
|
||||
changes: { label: 'X', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' })
|
||||
})
|
||||
|
||||
it('allows keeping its own label without a false collision', () => {
|
||||
const live = liveAgent({ label: 'Keep Me' })
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live] }),
|
||||
mutation: {
|
||||
kind: 'update-custom',
|
||||
id: live.id,
|
||||
changes: { label: 'keep me', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete-custom', () => {
|
||||
const live = liveAgent({ label: 'Doomed', args: '--secret', env: { KEY: 'value' } })
|
||||
|
||||
it('tombstones id/base/label only, removes the live row and disabled entry, and strips model caches', () => {
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
customTuiAgents: [live],
|
||||
disabledTuiAgents: [live.id, 'gemini'],
|
||||
sourceControlAi: {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: { [live.id]: 'model-x', codex: 'model-y' },
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand: '',
|
||||
instructionsByOperation: {}
|
||||
} as GlobalSettings['sourceControlAi'],
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: { [live.id]: 'model-z' },
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
} as GlobalSettings['commitMessageAi']
|
||||
}),
|
||||
mutation: { kind: 'delete-custom', id: live.id }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents).toEqual([])
|
||||
const tombstone = result.patch.deletedCustomTuiAgents?.[0]
|
||||
expect(tombstone).toMatchObject({ id: live.id, baseAgent: 'codex', label: 'Doomed' })
|
||||
// Tombstones never carry recoverable config.
|
||||
expect(tombstone && 'args' in tombstone).toBe(false)
|
||||
expect(tombstone && 'env' in tombstone).toBe(false)
|
||||
expect(result.patch.disabledTuiAgents).toEqual(['gemini'])
|
||||
expect(result.patch.sourceControlAi?.selectedModelByAgent).toEqual({ codex: 'model-y' })
|
||||
expect(result.patch.commitMessageAi?.selectedModelByAgent).toEqual({})
|
||||
})
|
||||
|
||||
it('applies onDefault only when the deleted id is the current default', () => {
|
||||
const notDefault = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: 'codex' }),
|
||||
mutation: { kind: 'delete-custom', id: live.id, onDefault: 'clear' }
|
||||
})
|
||||
expect(notDefault.ok).toBe(true)
|
||||
if (!notDefault.ok) {
|
||||
return
|
||||
}
|
||||
expect('defaultTuiAgent' in notDefault.patch).toBe(false)
|
||||
|
||||
const keep = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }),
|
||||
mutation: { kind: 'delete-custom', id: live.id, onDefault: 'keep' }
|
||||
})
|
||||
expect(keep.ok).toBe(true)
|
||||
if (!keep.ok) {
|
||||
return
|
||||
}
|
||||
expect('defaultTuiAgent' in keep.patch).toBe(false)
|
||||
|
||||
for (const [onDefault, expected] of [
|
||||
['base', 'codex'],
|
||||
['auto', 'auto'],
|
||||
['clear', null]
|
||||
] as const) {
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }),
|
||||
mutation: { kind: 'delete-custom', id: live.id, onDefault }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
continue
|
||||
}
|
||||
expect(result.patch.defaultTuiAgent).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats onDefault base as clear when the base is disabled', () => {
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
customTuiAgents: [live],
|
||||
defaultTuiAgent: live.id,
|
||||
disabledTuiAgents: ['codex']
|
||||
}),
|
||||
mutation: { kind: 'delete-custom', id: live.id, onDefault: 'base' }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.defaultTuiAgent).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('set-enabled', () => {
|
||||
it('disables and re-enables known identities in one write each', () => {
|
||||
const live = liveAgent()
|
||||
const disable = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live] }),
|
||||
mutation: { kind: 'set-enabled', agent: live.id, enabled: false }
|
||||
})
|
||||
expect(disable.ok).toBe(true)
|
||||
if (!disable.ok) {
|
||||
return
|
||||
}
|
||||
expect(disable.patch.disabledTuiAgents).toEqual([live.id])
|
||||
|
||||
const enable = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], disabledTuiAgents: [live.id] }),
|
||||
mutation: { kind: 'set-enabled', agent: live.id, enabled: true }
|
||||
})
|
||||
expect(enable.ok).toBe(true)
|
||||
if (!enable.ok) {
|
||||
return
|
||||
}
|
||||
expect(enable.patch.disabledTuiAgents).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a disabled custom default as the stored reference', () => {
|
||||
const live = liveAgent()
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }),
|
||||
mutation: { kind: 'set-enabled', agent: live.id, enabled: false }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect('defaultTuiAgent' in result.patch).toBe(false)
|
||||
})
|
||||
|
||||
it('disabling a base repairs a base or derivative default to null in the same write', () => {
|
||||
const live = liveAgent()
|
||||
const derivative = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live], defaultTuiAgent: live.id }),
|
||||
mutation: { kind: 'set-enabled', agent: 'codex', enabled: false }
|
||||
})
|
||||
expect(derivative.ok).toBe(true)
|
||||
if (!derivative.ok) {
|
||||
return
|
||||
}
|
||||
expect(derivative.patch.defaultTuiAgent).toBeNull()
|
||||
|
||||
const builtIn = apply({
|
||||
settings: settingsWith({ defaultTuiAgent: 'codex' }),
|
||||
mutation: { kind: 'set-enabled', agent: 'codex', enabled: false }
|
||||
})
|
||||
expect(builtIn.ok).toBe(true)
|
||||
if (!builtIn.ok) {
|
||||
return
|
||||
}
|
||||
expect(builtIn.patch.defaultTuiAgent).toBeNull()
|
||||
|
||||
// Auto remains Auto and simply skips the disabled base.
|
||||
const auto = apply({
|
||||
settings: settingsWith({ defaultTuiAgent: 'auto' }),
|
||||
mutation: { kind: 'set-enabled', agent: 'codex', enabled: false }
|
||||
})
|
||||
expect(auto.ok).toBe(true)
|
||||
if (!auto.ok) {
|
||||
return
|
||||
}
|
||||
expect('defaultTuiAgent' in auto.patch).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unknown identities', () => {
|
||||
const result = apply({
|
||||
mutation: { kind: 'set-enabled', agent: customId('codex', UUID_B), enabled: false }
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('set-default', () => {
|
||||
it('accepts auto, blank, and enabled live identities; the public type cannot carry null', () => {
|
||||
const live = liveAgent()
|
||||
for (const target of ['auto', 'blank', 'codex', live.id] as const) {
|
||||
const result = apply({
|
||||
settings: settingsWith({ customTuiAgents: [live] }),
|
||||
mutation: { kind: 'set-default', agent: target }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
continue
|
||||
}
|
||||
expect(result.patch.defaultTuiAgent).toBe(target)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects disabled, tombstoned, unknown, and repair-required identities', () => {
|
||||
const live = liveAgent()
|
||||
const broken = {
|
||||
...liveAgent({ id: customId('claude', UUID_B), baseAgent: 'claude' }),
|
||||
label: ''
|
||||
}
|
||||
const settings = settingsWith({
|
||||
customTuiAgents: [live, broken],
|
||||
disabledTuiAgents: [live.id],
|
||||
deletedCustomTuiAgents: []
|
||||
})
|
||||
for (const target of [live.id, broken.id, customId('gemini', UUID_B), 'gemini'] as const) {
|
||||
const useSettings =
|
||||
target === 'gemini' ? settingsWith({ disabledTuiAgents: ['gemini'] }) : settings
|
||||
const result = apply({
|
||||
settings: useSettings,
|
||||
mutation: { kind: 'set-default', agent: target }
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('update-built-in', () => {
|
||||
it('writes the three override slots and clears empty ones', () => {
|
||||
const result = apply({
|
||||
settings: settingsWith({
|
||||
agentCmdOverrides: { codex: '/old' },
|
||||
agentDefaultArgs: { codex: '--old' },
|
||||
agentDefaultEnv: { codex: { OLD: '1' } }
|
||||
}),
|
||||
mutation: {
|
||||
kind: 'update-built-in',
|
||||
agent: 'codex',
|
||||
changes: { commandOverride: '/new/codex', args: '', env: { NEW: '2' } }
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.agentCmdOverrides).toEqual({ codex: '/new/codex' })
|
||||
expect(result.patch.agentDefaultArgs).toEqual({})
|
||||
expect(result.patch.agentDefaultEnv).toEqual({ codex: { NEW: '2' } })
|
||||
})
|
||||
|
||||
it('rejects control characters and prototype keys while keeping multi-token compatibility', () => {
|
||||
const multiToken = apply({
|
||||
mutation: {
|
||||
kind: 'update-built-in',
|
||||
agent: 'codex',
|
||||
changes: { commandOverride: '/opt/wrap codex --flag', args: '', env: {} }
|
||||
}
|
||||
})
|
||||
expect(multiToken.ok).toBe(true)
|
||||
|
||||
const controlChar = apply({
|
||||
mutation: {
|
||||
kind: 'update-built-in',
|
||||
agent: 'codex',
|
||||
changes: { commandOverride: 'a\nb', args: '', env: {} }
|
||||
}
|
||||
})
|
||||
expect(controlChar).toMatchObject({ ok: false, reason: 'control_char' })
|
||||
|
||||
const protoKey = apply({
|
||||
mutation: {
|
||||
kind: 'update-built-in',
|
||||
agent: 'codex',
|
||||
changes: {
|
||||
commandOverride: null,
|
||||
args: '',
|
||||
env: JSON.parse('{"__proto__": "x"}') as Record<string, string>
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(protoKey).toMatchObject({ ok: false, reason: 'prototype_key' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
// Atomic agent-catalog mutation engine. Every mutation validates against the
|
||||
// exact expected revision, produces one settings patch applied in one store
|
||||
// write, and increments the catalog revision exactly once. Failures perform no
|
||||
// write. Main owns id minting and dependent-field repair; corrupt rows are
|
||||
// addressed only by opaque revision-scoped repair tokens. Draft validation,
|
||||
// lifecycle, repair, and built-in override live in the re-exported siblings.
|
||||
|
||||
import type {
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
DeletedCustomTuiAgent,
|
||||
GlobalSettings
|
||||
} from '../../shared/types'
|
||||
import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot'
|
||||
import { normalizeAgentCatalog, type AgentCatalog } from '../../shared/custom-tui-agents'
|
||||
import type {
|
||||
AgentCatalogMutationApplication,
|
||||
TombstoneReferenceCount
|
||||
} from './agent-catalog-draft-validation'
|
||||
import {
|
||||
applyCreate,
|
||||
applyDelete,
|
||||
applyDuplicate,
|
||||
applySetDefault,
|
||||
applySetEnabled,
|
||||
applyUpdateCustom
|
||||
} from './agent-catalog-lifecycle-mutations'
|
||||
import { applyRepairCorrupt, applyResolveDuplicateId } from './agent-catalog-repair-mutations'
|
||||
import type { AgentCatalogRepairTokenRegistry } from './agent-catalog-repair-mutations'
|
||||
import { applyUpdateBuiltIn } from './agent-built-in-override-mutations'
|
||||
|
||||
export { AgentCatalogRepairTokenRegistry } from './agent-catalog-repair-mutations'
|
||||
export type {
|
||||
AgentCatalogMutationError,
|
||||
AgentCatalogMutationApplication,
|
||||
TombstoneReferenceCount
|
||||
} from './agent-catalog-draft-validation'
|
||||
|
||||
export type ApplyAgentCatalogMutationArgs = {
|
||||
settings: GlobalSettings
|
||||
request: AgentCatalogMutationRequest
|
||||
currentRevision: number
|
||||
repairTokens: AgentCatalogRepairTokenRegistry
|
||||
/** Authoritative reference count per tombstone id; 'unknown' means an owner
|
||||
* store could not be checked and the tombstone must be retained. */
|
||||
countTombstoneReferences: (id: CustomTuiAgentId) => TombstoneReferenceCount
|
||||
}
|
||||
|
||||
export type MutationContext = {
|
||||
args: ApplyAgentCatalogMutationArgs
|
||||
catalog: AgentCatalog
|
||||
persistedLive: readonly unknown[]
|
||||
persistedTombstones: readonly DeletedCustomTuiAgent[]
|
||||
newRevision: number
|
||||
}
|
||||
|
||||
function definitionsEqualById(
|
||||
agents: readonly CustomTuiAgent[]
|
||||
): Map<CustomTuiAgentId, CustomTuiAgent> {
|
||||
const map = new Map<CustomTuiAgentId, CustomTuiAgent>()
|
||||
for (const agent of agents) {
|
||||
map.set(agent.id, agent)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function applyAgentCatalogMutation(
|
||||
args: ApplyAgentCatalogMutationArgs
|
||||
): AgentCatalogMutationApplication {
|
||||
const { settings, request, currentRevision, repairTokens } = args
|
||||
if (request.expectedRevision !== currentRevision) {
|
||||
return { ok: false, code: 'catalog_revision_conflict' }
|
||||
}
|
||||
|
||||
const { catalog } = normalizeAgentCatalog({
|
||||
customTuiAgents: settings.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
defaultTuiAgent: settings.defaultTuiAgent
|
||||
})
|
||||
const persistedLive = Array.isArray(settings.customTuiAgents) ? settings.customTuiAgents : []
|
||||
const persistedTombstones = Array.isArray(settings.deletedCustomTuiAgents)
|
||||
? settings.deletedCustomTuiAgents
|
||||
: []
|
||||
const newRevision = currentRevision + 1
|
||||
const mutation = request.mutation
|
||||
const context: MutationContext = {
|
||||
args,
|
||||
catalog,
|
||||
persistedLive,
|
||||
persistedTombstones,
|
||||
newRevision
|
||||
}
|
||||
|
||||
switch (mutation.kind) {
|
||||
case 'create':
|
||||
return applyCreate(mutation.baseAgent, mutation.draft, context)
|
||||
case 'duplicate':
|
||||
return applyDuplicate(mutation.sourceAgent, mutation.label, context)
|
||||
case 'update-custom':
|
||||
return applyUpdateCustom(mutation.id, mutation.changes, context)
|
||||
case 'delete-custom':
|
||||
return applyDelete(mutation.id, mutation.onDefault ?? 'keep', context)
|
||||
case 'set-enabled':
|
||||
return applySetEnabled(mutation.agent, mutation.enabled, { args, catalog, newRevision })
|
||||
case 'set-default':
|
||||
return applySetDefault(mutation.agent, catalog, newRevision)
|
||||
case 'repair-corrupt':
|
||||
return applyRepairCorrupt(mutation.repairToken, mutation.action, {
|
||||
...context,
|
||||
repairTokens
|
||||
})
|
||||
case 'resolve-duplicate-id':
|
||||
return applyResolveDuplicateId(mutation.duplicateId, mutation.rows, {
|
||||
...context,
|
||||
repairTokens
|
||||
})
|
||||
case 'update-built-in':
|
||||
return applyUpdateBuiltIn(mutation, settings, newRevision)
|
||||
}
|
||||
}
|
||||
|
||||
export function liveDefinitionsById(
|
||||
settings: GlobalSettings
|
||||
): Map<CustomTuiAgentId, CustomTuiAgent> {
|
||||
const { catalog } = normalizeAgentCatalog({
|
||||
customTuiAgents: settings.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
defaultTuiAgent: settings.defaultTuiAgent
|
||||
})
|
||||
return definitionsEqualById(catalog.liveCustomAgents)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index'
|
||||
import { registerOrchestrationOwnerScanner } from './agent-catalog-owner-scanners'
|
||||
import type { CustomTuiAgentId } from '../../shared/types'
|
||||
|
||||
const deadId = 'custom-agent:codex:fedcba98-7654-4321-8fed-cba987654321' as CustomTuiAgentId
|
||||
|
||||
describe('orchestration owner scanner', () => {
|
||||
it('retains a tombstone while a dispatch references the id and prunes after it clears', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
let referenced: string[] = [deadId]
|
||||
registerOrchestrationOwnerScanner(index, () => referenced)
|
||||
|
||||
expect(index.countReferences(deadId)).toBe(1)
|
||||
expect(index.summarizeReferences(deadId)).toContainEqual({ owner: 'orchestration', count: 1 })
|
||||
|
||||
referenced = []
|
||||
expect(index.countReferences(deadId)).toBe(0)
|
||||
})
|
||||
|
||||
it('retains conservatively (unknown) when the dispatch store cannot be read', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
registerOrchestrationOwnerScanner(index, () => {
|
||||
throw new Error('orchestration db unavailable')
|
||||
})
|
||||
expect(index.countReferences(deadId)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('is idempotent so a shared index never double-counts a dispatch reference', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
const accessor = (): string[] => [deadId]
|
||||
registerOrchestrationOwnerScanner(index, accessor)
|
||||
registerOrchestrationOwnerScanner(index, accessor)
|
||||
expect(index.countReferences(deadId)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
// Built-in reference owner scanners: each enumerates the raw agent ids the
|
||||
// settings/repo/automation/session records currently point at. The index applies
|
||||
// the counting policy (custom-id tombstone GC, or base-disable impact matching);
|
||||
// a scan that throws returns { ok: false } so the tombstone is conservatively
|
||||
// retained.
|
||||
|
||||
import type { Store } from '../persistence'
|
||||
import type { GlobalSettings, TerminalQuickCommand } from '../../shared/types'
|
||||
import type { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index'
|
||||
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
|
||||
import { getHostBackgroundAgentLaunchStore } from './background-agent-launch-store-host'
|
||||
|
||||
/** Register the desktop's built-in reference owners against the shared index.
|
||||
* Later units add their own owner scanners through the same index. */
|
||||
export function registerBuiltInOwnerScanners(
|
||||
index: AgentTombstoneReferenceIndex,
|
||||
store: Store
|
||||
): void {
|
||||
const settings = (): GlobalSettings => store.getSettings()
|
||||
index.register({
|
||||
owner: 'default',
|
||||
scan: () => {
|
||||
try {
|
||||
return { ok: true, referencedIds: [settings().defaultTuiAgent] }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
owner: 'quick-command',
|
||||
scan: () => {
|
||||
try {
|
||||
const commands: TerminalQuickCommand[] = settings().terminalQuickCommands ?? []
|
||||
return {
|
||||
ok: true,
|
||||
referencedIds: commands.map((command) => ('agent' in command ? command.agent : null))
|
||||
}
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
owner: 'commit-message',
|
||||
scan: () => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
referencedIds: [settings().commitMessageAi?.agentId, settings().sourceControlAi?.agentId]
|
||||
}
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
owner: 'source-control-recipe',
|
||||
scan: () => {
|
||||
try {
|
||||
const references: unknown[] = []
|
||||
const actions = settings().sourceControlAi?.actions
|
||||
if (actions) {
|
||||
for (const action of Object.values(actions)) {
|
||||
if (action && typeof action === 'object' && 'agentId' in action) {
|
||||
references.push((action as { agentId?: unknown }).agentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Repo-scoped Source Control overrides are persisted per repo.
|
||||
for (const repo of store.getRepos()) {
|
||||
const overrides = repo.sourceControlAi?.actionOverrides
|
||||
if (!overrides) {
|
||||
continue
|
||||
}
|
||||
for (const override of Object.values(overrides)) {
|
||||
if (override && typeof override === 'object' && 'agentId' in override) {
|
||||
references.push((override as { agentId?: unknown }).agentId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true, referencedIds: references }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
owner: 'automation',
|
||||
scan: () => {
|
||||
try {
|
||||
const references: unknown[] = store
|
||||
.listAutomations()
|
||||
.map((automation) => automation.agentId)
|
||||
// U6: a persisted run's structured launch failure records the requested
|
||||
// identity, which survives even if the definition's agent later changes,
|
||||
// so a deleted custom id stays retained while any run failure names it.
|
||||
for (const run of store.listAutomationRuns()) {
|
||||
references.push(run.agentLaunchFailure?.requestedAgent)
|
||||
}
|
||||
return { ok: true, referencedIds: references }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
owner: 'workspace',
|
||||
scan: () => {
|
||||
try {
|
||||
// A two-stage creation records the pinned requested identity on both the
|
||||
// in-flight pending launch and the durable post-create failure, so a
|
||||
// tombstone stays retained until neither still points at the custom id.
|
||||
const references: unknown[] = []
|
||||
for (const meta of Object.values(store.getAllWorktreeMeta())) {
|
||||
references.push(meta.pendingAgentLaunch?.requestedAgent)
|
||||
references.push(meta.agentLaunchFailure?.requestedAgent)
|
||||
}
|
||||
return { ok: true, referencedIds: references }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
// §266 `session` = AI Vault/workspace plus sleeping/resumable sessions. The
|
||||
// host-private record store is the resume authority: every bound resumable
|
||||
// session registers its requested identity there and the record survives pane
|
||||
// dispose, so it is the complete source of custom-id session references.
|
||||
// AI Vault sessions are disk-discovered and hold no persisted catalog id.
|
||||
owner: 'session',
|
||||
scan: () => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
referencedIds: getHostAgentSessionRecordStore().referencedRequestedAgents()
|
||||
}
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
index.register({
|
||||
// §266/§217 `background` = generic unattended launches with no automation run
|
||||
// or orchestration dispatch to own them. Each attempt records its requested
|
||||
// identity, and a forgotten attempt still references it until pruned, so a
|
||||
// deleted custom id's tombstone stays retained while any attempt names it.
|
||||
owner: 'background',
|
||||
scan: () => {
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
referencedIds: getHostBackgroundAgentLaunchStore().referencedRequestedAgents()
|
||||
}
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: the orchestration dispatch store is per-runtime (not a host singleton like
|
||||
// session/background), so its scanner registers from the runtime rather than the
|
||||
// built-in pass. The guard keeps that registration idempotent even if several
|
||||
// runtimes share one catalog service (its store), so a shared index never
|
||||
// double-counts a dispatch reference.
|
||||
const orchestrationScannerRegistered = new WeakSet<AgentTombstoneReferenceIndex>()
|
||||
|
||||
/** §266/§217 `orchestration` = coordinator worker dispatches. Each dispatch row
|
||||
* records its requested identity, so a deleted custom id's tombstone stays
|
||||
* retained while any dispatch still names it. `referencedRequestedAgents` must
|
||||
* read the durable dispatch store (surviving reload); a read failure returns
|
||||
* `ok:false` so the tombstone is conservatively retained. */
|
||||
export function registerOrchestrationOwnerScanner(
|
||||
index: AgentTombstoneReferenceIndex,
|
||||
referencedRequestedAgents: () => Iterable<unknown>
|
||||
): void {
|
||||
if (orchestrationScannerRegistered.has(index)) {
|
||||
return
|
||||
}
|
||||
orchestrationScannerRegistered.add(index)
|
||||
index.register({
|
||||
owner: 'orchestration',
|
||||
scan: () => {
|
||||
try {
|
||||
return { ok: true, referencedIds: [...referencedRequestedAgents()] }
|
||||
} catch {
|
||||
return { ok: false }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CustomTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types'
|
||||
import {
|
||||
buildAgentCatalogSnapshot,
|
||||
buildLocalAgentCatalogSnapshot,
|
||||
measureLocalAgentCatalogStorage,
|
||||
projectLegacyDefaultTuiAgent,
|
||||
projectLegacyDisabledTuiAgents
|
||||
} from './agent-catalog-projections'
|
||||
import { AgentCatalogRepairTokenRegistry } from './agent-catalog-mutations'
|
||||
import { scanForCustomEnvLeak } from '../../shared/custom-env-leak-scan'
|
||||
|
||||
const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd'
|
||||
const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321'
|
||||
|
||||
function customId(base: string, uuid = UUID_A): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${uuid}` as CustomTuiAgentId
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<CustomTuiAgent> = {}): CustomTuiAgent {
|
||||
return {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'My Codex',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
defaultTuiAgent: 'auto',
|
||||
disabledTuiAgents: [],
|
||||
customTuiAgents: [],
|
||||
deletedCustomTuiAgents: [],
|
||||
agentCatalogRevision: 2,
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
describe('remote snapshot projection', () => {
|
||||
it('projects ready rows env-free with the conservative availability hint', () => {
|
||||
const withheld = liveAgent({ env: { KEY: 'secret-value' }, syncEnv: false })
|
||||
const available = liveAgent({
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Shared',
|
||||
env: { TOKEN: 'another-secret' },
|
||||
syncEnv: true
|
||||
})
|
||||
const snapshot = buildAgentCatalogSnapshot(
|
||||
settingsWith({ customTuiAgents: [withheld, available] })
|
||||
)
|
||||
expect('code' in snapshot).toBe(false)
|
||||
if ('code' in snapshot) {
|
||||
return
|
||||
}
|
||||
const text = JSON.stringify(snapshot)
|
||||
expect(text).not.toContain('secret-value')
|
||||
expect(text).not.toContain('another-secret')
|
||||
expect(text).not.toContain('KEY')
|
||||
expect(text).not.toContain('TOKEN')
|
||||
const [first, second] = snapshot.customAgents
|
||||
expect(first).toMatchObject({
|
||||
status: 'ready',
|
||||
envState: 'withheld',
|
||||
availabilityCheck: 'baseline-detection'
|
||||
})
|
||||
expect(second).toMatchObject({
|
||||
status: 'ready',
|
||||
envState: 'available',
|
||||
availabilityCheck: 'host-preflight'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses host-preflight for a configured executable regardless of env', () => {
|
||||
const snapshot = buildAgentCatalogSnapshot(
|
||||
settingsWith({ customTuiAgents: [liveAgent({ commandOverride: '/opt/codex' })] })
|
||||
)
|
||||
if ('code' in snapshot) {
|
||||
throw new Error('unexpected projection error')
|
||||
}
|
||||
expect(snapshot.customAgents[0]).toMatchObject({ availabilityCheck: 'host-preflight' })
|
||||
})
|
||||
|
||||
it('projects valid-id repair rows without raw fields and omits malformed/duplicate rows', () => {
|
||||
const repairRow = { ...liveAgent(), label: '', args: '"unclosed' }
|
||||
const malformed = { id: 'custom-agent:codex:nope', baseAgent: 'codex', label: 'Bad' }
|
||||
const duplicateId = customId('claude', UUID_B)
|
||||
const dupA = liveAgent({ id: duplicateId, baseAgent: 'claude', label: 'Dup A' })
|
||||
const dupB = liveAgent({ id: duplicateId, baseAgent: 'claude', label: 'Dup B' })
|
||||
const snapshot = buildAgentCatalogSnapshot(
|
||||
settingsWith({
|
||||
customTuiAgents: [repairRow, malformed as unknown as CustomTuiAgent, dupA, dupB]
|
||||
})
|
||||
)
|
||||
if ('code' in snapshot) {
|
||||
throw new Error('unexpected projection error')
|
||||
}
|
||||
expect(snapshot.customAgents).toHaveLength(1)
|
||||
expect(snapshot.customAgents[0]).toMatchObject({
|
||||
id: repairRow.id,
|
||||
status: 'repair-required',
|
||||
label: null,
|
||||
envState: 'none'
|
||||
})
|
||||
expect(JSON.stringify(snapshot)).not.toContain('unclosed')
|
||||
})
|
||||
|
||||
it('returns the typed projection error above 512 KiB while keeping version and revision', () => {
|
||||
// ~200 agents x ~4 KiB args ≈ >512 KiB serialized (args are projected).
|
||||
const agents: CustomTuiAgent[] = []
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
agents.push(
|
||||
liveAgent({
|
||||
id: `custom-agent:codex:${UUID_A.slice(0, 34)}${String(i % 100).padStart(2, '0')}` as CustomTuiAgentId,
|
||||
label: `Agent ${i}`,
|
||||
args: `--marker ${'x'.repeat(4000)}`
|
||||
})
|
||||
)
|
||||
}
|
||||
// Ensure unique canonical ids (vary last two hex chars).
|
||||
const unique = agents.map((agent, index) => ({
|
||||
...agent,
|
||||
id: `custom-agent:codex:${UUID_A.slice(0, -4)}${index.toString(16).padStart(4, '0')}` as CustomTuiAgentId
|
||||
}))
|
||||
const snapshot = buildAgentCatalogSnapshot(settingsWith({ customTuiAgents: unique }))
|
||||
expect(snapshot).toMatchObject({
|
||||
version: 1,
|
||||
revision: 2,
|
||||
code: 'agent_catalog_payload_too_large',
|
||||
maxBytes: 524_288
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces an invalid tombstone label with an empty string for remote fallback copy', () => {
|
||||
const snapshot = buildAgentCatalogSnapshot(
|
||||
settingsWith({
|
||||
deletedCustomTuiAgents: [
|
||||
{ id: customId('codex'), baseAgent: 'codex', label: ' ', deletedAt: 1 }
|
||||
]
|
||||
})
|
||||
)
|
||||
if ('code' in snapshot) {
|
||||
throw new Error('unexpected projection error')
|
||||
}
|
||||
expect(snapshot.deletedCustomAgents[0].label).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('local snapshot projection', () => {
|
||||
it('summarizes env numerically, mints repair tokens, and reports both budgets', () => {
|
||||
const live = liveAgent({ env: { KEY: 'secret-value' } })
|
||||
const malformed = { id: 'custom-agent:codex:nope', label: 'Bad' }
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const snapshot = buildLocalAgentCatalogSnapshot(
|
||||
settingsWith({ customTuiAgents: [live, malformed as unknown as CustomTuiAgent] }),
|
||||
registry
|
||||
)
|
||||
expect(JSON.stringify(snapshot)).not.toContain('secret-value')
|
||||
const ready = snapshot.customAgents.find((row) => row.status === 'ready')
|
||||
expect(ready && ready.status === 'ready' ? ready.envSummary.entryCount : -1).toBe(1)
|
||||
const repair = snapshot.customAgents.find((row) => row.status === 'repair-required')
|
||||
expect(
|
||||
repair && repair.status === 'repair-required' ? repair.repairToken.length : 0
|
||||
).toBeGreaterThan(0)
|
||||
expect(snapshot.projection.status).toBe('ready')
|
||||
expect(snapshot.localStorage.status).toBe('ready')
|
||||
})
|
||||
|
||||
it('labels desktop rows configured-executable, custom-path, or baseline-stock', () => {
|
||||
// Desktop-only status source (G8): a configured executable and an accepted
|
||||
// PATH override each defeat baseline stock detection and must be told apart
|
||||
// from a plain stock-prefix row so the UI shows the right status.
|
||||
const configured = liveAgent({ commandOverride: '/usr/local/bin/codex' })
|
||||
const customPath = liveAgent({
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Claude PATH',
|
||||
env: { PATH: '/opt/tools/bin' }
|
||||
})
|
||||
const baselineStock = liveAgent({
|
||||
id: customId('gemini'),
|
||||
baseAgent: 'gemini',
|
||||
label: 'Plain Gemini'
|
||||
})
|
||||
const snapshot = buildLocalAgentCatalogSnapshot(
|
||||
settingsWith({ customTuiAgents: [configured, customPath, baselineStock] }),
|
||||
new AgentCatalogRepairTokenRegistry()
|
||||
)
|
||||
const reasonById = new Map(
|
||||
snapshot.customAgents.flatMap((row) =>
|
||||
row.status === 'ready' ? [[row.definition.id, row.availabilityReason]] : []
|
||||
)
|
||||
)
|
||||
expect(reasonById.get(configured.id)).toBe('configured-executable')
|
||||
expect(reasonById.get(customPath.id)).toBe('custom-path')
|
||||
expect(reasonById.get(baselineStock.id)).toBe('baseline-stock')
|
||||
})
|
||||
|
||||
it('keeps repair tokens stable across unrelated revisions', () => {
|
||||
const malformed = { id: 'custom-agent:codex:nope', label: 'Bad' }
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const first = buildLocalAgentCatalogSnapshot(
|
||||
settingsWith({ customTuiAgents: [malformed as unknown as CustomTuiAgent] }),
|
||||
registry
|
||||
)
|
||||
const second = buildLocalAgentCatalogSnapshot(
|
||||
settingsWith({
|
||||
customTuiAgents: [malformed as unknown as CustomTuiAgent],
|
||||
agentCatalogRevision: 3
|
||||
}),
|
||||
registry
|
||||
)
|
||||
const tokenOf = (snapshot: typeof first): string => {
|
||||
const row = snapshot.customAgents[0]
|
||||
return row.status === 'repair-required' ? row.repairToken : ''
|
||||
}
|
||||
expect(tokenOf(first)).toBe(tokenOf(second))
|
||||
})
|
||||
|
||||
it('measures the 16 MiB local storage budget over the full env-bearing catalog', () => {
|
||||
const status = measureLocalAgentCatalogStorage(settingsWith({ customTuiAgents: [liveAgent()] }))
|
||||
expect(status.status).toBe('ready')
|
||||
expect(status.maxBytes).toBe(16_777_216)
|
||||
})
|
||||
})
|
||||
|
||||
describe('no custom env leaks recursively (G7 oracle-12/13)', () => {
|
||||
// Deliberately distinctive so a match cannot come from a legitimate id/label/arg.
|
||||
const ENV_KEY_A = 'ZZLEAKKEY_ALPHA'
|
||||
const ENV_VALUE_A = 'zzleakvalue_alpha_9f3'
|
||||
const ENV_KEY_B = 'ZZLEAKKEY_BETA'
|
||||
const ENV_VALUE_B = 'zzleakvalue_beta_7c1'
|
||||
const FORBIDDEN = [ENV_KEY_A, ENV_VALUE_A, ENV_KEY_B, ENV_VALUE_B]
|
||||
|
||||
function envBearingSettings(): GlobalSettings {
|
||||
return settingsWith({
|
||||
customTuiAgents: [
|
||||
// available (syncEnv on) — the case most at risk of leaking through env
|
||||
// application metadata.
|
||||
liveAgent({ env: { [ENV_KEY_A]: ENV_VALUE_A }, syncEnv: true }),
|
||||
// withheld (syncEnv off).
|
||||
liveAgent({
|
||||
id: customId('claude', UUID_B),
|
||||
baseAgent: 'claude',
|
||||
label: 'Withheld',
|
||||
env: { [ENV_KEY_B]: ENV_VALUE_B },
|
||||
syncEnv: false
|
||||
})
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
it('remote snapshot projection carries no env key or value at any depth', () => {
|
||||
const snapshot = buildAgentCatalogSnapshot(envBearingSettings())
|
||||
if ('code' in snapshot) {
|
||||
throw new Error('unexpected projection error')
|
||||
}
|
||||
expect(scanForCustomEnvLeak(snapshot, FORBIDDEN)).toEqual([])
|
||||
})
|
||||
|
||||
it('local snapshot projection carries no env key or value at any depth', () => {
|
||||
const snapshot = buildLocalAgentCatalogSnapshot(
|
||||
envBearingSettings(),
|
||||
new AgentCatalogRepairTokenRegistry()
|
||||
)
|
||||
// The env-numeric summary must survive so the scan is meaningful (env present).
|
||||
const ready = snapshot.customAgents.find((row) => row.status === 'ready')
|
||||
expect(ready && ready.status === 'ready' ? ready.envSummary.entryCount : 0).toBe(1)
|
||||
expect(scanForCustomEnvLeak(snapshot, FORBIDDEN)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy client projections', () => {
|
||||
it('maps defaults so old clients never see custom ids or non-Auto null', () => {
|
||||
expect(projectLegacyDefaultTuiAgent('codex')).toBe('codex')
|
||||
expect(projectLegacyDefaultTuiAgent('auto')).toBeNull()
|
||||
expect(projectLegacyDefaultTuiAgent('blank')).toBe('blank')
|
||||
expect(projectLegacyDefaultTuiAgent(null)).toBe('blank')
|
||||
expect(projectLegacyDefaultTuiAgent(customId('codex'))).toBe('blank')
|
||||
})
|
||||
|
||||
it('drops custom ids from the legacy disabled list', () => {
|
||||
expect(projectLegacyDisabledTuiAgents(['codex', customId('claude', UUID_B)])).toEqual(['codex'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
// Env-free projections of the agent catalog: the revisioned remote snapshot
|
||||
// synced to mobile/paired clients, the local (preload-IPC-only) repair summary,
|
||||
// and the legacy `settings` compatibility projection for pre-catalog clients.
|
||||
// No projection built here may contain a custom env key or value.
|
||||
|
||||
import type { CustomTuiAgent, GlobalSettings, TuiAgent } from '../../shared/types'
|
||||
import type {
|
||||
AgentCatalogProjectionError,
|
||||
AgentCatalogSnapshot,
|
||||
AgentProjectionStatus,
|
||||
LocalAgentCatalogSnapshot,
|
||||
LocalAgentCatalogStorageStatus,
|
||||
LocalCustomTuiAgent,
|
||||
SyncedCustomTuiAgent
|
||||
} from '../../shared/agent-catalog-snapshot'
|
||||
import { MAX_LOCAL_AGENT_DRAFT_BYTES } from '../../shared/agent-catalog-snapshot'
|
||||
import {
|
||||
MAX_AGENT_CATALOG_PROJECTION_BYTES,
|
||||
MAX_LOCAL_AGENT_CATALOG_BYTES,
|
||||
measureCustomAgentEnvBytes,
|
||||
normalizeAgentCatalog,
|
||||
utf8ByteLength,
|
||||
validateAgentLabel,
|
||||
type AgentCatalog,
|
||||
type CorruptCatalogRow
|
||||
} from '../../shared/custom-tui-agents'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import type { AgentCatalogRepairTokenRegistry } from './agent-catalog-mutations'
|
||||
|
||||
export function normalizeCatalogFromSettings(settings: GlobalSettings): AgentCatalog {
|
||||
return normalizeAgentCatalog({
|
||||
customTuiAgents: settings.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
defaultTuiAgent: settings.defaultTuiAgent
|
||||
}).catalog
|
||||
}
|
||||
|
||||
function remoteEnvState(definition: CustomTuiAgent): 'none' | 'available' | 'withheld' {
|
||||
if (Object.keys(definition.env).length === 0) {
|
||||
return 'none'
|
||||
}
|
||||
return definition.syncEnv ? 'available' : 'withheld'
|
||||
}
|
||||
|
||||
function hasCustomPathOverride(definition: CustomTuiAgent): boolean {
|
||||
return Object.keys(definition.env).some((key) => key.toLowerCase() === 'path')
|
||||
}
|
||||
|
||||
function syncedRow(definition: CustomTuiAgent): SyncedCustomTuiAgent {
|
||||
const envState = remoteEnvState(definition)
|
||||
return {
|
||||
id: definition.id,
|
||||
baseAgent: definition.baseAgent,
|
||||
label: definition.label,
|
||||
...(definition.commandOverride ? { commandOverride: definition.commandOverride } : {}),
|
||||
args: definition.args,
|
||||
syncEnv: definition.syncEnv,
|
||||
status: 'ready',
|
||||
envState,
|
||||
// Conservative: a configured executable or host-applicable env means stock
|
||||
// baseline detection cannot vouch for this row, and naming the actual
|
||||
// reason (e.g. PATH) would leak which env key exists.
|
||||
availabilityCheck:
|
||||
definition.commandOverride || envState === 'available'
|
||||
? 'host-preflight'
|
||||
: 'baseline-detection'
|
||||
}
|
||||
}
|
||||
|
||||
function syncedRepairRow(row: CorruptCatalogRow): SyncedCustomTuiAgent | null {
|
||||
// Only rows with an independently valid unique id and base may project; the
|
||||
// raw invalid command/args/env never leave the host.
|
||||
if (!row.id || !row.baseAgent) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
baseAgent: row.baseAgent,
|
||||
label: row.label !== null && !validateAgentLabel(row.label) ? row.label : null,
|
||||
status: 'repair-required',
|
||||
envState: 'none'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAgentCatalogSnapshot(
|
||||
settings: GlobalSettings,
|
||||
catalog: AgentCatalog = normalizeCatalogFromSettings(settings)
|
||||
): AgentCatalogSnapshot | AgentCatalogProjectionError {
|
||||
const revision = settings.agentCatalogRevision ?? 1
|
||||
const customAgents: SyncedCustomTuiAgent[] = []
|
||||
for (const definition of catalog.liveCustomAgents) {
|
||||
customAgents.push(syncedRow(definition))
|
||||
}
|
||||
for (const row of catalog.repairRequiredById.values()) {
|
||||
const projected = syncedRepairRow(row)
|
||||
if (projected) {
|
||||
customAgents.push(projected)
|
||||
}
|
||||
}
|
||||
// Malformed/duplicate identity rows exist only in the local snapshot.
|
||||
const snapshot: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision,
|
||||
defaultAgent: catalog.defaultAgent,
|
||||
disabledAgents: [...catalog.disabledAgents],
|
||||
customAgents,
|
||||
deletedCustomAgents: [...catalog.tombstonesById.values()].map((tombstone) => ({
|
||||
...tombstone,
|
||||
// Remote clients localize a generic fallback for an unsafe label rather
|
||||
// than receiving the raw invalid text.
|
||||
label: validateAgentLabel(tombstone.label) ? '' : tombstone.label
|
||||
}))
|
||||
}
|
||||
const bytes = utf8ByteLength(JSON.stringify(snapshot))
|
||||
if (bytes > MAX_AGENT_CATALOG_PROJECTION_BYTES) {
|
||||
return {
|
||||
version: 1,
|
||||
revision,
|
||||
code: 'agent_catalog_payload_too_large',
|
||||
maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function measureAgentCatalogProjection(
|
||||
settings: GlobalSettings,
|
||||
catalog: AgentCatalog = normalizeCatalogFromSettings(settings)
|
||||
): AgentProjectionStatus {
|
||||
const revision = settings.agentCatalogRevision ?? 1
|
||||
const customAgents: SyncedCustomTuiAgent[] = catalog.liveCustomAgents.map(syncedRow)
|
||||
for (const row of catalog.repairRequiredById.values()) {
|
||||
const projected = syncedRepairRow(row)
|
||||
if (projected) {
|
||||
customAgents.push(projected)
|
||||
}
|
||||
}
|
||||
const snapshot: AgentCatalogSnapshot = {
|
||||
version: 1,
|
||||
revision,
|
||||
defaultAgent: catalog.defaultAgent,
|
||||
disabledAgents: [...catalog.disabledAgents],
|
||||
customAgents,
|
||||
deletedCustomAgents: [...catalog.tombstonesById.values()]
|
||||
}
|
||||
const bytes = utf8ByteLength(JSON.stringify(snapshot))
|
||||
return bytes > MAX_AGENT_CATALOG_PROJECTION_BYTES
|
||||
? { status: 'too-large', bytes, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES }
|
||||
: { status: 'ready', bytes, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES }
|
||||
}
|
||||
|
||||
/** Complete UTF-8 JSON size of the persisted live+tombstone custom catalog,
|
||||
* including env (the 16 MiB local storage budget). */
|
||||
export function measureLocalAgentCatalogStorage(
|
||||
settings: GlobalSettings
|
||||
): LocalAgentCatalogStorageStatus {
|
||||
const bytes = utf8ByteLength(
|
||||
JSON.stringify({
|
||||
customTuiAgents: settings.customTuiAgents ?? [],
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents ?? []
|
||||
})
|
||||
)
|
||||
return bytes > MAX_LOCAL_AGENT_CATALOG_BYTES
|
||||
? { status: 'too-large', bytes, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES }
|
||||
: { status: 'ready', bytes, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES }
|
||||
}
|
||||
|
||||
function localReadyRow(definition: CustomTuiAgent): LocalCustomTuiAgent {
|
||||
const { env, ...definitionWithoutEnv } = definition
|
||||
return {
|
||||
status: 'ready',
|
||||
definition: definitionWithoutEnv,
|
||||
envSummary: {
|
||||
entryCount: Object.keys(env).length,
|
||||
bytes: measureCustomAgentEnvBytes(env)
|
||||
},
|
||||
availabilityReason: definition.commandOverride
|
||||
? 'configured-executable'
|
||||
: hasCustomPathOverride(definition)
|
||||
? 'custom-path'
|
||||
: 'baseline-stock'
|
||||
}
|
||||
}
|
||||
|
||||
function localRepairRow(
|
||||
row: CorruptCatalogRow,
|
||||
repairTokens: AgentCatalogRepairTokenRegistry
|
||||
): LocalCustomTuiAgent {
|
||||
return {
|
||||
status: 'repair-required',
|
||||
...(row.id ? { id: row.id } : {}),
|
||||
...(row.baseAgent ? { baseAgent: row.baseAgent } : {}),
|
||||
label: row.label,
|
||||
repairToken: repairTokens.tokenFor(row),
|
||||
issues: row.issues.map((issue) => ({
|
||||
// Identity/baseAgent issues map onto the repair-issue DTO field names.
|
||||
field: issue.field,
|
||||
reason: issue.reason,
|
||||
...(issue.envEntryIndex !== undefined ? { envEntryIndex: issue.envEntryIndex } : {})
|
||||
})),
|
||||
rawBytes: row.rawBytes,
|
||||
draftAvailability: row.rawBytes > MAX_LOCAL_AGENT_DRAFT_BYTES ? 'too-large' : 'available'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalAgentCatalogSnapshot(
|
||||
settings: GlobalSettings,
|
||||
repairTokens: AgentCatalogRepairTokenRegistry,
|
||||
catalog: AgentCatalog = normalizeCatalogFromSettings(settings)
|
||||
): LocalAgentCatalogSnapshot {
|
||||
const revision = settings.agentCatalogRevision ?? 1
|
||||
const customAgents: LocalCustomTuiAgent[] = []
|
||||
for (const definition of catalog.liveCustomAgents) {
|
||||
customAgents.push(localReadyRow(definition))
|
||||
}
|
||||
for (const row of catalog.repairRequiredById.values()) {
|
||||
customAgents.push(localRepairRow(row, repairTokens))
|
||||
}
|
||||
for (const row of catalog.corruptRows) {
|
||||
customAgents.push(localRepairRow(row, repairTokens))
|
||||
}
|
||||
const repairIssues = customAgents.flatMap((row) =>
|
||||
row.status === 'repair-required' ? row.issues : []
|
||||
)
|
||||
return {
|
||||
version: 1,
|
||||
revision,
|
||||
defaultAgent: catalog.defaultAgent,
|
||||
disabledAgents: [...catalog.disabledAgents],
|
||||
customAgents,
|
||||
deletedCustomAgents: [...catalog.tombstonesById.values()],
|
||||
repairIssues,
|
||||
projection: measureAgentCatalogProjection(settings, catalog),
|
||||
localStorage: measureLocalAgentCatalogStorage(settings)
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy `settings.defaultTuiAgent` projection for pre-catalog clients: an old
|
||||
* client must never receive a custom id (it cannot represent it) nor legacy
|
||||
* null for anything but Auto (null meant auto-launch). A custom, tombstoned,
|
||||
* or repair-needed default projects Blank — never its base — so a safe custom
|
||||
* default cannot become a built-in launch inheriting global/YOLO args. */
|
||||
export function projectLegacyDefaultTuiAgent(
|
||||
defaultAgent: TuiAgent | 'auto' | 'blank' | null | undefined
|
||||
): TuiAgent | 'blank' | null {
|
||||
if (defaultAgent === 'auto') {
|
||||
return null
|
||||
}
|
||||
if (defaultAgent === 'blank' || defaultAgent === null || defaultAgent === undefined) {
|
||||
return 'blank'
|
||||
}
|
||||
return isBuiltInTuiAgent(defaultAgent) ? defaultAgent : 'blank'
|
||||
}
|
||||
|
||||
/** Legacy disabled-list projection: omit custom ids an old client cannot render. */
|
||||
export function projectLegacyDisabledTuiAgents(disabled: readonly TuiAgent[]): TuiAgent[] {
|
||||
return disabled.filter((agent) => isBuiltInTuiAgent(agent))
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CustomTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types'
|
||||
import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot'
|
||||
import { normalizeAgentCatalog } from '../../shared/custom-tui-agents'
|
||||
import {
|
||||
AgentCatalogRepairTokenRegistry,
|
||||
applyAgentCatalogMutation,
|
||||
type ApplyAgentCatalogMutationArgs
|
||||
} from './agent-catalog-mutations'
|
||||
|
||||
const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd'
|
||||
|
||||
function customId(base: string, uuid = UUID_A): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${uuid}` as CustomTuiAgentId
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<CustomTuiAgent> = {}): CustomTuiAgent {
|
||||
return {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'My Codex',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function draft(overrides: Partial<CustomAgentDraft> = {}): CustomAgentDraft {
|
||||
return {
|
||||
label: 'New Agent',
|
||||
commandOverride: null,
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
defaultTuiAgent: 'auto',
|
||||
disabledTuiAgents: [],
|
||||
customTuiAgents: [],
|
||||
deletedCustomTuiAgents: [],
|
||||
agentCatalogRevision: 5,
|
||||
agentCmdOverrides: {},
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
function apply(
|
||||
overrides: Partial<ApplyAgentCatalogMutationArgs> & {
|
||||
mutation: ApplyAgentCatalogMutationArgs['request']['mutation']
|
||||
expectedRevision?: number
|
||||
}
|
||||
) {
|
||||
const { mutation, expectedRevision, ...rest } = overrides
|
||||
return applyAgentCatalogMutation({
|
||||
settings: settingsWith(),
|
||||
currentRevision: 5,
|
||||
repairTokens: new AgentCatalogRepairTokenRegistry(),
|
||||
countTombstoneReferences: () => 0,
|
||||
...rest,
|
||||
request: { expectedRevision: expectedRevision ?? 5, mutation }
|
||||
})
|
||||
}
|
||||
|
||||
function corruptRowsOf(settings: GlobalSettings) {
|
||||
return normalizeAgentCatalog({
|
||||
customTuiAgents: settings.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
defaultTuiAgent: settings.defaultTuiAgent
|
||||
}).catalog.corruptRows
|
||||
}
|
||||
|
||||
describe('repair-corrupt', () => {
|
||||
function corruptSettings() {
|
||||
// A malformed id cannot be addressed by id: identity-empty corrupt row.
|
||||
const malformed = {
|
||||
id: 'custom-agent:codex:not-a-uuid',
|
||||
baseAgent: 'codex',
|
||||
label: 'Bad',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false
|
||||
}
|
||||
return settingsWith({ customTuiAgents: [malformed as unknown as CustomTuiAgent] })
|
||||
}
|
||||
|
||||
it('discard removes only the selected physical row', () => {
|
||||
const settings = corruptSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
expect(rows).toHaveLength(1)
|
||||
const token = registry.tokenFor(rows[0])
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: { kind: 'repair-corrupt', repairToken: token, action: { kind: 'discard' } }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents).toEqual([])
|
||||
})
|
||||
|
||||
it('replace mints a new id in place and never tombstones the untrusted old id', () => {
|
||||
const settings = corruptSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const token = registry.tokenFor(corruptRowsOf(settings)[0])
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'repair-corrupt',
|
||||
repairToken: token,
|
||||
action: { kind: 'replace', baseAgent: 'claude', draft: draft({ label: 'Replaced' }) }
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents).toHaveLength(1)
|
||||
expect(result.patch.customTuiAgents?.[0]).toMatchObject({
|
||||
baseAgent: 'claude',
|
||||
label: 'Replaced'
|
||||
})
|
||||
expect(result.patch.customTuiAgents?.[0].id).toBe(result.mintedId)
|
||||
expect(result.patch.deletedCustomTuiAgents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects stale tokens without writing', () => {
|
||||
const settings = corruptSettings()
|
||||
const result = apply({
|
||||
settings,
|
||||
mutation: { kind: 'repair-corrupt', repairToken: 'stale', action: { kind: 'discard' } }
|
||||
})
|
||||
expect(result).toEqual({ ok: false, code: 'stale_agent_repair_token' })
|
||||
})
|
||||
|
||||
it('rejects single-row repair for duplicate-id rows', () => {
|
||||
const id = customId('codex')
|
||||
const settings = settingsWith({
|
||||
customTuiAgents: [liveAgent({ id, label: 'One' }), liveAgent({ id, label: 'Two' })]
|
||||
})
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
const token = registry.tokenFor(rows[0])
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: { kind: 'repair-corrupt', repairToken: token, action: { kind: 'discard' } }
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, reason: 'duplicate_id' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolve-duplicate-id', () => {
|
||||
const id = customId('codex')
|
||||
function duplicateSettings() {
|
||||
return settingsWith({
|
||||
customTuiAgents: [liveAgent({ id, label: 'One' }), liveAgent({ id, label: 'Two' })]
|
||||
})
|
||||
}
|
||||
|
||||
it('commits the whole group atomically with at most one kept canonical row', () => {
|
||||
const settings = duplicateSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
expect(rows).toHaveLength(2)
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[0]),
|
||||
action: {
|
||||
kind: 'keep-for-existing-references',
|
||||
repairedDraft: draft({ label: 'Kept' })
|
||||
}
|
||||
},
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[1]),
|
||||
action: { kind: 'replace', baseAgent: 'codex', draft: draft({ label: 'Split Off' }) }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const live = result.patch.customTuiAgents ?? []
|
||||
expect(live).toHaveLength(2)
|
||||
expect(live[0]).toMatchObject({ id, label: 'Kept' })
|
||||
expect(live[1].id).not.toBe(id)
|
||||
expect(live[1]).toMatchObject({ label: 'Split Off' })
|
||||
})
|
||||
|
||||
it('allows resolving with no kept row, leaving the old id unknown', () => {
|
||||
const settings = duplicateSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [
|
||||
{ repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } },
|
||||
{ repairToken: registry.tokenFor(rows[1]), action: { kind: 'discard' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.patch.customTuiAgents).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an incomplete group, repeated tokens, or two keeps', () => {
|
||||
const settings = duplicateSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
const incomplete = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [{ repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } }]
|
||||
}
|
||||
})
|
||||
expect(incomplete).toEqual({ ok: false, code: 'stale_agent_repair_token' })
|
||||
|
||||
const repeated = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [
|
||||
{ repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } },
|
||||
{ repairToken: registry.tokenFor(rows[0]), action: { kind: 'discard' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(repeated).toEqual({ ok: false, code: 'stale_agent_repair_token' })
|
||||
|
||||
const twoKeeps = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[0]),
|
||||
action: { kind: 'keep-for-existing-references', repairedDraft: draft({ label: 'A' }) }
|
||||
},
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[1]),
|
||||
action: { kind: 'keep-for-existing-references', repairedDraft: draft({ label: 'B' }) }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(twoKeeps).toMatchObject({ ok: false, code: 'invalid_agent_field' })
|
||||
})
|
||||
|
||||
it('applies nothing when one row in the group is invalid (oracle 36)', () => {
|
||||
// Failure-side atomicity: the first row is fully valid and would be kept, but
|
||||
// the second row's draft is invalid. The mutation must reject wholesale with
|
||||
// no patch — the valid row's mid-loop accumulation is never committed.
|
||||
const settings = duplicateSettings()
|
||||
const registry = new AgentCatalogRepairTokenRegistry()
|
||||
const rows = corruptRowsOf(settings)
|
||||
const result = apply({
|
||||
settings,
|
||||
repairTokens: registry,
|
||||
mutation: {
|
||||
kind: 'resolve-duplicate-id',
|
||||
duplicateId: id,
|
||||
rows: [
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[0]),
|
||||
action: {
|
||||
kind: 'keep-for-existing-references',
|
||||
repairedDraft: draft({ label: 'Kept' })
|
||||
}
|
||||
},
|
||||
{
|
||||
repairToken: registry.tokenFor(rows[1]),
|
||||
action: { kind: 'replace', baseAgent: 'codex', draft: draft({ label: '' }) }
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
// field:'label' pins the failure to row1's draft validation, not row0's
|
||||
// parsedBase guard (which returns invalid_agent_field with no field) — so this
|
||||
// can only pass if row0 was accepted mid-loop and then discarded on reject.
|
||||
expect(result).toMatchObject({ ok: false, code: 'invalid_agent_field', field: 'label' })
|
||||
expect((result as { patch?: unknown }).patch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,232 @@
|
||||
// Corrupt-row repair mutations and the repair-token registry. Repair tokens are
|
||||
// revision-scoped, per-physical-record handles; duplicate-id groups resolve
|
||||
// atomically. Never persisted, synced, or logged.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BuiltInTuiAgent, CustomTuiAgent, CustomTuiAgentId } from '../../shared/types'
|
||||
import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot'
|
||||
import {
|
||||
mintCustomTuiAgentId,
|
||||
normalizeAgentLabelKey,
|
||||
type CorruptCatalogRow
|
||||
} from '../../shared/custom-tui-agents'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
draftToDefinition,
|
||||
labelCollides,
|
||||
validateDraft,
|
||||
type AgentCatalogMutationApplication
|
||||
} from './agent-catalog-draft-validation'
|
||||
import type { MutationContext } from './agent-catalog-mutations'
|
||||
|
||||
/** Repair tokens are minted per physical corrupt record and stay stable while
|
||||
* that record (content and position) is unchanged, so editor focus/drafts do
|
||||
* not remount on unrelated revisions. They are never persisted, synced, or
|
||||
* logged, and resolve only with the exact current catalog revision. */
|
||||
export class AgentCatalogRepairTokenRegistry {
|
||||
private readonly tokensByRecordKey = new Map<string, string>()
|
||||
|
||||
private recordKey(row: CorruptCatalogRow): string {
|
||||
const contentHash = createHash('sha256')
|
||||
.update(JSON.stringify(row.raw) ?? 'null')
|
||||
.digest('hex')
|
||||
return `${contentHash}:${row.physicalIndex}`
|
||||
}
|
||||
|
||||
tokenFor(row: CorruptCatalogRow): string {
|
||||
const key = this.recordKey(row)
|
||||
const existing = this.tokensByRecordKey.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const token = createHash('sha256')
|
||||
.update(`${key}:${crypto.randomUUID()}`)
|
||||
.digest('hex')
|
||||
.slice(0, 32)
|
||||
this.tokensByRecordKey.set(key, token)
|
||||
return token
|
||||
}
|
||||
|
||||
resolve(token: string, rows: readonly CorruptCatalogRow[]): CorruptCatalogRow | null {
|
||||
for (const row of rows) {
|
||||
if (this.tokenFor(row) === token) {
|
||||
return row
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type RepairContext = MutationContext & { repairTokens: AgentCatalogRepairTokenRegistry }
|
||||
|
||||
export function applyRepairCorrupt(
|
||||
repairToken: string,
|
||||
action:
|
||||
| { kind: 'discard' }
|
||||
| { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft },
|
||||
context: RepairContext
|
||||
): AgentCatalogMutationApplication {
|
||||
const row = context.repairTokens.resolve(repairToken, context.catalog.corruptRows)
|
||||
if (!row) {
|
||||
return { ok: false, code: 'stale_agent_repair_token' }
|
||||
}
|
||||
// Duplicate-id rows reject single-row repair: the group must resolve at once.
|
||||
if (row.issues.some((issue) => issue.reason === 'duplicate_id')) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'duplicate_id' }
|
||||
}
|
||||
const nextLive = [...context.persistedLive]
|
||||
if (row.physicalIndex < 0 || row.physicalIndex >= nextLive.length) {
|
||||
return { ok: false, code: 'stale_agent_repair_token' }
|
||||
}
|
||||
if (action.kind === 'discard') {
|
||||
nextLive.splice(row.physicalIndex, 1)
|
||||
return {
|
||||
ok: true,
|
||||
patch: {
|
||||
customTuiAgents: nextLive as CustomTuiAgent[],
|
||||
agentCatalogRevision: context.newRevision
|
||||
},
|
||||
newRevision: context.newRevision,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
if (!isBuiltInTuiAgent(action.baseAgent)) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const draftError = validateDraft(action.draft)
|
||||
if (draftError) {
|
||||
return draftError
|
||||
}
|
||||
const retained = context.persistedTombstones.filter(
|
||||
(tombstone) => context.args.countTombstoneReferences(tombstone.id) !== 0
|
||||
)
|
||||
const candidateKey = normalizeAgentLabelKey(action.draft.label)
|
||||
if (labelCollides(candidateKey, context.catalog, retained)) {
|
||||
return { ok: false, code: 'duplicate_agent_label', field: 'label' }
|
||||
}
|
||||
// Replace mints a new canonical id in the same physical slot for stable
|
||||
// visual order; it never creates a tombstone for the untrusted old id and
|
||||
// never rebinds any reference.
|
||||
const id = mintCustomTuiAgentId(action.baseAgent)
|
||||
nextLive.splice(row.physicalIndex, 1, draftToDefinition(id, action.baseAgent, action.draft))
|
||||
return {
|
||||
ok: true,
|
||||
patch: {
|
||||
customTuiAgents: nextLive as CustomTuiAgent[],
|
||||
agentCatalogRevision: context.newRevision
|
||||
},
|
||||
newRevision: context.newRevision,
|
||||
mintedId: id,
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
|
||||
export function applyResolveDuplicateId(
|
||||
duplicateId: CustomTuiAgentId,
|
||||
rows: readonly {
|
||||
repairToken: string
|
||||
action:
|
||||
| { kind: 'keep-for-existing-references'; repairedDraft: CustomAgentDraft }
|
||||
| { kind: 'discard' }
|
||||
| { kind: 'replace'; baseAgent: BuiltInTuiAgent; draft: CustomAgentDraft }
|
||||
}[],
|
||||
context: RepairContext
|
||||
): AgentCatalogMutationApplication {
|
||||
const groupRows = context.catalog.corruptRows.filter(
|
||||
(row) => row.id === duplicateId && row.issues.some((issue) => issue.reason === 'duplicate_id')
|
||||
)
|
||||
if (groupRows.length === 0) {
|
||||
return { ok: false, code: 'stale_agent_repair_token' }
|
||||
}
|
||||
// The submitted tokens must cover the exact current duplicate group once each.
|
||||
const resolved = new Map<CorruptCatalogRow, (typeof rows)[number]>()
|
||||
for (const submitted of rows) {
|
||||
const row = context.repairTokens.resolve(submitted.repairToken, groupRows)
|
||||
if (!row || resolved.has(row)) {
|
||||
return { ok: false, code: 'stale_agent_repair_token' }
|
||||
}
|
||||
resolved.set(row, submitted)
|
||||
}
|
||||
if (resolved.size !== groupRows.length) {
|
||||
return { ok: false, code: 'stale_agent_repair_token' }
|
||||
}
|
||||
const keeps = rows.filter((row) => row.action.kind === 'keep-for-existing-references')
|
||||
if (keeps.length > 1) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'duplicate_id' }
|
||||
}
|
||||
|
||||
const parsedBase = context.catalog.corruptRows.find((row) => row.id === duplicateId)?.baseAgent
|
||||
const replacements = new Map<number, CustomTuiAgent | null>()
|
||||
let mintedId: CustomTuiAgentId | undefined
|
||||
const retained = context.persistedTombstones.filter(
|
||||
(tombstone) => context.args.countTombstoneReferences(tombstone.id) !== 0
|
||||
)
|
||||
const pendingLabels: string[] = []
|
||||
for (const [row, submitted] of resolved) {
|
||||
if (submitted.action.kind === 'discard') {
|
||||
replacements.set(row.physicalIndex, null)
|
||||
continue
|
||||
}
|
||||
if (submitted.action.kind === 'keep-for-existing-references') {
|
||||
if (!parsedBase) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const draftError = validateDraft(submitted.action.repairedDraft)
|
||||
if (draftError) {
|
||||
return draftError
|
||||
}
|
||||
const key = normalizeAgentLabelKey(submitted.action.repairedDraft.label)
|
||||
if (labelCollides(key, context.catalog, retained) || pendingLabels.includes(key)) {
|
||||
return { ok: false, code: 'duplicate_agent_label', field: 'label' }
|
||||
}
|
||||
pendingLabels.push(key)
|
||||
// The kept row preserves the old id only after this explicit choice.
|
||||
replacements.set(
|
||||
row.physicalIndex,
|
||||
draftToDefinition(duplicateId, parsedBase, submitted.action.repairedDraft)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (!isBuiltInTuiAgent(submitted.action.baseAgent)) {
|
||||
return { ok: false, code: 'invalid_agent_field', reason: 'identity_mismatch' }
|
||||
}
|
||||
const draftError = validateDraft(submitted.action.draft)
|
||||
if (draftError) {
|
||||
return draftError
|
||||
}
|
||||
const key = normalizeAgentLabelKey(submitted.action.draft.label)
|
||||
if (labelCollides(key, context.catalog, retained) || pendingLabels.includes(key)) {
|
||||
return { ok: false, code: 'duplicate_agent_label', field: 'label' }
|
||||
}
|
||||
pendingLabels.push(key)
|
||||
const id = mintCustomTuiAgentId(submitted.action.baseAgent)
|
||||
mintedId = id
|
||||
replacements.set(
|
||||
row.physicalIndex,
|
||||
draftToDefinition(id, submitted.action.baseAgent, submitted.action.draft)
|
||||
)
|
||||
}
|
||||
|
||||
const nextLive: unknown[] = []
|
||||
context.persistedLive.forEach((row, index) => {
|
||||
if (!replacements.has(index)) {
|
||||
nextLive.push(row)
|
||||
return
|
||||
}
|
||||
const replacement = replacements.get(index)
|
||||
if (replacement !== null && replacement !== undefined) {
|
||||
nextLive.push(replacement)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
patch: {
|
||||
customTuiAgents: nextLive as CustomTuiAgent[],
|
||||
agentCatalogRevision: context.newRevision
|
||||
},
|
||||
newRevision: context.newRevision,
|
||||
...(mintedId ? { mintedId } : {}),
|
||||
prunedTombstoneIds: []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, afterEach } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
createPinnedPreV1Backup,
|
||||
migrateAgentCatalogSchema,
|
||||
pinnedPreV1BackupPath
|
||||
} from './agent-catalog-schema-migration'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
function makeDataFile(contents: string, mode?: number): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-'))
|
||||
tempDirs.push(dir)
|
||||
const dataFile = join(dir, 'orca-data.json')
|
||||
writeFileSync(dataFile, contents, mode !== undefined ? { mode } : undefined)
|
||||
return dataFile
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('createPinnedPreV1Backup', () => {
|
||||
it('writes the exact raw bytes with matching permissions', () => {
|
||||
const raw = '{"settings":{"defaultTuiAgent":null}}'
|
||||
const dataFile = makeDataFile(raw, 0o600)
|
||||
const result = createPinnedPreV1Backup(dataFile, raw)
|
||||
expect(result).toEqual({ ok: true, created: true })
|
||||
const backupFile = pinnedPreV1BackupPath(dataFile)
|
||||
expect(readFileSync(backupFile, 'utf-8')).toBe(raw)
|
||||
expect(statSync(backupFile).mode & 0o777).toBe(statSync(dataFile).mode & 0o777)
|
||||
})
|
||||
|
||||
it('keeps an existing pinned backup instead of overwriting it', () => {
|
||||
const original = '{"original":true}'
|
||||
const dataFile = makeDataFile(original)
|
||||
expect(createPinnedPreV1Backup(dataFile, original)).toEqual({ ok: true, created: true })
|
||||
const second = createPinnedPreV1Backup(dataFile, '{"newer":true}')
|
||||
expect(second).toEqual({ ok: true, created: false })
|
||||
expect(readFileSync(pinnedPreV1BackupPath(dataFile), 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('fails without leaving a partial backup when the data file is unreadable', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-migration-'))
|
||||
tempDirs.push(dir)
|
||||
const missing = join(dir, 'missing.json')
|
||||
const result = createPinnedPreV1Backup(missing, '{}')
|
||||
expect(result.ok).toBe(false)
|
||||
expect(existsSync(pinnedPreV1BackupPath(missing))).toBe(false)
|
||||
expect(existsSync(`${pinnedPreV1BackupPath(missing)}.tmp`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrateAgentCatalogSchema', () => {
|
||||
it('maps shipped legacy null (and missing) defaults to auto exactly once', () => {
|
||||
for (const legacyDefault of [null, undefined]) {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: legacyDefault === undefined ? {} : { defaultTuiAgent: legacyDefault },
|
||||
preV1RawContents: '{}',
|
||||
createBackup: () => ({ ok: true, created: true })
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(true)
|
||||
expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto')
|
||||
expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(1)
|
||||
expect(outcome.settingsPatch.agentCatalogRevision).toBe(1)
|
||||
expect(outcome.settingsPatch.agentReferenceRevision).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves explicit blank and concrete-id defaults', () => {
|
||||
for (const explicit of ['blank', 'codex'] as const) {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: { defaultTuiAgent: explicit },
|
||||
preV1RawContents: '{}',
|
||||
createBackup: () => ({ ok: true, created: true })
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(true)
|
||||
expect('defaultTuiAgent' in outcome.settingsPatch).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('is idempotent: a second load with v1 stamped is a no-op', () => {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: {
|
||||
agentCatalogSchemaVersion: 1,
|
||||
agentCatalogRevision: 7,
|
||||
agentReferenceRevision: 3,
|
||||
defaultTuiAgent: null
|
||||
},
|
||||
preV1RawContents: '{}',
|
||||
createBackup: () => {
|
||||
throw new Error('backup must not run for a v1 profile')
|
||||
}
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(false)
|
||||
expect(outcome.settingsPatch).toEqual({})
|
||||
// Post-v1 null stays null: repair-needed defaults never become Auto again.
|
||||
})
|
||||
|
||||
it('performs no v1 write when backup creation fails and forces pre-v1 shape', () => {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: { defaultTuiAgent: null },
|
||||
preV1RawContents: '{"settings":{"defaultTuiAgent":null}}',
|
||||
createBackup: () => ({ ok: false, error: 'disk full' })
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(false)
|
||||
expect(outcome.backupError).toBe('disk full')
|
||||
expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBeUndefined()
|
||||
expect(outcome.settingsPatch.agentCatalogRevision).toBeUndefined()
|
||||
expect(outcome.settingsPatch.agentReferenceRevision).toBeUndefined()
|
||||
expect(outcome.settingsPatch.defaultTuiAgent).toBeNull()
|
||||
// The forced patch must explicitly carry the pre-v1 keys so fresh-install
|
||||
// defaults cannot leak through the settings spread.
|
||||
expect('agentCatalogSchemaVersion' in outcome.settingsPatch).toBe(true)
|
||||
expect('customTuiAgents' in outcome.settingsPatch).toBe(true)
|
||||
expect('deletedCustomTuiAgents' in outcome.settingsPatch).toBe(true)
|
||||
})
|
||||
|
||||
it('skips the backup for a fresh install with no persisted file', () => {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: undefined,
|
||||
preV1RawContents: null,
|
||||
createBackup: () => {
|
||||
throw new Error('backup must not run for a fresh install')
|
||||
}
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(true)
|
||||
expect(outcome.settingsPatch.defaultTuiAgent).toBe('auto')
|
||||
expect(outcome.settingsPatch.agentCatalogSchemaVersion).toBe(1)
|
||||
})
|
||||
|
||||
it('normalizes hand-edited negative or non-integer revisions on v1 profiles', () => {
|
||||
const outcome = migrateAgentCatalogSchema({
|
||||
settings: {
|
||||
agentCatalogSchemaVersion: 1,
|
||||
agentCatalogRevision: -5 as number,
|
||||
agentReferenceRevision: 1.5 as number
|
||||
},
|
||||
preV1RawContents: '{}',
|
||||
createBackup: () => ({ ok: true, created: true })
|
||||
})
|
||||
expect(outcome.didMigrate).toBe(true)
|
||||
expect(outcome.settingsPatch.agentCatalogRevision).toBe(1)
|
||||
expect(outcome.settingsPatch.agentReferenceRevision).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
// One-time agent-catalog v1 schema migration: maps the shipped legacy
|
||||
// `defaultTuiAgent: null` (which meant Auto) to the explicit persisted 'auto'
|
||||
// and stamps `agentCatalogSchemaVersion: 1`. Before the first v1 write of an
|
||||
// existing profile, a pinned same-permission pre-v1 backup is created beside
|
||||
// the rotating backups; if that backup cannot be created, no v1 write happens
|
||||
// and launch behavior stays on the clean built-in baseline.
|
||||
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fsyncSync,
|
||||
openSync,
|
||||
renameSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeSync
|
||||
} from 'node:fs'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
export const AGENT_CATALOG_SCHEMA_VERSION = 1
|
||||
|
||||
export function pinnedPreV1BackupPath(dataFile: string): string {
|
||||
return `${dataFile}.pre-agent-catalog-v1.backup`
|
||||
}
|
||||
|
||||
export type PinnedBackupResult = { ok: true; created: boolean } | { ok: false; error: string }
|
||||
|
||||
/** Write the exact pre-v1 raw bytes to the pinned backup with the data file's
|
||||
* permissions, fsync, then atomically rename into place. An existing pinned
|
||||
* backup is kept (a crash between backup and first v1 write must not let a
|
||||
* second attempt overwrite the original pre-v1 state). */
|
||||
export function createPinnedPreV1Backup(dataFile: string, rawContents: string): PinnedBackupResult {
|
||||
const backupFile = pinnedPreV1BackupPath(dataFile)
|
||||
try {
|
||||
if (existsSync(backupFile)) {
|
||||
return { ok: true, created: false }
|
||||
}
|
||||
const mode = statSync(dataFile).mode & 0o777
|
||||
const tmpFile = `${backupFile}.tmp`
|
||||
const fd = openSync(tmpFile, 'w', mode)
|
||||
try {
|
||||
writeSync(fd, rawContents)
|
||||
fsyncSync(fd)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
try {
|
||||
renameSync(tmpFile, backupFile)
|
||||
} catch (error) {
|
||||
try {
|
||||
unlinkSync(tmpFile)
|
||||
} catch {
|
||||
// Best-effort tmp cleanup; the rename failure is the reported error.
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return { ok: true, created: true }
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentCatalogSchemaMigrationOutcome = {
|
||||
/** Patch merged into loaded settings; empty object when nothing changed. */
|
||||
settingsPatch: Partial<GlobalSettings>
|
||||
didMigrate: boolean
|
||||
/** Present when the pinned backup failed; the profile stays pre-v1 and Settings
|
||||
* must surface a local migration error. */
|
||||
backupError?: string
|
||||
}
|
||||
|
||||
function normalizeRevision(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback
|
||||
}
|
||||
|
||||
/** Compute the one-time v1 migration for loaded settings. Pure except for the
|
||||
* injected backup step; a second load with v1 already stamped is a no-op. */
|
||||
export function migrateAgentCatalogSchema(args: {
|
||||
settings: Partial<GlobalSettings> | undefined
|
||||
/** Null for a fresh install with no persisted file (no pre-v1 state to pin). */
|
||||
preV1RawContents: string | null
|
||||
createBackup: () => PinnedBackupResult
|
||||
}): AgentCatalogSchemaMigrationOutcome {
|
||||
const settings = args.settings
|
||||
const currentVersion = normalizeRevision(settings?.agentCatalogSchemaVersion, 0)
|
||||
if (currentVersion >= AGENT_CATALOG_SCHEMA_VERSION) {
|
||||
// Revisions must remain monotonic non-negative integers even if hand-edited.
|
||||
const catalogRevision = normalizeRevision(settings?.agentCatalogRevision, 1)
|
||||
const referenceRevision = normalizeRevision(settings?.agentReferenceRevision, 1)
|
||||
const patch: Partial<GlobalSettings> = {}
|
||||
let didMigrate = false
|
||||
if (settings?.agentCatalogRevision !== catalogRevision) {
|
||||
patch.agentCatalogRevision = catalogRevision
|
||||
didMigrate = true
|
||||
}
|
||||
if (settings?.agentReferenceRevision !== referenceRevision) {
|
||||
patch.agentReferenceRevision = referenceRevision
|
||||
didMigrate = true
|
||||
}
|
||||
return { settingsPatch: patch, didMigrate }
|
||||
}
|
||||
|
||||
if (args.preV1RawContents !== null) {
|
||||
const backup = args.createBackup()
|
||||
if (!backup.ok) {
|
||||
// No v1 write of any kind: force the merged settings back to the exact
|
||||
// pre-v1 shape so the fresh-install defaults (schema version, 'auto',
|
||||
// empty catalog arrays) cannot leak through the defaults spread.
|
||||
return {
|
||||
settingsPatch: {
|
||||
agentCatalogSchemaVersion: undefined,
|
||||
agentCatalogRevision: undefined,
|
||||
agentReferenceRevision: undefined,
|
||||
customTuiAgents: settings?.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings?.deletedCustomTuiAgents,
|
||||
defaultTuiAgent: settings?.defaultTuiAgent ?? null
|
||||
},
|
||||
didMigrate: false,
|
||||
backupError: backup.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Partial<GlobalSettings> = {
|
||||
agentCatalogSchemaVersion: AGENT_CATALOG_SCHEMA_VERSION,
|
||||
agentCatalogRevision: 1,
|
||||
agentReferenceRevision: 1
|
||||
}
|
||||
// Shipped legacy null meant Auto. This mapping runs exactly once, before any
|
||||
// repair can produce a new null; later repair-generated null stays null.
|
||||
const rawDefault = settings?.defaultTuiAgent
|
||||
if (rawDefault === null || rawDefault === undefined) {
|
||||
patch.defaultTuiAgent = 'auto'
|
||||
}
|
||||
return { settingsPatch: patch, didMigrate: true }
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
GlobalSettings,
|
||||
Repo,
|
||||
TerminalAgentQuickCommand,
|
||||
TuiAgent,
|
||||
WorktreeMeta
|
||||
} from '../../shared/types'
|
||||
import type { Automation, AutomationRun } from '../../shared/automations-types'
|
||||
import type { Store } from '../persistence'
|
||||
import { AgentCatalogService } from './agent-catalog-service'
|
||||
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
|
||||
import type { HostSessionLaunchRecord } from './agent-session-record-store'
|
||||
import { getHostBackgroundAgentLaunchStore } from './background-agent-launch-store-host'
|
||||
|
||||
const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd'
|
||||
const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321'
|
||||
|
||||
function customId(base: string, uuid = UUID_A): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${uuid}` as CustomTuiAgentId
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<CustomTuiAgent> = {}): CustomTuiAgent {
|
||||
return {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'My Codex',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type StoreStubState = {
|
||||
settings: GlobalSettings
|
||||
repos: Repo[]
|
||||
automations: Automation[]
|
||||
automationRuns?: AutomationRun[]
|
||||
worktreeMeta?: Record<string, WorktreeMeta>
|
||||
failAutomationScan?: boolean
|
||||
failWorktreeScan?: boolean
|
||||
}
|
||||
|
||||
function makeStoreStub(state: StoreStubState): Store {
|
||||
const stub = {
|
||||
getSettings: () => state.settings,
|
||||
updateSettings: (updates: Partial<GlobalSettings>) => {
|
||||
state.settings = { ...state.settings, ...updates }
|
||||
return state.settings
|
||||
},
|
||||
getRepos: () => state.repos,
|
||||
listAutomations: () => {
|
||||
if (state.failAutomationScan) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
return state.automations
|
||||
},
|
||||
listAutomationRuns: () => state.automationRuns ?? [],
|
||||
getAllWorktreeMeta: () => {
|
||||
if (state.failWorktreeScan) {
|
||||
throw new Error('store unavailable')
|
||||
}
|
||||
return state.worktreeMeta ?? {}
|
||||
}
|
||||
}
|
||||
return stub as unknown as Store
|
||||
}
|
||||
|
||||
function baseSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
defaultTuiAgent: 'auto',
|
||||
disabledTuiAgents: [],
|
||||
customTuiAgents: [],
|
||||
deletedCustomTuiAgents: [],
|
||||
agentCatalogRevision: 1,
|
||||
agentReferenceRevision: 1,
|
||||
terminalQuickCommands: [],
|
||||
agentCmdOverrides: {},
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
function tombstoneFor(id: CustomTuiAgentId) {
|
||||
return { id, baseAgent: 'codex' as const, label: 'Gone', deletedAt: 1 }
|
||||
}
|
||||
|
||||
function agentQuickCommand(agent: CustomTuiAgentId): TerminalAgentQuickCommand {
|
||||
return { id: 'qc-1', label: 'Q', action: 'agent-prompt', agent, prompt: 'p' }
|
||||
}
|
||||
|
||||
describe('tombstone reference GC across owners', () => {
|
||||
const deadId = customId('codex', UUID_B)
|
||||
|
||||
function serviceWith(state: Partial<StoreStubState>): {
|
||||
service: AgentCatalogService
|
||||
state: StoreStubState
|
||||
} {
|
||||
const fullState: StoreStubState = {
|
||||
settings: baseSettings(),
|
||||
repos: [],
|
||||
automations: [],
|
||||
...state
|
||||
}
|
||||
return { service: new AgentCatalogService(makeStoreStub(fullState)), state: fullState }
|
||||
}
|
||||
|
||||
it('retains the tombstone while the default references it and prunes after the last reference clears', () => {
|
||||
const { service, state } = serviceWith({
|
||||
settings: baseSettings({
|
||||
defaultTuiAgent: deadId,
|
||||
deletedCustomTuiAgents: [tombstoneFor(deadId)]
|
||||
})
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1)
|
||||
|
||||
// Create with prune: tombstone retained because the default still points at it.
|
||||
const created = service.mutate({
|
||||
expectedRevision: 1,
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'claude',
|
||||
draft: { label: 'Other', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(created.ok).toBe(true)
|
||||
expect(state.settings.deletedCustomTuiAgents).toHaveLength(1)
|
||||
|
||||
// Clear the default (last reference), then the next prune removes it.
|
||||
const cleared = service.mutate({
|
||||
expectedRevision: state.settings.agentCatalogRevision ?? 1,
|
||||
mutation: { kind: 'set-default', agent: 'auto' }
|
||||
})
|
||||
expect(cleared.ok).toBe(true)
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0)
|
||||
const created2 = service.mutate({
|
||||
expectedRevision: state.settings.agentCatalogRevision ?? 1,
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'gemini',
|
||||
draft: { label: 'Another', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(created2.ok).toBe(true)
|
||||
expect(state.settings.deletedCustomTuiAgents).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('counts quick-command, commit-message, source-control (global and repo), and automation references', () => {
|
||||
const { service } = serviceWith({
|
||||
settings: baseSettings({
|
||||
terminalQuickCommands: [agentQuickCommand(deadId)],
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: deadId,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
},
|
||||
sourceControlAi: {
|
||||
enabled: true,
|
||||
agentId: deadId,
|
||||
actions: { 'commit-message': { agentId: deadId, commandInputTemplate: '' } },
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand: '',
|
||||
instructionsByOperation: {}
|
||||
} as GlobalSettings['sourceControlAi'],
|
||||
deletedCustomTuiAgents: [tombstoneFor(deadId)]
|
||||
}),
|
||||
repos: [
|
||||
{
|
||||
id: 'repo-1',
|
||||
sourceControlAi: {
|
||||
actionOverrides: { 'pr-review': { agentId: deadId, commandInputTemplate: '' } }
|
||||
}
|
||||
} as unknown as Repo
|
||||
],
|
||||
automations: [{ id: 'auto-1', agentId: deadId } as unknown as Automation]
|
||||
})
|
||||
// quick-command 1 + commit-message agentId 1 + sourceControlAi agentId 1 +
|
||||
// action recipe 1 + repo override 1 + automation 1 = 6
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(6)
|
||||
const summary = service.getReferenceSummaries(deadId)
|
||||
expect(summary).toContainEqual({ owner: 'quick-command', count: 1 })
|
||||
expect(summary).toContainEqual({ owner: 'commit-message', count: 2 })
|
||||
expect(summary).toContainEqual({ owner: 'source-control-recipe', count: 2 })
|
||||
expect(summary).toContainEqual({ owner: 'automation', count: 1 })
|
||||
})
|
||||
|
||||
it('retains via a run launch-failure even after the definition agent changed, and prunes after the run clears', () => {
|
||||
// The automation definition points at a live agent now, but a past run's
|
||||
// structured launch failure still references the deleted custom id — the
|
||||
// tombstone must stay retained until that run record is gone too.
|
||||
const { service, state } = serviceWith({
|
||||
settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }),
|
||||
automations: [{ id: 'auto-1', agentId: 'claude' } as unknown as Automation],
|
||||
automationRuns: [
|
||||
{
|
||||
id: 'run-1',
|
||||
agentLaunchFailure: {
|
||||
version: 1,
|
||||
code: 'base_agent_disabled',
|
||||
requestedAgent: deadId,
|
||||
failureId: 'rf-1',
|
||||
intent: 'automation',
|
||||
occurredAt: 1
|
||||
}
|
||||
} as unknown as AutomationRun
|
||||
]
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1)
|
||||
expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'automation', count: 1 })
|
||||
|
||||
state.automationRuns = []
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0)
|
||||
})
|
||||
|
||||
it('counts workspace pending-launch and durable-failure references and prunes after the last clears', () => {
|
||||
const { service, state } = serviceWith({
|
||||
settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }),
|
||||
worktreeMeta: {
|
||||
'wt-1': {
|
||||
pendingAgentLaunch: { operationId: 'op-1', requestedAgent: deadId }
|
||||
} as unknown as WorktreeMeta,
|
||||
'wt-2': {
|
||||
agentLaunchFailure: {
|
||||
version: 1,
|
||||
code: 'spawn_failed',
|
||||
requestedAgent: deadId,
|
||||
failureId: 'f-1',
|
||||
intent: 'interactive',
|
||||
occurredAt: 1
|
||||
}
|
||||
} as unknown as WorktreeMeta
|
||||
}
|
||||
})
|
||||
// pendingAgentLaunch.requestedAgent + agentLaunchFailure.requestedAgent = 2.
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(2)
|
||||
expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'workspace', count: 2 })
|
||||
|
||||
// Last reference cleared -> the tombstone can prune.
|
||||
state.worktreeMeta = {}
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the tombstone when the workspace store is unavailable', () => {
|
||||
const { service } = serviceWith({
|
||||
settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }),
|
||||
failWorktreeScan: true
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('treats an unavailable owner store as unknown and retains the tombstone', () => {
|
||||
const { service, state } = serviceWith({
|
||||
settings: baseSettings({
|
||||
deletedCustomTuiAgents: [tombstoneFor(deadId)]
|
||||
}),
|
||||
failAutomationScan: true
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown')
|
||||
const created = service.mutate({
|
||||
expectedRevision: 1,
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'claude',
|
||||
draft: { label: 'New One', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(created.ok).toBe(true)
|
||||
expect(state.settings.deletedCustomTuiAgents).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reference removal prunes the tombstone and advances both revisions', () => {
|
||||
const { service, state } = serviceWith({
|
||||
settings: baseSettings({
|
||||
terminalQuickCommands: [agentQuickCommand(deadId)],
|
||||
deletedCustomTuiAgents: [tombstoneFor(deadId)]
|
||||
})
|
||||
})
|
||||
const result = service.mutateReferences({
|
||||
expectedReferenceRevision: 1,
|
||||
mutation: { kind: 'quick-command-delete', id: 'qc-1' }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.referenceRevision).toBe(2)
|
||||
// Tombstone pruned in the follow-up catalog write with its own revision bump.
|
||||
expect(state.settings.deletedCustomTuiAgents).toHaveLength(0)
|
||||
expect(state.settings.agentCatalogRevision).toBe(2)
|
||||
expect(result.catalogRevision).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session owner (host-private resume records)', () => {
|
||||
const recordStore = getHostAgentSessionRecordStore()
|
||||
const deadId = customId('codex', UUID_B)
|
||||
|
||||
afterEach(() => {
|
||||
// The record store is a host-wide singleton; clear seeded records between tests.
|
||||
recordStore.rebuildRecordsFrom([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function sessionRecord(requestedAgent: TuiAgent): HostSessionLaunchRecord {
|
||||
return {
|
||||
worktreeId: 'wt-session',
|
||||
requestedAgent,
|
||||
baseAgent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
registeredAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function serviceWithTombstone(): AgentCatalogService {
|
||||
const state: StoreStubState = {
|
||||
settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }),
|
||||
repos: [],
|
||||
automations: []
|
||||
}
|
||||
return new AgentCatalogService(makeStoreStub(state))
|
||||
}
|
||||
|
||||
it('retains the tombstone while a resumable session references the custom id and prunes after it is forgotten', () => {
|
||||
const service = serviceWithTombstone()
|
||||
|
||||
recordStore.rebuildRecordsFrom([sessionRecord(deadId)])
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1)
|
||||
expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'session', count: 1 })
|
||||
|
||||
// Forgetting the last referencing session clears the reference so it can prune.
|
||||
recordStore.rebuildRecordsFrom([])
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the tombstone when the session record store cannot be read', () => {
|
||||
const service = serviceWithTombstone()
|
||||
vi.spyOn(recordStore, 'referencedRequestedAgents').mockImplementation(() => {
|
||||
throw new Error('store unavailable')
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('background owner (host-private generic launch attempts)', () => {
|
||||
const attemptStore = getHostBackgroundAgentLaunchStore()
|
||||
const deadId = customId('codex', UUID_B)
|
||||
|
||||
afterEach(() => {
|
||||
// Host-wide singleton; clear seeded attempts between tests.
|
||||
attemptStore.rebuildFrom([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function serviceWithTombstone(): AgentCatalogService {
|
||||
const state: StoreStubState = {
|
||||
settings: baseSettings({ deletedCustomTuiAgents: [tombstoneFor(deadId)] }),
|
||||
repos: [],
|
||||
automations: []
|
||||
}
|
||||
return new AgentCatalogService(makeStoreStub(state))
|
||||
}
|
||||
|
||||
it('retains the tombstone while a background attempt references the custom id and prunes after it is gone', () => {
|
||||
const service = serviceWithTombstone()
|
||||
|
||||
attemptStore.create({
|
||||
attemptId: 'attempt-dead',
|
||||
worktreeId: 'wt-bg',
|
||||
operationId: 'op-1',
|
||||
requestedAgent: deadId,
|
||||
baseAgent: 'codex'
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(1)
|
||||
expect(service.getReferenceSummaries(deadId)).toContainEqual({ owner: 'background', count: 1 })
|
||||
|
||||
// Pruning the last referencing attempt clears the reference.
|
||||
attemptStore.rebuildFrom([])
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the tombstone when the background attempt store cannot be read', () => {
|
||||
const service = serviceWithTombstone()
|
||||
vi.spyOn(attemptStore, 'referencedRequestedAgents').mockImplementation(() => {
|
||||
throw new Error('store unavailable')
|
||||
})
|
||||
expect(service.tombstoneReferenceIndex.countReferences(deadId)).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('base-disable impact (§973)', () => {
|
||||
const recordStore = getHostAgentSessionRecordStore()
|
||||
const derivId = customId('claude', UUID_A)
|
||||
const otherBaseId = customId('codex', UUID_B)
|
||||
|
||||
afterEach(() => {
|
||||
recordStore.rebuildRecordsFrom([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function sessionRecord(
|
||||
baseAgent: 'claude' | 'codex',
|
||||
requestedAgent: TuiAgent,
|
||||
sessionId: string
|
||||
): HostSessionLaunchRecord {
|
||||
return {
|
||||
worktreeId: 'wt-impact',
|
||||
requestedAgent,
|
||||
baseAgent,
|
||||
// Both claude and codex key on 'session_id' (only antigravity differs); the
|
||||
// key value is irrelevant here — countRecordsByBase reads baseAgent only.
|
||||
providerSession: { key: 'session_id', id: sessionId },
|
||||
registeredAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function impactService(state: Partial<StoreStubState> = {}): AgentCatalogService {
|
||||
const fullState: StoreStubState = {
|
||||
settings: baseSettings({
|
||||
defaultTuiAgent: 'claude',
|
||||
customTuiAgents: [
|
||||
liveAgent({ id: derivId, baseAgent: 'claude', label: 'Claude Deriv' }),
|
||||
liveAgent({ id: otherBaseId, baseAgent: 'codex', label: 'Codex Custom' })
|
||||
],
|
||||
terminalQuickCommands: [
|
||||
agentQuickCommand(derivId),
|
||||
{ id: 'qc-2', label: 'Q2', action: 'agent-prompt', agent: 'codex', prompt: 'p' }
|
||||
]
|
||||
}),
|
||||
repos: [],
|
||||
automations: [],
|
||||
...state
|
||||
}
|
||||
return new AgentCatalogService(makeStoreStub(fullState))
|
||||
}
|
||||
|
||||
it('counts base-direct + derivative saved references (excluding sessions) and resumable sessions by base', () => {
|
||||
const service = impactService()
|
||||
// A claude session on the derivative is counted as a session, NOT double-counted
|
||||
// under savedReferences; a codex session on a different base is ignored for claude.
|
||||
recordStore.rebuildRecordsFrom([
|
||||
sessionRecord('claude', derivId, 'sess-claude'),
|
||||
sessionRecord('codex', otherBaseId, 'sess-codex')
|
||||
])
|
||||
const impact = service.getBaseDisableImpact('claude')
|
||||
// default 'claude' (base-direct) + quick-command on the derivative = 2.
|
||||
expect(impact.savedReferences).toEqual({ count: 2, atLeast: false })
|
||||
expect(impact.resumableSessions).toEqual({ count: 1, atLeast: false })
|
||||
})
|
||||
|
||||
it('reports atLeast on saved references when a reference owner store cannot be read', () => {
|
||||
const service = impactService({ failAutomationScan: true })
|
||||
const impact = service.getBaseDisableImpact('claude')
|
||||
// Readable owners (default + quick-command) still count; automation is unknown.
|
||||
expect(impact.savedReferences).toEqual({ count: 2, atLeast: true })
|
||||
expect(impact.resumableSessions.atLeast).toBe(false)
|
||||
})
|
||||
|
||||
it('reports atLeast on resumable sessions when the record store cannot be read', () => {
|
||||
const service = impactService()
|
||||
vi.spyOn(recordStore, 'countRecordsByBase').mockImplementation(() => {
|
||||
throw new Error('store unavailable')
|
||||
})
|
||||
const impact = service.getBaseDisableImpact('claude')
|
||||
expect(impact.resumableSessions).toEqual({ count: 0, atLeast: true })
|
||||
expect(impact.savedReferences.atLeast).toBe(false)
|
||||
})
|
||||
|
||||
it('returns zero impact for a base with no references or sessions', () => {
|
||||
const service = impactService()
|
||||
const impact = service.getBaseDisableImpact('gemini')
|
||||
expect(impact.savedReferences).toEqual({ count: 0, atLeast: false })
|
||||
expect(impact.resumableSessions).toEqual({ count: 0, atLeast: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('delete -> tombstone -> reference lifecycle', () => {
|
||||
it('keeps the tombstone alive through delete while a quick command references it', () => {
|
||||
const live = liveAgent()
|
||||
const state: StoreStubState = {
|
||||
settings: baseSettings({
|
||||
customTuiAgents: [live],
|
||||
terminalQuickCommands: [agentQuickCommand(live.id)]
|
||||
}),
|
||||
repos: [],
|
||||
automations: []
|
||||
}
|
||||
const service = new AgentCatalogService(makeStoreStub(state))
|
||||
const deleted = service.mutate({
|
||||
expectedRevision: 1,
|
||||
mutation: { kind: 'delete-custom', id: live.id }
|
||||
})
|
||||
expect(deleted.ok).toBe(true)
|
||||
expect(state.settings.customTuiAgents).toHaveLength(0)
|
||||
expect(state.settings.deletedCustomTuiAgents?.[0]?.id).toBe(live.id)
|
||||
expect(service.tombstoneReferenceIndex.countReferences(live.id)).toBe(1)
|
||||
// The label stays reserved while referenced.
|
||||
const relabel = service.mutate({
|
||||
expectedRevision: state.settings.agentCatalogRevision ?? 1,
|
||||
mutation: {
|
||||
kind: 'create',
|
||||
baseAgent: 'codex',
|
||||
draft: { label: 'My Codex', commandOverride: null, args: '', env: {}, syncEnv: false }
|
||||
}
|
||||
})
|
||||
expect(relabel).toMatchObject({ ok: false, code: 'duplicate_agent_label' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('local draft endpoint', () => {
|
||||
it('returns exactly one row at the current revision and rejects stale locators', () => {
|
||||
const live = liveAgent({ env: { SECRET: 'value' } })
|
||||
const state: StoreStubState = {
|
||||
settings: baseSettings({ customTuiAgents: [live], agentCatalogRevision: 7 }),
|
||||
repos: [],
|
||||
automations: []
|
||||
}
|
||||
const service = new AgentCatalogService(makeStoreStub(state))
|
||||
const draft = service.getLocalDraft({ id: live.id }, 7)
|
||||
expect(draft).toMatchObject({
|
||||
status: 'ready',
|
||||
revision: 7,
|
||||
draft: { label: 'My Codex', env: { SECRET: 'value' } }
|
||||
})
|
||||
expect(service.getLocalDraft({ id: live.id }, 6)).toEqual({ status: 'stale' })
|
||||
expect(service.getLocalDraft({ id: customId('claude', UUID_B) }, 7)).toEqual({
|
||||
status: 'stale'
|
||||
})
|
||||
})
|
||||
|
||||
it('never returns env values in the list snapshot while the draft carries them', () => {
|
||||
const live = liveAgent({ env: { SECRET: 'value' } })
|
||||
const state: StoreStubState = {
|
||||
settings: baseSettings({ customTuiAgents: [live] }),
|
||||
repos: [],
|
||||
automations: []
|
||||
}
|
||||
const service = new AgentCatalogService(makeStoreStub(state))
|
||||
const snapshotText = JSON.stringify(service.getLocalSnapshot())
|
||||
expect(snapshotText).not.toContain('SECRET')
|
||||
expect(snapshotText).not.toContain('value')
|
||||
const remoteText = JSON.stringify(service.getRemoteSnapshot())
|
||||
expect(remoteText).not.toContain('SECRET')
|
||||
expect(remoteText).not.toContain('value')
|
||||
// Env presence is summarized numerically only.
|
||||
const local = service.getLocalSnapshot()
|
||||
const row = local.customAgents[0]
|
||||
expect(row.status).toBe('ready')
|
||||
if (row.status === 'ready') {
|
||||
expect(row.envSummary.entryCount).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,354 @@
|
||||
// Main-owned agent-catalog service: the single authoring authority. Desktop
|
||||
// Settings mutate through it (never by writing whole settings arrays), it owns
|
||||
// repair tokens and the tombstone reference index, and it enforces the local
|
||||
// (16 MiB) and remote-projection (512 KiB) payload budgets before any write.
|
||||
|
||||
import type { Store } from '../persistence'
|
||||
import type { BuiltInTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types'
|
||||
import type {
|
||||
AgentCatalogMutationRequest,
|
||||
AgentCatalogMutationResult,
|
||||
LocalAgentCatalogSnapshot,
|
||||
LocalCustomAgentDraftResult
|
||||
} from '../../shared/agent-catalog-snapshot'
|
||||
import { MAX_LOCAL_AGENT_DRAFT_BYTES } from '../../shared/agent-catalog-snapshot'
|
||||
import { utf8ByteLength } from '../../shared/custom-tui-agents'
|
||||
import {
|
||||
AgentCatalogRepairTokenRegistry,
|
||||
applyAgentCatalogMutation
|
||||
} from './agent-catalog-mutations'
|
||||
import {
|
||||
buildAgentCatalogSnapshot,
|
||||
buildLocalAgentCatalogSnapshot,
|
||||
measureAgentCatalogProjection,
|
||||
measureLocalAgentCatalogStorage,
|
||||
normalizeCatalogFromSettings
|
||||
} from './agent-catalog-projections'
|
||||
import {
|
||||
AgentTombstoneReferenceIndex,
|
||||
type AgentReferenceSummary
|
||||
} from './agent-tombstone-reference-index'
|
||||
import { registerBuiltInOwnerScanners } from './agent-catalog-owner-scanners'
|
||||
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
|
||||
import { applyAgentReferenceMutation } from './agent-reference-mutations'
|
||||
import type {
|
||||
AgentReferenceMutationRequest,
|
||||
AgentReferenceMutationResult,
|
||||
AgentReferenceProjectionError,
|
||||
AgentReferenceSnapshot,
|
||||
BaseDisableImpact,
|
||||
LocalAgentReferenceSnapshot
|
||||
} from '../../shared/agent-reference-snapshot'
|
||||
|
||||
/** Mutations that reduce risk/size and stay allowed while a payload budget is
|
||||
* already exceeded; they must never add arbitrary user text or a reference. */
|
||||
function isSecurityReducingMutation(request: AgentCatalogMutationRequest): boolean {
|
||||
const mutation = request.mutation
|
||||
switch (mutation.kind) {
|
||||
case 'delete-custom':
|
||||
return true
|
||||
case 'set-enabled':
|
||||
return mutation.enabled === false
|
||||
case 'set-default':
|
||||
return mutation.agent === 'auto' || mutation.agent === 'blank'
|
||||
case 'repair-corrupt':
|
||||
return mutation.action.kind === 'discard'
|
||||
case 'resolve-duplicate-id':
|
||||
return mutation.rows.every((row) => row.action.kind === 'discard')
|
||||
case 'create':
|
||||
case 'duplicate':
|
||||
case 'update-custom':
|
||||
case 'update-built-in':
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
let serviceInstance: AgentCatalogService | null = null
|
||||
let serviceStore: Store | null = null
|
||||
|
||||
/** One service per Store instance (profile switching replaces the Store). Both
|
||||
* local IPC and the runtime RPC layer must share this instance so repair
|
||||
* tokens and reference scanners agree. */
|
||||
export function getOrCreateAgentCatalogService(store: Store): AgentCatalogService {
|
||||
if (!serviceInstance || serviceStore !== store) {
|
||||
serviceInstance = new AgentCatalogService(store)
|
||||
serviceStore = store
|
||||
}
|
||||
return serviceInstance
|
||||
}
|
||||
|
||||
export class AgentCatalogService {
|
||||
private readonly repairTokens = new AgentCatalogRepairTokenRegistry()
|
||||
private readonly referenceIndex = new AgentTombstoneReferenceIndex()
|
||||
private readonly changeListeners = new Set<(revision: number) => void>()
|
||||
|
||||
constructor(private readonly store: Store) {
|
||||
registerBuiltInOwnerScanners(this.referenceIndex, this.store)
|
||||
}
|
||||
|
||||
/** Later units (worktree pending launches, background attempts, orchestration,
|
||||
* sleeping sessions) register their owner scanners through this. */
|
||||
get tombstoneReferenceIndex(): AgentTombstoneReferenceIndex {
|
||||
return this.referenceIndex
|
||||
}
|
||||
|
||||
onDidChange(listener: (revision: number) => void): () => void {
|
||||
this.changeListeners.add(listener)
|
||||
return () => {
|
||||
this.changeListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
getRevision(): number {
|
||||
return this.store.getSettings().agentCatalogRevision ?? 1
|
||||
}
|
||||
|
||||
getLocalSnapshot(): LocalAgentCatalogSnapshot {
|
||||
return buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens)
|
||||
}
|
||||
|
||||
getRemoteSnapshot(): ReturnType<typeof buildAgentCatalogSnapshot> {
|
||||
return buildAgentCatalogSnapshot(this.store.getSettings())
|
||||
}
|
||||
|
||||
/** Local-desktop-only reference summary for delete confirmation and "Review
|
||||
* references"; owner kind + count only, no prompt/config/env. */
|
||||
getReferenceSummaries(id: CustomTuiAgentId): AgentReferenceSummary[] {
|
||||
return this.referenceIndex.summarizeReferences(id)
|
||||
}
|
||||
|
||||
/** §973 base-disable impact: the counts of persisted-owner references and
|
||||
* resumable sessions that will block when a built-in base is disabled. Saved
|
||||
* references include the base id and any live custom derivative of it (a
|
||||
* derivative can't launch without its harness); sessions are counted by base
|
||||
* and excluded from the reference scan so the two counts never overlap. Counts
|
||||
* only — never a label or config. Enabled-derivative counts stay client-side. */
|
||||
getBaseDisableImpact(base: BuiltInTuiAgent): BaseDisableImpact {
|
||||
const catalog = normalizeCatalogFromSettings(this.store.getSettings())
|
||||
const derivativeIds = new Set<string>()
|
||||
for (const agent of catalog.liveCustomAgents) {
|
||||
if (agent.baseAgent === base) {
|
||||
derivativeIds.add(agent.id)
|
||||
}
|
||||
}
|
||||
const matches = (value: unknown): boolean =>
|
||||
value === base || (typeof value === 'string' && derivativeIds.has(value))
|
||||
const saved = this.referenceIndex.countMatchingReferences(matches, {
|
||||
excludeOwners: new Set(['session'])
|
||||
})
|
||||
let resumableSessions: BaseDisableImpact['resumableSessions']
|
||||
try {
|
||||
resumableSessions = {
|
||||
count: getHostAgentSessionRecordStore().countRecordsByBase(base),
|
||||
atLeast: false
|
||||
}
|
||||
} catch {
|
||||
resumableSessions = { count: 0, atLeast: true }
|
||||
}
|
||||
return {
|
||||
savedReferences: { count: saved.count, atLeast: !saved.complete },
|
||||
resumableSessions
|
||||
}
|
||||
}
|
||||
|
||||
/** Single-record full-env editor read, access-checked by the preload boundary
|
||||
* and capped at 1 MiB. Never registered as a runtime RPC. */
|
||||
getLocalDraft(
|
||||
locator: { id: CustomTuiAgentId } | { repairToken: string },
|
||||
expectedRevision: number
|
||||
): LocalCustomAgentDraftResult | { status: 'stale' } {
|
||||
const settings = this.store.getSettings()
|
||||
const revision = settings.agentCatalogRevision ?? 1
|
||||
if (expectedRevision !== revision) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
const catalog = normalizeCatalogFromSettings(settings)
|
||||
const raw: unknown =
|
||||
'id' in locator
|
||||
? (catalog.liveById.get(locator.id) ??
|
||||
catalog.repairRequiredById.get(locator.id)?.raw ??
|
||||
null)
|
||||
: this.repairTokens.resolve(locator.repairToken, [
|
||||
...catalog.corruptRows,
|
||||
...catalog.repairRequiredById.values()
|
||||
])?.raw
|
||||
if (raw === null || raw === undefined) {
|
||||
return { status: 'stale' }
|
||||
}
|
||||
const bytes = utf8ByteLength(JSON.stringify(raw) ?? 'null')
|
||||
if (bytes > MAX_LOCAL_AGENT_DRAFT_BYTES) {
|
||||
return { status: 'too-large', revision, bytes, maxBytes: MAX_LOCAL_AGENT_DRAFT_BYTES }
|
||||
}
|
||||
const record = raw as Record<string, unknown>
|
||||
return {
|
||||
status: 'ready',
|
||||
revision,
|
||||
draft: {
|
||||
label: typeof record.label === 'string' ? record.label : '',
|
||||
commandOverride: typeof record.commandOverride === 'string' ? record.commandOverride : null,
|
||||
args: typeof record.args === 'string' ? record.args : '',
|
||||
env:
|
||||
record.env && typeof record.env === 'object' && !Array.isArray(record.env)
|
||||
? ({ ...(record.env as Record<string, string>) } as Record<string, string>)
|
||||
: {},
|
||||
syncEnv: record.syncEnv === true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getReferenceRevision(): number {
|
||||
return this.store.getSettings().agentReferenceRevision ?? 1
|
||||
}
|
||||
|
||||
private buildReferenceSnapshot(): AgentReferenceSnapshot {
|
||||
const settings = this.store.getSettings()
|
||||
return {
|
||||
version: 1,
|
||||
revision: settings.agentReferenceRevision ?? 1,
|
||||
terminalQuickCommands: settings.terminalQuickCommands ?? [],
|
||||
...(settings.commitMessageAi ? { commitMessageAi: settings.commitMessageAi } : {}),
|
||||
...(settings.sourceControlAi ? { sourceControlAi: settings.sourceControlAi } : {})
|
||||
}
|
||||
}
|
||||
|
||||
private measureReferenceProjection(): { bytes: number; tooLarge: boolean } {
|
||||
const bytes = utf8ByteLength(JSON.stringify(this.buildReferenceSnapshot()))
|
||||
return { bytes, tooLarge: bytes > 524_288 }
|
||||
}
|
||||
|
||||
/** Remote (runtime RPC) reference snapshot; typed projection error when over
|
||||
* the 512 KiB frame budget. */
|
||||
getRemoteReferenceSnapshot(): AgentReferenceSnapshot | AgentReferenceProjectionError {
|
||||
const snapshot = this.buildReferenceSnapshot()
|
||||
const { tooLarge } = this.measureReferenceProjection()
|
||||
if (tooLarge) {
|
||||
return {
|
||||
version: 1,
|
||||
revision: snapshot.revision,
|
||||
code: 'agent_reference_payload_too_large',
|
||||
maxBytes: 524_288
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** Uncapped authoring/repair view over local preload IPC only. */
|
||||
getLocalReferenceSnapshot(): LocalAgentReferenceSnapshot {
|
||||
const snapshot = this.buildReferenceSnapshot()
|
||||
const { bytes, tooLarge } = this.measureReferenceProjection()
|
||||
return {
|
||||
...snapshot,
|
||||
projection: tooLarge
|
||||
? { status: 'too-large', bytes, maxBytes: 524_288 }
|
||||
: { status: 'ready', bytes, maxBytes: 524_288 }
|
||||
}
|
||||
}
|
||||
|
||||
mutateReferences(
|
||||
request: AgentReferenceMutationRequest
|
||||
): AgentReferenceMutationResult<LocalAgentReferenceSnapshot> {
|
||||
const settings = this.store.getSettings()
|
||||
const currentReferenceRevision = settings.agentReferenceRevision ?? 1
|
||||
const application = applyAgentReferenceMutation({
|
||||
settings,
|
||||
request,
|
||||
currentReferenceRevision,
|
||||
catalog: normalizeCatalogFromSettings(settings)
|
||||
})
|
||||
if (!application.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: application.code,
|
||||
referenceRevision: currentReferenceRevision,
|
||||
catalogRevision: this.getRevision(),
|
||||
...(application.code === 'reference_revision_conflict'
|
||||
? { snapshot: this.getLocalReferenceSnapshot() }
|
||||
: {}),
|
||||
...(application.owner ? { owner: application.owner } : {}),
|
||||
...(application.field ? { field: application.field } : {}),
|
||||
...(application.reason ? { reason: application.reason } : {})
|
||||
}
|
||||
}
|
||||
// Owner change commits before any prune; a failure between the two leaves
|
||||
// the tombstone conservatively retained for the next indexed recheck.
|
||||
this.store.updateSettings(application.patch, { notifyListeners: true })
|
||||
this.pruneUnreferencedTombstonesAfterReferenceRemoval()
|
||||
return {
|
||||
ok: true,
|
||||
referenceRevision: application.newReferenceRevision,
|
||||
catalogRevision: this.getRevision(),
|
||||
snapshot: this.getLocalReferenceSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reference-aware prune run after a reference removal; a prune advances and
|
||||
* publishes the catalog revision so receivers replace their snapshot. */
|
||||
private pruneUnreferencedTombstonesAfterReferenceRemoval(): void {
|
||||
const settings = this.store.getSettings()
|
||||
const tombstones = settings.deletedCustomTuiAgents ?? []
|
||||
if (tombstones.length === 0) {
|
||||
return
|
||||
}
|
||||
const retained = tombstones.filter(
|
||||
(tombstone) => this.referenceIndex.countReferences(tombstone.id) !== 0
|
||||
)
|
||||
if (retained.length === tombstones.length) {
|
||||
return
|
||||
}
|
||||
const newRevision = (settings.agentCatalogRevision ?? 1) + 1
|
||||
this.store.updateSettings(
|
||||
{ deletedCustomTuiAgents: retained, agentCatalogRevision: newRevision },
|
||||
{ notifyListeners: true }
|
||||
)
|
||||
for (const listener of this.changeListeners) {
|
||||
listener(newRevision)
|
||||
}
|
||||
}
|
||||
|
||||
mutate(request: AgentCatalogMutationRequest): AgentCatalogMutationResult {
|
||||
const settings = this.store.getSettings()
|
||||
const currentRevision = settings.agentCatalogRevision ?? 1
|
||||
const application = applyAgentCatalogMutation({
|
||||
settings,
|
||||
request,
|
||||
currentRevision,
|
||||
repairTokens: this.repairTokens,
|
||||
countTombstoneReferences: (id) => this.referenceIndex.countReferences(id)
|
||||
})
|
||||
if (!application.ok) {
|
||||
const revisionForError =
|
||||
application.code === 'catalog_revision_conflict' ? currentRevision : currentRevision
|
||||
return {
|
||||
ok: false,
|
||||
code: application.code,
|
||||
revision: revisionForError,
|
||||
...(application.code === 'catalog_revision_conflict'
|
||||
? { snapshot: this.getLocalSnapshot() }
|
||||
: {}),
|
||||
...(application.field ? { field: application.field } : {}),
|
||||
...(application.reason ? { reason: application.reason } : {}),
|
||||
...(application.envEntryIndex !== undefined
|
||||
? { envEntryIndex: application.envEntryIndex }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
// Payload budgets are checked on the post-mutation state; while a budget is
|
||||
// exceeded only the security-reducing allowlist may still commit.
|
||||
const nextSettings = { ...settings, ...application.patch }
|
||||
const localStorageStatus = measureLocalAgentCatalogStorage(nextSettings as GlobalSettings)
|
||||
const projectionStatus = measureAgentCatalogProjection(nextSettings as GlobalSettings)
|
||||
if (localStorageStatus.status === 'too-large' && !isSecurityReducingMutation(request)) {
|
||||
return { ok: false, code: 'agent_catalog_local_payload_too_large', revision: currentRevision }
|
||||
}
|
||||
if (projectionStatus.status === 'too-large' && !isSecurityReducingMutation(request)) {
|
||||
return { ok: false, code: 'agent_catalog_payload_too_large', revision: currentRevision }
|
||||
}
|
||||
|
||||
this.store.updateSettings(application.patch, { notifyListeners: true })
|
||||
for (const listener of this.changeListeners) {
|
||||
listener(application.newRevision)
|
||||
}
|
||||
return { ok: true, revision: application.newRevision, snapshot: this.getLocalSnapshot() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator,
|
||||
MAX_PENDING_LAUNCHES_PER_HOST,
|
||||
MAX_PENDING_LAUNCHES_PER_PRINCIPAL,
|
||||
MAX_PENDING_LAUNCHES_PER_WORKTREE,
|
||||
MAX_PENDING_LAUNCHES_REMOTE_TOTAL,
|
||||
type AdmissionPrincipal
|
||||
} from './agent-launch-admission-store'
|
||||
|
||||
const SNAPSHOT: AgentLaunchSnapshot = Object.freeze({
|
||||
version: 1,
|
||||
requestedAgent: 'codex',
|
||||
baseAgent: 'codex',
|
||||
displayLabel: 'Codex',
|
||||
mode: 'built-in',
|
||||
argv: ['codex'],
|
||||
agentEnv: {},
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
} as const) as unknown as AgentLaunchSnapshot
|
||||
|
||||
function admitOne(
|
||||
store: AgentLaunchAdmissionStore,
|
||||
principal: AdmissionPrincipal,
|
||||
scope = 'wt-1',
|
||||
worktreeId: string | null = null
|
||||
) {
|
||||
return store.admit({
|
||||
principal,
|
||||
intent: 'interactive',
|
||||
scope,
|
||||
worktreeId,
|
||||
fingerprint: 'fp',
|
||||
snapshot: SNAPSHOT,
|
||||
admittedAt: 1
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentLaunchAdmissionStore capacity', () => {
|
||||
it('caps each principal at 64 pending records', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const principal: AdmissionPrincipal = { kind: 'remote', id: 'device-1' }
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) {
|
||||
expect(admitOne(store, principal).ok).toBe(true)
|
||||
}
|
||||
const rejected = admitOne(store, principal)
|
||||
expect(rejected).toMatchObject({
|
||||
ok: false,
|
||||
failure: { code: 'launch_capacity_exceeded', reason: 'capacity' }
|
||||
})
|
||||
// A different principal still has capacity.
|
||||
expect(admitOne(store, { kind: 'remote', id: 'device-2' }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('stops remote principals collectively at 192, reserving 64 local slots', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
for (let device = 0; device < 3; device += 1) {
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) {
|
||||
expect(admitOne(store, { kind: 'remote', id: `device-${device}` }).ok).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(store.pendingCount()).toBe(MAX_PENDING_LAUNCHES_REMOTE_TOTAL)
|
||||
expect(admitOne(store, { kind: 'remote', id: 'device-4' }).ok).toBe(false)
|
||||
// The local host retains its reserved capacity up to the host cap.
|
||||
let localAdmitted = 0
|
||||
while (admitOne(store, { kind: 'local' }).ok) {
|
||||
localAdmitted += 1
|
||||
}
|
||||
expect(localAdmitted).toBe(MAX_PENDING_LAUNCHES_PER_HOST - MAX_PENDING_LAUNCHES_REMOTE_TOTAL)
|
||||
expect(store.pendingCount()).toBe(MAX_PENDING_LAUNCHES_PER_HOST)
|
||||
})
|
||||
|
||||
it('caps a single worktree at 8 committed launches, independent of other worktrees', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const principal: AdmissionPrincipal = { kind: 'local' }
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_WORKTREE; i += 1) {
|
||||
expect(admitOne(store, principal, `run-${i}`, 'wt-busy').ok).toBe(true)
|
||||
}
|
||||
expect(store.pendingForWorktree('wt-busy')).toBe(MAX_PENDING_LAUNCHES_PER_WORKTREE)
|
||||
// The 9th launch into the same worktree is rejected before any provider I/O.
|
||||
expect(admitOne(store, principal, 'run-9', 'wt-busy')).toMatchObject({
|
||||
ok: false,
|
||||
failure: { code: 'launch_capacity_exceeded', reason: 'capacity' }
|
||||
})
|
||||
// A different worktree still has its own capacity.
|
||||
expect(admitOne(store, principal, 'run-other', 'wt-quiet').ok).toBe(true)
|
||||
// A launch that names no worktree never trips the per-worktree cap.
|
||||
expect(admitOne(store, principal, 'no-worktree', null).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('releasing a worktree launch frees exactly one per-worktree slot', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const principal: AdmissionPrincipal = { kind: 'local' }
|
||||
const admitted = admitOne(store, principal, 'run-0', 'wt-busy')
|
||||
for (let i = 1; i < MAX_PENDING_LAUNCHES_PER_WORKTREE; i += 1) {
|
||||
admitOne(store, principal, `run-${i}`, 'wt-busy')
|
||||
}
|
||||
expect(admitOne(store, principal, 'run-9', 'wt-busy').ok).toBe(false)
|
||||
if (!admitted.ok) {
|
||||
throw new Error('fixture admit failed')
|
||||
}
|
||||
expect(store.release(admitted.record.launchToken)).toBe(true)
|
||||
expect(store.pendingForWorktree('wt-busy')).toBe(MAX_PENDING_LAUNCHES_PER_WORKTREE - 1)
|
||||
// The freed slot admits again.
|
||||
expect(admitOne(store, principal, 'run-9', 'wt-busy').ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rebuildFrom restores per-worktree counts from durable records', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const a = admitOne(store, { kind: 'local' }, 'run-a', 'wt-busy')
|
||||
const b = admitOne(store, { kind: 'local' }, 'run-b', 'wt-busy')
|
||||
const c = admitOne(store, { kind: 'local' }, 'run-c', null)
|
||||
if (!a.ok || !b.ok || !c.ok) {
|
||||
throw new Error('fixture admit failed')
|
||||
}
|
||||
const rebuilt = new AgentLaunchAdmissionStore()
|
||||
rebuilt.rebuildFrom([a.record, b.record, c.record])
|
||||
expect(rebuilt.pendingForWorktree('wt-busy')).toBe(2)
|
||||
})
|
||||
|
||||
it('release frees exactly one reservation and unknown tokens are no-ops', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const admitted = admitOne(store, { kind: 'local' })
|
||||
expect(admitted.ok).toBe(true)
|
||||
if (!admitted.ok) {
|
||||
return
|
||||
}
|
||||
expect(store.release(admitted.record.launchToken)).toBe(true)
|
||||
expect(store.release(admitted.record.launchToken)).toBe(false)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
expect(store.pendingForPrincipal({ kind: 'local' })).toBe(0)
|
||||
})
|
||||
|
||||
it('rebuilds counters once from durable records', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const first = admitOne(store, { kind: 'remote', id: 'device-1' })
|
||||
const second = admitOne(store, { kind: 'local' })
|
||||
if (!first.ok || !second.ok) {
|
||||
throw new Error('fixture admit failed')
|
||||
}
|
||||
const rebuilt = new AgentLaunchAdmissionStore()
|
||||
rebuilt.rebuildFrom([first.record, second.record])
|
||||
expect(rebuilt.pendingCount()).toBe(2)
|
||||
expect(rebuilt.pendingForPrincipal({ kind: 'remote', id: 'device-1' })).toBe(1)
|
||||
expect(rebuilt.pendingForPrincipal({ kind: 'local' })).toBe(1)
|
||||
})
|
||||
|
||||
it('summaries stay secret-free and principal-scoped', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const mine = admitOne(store, { kind: 'remote', id: 'device-1' }, 'wt-42')
|
||||
admitOne(store, { kind: 'remote', id: 'device-2' }, 'wt-secret')
|
||||
expect(mine.ok).toBe(true)
|
||||
const rows = store.summarizeFor({ kind: 'remote', id: 'device-1' })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({ intent: 'interactive', scope: 'wt-42' })
|
||||
const text = JSON.stringify(rows)
|
||||
expect(text).not.toContain('argv')
|
||||
expect(text).not.toContain('agentEnv')
|
||||
expect(text).not.toContain('wt-secret')
|
||||
})
|
||||
|
||||
it('capacity rows add base harness + host id, stay principal-scoped and secret-free', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const mine = admitOne(store, { kind: 'remote', id: 'device-1' }, 'wt-42')
|
||||
admitOne(store, { kind: 'remote', id: 'device-2' }, 'wt-secret')
|
||||
expect(mine.ok).toBe(true)
|
||||
const rows = store.capacitySummaryFor({ kind: 'remote', id: 'device-1' })
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
intent: 'interactive',
|
||||
scope: 'wt-42',
|
||||
baseHarness: 'codex',
|
||||
executionHostId: 'local'
|
||||
})
|
||||
const text = JSON.stringify(rows)
|
||||
// Snapshot secrets never enter the row; only baseAgent + executionHostId do.
|
||||
expect(text).not.toContain('argv')
|
||||
expect(text).not.toContain('agentEnv')
|
||||
expect(text).not.toContain('displayLabel')
|
||||
expect(text).not.toContain('wt-secret')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLaunchAdmissionStore reservations', () => {
|
||||
function reserveOne(store: AgentLaunchAdmissionStore, principal: AdmissionPrincipal) {
|
||||
return store.reserve(principal)
|
||||
}
|
||||
|
||||
function admitReservedOne(
|
||||
store: AgentLaunchAdmissionStore,
|
||||
reservationId: string,
|
||||
scope = 'wt-1',
|
||||
worktreeId: string | null = null
|
||||
) {
|
||||
return store.admitReserved(reservationId, {
|
||||
intent: 'interactive',
|
||||
scope,
|
||||
worktreeId,
|
||||
fingerprint: 'fp',
|
||||
snapshot: SNAPSHOT,
|
||||
admittedAt: 1
|
||||
})
|
||||
}
|
||||
|
||||
it('reserve holds capacity, and admitReserved converts without double-counting', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const reservation = reserveOne(store, { kind: 'local' })
|
||||
expect(reservation.ok).toBe(true)
|
||||
if (!reservation.ok) {
|
||||
return
|
||||
}
|
||||
// The hold counts toward the principal cap before any commit.
|
||||
expect(store.pendingForPrincipal({ kind: 'local' })).toBe(1)
|
||||
// pendingCount tracks committed records only; the hold is not committed yet.
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
const admitted = admitReservedOne(store, reservation.reservation.reservationId)
|
||||
expect(admitted.ok).toBe(true)
|
||||
// Converting a hold does not re-increment: still exactly one for the principal.
|
||||
expect(store.pendingForPrincipal({ kind: 'local' })).toBe(1)
|
||||
expect(store.pendingCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('admitReserved commits against the now-known worktree and counts toward its cap', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const reservation = reserveOne(store, { kind: 'local' })
|
||||
expect(reservation.ok).toBe(true)
|
||||
if (!reservation.ok) {
|
||||
return
|
||||
}
|
||||
// A reservation names no worktree, so the per-worktree count is still 0.
|
||||
expect(store.pendingForWorktree('wt-new')).toBe(0)
|
||||
const admitted = admitReservedOne(
|
||||
store,
|
||||
reservation.reservation.reservationId,
|
||||
'wt-new',
|
||||
'wt-new'
|
||||
)
|
||||
expect(admitted.ok).toBe(true)
|
||||
// Committing binds the launch to the freshly-created worktree.
|
||||
expect(store.pendingForWorktree('wt-new')).toBe(1)
|
||||
})
|
||||
|
||||
it('held reservations count toward the per-principal cap', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const principal: AdmissionPrincipal = { kind: 'remote', id: 'device-1' }
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) {
|
||||
expect(reserveOne(store, principal).ok).toBe(true)
|
||||
}
|
||||
// Both a further reserve and a direct admit are rejected once the holds fill.
|
||||
expect(reserveOne(store, principal).ok).toBe(false)
|
||||
expect(admitOne(store, principal).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('held reservations count toward the collective remote cap', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
for (let device = 0; device < 3; device += 1) {
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) {
|
||||
expect(reserveOne(store, { kind: 'remote', id: `device-${device}` }).ok).toBe(true)
|
||||
}
|
||||
}
|
||||
expect(reserveOne(store, { kind: 'remote', id: 'device-4' }).ok).toBe(false)
|
||||
// Local capacity is still reserved even while remote holds are maxed.
|
||||
expect(reserveOne(store, { kind: 'local' }).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('releaseReservation frees the held slot and unknown ids are no-ops', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const reservation = reserveOne(store, { kind: 'local' })
|
||||
expect(reservation.ok).toBe(true)
|
||||
if (!reservation.ok) {
|
||||
return
|
||||
}
|
||||
expect(store.releaseReservation(reservation.reservation.reservationId)).toBe(true)
|
||||
expect(store.releaseReservation(reservation.reservation.reservationId)).toBe(false)
|
||||
expect(store.pendingForPrincipal({ kind: 'local' })).toBe(0)
|
||||
})
|
||||
|
||||
it('admitReserved fails closed for a released or unknown reservation', () => {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const reservation = reserveOne(store, { kind: 'local' })
|
||||
expect(reservation.ok).toBe(true)
|
||||
if (!reservation.ok) {
|
||||
return
|
||||
}
|
||||
store.releaseReservation(reservation.reservation.reservationId)
|
||||
const admitted = admitReservedOne(store, reservation.reservation.reservationId)
|
||||
expect(admitted).toMatchObject({
|
||||
ok: false,
|
||||
failure: { code: 'launch_capacity_exceeded', reason: 'capacity' }
|
||||
})
|
||||
expect(admitReservedOne(store, 'never-issued').ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LaunchAdmissionCoordinator', () => {
|
||||
it('serializes critical sections in FIFO order and survives a throwing section', async () => {
|
||||
const coordinator = new LaunchAdmissionCoordinator()
|
||||
const order: number[] = []
|
||||
const first = coordinator.runExclusive(() => {
|
||||
order.push(1)
|
||||
return 'a'
|
||||
})
|
||||
const failing = coordinator.runExclusive(() => {
|
||||
order.push(2)
|
||||
throw new Error('boom')
|
||||
})
|
||||
const third = coordinator.runExclusive(() => {
|
||||
order.push(3)
|
||||
return 'c'
|
||||
})
|
||||
await expect(first).resolves.toBe('a')
|
||||
await expect(failing).rejects.toThrow('boom')
|
||||
await expect(third).resolves.toBe('c')
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,355 @@
|
||||
// Host-private admitted-pending launch store and the admission coordinator.
|
||||
// Admission is the launch linearization point (I24): inside one short critical
|
||||
// section the host revalidates the relevant-input fingerprint and commits the
|
||||
// token/snapshot/provider intent BEFORE any provider I/O. Records are bounded:
|
||||
// 256 per host, 64 per authenticated principal, remote principals collectively
|
||||
// capped so 64 slots stay reserved for local desktop/host work. Rejection is
|
||||
// launch_capacity_exceeded before provider I/O and before any owner mutation.
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type {
|
||||
AgentLaunchExecutionHostId,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract'
|
||||
import type { BuiltInTuiAgent } from '../../shared/types'
|
||||
|
||||
export const MAX_PENDING_LAUNCHES_PER_HOST = 256
|
||||
export const MAX_PENDING_LAUNCHES_PER_PRINCIPAL = 64
|
||||
export const MAX_PENDING_LAUNCHES_REMOTE_TOTAL = 192
|
||||
// Per-worktree bound (G6): unattended launches (orchestration workers, automation
|
||||
// runs, background attempts) can pile many pending launches into ONE worktree, so
|
||||
// a worktree-scoped cap stops a single workspace from monopolizing host capacity.
|
||||
export const MAX_PENDING_LAUNCHES_PER_WORKTREE = 8
|
||||
|
||||
/** Stable authenticated principal: the remote caller's clientKind ('mobile' |
|
||||
* 'runtime') for remote callers, the local desktop/host otherwise. Never a
|
||||
* per-connection value.
|
||||
* U10 marker (§U9 ledger #18): despite "id", this is TODAY the coarse clientKind,
|
||||
* NOT a per-device id — every same-kind paired device shares one principal. Do not
|
||||
* treat `id` as device-granular until per-device admission principals land (the
|
||||
* revoked-principal forget override reads revocation at clientKind granularity for
|
||||
* exactly this reason). */
|
||||
export type AdmissionPrincipal = { kind: 'local' } | { kind: 'remote'; id: string }
|
||||
|
||||
export type AdmittedLaunchRecord = {
|
||||
launchToken: string
|
||||
principal: AdmissionPrincipal
|
||||
intent: AgentLaunchIntentKind
|
||||
/** Owner scope for reconciliation joins (worktree id, pane key, run id …). */
|
||||
scope: string
|
||||
/** Worktree this launch targets, for the per-worktree cap. Null when the
|
||||
* launch names no worktree (e.g. a not-yet-created two-stage worktree). */
|
||||
worktreeId: string | null
|
||||
fingerprint: string
|
||||
snapshot: AgentLaunchSnapshot
|
||||
admittedAt: number
|
||||
}
|
||||
|
||||
export type AdmissionResult =
|
||||
| { ok: true; record: AdmittedLaunchRecord }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
|
||||
/** Redacted host-side capacity-recovery row for the pending-summary surface.
|
||||
* Adds only the two non-secret snapshot fields the sheet needs (base harness,
|
||||
* execution host id) to the summarize set; the launch token stays host-side for
|
||||
* the liveness scan and is never projected to the client DTO. */
|
||||
export type AdmissionCapacityRow = {
|
||||
intent: AgentLaunchIntentKind
|
||||
scope: string
|
||||
admittedAt: number
|
||||
launchToken: string
|
||||
baseHarness: BuiltInTuiAgent
|
||||
executionHostId: AgentLaunchExecutionHostId
|
||||
}
|
||||
|
||||
/** Fields common to a fresh admit and a reserved admit. Principal comes from the
|
||||
* request for admit and from the held reservation for admitReserved. */
|
||||
export type AgentLaunchAdmitInput = {
|
||||
intent: AgentLaunchIntentKind
|
||||
scope: string
|
||||
/** Target worktree for the per-worktree cap, or null when the launch names no
|
||||
* worktree yet (a fresh two-stage creation counts trivially against a brand-
|
||||
* new worktree, so a null-worktree reservation never hits the cap). */
|
||||
worktreeId: string | null
|
||||
fingerprint: string
|
||||
snapshot: AgentLaunchSnapshot
|
||||
admittedAt: number
|
||||
}
|
||||
|
||||
/** A pre-spawn capacity hold taken before git/worktree mutation so a
|
||||
* launch_capacity_exceeded rejection precedes any side effect. Converted into a
|
||||
* committed record by admitReserved, or dropped by releaseReservation on any
|
||||
* pre-spawn exit. Counts toward the caps while held. */
|
||||
export type AdmissionReservation = { reservationId: string; principal: AdmissionPrincipal }
|
||||
|
||||
export type ReservationResult =
|
||||
| { ok: true; reservation: AdmissionReservation }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
|
||||
export function principalKey(principal: AdmissionPrincipal): string {
|
||||
return principal.kind === 'local' ? 'local' : `remote:${principal.id}`
|
||||
}
|
||||
|
||||
export class AgentLaunchAdmissionStore {
|
||||
private readonly byToken = new Map<string, AdmittedLaunchRecord>()
|
||||
private readonly countsByPrincipal = new Map<string, number>()
|
||||
private readonly countsByWorktree = new Map<string, number>()
|
||||
private readonly reservations = new Map<string, AdmissionPrincipal>()
|
||||
private remoteTotal = 0
|
||||
|
||||
/** launch_capacity_exceeded when any cap is at its bound, else null. Held
|
||||
* reservations count toward the principal/host/remote caps; the per-worktree
|
||||
* cap counts only committed records (a two-stage reservation has no worktree
|
||||
* yet). */
|
||||
private capacityFailure(
|
||||
principal: AdmissionPrincipal,
|
||||
worktreeId: string | null
|
||||
): AgentLaunchFailure | null {
|
||||
const principalCount = this.countsByPrincipal.get(principalKey(principal)) ?? 0
|
||||
const worktreeCount = worktreeId ? (this.countsByWorktree.get(worktreeId) ?? 0) : 0
|
||||
if (
|
||||
this.byToken.size + this.reservations.size >= MAX_PENDING_LAUNCHES_PER_HOST ||
|
||||
principalCount >= MAX_PENDING_LAUNCHES_PER_PRINCIPAL ||
|
||||
// Remote principals collectively stop short of the host cap so local
|
||||
// desktop/host work always retains reserved capacity.
|
||||
(principal.kind === 'remote' && this.remoteTotal >= MAX_PENDING_LAUNCHES_REMOTE_TOTAL) ||
|
||||
(worktreeId !== null && worktreeCount >= MAX_PENDING_LAUNCHES_PER_WORKTREE)
|
||||
) {
|
||||
return { code: 'launch_capacity_exceeded', reason: 'capacity' }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private incrementCounters(principal: AdmissionPrincipal): void {
|
||||
const key = principalKey(principal)
|
||||
this.countsByPrincipal.set(key, (this.countsByPrincipal.get(key) ?? 0) + 1)
|
||||
if (principal.kind === 'remote') {
|
||||
this.remoteTotal += 1
|
||||
}
|
||||
}
|
||||
|
||||
private decrementCounters(principal: AdmissionPrincipal): void {
|
||||
const key = principalKey(principal)
|
||||
const count = this.countsByPrincipal.get(key) ?? 0
|
||||
if (count <= 1) {
|
||||
this.countsByPrincipal.delete(key)
|
||||
} else {
|
||||
this.countsByPrincipal.set(key, count - 1)
|
||||
}
|
||||
if (principal.kind === 'remote') {
|
||||
this.remoteTotal = Math.max(0, this.remoteTotal - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-worktree counters track committed records only. Called when a record
|
||||
* is committed (admit / admitReserved) and released. */
|
||||
private incrementWorktree(worktreeId: string | null): void {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
this.countsByWorktree.set(worktreeId, (this.countsByWorktree.get(worktreeId) ?? 0) + 1)
|
||||
}
|
||||
|
||||
private decrementWorktree(worktreeId: string | null): void {
|
||||
if (!worktreeId) {
|
||||
return
|
||||
}
|
||||
const count = this.countsByWorktree.get(worktreeId) ?? 0
|
||||
if (count <= 1) {
|
||||
this.countsByWorktree.delete(worktreeId)
|
||||
} else {
|
||||
this.countsByWorktree.set(worktreeId, count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit an admitted-pending record. Call ONLY from inside the coordinator's
|
||||
* critical section, after the fingerprint recheck passed. */
|
||||
admit(input: AgentLaunchAdmitInput & { principal: AdmissionPrincipal }): AdmissionResult {
|
||||
const failure = this.capacityFailure(input.principal, input.worktreeId)
|
||||
if (failure) {
|
||||
return { ok: false, failure }
|
||||
}
|
||||
const record: AdmittedLaunchRecord = {
|
||||
launchToken: randomBytes(24).toString('base64url'),
|
||||
principal: input.principal,
|
||||
intent: input.intent,
|
||||
scope: input.scope,
|
||||
worktreeId: input.worktreeId,
|
||||
fingerprint: input.fingerprint,
|
||||
snapshot: input.snapshot,
|
||||
admittedAt: input.admittedAt
|
||||
}
|
||||
this.byToken.set(record.launchToken, record)
|
||||
this.incrementCounters(input.principal)
|
||||
this.incrementWorktree(record.worktreeId)
|
||||
return { ok: true, record }
|
||||
}
|
||||
|
||||
/** Take a capacity hold before git/worktree mutation. The pre-create stage
|
||||
* reserves so a full worktree is never created for an over-cap launch. */
|
||||
reserve(principal: AdmissionPrincipal): ReservationResult {
|
||||
// A pre-create reservation names no worktree yet (it is creating one), so it
|
||||
// never counts against the per-worktree cap.
|
||||
const failure = this.capacityFailure(principal, null)
|
||||
if (failure) {
|
||||
return { ok: false, failure }
|
||||
}
|
||||
const reservationId = randomBytes(18).toString('base64url')
|
||||
this.reservations.set(reservationId, principal)
|
||||
this.incrementCounters(principal)
|
||||
return { ok: true, reservation: { reservationId, principal } }
|
||||
}
|
||||
|
||||
/** Convert a held reservation into a committed record after the post-create
|
||||
* fingerprint recheck. Counters already include the reservation, so this
|
||||
* never re-increments. A lost/expired reservation fails closed. */
|
||||
admitReserved(reservationId: string, input: AgentLaunchAdmitInput): AdmissionResult {
|
||||
const principal = this.reservations.get(reservationId)
|
||||
if (!principal) {
|
||||
return { ok: false, failure: { code: 'launch_capacity_exceeded', reason: 'capacity' } }
|
||||
}
|
||||
this.reservations.delete(reservationId)
|
||||
const record: AdmittedLaunchRecord = {
|
||||
launchToken: randomBytes(24).toString('base64url'),
|
||||
principal,
|
||||
intent: input.intent,
|
||||
scope: input.scope,
|
||||
worktreeId: input.worktreeId,
|
||||
fingerprint: input.fingerprint,
|
||||
snapshot: input.snapshot,
|
||||
admittedAt: input.admittedAt
|
||||
}
|
||||
this.byToken.set(record.launchToken, record)
|
||||
// The reservation already counted toward principal/host/remote; the worktree
|
||||
// is known only now (post-create), and a brand-new worktree starts at 0, so
|
||||
// this commit never trips the per-worktree cap.
|
||||
this.incrementWorktree(record.worktreeId)
|
||||
return { ok: true, record }
|
||||
}
|
||||
|
||||
/** Drop a reservation that never admitted (pre-spawn exit, mismatch, or a
|
||||
* failed post-create resolution). Frees its held capacity. */
|
||||
releaseReservation(reservationId: string): boolean {
|
||||
const principal = this.reservations.get(reservationId)
|
||||
if (!principal) {
|
||||
return false
|
||||
}
|
||||
this.reservations.delete(reservationId)
|
||||
this.decrementCounters(principal)
|
||||
return true
|
||||
}
|
||||
|
||||
get(launchToken: string): AdmittedLaunchRecord | null {
|
||||
return this.byToken.get(launchToken) ?? null
|
||||
}
|
||||
|
||||
/** Release on receipt (moved to terminal attribution), provider failure,
|
||||
* admission mismatch, authoritative reconciliation, or explicit forget.
|
||||
* Never by age while liveness is unknown. */
|
||||
release(launchToken: string): boolean {
|
||||
const record = this.byToken.get(launchToken)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
this.byToken.delete(launchToken)
|
||||
this.decrementCounters(record.principal)
|
||||
this.decrementWorktree(record.worktreeId)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Rebuild counters from durable pending records once at startup; later
|
||||
* transitions update counters incrementally rather than rescanning.
|
||||
* Reservations are ephemeral pre-spawn holds and never persist, so a rebuild
|
||||
* starts with none. */
|
||||
rebuildFrom(records: Iterable<AdmittedLaunchRecord>): void {
|
||||
this.byToken.clear()
|
||||
this.countsByPrincipal.clear()
|
||||
this.countsByWorktree.clear()
|
||||
this.reservations.clear()
|
||||
this.remoteTotal = 0
|
||||
for (const record of records) {
|
||||
this.byToken.set(record.launchToken, record)
|
||||
this.incrementCounters(record.principal)
|
||||
this.incrementWorktree(record.worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
pendingCount(): number {
|
||||
return this.byToken.size
|
||||
}
|
||||
|
||||
pendingForPrincipal(principal: AdmissionPrincipal): number {
|
||||
return this.countsByPrincipal.get(principalKey(principal)) ?? 0
|
||||
}
|
||||
|
||||
pendingForWorktree(worktreeId: string): number {
|
||||
return this.countsByWorktree.get(worktreeId) ?? 0
|
||||
}
|
||||
|
||||
/** Secret-free rows for the capacity-recovery surface: never snapshot, argv,
|
||||
* env, prompt, label, or the token of another principal's row. */
|
||||
summarizeFor(principal: AdmissionPrincipal): {
|
||||
intent: AgentLaunchIntentKind
|
||||
scope: string
|
||||
admittedAt: number
|
||||
launchToken: string
|
||||
}[] {
|
||||
const key = principalKey(principal)
|
||||
const rows: {
|
||||
intent: AgentLaunchIntentKind
|
||||
scope: string
|
||||
admittedAt: number
|
||||
launchToken: string
|
||||
}[] = []
|
||||
for (const record of this.byToken.values()) {
|
||||
if (principalKey(record.principal) === key) {
|
||||
rows.push({
|
||||
intent: record.intent,
|
||||
scope: record.scope,
|
||||
admittedAt: record.admittedAt,
|
||||
launchToken: record.launchToken
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Redacted capacity-recovery rows for one principal: the summarize set plus the
|
||||
* two non-secret snapshot fields the sheet needs. Filters strictly to the
|
||||
* principal's own records; never another principal's row. */
|
||||
capacitySummaryFor(principal: AdmissionPrincipal): AdmissionCapacityRow[] {
|
||||
const key = principalKey(principal)
|
||||
const rows: AdmissionCapacityRow[] = []
|
||||
for (const record of this.byToken.values()) {
|
||||
if (principalKey(record.principal) === key) {
|
||||
rows.push({
|
||||
intent: record.intent,
|
||||
scope: record.scope,
|
||||
admittedAt: record.admittedAt,
|
||||
launchToken: record.launchToken,
|
||||
baseHarness: record.snapshot.baseAgent,
|
||||
executionHostId: record.snapshot.target.executionHostId
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
|
||||
/** Short async critical section shared by launch admission and every mutation
|
||||
* of admission-relevant inputs. No trust, filesystem, network, home lookup, or
|
||||
* provider call may run while held — callers do I/O before/after, never inside. */
|
||||
export class LaunchAdmissionCoordinator {
|
||||
private tail: Promise<void> = Promise.resolve()
|
||||
|
||||
runExclusive<T>(critical: () => T): Promise<T> {
|
||||
const run = this.tail.then(() => critical())
|
||||
this.tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
return run
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// Request/result contract for the agent-launch host boundary (U3/U4). Split from
|
||||
// agent-launch-boundary.ts so the boundary class stays within the module size
|
||||
// budget; the boundary re-exports these, so existing importers are unaffected.
|
||||
|
||||
import type { AdmissionPrincipal } from './agent-launch-admission-store'
|
||||
import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
import type { ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchNotice,
|
||||
AgentLaunchReceipt,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan'
|
||||
|
||||
/** Collapse duplicate notice codes so a receipt carries each notice once. */
|
||||
export function dedupeNoticesByCode(notices: readonly AgentLaunchNotice[]): AgentLaunchNotice[] {
|
||||
const seen = new Set<string>()
|
||||
const deduped: AgentLaunchNotice[] = []
|
||||
for (const notice of notices) {
|
||||
if (!seen.has(notice.code)) {
|
||||
seen.add(notice.code)
|
||||
deduped.push(notice)
|
||||
}
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
/** Map a re-resolve mismatch inside the coordinator: a newly disabled base is
|
||||
* base_agent_disabled; any other relevant change is agent_configuration_changed.
|
||||
* The fingerprint only hashes relevant inputs, so an unrelated catalog edit does
|
||||
* not reach this path. */
|
||||
export function mapAdmissionMismatch(failure: AgentLaunchFailure): AgentLaunchFailure {
|
||||
if (failure.code === 'base_agent_disabled') {
|
||||
return failure
|
||||
}
|
||||
return {
|
||||
code: 'agent_configuration_changed',
|
||||
...(failure.requestedAgent ? { requestedAgent: failure.requestedAgent } : {}),
|
||||
...(failure.baseAgent ? { baseAgent: failure.baseAgent } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** The client-safe identity pair carried on failures/receipts. */
|
||||
export function agentIds(launch: ResolvedAgentLaunch): {
|
||||
requestedAgent: ResolvedAgentLaunch['requestedAgent']
|
||||
baseAgent: ResolvedAgentLaunch['baseAgent']
|
||||
} {
|
||||
return { requestedAgent: launch.requestedAgent, baseAgent: launch.baseAgent }
|
||||
}
|
||||
|
||||
/** Authenticated RPC client kind. `undefined` is an in-process/host caller —
|
||||
* desktop, never mobile by guesswork. Never copied from client JSON. */
|
||||
export type AuthenticatedClientKind = 'runtime' | 'mobile' | undefined
|
||||
|
||||
/** Map the authenticated RPC scope to the launch-intent client. Callers build
|
||||
* their interactive/resume LaunchIntent host-side with this — the boundary's
|
||||
* intent construction lives here so no path derives it from client payload. */
|
||||
export function mapClientKindToLaunchClient(
|
||||
kind: AuthenticatedClientKind
|
||||
): 'desktop' | 'paired-web' | 'mobile' {
|
||||
if (kind === 'runtime') {
|
||||
return 'paired-web'
|
||||
}
|
||||
if (kind === 'mobile') {
|
||||
return 'mobile'
|
||||
}
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
/** One resolution against the current atomic host state view (settings +
|
||||
* normalized catalog + detection snapshot + derived target). The caller closes
|
||||
* over the fixed request (selection/intent/reference/variables/target) and
|
||||
* re-reads volatile host state on each call; it performs no async I/O so it is
|
||||
* safe to invoke inside the coordinator's critical section. */
|
||||
export type HostStateResolution = {
|
||||
outcome: ResolveAgentLaunchOutcome
|
||||
catalogRevision: number
|
||||
}
|
||||
|
||||
export type ExecuteAgentLaunchArgs = {
|
||||
/** Owner scope for reconciliation joins (worktree id, pane key, run id …). */
|
||||
scope: string
|
||||
/** Target worktree for the per-worktree admission cap. Omit/null when the
|
||||
* launch names no worktree (the scope already IS the worktree for interactive
|
||||
* worktree launches, but unattended launches scope by run/dispatch/attempt id
|
||||
* and must name the worktree separately). */
|
||||
worktreeId?: string | null
|
||||
principal: AdmissionPrincipal
|
||||
/** Re-resolve from a fresh atomic host view. Called once before admission and
|
||||
* once inside the coordinator; both re-read settings. */
|
||||
resolve: () => HostStateResolution
|
||||
prompt: string
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
/** 'draft' lands the prompt unsubmitted; default 'submit'. */
|
||||
promptDelivery?: 'submit' | 'draft'
|
||||
/** Inline draft-flag command ceiling (STARTUP_COMMAND_TEXT_MAX_CHARS), threaded
|
||||
* from the provider layer so the shared plan builder stays main-free. */
|
||||
maxInlineDraftChars?: number
|
||||
/** Trust preflight, OUTSIDE the coordinator. A throw maps to
|
||||
* trust_preflight_failed and commits no admission record. */
|
||||
preflight?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
/** Provider env preparation, OUTSIDE the coordinator. Same failure mapping as
|
||||
* preflight: a pre-spawn preparation throw is a trust_preflight_failed with
|
||||
* no admission record (no dedicated failure code exists for this phase). */
|
||||
prepareEnv?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export type ExecuteAgentLaunchResult =
|
||||
| { ok: true; plan: AgentStartupPlan; receipt: AgentLaunchReceipt }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
| { ok: false; requestError: AgentLaunchRequestError }
|
||||
|
||||
/** Resolve-only startup-plan request for the legacy renderer-spawned worktree-
|
||||
* create path. It resolves once against the atomic host view and builds a plan,
|
||||
* but takes NO admission token — that path registers no terminal receipt and has
|
||||
* no settle seam, so an admitted hold would leak capacity forever. */
|
||||
export type ResolveAgentLaunchPlanArgs = {
|
||||
resolve: () => HostStateResolution
|
||||
prompt: string
|
||||
allowEmptyPromptLaunch?: boolean
|
||||
promptDelivery?: 'submit' | 'draft'
|
||||
maxInlineDraftChars?: number
|
||||
}
|
||||
|
||||
export type ResolveAgentLaunchPlanResult =
|
||||
| { ok: true; plan: AgentStartupPlan }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
| { ok: false; requestError: AgentLaunchRequestError }
|
||||
|
||||
/** Resolve once and build a startup plan without admitting. Extracted from the
|
||||
* boundary class because it holds no admission/coordinator state — it is the
|
||||
* legacy path's whole pipeline. */
|
||||
export function resolveAgentLaunchPlanWithoutAdmission(
|
||||
args: ResolveAgentLaunchPlanArgs
|
||||
): ResolveAgentLaunchPlanResult {
|
||||
const resolution = args.resolve()
|
||||
if (!resolution.outcome.ok) {
|
||||
if ('requestError' in resolution.outcome) {
|
||||
return { ok: false, requestError: resolution.outcome.requestError }
|
||||
}
|
||||
return { ok: false, failure: resolution.outcome.failure }
|
||||
}
|
||||
const original = resolution.outcome.launch
|
||||
const plan = buildAgentStartupPlanFromResolvedLaunch({
|
||||
launch: original,
|
||||
prompt: args.prompt,
|
||||
...(args.allowEmptyPromptLaunch !== undefined
|
||||
? { allowEmptyPromptLaunch: args.allowEmptyPromptLaunch }
|
||||
: {}),
|
||||
...(args.promptDelivery !== undefined ? { promptDelivery: args.promptDelivery } : {}),
|
||||
...(args.maxInlineDraftChars !== undefined
|
||||
? { maxInlineDraftChars: args.maxInlineDraftChars }
|
||||
: {})
|
||||
// No launchToken: nothing is admitted, so there is nothing to reconcile.
|
||||
})
|
||||
if (!plan) {
|
||||
return { ok: false, failure: { code: 'no_agent_selected', ...agentIds(original) } }
|
||||
}
|
||||
return { ok: true, plan }
|
||||
}
|
||||
|
||||
export type PrepareReservedAgentLaunchArgs = {
|
||||
principal: AdmissionPrincipal
|
||||
/** Resolve selection (may be `default`) against the atomic host view with
|
||||
* provisional variables — the worktree path is not yet authoritative. Only
|
||||
* the pinned identity + config-only digest survive; the argv is discarded. */
|
||||
resolve: () => HostStateResolution
|
||||
}
|
||||
|
||||
/** Pre-create outcome held across git mutation. The reservation must be
|
||||
* converted by executeReservedAgentLaunch or dropped via releaseReservation on
|
||||
* every pre-spawn exit; the caller owns that lifecycle. */
|
||||
export type PrepareReservedAgentLaunchResult =
|
||||
| {
|
||||
ok: true
|
||||
reservationId: string
|
||||
requestedAgent: ResolvedAgentLaunch['requestedAgent']
|
||||
baseAgent: ResolvedAgentLaunch['baseAgent']
|
||||
stableInputDigest: string
|
||||
}
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
| { ok: false; requestError: AgentLaunchRequestError }
|
||||
|
||||
export type ExecuteReservedAgentLaunchArgs = ExecuteAgentLaunchArgs & {
|
||||
/** The hold taken by prepareReservedAgentLaunch. */
|
||||
reservationId: string
|
||||
/** The config-only digest pinned pre-create; a post-create mismatch means the
|
||||
* definition/default/base changed across the git operation. */
|
||||
expectedStableInputDigest: string
|
||||
}
|
||||
|
||||
/** Terminal outcome the caller boundary reports back so admission moves or
|
||||
* releases: `registered` keeps a private reconciliation handoff record, while
|
||||
* `failed` releases the reservation entirely. */
|
||||
export type LaunchSettlement = 'registered' | 'failed'
|
||||
@@ -0,0 +1,22 @@
|
||||
// Host-wide singleton launch boundary. Admission bounds (256 host / 64 principal
|
||||
// / 192 remote) are host-scoped, not per-profile, so one boundary — with one
|
||||
// admission store and one coordinator — serves every launch surface. U4 attaches
|
||||
// durable persistence; U3 uses the in-memory boundary.
|
||||
|
||||
import { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator
|
||||
} from './agent-launch-admission-store'
|
||||
|
||||
let boundary: AgentLaunchBoundary | null = null
|
||||
|
||||
export function getHostAgentLaunchBoundary(): AgentLaunchBoundary {
|
||||
if (!boundary) {
|
||||
boundary = new AgentLaunchBoundary({
|
||||
admissionStore: new AgentLaunchAdmissionStore(),
|
||||
coordinator: new LaunchAdmissionCoordinator()
|
||||
})
|
||||
}
|
||||
return boundary
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AgentLaunchBoundary,
|
||||
mapClientKindToLaunchClient,
|
||||
type HostStateResolution
|
||||
} from './agent-launch-boundary'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator,
|
||||
MAX_PENDING_LAUNCHES_PER_PRINCIPAL,
|
||||
type AdmissionPrincipal
|
||||
} from './agent-launch-admission-store'
|
||||
import type {
|
||||
ResolvedAgentLaunch,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
|
||||
const LOCAL_PRINCIPAL: AdmissionPrincipal = { kind: 'local' }
|
||||
|
||||
function makeSnapshot(overrides: Partial<AgentLaunchSnapshot> = {}): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['/bin/secretexe', '--flag'],
|
||||
agentEnv: { SECRET_ENV: 'topsecret-value' },
|
||||
capturedEnvPolicy: 'full',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function makeLaunch(
|
||||
fingerprint: string,
|
||||
overrides: Partial<ResolvedAgentLaunch> = {}
|
||||
): ResolvedAgentLaunch {
|
||||
const snapshot = overrides.snapshot ?? makeSnapshot()
|
||||
return {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
argv: snapshot.argv,
|
||||
agentEnv: snapshot.agentEnv,
|
||||
variables: { values: { repoPath: null, worktreePath: null }, referenced: [] },
|
||||
snapshot,
|
||||
policy: {
|
||||
intent: 'interactive',
|
||||
mode: 'built-in',
|
||||
client: 'desktop',
|
||||
isRemote: false,
|
||||
platform: 'linux',
|
||||
promptInjectionMode: 'stdin-after-start',
|
||||
expectedProcess: 'claude',
|
||||
env: 'full'
|
||||
},
|
||||
notices: [],
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false },
|
||||
admissionGuard: { fingerprint, stableInputDigest: fingerprint, basis: 'explicit' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function okResolution(launch: ResolvedAgentLaunch, catalogRevision = 1): HostStateResolution {
|
||||
return { outcome: { ok: true, launch }, catalogRevision }
|
||||
}
|
||||
|
||||
function failureResolution(
|
||||
outcome: Extract<ResolveAgentLaunchOutcome, { ok: false }>,
|
||||
catalogRevision = 1
|
||||
): HostStateResolution {
|
||||
return { outcome, catalogRevision }
|
||||
}
|
||||
|
||||
function makeBoundary(): {
|
||||
boundary: AgentLaunchBoundary
|
||||
store: AgentLaunchAdmissionStore
|
||||
} {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const boundary = new AgentLaunchBoundary({
|
||||
admissionStore: store,
|
||||
coordinator: new LaunchAdmissionCoordinator(),
|
||||
now: () => 1000
|
||||
})
|
||||
return { boundary, store }
|
||||
}
|
||||
|
||||
describe('mapClientKindToLaunchClient', () => {
|
||||
it('maps runtime to paired-web, mobile to mobile, undefined to desktop', () => {
|
||||
expect(mapClientKindToLaunchClient('runtime')).toBe('paired-web')
|
||||
expect(mapClientKindToLaunchClient('mobile')).toBe('mobile')
|
||||
expect(mapClientKindToLaunchClient(undefined)).toBe('desktop')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLaunchBoundary.executeAgentLaunch', () => {
|
||||
it('resolves for the plan once and admits the original snapshot', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const original = makeLaunch('fp-1')
|
||||
// Second resolve returns a distinct launch object with the SAME fingerprint
|
||||
// (an unrelated catalog edit). The admitted snapshot must be the original.
|
||||
const reResolveLaunch = makeLaunch('fp-1', {
|
||||
snapshot: makeSnapshot({ displayLabel: 'edited' })
|
||||
})
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValueOnce(okResolution(original))
|
||||
.mockReturnValueOnce(okResolution(reResolveLaunch, 2))
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 'worktree-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: '',
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const admitted = store.get(result.receipt.launchToken)
|
||||
expect(admitted?.snapshot).toBe(original.snapshot)
|
||||
expect(result.receipt.catalogRevision).toBe(2)
|
||||
})
|
||||
|
||||
it('never serializes snapshot argv/env, fingerprint, or digest into the launched receipt', async () => {
|
||||
const { boundary } = makeBoundary()
|
||||
const launch = makeLaunch('fp-secret', {
|
||||
admissionGuard: {
|
||||
fingerprint: 'fp-secret',
|
||||
stableInputDigest: 'digest-secret',
|
||||
basis: 'explicit'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 'worktree-secret',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(launch),
|
||||
prompt: '',
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
// The receipt is the ONLY agent-launch payload that crosses to clients, so it
|
||||
// must carry the client-safe identity/notices/token only — never the host-
|
||||
// private snapshot argv/env, the relevant-input fingerprint, or the digest.
|
||||
expect(Object.keys(result.receipt).sort()).toEqual([
|
||||
'baseAgent',
|
||||
'catalogRevision',
|
||||
'launchToken',
|
||||
'notices',
|
||||
'requestedAgent',
|
||||
'telemetry'
|
||||
])
|
||||
// Oracle 17: the receipt's telemetry marker is client-safe — the base kind
|
||||
// enum and a boolean only, never the requested (possibly custom) id or label.
|
||||
expect(Object.keys(result.receipt.telemetry).sort()).toEqual(['agentKind', 'usedCustomAgent'])
|
||||
expect(typeof result.receipt.telemetry.usedCustomAgent).toBe('boolean')
|
||||
const serialized = JSON.stringify(result.receipt)
|
||||
expect(serialized).not.toContain('secretexe') // snapshot argv executable
|
||||
expect(serialized).not.toContain('topsecret-value') // snapshot agentEnv value
|
||||
expect(serialized).not.toContain('SECRET_ENV') // snapshot agentEnv key
|
||||
expect(serialized).not.toContain('fp-secret') // relevant-input fingerprint
|
||||
expect(serialized).not.toContain('digest-secret') // config-only digest
|
||||
})
|
||||
|
||||
it('returns the initial failure without admitting', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() =>
|
||||
failureResolution({ ok: false, failure: { code: 'no_agent_selected' } })
|
||||
)
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'no_agent_selected' } })
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('returns an initial request error without admitting', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() =>
|
||||
failureResolution({ ok: false, requestError: { code: 'untrusted_reference' } })
|
||||
)
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } })
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('maps a base disable that wins the admission race to base_agent_disabled with no reservation', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValueOnce(okResolution(makeLaunch('fp-1')))
|
||||
// Mutation committed between resolve and admit: base is now disabled.
|
||||
.mockReturnValueOnce(
|
||||
failureResolution({
|
||||
ok: false,
|
||||
failure: { code: 'base_agent_disabled', baseAgent: 'claude' }
|
||||
})
|
||||
)
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('base_agent_disabled')
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('maps any other relevant change to agent_configuration_changed', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValueOnce(okResolution(makeLaunch('fp-1')))
|
||||
// Different fingerprint: a relevant input changed (definition/default/env).
|
||||
.mockReturnValueOnce(okResolution(makeLaunch('fp-2')))
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('agent_configuration_changed')
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('proceeds when only an unrelated agent changed (fingerprint unchanged)', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValueOnce(okResolution(makeLaunch('fp-1')))
|
||||
.mockReturnValueOnce(okResolution(makeLaunch('fp-1')))
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(store.pendingCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('fails trust_preflight_failed on a thrown preflight and admits nothing', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
const preflight = vi.fn(() => {
|
||||
throw new Error('trust denied')
|
||||
})
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi',
|
||||
preflight
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('trust_preflight_failed')
|
||||
expect(preflight).toHaveBeenCalledTimes(1)
|
||||
// No re-resolve happened because we never entered the coordinator.
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('fails trust_preflight_failed on a thrown provider env preparation hook', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
const prepareEnv = vi.fn(async () => {
|
||||
throw new Error('env prep failed')
|
||||
})
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi',
|
||||
prepareEnv
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('trust_preflight_failed')
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects with launch_capacity_exceeded before producing a plan', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
for (let index = 0; index < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; index += 1) {
|
||||
store.admit({
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
intent: 'interactive',
|
||||
scope: `filler-${index}`,
|
||||
worktreeId: null,
|
||||
fingerprint: 'x',
|
||||
snapshot: makeSnapshot(),
|
||||
admittedAt: 1
|
||||
})
|
||||
}
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('launch_capacity_exceeded')
|
||||
expect('plan' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('produces a receipt free of argv, env, and snapshot material', async () => {
|
||||
const { boundary } = makeBoundary()
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'do the thing'
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const serialized = JSON.stringify(result.receipt)
|
||||
expect(serialized).not.toContain('topsecret-value')
|
||||
expect(serialized).not.toContain('secretexe')
|
||||
expect(serialized).not.toContain('SECRET_ENV')
|
||||
expect(result.receipt.launchToken.length).toBeGreaterThan(0)
|
||||
// The plan carries the token; the receipt echoes it for the caller.
|
||||
expect(result.plan.launchToken).toBe(result.receipt.launchToken)
|
||||
})
|
||||
|
||||
it('deduplicates receipt notices by code', async () => {
|
||||
const { boundary } = makeBoundary()
|
||||
const launch = makeLaunch('fp-1', {
|
||||
notices: [
|
||||
{ code: 'env_withheld', label: 'Claude' },
|
||||
{ code: 'env_withheld', label: 'Claude' },
|
||||
{ code: 'snapshot_definition_changed', label: 'Claude' }
|
||||
]
|
||||
})
|
||||
const resolve = vi.fn(() => okResolution(launch))
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 's',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.receipt.notices.map((notice) => notice.code)).toEqual([
|
||||
'env_withheld',
|
||||
'snapshot_definition_changed'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLaunchBoundary.settleAgentLaunch', () => {
|
||||
it('registered retains a private handoff record and frees the reservation', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 'worktree-9',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const token = result.receipt.launchToken
|
||||
boundary.settleAgentLaunch(token, 'registered')
|
||||
expect(store.get(token)).toBeNull()
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
expect(boundary.retainedFor(token)?.scope).toBe('worktree-9')
|
||||
})
|
||||
|
||||
it('failed releases the reservation and retains nothing', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi.fn(() => okResolution(makeLaunch('fp-1')))
|
||||
const result = await boundary.executeAgentLaunch({
|
||||
scope: 'worktree-9',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const token = result.receipt.launchToken
|
||||
boundary.settleAgentLaunch(token, 'failed')
|
||||
expect(store.get(token)).toBeNull()
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
expect(boundary.retainedFor(token)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLaunchBoundary.resolveAgentLaunchPlanWithoutAdmission', () => {
|
||||
it('resolves once and builds a plan without an admission token or capacity hold', () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValue(okResolution(makeLaunch('fp-1')))
|
||||
|
||||
const result = boundary.resolveAgentLaunchPlanWithoutAdmission({
|
||||
resolve,
|
||||
prompt: '',
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
|
||||
// The legacy path resolves exactly once — no coordinator re-resolve — and
|
||||
// never admits: the plan carries no launchToken and no capacity is held.
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.plan.launchToken).toBeUndefined()
|
||||
// All capacity is still free: nothing was reserved or admitted.
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i++) {
|
||||
expect(store.reserve(LOCAL_PRINCIPAL).ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the resolver failure without holding capacity', () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValue(failureResolution({ ok: false, failure: { code: 'base_agent_disabled' } }))
|
||||
|
||||
const result = boundary.resolveAgentLaunchPlanWithoutAdmission({ resolve, prompt: 'hi' })
|
||||
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'base_agent_disabled' } })
|
||||
for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i++) {
|
||||
expect(store.reserve(LOCAL_PRINCIPAL).ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('passes through a request-error resolution', () => {
|
||||
const { boundary } = makeBoundary()
|
||||
const result = boundary.resolveAgentLaunchPlanWithoutAdmission({
|
||||
resolve: () =>
|
||||
failureResolution({ ok: false, requestError: { code: 'stale_agent_launch_failure' } }),
|
||||
prompt: 'hi'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentLaunchBoundary two-stage reserved launch', () => {
|
||||
function digestLaunch(fingerprint: string, stableInputDigest: string): ResolvedAgentLaunch {
|
||||
return makeLaunch(fingerprint, {
|
||||
admissionGuard: { fingerprint, stableInputDigest, basis: 'explicit' }
|
||||
})
|
||||
}
|
||||
|
||||
function prepareHold(
|
||||
boundary: AgentLaunchBoundary,
|
||||
launch: ResolvedAgentLaunch
|
||||
): { reservationId: string; stableInputDigest: string } {
|
||||
const prepared = boundary.prepareReservedAgentLaunch({
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(launch)
|
||||
})
|
||||
if (!prepared.ok) {
|
||||
throw new Error('expected prepare to succeed')
|
||||
}
|
||||
return { reservationId: prepared.reservationId, stableInputDigest: prepared.stableInputDigest }
|
||||
}
|
||||
|
||||
it('prepare pins identity + digest and holds one reservation before git', () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const prepared = boundary.prepareReservedAgentLaunch({
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(digestLaunch('fp-1', 'stable-A'))
|
||||
})
|
||||
expect(prepared.ok).toBe(true)
|
||||
if (!prepared.ok) {
|
||||
return
|
||||
}
|
||||
expect(prepared.requestedAgent).toBe('claude')
|
||||
expect(prepared.stableInputDigest).toBe('stable-A')
|
||||
// The hold counts toward capacity but is not a committed token yet.
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(1)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('prepare takes no reservation when the pin resolve fails', () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const prepared = boundary.prepareReservedAgentLaunch({
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => failureResolution({ ok: false, failure: { code: 'no_agent_selected' } })
|
||||
})
|
||||
expect(prepared.ok).toBe(false)
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
})
|
||||
|
||||
it('prepare rejects launch_capacity_exceeded without leaking a hold', () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
for (let index = 0; index < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; index += 1) {
|
||||
store.reserve(LOCAL_PRINCIPAL)
|
||||
}
|
||||
const prepared = boundary.prepareReservedAgentLaunch({
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(makeLaunch('fp-1'))
|
||||
})
|
||||
expect(prepared.ok).toBe(false)
|
||||
if (prepared.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in prepared && prepared.failure.code).toBe('launch_capacity_exceeded')
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(MAX_PENDING_LAUNCHES_PER_PRINCIPAL)
|
||||
})
|
||||
|
||||
it('executeReserved converts the hold to exactly one token on success', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const launch = digestLaunch('fp-1', 'stable-A')
|
||||
const { reservationId, stableInputDigest } = prepareHold(boundary, launch)
|
||||
const result = await boundary.executeReservedAgentLaunch({
|
||||
scope: 'wt-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(launch),
|
||||
prompt: 'hi',
|
||||
reservationId,
|
||||
expectedStableInputDigest: stableInputDigest
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
// Converted, not double-counted: one committed record, no dangling hold.
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(1)
|
||||
expect(store.pendingCount()).toBe(1)
|
||||
if (result.ok) {
|
||||
expect(store.get(result.receipt.launchToken)?.snapshot).toBe(launch.snapshot)
|
||||
}
|
||||
})
|
||||
|
||||
it('executeReserved releases the hold when the post-create config digest differs', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const { reservationId } = prepareHold(boundary, digestLaunch('fp-1', 'stable-A'))
|
||||
const resolve = vi.fn(() => okResolution(digestLaunch('fp-2', 'stable-B')))
|
||||
const result = await boundary.executeReservedAgentLaunch({
|
||||
scope: 'wt-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi',
|
||||
reservationId,
|
||||
expectedStableInputDigest: 'stable-A'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('agent_configuration_changed')
|
||||
// Hold released, no capacity leaked; rejected before entering the coordinator.
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
expect(resolve).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('executeReserved releases the hold when the post-create resolve fails', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const { reservationId } = prepareHold(boundary, digestLaunch('fp-1', 'stable-A'))
|
||||
const result = await boundary.executeReservedAgentLaunch({
|
||||
scope: 'wt-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () =>
|
||||
failureResolution({
|
||||
ok: false,
|
||||
failure: { code: 'missing_variable', variable: 'worktreePath' }
|
||||
}),
|
||||
prompt: 'hi',
|
||||
reservationId,
|
||||
expectedStableInputDigest: 'stable-A'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
})
|
||||
|
||||
it('executeReserved releases the hold on a thrown preflight', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const launch = digestLaunch('fp-1', 'stable-A')
|
||||
const { reservationId } = prepareHold(boundary, launch)
|
||||
const result = await boundary.executeReservedAgentLaunch({
|
||||
scope: 'wt-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve: () => okResolution(launch),
|
||||
prompt: 'hi',
|
||||
preflight: () => {
|
||||
throw new Error('trust denied')
|
||||
},
|
||||
reservationId,
|
||||
expectedStableInputDigest: 'stable-A'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('trust_preflight_failed')
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
})
|
||||
|
||||
it('executeReserved releases the hold on an in-coordinator fingerprint mismatch', async () => {
|
||||
const { boundary, store } = makeBoundary()
|
||||
const pinned = digestLaunch('fp-1', 'stable-A')
|
||||
const { reservationId } = prepareHold(boundary, pinned)
|
||||
// Pre-coordinator resolve matches the pin; the in-coordinator re-resolve keeps
|
||||
// the same config digest but a changed fingerprint (a relevant edit committed).
|
||||
const resolve = vi
|
||||
.fn<() => HostStateResolution>()
|
||||
.mockReturnValueOnce(okResolution(pinned))
|
||||
.mockReturnValueOnce(okResolution(digestLaunch('fp-9', 'stable-A')))
|
||||
const result = await boundary.executeReservedAgentLaunch({
|
||||
scope: 'wt-1',
|
||||
principal: LOCAL_PRINCIPAL,
|
||||
resolve,
|
||||
prompt: 'hi',
|
||||
reservationId,
|
||||
expectedStableInputDigest: 'stable-A'
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in result && result.failure.code).toBe('agent_configuration_changed')
|
||||
expect(store.pendingForPrincipal(LOCAL_PRINCIPAL)).toBe(0)
|
||||
expect(resolve).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,351 @@
|
||||
// The single host boundary every agent spawn routes through (U3). It sequences
|
||||
// the launch pipeline exactly per plan §3's admission paragraph: resolve once
|
||||
// from an atomic host view, run trust/provider-env preparation OUTSIDE the
|
||||
// admission coordinator, then INSIDE the coordinator re-take the host view,
|
||||
// recompute the relevant-input fingerprint, and commit the admitted token/
|
||||
// snapshot before any provider I/O. The startup plan is built from the ORIGINAL
|
||||
// resolved launch: admission commits that snapshot and later edits affect only
|
||||
// future launches. Dependency-injected and electron-free so it is unit-testable.
|
||||
|
||||
import type {
|
||||
AdmissionCapacityRow,
|
||||
AdmissionPrincipal,
|
||||
AgentLaunchAdmissionStore,
|
||||
AdmittedLaunchRecord,
|
||||
LaunchAdmissionCoordinator
|
||||
} from './agent-launch-admission-store'
|
||||
import type { ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import { buildAgentStartupPlanFromResolvedLaunch } from '../../shared/resolved-agent-startup-plan'
|
||||
import {
|
||||
agentIds,
|
||||
dedupeNoticesByCode,
|
||||
mapAdmissionMismatch,
|
||||
resolveAgentLaunchPlanWithoutAdmission,
|
||||
type ExecuteAgentLaunchArgs,
|
||||
type ExecuteAgentLaunchResult,
|
||||
type ExecuteReservedAgentLaunchArgs,
|
||||
type LaunchSettlement,
|
||||
type PrepareReservedAgentLaunchArgs,
|
||||
type PrepareReservedAgentLaunchResult,
|
||||
type ResolveAgentLaunchPlanArgs,
|
||||
type ResolveAgentLaunchPlanResult
|
||||
} from './agent-launch-boundary-contract'
|
||||
|
||||
// Re-export the boundary contract so existing importers keep a single entry.
|
||||
export * from './agent-launch-boundary-contract'
|
||||
|
||||
type CriticalResult =
|
||||
| { kind: 'admitted'; record: AdmittedLaunchRecord; catalogRevision: number }
|
||||
| { kind: 'failure'; failure: AgentLaunchFailure }
|
||||
| { kind: 'requestError'; requestError: AgentLaunchRequestError }
|
||||
|
||||
export class AgentLaunchBoundary {
|
||||
private readonly admissionStore: AgentLaunchAdmissionStore
|
||||
private readonly coordinator: LaunchAdmissionCoordinator
|
||||
private readonly now: () => number
|
||||
/** Private reconciliation handoff for registered launches; U4 replaces this
|
||||
* with the durable operation ledger. Never serialized to clients/logs. */
|
||||
private readonly retained = new Map<string, AdmittedLaunchRecord>()
|
||||
|
||||
constructor(deps: {
|
||||
admissionStore: AgentLaunchAdmissionStore
|
||||
coordinator: LaunchAdmissionCoordinator
|
||||
now?: () => number
|
||||
}) {
|
||||
this.admissionStore = deps.admissionStore
|
||||
this.coordinator = deps.coordinator
|
||||
this.now = deps.now ?? (() => Date.now())
|
||||
}
|
||||
|
||||
async executeAgentLaunch(args: ExecuteAgentLaunchArgs): Promise<ExecuteAgentLaunchResult> {
|
||||
const nowFn = args.now ?? this.now
|
||||
const initial = args.resolve()
|
||||
if (!initial.outcome.ok) {
|
||||
if ('requestError' in initial.outcome) {
|
||||
return { ok: false, requestError: initial.outcome.requestError }
|
||||
}
|
||||
return { ok: false, failure: initial.outcome.failure }
|
||||
}
|
||||
const original = initial.outcome.launch
|
||||
const originalFingerprint = original.admissionGuard.fingerprint
|
||||
|
||||
const prepFailure = await this.runPreparation(args, original)
|
||||
if (prepFailure) {
|
||||
return { ok: false, failure: prepFailure }
|
||||
}
|
||||
|
||||
const result = await this.coordinator.runExclusive<CriticalResult>(() =>
|
||||
this.admitInsideCoordinator(args.resolve, original, originalFingerprint, () =>
|
||||
this.admissionStore.admit({
|
||||
principal: args.principal,
|
||||
intent: original.policy.intent,
|
||||
scope: args.scope,
|
||||
worktreeId: args.worktreeId ?? null,
|
||||
fingerprint: originalFingerprint,
|
||||
snapshot: original.snapshot,
|
||||
admittedAt: nowFn()
|
||||
})
|
||||
)
|
||||
)
|
||||
if (result.kind === 'requestError') {
|
||||
return { ok: false, requestError: result.requestError }
|
||||
}
|
||||
if (result.kind === 'failure') {
|
||||
return { ok: false, failure: result.failure }
|
||||
}
|
||||
|
||||
return this.finalizeAdmittedLaunch(
|
||||
args,
|
||||
original,
|
||||
result.record.launchToken,
|
||||
result.catalogRevision
|
||||
)
|
||||
}
|
||||
|
||||
/** Build the startup plan from the ORIGINAL admitted launch and assemble the
|
||||
* receipt. Shared by the single-shot and two-stage paths. A null plan
|
||||
* (nothing launchable) releases the admitted token rather than stranding it. */
|
||||
private finalizeAdmittedLaunch(
|
||||
args: ExecuteAgentLaunchArgs,
|
||||
original: ResolvedAgentLaunch,
|
||||
token: string,
|
||||
catalogRevision: number
|
||||
): ExecuteAgentLaunchResult {
|
||||
const plan = buildAgentStartupPlanFromResolvedLaunch({
|
||||
launch: original,
|
||||
prompt: args.prompt,
|
||||
...(args.allowEmptyPromptLaunch !== undefined
|
||||
? { allowEmptyPromptLaunch: args.allowEmptyPromptLaunch }
|
||||
: {}),
|
||||
...(args.promptDelivery !== undefined ? { promptDelivery: args.promptDelivery } : {}),
|
||||
...(args.maxInlineDraftChars !== undefined
|
||||
? { maxInlineDraftChars: args.maxInlineDraftChars }
|
||||
: {}),
|
||||
launchToken: token
|
||||
})
|
||||
if (!plan) {
|
||||
this.admissionStore.release(token)
|
||||
return {
|
||||
ok: false,
|
||||
failure: {
|
||||
code: 'no_agent_selected',
|
||||
requestedAgent: original.requestedAgent,
|
||||
baseAgent: original.baseAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
plan,
|
||||
receipt: {
|
||||
requestedAgent: original.requestedAgent,
|
||||
baseAgent: original.baseAgent,
|
||||
notices: dedupeNoticesByCode(original.notices),
|
||||
launchToken: token,
|
||||
catalogRevision,
|
||||
telemetry: original.telemetry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve-only entry for the legacy renderer-spawned worktree-create startup
|
||||
* path: it resolves once from the atomic host view and builds a plan, but
|
||||
* never admits — that path registers no terminal receipt and has no settle
|
||||
* seam, so an admitted hold would leak capacity forever. One-release
|
||||
* compatibility code, deleted with the legacy startupAgent/startupDraft fields. */
|
||||
resolveAgentLaunchPlanWithoutAdmission(
|
||||
args: ResolveAgentLaunchPlanArgs
|
||||
): ResolveAgentLaunchPlanResult {
|
||||
return resolveAgentLaunchPlanWithoutAdmission(args)
|
||||
}
|
||||
|
||||
/** Pre-create stage of a two-stage worktree launch: resolve once to pin the
|
||||
* concrete identity + capture the config-only digest, then take a capacity
|
||||
* hold — all BEFORE git mutation so launch_capacity_exceeded precedes any
|
||||
* side effect. The resolved argv is intentionally discarded; only the pinned
|
||||
* identity + digest + reservation survive. */
|
||||
prepareReservedAgentLaunch(
|
||||
args: PrepareReservedAgentLaunchArgs
|
||||
): PrepareReservedAgentLaunchResult {
|
||||
const resolution = args.resolve()
|
||||
if (!resolution.outcome.ok) {
|
||||
if ('requestError' in resolution.outcome) {
|
||||
return { ok: false, requestError: resolution.outcome.requestError }
|
||||
}
|
||||
return { ok: false, failure: resolution.outcome.failure }
|
||||
}
|
||||
const reservation = this.admissionStore.reserve(args.principal)
|
||||
if (!reservation.ok) {
|
||||
return { ok: false, failure: reservation.failure }
|
||||
}
|
||||
const launch = resolution.outcome.launch
|
||||
return {
|
||||
ok: true,
|
||||
reservationId: reservation.reservation.reservationId,
|
||||
requestedAgent: launch.requestedAgent,
|
||||
baseAgent: launch.baseAgent,
|
||||
stableInputDigest: launch.admissionGuard.stableInputDigest
|
||||
}
|
||||
}
|
||||
|
||||
/** Post-create stage: resolve with authoritative paths and the pinned
|
||||
* identity, recheck the config-only digest against the pin (a mismatch is a
|
||||
* config change across the git operation → agent_configuration_changed), then
|
||||
* convert the held reservation to a token/snapshot inside the coordinator.
|
||||
* The reservation is released on EVERY post-reserve exit so a failed launch
|
||||
* never permanently burns capacity. */
|
||||
async executeReservedAgentLaunch(
|
||||
args: ExecuteReservedAgentLaunchArgs
|
||||
): Promise<ExecuteAgentLaunchResult> {
|
||||
const nowFn = args.now ?? this.now
|
||||
const initial = args.resolve()
|
||||
if (!initial.outcome.ok) {
|
||||
this.admissionStore.releaseReservation(args.reservationId)
|
||||
if ('requestError' in initial.outcome) {
|
||||
return { ok: false, requestError: initial.outcome.requestError }
|
||||
}
|
||||
return { ok: false, failure: initial.outcome.failure }
|
||||
}
|
||||
const original = initial.outcome.launch
|
||||
if (original.admissionGuard.stableInputDigest !== args.expectedStableInputDigest) {
|
||||
this.admissionStore.releaseReservation(args.reservationId)
|
||||
return { ok: false, failure: { code: 'agent_configuration_changed', ...agentIds(original) } }
|
||||
}
|
||||
|
||||
const prepFailure = await this.runPreparation(args, original)
|
||||
if (prepFailure) {
|
||||
this.admissionStore.releaseReservation(args.reservationId)
|
||||
return { ok: false, failure: prepFailure }
|
||||
}
|
||||
|
||||
const originalFingerprint = original.admissionGuard.fingerprint
|
||||
const result = await this.coordinator.runExclusive<CriticalResult>(() =>
|
||||
this.admitInsideCoordinator(args.resolve, original, originalFingerprint, () =>
|
||||
this.admissionStore.admitReserved(args.reservationId, {
|
||||
intent: original.policy.intent,
|
||||
scope: args.scope,
|
||||
worktreeId: args.worktreeId ?? null,
|
||||
fingerprint: originalFingerprint,
|
||||
snapshot: original.snapshot,
|
||||
admittedAt: nowFn()
|
||||
})
|
||||
)
|
||||
)
|
||||
if (result.kind === 'requestError') {
|
||||
this.admissionStore.releaseReservation(args.reservationId)
|
||||
return { ok: false, requestError: result.requestError }
|
||||
}
|
||||
if (result.kind === 'failure') {
|
||||
// admitReserved was either never reached (fingerprint mismatch) or failed;
|
||||
// the hold is still ours to release.
|
||||
this.admissionStore.releaseReservation(args.reservationId)
|
||||
return { ok: false, failure: result.failure }
|
||||
}
|
||||
// admitReserved consumed the reservation into result.record's token.
|
||||
return this.finalizeAdmittedLaunch(
|
||||
args,
|
||||
original,
|
||||
result.record.launchToken,
|
||||
result.catalogRevision
|
||||
)
|
||||
}
|
||||
|
||||
/** Drop a held pre-create reservation when the caller aborts BEFORE
|
||||
* executeReservedAgentLaunch — e.g. the git worktree creation threw between
|
||||
* prepare and execute. Frees the capacity a leaked hold would burn forever. */
|
||||
releaseReservedAgentLaunch(reservationId: string): void {
|
||||
this.admissionStore.releaseReservation(reservationId)
|
||||
}
|
||||
|
||||
/** Move or release the admission reservation once the caller's writer settled.
|
||||
* Registered retains a private handoff record; failed releases entirely. */
|
||||
settleAgentLaunch(launchToken: string, settlement: LaunchSettlement): void {
|
||||
if (settlement === 'registered') {
|
||||
const record = this.admissionStore.get(launchToken)
|
||||
if (record) {
|
||||
this.retained.set(launchToken, record)
|
||||
}
|
||||
}
|
||||
this.admissionStore.release(launchToken)
|
||||
}
|
||||
|
||||
/** Private reconciliation lookup; never returned to clients. */
|
||||
retainedFor(launchToken: string): AdmittedLaunchRecord | null {
|
||||
return this.retained.get(launchToken) ?? null
|
||||
}
|
||||
|
||||
/** Host-only accessor for the admitted-but-unsettled snapshot, so the created-
|
||||
* path transition can persist it into the private pending-snapshot store in the
|
||||
* same write as the public pending metadata. Never serialized to clients/logs. */
|
||||
pendingSnapshotFor(launchToken: string): AdmittedLaunchRecord['snapshot'] | null {
|
||||
return this.admissionStore.get(launchToken)?.snapshot ?? null
|
||||
}
|
||||
|
||||
/** Redacted capacity-recovery rows for the pending-summary surface, filtered to
|
||||
* the caller's own principal. Keeps the admission store private; the runtime
|
||||
* drops the launch token before projecting to the client DTO. */
|
||||
capacitySummaryFor(principal: AdmissionPrincipal): AdmissionCapacityRow[] {
|
||||
return this.admissionStore.capacitySummaryFor(principal)
|
||||
}
|
||||
|
||||
private async runPreparation(
|
||||
args: ExecuteAgentLaunchArgs,
|
||||
original: ResolvedAgentLaunch
|
||||
): Promise<AgentLaunchFailure | null> {
|
||||
const preflightFailure = { code: 'trust_preflight_failed' as const, ...agentIds(original) }
|
||||
if (args.preflight) {
|
||||
try {
|
||||
await args.preflight(original)
|
||||
} catch {
|
||||
return preflightFailure
|
||||
}
|
||||
}
|
||||
if (args.prepareEnv) {
|
||||
try {
|
||||
await args.prepareEnv(original)
|
||||
} catch {
|
||||
return preflightFailure
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Coordinator critical section shared by the single-shot and two-stage
|
||||
* paths: re-take the atomic host view, recheck the relevant-input
|
||||
* fingerprint, then run the store admit step. No trust/fs/network/provider
|
||||
* I/O runs here. On the two-stage path a mismatch leaves the reservation
|
||||
* intact for the caller to release. */
|
||||
private admitInsideCoordinator(
|
||||
resolve: () => ReturnType<ExecuteAgentLaunchArgs['resolve']>,
|
||||
original: ResolvedAgentLaunch,
|
||||
originalFingerprint: string,
|
||||
admit: () => ReturnType<AgentLaunchAdmissionStore['admit']>
|
||||
): CriticalResult {
|
||||
const reResolution = resolve()
|
||||
if (!reResolution.outcome.ok) {
|
||||
if ('requestError' in reResolution.outcome) {
|
||||
return { kind: 'requestError', requestError: reResolution.outcome.requestError }
|
||||
}
|
||||
return { kind: 'failure', failure: mapAdmissionMismatch(reResolution.outcome.failure) }
|
||||
}
|
||||
if (reResolution.outcome.launch.admissionGuard.fingerprint !== originalFingerprint) {
|
||||
return {
|
||||
kind: 'failure',
|
||||
failure: { code: 'agent_configuration_changed', ...agentIds(original) }
|
||||
}
|
||||
}
|
||||
const admission = admit()
|
||||
if (!admission.ok) {
|
||||
return { kind: 'failure', failure: admission.failure }
|
||||
}
|
||||
return {
|
||||
kind: 'admitted',
|
||||
record: admission.record,
|
||||
catalogRevision: reResolution.catalogRevision
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Host-private admission fingerprint: a sha256 over only the inputs that could
|
||||
// change THIS launch. It is never logged, serialized, or returned to a client;
|
||||
// the admission coordinator (U3) recomputes it under a lock to detect a relevant
|
||||
// mutation between resolution and commit. Managed-provider inputs land in U3 —
|
||||
// the `managedProvider` slot is reserved so its shape stays stable.
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BuiltInTuiAgent, TuiAgent } from '../../shared/types'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
|
||||
export type AdmissionFingerprintBasis = 'explicit' | 'default' | 'snapshot'
|
||||
|
||||
export type AdmissionFingerprintInputs = {
|
||||
basis: AdmissionFingerprintBasis
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: BuiltInTuiAgent
|
||||
mode: 'built-in' | 'custom' | 'safe-fallback'
|
||||
/** Normalized definition digest (custom) or replay-policy digest (snapshot). */
|
||||
definitionDigest: string
|
||||
baseEnabled: boolean
|
||||
/** Applicable built-in command config (prefix override + default args) digest. */
|
||||
builtInCommandConfig: string
|
||||
variableValues: { repoPath: string | null; worktreePath: string | null }
|
||||
/** Authenticated remote-env authorization for this launch (full/withheld/none). */
|
||||
remoteEnvAuthorization: string
|
||||
/** Reserved for U3 managed-provider selection/defaults; empty until then. */
|
||||
managedProvider: string
|
||||
target: {
|
||||
platform: NodeJS.Platform
|
||||
execution: 'native' | 'wsl'
|
||||
shell: AgentStartupShell
|
||||
isRemote: boolean
|
||||
executionHostId: AgentLaunchExecutionHostId
|
||||
homePath: string | null
|
||||
}
|
||||
/** Transport-confidentiality capability, when known to the resolver. */
|
||||
transportConfidential: boolean | null
|
||||
}
|
||||
|
||||
/** Deterministic canonical JSON: object keys are emitted in sorted order so the
|
||||
* digest is stable across key-insertion order. */
|
||||
function canonicalize(value: unknown): string {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value) ?? 'null'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map(canonicalize).join(',')}]`
|
||||
}
|
||||
const record = value as Record<string, unknown>
|
||||
const keys = Object.keys(record).sort()
|
||||
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`).join(',')}}`
|
||||
}
|
||||
|
||||
/** Compute the host-private admission fingerprint. Never log or serialize the
|
||||
* return value or its source inputs. */
|
||||
export function computeAdmissionFingerprint(inputs: AdmissionFingerprintInputs): string {
|
||||
return createHash('sha256').update(canonicalize(inputs)).digest('hex')
|
||||
}
|
||||
|
||||
/** Stable digest of an object subset used inside the fingerprint (definition,
|
||||
* built-in command config). Keeps raw values out of the exposed structure. */
|
||||
export function digestObject(value: unknown): string {
|
||||
return createHash('sha256').update(canonicalize(value)).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
deriveAgentLaunchHostState,
|
||||
defaultTransportConfidentiality,
|
||||
describeSpawnExecutionHost,
|
||||
detectionUnavailable,
|
||||
executionHostIdForDescriptor,
|
||||
isRemoteForDescriptor,
|
||||
platformForDescriptor,
|
||||
resolveLocalTargetHomePath,
|
||||
toStockBaseAgentSet,
|
||||
type AgentLaunchHostDescriptor,
|
||||
type AgentLaunchHostStateDeps
|
||||
} from './agent-launch-host-state'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
|
||||
function makeDeps(overrides: Partial<AgentLaunchHostStateDeps> = {}): AgentLaunchHostStateDeps {
|
||||
return {
|
||||
getSettings: () => ({}) as GlobalSettings,
|
||||
getCatalogRevision: () => 3,
|
||||
detectStockBaseAgents: async () => ['claude', 'codex'],
|
||||
resolveTargetHomePath: async () => '/home/dev',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('executionHostIdForDescriptor', () => {
|
||||
it('maps each surface to its stable host id', () => {
|
||||
expect(executionHostIdForDescriptor({ kind: 'local', platform: 'darwin' })).toBe('local')
|
||||
expect(executionHostIdForDescriptor({ kind: 'wsl', distro: 'Ubuntu 22.04' })).toBe(
|
||||
'wsl:Ubuntu%2022.04'
|
||||
)
|
||||
expect(
|
||||
executionHostIdForDescriptor({ kind: 'ssh', connectionId: 'my host', platform: 'linux' })
|
||||
).toBe('ssh:my%20host')
|
||||
expect(
|
||||
executionHostIdForDescriptor({ kind: 'runtime', environmentId: 'env/1', platform: 'linux' })
|
||||
).toBe('runtime:env%2F1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('platformForDescriptor / isRemoteForDescriptor', () => {
|
||||
it('forces linux for WSL and keeps the named platform otherwise', () => {
|
||||
expect(platformForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe('linux')
|
||||
expect(platformForDescriptor({ kind: 'local', platform: 'win32' })).toBe('win32')
|
||||
expect(platformForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(
|
||||
'linux'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats SSH and default runtime as remote, local and WSL as local', () => {
|
||||
expect(isRemoteForDescriptor({ kind: 'local', platform: 'darwin' })).toBe(false)
|
||||
expect(isRemoteForDescriptor({ kind: 'wsl', distro: 'Ubuntu' })).toBe(false)
|
||||
expect(isRemoteForDescriptor({ kind: 'ssh', connectionId: 'h', platform: 'linux' })).toBe(true)
|
||||
expect(isRemoteForDescriptor({ kind: 'runtime', environmentId: 'e', platform: 'linux' })).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
isRemoteForDescriptor({
|
||||
kind: 'runtime',
|
||||
environmentId: 'e',
|
||||
platform: 'linux',
|
||||
isRemote: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defaultTransportConfidentiality', () => {
|
||||
it('is undefined same-host, true for SSH, false for an unproven runtime channel', () => {
|
||||
expect(defaultTransportConfidentiality({ kind: 'local', platform: 'darwin' })).toBeUndefined()
|
||||
expect(defaultTransportConfidentiality({ kind: 'wsl', distro: 'Ubuntu' })).toBeUndefined()
|
||||
expect(
|
||||
defaultTransportConfidentiality({ kind: 'ssh', connectionId: 'h', platform: 'linux' })
|
||||
).toBe(true)
|
||||
expect(
|
||||
defaultTransportConfidentiality({ kind: 'runtime', environmentId: 'e', platform: 'linux' })
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toStockBaseAgentSet', () => {
|
||||
it('preserves the unknown/known-none distinction and filters to built-ins', () => {
|
||||
expect(toStockBaseAgentSet(null)).toBeNull()
|
||||
expect(toStockBaseAgentSet(undefined)).toBeNull()
|
||||
const none = toStockBaseAgentSet([])
|
||||
expect(none).not.toBeNull()
|
||||
expect(none!.size).toBe(0)
|
||||
const some = toStockBaseAgentSet(['claude', 'not-an-agent', 'codex'])
|
||||
expect([...some!].sort()).toEqual(['claude', 'codex'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveAgentLaunchHostState', () => {
|
||||
it('derives a full local target with detection and home', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps(),
|
||||
{ kind: 'local', platform: 'darwin' },
|
||||
{ repoPath: '/repo', worktreePath: '/repo/wt' }
|
||||
)
|
||||
expect(state.target.platform).toBe('darwin')
|
||||
expect(state.target.isRemote).toBe(false)
|
||||
expect(state.target.executionHostId).toBe('local')
|
||||
expect(state.target.targetHomePath).toBe('/home/dev')
|
||||
expect([...state.target.detectedStockBaseAgents!].sort()).toEqual(['claude', 'codex'])
|
||||
// Same-host: confidentiality is omitted (undefined), not false.
|
||||
expect('transportConfidentialityAvailable' in state.target).toBe(false)
|
||||
expect(state.variables).toEqual({ repoPath: '/repo', worktreePath: '/repo/wt' })
|
||||
expect(state.getCatalogRevision()).toBe(3)
|
||||
})
|
||||
|
||||
it('carries an SSH target with confidential transport and derived host id', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveTargetHomePath: async () => '/home/remote' }),
|
||||
{ kind: 'ssh', connectionId: 'box-1', platform: 'linux', shell: 'posix' },
|
||||
{}
|
||||
)
|
||||
expect(state.target.isRemote).toBe(true)
|
||||
expect(state.target.executionHostId).toBe('ssh:box-1')
|
||||
expect(state.target.shell).toBe('posix')
|
||||
expect(state.target.targetHomePath).toBe('/home/remote')
|
||||
expect(state.target.transportConfidentialityAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('derives a WSL target as local linux with a wsl host id', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveTargetHomePath: async () => null }),
|
||||
{ kind: 'wsl', distro: 'Ubuntu' },
|
||||
{ repoPath: '/mnt/c/repo' }
|
||||
)
|
||||
expect(state.target.platform).toBe('linux')
|
||||
expect(state.target.isRemote).toBe(false)
|
||||
expect(state.target.executionHostId).toBe('wsl:Ubuntu')
|
||||
// Home unknown -> null so the resolver fails missing_target_home for ~ prefixes.
|
||||
expect(state.target.targetHomePath).toBeNull()
|
||||
expect('transportConfidentialityAvailable' in state.target).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on a runtime channel: remote, plaintext-conservative confidentiality', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps(),
|
||||
{ kind: 'runtime', environmentId: 'sandbox-9', platform: 'linux' },
|
||||
{}
|
||||
)
|
||||
expect(state.target.isRemote).toBe(true)
|
||||
expect(state.target.executionHostId).toBe('runtime:sandbox-9')
|
||||
expect(state.target.transportConfidentialityAvailable).toBe(false)
|
||||
})
|
||||
|
||||
it('honors an injected confidentiality override for an identified binding', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({ resolveTransportConfidentiality: () => true }),
|
||||
{ kind: 'runtime', environmentId: 'sandbox-9', platform: 'linux' },
|
||||
{}
|
||||
)
|
||||
expect(state.target.transportConfidentialityAvailable).toBe(true)
|
||||
})
|
||||
|
||||
it('passes honest unknowns through when detection and home are unavailable', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps({
|
||||
detectStockBaseAgents: detectionUnavailable,
|
||||
resolveTargetHomePath: async () => null
|
||||
}),
|
||||
{ kind: 'ssh', connectionId: 'box-1', platform: 'linux' },
|
||||
{}
|
||||
)
|
||||
expect(state.target.detectedStockBaseAgents).toBeNull()
|
||||
expect(state.target.targetHomePath).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes missing variables to null', async () => {
|
||||
const state = await deriveAgentLaunchHostState(
|
||||
makeDeps(),
|
||||
{ kind: 'local', platform: 'linux' },
|
||||
{}
|
||||
)
|
||||
expect(state.variables).toEqual({ repoPath: null, worktreePath: null })
|
||||
})
|
||||
|
||||
it('runs the async host reads exactly once', async () => {
|
||||
const detect = vi.fn(async () => ['claude'])
|
||||
const home = vi.fn(async () => '/home/dev')
|
||||
await deriveAgentLaunchHostState(
|
||||
makeDeps({ detectStockBaseAgents: detect, resolveTargetHomePath: home }),
|
||||
{ kind: 'local', platform: 'linux' },
|
||||
{}
|
||||
)
|
||||
expect(detect).toHaveBeenCalledTimes(1)
|
||||
expect(home).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeSpawnExecutionHost', () => {
|
||||
it('describes a local target with this machine platform', () => {
|
||||
const descriptor = describeSpawnExecutionHost({ connectionId: null, cwd: '/repo' })
|
||||
expect(descriptor.kind).toBe('local')
|
||||
expect(descriptor).toMatchObject({ kind: 'local', platform: process.platform })
|
||||
})
|
||||
|
||||
it('describes an SSH target and infers linux from a POSIX cwd', () => {
|
||||
const descriptor = describeSpawnExecutionHost({
|
||||
connectionId: 'host-1',
|
||||
cwd: '/home/user/repo'
|
||||
})
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' })
|
||||
})
|
||||
|
||||
it('infers win32 for an SSH target with a Windows-shaped cwd', () => {
|
||||
const descriptor = describeSpawnExecutionHost({
|
||||
connectionId: 'host-1',
|
||||
cwd: 'C:\\Users\\me\\repo'
|
||||
})
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'win32' })
|
||||
})
|
||||
|
||||
it('defaults an SSH target to linux when the cwd is unknown', () => {
|
||||
const descriptor = describeSpawnExecutionHost({ connectionId: 'host-1' })
|
||||
expect(descriptor).toEqual({ kind: 'ssh', connectionId: 'host-1', platform: 'linux' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLocalTargetHomePath', () => {
|
||||
it('returns a home dir for local and null for every other surface', async () => {
|
||||
const local: AgentLaunchHostDescriptor = { kind: 'local', platform: process.platform }
|
||||
await expect(resolveLocalTargetHomePath(local)).resolves.toEqual(expect.any(String))
|
||||
await expect(
|
||||
resolveLocalTargetHomePath({ kind: 'ssh', connectionId: 'h', platform: 'linux' })
|
||||
).resolves.toBeNull()
|
||||
await expect(resolveLocalTargetHomePath({ kind: 'wsl', distro: 'Ubuntu' })).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,221 @@
|
||||
// Main-side host-state provider for agent launches (U3). Given a spawn surface's
|
||||
// execution descriptor (local, WSL, SSH, or runtime), it derives the fixed
|
||||
// AgentLaunchSpawnTarget the resolver consumes: platform, shell, isRemote, the
|
||||
// stable execution-host id, the target home path for `~` expansion, the stock
|
||||
// detection snapshot, and the cross-host transport-confidentiality signal.
|
||||
//
|
||||
// The provider NEVER fabricates a value it cannot observe. Detection is null when
|
||||
// unavailable (never an empty set standing in for "unknown"); the target home is
|
||||
// null when the host has not resolved it (the resolver then fails
|
||||
// missing_target_home only for `~`-prefixed values); confidentiality is undefined
|
||||
// for same-host launches and conservatively false for a cross-host channel whose
|
||||
// binding cannot be proven. Detection/home resolution are injected async host
|
||||
// reads so this module stays electron-free and unit-testable.
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import type { BuiltInTuiAgent, GlobalSettings } from '../../shared/types'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
|
||||
import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell'
|
||||
import type { AgentLaunchSpawnTarget } from './agent-launch-spawn'
|
||||
|
||||
/** The execution surface a launch targets. isRemote/platform/executionHostId are
|
||||
* derived from this shape; nothing is copied from a client payload. */
|
||||
export type AgentLaunchHostDescriptor =
|
||||
| { kind: 'local'; platform: NodeJS.Platform; shell?: AgentStartupShell }
|
||||
| { kind: 'wsl'; distro: string; shell?: AgentStartupShell }
|
||||
| { kind: 'ssh'; connectionId: string; platform: NodeJS.Platform; shell?: AgentStartupShell }
|
||||
| {
|
||||
kind: 'runtime'
|
||||
environmentId: string
|
||||
platform: NodeJS.Platform
|
||||
/** Runtime environments are separate hosts by default; a caller that knows
|
||||
* the env is in-process may set false. */
|
||||
isRemote?: boolean
|
||||
shell?: AgentStartupShell
|
||||
}
|
||||
|
||||
/** The stable execution-host id, reusing the shared SSH/runtime encoders and this
|
||||
* feature's `wsl:${distro}` variant (the shared ExecutionHostId grammar has no
|
||||
* WSL arm). */
|
||||
export function executionHostIdForDescriptor(
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
): AgentLaunchExecutionHostId {
|
||||
switch (descriptor.kind) {
|
||||
case 'local':
|
||||
return 'local'
|
||||
case 'wsl':
|
||||
return `wsl:${encodeURIComponent(descriptor.distro)}`
|
||||
case 'ssh':
|
||||
return toSshExecutionHostId(descriptor.connectionId)
|
||||
case 'runtime':
|
||||
return toRuntimeExecutionHostId(descriptor.environmentId)
|
||||
}
|
||||
}
|
||||
|
||||
/** WSL always executes a Linux userland; every other descriptor names its own
|
||||
* terminal-target platform. */
|
||||
export function platformForDescriptor(descriptor: AgentLaunchHostDescriptor): NodeJS.Platform {
|
||||
return descriptor.kind === 'wsl' ? 'linux' : descriptor.platform
|
||||
}
|
||||
|
||||
/** SSH and (by default) runtime are separate hosts; local and WSL execute on this
|
||||
* machine, matching repoIsRemote (connectionId-only) semantics. */
|
||||
export function isRemoteForDescriptor(descriptor: AgentLaunchHostDescriptor): boolean {
|
||||
if (descriptor.kind === 'ssh') {
|
||||
return true
|
||||
}
|
||||
if (descriptor.kind === 'runtime') {
|
||||
return descriptor.isRemote ?? true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Conservative confidentiality: same-host launches carry no cross-host transport
|
||||
* (undefined). SSH is authenticated and confidential (true). A runtime channel's
|
||||
* binding cannot be proven from host state alone, so env-bearing launches into it
|
||||
* fail closed (false) unless a caller overrides with an identified binding. */
|
||||
export function defaultTransportConfidentiality(
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
): boolean | undefined {
|
||||
if (descriptor.kind === 'local' || descriptor.kind === 'wsl') {
|
||||
return undefined
|
||||
}
|
||||
if (descriptor.kind === 'ssh') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Filter a raw detected-agent list to the stock base agents the resolver gates
|
||||
* on. null/undefined input means detection is unavailable and is preserved as
|
||||
* null (unknown); an empty array is "detection ran, nothing installed" and stays
|
||||
* an empty set — the two must not collapse. */
|
||||
export function toStockBaseAgentSet(
|
||||
detected: readonly string[] | null | undefined
|
||||
): ReadonlySet<BuiltInTuiAgent> | null {
|
||||
if (detected === null || detected === undefined) {
|
||||
return null
|
||||
}
|
||||
const set = new Set<BuiltInTuiAgent>()
|
||||
for (const id of detected) {
|
||||
if (isBuiltInTuiAgent(id)) {
|
||||
set.add(id)
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
/** Map a terminal spawn's connection + cwd to its execution-host descriptor.
|
||||
* An SSH target (connectionId present) infers platform from the remote cwd's
|
||||
* path shape — the same heuristic the runtime uses — because the IPC boundary
|
||||
* has no synchronous remote-platform probe; its home/detection stay honest
|
||||
* unknowns until a caller that can probe supplies them. A local target uses
|
||||
* this machine's platform and Windows shell family. WSL/runtime hosts are
|
||||
* described by callers that know the distro/env id. */
|
||||
export function describeSpawnExecutionHost(args: {
|
||||
connectionId?: string | null
|
||||
cwd?: string | null
|
||||
terminalWindowsShell?: string | null
|
||||
}): AgentLaunchHostDescriptor {
|
||||
if (args.connectionId) {
|
||||
return {
|
||||
kind: 'ssh',
|
||||
connectionId: args.connectionId,
|
||||
platform: args.cwd && isWindowsAbsolutePathLike(args.cwd) ? 'win32' : 'linux'
|
||||
}
|
||||
}
|
||||
const shell = resolveLocalWindowsAgentStartupShell({
|
||||
platform: process.platform,
|
||||
isRemote: false,
|
||||
terminalWindowsShell: args.terminalWindowsShell
|
||||
})
|
||||
return {
|
||||
kind: 'local',
|
||||
platform: process.platform,
|
||||
...(shell ? { shell } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export type AgentLaunchHostStateDeps = {
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
/** Detect stock base agents on the target's baseline PATH. Return null when
|
||||
* detection is genuinely unavailable — never an empty list to mean unknown. */
|
||||
detectStockBaseAgents: (
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
) => Promise<readonly string[] | null>
|
||||
/** Resolve the target host's home dir for `~` expansion, or null when the host
|
||||
* has not resolved it (SSH before resolveHome, an unknown WSL distro $HOME). */
|
||||
resolveTargetHomePath: (descriptor: AgentLaunchHostDescriptor) => Promise<string | null>
|
||||
/** Override the default confidentiality derivation when a cross-host channel's
|
||||
* binding is identifiable (e.g. a runtime env reached over SSH). */
|
||||
resolveTransportConfidentiality?: (descriptor: AgentLaunchHostDescriptor) => boolean | undefined
|
||||
}
|
||||
|
||||
/** The surface-specific host state a launch resolves against: the live settings
|
||||
* accessors and the fixed target/variables snapshot. Settings and the normalized
|
||||
* catalog are read live per resolution by resolveAgentLaunchSpawn; the target and
|
||||
* variables are the immutable per-surface derivation captured here. */
|
||||
export type AgentLaunchHostState = {
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
target: AgentLaunchSpawnTarget
|
||||
variables: { repoPath: string | null; worktreePath: string | null }
|
||||
}
|
||||
|
||||
/** Derive the per-surface host state for a launch. Performs the async host reads
|
||||
* (detection, target home) once, up front, so the boundary's synchronous
|
||||
* re-resolution inside the admission coordinator only re-reads settings. */
|
||||
export async function deriveAgentLaunchHostState(
|
||||
deps: AgentLaunchHostStateDeps,
|
||||
descriptor: AgentLaunchHostDescriptor,
|
||||
variables: { repoPath?: string | null; worktreePath?: string | null }
|
||||
): Promise<AgentLaunchHostState> {
|
||||
const platform = platformForDescriptor(descriptor)
|
||||
const isRemote = isRemoteForDescriptor(descriptor)
|
||||
const executionHostId = executionHostIdForDescriptor(descriptor)
|
||||
const [detected, targetHomePath] = await Promise.all([
|
||||
deps.detectStockBaseAgents(descriptor),
|
||||
deps.resolveTargetHomePath(descriptor)
|
||||
])
|
||||
const confidentiality = (deps.resolveTransportConfidentiality ?? defaultTransportConfidentiality)(
|
||||
descriptor
|
||||
)
|
||||
|
||||
const target: AgentLaunchSpawnTarget = {
|
||||
platform,
|
||||
...(descriptor.shell ? { shell: descriptor.shell } : {}),
|
||||
isRemote,
|
||||
executionHostId,
|
||||
targetHomePath: targetHomePath ?? null,
|
||||
detectedStockBaseAgents: toStockBaseAgentSet(detected),
|
||||
...(confidentiality !== undefined ? { transportConfidentialityAvailable: confidentiality } : {})
|
||||
}
|
||||
|
||||
return {
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
target,
|
||||
variables: {
|
||||
repoPath: variables.repoPath ?? null,
|
||||
worktreePath: variables.worktreePath ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Default detection resolver: detection unavailable (unknown). Callers that can
|
||||
* run real stock detection inject their own; the honest default never claims an
|
||||
* agent is missing. */
|
||||
export const detectionUnavailable = async (): Promise<null> => null
|
||||
|
||||
/** Default home resolver: this machine's home dir for a local target, null
|
||||
* otherwise (a remote/WSL home must be resolved by the host that owns it). */
|
||||
export async function resolveLocalTargetHomePath(
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
): Promise<string | null> {
|
||||
return descriptor.kind === 'local' ? homedir() : null
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// The host resolver and its host-only contract must never reach a renderer,
|
||||
// mobile, or web bundle. Type location alone is not a security boundary; this
|
||||
// grep is the enforcement (allowed importers: src/main/**, src/shared/**, tests).
|
||||
const REPO_ROOT = join(__dirname, '..', '..', '..')
|
||||
|
||||
const CLIENT_ROOTS = ['src/renderer', 'mobile/src', 'src/web']
|
||||
|
||||
const FORBIDDEN_IMPORTS = [
|
||||
'agent-launch-host-contract',
|
||||
'agent-launch/resolve-agent-launch',
|
||||
'agent-launch/resolve-agent-command',
|
||||
'agent-launch/resolve-agent-selection',
|
||||
'agent-launch/compose-agent-launch-env'
|
||||
]
|
||||
|
||||
const SOURCE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
||||
|
||||
function collectSourceFiles(dir: string, out: string[]): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry)
|
||||
if (statSync(full).isDirectory()) {
|
||||
if (entry === 'node_modules' || entry === '.git') {
|
||||
continue
|
||||
}
|
||||
collectSourceFiles(full, out)
|
||||
continue
|
||||
}
|
||||
const dot = entry.lastIndexOf('.')
|
||||
if (dot >= 0 && SOURCE_EXT.has(entry.slice(dot))) {
|
||||
out.push(full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent-launch host-boundary imports', () => {
|
||||
it('no renderer/mobile/web source imports the host resolver or host contract', () => {
|
||||
const files: string[] = []
|
||||
for (const root of CLIENT_ROOTS) {
|
||||
collectSourceFiles(join(REPO_ROOT, root), files)
|
||||
}
|
||||
// Guard against silently scanning nothing (e.g. a moved directory).
|
||||
expect(files.length).toBeGreaterThan(0)
|
||||
|
||||
const offenders: string[] = []
|
||||
for (const file of files) {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
for (const forbidden of FORBIDDEN_IMPORTS) {
|
||||
if (text.includes(forbidden)) {
|
||||
offenders.push(`${file} -> ${forbidden}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(offenders).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
// U5: opaque one-release legacy-config replay. Proves provider resume flags are
|
||||
// appended exactly once to the one-shot command (never the durable config), that
|
||||
// Orca attribution env is stripped, and that every failure mode fails closed to
|
||||
// invalid_launch_snapshot without a partial replay.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
RESUMABLE_TUI_AGENTS,
|
||||
getAgentResumeArgv,
|
||||
providerSessionKeyForResumableBase
|
||||
} from '../../shared/agent-session-resume'
|
||||
import { buildLegacyResumeReplay } from './agent-launch-legacy-replay'
|
||||
|
||||
function replay(overrides: Partial<Parameters<typeof buildLegacyResumeReplay>[0]> = {}) {
|
||||
return buildLegacyResumeReplay({
|
||||
legacyLaunchConfig: { agentCommand: 'claude', agentArgs: '--model opus', agentEnv: {} },
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
shell: 'posix',
|
||||
recordedConnectionId: null,
|
||||
currentConnectionId: null,
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('buildLegacyResumeReplay', () => {
|
||||
it('appends the provider resume flags to the one-shot command only', () => {
|
||||
const result = replay()
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.launchCommand).toContain('--resume')
|
||||
expect(result.launchCommand).toContain('sess-1')
|
||||
// Durable config keeps the base args only, so a fresh relaunch never re-resumes.
|
||||
expect(result.launchConfig.agentArgs).toBe('--model opus')
|
||||
expect(result.launchConfig.agentArgs).not.toContain('--resume')
|
||||
expect(result.launchConfig.agentCommand).toBe('claude')
|
||||
})
|
||||
|
||||
it('appends resume argv once for every resumable base', () => {
|
||||
for (const base of RESUMABLE_TUI_AGENTS) {
|
||||
const key = providerSessionKeyForResumableBase(base)
|
||||
const providerSession = { key, id: 'sess-9' } as const
|
||||
const result = replay({ baseAgent: base, requestedAgent: base, providerSession })
|
||||
expect(result.ok, `base ${base}`).toBe(true)
|
||||
if (!result.ok) {
|
||||
continue
|
||||
}
|
||||
const resumeArgv = getAgentResumeArgv(base, providerSession)
|
||||
expect(resumeArgv).not.toBeNull()
|
||||
// The final flag/value pair appears exactly once in the one-shot command.
|
||||
const lastFlag = resumeArgv?.at(-2)
|
||||
if (lastFlag) {
|
||||
const occurrences = result.launchCommand.split(lastFlag).length - 1
|
||||
expect(occurrences, `base ${base} flag ${lastFlag}`).toBe(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('strips Orca attribution and tmux identity env before replay', () => {
|
||||
const result = replay({
|
||||
legacyLaunchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: {
|
||||
FOO: 'bar',
|
||||
ORCA_PANE_KEY: 'p',
|
||||
ORCA_AGENT_LAUNCH_TOKEN: 't',
|
||||
TMUX: 'x',
|
||||
TMUX_PANE: '%1'
|
||||
}
|
||||
}
|
||||
})
|
||||
expect(result.ok && result.launchConfig.agentEnv).toEqual({ FOO: 'bar' })
|
||||
})
|
||||
|
||||
it('strips captured Agent Teams identity and the shim PATH prefix from the durable config', () => {
|
||||
const result = replay({
|
||||
legacyLaunchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
TERM: 'screen-256color',
|
||||
TMUX: 'x',
|
||||
TMUX_PANE: '%1',
|
||||
ORCA_AGENT_TEAMS_TOKEN: 'stale-token',
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: '/home/me/.orca/teams-bin',
|
||||
PATH: '/home/me/.orca/teams-bin:/usr/bin',
|
||||
MY_TOKEN: 'keep'
|
||||
}
|
||||
}
|
||||
})
|
||||
// Generated team identity and stale token are gone; the user PATH tail and
|
||||
// custom key survive so the downstream path can regenerate a fresh team plan.
|
||||
expect(result.ok && result.launchConfig.agentEnv).toEqual({
|
||||
PATH: '/usr/bin',
|
||||
MY_TOKEN: 'keep'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the recorded owner differs from the current owner', () => {
|
||||
expect(replay({ recordedConnectionId: 'ssh:a', currentConnectionId: 'ssh:b' }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on an empty command', () => {
|
||||
expect(
|
||||
replay({ legacyLaunchConfig: { agentCommand: '', agentArgs: '', agentEnv: {} } }).ok
|
||||
).toBe(false)
|
||||
expect(replay({ legacyLaunchConfig: { agentArgs: '', agentEnv: {} } }).ok).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed on a control character in the command', () => {
|
||||
expect(
|
||||
replay({ legacyLaunchConfig: { agentCommand: 'claude\n rm', agentArgs: '', agentEnv: {} } })
|
||||
.ok
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed when the session key type does not match the base', () => {
|
||||
// claude is session_id-keyed; a conversation_id session cannot resume it.
|
||||
expect(replay({ providerSession: { key: 'conversation_id', id: 'x' } }).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,106 @@
|
||||
// Opaque one-release legacy-config replay (U5, plan §570-575). A pre-U5 sleeping
|
||||
// record persisted a pre-quoted `agentCommand` that "cannot always be split
|
||||
// safely", so it is replayed verbatim as an opaque string rather than re-parsed
|
||||
// into the v1 snapshot's structured argv. This path is desktop/host-initiated
|
||||
// only: the renderer surrenders the legacy config over trusted IPC exactly once,
|
||||
// the host validates it, the ingest layer persists it into the private record
|
||||
// store (owns it thereafter), and it never rides untrusted runtime/mobile RPC —
|
||||
// so a mobile/paired legacy resume falls through to invalid_launch_snapshot.
|
||||
//
|
||||
// Unlike the resolver, this does NOT produce a ResolvedAgentLaunch: the opaque
|
||||
// command bypasses structured resolution and feeds the pre-U5 launchCommand /
|
||||
// launchConfig fields directly. The durable config stays base-only (no resume
|
||||
// flags) so a fresh relaunch never re-resumes a stale session; the provider
|
||||
// resume flags land only in the one-shot launchCommand.
|
||||
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
getAgentResumeArgv,
|
||||
type AgentProviderSessionMetadata,
|
||||
type ResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import { validateCustomAgentEnv } from '../../shared/custom-tui-agent-fields'
|
||||
import { quoteStartupArg, type AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import { stripLegacyReplayEnv } from './agent-launch-legacy-teams-env'
|
||||
|
||||
export type LegacyReplayInput = {
|
||||
legacyLaunchConfig: SleepingAgentLaunchConfig
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: ResumableTuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
shell: AgentStartupShell
|
||||
/** Recorded execution owner of the sleeping pane. */
|
||||
recordedConnectionId: string | null
|
||||
/** Current spawn's execution owner; provenance requires it to equal the
|
||||
* recorded owner (never inferred from focused repo/client — plan §573). */
|
||||
currentConnectionId: string | null
|
||||
}
|
||||
|
||||
export type LegacyReplayResult =
|
||||
| {
|
||||
ok: true
|
||||
launchCommand: string
|
||||
launchConfig: SleepingAgentLaunchConfig
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: ResumableTuiAgent
|
||||
}
|
||||
| { ok: false; failure: { code: 'invalid_launch_snapshot' } }
|
||||
|
||||
const INVALID: LegacyReplayResult = { ok: false, failure: { code: 'invalid_launch_snapshot' } }
|
||||
|
||||
// Control chars that would corrupt an opaque shell command. Mirrors the command
|
||||
// override guard; the pre-quoted command text is otherwise passed through.
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
|
||||
const COMMAND_CONTROL_RE = /[\0\r\n\x01-\x08\x0b\x0c\x0e-\x1f\x7f]/
|
||||
|
||||
/** Assemble an opaque legacy resume launch. Fails closed (`invalid_launch_snapshot`,
|
||||
* leaving the source record untouched) on owner mismatch, an unresumable base, an
|
||||
* empty/control-char command, or invalid surviving env — never a partial replay. */
|
||||
export function buildLegacyResumeReplay(input: LegacyReplayInput): LegacyReplayResult {
|
||||
// Provenance: the recorded execution owner must match the current spawn's owner.
|
||||
// Missing/conflicting owner evidence fails closed rather than inferring a target.
|
||||
if ((input.recordedConnectionId ?? null) !== (input.currentConnectionId ?? null)) {
|
||||
return INVALID
|
||||
}
|
||||
|
||||
const { agentCommand, agentArgs } = input.legacyLaunchConfig
|
||||
const command = agentCommand?.trim() ?? ''
|
||||
if (!command || COMMAND_CONTROL_RE.test(command)) {
|
||||
return INVALID
|
||||
}
|
||||
const trimmedArgs = agentArgs.trim()
|
||||
if (trimmedArgs && COMMAND_CONTROL_RE.test(trimmedArgs)) {
|
||||
return INVALID
|
||||
}
|
||||
|
||||
// Strip Orca attribution + generated Agent Teams keys (and the proven shim PATH
|
||||
// prefix) first, then validate the surviving user env as a whole; any invalid
|
||||
// key/value invalidates the entire config (never partial). The downstream
|
||||
// launch path regenerates a fresh team plan for a captured team config.
|
||||
const cleanedEnv = stripLegacyReplayEnv(input.legacyLaunchConfig.agentEnv, input.shell)
|
||||
if (validateCustomAgentEnv(cleanedEnv).length > 0) {
|
||||
return INVALID
|
||||
}
|
||||
|
||||
// Provider resume flags append to the one-shot command only. An unresumable
|
||||
// base or a session whose key type does not match the base cannot resume.
|
||||
const resumeArgv = getAgentResumeArgv(input.baseAgent, input.providerSession)
|
||||
if (!resumeArgv) {
|
||||
return INVALID
|
||||
}
|
||||
const resumeSuffix = resumeArgv
|
||||
.slice(1)
|
||||
.map((element) => quoteStartupArg(element, input.shell))
|
||||
.join(' ')
|
||||
|
||||
const launchCommand = [command, trimmedArgs, resumeSuffix].filter(Boolean).join(' ')
|
||||
return {
|
||||
ok: true,
|
||||
launchCommand,
|
||||
// Durable config: base command/args only (no resume flags), cleaned env.
|
||||
launchConfig: { agentCommand: command, agentArgs: trimmedArgs, agentEnv: cleanedEnv },
|
||||
requestedAgent: input.requestedAgent,
|
||||
baseAgent: input.baseAgent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isCapturedAgentTeamsConfig,
|
||||
pathDelimiterForShell,
|
||||
stripLegacyReplayEnv
|
||||
} from './agent-launch-legacy-teams-env'
|
||||
|
||||
// A captured Claude Agent Teams leader env (see createLaunchEnv), plus a user's
|
||||
// own custom key that must survive replay.
|
||||
function capturedTeamEnv(overrides: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
PATH: '/home/me/.orca/claude-agent-teams-bin:/usr/local/bin:/usr/bin',
|
||||
TMUX: '/tmp/orca-claude-agent-teams/team-abc,0,1',
|
||||
TMUX_PANE: '%1',
|
||||
TERM: 'screen-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
ORCA_AGENT_TEAMS_TEAM_ID: 'team-abc',
|
||||
ORCA_AGENT_TEAMS_TOKEN: 'secret-token',
|
||||
ORCA_AGENT_TEAMS_LEADER_PANE: '%1',
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: '/home/me/.orca/claude-agent-teams-bin',
|
||||
ORCA_AGENT_TEAMS_SHIM_BIN: '/opt/orca/bin/orca',
|
||||
ORCA_PAIRING_CODE: 'pair-123',
|
||||
ORCA_ENVIRONMENT: 'prod',
|
||||
ORCA_PANE_KEY: 'pane-key',
|
||||
MY_CUSTOM_TOKEN: 'keep-me',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('pathDelimiterForShell', () => {
|
||||
it('uses : on posix and ; on Windows shells', () => {
|
||||
expect(pathDelimiterForShell('posix')).toBe(':')
|
||||
expect(pathDelimiterForShell('powershell')).toBe(';')
|
||||
expect(pathDelimiterForShell('cmd')).toBe(';')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isCapturedAgentTeamsConfig', () => {
|
||||
it('detects a team config by its generated markers', () => {
|
||||
expect(isCapturedAgentTeamsConfig({ ORCA_AGENT_TEAMS_TEAM_ID: 'x' })).toBe(true)
|
||||
expect(isCapturedAgentTeamsConfig({ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' })).toBe(true)
|
||||
})
|
||||
|
||||
it('does not flag an ordinary user env', () => {
|
||||
expect(isCapturedAgentTeamsConfig({ PATH: '/usr/bin', TERM: 'xterm', FOO: 'bar' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripLegacyReplayEnv — non-team config', () => {
|
||||
it('preserves user PATH and TERM, stripping only orca attribution + tmux', () => {
|
||||
const cleaned = stripLegacyReplayEnv(
|
||||
{
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
TERM: 'xterm-256color',
|
||||
TMUX: 'x',
|
||||
TMUX_PANE: '%9',
|
||||
ORCA_PANE_KEY: 'pane',
|
||||
MY_TOKEN: 'keep'
|
||||
},
|
||||
'posix'
|
||||
)
|
||||
expect(cleaned).toEqual({
|
||||
PATH: '/usr/local/bin:/usr/bin',
|
||||
TERM: 'xterm-256color',
|
||||
MY_TOKEN: 'keep'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('stripLegacyReplayEnv — captured team config', () => {
|
||||
it('drops every generated team/auth/TMUX/TERM/pairing key and keeps user env', () => {
|
||||
const cleaned = stripLegacyReplayEnv(capturedTeamEnv(), 'posix')
|
||||
expect(cleaned.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS).toBeUndefined()
|
||||
expect(cleaned.TMUX).toBeUndefined()
|
||||
expect(cleaned.TMUX_PANE).toBeUndefined()
|
||||
expect(cleaned.TERM).toBeUndefined()
|
||||
expect(cleaned.COLORTERM).toBeUndefined()
|
||||
expect(cleaned.ORCA_AGENT_TEAMS_TOKEN).toBeUndefined()
|
||||
expect(cleaned.ORCA_AGENT_TEAMS_SHIM_DIR).toBeUndefined()
|
||||
expect(cleaned.ORCA_PAIRING_CODE).toBeUndefined()
|
||||
expect(cleaned.ORCA_ENVIRONMENT).toBeUndefined()
|
||||
expect(cleaned.ORCA_PANE_KEY).toBeUndefined()
|
||||
// The user's own custom key survives.
|
||||
expect(cleaned.MY_CUSTOM_TOKEN).toBe('keep-me')
|
||||
})
|
||||
|
||||
it('removes the proven shim prefix from PATH, preserving the user tail', () => {
|
||||
const cleaned = stripLegacyReplayEnv(capturedTeamEnv(), 'posix')
|
||||
expect(cleaned.PATH).toBe('/usr/local/bin:/usr/bin')
|
||||
})
|
||||
|
||||
it('quotes the Windows shim prefix with the ; delimiter', () => {
|
||||
const cleaned = stripLegacyReplayEnv(
|
||||
capturedTeamEnv({
|
||||
PATH: 'C:\\Users\\me\\.orca\\bin;C:\\Windows\\System32',
|
||||
ORCA_AGENT_TEAMS_SHIM_DIR: 'C:\\Users\\me\\.orca\\bin'
|
||||
}),
|
||||
'powershell'
|
||||
)
|
||||
expect(cleaned.PATH).toBe('C:\\Windows\\System32')
|
||||
})
|
||||
|
||||
it('drops PATH when the shim dir cannot be proven (ambiguous)', () => {
|
||||
const withoutShimDir = capturedTeamEnv()
|
||||
delete withoutShimDir.ORCA_AGENT_TEAMS_SHIM_DIR
|
||||
// Still a team config via CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS, but the shim
|
||||
// segment is now unprovable → drop rather than replay a poisoned PATH.
|
||||
const cleaned = stripLegacyReplayEnv(withoutShimDir, 'posix')
|
||||
expect(cleaned.PATH).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops PATH when its first segment is not the captured shim dir', () => {
|
||||
const cleaned = stripLegacyReplayEnv(
|
||||
capturedTeamEnv({ PATH: '/usr/local/bin:/home/me/.orca/claude-agent-teams-bin' }),
|
||||
'posix'
|
||||
)
|
||||
expect(cleaned.PATH).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops PATH entirely when the shim dir is the only segment', () => {
|
||||
const cleaned = stripLegacyReplayEnv(
|
||||
capturedTeamEnv({ PATH: '/home/me/.orca/claude-agent-teams-bin' }),
|
||||
'posix'
|
||||
)
|
||||
expect(cleaned.PATH).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
// §571 env cleaning for opaque legacy replay. A pre-U5 sleeping record may have
|
||||
// captured a Claude Agent Teams launch env whose team identity, tmux/TERM state,
|
||||
// pairing keys, and shim-prefixed PATH were minted per-launch. Replaying those
|
||||
// verbatim would re-inject a stale team token/shim; the downstream launch path
|
||||
// regenerates a fresh team plan, so the durable replay config must drop the
|
||||
// generated keys while preserving a safely separable user PATH tail.
|
||||
//
|
||||
// This is deliberately NOT an extension of the shared `stripEphemeralAgentTeamsEnv`
|
||||
// (claude-agent-teams-service.ts): that function also cleans the FRESH-launch
|
||||
// durable snapshot (orca-runtime), where a user's own custom TERM/PATH must be
|
||||
// preserved. Stripping TERM/PATH there would regress custom env. The legacy
|
||||
// cleaning only engages for a CAPTURED team config and removes the shim PATH
|
||||
// prefix surgically (proven by the captured shim-dir), so it is safe to apply
|
||||
// only on the legacy replay path.
|
||||
|
||||
// Generated keys removed only when the config is a captured team launch. TMUX /
|
||||
// TMUX_PANE are ephemeral for every launch and stripped unconditionally below.
|
||||
const GENERATED_TEAM_ONLY_KEYS = new Set([
|
||||
'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS',
|
||||
'TERM',
|
||||
'COLORTERM'
|
||||
])
|
||||
|
||||
// Presence of any of these proves the captured env came from an Agent Teams
|
||||
// launch; only then do the team-only strips and PATH-shim removal engage.
|
||||
const TEAM_MARKER_KEY_PREFIX = 'ORCA_AGENT_TEAMS_'
|
||||
const TEAM_MARKER_KEYS = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS']
|
||||
|
||||
export function isCapturedAgentTeamsConfig(env: Record<string, string>): boolean {
|
||||
return Object.keys(env).some(
|
||||
(key) => key.startsWith(TEAM_MARKER_KEY_PREFIX) || TEAM_MARKER_KEYS.includes(key)
|
||||
)
|
||||
}
|
||||
|
||||
export function pathDelimiterForShell(shell: 'posix' | 'powershell' | 'cmd'): string {
|
||||
return shell === 'posix' ? ':' : ';'
|
||||
}
|
||||
|
||||
/** Remove Orca attribution + (for captured team configs) generated team/auth/
|
||||
* TMUX/TERM/pairing keys and the proven shim PATH prefix. Non-team configs keep
|
||||
* their PATH and TERM untouched. Fails safe on an unprovable shim by dropping the
|
||||
* whole PATH entry rather than replaying a possibly shim-poisoned one. */
|
||||
export function stripLegacyReplayEnv(
|
||||
env: Record<string, string>,
|
||||
shell: 'posix' | 'powershell' | 'cmd'
|
||||
): Record<string, string> {
|
||||
const isTeam = isCapturedAgentTeamsConfig(env)
|
||||
const shimDir = env.ORCA_AGENT_TEAMS_SHIM_DIR?.trim() || null
|
||||
const delimiter = pathDelimiterForShell(shell)
|
||||
const cleaned: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const lower = key.toLowerCase()
|
||||
// Orca attribution (pane/hook/token, team ids, pairing, environment) plus the
|
||||
// tmux pane handle are always regenerated and must never replay.
|
||||
if (lower.startsWith('orca_') || key === 'TMUX' || key === 'TMUX_PANE') {
|
||||
continue
|
||||
}
|
||||
if (isTeam && GENERATED_TEAM_ONLY_KEYS.has(key)) {
|
||||
continue
|
||||
}
|
||||
if (key === 'PATH' && isTeam) {
|
||||
const tail = resolveUserPathTail(value, shimDir, delimiter)
|
||||
if (tail) {
|
||||
cleaned.PATH = tail
|
||||
}
|
||||
continue
|
||||
}
|
||||
cleaned[key] = value
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/** Return the user PATH tail after removing the proven shim prefix, or null when
|
||||
* the shim cannot be proven (drop the ambiguous PATH rather than guess). The
|
||||
* shim dir is prepended as the FIRST segment by createLaunchEnv. */
|
||||
function resolveUserPathTail(
|
||||
pathValue: string,
|
||||
shimDir: string | null,
|
||||
delimiter: string
|
||||
): string | null {
|
||||
if (!shimDir) {
|
||||
return null
|
||||
}
|
||||
const segments = pathValue.split(delimiter)
|
||||
if (segments[0] !== shimDir) {
|
||||
return null
|
||||
}
|
||||
const tail = segments.slice(1).filter(Boolean)
|
||||
return tail.length > 0 ? tail.join(delimiter) : null
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Host-wide singleton launch-operation store. Paired with the singleton launch
|
||||
// boundary (agent-launch-boundary-host.ts): the boundary owns admission, this
|
||||
// owns the durable idempotency ledger + private pending-snapshot attribution.
|
||||
// One instance per host so retry idempotency and crash reconciliation see every
|
||||
// creation attempt. Durable persistence (rehydrate on startup) attaches with the
|
||||
// reconciliation work; the in-memory instance backs the create/retry path.
|
||||
|
||||
import { AgentLaunchOperationStore } from './agent-launch-operation-store'
|
||||
|
||||
let store: AgentLaunchOperationStore | null = null
|
||||
|
||||
export function getHostAgentLaunchOperationStore(): AgentLaunchOperationStore {
|
||||
if (!store) {
|
||||
store = new AgentLaunchOperationStore()
|
||||
}
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
|
||||
// The module imports `safeStorage` at top for its Electron cipher factory; these
|
||||
// tests inject their own cipher, so a bare stub keeps the import resolvable.
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
|
||||
import type {
|
||||
AgentLaunchOperationStoreDurableState,
|
||||
PendingAgentLaunchSnapshot,
|
||||
SettledAgentLaunchOperation
|
||||
} from './agent-launch-operation-store'
|
||||
import {
|
||||
agentLaunchOperationStorePath,
|
||||
decodeAgentLaunchOperationStore,
|
||||
encodeAgentLaunchOperationStore,
|
||||
loadAgentLaunchOperationStoreState,
|
||||
writeAgentLaunchOperationStoreState,
|
||||
type AgentLaunchOperationCipher
|
||||
} from './agent-launch-operation-store-persistence'
|
||||
|
||||
// XOR-ish reversible transform standing in for safeStorage so the envelope
|
||||
// round-trip is exercised without an OS keychain, and the on-disk pending bytes
|
||||
// are verifiably NOT the plaintext.
|
||||
function reversibleCipher(available: boolean): AgentLaunchOperationCipher {
|
||||
return {
|
||||
available: () => available,
|
||||
encrypt: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
||||
decrypt: (ciphertext) => ciphertext.toString('utf-8').replace(/^enc:/, '')
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: AgentLaunchSnapshot = {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: { SECRET_TOKEN: 'do-not-leak' },
|
||||
capturedEnvPolicy: 'full',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
function pending(token: string): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: `op-${token}`,
|
||||
idempotencyKey: `key-${token}`,
|
||||
scope: 'r1::/wt',
|
||||
clientMutationId: null,
|
||||
payloadDigest: `digest-${token}`,
|
||||
launchToken: token,
|
||||
intent: 'interactive',
|
||||
snapshot
|
||||
}
|
||||
}
|
||||
|
||||
function settled(operationId: string): SettledAgentLaunchOperation {
|
||||
return {
|
||||
operationId,
|
||||
idempotencyKey: `key-${operationId}`,
|
||||
scope: 'r1::/wt',
|
||||
payloadDigest: `digest-${operationId}`,
|
||||
status: 'launched',
|
||||
terminalId: 'term-1',
|
||||
failureId: null,
|
||||
settledAt: 10
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent-launch operation-store persistence', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-launch-store-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('round-trips both halves through encrypted encode/decode', () => {
|
||||
const cipher = reversibleCipher(true)
|
||||
const state: AgentLaunchOperationStoreDurableState = {
|
||||
pending: [pending('tok-a')],
|
||||
settled: [settled('op-1')]
|
||||
}
|
||||
const decoded = decodeAgentLaunchOperationStore(
|
||||
encodeAgentLaunchOperationStore(state, cipher),
|
||||
cipher
|
||||
)
|
||||
expect(decoded.pending).toEqual(state.pending)
|
||||
expect(decoded.settled).toEqual(state.settled)
|
||||
})
|
||||
|
||||
it('encrypts the pending section so the token never appears in cleartext on disk', () => {
|
||||
const cipher = reversibleCipher(true)
|
||||
const path = agentLaunchOperationStorePath(dir)
|
||||
writeAgentLaunchOperationStoreState(
|
||||
path,
|
||||
{ pending: [pending('super-secret-token')], settled: [] },
|
||||
cipher
|
||||
)
|
||||
const bytes = readFileSync(path, 'utf-8')
|
||||
expect(bytes).not.toContain('super-secret-token')
|
||||
expect(bytes).not.toContain('do-not-leak')
|
||||
const reloaded = loadAgentLaunchOperationStoreState(path, cipher)
|
||||
expect(reloaded.pending[0]?.launchToken).toBe('super-secret-token')
|
||||
})
|
||||
|
||||
it('falls back to a hardened plaintext pending section when encryption is unavailable', () => {
|
||||
const cipher = reversibleCipher(false)
|
||||
const path = agentLaunchOperationStorePath(dir)
|
||||
writeAgentLaunchOperationStoreState(path, { pending: [pending('tok-b')], settled: [] }, cipher)
|
||||
const reloaded = loadAgentLaunchOperationStoreState(path, cipher)
|
||||
expect(reloaded.pending[0]?.launchToken).toBe('tok-b')
|
||||
})
|
||||
|
||||
it('returns empty state for a missing file', () => {
|
||||
expect(
|
||||
loadAgentLaunchOperationStoreState(agentLaunchOperationStorePath(dir), reversibleCipher(true))
|
||||
).toEqual({
|
||||
pending: [],
|
||||
settled: []
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the settled ledger but drops pending when the pending section cannot be decrypted', () => {
|
||||
// Written with an available cipher, reloaded with an unavailable one: the
|
||||
// encrypted pending cannot be read, but the plaintext ledger survives.
|
||||
const path = agentLaunchOperationStorePath(dir)
|
||||
writeAgentLaunchOperationStoreState(
|
||||
path,
|
||||
{ pending: [pending('tok-c')], settled: [settled('op-2')] },
|
||||
reversibleCipher(true)
|
||||
)
|
||||
const reloaded = loadAgentLaunchOperationStoreState(path, reversibleCipher(false))
|
||||
expect(reloaded.pending).toEqual([])
|
||||
expect(reloaded.settled).toEqual([settled('op-2')])
|
||||
})
|
||||
|
||||
it('returns empty state for a corrupt file', () => {
|
||||
const path = agentLaunchOperationStorePath(dir)
|
||||
writeFileSync(path, '{ not json', 'utf-8')
|
||||
expect(loadAgentLaunchOperationStoreState(path, reversibleCipher(true))).toEqual({
|
||||
pending: [],
|
||||
settled: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
// Host-private durable persistence for the launch-operation store (U4). Both
|
||||
// durable halves live in ONE file under the host data dir, never client-synced:
|
||||
// • the settled ledger — digests, status, terminal id, and failure id only,
|
||||
// non-sensitive by construction, so it is written in plaintext for restart
|
||||
// idempotency;
|
||||
// • the pending snapshots — they carry argv, the admitted agent env, and the
|
||||
// launch token, so they are encrypted at rest via Electron safeStorage (the
|
||||
// existing secret-settings standard). A pending snapshot that outlives a
|
||||
// main crash is what lets reconciliation re-attribute a terminal by its
|
||||
// token, so this map must be durable, not memory-only.
|
||||
// The file is written with the same atomic tmp+rename + permission-hardening
|
||||
// discipline as the other host credential stores (writeSecureJsonFile). The
|
||||
// encode/decode core takes an injected cipher so it is testable without Electron.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { safeStorage } from 'electron'
|
||||
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
|
||||
import type {
|
||||
AgentLaunchOperationStoreDurableState,
|
||||
PendingAgentLaunchSnapshot,
|
||||
SettledAgentLaunchOperation
|
||||
} from './agent-launch-operation-store'
|
||||
import { getHostAgentLaunchOperationStore } from './agent-launch-operation-store-host'
|
||||
|
||||
const STORE_FILENAME = 'agent-launch-operations.json'
|
||||
|
||||
export function agentLaunchOperationStorePath(userDataPath: string): string {
|
||||
return join(userDataPath, STORE_FILENAME)
|
||||
}
|
||||
|
||||
/** Crypto boundary for the encrypted pending section. Injected so the envelope
|
||||
* round-trip is unit-testable without an Electron/OS keychain. */
|
||||
export type AgentLaunchOperationCipher = {
|
||||
available: () => boolean
|
||||
encrypt: (plaintext: string) => Buffer
|
||||
decrypt: (ciphertext: Buffer) => string
|
||||
}
|
||||
|
||||
export function electronSafeStorageCipher(): AgentLaunchOperationCipher {
|
||||
return {
|
||||
available: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plaintext) => safeStorage.encryptString(plaintext),
|
||||
decrypt: (ciphertext) => safeStorage.decryptString(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
type PersistedPendingSection =
|
||||
| { format: 'electron-safe-storage-v1'; ciphertext: string }
|
||||
// Plaintext fallback only when OS-backed encryption is unavailable; the file
|
||||
// itself is still permission-hardened. Matches the secret-settings standard.
|
||||
| { format: 'plaintext-v1'; snapshots: PendingAgentLaunchSnapshot[] }
|
||||
|
||||
type PersistedFile = {
|
||||
version: 1
|
||||
settled: SettledAgentLaunchOperation[]
|
||||
pending: PersistedPendingSection
|
||||
}
|
||||
|
||||
export function encodeAgentLaunchOperationStore(
|
||||
state: AgentLaunchOperationStoreDurableState,
|
||||
cipher: AgentLaunchOperationCipher
|
||||
): PersistedFile {
|
||||
const snapshots = [...state.pending]
|
||||
const pending: PersistedPendingSection = cipher.available()
|
||||
? {
|
||||
format: 'electron-safe-storage-v1',
|
||||
ciphertext: cipher.encrypt(JSON.stringify(snapshots)).toString('base64')
|
||||
}
|
||||
: { format: 'plaintext-v1', snapshots }
|
||||
return { version: 1, settled: [...state.settled], pending }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function decodePending(
|
||||
pending: unknown,
|
||||
cipher: AgentLaunchOperationCipher
|
||||
): PendingAgentLaunchSnapshot[] {
|
||||
if (!isRecord(pending)) {
|
||||
return []
|
||||
}
|
||||
if (pending.format === 'plaintext-v1' && Array.isArray(pending.snapshots)) {
|
||||
return pending.snapshots as PendingAgentLaunchSnapshot[]
|
||||
}
|
||||
if (
|
||||
pending.format === 'electron-safe-storage-v1' &&
|
||||
typeof pending.ciphertext === 'string' &&
|
||||
cipher.available()
|
||||
) {
|
||||
// A decrypt failure (keychain reset) drops only the pending map, never the
|
||||
// whole file: reconciliation then treats those launches conservatively
|
||||
// rather than mis-attributing, and the settled ledger stays intact.
|
||||
const decrypted = cipher.decrypt(Buffer.from(pending.ciphertext, 'base64'))
|
||||
const parsed = JSON.parse(decrypted)
|
||||
return Array.isArray(parsed) ? (parsed as PendingAgentLaunchSnapshot[]) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function decodeAgentLaunchOperationStore(
|
||||
raw: unknown,
|
||||
cipher: AgentLaunchOperationCipher
|
||||
): AgentLaunchOperationStoreDurableState {
|
||||
if (!isRecord(raw) || raw.version !== 1) {
|
||||
return { pending: [], settled: [] }
|
||||
}
|
||||
const settled = Array.isArray(raw.settled) ? (raw.settled as SettledAgentLaunchOperation[]) : []
|
||||
let pending: PendingAgentLaunchSnapshot[]
|
||||
try {
|
||||
pending = decodePending(raw.pending, cipher)
|
||||
} catch {
|
||||
pending = []
|
||||
}
|
||||
return { pending, settled }
|
||||
}
|
||||
|
||||
export function loadAgentLaunchOperationStoreState(
|
||||
path: string,
|
||||
cipher: AgentLaunchOperationCipher
|
||||
): AgentLaunchOperationStoreDurableState {
|
||||
if (!existsSync(path)) {
|
||||
return { pending: [], settled: [] }
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(path)
|
||||
return decodeAgentLaunchOperationStore(JSON.parse(readFileSync(path, 'utf-8')), cipher)
|
||||
} catch {
|
||||
// A corrupt ledger must never block boot; start empty and let the create/
|
||||
// retry path rebuild idempotency state from scratch.
|
||||
return { pending: [], settled: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAgentLaunchOperationStoreState(
|
||||
path: string,
|
||||
state: AgentLaunchOperationStoreDurableState,
|
||||
cipher: AgentLaunchOperationCipher
|
||||
): void {
|
||||
writeSecureJsonFile(path, encodeAgentLaunchOperationStore(state, cipher))
|
||||
}
|
||||
|
||||
/** Boot-time wiring: rehydrate the durable state, then attach the write-back
|
||||
* sink so every later mutation is persisted. Called once from the main-process
|
||||
* startup after the user data dir is stable. The startup reconcile trigger that
|
||||
* consumes rehydrated pending snapshots lands with its first producer; the data
|
||||
* is made durable here regardless. */
|
||||
export function initHostAgentLaunchOperationStorePersistence(userDataPath: string): void {
|
||||
const path = agentLaunchOperationStorePath(userDataPath)
|
||||
const cipher = electronSafeStorageCipher()
|
||||
const state = loadAgentLaunchOperationStoreState(path, cipher)
|
||||
const store = getHostAgentLaunchOperationStore()
|
||||
store.rebuildSettledFrom(state.settled)
|
||||
store.rebuildPendingFrom(state.pending)
|
||||
store.setDurablePersistence((next) => {
|
||||
try {
|
||||
writeAgentLaunchOperationStoreState(path, next, cipher)
|
||||
} catch {
|
||||
// A failed persist must not break the in-flight launch; the in-memory
|
||||
// store stays authoritative and the next mutation retries the write.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Step 1 foundation: the host-private operation store's data-structure
|
||||
// invariants — canonical digest determinism, idempotency-key stability, the
|
||||
// per-scope settled-ledger bound, and in-flight snapshot lookups. Reconciliation
|
||||
// and retry idempotency that consume these land in later steps.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
AgentLaunchOperationStore,
|
||||
MAX_SETTLED_OPERATIONS_PER_SCOPE,
|
||||
agentLaunchIdempotencyKey,
|
||||
canonicalPayloadDigest,
|
||||
mintAgentLaunchOperationId,
|
||||
type PendingAgentLaunchSnapshot,
|
||||
type SettledAgentLaunchOperation
|
||||
} from './agent-launch-operation-store'
|
||||
|
||||
const SNAPSHOT: AgentLaunchSnapshot = {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
function pending(overrides: Partial<PendingAgentLaunchSnapshot> = {}): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: mintAgentLaunchOperationId(),
|
||||
idempotencyKey: 'key-a',
|
||||
scope: 'wt-1',
|
||||
clientMutationId: null,
|
||||
payloadDigest: 'digest-a',
|
||||
launchToken: 'token-a',
|
||||
intent: 'interactive',
|
||||
snapshot: SNAPSHOT,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function settled(
|
||||
overrides: Partial<SettledAgentLaunchOperation> = {}
|
||||
): SettledAgentLaunchOperation {
|
||||
return {
|
||||
operationId: mintAgentLaunchOperationId(),
|
||||
idempotencyKey: 'key-a',
|
||||
scope: 'wt-1',
|
||||
payloadDigest: 'digest-a',
|
||||
status: 'launched',
|
||||
terminalId: 'term-1',
|
||||
failureId: null,
|
||||
settledAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('canonicalPayloadDigest', () => {
|
||||
it('is insensitive to property order and absent optional fields', () => {
|
||||
const a = canonicalPayloadDigest({ action: { kind: 'retry-same' }, agent: 'claude' })
|
||||
const b = canonicalPayloadDigest({ agent: 'claude', action: { kind: 'retry-same' } })
|
||||
const c = canonicalPayloadDigest({
|
||||
agent: 'claude',
|
||||
action: { kind: 'retry-same' },
|
||||
extra: undefined
|
||||
})
|
||||
expect(a).toBe(b)
|
||||
expect(a).toBe(c)
|
||||
})
|
||||
|
||||
it('changes when a meaningful field changes', () => {
|
||||
const base = canonicalPayloadDigest({ action: { kind: 'change-agent', agent: 'claude' } })
|
||||
const changed = canonicalPayloadDigest({ action: { kind: 'change-agent', agent: 'codex' } })
|
||||
expect(base).not.toBe(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentLaunchIdempotencyKey', () => {
|
||||
it('is stable for identical inputs and varies by every component', () => {
|
||||
const base = agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: 'wt-1',
|
||||
clientMutationId: 'm-1'
|
||||
})
|
||||
expect(
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: 'wt-1',
|
||||
clientMutationId: 'm-1'
|
||||
})
|
||||
).toBe(base)
|
||||
expect(
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: 'wt-1',
|
||||
clientMutationId: 'm-2'
|
||||
})
|
||||
).not.toBe(base)
|
||||
expect(
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: 'wt-2',
|
||||
clientMutationId: 'm-1'
|
||||
})
|
||||
).not.toBe(base)
|
||||
expect(
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'remote', id: 'device-1' },
|
||||
scope: 'wt-1',
|
||||
clientMutationId: 'm-1'
|
||||
})
|
||||
).not.toBe(base)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mintAgentLaunchOperationId', () => {
|
||||
it('mints distinct ids', () => {
|
||||
expect(mintAgentLaunchOperationId()).not.toBe(mintAgentLaunchOperationId())
|
||||
})
|
||||
})
|
||||
|
||||
describe('in-flight pending snapshots', () => {
|
||||
it('stores, looks up by token and idempotency key, and clears', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending({ idempotencyKey: 'key-x', launchToken: 'token-x' })
|
||||
store.beginPending(entry)
|
||||
expect(store.getPending('token-x')).toBe(entry)
|
||||
expect(store.findPendingByIdempotencyKey('wt-1', 'key-x')).toBe(entry)
|
||||
expect(store.findPendingByIdempotencyKey('wt-2', 'key-x')).toBeNull()
|
||||
expect(store.pendingSnapshots()).toHaveLength(1)
|
||||
expect(store.clearPending('token-x')).toBe(true)
|
||||
expect(store.getPending('token-x')).toBeNull()
|
||||
expect(store.pendingSnapshots()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rehydrates durable in-flight snapshots at startup', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const a = pending({ launchToken: 'token-1', idempotencyKey: 'k1' })
|
||||
const b = pending({ launchToken: 'token-2', idempotencyKey: 'k2' })
|
||||
store.rebuildPendingFrom([a, b])
|
||||
expect(store.getPending('token-1')).toBe(a)
|
||||
expect(store.getPending('token-2')).toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
describe('settled ledger', () => {
|
||||
it('retains only the newest entries per scope and isolates scopes', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
for (let index = 0; index < MAX_SETTLED_OPERATIONS_PER_SCOPE + 4; index += 1) {
|
||||
store.recordSettled(
|
||||
settled({ operationId: `op-${index}`, idempotencyKey: `k-${index}`, settledAt: index })
|
||||
)
|
||||
}
|
||||
store.recordSettled(settled({ scope: 'wt-2', operationId: 'other', idempotencyKey: 'k-other' }))
|
||||
const bucket = store.settledForScope('wt-1')
|
||||
expect(bucket).toHaveLength(MAX_SETTLED_OPERATIONS_PER_SCOPE)
|
||||
// Oldest four evicted; newest retained.
|
||||
expect(bucket.at(0)?.operationId).toBe('op-4')
|
||||
expect(bucket.at(-1)?.operationId).toBe(`op-${MAX_SETTLED_OPERATIONS_PER_SCOPE + 3}`)
|
||||
expect(store.settledForScope('wt-2')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('replaces an existing entry for the same operation rather than growing', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.recordSettled(
|
||||
settled({ operationId: 'op-1', status: 'failed', failureId: 'f-1', terminalId: null })
|
||||
)
|
||||
store.recordSettled(
|
||||
settled({ operationId: 'op-1', status: 'launched', terminalId: 't-1', failureId: null })
|
||||
)
|
||||
const bucket = store.settledForScope('wt-1')
|
||||
expect(bucket).toHaveLength(1)
|
||||
expect(bucket[0].status).toBe('launched')
|
||||
expect(bucket[0].terminalId).toBe('t-1')
|
||||
})
|
||||
|
||||
it('finds the newest settled entry by idempotency key', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.recordSettled(
|
||||
settled({ operationId: 'op-1', idempotencyKey: 'k-1', status: 'failed', settledAt: 1 })
|
||||
)
|
||||
store.recordSettled(
|
||||
settled({ operationId: 'op-2', idempotencyKey: 'k-1', status: 'launched', settledAt: 2 })
|
||||
)
|
||||
const found = store.findSettledByIdempotencyKey('wt-1', 'k-1')
|
||||
expect(found?.operationId).toBe('op-2')
|
||||
expect(store.findSettledByIdempotencyKey('wt-1', 'missing')).toBeNull()
|
||||
expect(store.findSettledByIdempotencyKey('wt-9', 'k-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('rehydrates the settled ledger in chronological order under the bound', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entries: SettledAgentLaunchOperation[] = []
|
||||
for (let index = 0; index < MAX_SETTLED_OPERATIONS_PER_SCOPE + 3; index += 1) {
|
||||
entries.push(
|
||||
settled({ operationId: `op-${index}`, idempotencyKey: `k-${index}`, settledAt: index })
|
||||
)
|
||||
}
|
||||
// Shuffle the durable order to prove rebuild sorts by settledAt before bounding.
|
||||
store.rebuildSettledFrom(entries.toReversed())
|
||||
const bucket = store.settledForScope('wt-1')
|
||||
expect(bucket).toHaveLength(MAX_SETTLED_OPERATIONS_PER_SCOPE)
|
||||
expect(bucket.at(0)?.operationId).toBe('op-3')
|
||||
expect(bucket.at(-1)?.operationId).toBe(`op-${MAX_SETTLED_OPERATIONS_PER_SCOPE + 2}`)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -0,0 +1,72 @@
|
||||
// Conservative payload caps applied after env composition. These do not replace
|
||||
// a lower provider limit (which stays spawn_failed); they fail closed before a
|
||||
// writer runs so persisted/remote data and inherited env size cannot smuggle an
|
||||
// oversized command or environment past resolution.
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import { buildShellCommandFromArgv } from '../../shared/tui-agent-startup-shell'
|
||||
import {
|
||||
CMD_EXE_COMMAND_LINE_MAX_CHARS,
|
||||
POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS
|
||||
} from '../providers/windows-shell-args'
|
||||
import { utf8ByteLength } from '../../shared/custom-tui-agent-fields'
|
||||
import type { AgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import type { AgentArgv } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
measurePosixArgEnvBytes,
|
||||
measureWindowsEnvironmentBlockCodeUnits,
|
||||
POSIX_ARG_ENV_SAFE_MAX_BYTES,
|
||||
POSIX_STARTUP_COMMAND_MAX_BYTES,
|
||||
WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS,
|
||||
type EnvLayer
|
||||
} from './compose-agent-launch-env'
|
||||
|
||||
/** PowerShell -EncodedCommand is base64 of the UTF-16LE command; this mirrors
|
||||
* that length so the hard OS command-line ceiling is enforced pre-spawn. */
|
||||
function estimatePowerShellEncodedLength(commandText: string): number {
|
||||
return Math.ceil(Buffer.byteLength(commandText, 'utf16le') / 3) * 4
|
||||
}
|
||||
|
||||
/** Reject a command whose final shell form exceeds the target's hard OS limit.
|
||||
* The 6000-char inline threshold is a delivery-path switch, not a failure, and
|
||||
* lives in the startup writer (U3). */
|
||||
export function checkCommandTooLong(
|
||||
argv: AgentArgv,
|
||||
shell: AgentStartupShell
|
||||
): AgentLaunchFailure | null {
|
||||
const commandText = buildShellCommandFromArgv(argv, shell)
|
||||
if (shell === 'cmd') {
|
||||
return commandText.length > CMD_EXE_COMMAND_LINE_MAX_CHARS
|
||||
? { code: 'launch_command_too_long', shell }
|
||||
: null
|
||||
}
|
||||
if (shell === 'powershell') {
|
||||
return estimatePowerShellEncodedLength(commandText) > POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS
|
||||
? { code: 'launch_command_too_long', shell }
|
||||
: null
|
||||
}
|
||||
return utf8ByteLength(commandText) > POSIX_STARTUP_COMMAND_MAX_BYTES
|
||||
? { code: 'launch_command_too_long', shell }
|
||||
: null
|
||||
}
|
||||
|
||||
/** Reject an oversized effective environment. Native-Windows spawns measure the
|
||||
* CreateProcess environment block; every other target measures the combined
|
||||
* UTF-8 argv+env payload delivered as shell text. */
|
||||
export function checkEnvPayloadTooLarge(
|
||||
argv: AgentArgv,
|
||||
env: EnvLayer,
|
||||
target: { platform: NodeJS.Platform; execution: 'native' | 'wsl'; isRemote: boolean }
|
||||
): AgentLaunchFailure | null {
|
||||
const isNativeWindowsSpawn =
|
||||
target.platform === 'win32' && target.execution === 'native' && !target.isRemote
|
||||
if (isNativeWindowsSpawn) {
|
||||
return measureWindowsEnvironmentBlockCodeUnits(env) > WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS
|
||||
? { code: 'invalid_agent_env', field: 'env', reason: 'environment_block_too_large' }
|
||||
: null
|
||||
}
|
||||
return measurePosixArgEnvBytes(argv, env) > POSIX_ARG_ENV_SAFE_MAX_BYTES
|
||||
? { code: 'invalid_agent_env', field: 'env', reason: 'arg_env_too_large' }
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentLaunchExecutionHostDisplayName,
|
||||
buildPendingAgentLaunchSummary
|
||||
} from './agent-launch-pending-summary-host'
|
||||
import type { AdmissionCapacityRow } from './agent-launch-admission-store'
|
||||
|
||||
function row(over: Partial<AdmissionCapacityRow>): AdmissionCapacityRow {
|
||||
return {
|
||||
intent: 'cli',
|
||||
scope: 'wt-1',
|
||||
admittedAt: 1,
|
||||
launchToken: 'secret-tok',
|
||||
baseHarness: 'codex',
|
||||
executionHostId: 'local',
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
describe('agentLaunchExecutionHostDisplayName', () => {
|
||||
it('labels local, ssh (alias then id fallback), wsl distro, and runtime env', () => {
|
||||
expect(agentLaunchExecutionHostDisplayName('local', () => undefined)).toBeTruthy()
|
||||
expect(
|
||||
agentLaunchExecutionHostDisplayName('ssh:prod', (t) =>
|
||||
t === 'prod' ? 'Prod box' : undefined
|
||||
)
|
||||
).toBe('Prod box')
|
||||
expect(agentLaunchExecutionHostDisplayName('ssh:prod', () => undefined)).toBe('prod')
|
||||
// wsl:${encodeURIComponent(distro)} — decoded back to the distro name.
|
||||
expect(agentLaunchExecutionHostDisplayName('wsl:My%20Distro', () => undefined)).toBe(
|
||||
'My Distro'
|
||||
)
|
||||
expect(agentLaunchExecutionHostDisplayName('runtime:env-9', () => undefined)).toBe('env-9')
|
||||
})
|
||||
|
||||
it('never returns a path-shaped value for a display name', () => {
|
||||
for (const id of ['local', 'ssh:prod', 'wsl:Ubuntu', 'runtime:env-1'] as const) {
|
||||
expect(agentLaunchExecutionHostDisplayName(id, () => undefined)).not.toContain('/')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPendingAgentLaunchSummary', () => {
|
||||
it('projects redacted rows and never emits the host-private launch token', () => {
|
||||
const result = buildPendingAgentLaunchSummary(
|
||||
[
|
||||
row({
|
||||
launchToken: 'secret-tok',
|
||||
scope: 'wt-1',
|
||||
intent: 'cli',
|
||||
baseHarness: 'codex',
|
||||
admittedAt: 42
|
||||
})
|
||||
],
|
||||
{
|
||||
resolveLiveness: () => 'live',
|
||||
resolveDeepLink: (r) => ({ kind: 'worktree', worktreeId: r.scope }),
|
||||
sshLabelFor: () => undefined
|
||||
}
|
||||
)
|
||||
expect(result.rows[0]).toEqual({
|
||||
sourceKind: 'cli',
|
||||
baseHarness: 'codex',
|
||||
targetHostDisplayName: expect.any(String),
|
||||
admittedAt: 42,
|
||||
liveness: 'live',
|
||||
deepLink: { kind: 'worktree', worktreeId: 'wt-1' }
|
||||
})
|
||||
// The launch token is host-private and must never reach the client DTO.
|
||||
expect(result.rows[0]).not.toHaveProperty('launchToken')
|
||||
expect(JSON.stringify(result)).not.toContain('secret-tok')
|
||||
})
|
||||
|
||||
it('omits deepLink when no owner resolves and passes injected liveness through', () => {
|
||||
const result = buildPendingAgentLaunchSummary([row({})], {
|
||||
resolveLiveness: () => 'absent',
|
||||
resolveDeepLink: () => undefined,
|
||||
sshLabelFor: () => undefined
|
||||
})
|
||||
expect(result.rows[0]).not.toHaveProperty('deepLink')
|
||||
expect(result.rows[0].liveness).toBe('absent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
// Pure projection from redacted admission capacity rows to the client-safe
|
||||
// pending-summary DTO. Dependency-injected (liveness, deep link, ssh label) and
|
||||
// electron-free so it is unit-testable; the runtime supplies the host lookups.
|
||||
// The launch token on each input row stays host-side — it is used only to feed
|
||||
// the injected liveness resolver and is never copied into an output row.
|
||||
|
||||
import { getLocalExecutionHostLabel, parseExecutionHostId } from '../../shared/execution-host'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
PendingAgentLaunchDeepLink,
|
||||
PendingAgentLaunchLiveness,
|
||||
PendingAgentLaunchSummary,
|
||||
PendingAgentLaunchSummaryRow
|
||||
} from '../../shared/agent-launch-pending-summary'
|
||||
import type { AdmissionCapacityRow } from './agent-launch-admission-store'
|
||||
|
||||
/** User-facing display name for a launch's execution host. Composes the shared
|
||||
* local/ssh/runtime labelers with this feature's `wsl:${distro}` arm (the shared
|
||||
* grammar has no WSL variant). Returns a name, never a path. */
|
||||
export function agentLaunchExecutionHostDisplayName(
|
||||
id: AgentLaunchExecutionHostId,
|
||||
sshLabelFor: (targetId: string) => string | undefined
|
||||
): string {
|
||||
if (id === 'local') {
|
||||
return getLocalExecutionHostLabel()
|
||||
}
|
||||
if (id.startsWith('wsl:')) {
|
||||
try {
|
||||
return decodeURIComponent(id.slice('wsl:'.length))
|
||||
} catch {
|
||||
return id
|
||||
}
|
||||
}
|
||||
const parsed = parseExecutionHostId(id)
|
||||
if (parsed?.kind === 'ssh') {
|
||||
return sshLabelFor(parsed.targetId) ?? parsed.targetId
|
||||
}
|
||||
if (parsed?.kind === 'runtime') {
|
||||
return parsed.environmentId
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export type PendingAgentLaunchSummaryDeps = {
|
||||
resolveLiveness: (row: AdmissionCapacityRow) => PendingAgentLaunchLiveness
|
||||
resolveDeepLink: (row: AdmissionCapacityRow) => PendingAgentLaunchDeepLink | undefined
|
||||
sshLabelFor: (targetId: string) => string | undefined
|
||||
}
|
||||
|
||||
export function buildPendingAgentLaunchSummary(
|
||||
rows: readonly AdmissionCapacityRow[],
|
||||
deps: PendingAgentLaunchSummaryDeps
|
||||
): PendingAgentLaunchSummary {
|
||||
return {
|
||||
rows: rows.map((row): PendingAgentLaunchSummaryRow => {
|
||||
const deepLink = deps.resolveDeepLink(row)
|
||||
return {
|
||||
sourceKind: row.intent,
|
||||
baseHarness: row.baseHarness,
|
||||
targetHostDisplayName: agentLaunchExecutionHostDisplayName(
|
||||
row.executionHostId,
|
||||
deps.sshLabelFor
|
||||
),
|
||||
admittedAt: row.admittedAt,
|
||||
liveness: deps.resolveLiveness(row),
|
||||
...(deepLink ? { deepLink } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store'
|
||||
import {
|
||||
reconcilePersistenceForIntent,
|
||||
type ReconcileIntentRouterArms
|
||||
} from './agent-launch-reconcile-intent-router'
|
||||
import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pending(intent: AgentLaunchIntentKind, scope: string): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: 'op-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
scope,
|
||||
clientMutationId: null,
|
||||
payloadDigest: 'digest-1',
|
||||
launchToken: 'token-1',
|
||||
intent,
|
||||
snapshot: snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Arms that tag each returned slice with the family + scope it was built for, so
|
||||
* a test can assert which arm handled a given intent and with which owner id. */
|
||||
function taggingArms(): {
|
||||
arms: ReconcileIntentRouterArms
|
||||
calls: { family: keyof ReconcileIntentRouterArms; scope: string }[]
|
||||
} {
|
||||
const calls: { family: keyof ReconcileIntentRouterArms; scope: string }[] = []
|
||||
const arm =
|
||||
(family: keyof ReconcileIntentRouterArms) =>
|
||||
(scope: string): ReconcileScopePersistence => {
|
||||
calls.push({ family, scope })
|
||||
return { settleLaunched: () => {}, settleFailed: () => {}, markUnknown: () => {} }
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
arms: {
|
||||
worktree: arm('worktree'),
|
||||
automation: arm('automation'),
|
||||
orchestration: arm('orchestration'),
|
||||
background: arm('background')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('reconcilePersistenceForIntent', () => {
|
||||
it('routes interactive, cli, and resume to the worktree arm with the scope id', () => {
|
||||
for (const intent of ['interactive', 'cli', 'resume'] as const) {
|
||||
const { arms, calls } = taggingArms()
|
||||
reconcilePersistenceForIntent(arms, pending(intent, 'wt-1'))
|
||||
expect(calls).toEqual([{ family: 'worktree', scope: 'wt-1' }])
|
||||
}
|
||||
})
|
||||
|
||||
it('routes automation to the automation arm with the run id', () => {
|
||||
const { arms, calls } = taggingArms()
|
||||
reconcilePersistenceForIntent(arms, pending('automation', 'run-9'))
|
||||
expect(calls).toEqual([{ family: 'automation', scope: 'run-9' }])
|
||||
})
|
||||
|
||||
it('routes orchestration to the orchestration arm with the dispatch id', () => {
|
||||
const { arms, calls } = taggingArms()
|
||||
reconcilePersistenceForIntent(arms, pending('orchestration', 'dispatch-7'))
|
||||
expect(calls).toEqual([{ family: 'orchestration', scope: 'dispatch-7' }])
|
||||
})
|
||||
|
||||
it('routes background to the background arm with the attempt id', () => {
|
||||
const { arms, calls } = taggingArms()
|
||||
reconcilePersistenceForIntent(arms, pending('background', 'attempt-3'))
|
||||
expect(calls).toEqual([{ family: 'background', scope: 'attempt-3' }])
|
||||
})
|
||||
|
||||
it('never crosses families when two owners share a scope id namespace', () => {
|
||||
const { arms, calls } = taggingArms()
|
||||
reconcilePersistenceForIntent(arms, pending('background', 'shared-id'))
|
||||
reconcilePersistenceForIntent(arms, pending('automation', 'shared-id'))
|
||||
expect(calls).toEqual([
|
||||
{ family: 'background', scope: 'shared-id' },
|
||||
{ family: 'automation', scope: 'shared-id' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// Routes a reconciling pending launch to its owner record's persistence slice by
|
||||
// INTENT (U6). Each unattended launch kind lands its reconciled outcome in a
|
||||
// different owner store — background attempt, automation run, orchestration
|
||||
// dispatch, or the interactive worktree meta — so a background attempt's failure
|
||||
// never overwrites a worktree's launch card even if their scope ids collide. The
|
||||
// arms are injected so this stays electron-free and unit-testable; the runtime
|
||||
// binds each arm to its concrete store write.
|
||||
|
||||
import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store'
|
||||
import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
/** Owner-record persistence factories, one per launch-intent family. Each takes
|
||||
* the pending's scope id (the owner bucket: worktree id, run id, dispatch id, or
|
||||
* attempt id) and returns the tri-state writer the reconciler drives. */
|
||||
export type ReconcileIntentRouterArms = {
|
||||
/** interactive / cli / resume launches — scope is a worktree id. */
|
||||
worktree: (worktreeId: string) => ReconcileScopePersistence
|
||||
/** automation launches — scope is an automation run id. */
|
||||
automation: (runId: string) => ReconcileScopePersistence
|
||||
/** orchestration launches — scope is a dispatch context id. */
|
||||
orchestration: (dispatchId: string) => ReconcileScopePersistence
|
||||
/** background launches — scope is a background attempt id. */
|
||||
background: (attemptId: string) => ReconcileScopePersistence
|
||||
}
|
||||
|
||||
/** Pick the owner-record persistence slice for one pending launch by its intent.
|
||||
* interactive/cli/resume all resolve to the worktree writer (they share the
|
||||
* WorktreeMeta launch card); the three unattended kinds each get their own. */
|
||||
export function reconcilePersistenceForIntent(
|
||||
arms: ReconcileIntentRouterArms,
|
||||
pending: PendingAgentLaunchSnapshot
|
||||
): ReconcileScopePersistence {
|
||||
switch (pending.intent) {
|
||||
case 'interactive':
|
||||
case 'cli':
|
||||
case 'resume':
|
||||
return arms.worktree(pending.scope)
|
||||
case 'automation':
|
||||
return arms.automation(pending.scope)
|
||||
case 'orchestration':
|
||||
return arms.orchestration(pending.scope)
|
||||
case 'background':
|
||||
return arms.background(pending.scope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentLaunchIntentKind } from '../../shared/agent-launch-contract'
|
||||
import {
|
||||
AgentLaunchOperationStore,
|
||||
type PendingAgentLaunchSnapshot
|
||||
} from './agent-launch-operation-store'
|
||||
import { BackgroundAgentLaunchStore } from './background-agent-launch-store'
|
||||
import {
|
||||
buildReconcileAgentLaunchDeps,
|
||||
type LiveTerminalForToken,
|
||||
type ReconcileRuntimeDeps
|
||||
} from './agent-launch-reconcile-runtime-deps'
|
||||
import type { ReconcileIntentRouterArms } from './agent-launch-reconcile-intent-router'
|
||||
import {
|
||||
reconcileOnePendingAgentLaunch,
|
||||
type ReconcileScopePersistence
|
||||
} from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
function snapshot(executionHostId: AgentLaunchExecutionHostId): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: executionHostId !== 'local',
|
||||
executionHostId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pending(
|
||||
overrides: Partial<PendingAgentLaunchSnapshot> = {},
|
||||
executionHostId: AgentLaunchExecutionHostId = 'local'
|
||||
): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: 'op-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
scope: 'wt-1',
|
||||
clientMutationId: null,
|
||||
payloadDigest: 'digest-1',
|
||||
launchToken: 'token-1',
|
||||
intent: 'interactive' as AgentLaunchIntentKind,
|
||||
snapshot: snapshot(executionHostId),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function spyArm(): ReconcileScopePersistence & { calls: string[] } {
|
||||
const calls: string[] = []
|
||||
return {
|
||||
calls,
|
||||
settleLaunched: () => calls.push('launched'),
|
||||
settleFailed: () => calls.push('failed'),
|
||||
markUnknown: () => calls.push('unknown')
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
overrides: Partial<ReconcileRuntimeDeps> & {
|
||||
liveTerminalByToken?: (token: string) => LiveTerminalForToken | null
|
||||
arms?: ReconcileIntentRouterArms
|
||||
}
|
||||
): { store: AgentLaunchOperationStore; deps: ReturnType<typeof buildReconcileAgentLaunchDeps> } {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const noopArm = (): ReconcileScopePersistence => spyArm()
|
||||
const runtimeDeps: ReconcileRuntimeDeps = {
|
||||
operationStore: store,
|
||||
liveTerminalByToken: overrides.liveTerminalByToken ?? (() => null),
|
||||
isHostAuthoritative: overrides.isHostAuthoritative ?? ((id) => id === 'local'),
|
||||
expectedWorktreeId: overrides.expectedWorktreeId ?? ((p) => p.scope),
|
||||
arms: overrides.arms ?? {
|
||||
worktree: noopArm,
|
||||
automation: noopArm,
|
||||
orchestration: noopArm,
|
||||
background: noopArm
|
||||
},
|
||||
settleBoundary: overrides.settleBoundary ?? vi.fn(),
|
||||
mintFailureId: overrides.mintFailureId ?? (() => 'failure-1'),
|
||||
now: () => 1000
|
||||
}
|
||||
return { store, deps: buildReconcileAgentLaunchDeps(runtimeDeps) }
|
||||
}
|
||||
|
||||
describe('buildReconcileAgentLaunchDeps liveness', () => {
|
||||
it('resolves a live token in the launch worktree as attributed', () => {
|
||||
const arm = spyArm()
|
||||
const { store, deps } = buildDeps({
|
||||
liveTerminalByToken: () => ({ ptyId: 'term-9', worktreeId: 'wt-1' }),
|
||||
arms: {
|
||||
worktree: () => arm,
|
||||
automation: () => arm,
|
||||
orchestration: () => arm,
|
||||
background: () => arm
|
||||
}
|
||||
})
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'launched' })
|
||||
expect(arm.calls).toEqual(['launched'])
|
||||
})
|
||||
|
||||
it('resolves a live token in a different worktree as unattributed (theft class)', () => {
|
||||
const arm = spyArm()
|
||||
const { store, deps } = buildDeps({
|
||||
liveTerminalByToken: () => ({ ptyId: 'term-hijack', worktreeId: 'wt-OTHER' }),
|
||||
arms: {
|
||||
worktree: () => arm,
|
||||
automation: () => arm,
|
||||
orchestration: () => arm,
|
||||
background: () => arm
|
||||
}
|
||||
})
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'invalid_launch_snapshot' })
|
||||
expect(arm.calls).toEqual(['failed'])
|
||||
})
|
||||
|
||||
it('settles a non-live local pending as absent → spawn_failed (host is authoritative)', () => {
|
||||
const arm = spyArm()
|
||||
const { store, deps } = buildDeps({
|
||||
isHostAuthoritative: (id) => id === 'local',
|
||||
arms: {
|
||||
worktree: () => arm,
|
||||
automation: () => arm,
|
||||
orchestration: () => arm,
|
||||
background: () => arm
|
||||
}
|
||||
})
|
||||
const entry = pending({}, 'local')
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'spawn_failed' })
|
||||
expect(arm.calls).toEqual(['failed'])
|
||||
})
|
||||
|
||||
it('keeps a non-live remote pending unknown when its host is not authoritative', () => {
|
||||
const arm = spyArm()
|
||||
const { store, deps } = buildDeps({
|
||||
isHostAuthoritative: (id) => id === 'local',
|
||||
arms: {
|
||||
worktree: () => arm,
|
||||
automation: () => arm,
|
||||
orchestration: () => arm,
|
||||
background: () => arm
|
||||
}
|
||||
})
|
||||
const entry = pending({ launchToken: 'token-r' }, 'ssh:host-a')
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'launch_state_unknown' })
|
||||
expect(arm.calls).toEqual(['unknown'])
|
||||
// Coexistence: the reservation and pending survive for a later reconnect probe.
|
||||
expect(store.getPending('token-r')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('settles a remote pending absent once its host becomes authoritative (reconnect probe)', () => {
|
||||
const arm = spyArm()
|
||||
const { store, deps } = buildDeps({
|
||||
isHostAuthoritative: (id) => id === 'local' || id === 'ssh:host-a',
|
||||
arms: {
|
||||
worktree: () => arm,
|
||||
automation: () => arm,
|
||||
orchestration: () => arm,
|
||||
background: () => arm
|
||||
}
|
||||
})
|
||||
const entry = pending({ launchToken: 'token-r' }, 'ssh:host-a')
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'spawn_failed' })
|
||||
expect(arm.calls).toEqual(['failed'])
|
||||
})
|
||||
|
||||
it('routes a background pending to the background store keyed by attempt id', () => {
|
||||
const background = new BackgroundAgentLaunchStore({ now: () => 1000 })
|
||||
background.create({
|
||||
attemptId: 'attempt-7',
|
||||
worktreeId: 'wt-bg',
|
||||
operationId: 'op-bg',
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude'
|
||||
})
|
||||
const { store, deps } = buildDeps({
|
||||
isHostAuthoritative: () => true,
|
||||
expectedWorktreeId: () => 'wt-bg',
|
||||
arms: {
|
||||
worktree: () => spyArm(),
|
||||
automation: () => spyArm(),
|
||||
orchestration: () => spyArm(),
|
||||
background: (attemptId) => background.persistenceForAttempt(attemptId)
|
||||
}
|
||||
})
|
||||
const entry = pending(
|
||||
{ scope: 'attempt-7', launchToken: 'token-bg', intent: 'background' },
|
||||
'local'
|
||||
)
|
||||
store.beginPending(entry)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
// Local + no live token → absent → spawn_failed lands in the attempt record.
|
||||
expect(outcome).toEqual({ kind: 'spawn_failed' })
|
||||
expect(background.get('attempt-7')?.state).toBe('failed')
|
||||
expect(background.get('attempt-7')?.failure?.code).toBe('spawn_failed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
// Builds the ReconcileAgentLaunchDeps the runtime drives from injected host
|
||||
// primitives (U6). Keeps the liveness-resolution + owner-routing wiring pure and
|
||||
// electron-free so it is unit-testable away from the 20k-line runtime; the
|
||||
// runtime supplies the concrete token probe, host-authority predicate, and owner
|
||||
// writers.
|
||||
//
|
||||
// Liveness follows the plan's reconciliation contract (487-513) exactly:
|
||||
// - A launch token matched to a live terminal → `live`; `attributed` is whether
|
||||
// that terminal still belongs to the launch's worktree (an unattributed live
|
||||
// token is the pane-identity-theft class → invalid_launch_snapshot).
|
||||
// - No live token match → `absent` ONLY when the pending's execution host is
|
||||
// currently authoritatively listable (local in-process terminals died with
|
||||
// main; a reconnected provider just re-listed its terminals). Otherwise the
|
||||
// host is a possibly-unreachable survivor → `unknown` (non-retryable, durable)
|
||||
// until its own terminal-list/reconnect event re-probes. `isHostAuthoritative`
|
||||
// encodes which hosts a given reconcile pass can speak for, so a daemon/SSH
|
||||
// survivor is never falsely settled `absent` before its provider reconnects.
|
||||
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type { PendingAgentLaunchSnapshot } from './agent-launch-operation-store'
|
||||
import type { AgentLaunchOperationStore } from './agent-launch-operation-store'
|
||||
import {
|
||||
reconcilePersistenceForIntent,
|
||||
type ReconcileIntentRouterArms
|
||||
} from './agent-launch-reconcile-intent-router'
|
||||
import type {
|
||||
ReconcileAgentLaunchDeps,
|
||||
ResolvedLaunchLiveness
|
||||
} from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
/** A live terminal a launch token currently maps to. `worktreeId` is compared to
|
||||
* the launch's expected worktree for attribution. */
|
||||
export type LiveTerminalForToken = { ptyId: string; worktreeId: string }
|
||||
|
||||
export type ReconcileRuntimeDeps = {
|
||||
operationStore: AgentLaunchOperationStore
|
||||
/** The live terminal holding a launch token, or null if none is live. */
|
||||
liveTerminalByToken: (launchToken: string) => LiveTerminalForToken | null
|
||||
/** Whether a non-live pending's host can be spoken for authoritatively in this
|
||||
* reconcile pass (→ `absent`); false leaves it `unknown`. */
|
||||
isHostAuthoritative: (executionHostId: AgentLaunchExecutionHostId) => boolean
|
||||
/** The worktree a live token must belong to for attribution: the scope for a
|
||||
* worktree launch, the attempt's worktree for a background launch, or null when
|
||||
* the intent has no worktree to compare (attribution then trusts the token). */
|
||||
expectedWorktreeId: (pending: PendingAgentLaunchSnapshot) => string | null
|
||||
arms: ReconcileIntentRouterArms
|
||||
settleBoundary: (launchToken: string, settlement: 'registered' | 'failed') => void
|
||||
mintFailureId: () => string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
function resolveLiveness(
|
||||
deps: ReconcileRuntimeDeps,
|
||||
pending: PendingAgentLaunchSnapshot
|
||||
): ResolvedLaunchLiveness {
|
||||
const live = deps.liveTerminalByToken(pending.launchToken)
|
||||
if (live) {
|
||||
const expected = deps.expectedWorktreeId(pending)
|
||||
return {
|
||||
kind: 'live',
|
||||
attributed: expected === null || live.worktreeId === expected,
|
||||
terminalId: live.ptyId
|
||||
}
|
||||
}
|
||||
const host = pending.snapshot.target.executionHostId
|
||||
return deps.isHostAuthoritative(host) ? { kind: 'absent' } : { kind: 'unknown' }
|
||||
}
|
||||
|
||||
export function buildReconcileAgentLaunchDeps(
|
||||
deps: ReconcileRuntimeDeps
|
||||
): ReconcileAgentLaunchDeps {
|
||||
return {
|
||||
operationStore: deps.operationStore,
|
||||
resolveLiveness: (pending) => resolveLiveness(deps, pending),
|
||||
persistenceFor: (pending) => reconcilePersistenceForIntent(deps.arms, pending),
|
||||
settleBoundary: deps.settleBoundary,
|
||||
mintFailureId: deps.mintFailureId,
|
||||
now: deps.now
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
reconcileAgentLaunchLiveness,
|
||||
retryRecoveryGateForFailureCode
|
||||
} from './agent-launch-reconciliation'
|
||||
|
||||
describe('reconcileAgentLaunchLiveness', () => {
|
||||
it('live + attributed settles launched', () => {
|
||||
expect(reconcileAgentLaunchLiveness({ kind: 'live', attributed: true })).toEqual({
|
||||
kind: 'launched'
|
||||
})
|
||||
})
|
||||
|
||||
it('live + unattributed records invalid_launch_snapshot', () => {
|
||||
expect(reconcileAgentLaunchLiveness({ kind: 'live', attributed: false })).toEqual({
|
||||
kind: 'invalid_launch_snapshot'
|
||||
})
|
||||
})
|
||||
|
||||
it('absent settles spawn_failed so retry becomes available', () => {
|
||||
expect(reconcileAgentLaunchLiveness({ kind: 'absent' })).toEqual({ kind: 'spawn_failed' })
|
||||
})
|
||||
|
||||
it('unknown keeps the launch pending as launch_state_unknown', () => {
|
||||
expect(reconcileAgentLaunchLiveness({ kind: 'unknown' })).toEqual({
|
||||
kind: 'launch_state_unknown'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('retryRecoveryGateForFailureCode', () => {
|
||||
it('blocks retry while liveness is unknown', () => {
|
||||
expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({
|
||||
kind: 'launch_state_unknown'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks retry while a token-live terminal lacks attribution', () => {
|
||||
expect(retryRecoveryGateForFailureCode('invalid_launch_snapshot')).toEqual({
|
||||
kind: 'invalid_launch_snapshot'
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an ordinary spawn failure as retryable', () => {
|
||||
expect(retryRecoveryGateForFailureCode('spawn_failed')).toEqual({ kind: 'retryable' })
|
||||
})
|
||||
|
||||
it('treats an absent durable failure as retryable', () => {
|
||||
expect(retryRecoveryGateForFailureCode(undefined)).toEqual({ kind: 'retryable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// Pure tri-state reconciliation for a pending agent launch (U4/U5). The provider
|
||||
// reports one of three liveness results for a launch token — live, absent, or
|
||||
// unknown — and this maps them to the four persisted recovery outcomes in the
|
||||
// plan's table. It NEVER polls or sleeps: provider reconnect / terminal-list
|
||||
// events rerun it. Absence is authoritative only for providers whose terminals
|
||||
// die with main (local in-process PTYs); daemon/SSH/WSL-relay/remote-runtime
|
||||
// terminals may outlive main, so their `absent` must come from a real provider
|
||||
// listing, and a disconnected provider is `unknown`, never a false `absent` that
|
||||
// would enable a duplicate retry. Electron-free and injectable.
|
||||
|
||||
import type { AgentLaunchFailureCode } from '../../shared/agent-launch-contract'
|
||||
import type { RetryRecoveryGate } from './agent-launch-worktree-retry'
|
||||
|
||||
/** Provider liveness for a launch token. `attributed` is whether the live
|
||||
* terminal still carries a matching private snapshot/token attribution; a
|
||||
* token-matched terminal without it cannot be trusted as the launched agent. */
|
||||
export type ProviderLiveness =
|
||||
| { kind: 'live'; attributed: boolean }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
/** Reconciled outcome, one per row of the plan's reconciliation table. */
|
||||
export type AgentLaunchReconcileOutcome =
|
||||
// Settle launched, clear pending/failure, never spawn again.
|
||||
| { kind: 'launched' }
|
||||
// Token-live but unattributed: record failed/invalid_launch_snapshot, keep the
|
||||
// terminal visible, disable Retry/Choose while live, never spawn a duplicate.
|
||||
| { kind: 'invalid_launch_snapshot' }
|
||||
// Absent: settle failed/spawn_failed; Retry becomes available.
|
||||
| { kind: 'spawn_failed' }
|
||||
// Unknown: keep pending, show "Launch state unavailable", spawn/tear down nothing.
|
||||
| { kind: 'launch_state_unknown' }
|
||||
|
||||
export function reconcileAgentLaunchLiveness(
|
||||
liveness: ProviderLiveness
|
||||
): AgentLaunchReconcileOutcome {
|
||||
switch (liveness.kind) {
|
||||
case 'live':
|
||||
return liveness.attributed ? { kind: 'launched' } : { kind: 'invalid_launch_snapshot' }
|
||||
case 'absent':
|
||||
return { kind: 'spawn_failed' }
|
||||
case 'unknown':
|
||||
return { kind: 'launch_state_unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry recovery gate derived from the CURRENT persisted failure code, not a
|
||||
* live probe: reconciliation is event-driven and has already written the code
|
||||
* the recovery card renders, so the server-side gate reads that same state. The
|
||||
* two blocking codes (launch_state_unknown while liveness is unknown,
|
||||
* invalid_launch_snapshot while a token-live terminal lacks attribution) fail
|
||||
* the retry WITHOUT mutation; every other durable failure is retryable. */
|
||||
export function retryRecoveryGateForFailureCode(
|
||||
code: AgentLaunchFailureCode | undefined
|
||||
): RetryRecoveryGate {
|
||||
if (code === 'launch_state_unknown') {
|
||||
return { kind: 'launch_state_unknown' }
|
||||
}
|
||||
if (code === 'invalid_launch_snapshot') {
|
||||
return { kind: 'invalid_launch_snapshot' }
|
||||
}
|
||||
return { kind: 'retryable' }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// U5: resume/fork ingestion resolves the private record by ownership key and
|
||||
// produces the resume-specific launch inputs (v1-snapshot replay or opaque legacy
|
||||
// replay), or an in-band invalid_launch_snapshot that never silently substitutes
|
||||
// current config.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentSessionOwnershipKey,
|
||||
SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import {
|
||||
resolveResumeLaunchIngest,
|
||||
type ResumeLaunchIngestInput
|
||||
} from './agent-launch-resume-ingest'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'custom-agent:claude:reviewer',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Reviewer',
|
||||
mode: 'custom',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const KEY: AgentSessionOwnershipKey = {
|
||||
worktreeId: 'wt-1',
|
||||
baseAgent: 'claude',
|
||||
providerSessionId: 'sess-1'
|
||||
}
|
||||
|
||||
const LEGACY_CONFIG: SleepingAgentLaunchConfig = {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '--model opus',
|
||||
agentEnv: { FOO: 'bar' }
|
||||
}
|
||||
|
||||
/** Desktop trusted context with an optional first-resume handoff. */
|
||||
function desktopLegacy(
|
||||
handoff?: { launchConfig: SleepingAgentLaunchConfig; recordedConnectionId: string | null },
|
||||
connectionId: string | null = null
|
||||
): ResumeLaunchIngestInput['legacy'] {
|
||||
return { shell: 'posix', connectionId, ...(handoff ? { handoff } : {}) }
|
||||
}
|
||||
|
||||
function storeWithBoundRecord(): AgentSessionRecordStore {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register({
|
||||
paneKey: 'pane-a',
|
||||
terminalId: 'term-a',
|
||||
worktreeId: 'wt-1',
|
||||
requestedAgent: 'custom-agent:claude:reviewer',
|
||||
baseAgent: 'claude',
|
||||
launchSnapshot: snapshot(),
|
||||
launchToken: 'token-a'
|
||||
})
|
||||
store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' })
|
||||
return store
|
||||
}
|
||||
|
||||
describe('resolveResumeLaunchIngest — v1 snapshot', () => {
|
||||
it('produces resume inputs from a bound v1-snapshot record', () => {
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{ resume: { operation: 'resume', sessionKey: KEY }, client: 'desktop' },
|
||||
storeWithBoundRecord()
|
||||
)
|
||||
expect(result.ok && result.kind).toBe('snapshot')
|
||||
if (!result.ok || result.kind !== 'snapshot') {
|
||||
return
|
||||
}
|
||||
expect(result.request.selection).toEqual({
|
||||
kind: 'agent',
|
||||
agent: 'custom-agent:claude:reviewer'
|
||||
})
|
||||
expect(result.request.sourceRecord).toEqual({ owner: 'session' })
|
||||
expect(result.request.allowEmptyPromptLaunch).toBe(true)
|
||||
expect(result.request.prompt).toBeUndefined()
|
||||
expect(result.intent).toEqual({ kind: 'resume', operation: 'resume', client: 'desktop' })
|
||||
expect(result.persistedSnapshot).toEqual(snapshot())
|
||||
expect(result.resumeProviderSession).toEqual({ key: 'session_id', id: 'sess-1' })
|
||||
})
|
||||
|
||||
it('carries the fork operation into the intent', () => {
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{ resume: { operation: 'fork', sessionKey: KEY }, client: 'desktop' },
|
||||
storeWithBoundRecord()
|
||||
)
|
||||
expect(result.ok && result.intent).toMatchObject({ kind: 'resume', operation: 'fork' })
|
||||
})
|
||||
|
||||
it('maps the authenticated client into the resume intent', () => {
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{ resume: { operation: 'resume', sessionKey: KEY }, client: 'mobile' },
|
||||
storeWithBoundRecord()
|
||||
)
|
||||
expect(result.ok && result.intent).toMatchObject({ client: 'mobile' })
|
||||
})
|
||||
|
||||
it('returns invalid_launch_snapshot for an unknown ownership key', () => {
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: {
|
||||
operation: 'resume',
|
||||
sessionKey: { worktreeId: 'wt-x', baseAgent: 'codex', providerSessionId: 'nope' }
|
||||
},
|
||||
client: 'desktop'
|
||||
},
|
||||
storeWithBoundRecord()
|
||||
)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveResumeLaunchIngest — opaque legacy replay', () => {
|
||||
it('persists the surrendered config once and replays it opaquely on first resume', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: { operation: 'resume', sessionKey: KEY },
|
||||
client: 'desktop',
|
||||
legacy: desktopLegacy({ launchConfig: LEGACY_CONFIG, recordedConnectionId: null })
|
||||
},
|
||||
store
|
||||
)
|
||||
expect(result.ok && result.kind).toBe('legacy')
|
||||
if (!result.ok || result.kind !== 'legacy') {
|
||||
return
|
||||
}
|
||||
expect(result.baseAgent).toBe('claude')
|
||||
expect(result.requestedAgent).toBe('claude')
|
||||
// Base command + args, then the appended provider resume flags (one-shot only).
|
||||
expect(result.launchCommand).toContain('claude')
|
||||
expect(result.launchCommand).toContain('--model')
|
||||
expect(result.launchCommand).toContain('--resume')
|
||||
expect(result.launchCommand).toContain('sess-1')
|
||||
// Durable config stays base-only so a fresh relaunch never re-resumes.
|
||||
expect(result.launchConfig.agentArgs).toBe('--model opus')
|
||||
expect(result.launchConfig.agentArgs).not.toContain('--resume')
|
||||
// Persist-once: the host now owns the record and a second resume needs no handoff.
|
||||
const stored = store.resolveByOwnershipKey(KEY)
|
||||
expect(stored?.legacyLaunchConfig).toEqual(LEGACY_CONFIG)
|
||||
const second = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: { operation: 'resume', sessionKey: KEY },
|
||||
client: 'desktop',
|
||||
legacy: desktopLegacy()
|
||||
},
|
||||
store
|
||||
)
|
||||
expect(second.ok && second.kind).toBe('legacy')
|
||||
})
|
||||
|
||||
it('strips Orca attribution env before replay', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: { operation: 'resume', sessionKey: KEY },
|
||||
client: 'desktop',
|
||||
legacy: desktopLegacy({
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: { FOO: 'bar', ORCA_PANE_KEY: 'pane', TMUX: 'x' }
|
||||
},
|
||||
recordedConnectionId: null
|
||||
})
|
||||
},
|
||||
store
|
||||
)
|
||||
expect(result.ok && result.kind === 'legacy' && result.launchConfig.agentEnv).toEqual({
|
||||
FOO: 'bar'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed when the recorded execution owner no longer matches', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: { operation: 'resume', sessionKey: KEY },
|
||||
client: 'desktop',
|
||||
legacy: desktopLegacy(
|
||||
{ launchConfig: LEGACY_CONFIG, recordedConnectionId: 'ssh:old' },
|
||||
'ssh:new'
|
||||
)
|
||||
},
|
||||
store
|
||||
)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } })
|
||||
// Never a partial write: an owner mismatch leaves the store untouched.
|
||||
expect(store.resolveByOwnershipKey(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('fails closed on an invalid surviving env and never writes the record', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{
|
||||
resume: { operation: 'resume', sessionKey: KEY },
|
||||
client: 'desktop',
|
||||
legacy: desktopLegacy({
|
||||
launchConfig: { agentCommand: 'claude', agentArgs: '', agentEnv: { BAD: 'a\u0000b' } },
|
||||
recordedConnectionId: null
|
||||
})
|
||||
},
|
||||
store
|
||||
)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } })
|
||||
expect(store.resolveByOwnershipKey(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('never opaque-replays a stored legacy record for a non-desktop client', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.ingestLegacyRecord({
|
||||
ownershipKey: KEY,
|
||||
requestedAgent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
legacyLaunchConfig: LEGACY_CONFIG,
|
||||
connectionId: null
|
||||
})
|
||||
// Mobile/paired never carry the trusted legacy context, so the record fails
|
||||
// closed to "Launch with current settings".
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{ resume: { operation: 'resume', sessionKey: KEY }, client: 'mobile' },
|
||||
store
|
||||
)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
|
||||
it('returns invalid_launch_snapshot for a legacy record with no v1 snapshot when no trusted context', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.ingestLegacyRecord({
|
||||
ownershipKey: KEY,
|
||||
requestedAgent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
legacyLaunchConfig: LEGACY_CONFIG,
|
||||
connectionId: null
|
||||
})
|
||||
const result = resolveResumeLaunchIngest(
|
||||
{ resume: { operation: 'resume', sessionKey: KEY }, client: 'desktop' },
|
||||
store
|
||||
)
|
||||
expect(result).toEqual({ ok: false, failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
// Host ingestion for the provider-session resume/fork variant (U5). A resume
|
||||
// request names only the session ownership key; this module loads the host-private
|
||||
// record and produces the resume-specific launch inputs the shared spawn pipeline
|
||||
// consumes. A v1-snapshot record replays through resolveAgentLaunch's snapshot
|
||||
// path (structured argv); a one-release legacy record replays OPAQUELY through
|
||||
// agent-launch-legacy-replay (pre-quoted command), which bypasses the resolver.
|
||||
//
|
||||
// Precedence per plan §575: a present valid v1 snapshot replays; else a present
|
||||
// valid + eligible legacy config replays (desktop/host-initiated only); else a
|
||||
// record with neither field, or no record at all, returns in-band
|
||||
// `invalid_launch_snapshot` (a persisted launch-attempt failure, NOT a request
|
||||
// error) so the client offers "Launch with current settings" rather than silently
|
||||
// substituting current config. A present-but-invalid value fails the same way and
|
||||
// leaves the source record unchanged (never a partial write).
|
||||
|
||||
import type { AgentLaunchSnapshot, LaunchIntent } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentLaunchResumeRequest,
|
||||
AgentLaunchSpawnRequest
|
||||
} from '../../shared/agent-launch-spawn-request'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import {
|
||||
providerSessionKeyForResumableBase,
|
||||
type AgentProviderSessionMetadata,
|
||||
type ResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import { buildLegacyResumeReplay } from './agent-launch-legacy-replay'
|
||||
import type { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
|
||||
/** Client kind for the resume intent, mapped host-side from the authenticated
|
||||
* scope — never copied from client payload. */
|
||||
export type ResumeLaunchClient = 'desktop' | 'paired-web' | 'mobile'
|
||||
|
||||
export type ResumeLaunchIngestInput = {
|
||||
resume: AgentLaunchResumeRequest['resume']
|
||||
client: ResumeLaunchClient
|
||||
/** Trusted desktop-only opaque legacy replay context. Present only on the
|
||||
* in-process pty:spawn surface; absent on runtime/mobile/paired RPC, so a
|
||||
* legacy record there resolves to invalid_launch_snapshot per the migration
|
||||
* rules (opaque replay is desktop/host-initiated only). */
|
||||
legacy?: {
|
||||
shell: AgentStartupShell
|
||||
/** Current spawn's execution owner, for legacy provenance. */
|
||||
connectionId: string | null
|
||||
/** The pre-quoted config the renderer surrenders over trusted IPC on first
|
||||
* resume of a pre-U5 session; absent once the host owns the record. */
|
||||
handoff?: { launchConfig: SleepingAgentLaunchConfig; recordedConnectionId: string | null }
|
||||
}
|
||||
}
|
||||
|
||||
/** A v1-snapshot resume: merged with host-context target/variables/scope/principal
|
||||
* into an AgentLaunchSpawnInput and resolved through the snapshot replay path. */
|
||||
export type ResumeSnapshotIngest = {
|
||||
ok: true
|
||||
kind: 'snapshot'
|
||||
request: AgentLaunchSpawnRequest
|
||||
intent: LaunchIntent
|
||||
persistedSnapshot: AgentLaunchSnapshot
|
||||
resumeProviderSession: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
/** An opaque legacy resume: the launchCommand/launchConfig feed the pre-U5 spawn
|
||||
* fields directly, bypassing the resolver (no admission token/receipt). */
|
||||
export type ResumeLegacyIngest = {
|
||||
ok: true
|
||||
kind: 'legacy'
|
||||
intent: LaunchIntent
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: ResumableTuiAgent
|
||||
launchCommand: string
|
||||
launchConfig: SleepingAgentLaunchConfig
|
||||
}
|
||||
|
||||
export type ResumeLaunchIngestResult =
|
||||
| ResumeSnapshotIngest
|
||||
| ResumeLegacyIngest
|
||||
| { ok: false; failure: { code: 'invalid_launch_snapshot' } }
|
||||
|
||||
const INVALID = { ok: false, failure: { code: 'invalid_launch_snapshot' } } as const
|
||||
|
||||
/** Resolve a resume/fork request against the private record store. */
|
||||
export function resolveResumeLaunchIngest(
|
||||
input: ResumeLaunchIngestInput,
|
||||
store: AgentSessionRecordStore
|
||||
): ResumeLaunchIngestResult {
|
||||
const intent: LaunchIntent = {
|
||||
kind: 'resume',
|
||||
operation: input.resume.operation,
|
||||
client: input.client
|
||||
}
|
||||
const record = store.resolveByOwnershipKey(input.resume.sessionKey)
|
||||
|
||||
if (record?.launchSnapshot) {
|
||||
// The record's requested identity resolves the same base the snapshot pins;
|
||||
// the resolver's replay path re-checks the snapshot/identity match. `session`
|
||||
// marks the reference authority so a live picker cannot forge it.
|
||||
return {
|
||||
ok: true,
|
||||
kind: 'snapshot',
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: record.requestedAgent },
|
||||
// Resume launches a bare TUI (no client prompt); the provider resume flags
|
||||
// come from the snapshot replay, not a prompt.
|
||||
allowEmptyPromptLaunch: true,
|
||||
sourceRecord: { owner: 'session' }
|
||||
},
|
||||
intent,
|
||||
persistedSnapshot: record.launchSnapshot,
|
||||
resumeProviderSession: record.providerSession
|
||||
}
|
||||
}
|
||||
|
||||
// Opaque legacy replay is desktop/host-initiated only; the trusted context is
|
||||
// absent on every untrusted surface, so those legacy resumes fail closed.
|
||||
if (input.legacy && input.client === 'desktop') {
|
||||
if (record?.legacyLaunchConfig) {
|
||||
// Host already owns the config: re-validate provenance and replay from it.
|
||||
const replay = buildLegacyResumeReplay({
|
||||
legacyLaunchConfig: record.legacyLaunchConfig,
|
||||
requestedAgent: record.requestedAgent,
|
||||
baseAgent: record.baseAgent,
|
||||
providerSession: record.providerSession,
|
||||
shell: input.legacy.shell,
|
||||
recordedConnectionId: record.legacyConnectionId ?? null,
|
||||
currentConnectionId: input.legacy.connectionId
|
||||
})
|
||||
return replay.ok ? { ok: true, kind: 'legacy', intent, ...replayFields(replay) } : INVALID
|
||||
}
|
||||
if (!record && input.legacy.handoff) {
|
||||
// First resume of a pre-U5 session: the renderer surrenders the config.
|
||||
// A legacy record's requested identity equals its base (migration rule).
|
||||
const baseAgent = input.resume.sessionKey.baseAgent
|
||||
const providerSession: AgentProviderSessionMetadata = {
|
||||
key: providerSessionKeyForResumableBase(baseAgent),
|
||||
id: input.resume.sessionKey.providerSessionId
|
||||
}
|
||||
const replay = buildLegacyResumeReplay({
|
||||
legacyLaunchConfig: input.legacy.handoff.launchConfig,
|
||||
requestedAgent: baseAgent,
|
||||
baseAgent,
|
||||
providerSession,
|
||||
shell: input.legacy.shell,
|
||||
recordedConnectionId: input.legacy.handoff.recordedConnectionId,
|
||||
currentConnectionId: input.legacy.connectionId
|
||||
})
|
||||
if (!replay.ok) {
|
||||
// Validation failed: leave the store untouched (never a partial write).
|
||||
return INVALID
|
||||
}
|
||||
// Persist-once: the host owns the config thereafter, so a later resume works
|
||||
// without the renderer re-sending it (the client field is deleted next
|
||||
// release). Validation ran first, so this write is only ever a valid config.
|
||||
store.ingestLegacyRecord({
|
||||
ownershipKey: input.resume.sessionKey,
|
||||
requestedAgent: baseAgent,
|
||||
providerSession,
|
||||
legacyLaunchConfig: input.legacy.handoff.launchConfig,
|
||||
connectionId: input.legacy.handoff.recordedConnectionId
|
||||
})
|
||||
return { ok: true, kind: 'legacy', intent, ...replayFields(replay) }
|
||||
}
|
||||
}
|
||||
|
||||
// No record, a record without a replayable field, or a legacy record reached
|
||||
// over an untrusted surface: never silently resolve current config.
|
||||
return INVALID
|
||||
}
|
||||
|
||||
function replayFields(
|
||||
replay: Extract<ReturnType<typeof buildLegacyResumeReplay>, { ok: true }>
|
||||
): Pick<ResumeLegacyIngest, 'requestedAgent' | 'baseAgent' | 'launchCommand' | 'launchConfig'> {
|
||||
return {
|
||||
requestedAgent: replay.requestedAgent,
|
||||
baseAgent: replay.baseAgent,
|
||||
launchCommand: replay.launchCommand,
|
||||
launchConfig: replay.launchConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { wrapAgentPlanWithSetupSequence } from './agent-launch-setup-sequence-wrap'
|
||||
import { SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV } from '../../shared/setup-agent-sequencing'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type { WorktreeSetupLaunch } from '../../shared/types'
|
||||
|
||||
const PLAN: AgentStartupPlan = {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude --resume',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} },
|
||||
env: { ORCA_AGENT_ENV: 'user-value' }
|
||||
}
|
||||
|
||||
const SETUP: WorktreeSetupLaunch = {
|
||||
runnerScriptPath: '/wt/.orca/setup.sh',
|
||||
waitForAgentStartup: true
|
||||
} as WorktreeSetupLaunch
|
||||
|
||||
describe('wrapAgentPlanWithSetupSequence', () => {
|
||||
it('passes the plan through unchanged when setup does not wait for agent startup', () => {
|
||||
const wrapped = wrapAgentPlanWithSetupSequence(PLAN, undefined)
|
||||
expect(wrapped.command).toBe('claude --resume')
|
||||
expect(wrapped.env).toEqual({ ORCA_AGENT_ENV: 'user-value' })
|
||||
expect(wrapped.wrappedSetupCommand).toBeUndefined()
|
||||
// The real launch command is never moved into the sequenced env.
|
||||
expect(wrapped.env).not.toHaveProperty(SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV)
|
||||
})
|
||||
|
||||
it('carries the resolved launch command in the SPAWN env only when waiting for setup', () => {
|
||||
const createSequenced = vi.fn(() => ({
|
||||
setupCommand: 'run-setup && touch marker',
|
||||
startupCommand: 'wait-for marker; exec "$ORCA_SEQUENCED_STARTUP_COMMAND"',
|
||||
startupEnv: { [SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]: 'claude --resume' }
|
||||
}))
|
||||
const wrapped = wrapAgentPlanWithSetupSequence(PLAN, SETUP, createSequenced)
|
||||
|
||||
// createSequenced is fed the resolved launch command as the startup command.
|
||||
expect(createSequenced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: '/wt/.orca/setup.sh',
|
||||
startupCommand: 'claude --resume'
|
||||
})
|
||||
)
|
||||
// The spawned command is the wait-then-run wrapper, not the raw agent command.
|
||||
expect(wrapped.command).toBe('wait-for marker; exec "$ORCA_SEQUENCED_STARTUP_COMMAND"')
|
||||
expect(wrapped.command).not.toBe('claude --resume')
|
||||
// The real launch command travels in the spawn env (this env is applied by the
|
||||
// caller AFTER admission, so it never reaches the admitted snapshot).
|
||||
expect(wrapped.env?.[SETUP_AGENT_SEQUENCE_STARTUP_COMMAND_ENV]).toBe('claude --resume')
|
||||
// User agent env is preserved alongside the sequenced key.
|
||||
expect(wrapped.env?.ORCA_AGENT_ENV).toBe('user-value')
|
||||
expect(wrapped.wrappedSetupCommand).toBe('run-setup && touch marker')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
createSequencedSetupAgentCommands,
|
||||
type SequencedSetupAgentCommands
|
||||
} from '../../shared/setup-agent-sequencing'
|
||||
import { getSetupRunnerCommandPlatformForPath } from '../../shared/setup-runner-command'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type { WorktreeSetupLaunch } from '../../shared/types'
|
||||
|
||||
export type WrappedAgentSpawnCommand = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
wrappedSetupCommand?: string
|
||||
}
|
||||
|
||||
/** Wait-for-agent setup sequencing (#6298) for a resolved agent plan: when the
|
||||
* setup runner requests it, the agent terminal waits on the setup marker, then
|
||||
* runs the resolved launch command carried by the sequenced env.
|
||||
*
|
||||
* SECURITY: this MUST be applied AFTER admission, in the spawn path only. The
|
||||
* sequenced env holds the real launch command, so it belongs to the spawned
|
||||
* PTY's env and must never enter the admitted snapshot or a persisted failure —
|
||||
* both are produced upstream from the resolved plan, before this wrap runs. */
|
||||
export function wrapAgentPlanWithSetupSequence(
|
||||
plan: AgentStartupPlan,
|
||||
setup: WorktreeSetupLaunch | undefined,
|
||||
createSequenced: (args: {
|
||||
runnerScriptPath: string
|
||||
startupCommand: string
|
||||
platform: ReturnType<typeof getSetupRunnerCommandPlatformForPath>
|
||||
}) => SequencedSetupAgentCommands = createSequencedSetupAgentCommands
|
||||
): WrappedAgentSpawnCommand {
|
||||
if (setup?.waitForAgentStartup !== true) {
|
||||
return { command: plan.launchCommand, ...(plan.env ? { env: plan.env } : {}) }
|
||||
}
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequenced({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: plan.launchCommand,
|
||||
platform
|
||||
})
|
||||
return {
|
||||
command: sequenced.startupCommand,
|
||||
env: { ...plan.env, ...sequenced.startupEnv },
|
||||
wrappedSetupCommand: sequenced.setupCommand
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { dispatchAgentLaunchSpawn } from './agent-launch-spawn-dispatch'
|
||||
import type {
|
||||
AgentLaunchSpawnDeps,
|
||||
AgentLaunchSpawnInput,
|
||||
AgentLaunchSpawnTarget
|
||||
} from './agent-launch-spawn'
|
||||
import { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator
|
||||
} from './agent-launch-admission-store'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type {
|
||||
ResolvedAgentLaunch,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
|
||||
function makeSnapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['/opt/resolved-claude', '--tui'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeLaunch(): ResolvedAgentLaunch {
|
||||
const snapshot = makeSnapshot()
|
||||
return {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
argv: snapshot.argv,
|
||||
agentEnv: snapshot.agentEnv,
|
||||
variables: { values: { repoPath: null, worktreePath: null }, referenced: [] },
|
||||
snapshot,
|
||||
policy: {
|
||||
intent: 'interactive',
|
||||
mode: 'built-in',
|
||||
client: 'desktop',
|
||||
isRemote: false,
|
||||
platform: 'linux',
|
||||
promptInjectionMode: 'stdin-after-start',
|
||||
expectedProcess: 'claude',
|
||||
env: 'none'
|
||||
},
|
||||
notices: [],
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false },
|
||||
admissionGuard: { fingerprint: 'fp-1', stableInputDigest: 'sfp-1', basis: 'explicit' }
|
||||
}
|
||||
}
|
||||
|
||||
const TARGET: AgentLaunchSpawnTarget = {
|
||||
platform: 'linux',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local',
|
||||
targetHomePath: '/home/dev'
|
||||
}
|
||||
|
||||
function makeDeps(outcome: () => ResolveAgentLaunchOutcome): {
|
||||
deps: AgentLaunchSpawnDeps
|
||||
store: AgentLaunchAdmissionStore
|
||||
boundary: AgentLaunchBoundary
|
||||
} {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const boundary = new AgentLaunchBoundary({
|
||||
admissionStore: store,
|
||||
coordinator: new LaunchAdmissionCoordinator()
|
||||
})
|
||||
return {
|
||||
store,
|
||||
boundary,
|
||||
deps: {
|
||||
getSettings: () => ({}) as GlobalSettings,
|
||||
getCatalogRevision: () => 5,
|
||||
boundary,
|
||||
resolve: () => outcome()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseInput(): AgentLaunchSpawnInput {
|
||||
return {
|
||||
request: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'do the thing' },
|
||||
intent: { kind: 'interactive', client: 'desktop' },
|
||||
target: TARGET,
|
||||
variables: { repoPath: '/repo', worktreePath: '/repo/wt' },
|
||||
scope: 'worktree-1',
|
||||
principal: { kind: 'local' }
|
||||
}
|
||||
}
|
||||
|
||||
describe('dispatchAgentLaunchSpawn', () => {
|
||||
it('spawns exactly one PTY from the resolved command and settles registered', async () => {
|
||||
const { deps, store } = makeDeps(() => ({ ok: true, launch: makeLaunch() }))
|
||||
const spawn = vi.fn(async (plan, token) => {
|
||||
// The plan command comes from host resolution, not any client input.
|
||||
expect(plan.launchCommand).toContain('/opt/resolved-claude')
|
||||
expect(token).toMatch(/.+/)
|
||||
return { id: 'pty-1' }
|
||||
})
|
||||
const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
if (result.ok) {
|
||||
expect(result.result).toEqual({ id: 'pty-1' })
|
||||
expect(result.receipt.launchToken).toBeTruthy()
|
||||
}
|
||||
// Registered settles the reservation (released from the pending store).
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('creates zero PTYs on a typed resolution failure', async () => {
|
||||
const { deps, store } = makeDeps(() => ({
|
||||
ok: false,
|
||||
failure: { code: 'base_agent_unavailable', baseAgent: 'claude' }
|
||||
}))
|
||||
const spawn = vi.fn(async () => ({ id: 'pty-x' }))
|
||||
const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn })
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
failure: { code: 'base_agent_unavailable', baseAgent: 'claude' }
|
||||
})
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('settles failed and rethrows when the spawn executor throws', async () => {
|
||||
const { deps, store } = makeDeps(() => ({ ok: true, launch: makeLaunch() }))
|
||||
const spawn = vi.fn(async () => {
|
||||
throw new Error('spawn boom')
|
||||
})
|
||||
await expect(dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn })).rejects.toThrow(
|
||||
/spawn boom/
|
||||
)
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
// Failed releases the reservation entirely; no leaked pending record.
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('propagates a request error without spawning', async () => {
|
||||
const { deps } = makeDeps(() => ({
|
||||
ok: false,
|
||||
requestError: { code: 'untrusted_reference' }
|
||||
}))
|
||||
const spawn = vi.fn(async () => ({ id: 'pty-x' }))
|
||||
const result = await dispatchAgentLaunchSpawn({ deps, input: baseInput(), spawn })
|
||||
expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } })
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
// The resolve -> spawn -> settle sequencer every host launch surface shares (U3).
|
||||
// A surface supplies the resolution inputs plus a `spawn` executor that creates
|
||||
// and registers exactly ONE PTY from the resolved plan; this module runs the
|
||||
// resolution through the host boundary, invokes the executor only on success, and
|
||||
// settles the admission reservation ('registered' once the PTY is registered,
|
||||
// 'failed' if the executor throws). A typed resolution failure/request error
|
||||
// returns without ever calling the executor, so no PTY is created. Client-supplied
|
||||
// command/env/launchConfig are irrelevant here: the plan comes only from
|
||||
// resolveAgentLaunchSpawn's host resolution.
|
||||
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchReceipt,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import {
|
||||
resolveAgentLaunchSpawn,
|
||||
type AgentLaunchSpawnDeps,
|
||||
type AgentLaunchSpawnInput
|
||||
} from './agent-launch-spawn'
|
||||
|
||||
/** Creates and registers exactly one PTY from the resolved plan. Must throw on
|
||||
* spawn/registration failure so the reservation settles 'failed'; a returned
|
||||
* value means the PTY is registered. */
|
||||
export type LaunchSpawnExecutor<R> = (plan: AgentStartupPlan, launchToken: string) => Promise<R>
|
||||
|
||||
export type DispatchAgentLaunchArgs<R> = {
|
||||
deps: AgentLaunchSpawnDeps
|
||||
input: AgentLaunchSpawnInput
|
||||
spawn: LaunchSpawnExecutor<R>
|
||||
}
|
||||
|
||||
export type DispatchAgentLaunchResult<R> =
|
||||
| { ok: true; result: R; receipt: AgentLaunchReceipt }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
| { ok: false; requestError: AgentLaunchRequestError }
|
||||
|
||||
/** Resolve, then spawn+settle exactly once. Rethrows an executor failure after
|
||||
* settling 'failed' so the caller's existing spawn-error handling still runs. */
|
||||
export async function dispatchAgentLaunchSpawn<R>(
|
||||
args: DispatchAgentLaunchArgs<R>
|
||||
): Promise<DispatchAgentLaunchResult<R>> {
|
||||
const resolution = await resolveAgentLaunchSpawn(args.deps, args.input)
|
||||
if (!resolution.ok) {
|
||||
return resolution
|
||||
}
|
||||
const { plan, receipt } = resolution
|
||||
try {
|
||||
const result = await args.spawn(plan, receipt.launchToken)
|
||||
args.deps.boundary.settleAgentLaunch(receipt.launchToken, 'registered')
|
||||
return { ok: true, result, receipt }
|
||||
} catch (err) {
|
||||
args.deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
resolveAgentLaunchSpawn,
|
||||
type AgentLaunchSpawnDeps,
|
||||
type AgentLaunchSpawnInput,
|
||||
type AgentLaunchSpawnTarget
|
||||
} from './agent-launch-spawn'
|
||||
import { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator
|
||||
} from './agent-launch-admission-store'
|
||||
import type { CustomTuiAgentId, GlobalSettings } from '../../shared/types'
|
||||
import type {
|
||||
ResolvedAgentLaunch,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { ResolveAgentLaunchRequest } from '../../shared/agent-launch-host-contract'
|
||||
import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
import { customId } from './agent-launch-test-catalog'
|
||||
|
||||
function makeSnapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['/opt/resolved-claude', '--tui'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeLaunch(): ResolvedAgentLaunch {
|
||||
const snapshot = makeSnapshot()
|
||||
return {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
argv: snapshot.argv,
|
||||
agentEnv: snapshot.agentEnv,
|
||||
variables: { values: { repoPath: null, worktreePath: null }, referenced: [] },
|
||||
snapshot,
|
||||
policy: {
|
||||
intent: 'interactive',
|
||||
mode: 'built-in',
|
||||
client: 'desktop',
|
||||
isRemote: false,
|
||||
platform: 'linux',
|
||||
promptInjectionMode: 'stdin-after-start',
|
||||
expectedProcess: 'claude',
|
||||
env: 'none'
|
||||
},
|
||||
notices: [],
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false },
|
||||
admissionGuard: { fingerprint: 'fp-1', stableInputDigest: 'sfp-1', basis: 'explicit' }
|
||||
}
|
||||
}
|
||||
|
||||
const TARGET: AgentLaunchSpawnTarget = {
|
||||
platform: 'linux',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local',
|
||||
targetHomePath: '/home/dev'
|
||||
}
|
||||
|
||||
function makeDeps(
|
||||
resolve: (request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome
|
||||
): AgentLaunchSpawnDeps {
|
||||
return {
|
||||
getSettings: () => ({}) as GlobalSettings,
|
||||
getCatalogRevision: () => 7,
|
||||
boundary: new AgentLaunchBoundary({
|
||||
admissionStore: new AgentLaunchAdmissionStore(),
|
||||
coordinator: new LaunchAdmissionCoordinator()
|
||||
}),
|
||||
resolve: (request) => resolve(request)
|
||||
}
|
||||
}
|
||||
|
||||
function baseInput(overrides: Partial<AgentLaunchSpawnInput> = {}): AgentLaunchSpawnInput {
|
||||
return {
|
||||
request: { selection: { kind: 'agent', agent: 'claude' }, prompt: 'do the thing' },
|
||||
intent: { kind: 'interactive', client: 'desktop' },
|
||||
target: TARGET,
|
||||
variables: { repoPath: '/repo', worktreePath: '/repo/wt' },
|
||||
scope: 'worktree-1',
|
||||
principal: { kind: 'local' },
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveAgentLaunchSpawn', () => {
|
||||
it('resolves the command from host state, never a client-supplied command/env', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
const result = await resolveAgentLaunchSpawn(deps, baseInput())
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
// The launch command comes from the resolved argv, not any client input.
|
||||
expect(result.plan.launchCommand).toContain('/opt/resolved-claude')
|
||||
expect(result.receipt.catalogRevision).toBe(7)
|
||||
|
||||
const request = resolve.mock.calls[0]![0]
|
||||
expect(request.selection).toEqual({ kind: 'agent', agent: 'claude' })
|
||||
expect(request.platform).toBe('linux')
|
||||
expect(request.executionHostId).toBe('local')
|
||||
expect(request.targetHomePath).toBe('/home/dev')
|
||||
// The request is assembled only from host inputs; it has no command/env keys.
|
||||
expect('command' in request).toBe(false)
|
||||
expect('env' in request).toBe(false)
|
||||
})
|
||||
|
||||
it('derives persisted default reference for a default selection', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({ request: { selection: { kind: 'default' }, prompt: 'x' } })
|
||||
)
|
||||
expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'persisted', owner: 'default' })
|
||||
})
|
||||
|
||||
it('resolves a source-control recipe id to its stored agentArgs as perLaunchArgs (U7)', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: 'claude' },
|
||||
prompt: 'x',
|
||||
sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }
|
||||
},
|
||||
recipeRepo: {
|
||||
sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } }
|
||||
}
|
||||
})
|
||||
)
|
||||
// The host reads recipe.agentArgs from settings and threads it; the client
|
||||
// sent only the recipe id, never args.
|
||||
expect(resolve.mock.calls[0]![0].perLaunchArgs).toBe('--recipe one')
|
||||
expect(resolve.mock.calls[0]![0].reference).toEqual({
|
||||
kind: 'persisted',
|
||||
owner: 'source-control-recipe'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unknown recipe action id with untrusted_reference and never resolves (U7)', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
const result = await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: 'claude' },
|
||||
prompt: 'x',
|
||||
sourceRecord: { owner: 'source-control-recipe', id: 'not-a-real-action' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } })
|
||||
expect(resolve).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves perLaunchArgs unset for a non-recipe sourceRecord (U7)', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: 'claude' },
|
||||
prompt: 'x',
|
||||
sourceRecord: { owner: 'quick-command', id: 'qc-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect('perLaunchArgs' in resolve.mock.calls[0]![0]).toBe(false)
|
||||
})
|
||||
|
||||
it('derives live-selection reference for a bare agent selection', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(deps, baseInput())
|
||||
expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'live-selection' })
|
||||
})
|
||||
|
||||
it('derives a persisted owner reference from a validated source record', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: 'claude' },
|
||||
prompt: 'x',
|
||||
sourceRecord: { owner: 'session', id: 's-1' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(resolve.mock.calls[0]![0].reference).toEqual({ kind: 'persisted', owner: 'session' })
|
||||
})
|
||||
|
||||
it('propagates a typed resolution failure without a plan', async () => {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: false as const,
|
||||
failure: { code: 'base_agent_unavailable' as const, baseAgent: 'claude' as const }
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
const result = await resolveAgentLaunchSpawn(deps, baseInput())
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
failure: { code: 'base_agent_unavailable', baseAgent: 'claude' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// M-1 / plan §1364: Source Control AI runs the same custom-agent launch for a
|
||||
// GitHub, a GitLab, and a generic (non-GitHub/GitLab) review fixture. The
|
||||
// provider adapter supplies task text/URL (commandInputTemplate); it must not
|
||||
// reinterpret the agent id or assemble its command — recipe resolution reads
|
||||
// only agentArgs, so the launch is provider-neutral by construction.
|
||||
describe('Source Control AI custom-agent launch is provider-neutral (M-1, §1364)', () => {
|
||||
const REVIEW_ACTION = 'resolveComments'
|
||||
const CUSTOM: CustomTuiAgentId = customId('claude', '00000000-0000-4000-8000-0000000000c1')
|
||||
|
||||
const PROVIDER_FIXTURES = [
|
||||
{ name: 'GitHub', template: 'GitHub PR review: https://github.com/acme/app/pull/12' },
|
||||
{
|
||||
name: 'GitLab',
|
||||
template: 'GitLab MR review: https://gitlab.com/acme/app/-/merge_requests/34'
|
||||
},
|
||||
{
|
||||
name: 'Gitea (generic non-GitHub/GitLab)',
|
||||
template: 'Gitea review: https://gitea.example.com/acme/app/pulls/7'
|
||||
}
|
||||
] as const
|
||||
|
||||
// Each provider configures the SAME custom-agent recipe args on the review
|
||||
// action but a DIFFERENT provider task-text template. Returns the resolver
|
||||
// request the host assembled.
|
||||
async function resolvedRequestFor(template: string): Promise<ResolveAgentLaunchRequest> {
|
||||
const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({
|
||||
ok: true as const,
|
||||
launch: makeLaunch()
|
||||
}))
|
||||
const deps = makeDeps(resolve)
|
||||
await resolveAgentLaunchSpawn(
|
||||
deps,
|
||||
baseInput({
|
||||
request: {
|
||||
selection: { kind: 'agent', agent: CUSTOM },
|
||||
prompt: 'x',
|
||||
sourceRecord: { owner: 'source-control-recipe', id: REVIEW_ACTION }
|
||||
},
|
||||
recipeRepo: {
|
||||
sourceControlAi: {
|
||||
actionOverrides: {
|
||||
[REVIEW_ACTION]: { agentArgs: '--review one', commandInputTemplate: template }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
return resolve.mock.calls[0]![0]
|
||||
}
|
||||
|
||||
for (const fixture of PROVIDER_FIXTURES) {
|
||||
it(`${fixture.name}: threads the identical recipe args and preserves the custom agent id`, async () => {
|
||||
const request = await resolvedRequestFor(fixture.template)
|
||||
expect(request.perLaunchArgs).toBe('--review one')
|
||||
expect(request.selection).toEqual({ kind: 'agent', agent: CUSTOM })
|
||||
expect(request.reference).toEqual({ kind: 'persisted', owner: 'source-control-recipe' })
|
||||
// The provider's task text/URL never enters the resolved launch args.
|
||||
expect(request.perLaunchArgs).not.toMatch(/https?:|github|gitlab|gitea/i)
|
||||
})
|
||||
}
|
||||
|
||||
it('all three providers resolve byte-identical launch args and agent identity', async () => {
|
||||
const [gh, gl, generic] = await Promise.all(
|
||||
PROVIDER_FIXTURES.map((fixture) => resolvedRequestFor(fixture.template))
|
||||
)
|
||||
expect(gh.perLaunchArgs).toBe(gl.perLaunchArgs)
|
||||
expect(gl.perLaunchArgs).toBe(generic.perLaunchArgs)
|
||||
expect(gh.selection).toEqual(generic.selection)
|
||||
expect(gh.reference).toEqual(generic.reference)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
// Host adapter that turns a client `agentLaunch` request into a resolved startup
|
||||
// plan + receipt through the launch boundary (U3). The client request names only
|
||||
// the agent identity and prompt: this module builds the ResolveAgentLaunchRequest
|
||||
// entirely from HOST state (settings, normalized catalog, detection, derived
|
||||
// target) and NEVER reads a client command/launchConfig/launchAgent/env — those
|
||||
// fields have no representation in AgentLaunchSpawnInput. Intent is constructed
|
||||
// host-side; the reference authority is derived here, not copied from the client.
|
||||
|
||||
import type { GlobalSettings, BuiltInTuiAgent, Repo } from '../../shared/types'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type {
|
||||
AgentLaunchReceipt,
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type {
|
||||
AgentLaunchExecutionHostId,
|
||||
AgentLaunchSnapshot,
|
||||
AgentReferenceAuthority,
|
||||
LaunchIntent,
|
||||
ResolvedAgentLaunch
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentProviderSessionMetadata } from '../../shared/agent-session-resume'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import { isSourceControlActionId } from '../../shared/source-control-ai-actions'
|
||||
import { resolveSourceControlActionRecipe } from '../../shared/source-control-ai'
|
||||
import { normalizeCatalogFromSettings } from './agent-catalog-projections'
|
||||
import { STARTUP_COMMAND_TEXT_MAX_CHARS } from '../providers/windows-shell-args'
|
||||
import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
import type {
|
||||
AgentLaunchBoundary,
|
||||
HostStateResolution,
|
||||
ResolveAgentLaunchPlanResult
|
||||
} from './agent-launch-boundary'
|
||||
import type { AdmissionPrincipal } from './agent-launch-admission-store'
|
||||
|
||||
export type AgentLaunchSpawnTarget = {
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
isRemote: boolean
|
||||
executionHostId: AgentLaunchExecutionHostId
|
||||
targetHomePath?: string | null
|
||||
/** null = detection unavailable (unknown); never claims "not installed". */
|
||||
detectedStockBaseAgents?: ReadonlySet<BuiltInTuiAgent> | null
|
||||
transportConfidentialityAvailable?: boolean
|
||||
}
|
||||
|
||||
export type AgentLaunchSpawnDeps = {
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
boundary: AgentLaunchBoundary
|
||||
preflight?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
prepareEnv?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
/** Injectable for tests; defaults to the real total resolver. */
|
||||
resolve?: typeof resolveAgentLaunch
|
||||
}
|
||||
|
||||
export type AgentLaunchSpawnInput = {
|
||||
request: AgentLaunchSpawnRequest
|
||||
intent: LaunchIntent
|
||||
target: AgentLaunchSpawnTarget
|
||||
variables: { repoPath?: string | null; worktreePath?: string | null }
|
||||
/** Host-trusted repo overrides for a source-control-recipe sourceRecord lookup
|
||||
* (U7). Derived from the launch's worktree context, never client-supplied;
|
||||
* absent falls back to the global recipe. */
|
||||
recipeRepo?: Pick<Repo, 'sourceControlAi'> | null
|
||||
scope: string
|
||||
principal: AdmissionPrincipal
|
||||
persistedSnapshot?: AgentLaunchSnapshot
|
||||
/** Provider session for a resume/fork replay; drives the resolver's resume-argv
|
||||
* append. Only the resume ingestion sets it. */
|
||||
resumeProviderSession?: AgentProviderSessionMetadata
|
||||
}
|
||||
|
||||
export type AgentLaunchSpawnResolution =
|
||||
| { ok: true; plan: AgentStartupPlan; receipt: AgentLaunchReceipt }
|
||||
| { ok: false; failure: AgentLaunchFailure }
|
||||
| { ok: false; requestError: AgentLaunchRequestError }
|
||||
|
||||
/** Derive the reference authority host-side from the requested selection and any
|
||||
* host-verified saved owner. A live selection cannot forge persisted fallback
|
||||
* authority; that requires a validated sourceRecord owner. */
|
||||
function referenceFor(request: AgentLaunchSpawnRequest): AgentReferenceAuthority {
|
||||
if (request.selection.kind === 'default') {
|
||||
return { kind: 'persisted', owner: 'default' }
|
||||
}
|
||||
if (request.sourceRecord) {
|
||||
return { kind: 'persisted', owner: request.sourceRecord.owner }
|
||||
}
|
||||
return { kind: 'live-selection' }
|
||||
}
|
||||
|
||||
/** Resolve the host-owned per-launch args for a validated sourceRecord (U7). Only
|
||||
* a source-control-recipe owner contributes args today: the host validates the id
|
||||
* is a real action id (unknown/mismatched → untrusted_reference, no PTY), then
|
||||
* reads the recipe's stored agentArgs from repo-scoped settings (global fallback
|
||||
* when the repo id is absent). Clients never send args — only the recipe id. */
|
||||
function resolvePerLaunchArgs(
|
||||
request: AgentLaunchSpawnRequest,
|
||||
recipeRepo: Pick<Repo, 'sourceControlAi'> | null | undefined,
|
||||
settings: GlobalSettings
|
||||
): { ok: true; perLaunchArgs?: string } | { ok: false; requestError: AgentLaunchRequestError } {
|
||||
const sourceRecord = request.sourceRecord
|
||||
if (!sourceRecord || sourceRecord.owner !== 'source-control-recipe') {
|
||||
return { ok: true }
|
||||
}
|
||||
if (!sourceRecord.id || !isSourceControlActionId(sourceRecord.id)) {
|
||||
return { ok: false, requestError: { code: 'untrusted_reference' } }
|
||||
}
|
||||
const recipe = resolveSourceControlActionRecipe({
|
||||
settings,
|
||||
repo: recipeRepo,
|
||||
actionId: sourceRecord.id
|
||||
})
|
||||
return recipe.agentArgs !== undefined
|
||||
? { ok: true, perLaunchArgs: recipe.agentArgs }
|
||||
: { ok: true }
|
||||
}
|
||||
|
||||
/** Build the boundary's `resolve` closure from the surface deps + input. Each
|
||||
* call re-reads live settings and the normalized catalog and runs the total
|
||||
* resolver over the fixed request; it does no async I/O, so the boundary can
|
||||
* re-invoke it inside the admission coordinator. Shared by the single-shot
|
||||
* spawn path and U4's two-stage worktree transaction so both surfaces produce
|
||||
* one canonical serialization/fingerprint. */
|
||||
export function buildHostStateResolve(
|
||||
deps: AgentLaunchSpawnDeps,
|
||||
input: AgentLaunchSpawnInput
|
||||
): () => HostStateResolution {
|
||||
const resolveFn = deps.resolve ?? resolveAgentLaunch
|
||||
const reference = referenceFor(input.request)
|
||||
return (): HostStateResolution => {
|
||||
const settings = deps.getSettings()
|
||||
const perLaunch = resolvePerLaunchArgs(input.request, input.recipeRepo, settings)
|
||||
if (!perLaunch.ok) {
|
||||
return {
|
||||
outcome: { ok: false, requestError: perLaunch.requestError },
|
||||
catalogRevision: deps.getCatalogRevision()
|
||||
}
|
||||
}
|
||||
const catalog = normalizeCatalogFromSettings(settings)
|
||||
const outcome: ResolveAgentLaunchOutcome = resolveFn(
|
||||
{
|
||||
selection: input.request.selection,
|
||||
intent: input.intent,
|
||||
reference,
|
||||
variables: input.variables,
|
||||
...(perLaunch.perLaunchArgs !== undefined
|
||||
? { perLaunchArgs: perLaunch.perLaunchArgs }
|
||||
: {}),
|
||||
platform: input.target.platform,
|
||||
...(input.target.shell ? { shell: input.target.shell } : {}),
|
||||
isRemote: input.target.isRemote,
|
||||
targetHomePath: input.target.targetHomePath ?? null,
|
||||
detectedStockBaseAgents: input.target.detectedStockBaseAgents ?? null,
|
||||
executionHostId: input.target.executionHostId,
|
||||
...(input.target.transportConfidentialityAvailable !== undefined
|
||||
? { transportConfidentialityAvailable: input.target.transportConfidentialityAvailable }
|
||||
: {}),
|
||||
...(input.persistedSnapshot ? { persistedSnapshot: input.persistedSnapshot } : {}),
|
||||
...(input.resumeProviderSession
|
||||
? { resumeProviderSession: input.resumeProviderSession }
|
||||
: {})
|
||||
},
|
||||
catalog,
|
||||
settings
|
||||
)
|
||||
return { outcome, catalogRevision: deps.getCatalogRevision() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a legacy renderer-spawned startup request into a plan WITHOUT taking
|
||||
* an admission token. Reuses the exact host-state resolve closure the admitted
|
||||
* path builds, so the two share one serialization/fingerprint, but stops before
|
||||
* admission because this path registers no terminal receipt (no settle seam) and
|
||||
* a held token would leak capacity. One-release compatibility shim; removed with
|
||||
* the startupAgent/startupDraft fields. */
|
||||
export function resolveAgentLaunchStartupPlanWithoutAdmission(
|
||||
deps: AgentLaunchSpawnDeps,
|
||||
input: AgentLaunchSpawnInput
|
||||
): ResolveAgentLaunchPlanResult {
|
||||
const resolve = buildHostStateResolve(deps, input)
|
||||
return deps.boundary.resolveAgentLaunchPlanWithoutAdmission({
|
||||
resolve,
|
||||
prompt: input.request.prompt ?? '',
|
||||
...(input.request.allowEmptyPromptLaunch !== undefined
|
||||
? { allowEmptyPromptLaunch: input.request.allowEmptyPromptLaunch }
|
||||
: {}),
|
||||
...(input.request.promptDelivery !== undefined
|
||||
? { promptDelivery: input.request.promptDelivery }
|
||||
: {}),
|
||||
maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve a client agentLaunch request into a startup plan + receipt, or a
|
||||
* typed failure/request-error. Creates no PTY: the caller owns spawning. */
|
||||
export async function resolveAgentLaunchSpawn(
|
||||
deps: AgentLaunchSpawnDeps,
|
||||
input: AgentLaunchSpawnInput
|
||||
): Promise<AgentLaunchSpawnResolution> {
|
||||
const resolve = buildHostStateResolve(deps, input)
|
||||
|
||||
return deps.boundary.executeAgentLaunch({
|
||||
scope: input.scope,
|
||||
principal: input.principal,
|
||||
resolve,
|
||||
prompt: input.request.prompt ?? '',
|
||||
...(input.request.allowEmptyPromptLaunch !== undefined
|
||||
? { allowEmptyPromptLaunch: input.request.allowEmptyPromptLaunch }
|
||||
: {}),
|
||||
...(input.request.promptDelivery !== undefined
|
||||
? { promptDelivery: input.request.promptDelivery }
|
||||
: {}),
|
||||
// The shared plan builder is main-free, so the provider size ceiling is
|
||||
// threaded here rather than imported there.
|
||||
maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS,
|
||||
...(deps.preflight ? { preflight: deps.preflight } : {}),
|
||||
...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Test fixtures for the agent-launch resolver: catalog/settings/request builders
|
||||
// shared across the lifecycle, assembly, and env suites. Not a test file.
|
||||
|
||||
import type {
|
||||
BuiltInTuiAgent,
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
DeletedCustomTuiAgent,
|
||||
GlobalSettings,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import { normalizeAgentCatalog, type AgentCatalog } from '../../shared/agent-catalog-normalization'
|
||||
import type {
|
||||
AgentLaunchExecutionHostId,
|
||||
AgentReferenceAuthority,
|
||||
LaunchIntent,
|
||||
ResolveAgentLaunchRequest
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
|
||||
let uuidCounter = 0
|
||||
function nextUuid(): string {
|
||||
uuidCounter += 1
|
||||
const hex = uuidCounter.toString(16).padStart(12, '0')
|
||||
return `00000000-0000-4000-8000-${hex}`
|
||||
}
|
||||
|
||||
export function customId(base: BuiltInTuiAgent, suffix?: string): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${suffix ?? nextUuid()}`
|
||||
}
|
||||
|
||||
export function customAgent(
|
||||
overrides: Partial<CustomTuiAgent> & { id: CustomTuiAgentId }
|
||||
): CustomTuiAgent {
|
||||
return {
|
||||
baseAgent: 'claude',
|
||||
label: 'My Agent',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function tombstone(
|
||||
overrides: Partial<DeletedCustomTuiAgent> & { id: CustomTuiAgentId }
|
||||
): DeletedCustomTuiAgent {
|
||||
return { baseAgent: 'claude', label: 'Deleted Agent', deletedAt: 1, ...overrides }
|
||||
}
|
||||
|
||||
export function catalogOf(input: {
|
||||
customTuiAgents?: CustomTuiAgent[]
|
||||
deletedCustomTuiAgents?: DeletedCustomTuiAgent[]
|
||||
disabledTuiAgents?: TuiAgent[]
|
||||
defaultTuiAgent?: TuiAgent | 'auto' | 'blank' | null
|
||||
}): AgentCatalog {
|
||||
return normalizeAgentCatalog({
|
||||
customTuiAgents: input.customTuiAgents ?? [],
|
||||
deletedCustomTuiAgents: input.deletedCustomTuiAgents ?? [],
|
||||
disabledTuiAgents: input.disabledTuiAgents ?? [],
|
||||
// Preserve an explicit null (repair-needed default); only absent means auto.
|
||||
defaultTuiAgent: 'defaultTuiAgent' in input ? input.defaultTuiAgent : 'auto'
|
||||
}).catalog
|
||||
}
|
||||
|
||||
export function settingsOf(overrides?: {
|
||||
agentCmdOverrides?: Partial<Record<BuiltInTuiAgent, string>>
|
||||
agentDefaultArgs?: Partial<Record<BuiltInTuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<BuiltInTuiAgent, Record<string, string>>>
|
||||
}): GlobalSettings {
|
||||
return {
|
||||
agentCmdOverrides: overrides?.agentCmdOverrides ?? {},
|
||||
agentDefaultArgs: overrides?.agentDefaultArgs ?? {},
|
||||
agentDefaultEnv: overrides?.agentDefaultEnv ?? {}
|
||||
} as unknown as GlobalSettings
|
||||
}
|
||||
|
||||
export const INTERACTIVE_DESKTOP: LaunchIntent = { kind: 'interactive', client: 'desktop' }
|
||||
export const PERSISTED_DEFAULT: AgentReferenceAuthority = { kind: 'persisted', owner: 'default' }
|
||||
export const LIVE_SELECTION: AgentReferenceAuthority = { kind: 'live-selection' }
|
||||
|
||||
export function requestOf(
|
||||
overrides: Partial<ResolveAgentLaunchRequest> & {
|
||||
selection: ResolveAgentLaunchRequest['selection']
|
||||
}
|
||||
): ResolveAgentLaunchRequest {
|
||||
return {
|
||||
intent: INTERACTIVE_DESKTOP,
|
||||
reference: LIVE_SELECTION,
|
||||
variables: {},
|
||||
platform: 'linux',
|
||||
isRemote: false,
|
||||
targetHomePath: '/home/dev',
|
||||
detectedStockBaseAgents: null,
|
||||
executionHostId: 'local' as AgentLaunchExecutionHostId,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/** All base built-ins detected — a concrete non-empty detection set. */
|
||||
export function allDetected(...agents: BuiltInTuiAgent[]): ReadonlySet<BuiltInTuiAgent> {
|
||||
return new Set(agents)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildVaultResumeStartup,
|
||||
findVaultResumeSession,
|
||||
resolveRevalidatedVaultResume,
|
||||
resolveRevalidatedVaultResumeDetails,
|
||||
resolveVaultResumeCopyCommand,
|
||||
resolveVaultResumeSpawn,
|
||||
type VaultResumeSession
|
||||
} from './agent-launch-vault-resume'
|
||||
import { RESUMABLE_TUI_AGENTS } from '../../shared/agent-session-resume'
|
||||
import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentLaunchVaultResumeEntry } from '../../shared/agent-launch-spawn-request'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
|
||||
const CUSTOM_CODEX_ID = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as const
|
||||
|
||||
// Agents that are both AI Vault sessions AND resumable providers take the
|
||||
// structured startup-plan branch; the rest (e.g. OMP) fall through to the
|
||||
// path-based resume command. G5 requires every resumable provider to be proven.
|
||||
const RESUMABLE_VAULT_AGENTS = AI_VAULT_AGENTS.filter((agent) =>
|
||||
(RESUMABLE_TUI_AGENTS as readonly string[]).includes(agent)
|
||||
)
|
||||
|
||||
function vaultSession(overrides: Partial<VaultResumeSession> = {}): VaultResumeSession {
|
||||
return {
|
||||
agent: 'codex',
|
||||
sessionId: 'sess-abc-123',
|
||||
cwd: '/repo/app',
|
||||
codexHome: null,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function entryFor(session: VaultResumeSession): AgentLaunchVaultResumeEntry {
|
||||
return {
|
||||
executionHostId: session.executionHostId,
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId
|
||||
}
|
||||
}
|
||||
|
||||
describe('findVaultResumeSession', () => {
|
||||
it('matches on executionHostId, agent, and sessionId', () => {
|
||||
const target = vaultSession({ sessionId: 'match-me' })
|
||||
const sessions = [vaultSession({ sessionId: 'other' }), target]
|
||||
expect(findVaultResumeSession(entryFor(target), sessions)).toBe(target)
|
||||
})
|
||||
|
||||
it('returns null when any identity field differs', () => {
|
||||
const target = vaultSession({ sessionId: 'match-me', agent: 'codex' })
|
||||
const sessions = [target]
|
||||
expect(findVaultResumeSession({ ...entryFor(target), sessionId: 'nope' }, sessions)).toBeNull()
|
||||
expect(findVaultResumeSession({ ...entryFor(target), agent: 'claude' }, sessions)).toBeNull()
|
||||
expect(
|
||||
findVaultResumeSession({ ...entryFor(target), executionHostId: 'ssh:box' }, sessions)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores the client-echoed filePath entirely (host re-derives identity)', () => {
|
||||
const target = vaultSession({ agent: 'omp', filePath: '/host/derived.jsonl' })
|
||||
// A client sending a bogus filePath still matches on the three identity
|
||||
// fields and the assembly reads the host-discovered filePath, never this one.
|
||||
const entry: AgentLaunchVaultResumeEntry = {
|
||||
...entryFor(target),
|
||||
filePath: '/attacker/controlled.jsonl'
|
||||
}
|
||||
expect(findVaultResumeSession(entry, [target])).toBe(target)
|
||||
})
|
||||
|
||||
it('uses the locator to distinguish duplicate legacy identities', () => {
|
||||
const first = vaultSession({ sessionId: 'same', resumeLocator: 'a'.repeat(64) })
|
||||
const second = vaultSession({ sessionId: 'same', resumeLocator: 'b'.repeat(64) })
|
||||
expect(
|
||||
findVaultResumeSession({ ...entryFor(first), resumeLocator: second.resumeLocator }, [
|
||||
first,
|
||||
second
|
||||
])
|
||||
).toBe(second)
|
||||
expect(findVaultResumeSession(entryFor(first), [first, second])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
function capturedSnapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
displayLabel: 'Codex Sol',
|
||||
mode: 'custom',
|
||||
argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveRevalidatedVaultResume', () => {
|
||||
it('converts one correlated owner into the ordinary session resume request', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
{
|
||||
worktreeId: 'wt-source',
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'hook-resume-id',
|
||||
transcriptPath: '/repo/transcript.jsonl'
|
||||
},
|
||||
launchSnapshot: capturedSnapshot(),
|
||||
registeredAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
expect(
|
||||
resolveRevalidatedVaultResume({
|
||||
session: vaultSession({
|
||||
sessionId: 'scanner-id',
|
||||
filePath: '/repo/transcript.jsonl'
|
||||
}),
|
||||
sessionRecordStore: store,
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux',
|
||||
preferredWorktreeId: 'wt-destination'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'snapshot',
|
||||
request: {
|
||||
resume: {
|
||||
operation: 'resume',
|
||||
sessionKey: {
|
||||
worktreeId: 'wt-source',
|
||||
baseAgent: 'codex',
|
||||
providerSessionId: 'hook-resume-id'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a disclosed current-settings fallback only for resumable providers', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const fallback = resolveRevalidatedVaultResume({
|
||||
session: vaultSession(),
|
||||
sessionRecordStore: store,
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux',
|
||||
mintNoticeToken: () => 'notice-token'
|
||||
})
|
||||
expect(fallback).toMatchObject({
|
||||
kind: 'fallback',
|
||||
reason: 'missing',
|
||||
launchNotices: {
|
||||
launchToken: 'notice-token',
|
||||
notices: [{ code: 'vault_original_config_unavailable', baseAgent: 'codex' }]
|
||||
}
|
||||
})
|
||||
const unsupported = resolveRevalidatedVaultResume({
|
||||
session: vaultSession({ agent: 'omp' }),
|
||||
sessionRecordStore: store,
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
expect(unsupported.kind).toBe('fallback')
|
||||
if (unsupported.kind === 'fallback') {
|
||||
expect(unsupported.reason).toBe('unsupported')
|
||||
expect(unsupported.launchNotices).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveRevalidatedVaultResumeDetails', () => {
|
||||
it('returns the captured argument suffix, including the original effort setting', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
{
|
||||
worktreeId: 'wt-source',
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-abc-123' },
|
||||
launchSnapshot: capturedSnapshot(),
|
||||
registeredAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
|
||||
expect(
|
||||
resolveRevalidatedVaultResumeDetails({ session: vaultSession(), sessionRecordStore: store })
|
||||
).toEqual({
|
||||
status: 'ok',
|
||||
args: ['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not substitute current settings for missing private correlation', () => {
|
||||
expect(
|
||||
resolveRevalidatedVaultResumeDetails({
|
||||
session: vaultSession(),
|
||||
sessionRecordStore: new AgentSessionRecordStore()
|
||||
})
|
||||
).toEqual({ status: 'unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVaultResumeStartup', () => {
|
||||
it('appends the provider resume argv exactly once for every resumable vault agent', () => {
|
||||
for (const agent of RESUMABLE_VAULT_AGENTS) {
|
||||
const session = vaultSession({ agent: agent as AiVaultAgent, sessionId: `id-${agent}` })
|
||||
const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' })
|
||||
expect(startup.command).toContain(`id-${agent}`)
|
||||
// The session id is the resume target and must appear exactly once.
|
||||
expect(startup.command.split(`id-${agent}`).length - 1).toBe(1)
|
||||
expect(startup.launchConfig).toBeDefined()
|
||||
// The queued command re-enters the session's cwd before launching.
|
||||
expect(startup.command).toContain('/repo/app')
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes OMP by its host-derived transcript path, not the client field', () => {
|
||||
const session = vaultSession({
|
||||
agent: 'omp',
|
||||
sessionId: 'omp-sess',
|
||||
filePath: '/host/transcripts/omp-sess.jsonl'
|
||||
})
|
||||
const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' })
|
||||
// OMP is non-resumable → the path-based fallback resumes by absolute path.
|
||||
expect(startup.command).toContain('/host/transcripts/omp-sess.jsonl')
|
||||
expect(startup.launchConfig).toBeUndefined()
|
||||
})
|
||||
|
||||
it('replays a remote session command verbatim without re-deriving it', () => {
|
||||
const session = vaultSession({
|
||||
agent: 'codex',
|
||||
executionHostId: 'ssh:box',
|
||||
executionHostPlatform: 'linux',
|
||||
resumeCommand: 'REMOTE_READY_COMMAND --resume remote-id'
|
||||
})
|
||||
const startup = buildVaultResumeStartup({ session, hostPlatform: 'darwin' })
|
||||
expect(startup.command).toBe('REMOTE_READY_COMMAND --resume remote-id')
|
||||
expect(startup.launchConfig).toBeUndefined()
|
||||
expect(startup.env).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rewrites a WSL UNC Codex home to POSIX when the target is linux', () => {
|
||||
const session = vaultSession({
|
||||
agent: 'codex',
|
||||
codexHome: '\\\\wsl$\\Ubuntu\\home\\me\\.codex'
|
||||
})
|
||||
const startup = buildVaultResumeStartup({ session, hostPlatform: 'linux' })
|
||||
expect(startup.command).toContain('/home/me/.codex')
|
||||
expect(startup.command).not.toContain('wsl$')
|
||||
})
|
||||
|
||||
it('honors a per-agent command override', () => {
|
||||
const session = vaultSession({ agent: 'codex', sessionId: 'ov-id' })
|
||||
const startup = buildVaultResumeStartup({
|
||||
session,
|
||||
hostPlatform: 'linux',
|
||||
settings: { agentCmdOverrides: { codex: 'my-codex' } }
|
||||
})
|
||||
expect(startup.command).toContain('my-codex')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVaultResumeCopyCommand', () => {
|
||||
it('returns the assembled command for a discovered entry', () => {
|
||||
const session = vaultSession({ agent: 'codex', sessionId: 'copy-id' })
|
||||
const result = resolveVaultResumeCopyCommand({
|
||||
entry: entryFor(session),
|
||||
sessions: [session],
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
if (result.status === 'ok') {
|
||||
expect(result.command).toBe(
|
||||
buildVaultResumeStartup({ session, hostPlatform: 'linux' }).command
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed with invalid_launch_snapshot when the host did not discover the entry', () => {
|
||||
const session = vaultSession({ sessionId: 'known' })
|
||||
const result = resolveVaultResumeCopyCommand({
|
||||
entry: { ...entryFor(session), sessionId: 'unknown' },
|
||||
sessions: [session],
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
failure: { code: 'invalid_launch_snapshot' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVaultResumeSpawn (U7 runtime resume-via-arm)', () => {
|
||||
it('assembles the full startup (command/env/launchConfig) for a discovered resume', () => {
|
||||
const session = vaultSession({ agent: 'codex', sessionId: 'spawn-id' })
|
||||
const result = resolveVaultResumeSpawn({
|
||||
vaultResume: { operation: 'resume', entry: entryFor(session) },
|
||||
sessions: [session],
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
expect(result.status).toBe('ok')
|
||||
if (result.status === 'ok') {
|
||||
const expected = buildVaultResumeStartup({ session, hostPlatform: 'linux' })
|
||||
expect(result.startup.command).toBe(expected.command)
|
||||
expect(result.startup.launchConfig).toEqual(expected.launchConfig)
|
||||
}
|
||||
})
|
||||
|
||||
it('fails closed for an entry the host did not discover', () => {
|
||||
const session = vaultSession({ sessionId: 'known' })
|
||||
const result = resolveVaultResumeSpawn({
|
||||
vaultResume: { operation: 'resume', entry: { ...entryFor(session), sessionId: 'unknown' } },
|
||||
sessions: [session],
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
expect(result).toEqual({ status: 'failed', failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
|
||||
it('fails closed for a copy op reaching the spawn arm (misroute)', () => {
|
||||
// copy is served by the dedicated command method; a copy op must never spawn.
|
||||
const session = vaultSession({ sessionId: 'copy-misroute' })
|
||||
const result = resolveVaultResumeSpawn({
|
||||
vaultResume: { operation: 'copy', entry: entryFor(session) },
|
||||
sessions: [session],
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
expect(result).toEqual({ status: 'failed', failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
// Host-side AI Vault resume assembly (U5 FULL PORT of the renderer's
|
||||
// buildAiVaultResumeStartupForWorktree). The client only echoes a discovered
|
||||
// entry's identity; the host re-validates it against its OWN fresh discovery and
|
||||
// rebuilds the resume command here, bypassing the resolver like legacy opaque
|
||||
// replay (no admission token/receipt). The renderer helper deliberately encodes
|
||||
// semantics the structured resolver does not model — remote-verbatim resume,
|
||||
// OMP absolute-transcript resume, and WSL Codex-home rewrite — so this is a
|
||||
// faithful replication, not a re-derivation.
|
||||
//
|
||||
// The only renderer-specific piece dropped in the port is the AppState platform
|
||||
// heuristic (WSL/workspace probing): the host already knows the spawning target
|
||||
// platform, and a session may only resume on a target matching its own host, so
|
||||
// a non-local entry uses the discovered host platform and a local one uses the
|
||||
// spawning host's platform directly.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type { PersistedLaunchNoticeState } from '../../shared/agent-launch-contract'
|
||||
import {
|
||||
buildAiVaultResumeCommand,
|
||||
buildAiVaultResumeShellCommand
|
||||
} from '../../shared/ai-vault-resume-command'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import {
|
||||
isResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../shared/tui-agent-launch-defaults'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
import { resolveWindowsShellStartupFamily } from '../../shared/windows-terminal-shell'
|
||||
import { buildAgentResumeStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentStartupShell } from '../../shared/tui-agent-startup-shell'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import type {
|
||||
AgentLaunchResumeRequest,
|
||||
AgentLaunchVaultResumeDetailsResult,
|
||||
AgentLaunchVaultResumeCopyResult,
|
||||
AgentLaunchVaultResumeEntry
|
||||
} from '../../shared/agent-launch-spawn-request'
|
||||
import type { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
|
||||
/** Re-exported for host callers that assemble the copy result. */
|
||||
export type VaultResumeCopyResult = AgentLaunchVaultResumeCopyResult
|
||||
export type VaultResumeDetailsResult = AgentLaunchVaultResumeDetailsResult
|
||||
|
||||
/** The fresh discovery slice the assembly reads. Sourced from the host's own
|
||||
* `listAiVaultSessions`, never from the client — the client's echoed identity is
|
||||
* only used to look this up (its `filePath` is ignored and re-derived here). */
|
||||
export type VaultResumeSession = Pick<
|
||||
AiVaultSession,
|
||||
'agent' | 'sessionId' | 'cwd' | 'codexHome' | 'executionHostId'
|
||||
> &
|
||||
Partial<
|
||||
Pick<AiVaultSession, 'executionHostPlatform' | 'resumeCommand' | 'resumeLocator' | 'filePath'>
|
||||
>
|
||||
|
||||
/** Host settings the assembly reads. Built-in-keyed records are assignable to the
|
||||
* wider TuiAgent-keyed helper params (all keys optional). */
|
||||
export type VaultResumeAssemblySettings = {
|
||||
agentCmdOverrides?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
|
||||
terminalWindowsShell?: string
|
||||
}
|
||||
|
||||
export type VaultResumeStartup = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
}
|
||||
|
||||
/** Re-validate the client-echoed entry against the host's OWN fresh discovery.
|
||||
* New entries match their opaque locator exactly. Legacy entries without one
|
||||
* are accepted only when the three-field identity has one fresh match. */
|
||||
export function findVaultResumeSession<S extends VaultResumeSession>(
|
||||
entry: AgentLaunchVaultResumeEntry,
|
||||
sessions: readonly S[]
|
||||
): S | null {
|
||||
const identityMatches = sessions.filter(
|
||||
(session) =>
|
||||
session.executionHostId === entry.executionHostId &&
|
||||
session.agent === entry.agent &&
|
||||
session.sessionId === entry.sessionId
|
||||
)
|
||||
const matches = entry.resumeLocator
|
||||
? identityMatches.filter((session) => session.resumeLocator === entry.resumeLocator)
|
||||
: identityMatches
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
|
||||
/** Re-validate + assemble the copyable resume command for a client-echoed entry.
|
||||
* Shared by the desktop IPC and runtime RPC copy surfaces; the caller supplies
|
||||
* its own fresh discovery and the spawning host platform. */
|
||||
export function resolveVaultResumeCopyCommand(args: {
|
||||
entry: AgentLaunchVaultResumeEntry
|
||||
sessions: readonly VaultResumeSession[]
|
||||
hostPlatform: NodeJS.Platform
|
||||
settings?: VaultResumeAssemblySettings
|
||||
}): VaultResumeCopyResult {
|
||||
const session = findVaultResumeSession(args.entry, args.sessions)
|
||||
if (!session) {
|
||||
return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } }
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
command: buildVaultResumeStartup({
|
||||
session,
|
||||
hostPlatform: args.hostPlatform,
|
||||
settings: args.settings
|
||||
}).command
|
||||
}
|
||||
}
|
||||
|
||||
export type VaultResumeSpawnResult =
|
||||
| { status: 'ok'; startup: VaultResumeStartup }
|
||||
| { status: 'failed'; failure: { code: 'invalid_launch_snapshot' } }
|
||||
|
||||
/** Re-validate + assemble a vault resume SPAWN (as distinct from copy). A `copy`
|
||||
* operation is served by the dedicated command method, so reaching here is a
|
||||
* misroute; an entry the fresh scan does not contain fails closed. Both failures
|
||||
* are invalid_launch_snapshot — no terminal, no client path becomes a spawn input. */
|
||||
export function resolveVaultResumeSpawn(args: {
|
||||
vaultResume: { operation: 'resume' | 'copy'; entry: AgentLaunchVaultResumeEntry }
|
||||
sessions: readonly VaultResumeSession[]
|
||||
hostPlatform: NodeJS.Platform
|
||||
settings?: VaultResumeAssemblySettings
|
||||
}): VaultResumeSpawnResult {
|
||||
if (args.vaultResume.operation !== 'resume') {
|
||||
return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } }
|
||||
}
|
||||
const session = findVaultResumeSession(args.vaultResume.entry, args.sessions)
|
||||
if (!session) {
|
||||
return { status: 'failed', failure: { code: 'invalid_launch_snapshot' } }
|
||||
}
|
||||
return {
|
||||
status: 'ok',
|
||||
startup: buildVaultResumeStartup({
|
||||
session,
|
||||
hostPlatform: args.hostPlatform,
|
||||
settings: args.settings
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export type RevalidatedVaultResumeResolution =
|
||||
| { kind: 'snapshot'; request: AgentLaunchResumeRequest }
|
||||
| {
|
||||
kind: 'fallback'
|
||||
reason: 'missing' | 'ambiguous' | 'unsupported'
|
||||
startup: VaultResumeStartup
|
||||
launchNotices?: PersistedLaunchNoticeState
|
||||
}
|
||||
|
||||
/** Decide snapshot replay versus the disclosed current-settings fallback for an
|
||||
* already fresh-scan-validated row. Both desktop and runtime callers use this
|
||||
* exact correlation policy; only their scan and spawn mechanics differ. */
|
||||
export function resolveRevalidatedVaultResume(args: {
|
||||
session: VaultResumeSession
|
||||
sessionRecordStore: AgentSessionRecordStore
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
targetPlatform: NodeJS.Platform
|
||||
preferredWorktreeId?: string | null
|
||||
settings?: VaultResumeAssemblySettings
|
||||
mintNoticeToken?: () => string
|
||||
}): RevalidatedVaultResumeResolution {
|
||||
if (isResumableTuiAgent(args.session.agent)) {
|
||||
const owner = args.sessionRecordStore.resolveVaultSnapshotOwner({
|
||||
baseAgent: args.session.agent,
|
||||
scannedProviderSessionId: args.session.sessionId,
|
||||
scannedTranscriptPath: args.session.filePath,
|
||||
targetExecutionHostId: args.targetExecutionHostId,
|
||||
targetPlatform: args.targetPlatform,
|
||||
preferredWorktreeId: args.preferredWorktreeId
|
||||
})
|
||||
if (owner.kind === 'found') {
|
||||
return {
|
||||
kind: 'snapshot',
|
||||
request: { resume: { operation: 'resume', sessionKey: owner.sessionKey } }
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'fallback',
|
||||
reason: owner.kind,
|
||||
startup: buildVaultResumeStartup({
|
||||
session: args.session,
|
||||
hostPlatform: args.targetPlatform,
|
||||
settings: args.settings
|
||||
}),
|
||||
launchNotices: {
|
||||
launchToken: (args.mintNoticeToken ?? randomUUID)(),
|
||||
notices: [
|
||||
{
|
||||
code: 'vault_original_config_unavailable',
|
||||
baseAgent: args.session.agent
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'fallback',
|
||||
reason: 'unsupported',
|
||||
startup: buildVaultResumeStartup({
|
||||
session: args.session,
|
||||
hostPlatform: args.targetPlatform,
|
||||
settings: args.settings
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Expose only the original non-executable argv for an expanded, freshly
|
||||
* revalidated row. Missing or ambiguous private correlation never guesses. */
|
||||
export function resolveRevalidatedVaultResumeDetails(args: {
|
||||
session: VaultResumeSession
|
||||
sessionRecordStore: AgentSessionRecordStore
|
||||
}): VaultResumeDetailsResult {
|
||||
if (!isResumableTuiAgent(args.session.agent)) {
|
||||
return { status: 'unavailable' }
|
||||
}
|
||||
const snapshotArgs = args.sessionRecordStore.resolveVaultSnapshotArguments({
|
||||
baseAgent: args.session.agent,
|
||||
scannedProviderSessionId: args.session.sessionId,
|
||||
scannedTranscriptPath: args.session.filePath,
|
||||
scannedExecutionHostId: args.session.executionHostId
|
||||
})
|
||||
return snapshotArgs && snapshotArgs.length > 0
|
||||
? { status: 'ok', args: snapshotArgs }
|
||||
: { status: 'unavailable' }
|
||||
}
|
||||
|
||||
/** Build the resume startup for a re-validated (host-discovered) session. */
|
||||
export function buildVaultResumeStartup(args: {
|
||||
session: VaultResumeSession
|
||||
/** The spawning host's platform, used only for local sessions; a non-local
|
||||
* session uses its own discovered host platform. */
|
||||
hostPlatform: NodeJS.Platform
|
||||
settings?: VaultResumeAssemblySettings
|
||||
}): VaultResumeStartup {
|
||||
const { session, hostPlatform, settings } = args
|
||||
const commandOverride = settings?.agentCmdOverrides?.[session.agent as TuiAgent] ?? null
|
||||
const isRemote = !!session.executionHostId && session.executionHostId !== LOCAL_EXECUTION_HOST_ID
|
||||
// Remote-verbatim: a remote host stamped a ready-to-run resume command at
|
||||
// discovery time; replay it as-is rather than re-deriving remote semantics.
|
||||
if (isRemote && session.resumeCommand && !commandOverride?.trim()) {
|
||||
return { command: session.resumeCommand }
|
||||
}
|
||||
const platform: NodeJS.Platform =
|
||||
isRemote && session.executionHostPlatform ? session.executionHostPlatform : hostPlatform
|
||||
const codexHome = resolveVaultResumeCodexHome(session.codexHome ?? null, platform)
|
||||
// Why: the queued command is typed verbatim into a freshly spawned tab whose
|
||||
// live shell is the configured Windows shell (default PowerShell). Hardcoding
|
||||
// cmd quoting made PowerShell mis-parse the `""`-doubled wrapper (#6152), so
|
||||
// resolve the actual shell to quote per-shell instead.
|
||||
const queuedShell: AgentStartupShell | undefined =
|
||||
platform === 'win32'
|
||||
? resolveWindowsShellStartupFamily(settings?.terminalWindowsShell)
|
||||
: undefined
|
||||
if (isResumableTuiAgent(session.agent)) {
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: session.agent,
|
||||
providerSession: { key: 'session_id', id: session.sessionId },
|
||||
cmdOverrides: {
|
||||
...settings?.agentCmdOverrides,
|
||||
...(commandOverride?.trim() ? { [session.agent]: commandOverride } : {})
|
||||
},
|
||||
platform,
|
||||
shell: queuedShell,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(session.agent, settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(session.agent, settings?.agentDefaultEnv)
|
||||
})
|
||||
if (startupPlan) {
|
||||
return {
|
||||
command: buildAiVaultResumeShellCommand({
|
||||
resumeCommand: startupPlan.launchCommand,
|
||||
cwd: session.cwd,
|
||||
platform,
|
||||
codexHome,
|
||||
shell: queuedShell
|
||||
}),
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: buildAiVaultResumeCommand({
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId,
|
||||
// Why: OMP resumes by absolute transcript path, so local rebuilds must
|
||||
// forward the host-derived path — an id-prefix lookup scoped to the default
|
||||
// store would miss a custom OMP_CODING_AGENT_DIR / WSL-store session.
|
||||
resumeFilePath: session.filePath,
|
||||
cwd: session.cwd,
|
||||
platform,
|
||||
commandOverride,
|
||||
codexHome,
|
||||
// Why: non-resumable agents queue through this fallback too, so it must
|
||||
// quote for the live Windows shell like the startup-plan branch above.
|
||||
shell: queuedShell
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function resolveVaultResumeCodexHome(
|
||||
codexHome: string | null,
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
// Why: WSL UNC Codex homes must be POSIX when invoking Linux commands. Keep
|
||||
// original paths unchanged for non-Linux targets.
|
||||
if (!codexHome || platform !== 'linux') {
|
||||
return codexHome
|
||||
}
|
||||
return parseWslUncPath(codexHome)?.linuxPath ?? codexHome
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Receipt-cannot-lie guard for the desktop-local create host-spawn (Ruling 1a):
|
||||
// the CreatedWorktreeResult's `agentLaunchResult.status: 'launched'` is the sole
|
||||
// signal the renderer reads to conclude "the host already spawned the primary
|
||||
// agent terminal", and it must be inseparable from an actual registered PTY. In
|
||||
// finishLocalWorktreeCreateAgentLaunch the receipt is recorded INSIDE the spawn
|
||||
// closure, which the transaction runs before settle('registered') and only when
|
||||
// createTerminal resolves. So a launched outcome implies a recorded receipt, and
|
||||
// a spawn failure yields `failed` with no receipt — the signal cannot claim a
|
||||
// primary the host did not spawn. This test drives the same transaction + spawn
|
||||
// closure shape the runtime method uses.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentLaunchOperationStore } from './agent-launch-operation-store'
|
||||
import {
|
||||
runWorktreeAgentLaunchTransaction,
|
||||
type WorktreeAgentLaunchTransactionDeps
|
||||
} from './agent-launch-worktree-transaction'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract'
|
||||
|
||||
const SNAPSHOT: AgentLaunchSnapshot = {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
const PLAN: AgentStartupPlan = {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
|
||||
const RECEIPT: AgentLaunchReceipt = {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
notices: [],
|
||||
launchToken: 'tok-1',
|
||||
catalogRevision: 3,
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false }
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
operationStore: AgentLaunchOperationStore,
|
||||
spawn: WorktreeAgentLaunchTransactionDeps['spawn']
|
||||
): WorktreeAgentLaunchTransactionDeps {
|
||||
const boundary = {
|
||||
pendingSnapshotFor: vi.fn(() => SNAPSHOT),
|
||||
settleAgentLaunch: vi.fn()
|
||||
} as unknown as WorktreeAgentLaunchTransactionDeps['boundary']
|
||||
return {
|
||||
boundary,
|
||||
operationStore,
|
||||
persistPending: vi.fn(),
|
||||
spawn,
|
||||
clearPublicPending: vi.fn(),
|
||||
persistFailure: vi.fn(),
|
||||
mintFailureId: () => 'fail-1',
|
||||
now: () => 1000
|
||||
}
|
||||
}
|
||||
|
||||
const PARAMS = {
|
||||
operationId: 'op-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
scope: 'wt-1',
|
||||
payloadDigest: 'digest-1',
|
||||
clientMutationId: null,
|
||||
requestedAgent: 'claude' as const,
|
||||
intent: 'interactive' as const,
|
||||
execute: async () => ({ ok: true as const, plan: PLAN, receipt: RECEIPT })
|
||||
}
|
||||
|
||||
describe('desktop-local create host-spawn receipt attribution', () => {
|
||||
it('records the receipt exactly when the launch registers, so the launched signal is truthful', async () => {
|
||||
const operationStore = new AgentLaunchOperationStore()
|
||||
// Mirrors finishLocalWorktreeCreateAgentLaunch's spawn closure: createTerminal
|
||||
// resolves, then the receipt is attributed to the registered terminal id.
|
||||
const spawn = vi.fn(async (_plan: AgentStartupPlan, receipt: AgentLaunchReceipt) => {
|
||||
operationStore.recordRegisteredReceipt('term-1', receipt)
|
||||
return { terminalId: 'term-1' }
|
||||
})
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
buildDeps(operationStore, spawn),
|
||||
PARAMS
|
||||
)
|
||||
expect(outcome.status).toBe('launched')
|
||||
// The launched arm the renderer reads is backed by a recorded receipt.
|
||||
expect(operationStore.registeredReceipt('term-1')).toEqual(RECEIPT)
|
||||
})
|
||||
|
||||
it('reissues the local-git creation receipt on a settled-launched replay', async () => {
|
||||
const operationStore = new AgentLaunchOperationStore()
|
||||
// Mirrors createManagedWorktree's inline local-git spawn closure: it now
|
||||
// records the receipt just like the other two spawn sites, so the settled
|
||||
// ledger (which holds no token by design) can reissue the client-safe
|
||||
// receipt from terminal attribution when a create is replayed after restart.
|
||||
const spawn = vi.fn(async (_plan: AgentStartupPlan, receipt: AgentLaunchReceipt) => {
|
||||
operationStore.recordRegisteredReceipt('local-git-term', receipt)
|
||||
return { terminalId: 'local-git-term' }
|
||||
})
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
buildDeps(operationStore, spawn),
|
||||
PARAMS
|
||||
)
|
||||
expect(outcome.status).toBe('launched')
|
||||
const terminalId = outcome.status === 'launched' ? outcome.terminalId : null
|
||||
expect(terminalId).toBe('local-git-term')
|
||||
// resolveSettledWorktreeRetry reads exactly this to reissue `launched`; before
|
||||
// the fix a local-git creation left no attribution and returned a stale reject.
|
||||
expect(operationStore.registeredReceipt('local-git-term')).toEqual(RECEIPT)
|
||||
})
|
||||
|
||||
it('never records a receipt when the spawn fails, so no launched signal can appear', async () => {
|
||||
const operationStore = new AgentLaunchOperationStore()
|
||||
// createTerminal throws before the receipt line runs — exactly as a real spawn
|
||||
// failure would, so no attribution is left behind.
|
||||
const spawn = vi.fn(async () => {
|
||||
throw new Error('pty_spawn_failed')
|
||||
})
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
buildDeps(operationStore, spawn),
|
||||
PARAMS
|
||||
)
|
||||
expect(outcome.status).toBe('failed')
|
||||
expect(operationStore.registeredReceipt('term-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
AgentLaunchOperationStore,
|
||||
canonicalPayloadDigest,
|
||||
type PendingAgentLaunchSnapshot
|
||||
} from './agent-launch-operation-store'
|
||||
import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation'
|
||||
import {
|
||||
runForgetUnknownAgentLaunch,
|
||||
type ForgetUnknownAgentLaunchDeps
|
||||
} from './agent-launch-worktree-forget'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: true,
|
||||
executionHostId: 'ssh:host'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OPERATION_ID = 'op-unknown-1'
|
||||
const WORKTREE_ID = 'wt-1'
|
||||
const CLIENT_MUTATION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const IDEMPOTENCY_KEY = 'idem-forget-1'
|
||||
|
||||
function pending(): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: OPERATION_ID,
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
scope: WORKTREE_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID,
|
||||
payloadDigest: 'create-digest',
|
||||
launchToken: 'token-unknown-1',
|
||||
intent: 'interactive',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
type ForgetTestDeps = ForgetUnknownAgentLaunchDeps & {
|
||||
releaseReservation: ReturnType<typeof vi.fn>
|
||||
clearPublicState: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
store: AgentLaunchOperationStore,
|
||||
overrides: Partial<ForgetUnknownAgentLaunchDeps> = {}
|
||||
): ForgetTestDeps {
|
||||
const releaseReservation = vi.fn<(launchToken: string) => void>()
|
||||
const clearPublicState = vi.fn()
|
||||
return {
|
||||
operationStore: store,
|
||||
idempotencyKeyFor: () => IDEMPOTENCY_KEY,
|
||||
loadPendingSnapshot: () => store.getPending('token-unknown-1'),
|
||||
loadFailureCode: () => 'launch_state_unknown',
|
||||
releaseReservation,
|
||||
clearPublicState,
|
||||
now: () => 2000,
|
||||
...overrides
|
||||
} as ForgetTestDeps
|
||||
}
|
||||
|
||||
describe('runForgetUnknownAgentLaunch', () => {
|
||||
it('while unknown, Forget (not retry) releases the pending, token, and reservation', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.beginPending(pending())
|
||||
const deps = buildDeps(store)
|
||||
|
||||
// Retry is blocked while unknown: the recovery gate refuses without mutation,
|
||||
// so the trio is untouched by a retry.
|
||||
expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({
|
||||
kind: 'launch_state_unknown'
|
||||
})
|
||||
expect(store.getPending('token-unknown-1')).not.toBeNull()
|
||||
|
||||
const result = runForgetUnknownAgentLaunch(deps, {
|
||||
scope: WORKTREE_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'forgotten' })
|
||||
// Private attribution removed, reservation freed, public state cleared.
|
||||
expect(store.getPending('token-unknown-1')).toBeNull()
|
||||
expect(deps.releaseReservation).toHaveBeenCalledWith('token-unknown-1')
|
||||
expect(deps.clearPublicState).toHaveBeenCalledTimes(1)
|
||||
// Settled as `forgotten` for idempotency replay.
|
||||
expect(store.findSettledByIdempotencyKey(WORKTREE_ID, IDEMPOTENCY_KEY)).toMatchObject({
|
||||
status: 'forgotten',
|
||||
terminalId: null,
|
||||
failureId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('replays forgotten on a double-submit without re-releasing', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.beginPending(pending())
|
||||
const deps = buildDeps(store)
|
||||
const params = {
|
||||
scope: WORKTREE_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
}
|
||||
|
||||
expect(runForgetUnknownAgentLaunch(deps, params)).toEqual({ status: 'forgotten' })
|
||||
deps.releaseReservation.mockClear()
|
||||
deps.clearPublicState.mockClear()
|
||||
|
||||
// Second submit: the settled ledger replays `forgotten`, mutating nothing.
|
||||
expect(runForgetUnknownAgentLaunch(deps, params)).toEqual({ status: 'forgotten' })
|
||||
expect(deps.releaseReservation).not.toHaveBeenCalled()
|
||||
expect(deps.clearPublicState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a stale operation id without mutation', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.beginPending(pending())
|
||||
const deps = buildDeps(store)
|
||||
|
||||
const result = runForgetUnknownAgentLaunch(deps, {
|
||||
scope: WORKTREE_ID,
|
||||
expectedOperationId: 'op-stale',
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
expect(store.getPending('token-unknown-1')).not.toBeNull()
|
||||
expect(deps.releaseReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses to forget a launch that is not launch_state_unknown', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.beginPending(pending())
|
||||
const deps = buildDeps(store, { loadFailureCode: () => 'spawn_failed' })
|
||||
|
||||
const result = runForgetUnknownAgentLaunch(deps, {
|
||||
scope: WORKTREE_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
expect(store.getPending('token-unknown-1')).not.toBeNull()
|
||||
expect(deps.releaseReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns idempotency_conflict when the key was used with a different payload', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.recordSettled({
|
||||
operationId: 'op-other',
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
scope: WORKTREE_ID,
|
||||
payloadDigest: canonicalPayloadDigest({ kind: 'forget', expectedOperationId: 'op-other' }),
|
||||
status: 'forgotten',
|
||||
terminalId: null,
|
||||
failureId: null,
|
||||
settledAt: 1
|
||||
})
|
||||
const deps = buildDeps(store)
|
||||
|
||||
const result = runForgetUnknownAgentLaunch(deps, {
|
||||
scope: WORKTREE_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// Pure orchestrator for `forgetUnknownAgentLaunch` (U4/U5). An authorized owner
|
||||
// explicitly forgets a launch stranded in `launch_state_unknown` when Orca cannot
|
||||
// reach the terminal host. Forgetting NEVER kills or spawns anything (the remote
|
||||
// process may still be running); it only releases Orca's local bookkeeping:
|
||||
// - settles the public attempt as `forgotten` in the idempotency ledger,
|
||||
// - removes the private pending snapshot/token attribution,
|
||||
// - frees the held admission reservation (capacity),
|
||||
// - clears the public pending metadata and the unknown failure card.
|
||||
// Guards, in order: idempotency replay first (a double-submit after a successful
|
||||
// forget replays `forgotten` instead of hitting the now-empty pending), then the
|
||||
// operation-id anti-race guard, then the "only from matching launch_state_unknown"
|
||||
// gate. `expectedOperationId` is an anti-race guard, never authorization.
|
||||
// Electron-free and injectable.
|
||||
|
||||
import type {
|
||||
AgentLaunchFailureCode,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import {
|
||||
canonicalPayloadDigest,
|
||||
type AgentLaunchOperationStore,
|
||||
type PendingAgentLaunchSnapshot,
|
||||
type SettledAgentLaunchOperation
|
||||
} from './agent-launch-operation-store'
|
||||
|
||||
export type ForgetUnknownAgentLaunchParams = {
|
||||
/** Owner bucket for the op-store ledger/pending lookup: worktree id for an
|
||||
* interactive launch, attempt id for a generic background attempt. */
|
||||
scope: string
|
||||
expectedOperationId: string
|
||||
clientMutationId: string
|
||||
}
|
||||
|
||||
// The client-safe forget result lives in shared so renderer, preload, and this
|
||||
// host orchestrator type-check against one definition.
|
||||
export type { ForgetUnknownAgentLaunchResult } from '../../shared/agent-launch-worktree-recovery'
|
||||
import type { ForgetUnknownAgentLaunchResult } from '../../shared/agent-launch-worktree-recovery'
|
||||
|
||||
export type ForgetUnknownAgentLaunchDeps = {
|
||||
operationStore: AgentLaunchOperationStore
|
||||
idempotencyKeyFor: (clientMutationId: string) => string
|
||||
/** The private pending snapshot for this scope (source of the launch token and
|
||||
* the authoritative operation id), or null once nothing is pending. */
|
||||
loadPendingSnapshot: () => PendingAgentLaunchSnapshot | null
|
||||
/** The scope's current durable failure code; forget is allowed only when it is
|
||||
* `launch_state_unknown`. */
|
||||
loadFailureCode: () => AgentLaunchFailureCode | undefined
|
||||
/** Free the held admission reservation for the launch token (capacity). */
|
||||
releaseReservation: (launchToken: string) => void
|
||||
/** Clear the public pending metadata and the unknown failure card. */
|
||||
clearPublicState: () => void
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
const FORGET_KIND = 'forget' as const
|
||||
|
||||
function rejected(code: AgentLaunchRequestError['code']): ForgetUnknownAgentLaunchResult {
|
||||
return { status: 'rejected', requestError: { code } }
|
||||
}
|
||||
|
||||
function resolveSettled(settled: SettledAgentLaunchOperation): ForgetUnknownAgentLaunchResult {
|
||||
// Only a forget settles `forgotten`; any other settled status under this key
|
||||
// means the mutation id was reused for a different operation.
|
||||
return settled.status === 'forgotten' ? { status: 'forgotten' } : rejected('idempotency_conflict')
|
||||
}
|
||||
|
||||
export function runForgetUnknownAgentLaunch(
|
||||
deps: ForgetUnknownAgentLaunchDeps,
|
||||
params: ForgetUnknownAgentLaunchParams
|
||||
): ForgetUnknownAgentLaunchResult {
|
||||
const nowFn = deps.now ?? Date.now
|
||||
const idempotencyKey = deps.idempotencyKeyFor(params.clientMutationId)
|
||||
const payloadDigest = canonicalPayloadDigest({
|
||||
kind: FORGET_KIND,
|
||||
expectedOperationId: params.expectedOperationId
|
||||
})
|
||||
|
||||
// 1. Idempotency first: a settled ledger entry replays without re-mutating.
|
||||
const settled = deps.operationStore.findSettledByIdempotencyKey(params.scope, idempotencyKey)
|
||||
if (settled) {
|
||||
return settled.payloadDigest === payloadDigest
|
||||
? resolveSettled(settled)
|
||||
: rejected('idempotency_conflict')
|
||||
}
|
||||
|
||||
// 2. Operation-id anti-race guard: the private pending must still be present and
|
||||
// name the operation the client believes it is forgetting.
|
||||
const pending = deps.loadPendingSnapshot()
|
||||
if (!pending || pending.operationId !== params.expectedOperationId) {
|
||||
return rejected('stale_agent_launch_failure')
|
||||
}
|
||||
|
||||
// 3. Only a matching launch_state_unknown is forgettable; any other state means
|
||||
// reconciliation already resolved it, so there is nothing stranded to forget.
|
||||
if (deps.loadFailureCode() !== 'launch_state_unknown') {
|
||||
return rejected('stale_agent_launch_failure')
|
||||
}
|
||||
|
||||
// Settle `forgotten`, drop the private attribution, and free the reservation.
|
||||
// No kill/spawn: a later provider terminal is treated as unattributed.
|
||||
deps.operationStore.recordSettled({
|
||||
operationId: pending.operationId,
|
||||
idempotencyKey,
|
||||
scope: params.scope,
|
||||
payloadDigest,
|
||||
status: 'forgotten',
|
||||
terminalId: null,
|
||||
failureId: null,
|
||||
settledAt: nowFn()
|
||||
})
|
||||
deps.operationStore.clearPending(pending.launchToken)
|
||||
deps.releaseReservation(pending.launchToken)
|
||||
deps.clearPublicState()
|
||||
return { status: 'forgotten' }
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import {
|
||||
AgentLaunchOperationStore,
|
||||
type PendingAgentLaunchSnapshot
|
||||
} from './agent-launch-operation-store'
|
||||
import {
|
||||
reconcileAllPendingAgentLaunches,
|
||||
reconcileOnePendingAgentLaunch,
|
||||
type ReconcileAgentLaunchDeps,
|
||||
type ReconcileScopePersistence,
|
||||
type ResolvedLaunchLiveness
|
||||
} from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pending(overrides: Partial<PendingAgentLaunchSnapshot> = {}): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: 'op-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
scope: 'wt-1',
|
||||
clientMutationId: 'cmid-1',
|
||||
payloadDigest: 'digest-1',
|
||||
launchToken: 'token-1',
|
||||
intent: 'interactive',
|
||||
snapshot: snapshot(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function buildDeps(
|
||||
store: AgentLaunchOperationStore,
|
||||
liveness: ResolvedLaunchLiveness,
|
||||
persistence: ReconcileScopePersistence,
|
||||
settleBoundary = vi.fn()
|
||||
): ReconcileAgentLaunchDeps {
|
||||
let failureCounter = 0
|
||||
return {
|
||||
operationStore: store,
|
||||
resolveLiveness: () => liveness,
|
||||
persistenceFor: () => persistence,
|
||||
settleBoundary,
|
||||
mintFailureId: () => `failure-${(failureCounter += 1)}`,
|
||||
now: () => 1000
|
||||
}
|
||||
}
|
||||
|
||||
function persistenceSpy(): ReconcileScopePersistence & {
|
||||
launched: ReturnType<typeof vi.fn>
|
||||
failed: ReturnType<typeof vi.fn>
|
||||
unknown: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const launched = vi.fn()
|
||||
const failed = vi.fn<(failure: PersistedAgentLaunchFailure) => void>()
|
||||
const unknown = vi.fn<(failure: PersistedAgentLaunchFailure) => void>()
|
||||
return {
|
||||
settleLaunched: launched,
|
||||
settleFailed: failed,
|
||||
markUnknown: unknown,
|
||||
launched,
|
||||
failed,
|
||||
unknown
|
||||
}
|
||||
}
|
||||
|
||||
describe('reconcileOnePendingAgentLaunch', () => {
|
||||
it('live+attributed settles launched, clears pending, and registers the boundary', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
const persistence = persistenceSpy()
|
||||
const settleBoundary = vi.fn()
|
||||
const deps = buildDeps(
|
||||
store,
|
||||
{ kind: 'live', attributed: true, terminalId: 'term-9' },
|
||||
persistence,
|
||||
settleBoundary
|
||||
)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'launched' })
|
||||
expect(settleBoundary).toHaveBeenCalledWith('token-1', 'registered')
|
||||
expect(store.getPending('token-1')).toBeNull()
|
||||
expect(persistence.launched).toHaveBeenCalledTimes(1)
|
||||
const settled = store.findSettledByIdempotencyKey('wt-1', 'idem-1')
|
||||
expect(settled).toMatchObject({ status: 'launched', terminalId: 'term-9', failureId: null })
|
||||
})
|
||||
|
||||
it('live+unattributed records invalid_launch_snapshot without tearing the terminal down', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
const persistence = persistenceSpy()
|
||||
const settleBoundary = vi.fn()
|
||||
const deps = buildDeps(
|
||||
store,
|
||||
{ kind: 'live', attributed: false, terminalId: 'term-hijack' },
|
||||
persistence,
|
||||
settleBoundary
|
||||
)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'invalid_launch_snapshot' })
|
||||
expect(settleBoundary).toHaveBeenCalledWith('token-1', 'failed')
|
||||
expect(store.getPending('token-1')).toBeNull()
|
||||
const failure = persistence.failed.mock.calls[0][0]
|
||||
expect(failure).toMatchObject({ code: 'invalid_launch_snapshot', intent: 'interactive' })
|
||||
expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({
|
||||
status: 'failed',
|
||||
terminalId: 'term-hijack'
|
||||
})
|
||||
})
|
||||
|
||||
it('absent settles spawn_failed with Retry available', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
const persistence = persistenceSpy()
|
||||
const deps = buildDeps(store, { kind: 'absent' }, persistence)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'spawn_failed' })
|
||||
expect(store.getPending('token-1')).toBeNull()
|
||||
const failure = persistence.failed.mock.calls[0][0]
|
||||
expect(failure).toMatchObject({ code: 'spawn_failed', intent: 'interactive' })
|
||||
expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({
|
||||
status: 'failed',
|
||||
terminalId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('unknown writes the durable failure but keeps pending, snapshot, and reservation', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending()
|
||||
store.beginPending(entry)
|
||||
const persistence = persistenceSpy()
|
||||
const settleBoundary = vi.fn()
|
||||
const deps = buildDeps(store, { kind: 'unknown' }, persistence, settleBoundary)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toEqual({ kind: 'launch_state_unknown' })
|
||||
// Coexistence: the operation is NOT settled and nothing is released.
|
||||
expect(settleBoundary).not.toHaveBeenCalled()
|
||||
expect(store.getPending('token-1')).not.toBeNull()
|
||||
expect(store.findSettledByIdempotencyKey('wt-1', 'idem-1')).toBeNull()
|
||||
const failure = persistence.unknown.mock.calls[0][0]
|
||||
expect(failure).toMatchObject({ code: 'launch_state_unknown', intent: 'interactive' })
|
||||
expect(failure.failureId).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skips a snapshot a concurrent settle already cleared', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const entry = pending()
|
||||
// Not begun in the store: models a token already settled/forgotten elsewhere.
|
||||
const persistence = persistenceSpy()
|
||||
const settleBoundary = vi.fn()
|
||||
const deps = buildDeps(store, { kind: 'absent' }, persistence, settleBoundary)
|
||||
|
||||
const outcome = reconcileOnePendingAgentLaunch(deps, entry)
|
||||
|
||||
expect(outcome).toBeNull()
|
||||
expect(settleBoundary).not.toHaveBeenCalled()
|
||||
expect(persistence.failed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('reconcileAllPendingAgentLaunches', () => {
|
||||
it('reconciles only the filtered scope', () => {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
store.beginPending(pending())
|
||||
store.beginPending(
|
||||
pending({
|
||||
scope: 'wt-2',
|
||||
launchToken: 'token-2',
|
||||
operationId: 'op-2',
|
||||
idempotencyKey: 'idem-2'
|
||||
})
|
||||
)
|
||||
const persistence = persistenceSpy()
|
||||
const deps: ReconcileAgentLaunchDeps = {
|
||||
...buildDeps(store, { kind: 'absent' }, persistence),
|
||||
persistenceFor: () => persistence
|
||||
}
|
||||
|
||||
reconcileAllPendingAgentLaunches(deps, (entry) => entry.scope === 'wt-2')
|
||||
|
||||
expect(store.getPending('token-2')).toBeNull()
|
||||
expect(store.getPending('token-1')).not.toBeNull()
|
||||
expect(persistence.failed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// The event-driven WRITER half of U4/U5 reconciliation. The pure decision lives
|
||||
// in agent-launch-reconciliation.ts; this module takes a resolved liveness for a
|
||||
// pending launch snapshot and persists the mapped outcome through injected
|
||||
// callbacks, enforcing the plan's coexistence rule for the unknown state:
|
||||
// launched → settle the boundary registered, record `launched`,
|
||||
// clear pending (public + private), clear the failure.
|
||||
// invalid_launch_snapshot → record a durable failure, settle failed, clear
|
||||
// pending; NEVER tears down the live-but-unattributed
|
||||
// terminal (the retry gate blocks Retry while live).
|
||||
// spawn_failed → record a durable failure, settle failed, clear
|
||||
// pending; Retry becomes available.
|
||||
// launch_state_unknown → write the durable failure ONLY. The public pending,
|
||||
// the private snapshot/token, and the held admission
|
||||
// reservation ALL survive until a live/absent proof or
|
||||
// an explicit Forget releases them (never settled here).
|
||||
// Electron-free and injectable; the runtime supplies liveness + persistence.
|
||||
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchFailureCode,
|
||||
AgentLaunchIntentKind,
|
||||
PersistedAgentLaunchFailure
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type {
|
||||
AgentLaunchOperationStore,
|
||||
PendingAgentLaunchSnapshot
|
||||
} from './agent-launch-operation-store'
|
||||
import {
|
||||
reconcileAgentLaunchLiveness,
|
||||
type AgentLaunchReconcileOutcome,
|
||||
type ProviderLiveness
|
||||
} from './agent-launch-reconciliation'
|
||||
|
||||
/** Liveness the runtime resolves for one pending launch token against its own
|
||||
* live terminal view. `attributed` is whether a token-matched live terminal
|
||||
* still belongs to the launch's scope; `terminalId` names it for the ledger. */
|
||||
export type ResolvedLaunchLiveness =
|
||||
| { kind: 'live'; attributed: boolean; terminalId: string }
|
||||
| { kind: 'absent' }
|
||||
| { kind: 'unknown' }
|
||||
|
||||
/** Per-scope durable writes the reconciler drives. `settleLaunched`/`settleFailed`
|
||||
* clear the public pending; `markUnknown` MUST retain it (coexistence rule) and
|
||||
* should keep any existing launch_state_unknown failureId stable across idempotent
|
||||
* re-runs so the client's expectedFailureId guard does not churn. */
|
||||
export type ReconcileScopePersistence = {
|
||||
settleLaunched: () => void
|
||||
settleFailed: (failure: PersistedAgentLaunchFailure) => void
|
||||
markUnknown: (failure: PersistedAgentLaunchFailure) => void
|
||||
}
|
||||
|
||||
export type ReconcileAgentLaunchDeps = {
|
||||
operationStore: AgentLaunchOperationStore
|
||||
resolveLiveness: (pending: PendingAgentLaunchSnapshot) => ResolvedLaunchLiveness
|
||||
// Routes on the pending's INTENT (not just its scope string) so background,
|
||||
// automation, orchestration, and worktree launches land in their own owner
|
||||
// record even when two owners happen to share a scope id namespace.
|
||||
persistenceFor: (pending: PendingAgentLaunchSnapshot) => ReconcileScopePersistence
|
||||
settleBoundary: (launchToken: string, settlement: 'registered' | 'failed') => void
|
||||
mintFailureId: () => string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
function toProviderLiveness(liveness: ResolvedLaunchLiveness): ProviderLiveness {
|
||||
return liveness.kind === 'live'
|
||||
? { kind: 'live', attributed: liveness.attributed }
|
||||
: { kind: liveness.kind }
|
||||
}
|
||||
|
||||
function persistedFailure(
|
||||
code: AgentLaunchFailureCode,
|
||||
pending: PendingAgentLaunchSnapshot,
|
||||
deps: ReconcileAgentLaunchDeps,
|
||||
intent: AgentLaunchIntentKind,
|
||||
occurredAt: number
|
||||
): PersistedAgentLaunchFailure {
|
||||
const failure: AgentLaunchFailure = {
|
||||
code,
|
||||
requestedAgent: pending.snapshot.requestedAgent,
|
||||
baseAgent: pending.snapshot.baseAgent
|
||||
}
|
||||
return { ...failure, version: 1, failureId: deps.mintFailureId(), intent, occurredAt }
|
||||
}
|
||||
|
||||
/** Reconcile ONE pending launch snapshot against resolved liveness and persist
|
||||
* the mapped outcome. Idempotent: a snapshot a concurrent transaction/forget
|
||||
* already settled is skipped. Returns the applied outcome, or null if skipped. */
|
||||
export function reconcileOnePendingAgentLaunch(
|
||||
deps: ReconcileAgentLaunchDeps,
|
||||
pending: PendingAgentLaunchSnapshot
|
||||
): AgentLaunchReconcileOutcome | null {
|
||||
const nowFn = deps.now ?? Date.now
|
||||
// Re-read: a concurrent transaction/forget may have settled this token first.
|
||||
if (!deps.operationStore.getPending(pending.launchToken)) {
|
||||
return null
|
||||
}
|
||||
const liveness = deps.resolveLiveness(pending)
|
||||
const outcome = reconcileAgentLaunchLiveness(toProviderLiveness(liveness))
|
||||
const persistence = deps.persistenceFor(pending)
|
||||
const liveTerminalId = liveness.kind === 'live' ? liveness.terminalId : null
|
||||
|
||||
if (outcome.kind === 'launched') {
|
||||
deps.settleBoundary(pending.launchToken, 'registered')
|
||||
deps.operationStore.recordSettled({
|
||||
operationId: pending.operationId,
|
||||
idempotencyKey: pending.idempotencyKey,
|
||||
scope: pending.scope,
|
||||
payloadDigest: pending.payloadDigest,
|
||||
status: 'launched',
|
||||
terminalId: liveTerminalId,
|
||||
failureId: null,
|
||||
settledAt: nowFn()
|
||||
})
|
||||
deps.operationStore.clearPending(pending.launchToken)
|
||||
persistence.settleLaunched()
|
||||
return outcome
|
||||
}
|
||||
|
||||
if (outcome.kind === 'invalid_launch_snapshot' || outcome.kind === 'spawn_failed') {
|
||||
const failure = persistedFailure(outcome.kind, pending, deps, pending.intent, nowFn())
|
||||
deps.settleBoundary(pending.launchToken, 'failed')
|
||||
deps.operationStore.recordSettled({
|
||||
operationId: pending.operationId,
|
||||
idempotencyKey: pending.idempotencyKey,
|
||||
scope: pending.scope,
|
||||
payloadDigest: pending.payloadDigest,
|
||||
status: 'failed',
|
||||
terminalId: liveTerminalId,
|
||||
failureId: failure.failureId,
|
||||
settledAt: nowFn()
|
||||
})
|
||||
deps.operationStore.clearPending(pending.launchToken)
|
||||
persistence.settleFailed(failure)
|
||||
return outcome
|
||||
}
|
||||
|
||||
// launch_state_unknown — coexistence rule: settle nothing, clear nothing,
|
||||
// release nothing. Only the durable failure card is (re)written.
|
||||
const failure = persistedFailure('launch_state_unknown', pending, deps, pending.intent, nowFn())
|
||||
persistence.markUnknown(failure)
|
||||
return outcome
|
||||
}
|
||||
|
||||
/** Run reconciliation across every pending snapshot (optionally filtered to a
|
||||
* scope/provider). Snapshot the list first so per-entry clears do not disturb
|
||||
* iteration. */
|
||||
export function reconcileAllPendingAgentLaunches(
|
||||
deps: ReconcileAgentLaunchDeps,
|
||||
filter?: (pending: PendingAgentLaunchSnapshot) => boolean
|
||||
): void {
|
||||
for (const pending of deps.operationStore.pendingSnapshots()) {
|
||||
if (!filter || filter(pending)) {
|
||||
reconcileOnePendingAgentLaunch(deps, pending)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
prepareWorktreeAgentLaunch,
|
||||
executeWorktreeAgentLaunch,
|
||||
type WorktreeAgentLaunchContext,
|
||||
type WorktreeAgentLaunchDeps
|
||||
} from './agent-launch-worktree-resolution'
|
||||
import { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
import {
|
||||
AgentLaunchAdmissionStore,
|
||||
LaunchAdmissionCoordinator,
|
||||
type AdmissionPrincipal
|
||||
} from './agent-launch-admission-store'
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type {
|
||||
ResolveAgentLaunchRequest,
|
||||
ResolvedAgentLaunch,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
|
||||
const LOCAL: AdmissionPrincipal = { kind: 'local' }
|
||||
|
||||
function makeSnapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['/opt/claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeLaunch(
|
||||
fingerprint: string,
|
||||
stableInputDigest: string,
|
||||
worktreePath: string | null
|
||||
): ResolvedAgentLaunch {
|
||||
const snapshot = makeSnapshot()
|
||||
return {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
argv: snapshot.argv,
|
||||
agentEnv: snapshot.agentEnv,
|
||||
variables: { values: { repoPath: '/repo', worktreePath }, referenced: ['worktreePath'] },
|
||||
snapshot,
|
||||
policy: {
|
||||
intent: 'interactive',
|
||||
mode: 'built-in',
|
||||
client: 'desktop',
|
||||
isRemote: false,
|
||||
platform: 'linux',
|
||||
promptInjectionMode: 'stdin-after-start',
|
||||
expectedProcess: 'claude',
|
||||
env: 'none'
|
||||
},
|
||||
notices: [],
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false },
|
||||
admissionGuard: { fingerprint, stableInputDigest, basis: 'default' }
|
||||
}
|
||||
}
|
||||
|
||||
function makeSetup(resolve: (request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome): {
|
||||
deps: WorktreeAgentLaunchDeps
|
||||
store: AgentLaunchAdmissionStore
|
||||
} {
|
||||
const store = new AgentLaunchAdmissionStore()
|
||||
const boundary = new AgentLaunchBoundary({
|
||||
admissionStore: store,
|
||||
coordinator: new LaunchAdmissionCoordinator(),
|
||||
now: () => 1000
|
||||
})
|
||||
const deps: WorktreeAgentLaunchDeps = {
|
||||
boundary,
|
||||
getSettings: () => ({}) as GlobalSettings,
|
||||
getCatalogRevision: () => 5,
|
||||
detectStockBaseAgents: async () => null,
|
||||
resolveTargetHomePath: async () => '/home/dev',
|
||||
resolve: (request) => resolve(request)
|
||||
}
|
||||
return { deps, store }
|
||||
}
|
||||
|
||||
const CONTEXT: WorktreeAgentLaunchContext = {
|
||||
request: { selection: { kind: 'default' }, allowEmptyPromptLaunch: true },
|
||||
intent: { kind: 'interactive', client: 'desktop' },
|
||||
descriptor: { kind: 'local', platform: 'linux', shell: 'posix' },
|
||||
scope: 'wt-op',
|
||||
principal: LOCAL
|
||||
}
|
||||
|
||||
describe('two-stage worktree agent-launch resolution', () => {
|
||||
it('pins the config digest pre-git and admits it post-git across a changed path', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') })
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
repoPath: '/repo',
|
||||
worktreePath: '/wt-provisional'
|
||||
})
|
||||
expect(prepared.ok).toBe(true)
|
||||
if (!prepared.ok) {
|
||||
return
|
||||
}
|
||||
expect(prepared.stableInputDigest).toBe('sd-1')
|
||||
expect(prepared.requestedAgent).toBe('claude')
|
||||
// The hold counts before commit; nothing is admitted yet.
|
||||
expect(store.pendingForPrincipal(LOCAL)).toBe(1)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
|
||||
const executed = await executeWorktreeAgentLaunch(
|
||||
deps,
|
||||
CONTEXT,
|
||||
{ repoPath: '/repo', worktreePath: '/wt-real' },
|
||||
{
|
||||
reservationId: prepared.reservationId,
|
||||
expectedStableInputDigest: prepared.stableInputDigest
|
||||
}
|
||||
)
|
||||
expect(executed.ok).toBe(true)
|
||||
if (!executed.ok) {
|
||||
return
|
||||
}
|
||||
// The reservation converted into exactly one admitted token; no double-count.
|
||||
expect(store.pendingForPrincipal(LOCAL)).toBe(1)
|
||||
expect(store.get(executed.receipt.launchToken)?.snapshot.requestedAgent).toBe('claude')
|
||||
// Final resolution ran against the authoritative worktree path.
|
||||
expect(resolve.mock.calls[1]![0].variables.worktreePath).toBe('/wt-real')
|
||||
})
|
||||
|
||||
it('releases the reservation and reports a config change when the digest moved', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-prov', 'sd-1', '/wt-provisional') })
|
||||
.mockReturnValueOnce({ ok: true, launch: makeLaunch('fp-real', 'sd-2', '/wt-real') })
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
repoPath: '/repo',
|
||||
worktreePath: '/wt-provisional'
|
||||
})
|
||||
expect(prepared.ok).toBe(true)
|
||||
if (!prepared.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
const executed = await executeWorktreeAgentLaunch(
|
||||
deps,
|
||||
CONTEXT,
|
||||
{ repoPath: '/repo', worktreePath: '/wt-real' },
|
||||
{
|
||||
reservationId: prepared.reservationId,
|
||||
expectedStableInputDigest: prepared.stableInputDigest
|
||||
}
|
||||
)
|
||||
expect(executed.ok).toBe(false)
|
||||
if (executed.ok) {
|
||||
return
|
||||
}
|
||||
expect('failure' in executed && executed.failure.code).toBe('agent_configuration_changed')
|
||||
// A rejected two-stage launch never permanently burns capacity.
|
||||
expect(store.pendingForPrincipal(LOCAL)).toBe(0)
|
||||
expect(store.pendingCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('takes no reservation when pre-git resolution fails', async () => {
|
||||
const resolve = vi
|
||||
.fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>()
|
||||
.mockReturnValueOnce({ ok: false, failure: { code: 'custom_agent_disabled' } })
|
||||
const { deps, store } = makeSetup(resolve)
|
||||
|
||||
const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, {
|
||||
repoPath: '/repo',
|
||||
worktreePath: '/wt-provisional'
|
||||
})
|
||||
expect(prepared.ok).toBe(false)
|
||||
expect(store.pendingForPrincipal(LOCAL)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
// Two-stage host resolution for a worktree-creation `agentLaunch` request (U4).
|
||||
// Stage 1 (pre-git) pins the concrete requested identity + config-only digest and
|
||||
// takes one of the 256 admission reservations BEFORE any git side effect, so a
|
||||
// launch_capacity_exceeded (or a deterministic identity/enabled/template failure)
|
||||
// aborts creation without leaving an orphan worktree. Stage 2 (post-git) re-reads
|
||||
// one atomic settings/catalog view for BOTH the digest recheck and final
|
||||
// resolution against the authoritative worktree path, converting the held
|
||||
// reservation into an admitted token/snapshot/plan or releasing it. The client's
|
||||
// command/env/launchConfig/launchAgent are IGNORED — only the host-resolved plan
|
||||
// spawns. Electron-free and injection-based so it is unit-testable.
|
||||
|
||||
import type { GlobalSettings } from '../../shared/types'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import type { LaunchIntent, ResolvedAgentLaunch } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
deriveAgentLaunchHostState,
|
||||
type AgentLaunchHostDescriptor,
|
||||
type AgentLaunchHostStateDeps
|
||||
} from './agent-launch-host-state'
|
||||
import { buildHostStateResolve } from './agent-launch-spawn'
|
||||
import { STARTUP_COMMAND_TEXT_MAX_CHARS } from '../providers/windows-shell-args'
|
||||
import type { resolveAgentLaunch } from './resolve-agent-launch'
|
||||
import type {
|
||||
AgentLaunchBoundary,
|
||||
ExecuteAgentLaunchResult,
|
||||
PrepareReservedAgentLaunchResult
|
||||
} from './agent-launch-boundary'
|
||||
import type { AdmissionPrincipal } from './agent-launch-admission-store'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
|
||||
/** A pre-create (stage 1) launch rejection. Thrown so the worktree-create RPC
|
||||
* aborts BEFORE any git mutation — capacity and deterministic identity/enabled/
|
||||
* template failures create no worktree. The structured failure/requestError is
|
||||
* carried for the caller surface to render; it is never a created-worktree
|
||||
* result. */
|
||||
export class WorktreeAgentLaunchPreCreateError extends Error {
|
||||
readonly failure?: AgentLaunchFailure
|
||||
readonly requestError?: AgentLaunchRequestError
|
||||
constructor(rejection: { failure?: AgentLaunchFailure; requestError?: AgentLaunchRequestError }) {
|
||||
super(
|
||||
rejection.failure
|
||||
? `agent_launch_precreate_failed:${rejection.failure.code}`
|
||||
: `agent_launch_precreate_rejected:${rejection.requestError?.code ?? 'unknown'}`
|
||||
)
|
||||
this.name = 'WorktreeAgentLaunchPreCreateError'
|
||||
if (rejection.failure) {
|
||||
this.failure = rejection.failure
|
||||
}
|
||||
if (rejection.requestError) {
|
||||
this.requestError = rejection.requestError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type WorktreeAgentLaunchDeps = {
|
||||
boundary: AgentLaunchBoundary
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
detectStockBaseAgents: AgentLaunchHostStateDeps['detectStockBaseAgents']
|
||||
resolveTargetHomePath: AgentLaunchHostStateDeps['resolveTargetHomePath']
|
||||
resolveTransportConfidentiality?: AgentLaunchHostStateDeps['resolveTransportConfidentiality']
|
||||
/** Best-effort workspace trust for the resolved base agent, run as the
|
||||
* boundary's pre-admission preflight OUTSIDE the coordinator. A throw maps to
|
||||
* trust_preflight_failed with no admission record and the reservation freed. */
|
||||
markWorkspaceTrusted?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
/** Provider env preparation, OUTSIDE the coordinator; same failure mapping. */
|
||||
prepareEnv?: (launch: ResolvedAgentLaunch) => Promise<void> | void
|
||||
/** Injectable total resolver for tests; defaults to the real one. */
|
||||
resolve?: typeof resolveAgentLaunch
|
||||
}
|
||||
|
||||
/** The immutable per-creation context shared by both stages. `provisionalPaths`
|
||||
* seed the pre-git resolve (variable NAMES validate, values are provisional);
|
||||
* `authoritativePaths` are the real repo/worktree paths after git created the
|
||||
* workspace. */
|
||||
export type WorktreeAgentLaunchContext = {
|
||||
request: AgentLaunchSpawnRequest
|
||||
intent: LaunchIntent
|
||||
descriptor: AgentLaunchHostDescriptor
|
||||
scope: string
|
||||
principal: AdmissionPrincipal
|
||||
}
|
||||
|
||||
function toSpawnDeps(deps: WorktreeAgentLaunchDeps): {
|
||||
getSettings: () => GlobalSettings
|
||||
getCatalogRevision: () => number
|
||||
boundary: AgentLaunchBoundary
|
||||
resolve?: typeof resolveAgentLaunch
|
||||
} {
|
||||
return {
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
boundary: deps.boundary,
|
||||
...(deps.resolve ? { resolve: deps.resolve } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Stage 1: pin identity + config-only digest and reserve capacity, all before
|
||||
* git mutation. On failure NO reservation is held and the caller must not
|
||||
* create the worktree. */
|
||||
export async function prepareWorktreeAgentLaunch(
|
||||
deps: WorktreeAgentLaunchDeps,
|
||||
context: WorktreeAgentLaunchContext,
|
||||
provisionalPaths: { repoPath: string | null; worktreePath: string | null }
|
||||
): Promise<PrepareReservedAgentLaunchResult> {
|
||||
const hostState = await deriveAgentLaunchHostState(
|
||||
{
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
detectStockBaseAgents: deps.detectStockBaseAgents,
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath,
|
||||
...(deps.resolveTransportConfidentiality
|
||||
? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality }
|
||||
: {})
|
||||
},
|
||||
context.descriptor,
|
||||
provisionalPaths
|
||||
)
|
||||
const resolve = buildHostStateResolve(toSpawnDeps(deps), {
|
||||
request: context.request,
|
||||
intent: context.intent,
|
||||
target: hostState.target,
|
||||
variables: hostState.variables,
|
||||
scope: context.scope,
|
||||
principal: context.principal
|
||||
})
|
||||
return deps.boundary.prepareReservedAgentLaunch({ principal: context.principal, resolve })
|
||||
}
|
||||
|
||||
/** Stage 2: with the authoritative worktree path and the pinned reservation,
|
||||
* re-resolve, recheck the config-only digest, and convert the reservation into
|
||||
* a startup plan + receipt (or release it on any failure). Creates no PTY: the
|
||||
* caller persists the pending record, then spawns and settles. */
|
||||
export async function executeWorktreeAgentLaunch(
|
||||
deps: WorktreeAgentLaunchDeps,
|
||||
context: WorktreeAgentLaunchContext,
|
||||
authoritativePaths: { repoPath: string | null; worktreePath: string | null },
|
||||
reservation: { reservationId: string; expectedStableInputDigest: string }
|
||||
): Promise<ExecuteAgentLaunchResult> {
|
||||
const hostState = await deriveAgentLaunchHostState(
|
||||
{
|
||||
getSettings: deps.getSettings,
|
||||
getCatalogRevision: deps.getCatalogRevision,
|
||||
detectStockBaseAgents: deps.detectStockBaseAgents,
|
||||
resolveTargetHomePath: deps.resolveTargetHomePath,
|
||||
...(deps.resolveTransportConfidentiality
|
||||
? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality }
|
||||
: {})
|
||||
},
|
||||
context.descriptor,
|
||||
authoritativePaths
|
||||
)
|
||||
const resolve = buildHostStateResolve(toSpawnDeps(deps), {
|
||||
request: context.request,
|
||||
intent: context.intent,
|
||||
target: hostState.target,
|
||||
variables: hostState.variables,
|
||||
scope: context.scope,
|
||||
principal: context.principal
|
||||
})
|
||||
return deps.boundary.executeReservedAgentLaunch({
|
||||
scope: context.scope,
|
||||
principal: context.principal,
|
||||
resolve,
|
||||
prompt: context.request.prompt ?? '',
|
||||
...(context.request.allowEmptyPromptLaunch !== undefined
|
||||
? { allowEmptyPromptLaunch: context.request.allowEmptyPromptLaunch }
|
||||
: {}),
|
||||
...(context.request.promptDelivery !== undefined
|
||||
? { promptDelivery: context.request.promptDelivery }
|
||||
: {}),
|
||||
maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS,
|
||||
...(deps.markWorkspaceTrusted ? { preflight: deps.markWorkspaceTrusted } : {}),
|
||||
...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {}),
|
||||
reservationId: reservation.reservationId,
|
||||
expectedStableInputDigest: reservation.expectedStableInputDigest
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Ephemeral host-wide in-flight join registry for `worktree.retryAgentLaunch`.
|
||||
// A retry launch is registered here by idempotency key while it runs so a
|
||||
// concurrent duplicate (double-click, client reconnect) joins the same promise
|
||||
// instead of starting a second launch; the entry clears when the promise
|
||||
// settles. This is in-memory only — cross-restart idempotency is the durable
|
||||
// settled ledger's job, not this registry's.
|
||||
|
||||
import type {
|
||||
WorktreeRetryAgentLaunchResult,
|
||||
WorktreeRetryInFlight
|
||||
} from './agent-launch-worktree-retry'
|
||||
|
||||
const inFlightByKey = new Map<string, WorktreeRetryInFlight>()
|
||||
|
||||
export function findWorktreeRetryInFlight(idempotencyKey: string): WorktreeRetryInFlight | null {
|
||||
return inFlightByKey.get(idempotencyKey) ?? null
|
||||
}
|
||||
|
||||
export function registerWorktreeRetryInFlight(
|
||||
idempotencyKey: string,
|
||||
payloadDigest: string,
|
||||
promise: Promise<WorktreeRetryAgentLaunchResult>
|
||||
): void {
|
||||
inFlightByKey.set(idempotencyKey, { payloadDigest, promise })
|
||||
const clear = (): void => {
|
||||
// Only clear our own entry — a newer duplicate may have replaced it.
|
||||
if (inFlightByKey.get(idempotencyKey)?.promise === promise) {
|
||||
inFlightByKey.delete(idempotencyKey)
|
||||
}
|
||||
}
|
||||
void promise.then(clear, clear)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
runWorktreeRetryAgentLaunch,
|
||||
type RetryRecoveryGate,
|
||||
type WorktreeRetryAgentLaunchDeps,
|
||||
type WorktreeRetryAgentLaunchParams,
|
||||
type WorktreeRetryAgentLaunchResult,
|
||||
type WorktreeRetryInFlight
|
||||
} from './agent-launch-worktree-retry'
|
||||
import { AgentLaunchOperationStore, canonicalPayloadDigest } from './agent-launch-operation-store'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
|
||||
const WORKTREE = 'repo::/wt'
|
||||
const FAILURE_ID = 'f1'
|
||||
const IDEMPOTENCY_KEY = 'key-abc'
|
||||
|
||||
function durableFailure(
|
||||
overrides: Partial<PersistedAgentLaunchFailure> = {}
|
||||
): PersistedAgentLaunchFailure {
|
||||
return {
|
||||
code: 'spawn_failed',
|
||||
requestedAgent: 'claude',
|
||||
version: 1,
|
||||
failureId: FAILURE_ID,
|
||||
intent: 'interactive',
|
||||
occurredAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type Harness = {
|
||||
deps: WorktreeRetryAgentLaunchDeps
|
||||
runLaunch: ReturnType<typeof vi.fn>
|
||||
registerInFlight: ReturnType<typeof vi.fn>
|
||||
resolveSettled: ReturnType<typeof vi.fn>
|
||||
store: AgentLaunchOperationStore
|
||||
requests: AgentLaunchSpawnRequest[]
|
||||
}
|
||||
|
||||
function harness(overrides: Partial<WorktreeRetryAgentLaunchDeps> = {}): Harness {
|
||||
const store = new AgentLaunchOperationStore()
|
||||
const requests: AgentLaunchSpawnRequest[] = []
|
||||
const launched: WorktreeRetryAgentLaunchResult = {
|
||||
status: 'launched',
|
||||
receipt: {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
notices: [],
|
||||
launchToken: 'tok',
|
||||
catalogRevision: 1,
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false }
|
||||
}
|
||||
}
|
||||
const runLaunch = vi.fn(async (input: { request: AgentLaunchSpawnRequest }) => {
|
||||
requests.push(input.request)
|
||||
return launched
|
||||
})
|
||||
const registerInFlight = vi.fn()
|
||||
const resolveSettled = vi.fn(
|
||||
(): WorktreeRetryAgentLaunchResult => ({ status: 'launched', receipt: launched.receipt })
|
||||
)
|
||||
const deps: WorktreeRetryAgentLaunchDeps = {
|
||||
operationStore: store,
|
||||
idempotencyKeyFor: () => IDEMPOTENCY_KEY,
|
||||
findInFlight: () => null,
|
||||
registerInFlight,
|
||||
resolveSettled,
|
||||
loadDurableFailure: () => durableFailure(),
|
||||
resolveRecoveryGate: (): RetryRecoveryGate => ({ kind: 'retryable' }),
|
||||
runLaunch,
|
||||
...overrides
|
||||
}
|
||||
return { deps, runLaunch, registerInFlight, resolveSettled, store, requests }
|
||||
}
|
||||
|
||||
const RETRY_SAME: WorktreeRetryAgentLaunchParams = {
|
||||
scope: WORKTREE,
|
||||
expectedFailureId: FAILURE_ID,
|
||||
clientMutationId: '00000000-0000-4000-8000-000000000000',
|
||||
action: { kind: 'retry-same' }
|
||||
}
|
||||
|
||||
describe('runWorktreeRetryAgentLaunch idempotency', () => {
|
||||
it('replays the settled ledger result when key + payload match', async () => {
|
||||
const h = harness()
|
||||
h.store.recordSettled({
|
||||
operationId: 'op1',
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
scope: WORKTREE,
|
||||
payloadDigest: canonicalPayloadDigest({ kind: 'retry-same' }),
|
||||
status: 'launched',
|
||||
terminalId: 't1',
|
||||
failureId: null,
|
||||
settledAt: 1
|
||||
})
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result.status).toBe('launched')
|
||||
expect(h.resolveSettled).toHaveBeenCalledOnce()
|
||||
expect(h.runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns idempotency_conflict when the settled key is reused with a different payload', async () => {
|
||||
const h = harness()
|
||||
h.store.recordSettled({
|
||||
operationId: 'op1',
|
||||
idempotencyKey: IDEMPOTENCY_KEY,
|
||||
scope: WORKTREE,
|
||||
payloadDigest: canonicalPayloadDigest({ kind: 'change-agent', agent: 'codex' }),
|
||||
status: 'launched',
|
||||
terminalId: 't1',
|
||||
failureId: null,
|
||||
settledAt: 1
|
||||
})
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } })
|
||||
expect(h.runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('joins the in-flight promise when key + payload match', async () => {
|
||||
const inflightResult: WorktreeRetryAgentLaunchResult = {
|
||||
status: 'launched',
|
||||
receipt: {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
notices: [],
|
||||
launchToken: 'inflight',
|
||||
catalogRevision: 1,
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false }
|
||||
}
|
||||
}
|
||||
const inFlight: WorktreeRetryInFlight = {
|
||||
payloadDigest: canonicalPayloadDigest({ kind: 'retry-same' }),
|
||||
promise: Promise.resolve(inflightResult)
|
||||
}
|
||||
const h = harness({ findInFlight: () => inFlight })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toBe(inflightResult)
|
||||
expect(h.runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns idempotency_conflict when an in-flight key has a different payload', async () => {
|
||||
const inFlight: WorktreeRetryInFlight = {
|
||||
payloadDigest: canonicalPayloadDigest({ kind: 'change-agent', agent: 'codex' }),
|
||||
promise: Promise.resolve({ status: 'launched' } as WorktreeRetryAgentLaunchResult)
|
||||
}
|
||||
const h = harness({ findInFlight: () => inFlight })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({ status: 'rejected', requestError: { code: 'idempotency_conflict' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorktreeRetryAgentLaunch guards', () => {
|
||||
it('rejects with stale_agent_launch_failure when the durable failure is gone', async () => {
|
||||
const h = harness({ loadDurableFailure: () => null })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
expect(h.runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects with stale_agent_launch_failure when expectedFailureId mismatches', async () => {
|
||||
const h = harness({ loadDurableFailure: () => durableFailure({ failureId: 'other' }) })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks with launch_state_unknown without mutation when liveness is unknown', async () => {
|
||||
const h = harness({ resolveRecoveryGate: () => ({ kind: 'launch_state_unknown' }) })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({ status: 'blocked', failure: { code: 'launch_state_unknown' } })
|
||||
expect(h.runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('blocks with invalid_launch_snapshot while a token-live terminal lacks attribution', async () => {
|
||||
const h = harness({ resolveRecoveryGate: () => ({ kind: 'invalid_launch_snapshot' }) })
|
||||
const result = await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(result).toEqual({ status: 'blocked', failure: { code: 'invalid_launch_snapshot' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('runWorktreeRetryAgentLaunch action resolution', () => {
|
||||
it('retry-same launches the pinned identity with persisted workspace authority', async () => {
|
||||
const h = harness()
|
||||
await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(h.requests[0]).toEqual({
|
||||
selection: { kind: 'agent', agent: 'claude' },
|
||||
allowEmptyPromptLaunch: true,
|
||||
sourceRecord: { owner: 'workspace' }
|
||||
})
|
||||
expect(h.runLaunch.mock.calls[0][0].priorFailureId).toBe(FAILURE_ID)
|
||||
expect(h.registerInFlight).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retry-same with no pinned identity launches the host default', async () => {
|
||||
const h = harness({ loadDurableFailure: () => durableFailure({ requestedAgent: undefined }) })
|
||||
await runWorktreeRetryAgentLaunch(h.deps, RETRY_SAME)
|
||||
expect(h.requests[0]).toEqual({
|
||||
selection: { kind: 'default' },
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
})
|
||||
|
||||
it('change-agent launches a live selection with no fallback authority', async () => {
|
||||
const h = harness()
|
||||
await runWorktreeRetryAgentLaunch(h.deps, {
|
||||
...RETRY_SAME,
|
||||
action: { kind: 'change-agent', agent: 'codex' }
|
||||
})
|
||||
expect(h.requests[0]).toEqual({
|
||||
selection: { kind: 'agent', agent: 'codex' },
|
||||
allowEmptyPromptLaunch: true
|
||||
})
|
||||
expect(h.requests[0]).not.toHaveProperty('sourceRecord')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
// Host orchestration for `worktree.retryAgentLaunch` (U4). A retry is a fresh
|
||||
// two-stage launch against an EXISTING worktree, guarded by four ordered checks
|
||||
// the plan requires and applied here in this exact order:
|
||||
// 1. Payload-scoped idempotency FIRST — a settled-ledger hit replays the prior
|
||||
// result, an in-flight hit joins its promise, and a key reuse with a
|
||||
// DIFFERENT payload returns idempotency_conflict. Ordering it first means a
|
||||
// double-click after a successful retry replays `launched` instead of
|
||||
// tripping the (now-cleared) failure guard below.
|
||||
// 2. `expectedFailureId` anti-race guard against the current durable failure;
|
||||
// a mismatch (or a cleared/rotated failure) returns stale_agent_launch_failure.
|
||||
// 3. Server-side recovery-card gating that mirrors the exact state the card
|
||||
// renders (launch_state_unknown / invalid_launch_snapshot) and blocks WITHOUT
|
||||
// mutation, so the rejection code always matches the visible card state.
|
||||
// 4. Only then resolve the action into a launch request and run the shared
|
||||
// create transaction (which reserves capacity, re-resolves, and settles).
|
||||
// `expectedFailureId` is an anti-race guard shown in client metadata, never an
|
||||
// authorization secret. Electron-free and fully injection-based.
|
||||
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import type { AgentLaunchSpawnRequest } from '../../shared/agent-launch-spawn-request'
|
||||
import {
|
||||
canonicalPayloadDigest,
|
||||
type AgentLaunchOperationStore,
|
||||
type SettledAgentLaunchOperation
|
||||
} from './agent-launch-operation-store'
|
||||
|
||||
// The client-safe retry action and tri-state result live in shared so renderer,
|
||||
// preload, and this host orchestrator type-check against one definition.
|
||||
export type {
|
||||
RetryAgentLaunchAction,
|
||||
WorktreeRetryAgentLaunchResult
|
||||
} from '../../shared/agent-launch-worktree-recovery'
|
||||
import type {
|
||||
RetryAgentLaunchAction,
|
||||
WorktreeRetryAgentLaunchResult
|
||||
} from '../../shared/agent-launch-worktree-recovery'
|
||||
|
||||
export type WorktreeRetryAgentLaunchParams = {
|
||||
/** Owner bucket for the op-store ledger/idempotency joins: worktree id for an
|
||||
* interactive launch, attempt id for a generic background attempt. */
|
||||
scope: string
|
||||
expectedFailureId: string
|
||||
// Already validated to canonical lowercase UUID form by the RPC schema.
|
||||
clientMutationId: string
|
||||
action: RetryAgentLaunchAction
|
||||
}
|
||||
|
||||
/** Current recovery state derived from tri-state reconciliation. `retryable`
|
||||
* means the durable failure is settled and no live terminal contradicts it. */
|
||||
export type RetryRecoveryGate =
|
||||
| { kind: 'retryable' }
|
||||
| { kind: 'launch_state_unknown' }
|
||||
| { kind: 'invalid_launch_snapshot' }
|
||||
|
||||
export type WorktreeRetryInFlight = {
|
||||
payloadDigest: string
|
||||
promise: Promise<WorktreeRetryAgentLaunchResult>
|
||||
}
|
||||
|
||||
export type WorktreeRetryAgentLaunchDeps = {
|
||||
operationStore: AgentLaunchOperationStore
|
||||
/** Idempotency scope key = stable authenticated principal + worktree +
|
||||
* clientMutationId; survives host restart and client reconnect. */
|
||||
idempotencyKeyFor: (clientMutationId: string) => string
|
||||
/** Ephemeral cross-connection in-flight join; null when none is running. */
|
||||
findInFlight: (idempotencyKey: string) => WorktreeRetryInFlight | null
|
||||
/** Register the launch promise for concurrent joins; the implementation clears
|
||||
* it when the promise settles. Must be synchronous (no await before it) so the
|
||||
* find/register pair is atomic against a concurrent double-click. */
|
||||
registerInFlight: (
|
||||
idempotencyKey: string,
|
||||
payloadDigest: string,
|
||||
promise: Promise<WorktreeRetryAgentLaunchResult>
|
||||
) => void
|
||||
/** Map an evicted-or-current settled ledger entry to the authorized receipt or
|
||||
* durable failure it references. */
|
||||
resolveSettled: (settled: SettledAgentLaunchOperation) => WorktreeRetryAgentLaunchResult
|
||||
/** The current durable failure on the worktree, or null when cleared. */
|
||||
loadDurableFailure: () => PersistedAgentLaunchFailure | null
|
||||
/** Server-side recovery-card gate from tri-state reconciliation. */
|
||||
resolveRecoveryGate: () => RetryRecoveryGate
|
||||
/** Run the shared reserve -> execute -> spawn -> settle launch for the resolved
|
||||
* request; mirrors create's finish and owns prepare-failure classification
|
||||
* (capacity/deterministic -> blocked, request errors -> rejected). */
|
||||
runLaunch: (input: {
|
||||
request: AgentLaunchSpawnRequest
|
||||
idempotencyKey: string
|
||||
clientMutationId: string
|
||||
payloadDigest: string
|
||||
priorFailureId: string
|
||||
}) => Promise<WorktreeRetryAgentLaunchResult>
|
||||
}
|
||||
|
||||
/** Canonical payload for the idempotency digest: the action alone identifies the
|
||||
* request (retry-same is nullary; change-agent carries its target identity). */
|
||||
function canonicalizeAction(action: RetryAgentLaunchAction): unknown {
|
||||
return action.kind === 'change-agent'
|
||||
? { kind: 'change-agent', agent: action.agent }
|
||||
: { kind: 'retry-same' }
|
||||
}
|
||||
|
||||
/** Build the launch request from the action. retry-same loads the identity from
|
||||
* the durable failure and gets persisted-reference authority (a saved
|
||||
* `workspace` owner, so tombstone/safe-fallback resolution is allowed);
|
||||
* change-agent is a live selection with NO sourceRecord, so it must resolve a
|
||||
* currently-existing enabled agent and never gains fallback authority. */
|
||||
function buildRetryRequest(
|
||||
action: RetryAgentLaunchAction,
|
||||
failure: PersistedAgentLaunchFailure
|
||||
): AgentLaunchSpawnRequest {
|
||||
if (action.kind === 'change-agent') {
|
||||
return { selection: { kind: 'agent', agent: action.agent }, allowEmptyPromptLaunch: true }
|
||||
}
|
||||
if (failure.requestedAgent) {
|
||||
return {
|
||||
selection: { kind: 'agent', agent: failure.requestedAgent },
|
||||
allowEmptyPromptLaunch: true,
|
||||
sourceRecord: { owner: 'workspace' }
|
||||
}
|
||||
}
|
||||
// A failure with no pinned identity (e.g. no_agent_selected) retries the host
|
||||
// default, which already carries persisted/default authority.
|
||||
return { selection: { kind: 'default' }, allowEmptyPromptLaunch: true }
|
||||
}
|
||||
|
||||
export async function runWorktreeRetryAgentLaunch(
|
||||
deps: WorktreeRetryAgentLaunchDeps,
|
||||
params: WorktreeRetryAgentLaunchParams
|
||||
): Promise<WorktreeRetryAgentLaunchResult> {
|
||||
const idempotencyKey = deps.idempotencyKeyFor(params.clientMutationId)
|
||||
const payloadDigest = canonicalPayloadDigest(canonicalizeAction(params.action))
|
||||
|
||||
// 1. Idempotency — settled ledger, then in-flight. Same key + different payload
|
||||
// is a conflict; same key + same payload replays/joins without a second launch.
|
||||
const settled = deps.operationStore.findSettledByIdempotencyKey(params.scope, idempotencyKey)
|
||||
if (settled) {
|
||||
return settled.payloadDigest === payloadDigest
|
||||
? deps.resolveSettled(settled)
|
||||
: { status: 'rejected', requestError: { code: 'idempotency_conflict' } }
|
||||
}
|
||||
const inFlight = deps.findInFlight(idempotencyKey)
|
||||
if (inFlight) {
|
||||
return inFlight.payloadDigest === payloadDigest
|
||||
? inFlight.promise
|
||||
: { status: 'rejected', requestError: { code: 'idempotency_conflict' } }
|
||||
}
|
||||
|
||||
// 2. expectedFailureId guard against the current durable failure. A cleared or
|
||||
// rotated failure fails here rather than becoming a new launch.
|
||||
const failure = deps.loadDurableFailure()
|
||||
if (!failure || failure.failureId !== params.expectedFailureId) {
|
||||
return { status: 'rejected', requestError: { code: 'stale_agent_launch_failure' } }
|
||||
}
|
||||
|
||||
// 3. Recovery-card gate — block WITHOUT mutation so the rejection code matches
|
||||
// the exact state the card renders.
|
||||
const gate = deps.resolveRecoveryGate()
|
||||
if (gate.kind !== 'retryable') {
|
||||
return { status: 'blocked', failure: { code: gate.kind } }
|
||||
}
|
||||
|
||||
// 4. Resolve the action and run the shared launch. Register the promise before
|
||||
// returning (no await in between) so a concurrent duplicate joins it.
|
||||
const request = buildRetryRequest(params.action, failure)
|
||||
const promise = deps.runLaunch({
|
||||
request,
|
||||
idempotencyKey,
|
||||
clientMutationId: params.clientMutationId,
|
||||
payloadDigest,
|
||||
priorFailureId: params.expectedFailureId
|
||||
})
|
||||
deps.registerInFlight(idempotencyKey, payloadDigest, promise)
|
||||
return promise
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AgentLaunchOperationStore } from './agent-launch-operation-store'
|
||||
import {
|
||||
runWorktreeAgentLaunchTransaction,
|
||||
type WorktreeAgentLaunchTransactionDeps,
|
||||
type WorktreeAgentLaunchTransactionParams,
|
||||
type WorktreePendingAgentLaunch
|
||||
} from './agent-launch-worktree-transaction'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchReceipt,
|
||||
AgentLaunchRequestError
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type { ExecuteAgentLaunchResult } from './agent-launch-boundary'
|
||||
|
||||
const SNAPSHOT: AgentLaunchSnapshot = {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
const PLAN: AgentStartupPlan = {
|
||||
agent: 'claude',
|
||||
launchCommand: 'claude',
|
||||
expectedProcess: 'claude',
|
||||
followupPrompt: null,
|
||||
launchConfig: { agentArgs: '', agentEnv: {} }
|
||||
}
|
||||
|
||||
const RECEIPT: AgentLaunchReceipt = {
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
notices: [],
|
||||
launchToken: 'tok-1',
|
||||
catalogRevision: 3,
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: false }
|
||||
}
|
||||
|
||||
type CallLog = string[]
|
||||
|
||||
function makeDeps(overrides: {
|
||||
snapshot?: AgentLaunchSnapshot | null
|
||||
spawn?: WorktreeAgentLaunchTransactionDeps['spawn']
|
||||
log?: CallLog
|
||||
}): {
|
||||
deps: WorktreeAgentLaunchTransactionDeps
|
||||
operationStore: AgentLaunchOperationStore
|
||||
settle: ReturnType<typeof vi.fn>
|
||||
persistPending: ReturnType<typeof vi.fn>
|
||||
persistFailure: ReturnType<typeof vi.fn>
|
||||
clearPublicPending: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const log = overrides.log ?? []
|
||||
const operationStore = new AgentLaunchOperationStore()
|
||||
const settle = vi.fn((token: string, settlement: string) => {
|
||||
log.push(`settle:${settlement}:${token}`)
|
||||
})
|
||||
const persistPending = vi.fn((_pending: WorktreePendingAgentLaunch) => {
|
||||
log.push('persistPending')
|
||||
})
|
||||
const persistFailure = vi.fn(() => {
|
||||
log.push('persistFailure')
|
||||
})
|
||||
const clearPublicPending = vi.fn(() => {
|
||||
log.push('clearPublicPending')
|
||||
})
|
||||
const beginPending = operationStore.beginPending.bind(operationStore)
|
||||
operationStore.beginPending = ((entry) => {
|
||||
log.push('beginPending')
|
||||
return beginPending(entry)
|
||||
}) as typeof operationStore.beginPending
|
||||
const boundary = {
|
||||
pendingSnapshotFor: vi.fn(() =>
|
||||
overrides.snapshot === undefined ? SNAPSHOT : overrides.snapshot
|
||||
),
|
||||
settleAgentLaunch: settle
|
||||
} as unknown as WorktreeAgentLaunchTransactionDeps['boundary']
|
||||
const spawn =
|
||||
overrides.spawn ??
|
||||
vi.fn(async (_plan: unknown, receipt: { launchToken: string }) => {
|
||||
log.push('spawn')
|
||||
expect(receipt.launchToken).toBe('tok-1')
|
||||
return { terminalId: 'term-1' }
|
||||
})
|
||||
let failureCounter = 0
|
||||
const deps: WorktreeAgentLaunchTransactionDeps = {
|
||||
boundary,
|
||||
operationStore,
|
||||
persistPending,
|
||||
spawn,
|
||||
clearPublicPending,
|
||||
persistFailure,
|
||||
mintFailureId: () => `fail-${(failureCounter += 1)}`,
|
||||
now: () => 1000
|
||||
}
|
||||
return { deps, operationStore, settle, persistPending, persistFailure, clearPublicPending }
|
||||
}
|
||||
|
||||
function params(
|
||||
execute: () => Promise<ExecuteAgentLaunchResult>,
|
||||
extra?: Partial<WorktreeAgentLaunchTransactionParams>
|
||||
): WorktreeAgentLaunchTransactionParams {
|
||||
return {
|
||||
operationId: 'op-1',
|
||||
idempotencyKey: 'idem-1',
|
||||
scope: 'wt-1',
|
||||
payloadDigest: 'digest-1',
|
||||
clientMutationId: null,
|
||||
requestedAgent: 'claude',
|
||||
intent: 'interactive',
|
||||
execute,
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('runWorktreeAgentLaunchTransaction', () => {
|
||||
it('persists pending (public + private) before spawning, then settles launched', async () => {
|
||||
const log: CallLog = []
|
||||
const { deps, operationStore } = makeDeps({ log })
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT }))
|
||||
)
|
||||
expect(outcome).toEqual({ status: 'launched', receipt: RECEIPT, terminalId: 'term-1' })
|
||||
// Both persistence writes precede the writer; the private write is first.
|
||||
expect(log.indexOf('beginPending')).toBeLessThan(log.indexOf('spawn'))
|
||||
expect(log.indexOf('persistPending')).toBeLessThan(log.indexOf('spawn'))
|
||||
expect(log.indexOf('spawn')).toBeLessThan(log.indexOf('settle:registered:tok-1'))
|
||||
// Pending is cleared (public + private) and the ledger records launched.
|
||||
expect(operationStore.getPending('tok-1')).toBeNull()
|
||||
const settled = operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')
|
||||
expect(settled).toMatchObject({ status: 'launched', terminalId: 'term-1', failureId: null })
|
||||
})
|
||||
|
||||
it('keeps only client-safe fields in the public pending metadata', async () => {
|
||||
const { deps, persistPending } = makeDeps({})
|
||||
await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT }), {
|
||||
priorFailureId: 'prev-fail'
|
||||
})
|
||||
)
|
||||
expect(persistPending).toHaveBeenCalledWith({
|
||||
operationId: 'op-1',
|
||||
requestedAgent: 'claude',
|
||||
priorFailureId: 'prev-fail'
|
||||
})
|
||||
const pending = persistPending.mock.calls[0][0]
|
||||
expect(Object.keys(pending).sort()).toEqual(['operationId', 'priorFailureId', 'requestedAgent'])
|
||||
})
|
||||
|
||||
it('records a durable failure and spawns zero PTYs when execute fails', async () => {
|
||||
const log: CallLog = []
|
||||
const failure: AgentLaunchFailure = {
|
||||
code: 'agent_configuration_changed',
|
||||
requestedAgent: 'claude'
|
||||
}
|
||||
const { deps, operationStore, persistFailure } = makeDeps({ log })
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: false, failure }))
|
||||
)
|
||||
expect(log).not.toContain('spawn')
|
||||
expect(log).not.toContain('beginPending')
|
||||
expect(outcome.status).toBe('failed')
|
||||
if (outcome.status === 'failed') {
|
||||
expect(outcome.failure).toMatchObject({
|
||||
code: 'agent_configuration_changed',
|
||||
version: 1,
|
||||
failureId: 'fail-1',
|
||||
intent: 'interactive',
|
||||
occurredAt: 1000
|
||||
})
|
||||
}
|
||||
expect(persistFailure).toHaveBeenCalledTimes(1)
|
||||
expect(operationStore.findSettledByIdempotencyKey('wt-1', 'idem-1')).toMatchObject({
|
||||
status: 'failed',
|
||||
failureId: 'fail-1',
|
||||
terminalId: null
|
||||
})
|
||||
})
|
||||
|
||||
it('settles failed and records a durable failure when the writer throws', async () => {
|
||||
const log: CallLog = []
|
||||
const spawn = vi.fn(async () => {
|
||||
log.push('spawn')
|
||||
throw new Error('pty boom')
|
||||
})
|
||||
const { deps, operationStore, persistFailure } = makeDeps({ log, spawn })
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT }))
|
||||
)
|
||||
// Pending was persisted before the writer, then rolled to a failure.
|
||||
expect(log.indexOf('beginPending')).toBeLessThan(log.indexOf('spawn'))
|
||||
expect(spawn).toHaveBeenCalledTimes(1)
|
||||
expect(log).toContain('settle:failed:tok-1')
|
||||
expect(operationStore.getPending('tok-1')).toBeNull()
|
||||
expect(outcome.status).toBe('failed')
|
||||
if (outcome.status === 'failed') {
|
||||
expect(outcome.failure.code).toBe('spawn_failed')
|
||||
}
|
||||
expect(persistFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('performs no owner-state write on a request error', async () => {
|
||||
const requestError: AgentLaunchRequestError = { code: 'idempotency_conflict' }
|
||||
const { deps, operationStore, persistFailure, persistPending } = makeDeps({})
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: false, requestError }))
|
||||
)
|
||||
expect(outcome).toEqual({ status: 'request_error', requestError })
|
||||
expect(persistFailure).not.toHaveBeenCalled()
|
||||
expect(persistPending).not.toHaveBeenCalled()
|
||||
expect(operationStore.settledForScope('wt-1')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('fails closed and spawns nothing when the admitted snapshot is missing', async () => {
|
||||
const log: CallLog = []
|
||||
const { deps, persistFailure } = makeDeps({ log, snapshot: null })
|
||||
const outcome = await runWorktreeAgentLaunchTransaction(
|
||||
deps,
|
||||
params(async () => ({ ok: true, plan: PLAN, receipt: RECEIPT }))
|
||||
)
|
||||
expect(log).not.toContain('spawn')
|
||||
expect(log).toContain('settle:failed:tok-1')
|
||||
expect(outcome.status).toBe('failed')
|
||||
if (outcome.status === 'failed') {
|
||||
expect(outcome.failure.code).toBe('invalid_launch_snapshot')
|
||||
}
|
||||
expect(persistFailure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
// The created-path transaction for a worktree `agentLaunch` (U4). Given the
|
||||
// stage-2 resolution thunk (executeWorktreeAgentLaunch) and injected persistence/
|
||||
// spawn callbacks, it enforces the plan's ordering guarantees exactly:
|
||||
// 1. resolve+admit (the thunk) — a failure released the reservation already;
|
||||
// 2. persist the public pending metadata AND the private snapshot/token in ONE
|
||||
// synchronous write BEFORE the writer, so a crash mid-spawn still self-
|
||||
// identifies the terminal by token;
|
||||
// 3. spawn exactly ONE PTY from the resolved plan (token travels inside it);
|
||||
// 4. settle — registered clears pending + records `launched`; any post-create
|
||||
// failure keeps the workspace, writes a durable `agentLaunchFailure`, and
|
||||
// records `failed`. No path spawns a substitute blank terminal (I9).
|
||||
// A request error performs no owner-state write. Electron-free and injectable.
|
||||
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
AgentLaunchIntentKind,
|
||||
AgentLaunchReceipt,
|
||||
AgentLaunchRequestError,
|
||||
PersistedAgentLaunchFailure
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import type { AgentLaunchBoundary, ExecuteAgentLaunchResult } from './agent-launch-boundary'
|
||||
import type { AgentLaunchOperationStore } from './agent-launch-operation-store'
|
||||
|
||||
/** Public pending metadata the caller writes onto WorktreeMeta. The private
|
||||
* snapshot/token stay in the operation store and never enter this shape. */
|
||||
export type WorktreePendingAgentLaunch = {
|
||||
operationId: string
|
||||
requestedAgent: TuiAgent
|
||||
priorFailureId?: string
|
||||
}
|
||||
|
||||
/** Creates and registers exactly ONE PTY from the resolved plan. The receipt
|
||||
* carries the launch token (which travels inside the spawn request) plus the
|
||||
* built-in base agent the terminal binds for process/telemetry keying. Must
|
||||
* throw on spawn/registration failure so the reservation settles `failed`; a
|
||||
* returned value means the PTY is registered and names the terminal id. */
|
||||
export type WorktreeLaunchSpawn = (
|
||||
plan: AgentStartupPlan,
|
||||
receipt: AgentLaunchReceipt
|
||||
) => Promise<{ terminalId: string }>
|
||||
|
||||
export type WorktreeAgentLaunchTransactionDeps = {
|
||||
boundary: AgentLaunchBoundary
|
||||
operationStore: AgentLaunchOperationStore
|
||||
/** Public pending metadata write; paired with the private snapshot write in
|
||||
* the same synchronous transaction, before the writer. */
|
||||
persistPending: (pending: WorktreePendingAgentLaunch) => void
|
||||
spawn: WorktreeLaunchSpawn
|
||||
/** Clear the public pending metadata after a registered launch. */
|
||||
clearPublicPending: () => void
|
||||
/** Persist the durable failure onto WorktreeMeta.agentLaunchFailure and clear
|
||||
* any pending metadata. Must be safe to call whether or not pending was
|
||||
* written (execute-stage vs spawn-stage failure). */
|
||||
persistFailure: (failure: PersistedAgentLaunchFailure) => void
|
||||
mintFailureId: () => string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export type WorktreeAgentLaunchTransactionParams = {
|
||||
operationId: string
|
||||
idempotencyKey: string
|
||||
scope: string
|
||||
payloadDigest: string
|
||||
clientMutationId: string | null
|
||||
requestedAgent: TuiAgent
|
||||
intent: AgentLaunchIntentKind
|
||||
priorFailureId?: string
|
||||
/** Stage-2 resolution: re-resolve with authoritative paths + pinned identity,
|
||||
* recheck the digest, and convert the held reservation. Releases the
|
||||
* reservation itself on any failure. */
|
||||
execute: () => Promise<ExecuteAgentLaunchResult>
|
||||
}
|
||||
|
||||
export type WorktreeAgentLaunchOutcome =
|
||||
| { status: 'launched'; receipt: AgentLaunchReceipt; terminalId: string }
|
||||
| { status: 'failed'; failure: PersistedAgentLaunchFailure }
|
||||
| { status: 'request_error'; requestError: AgentLaunchRequestError }
|
||||
|
||||
function persistedFailure(
|
||||
deps: WorktreeAgentLaunchTransactionDeps,
|
||||
params: WorktreeAgentLaunchTransactionParams,
|
||||
failure: AgentLaunchFailure,
|
||||
nowFn: () => number
|
||||
): { status: 'failed'; failure: PersistedAgentLaunchFailure } {
|
||||
const persisted: PersistedAgentLaunchFailure = {
|
||||
...failure,
|
||||
version: 1,
|
||||
failureId: deps.mintFailureId(),
|
||||
intent: params.intent,
|
||||
occurredAt: nowFn()
|
||||
}
|
||||
// Keep the workspace; the durable failure card offers Retry/Choose agent.
|
||||
deps.persistFailure(persisted)
|
||||
deps.operationStore.recordSettled({
|
||||
operationId: params.operationId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
scope: params.scope,
|
||||
payloadDigest: params.payloadDigest,
|
||||
status: 'failed',
|
||||
terminalId: null,
|
||||
failureId: persisted.failureId,
|
||||
settledAt: nowFn()
|
||||
})
|
||||
return { status: 'failed', failure: persisted }
|
||||
}
|
||||
|
||||
/** Run the created-path transaction. The git worktree already exists; a failure
|
||||
* here NEVER rolls it back and NEVER spawns a substitute shell. */
|
||||
export async function runWorktreeAgentLaunchTransaction(
|
||||
deps: WorktreeAgentLaunchTransactionDeps,
|
||||
params: WorktreeAgentLaunchTransactionParams
|
||||
): Promise<WorktreeAgentLaunchOutcome> {
|
||||
const nowFn = deps.now ?? Date.now
|
||||
const execution = await params.execute()
|
||||
if (!execution.ok) {
|
||||
if ('requestError' in execution) {
|
||||
// Request errors perform no owner-state write; the reservation is already
|
||||
// released by execute.
|
||||
return { status: 'request_error', requestError: execution.requestError }
|
||||
}
|
||||
return persistedFailure(deps, params, execution.failure, nowFn)
|
||||
}
|
||||
const { plan, receipt } = execution
|
||||
const snapshot = deps.boundary.pendingSnapshotFor(receipt.launchToken)
|
||||
if (!snapshot) {
|
||||
// The admitted token must carry a private snapshot; a missing one cannot be
|
||||
// attributed, so fail closed rather than spawn an unattributable terminal.
|
||||
deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed')
|
||||
return persistedFailure(
|
||||
deps,
|
||||
params,
|
||||
{
|
||||
code: 'invalid_launch_snapshot',
|
||||
requestedAgent: receipt.requestedAgent,
|
||||
baseAgent: receipt.baseAgent
|
||||
},
|
||||
nowFn
|
||||
)
|
||||
}
|
||||
|
||||
// ONE persistence transaction before the writer: private snapshot/token first,
|
||||
// then the client-safe pending metadata. Both synchronous so no mutation lands
|
||||
// between them and a mid-spawn crash still resolves via the persisted token.
|
||||
deps.operationStore.beginPending({
|
||||
operationId: params.operationId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
scope: params.scope,
|
||||
clientMutationId: params.clientMutationId,
|
||||
payloadDigest: params.payloadDigest,
|
||||
launchToken: receipt.launchToken,
|
||||
intent: params.intent,
|
||||
snapshot
|
||||
})
|
||||
deps.persistPending({
|
||||
operationId: params.operationId,
|
||||
requestedAgent: receipt.requestedAgent,
|
||||
...(params.priorFailureId ? { priorFailureId: params.priorFailureId } : {})
|
||||
})
|
||||
|
||||
let terminalId: string
|
||||
try {
|
||||
const spawned = await deps.spawn(plan, receipt)
|
||||
terminalId = spawned.terminalId
|
||||
} catch {
|
||||
deps.boundary.settleAgentLaunch(receipt.launchToken, 'failed')
|
||||
deps.operationStore.clearPending(receipt.launchToken)
|
||||
return persistedFailure(
|
||||
deps,
|
||||
params,
|
||||
{
|
||||
code: 'spawn_failed',
|
||||
requestedAgent: receipt.requestedAgent,
|
||||
baseAgent: receipt.baseAgent
|
||||
},
|
||||
nowFn
|
||||
)
|
||||
}
|
||||
|
||||
// Registered: move attribution into the boundary's retained handoff, clear the
|
||||
// pending (public + private), and append the settled `launched` ledger entry.
|
||||
deps.boundary.settleAgentLaunch(receipt.launchToken, 'registered')
|
||||
deps.operationStore.clearPending(receipt.launchToken)
|
||||
deps.clearPublicPending()
|
||||
deps.operationStore.recordSettled({
|
||||
operationId: params.operationId,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
scope: params.scope,
|
||||
payloadDigest: params.payloadDigest,
|
||||
status: 'launched',
|
||||
terminalId,
|
||||
failureId: null,
|
||||
settledAt: nowFn()
|
||||
})
|
||||
return { status: 'launched', receipt, terminalId }
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
CustomTuiAgent,
|
||||
CustomTuiAgentId,
|
||||
GlobalSettings,
|
||||
TerminalAgentQuickCommand
|
||||
} from '../../shared/types'
|
||||
import { normalizeAgentCatalog } from '../../shared/custom-tui-agents'
|
||||
import type { AgentReferenceMutation } from '../../shared/agent-reference-snapshot'
|
||||
import { applyAgentReferenceMutation } from './agent-reference-mutations'
|
||||
|
||||
const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd'
|
||||
const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321'
|
||||
|
||||
function customId(base: string, uuid = UUID_A): CustomTuiAgentId {
|
||||
return `custom-agent:${base}:${uuid}` as CustomTuiAgentId
|
||||
}
|
||||
|
||||
function liveAgent(overrides: Partial<CustomTuiAgent> = {}): CustomTuiAgent {
|
||||
return {
|
||||
id: customId('codex'),
|
||||
baseAgent: 'codex',
|
||||
label: 'My Codex',
|
||||
args: '',
|
||||
env: {},
|
||||
syncEnv: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function settingsWith(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
|
||||
return {
|
||||
defaultTuiAgent: 'auto',
|
||||
disabledTuiAgents: [],
|
||||
customTuiAgents: [],
|
||||
deletedCustomTuiAgents: [],
|
||||
agentReferenceRevision: 3,
|
||||
terminalQuickCommands: [],
|
||||
...overrides
|
||||
} as GlobalSettings
|
||||
}
|
||||
|
||||
function apply(settings: GlobalSettings, mutation: AgentReferenceMutation, expected = 3) {
|
||||
const catalog = normalizeAgentCatalog({
|
||||
customTuiAgents: settings.customTuiAgents,
|
||||
deletedCustomTuiAgents: settings.deletedCustomTuiAgents,
|
||||
disabledTuiAgents: settings.disabledTuiAgents,
|
||||
defaultTuiAgent: settings.defaultTuiAgent
|
||||
}).catalog
|
||||
return applyAgentReferenceMutation({
|
||||
settings,
|
||||
request: { expectedReferenceRevision: expected, mutation },
|
||||
currentReferenceRevision: settings.agentReferenceRevision ?? 1,
|
||||
catalog
|
||||
})
|
||||
}
|
||||
|
||||
function agentQuickCommand(
|
||||
overrides: Partial<TerminalAgentQuickCommand> = {}
|
||||
): TerminalAgentQuickCommand {
|
||||
return {
|
||||
id: 'qc-1',
|
||||
label: 'Fix tests',
|
||||
action: 'agent-prompt',
|
||||
agent: 'codex',
|
||||
prompt: 'fix the tests',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('reference revision gating', () => {
|
||||
it('rejects a stale expectedReferenceRevision without writing', () => {
|
||||
const result = apply(settingsWith(), { kind: 'quick-command-delete', id: 'x' }, 2)
|
||||
expect(result).toEqual({ ok: false, code: 'reference_revision_conflict' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('quick-command stale-reference write rule', () => {
|
||||
const stale = customId('codex', UUID_B) // no live definition, no tombstone needed here
|
||||
const storedCommand = agentQuickCommand({ agent: stale })
|
||||
|
||||
it('preserves the exact stored stale reference when resubmitted unchanged', () => {
|
||||
const settings = settingsWith({ terminalQuickCommands: [storedCommand] })
|
||||
const result = apply(settings, {
|
||||
kind: 'quick-command-save',
|
||||
command: { ...storedCommand, label: 'Renamed', agent: stale }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
const saved = result.patch.terminalQuickCommands?.[0] as TerminalAgentQuickCommand
|
||||
expect(saved.label).toBe('Renamed')
|
||||
expect(saved.agent).toBe(stale)
|
||||
expect(result.patch.agentReferenceRevision).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects a changed agent that is not a current enabled live identity', () => {
|
||||
const settings = settingsWith({ terminalQuickCommands: [storedCommand] })
|
||||
const unknown = apply(settings, {
|
||||
kind: 'quick-command-save',
|
||||
command: { ...storedCommand, agent: customId('claude', UUID_A) }
|
||||
})
|
||||
expect(unknown).toMatchObject({
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'quick-command',
|
||||
reason: 'unknown_agent'
|
||||
})
|
||||
|
||||
const live = liveAgent()
|
||||
const disabledSettings = settingsWith({
|
||||
terminalQuickCommands: [storedCommand],
|
||||
customTuiAgents: [live],
|
||||
disabledTuiAgents: [live.id]
|
||||
})
|
||||
const disabled = apply(disabledSettings, {
|
||||
kind: 'quick-command-save',
|
||||
command: { ...storedCommand, agent: live.id }
|
||||
})
|
||||
expect(disabled).toMatchObject({ ok: false, reason: 'disabled_agent' })
|
||||
|
||||
const baseDisabledSettings = settingsWith({
|
||||
terminalQuickCommands: [storedCommand],
|
||||
customTuiAgents: [live],
|
||||
disabledTuiAgents: ['codex']
|
||||
})
|
||||
const baseDisabled = apply(baseDisabledSettings, {
|
||||
kind: 'quick-command-save',
|
||||
command: { ...storedCommand, agent: live.id }
|
||||
})
|
||||
expect(baseDisabled).toMatchObject({ ok: false, reason: 'disabled_agent' })
|
||||
})
|
||||
|
||||
it('accepts a changed agent that is an enabled live identity', () => {
|
||||
const live = liveAgent()
|
||||
const settings = settingsWith({
|
||||
terminalQuickCommands: [storedCommand],
|
||||
customTuiAgents: [live]
|
||||
})
|
||||
const result = apply(settings, {
|
||||
kind: 'quick-command-save',
|
||||
command: { ...storedCommand, agent: live.id }
|
||||
})
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('a new row cannot mint fallback authority from a stale id', () => {
|
||||
// The same stale id that is preserved on its own row is rejected when a
|
||||
// client echoes it into a different/new row.
|
||||
const settings = settingsWith({ terminalQuickCommands: [storedCommand] })
|
||||
const result = apply(settings, {
|
||||
kind: 'quick-command-save',
|
||||
command: agentQuickCommand({ id: 'qc-new', agent: stale })
|
||||
})
|
||||
expect(result).toMatchObject({ ok: false, reason: 'unknown_agent' })
|
||||
})
|
||||
|
||||
it('deletes and reorders without touching agent references', () => {
|
||||
const other = agentQuickCommand({ id: 'qc-2', agent: 'claude' })
|
||||
const settings = settingsWith({ terminalQuickCommands: [storedCommand, other] })
|
||||
const removed = apply(settings, { kind: 'quick-command-delete', id: 'qc-1' })
|
||||
expect(removed.ok).toBe(true)
|
||||
if (!removed.ok) {
|
||||
return
|
||||
}
|
||||
expect(removed.patch.terminalQuickCommands).toEqual([other])
|
||||
|
||||
const reordered = apply(settings, {
|
||||
kind: 'quick-commands-reorder',
|
||||
orderedIds: ['qc-2', 'qc-1']
|
||||
})
|
||||
expect(reordered.ok).toBe(true)
|
||||
if (!reordered.ok) {
|
||||
return
|
||||
}
|
||||
expect(reordered.patch.terminalQuickCommands?.map((command) => command.id)).toEqual([
|
||||
'qc-2',
|
||||
'qc-1'
|
||||
])
|
||||
|
||||
const badReorder = apply(settings, {
|
||||
kind: 'quick-commands-reorder',
|
||||
orderedIds: ['qc-2']
|
||||
})
|
||||
expect(badReorder).toMatchObject({ ok: false, code: 'invalid_reference_field' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('commit-message and source-control field-level rule', () => {
|
||||
const stale = customId('codex', UUID_B)
|
||||
|
||||
it('preserves a stored stale agentId when omitted or resubmitted; clears explicitly', () => {
|
||||
const settings = settingsWith({
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: stale,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
})
|
||||
const omitted = apply(settings, {
|
||||
kind: 'commit-message-update',
|
||||
changes: { enabled: false }
|
||||
})
|
||||
expect(omitted.ok).toBe(true)
|
||||
if (!omitted.ok) {
|
||||
return
|
||||
}
|
||||
expect(omitted.patch.commitMessageAi?.agentId).toBe(stale)
|
||||
expect(omitted.patch.commitMessageAi?.enabled).toBe(false)
|
||||
|
||||
const resubmitted = apply(settings, {
|
||||
kind: 'commit-message-update',
|
||||
changes: { agentId: stale }
|
||||
})
|
||||
expect(resubmitted.ok).toBe(true)
|
||||
|
||||
const cleared = apply(settings, {
|
||||
kind: 'commit-message-update',
|
||||
changes: { agentId: null }
|
||||
})
|
||||
expect(cleared.ok).toBe(true)
|
||||
if (!cleared.ok) {
|
||||
return
|
||||
}
|
||||
expect(cleared.patch.commitMessageAi?.agentId).toBeNull()
|
||||
})
|
||||
|
||||
it('allows the custom-command sentinel and enabled identities; rejects unknown ids', () => {
|
||||
const settings = settingsWith({
|
||||
commitMessageAi: {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customPrompt: '',
|
||||
customAgentCommand: ''
|
||||
}
|
||||
})
|
||||
expect(
|
||||
apply(settings, { kind: 'commit-message-update', changes: { agentId: 'custom' } }).ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
apply(settings, { kind: 'commit-message-update', changes: { agentId: 'claude' } }).ok
|
||||
).toBe(true)
|
||||
expect(
|
||||
apply(settings, { kind: 'commit-message-update', changes: { agentId: stale } })
|
||||
).toMatchObject({ ok: false, reason: 'unknown_agent' })
|
||||
})
|
||||
|
||||
it('applies the row-level rule to source-control action recipes', () => {
|
||||
const live = liveAgent()
|
||||
const settings = settingsWith({
|
||||
customTuiAgents: [live],
|
||||
sourceControlAi: {
|
||||
enabled: true,
|
||||
agentId: null,
|
||||
actions: {
|
||||
'commit-message': { agentId: stale, commandInputTemplate: 'x' }
|
||||
},
|
||||
selectedModelByAgent: {},
|
||||
selectedThinkingByModel: {},
|
||||
customAgentCommand: '',
|
||||
instructionsByOperation: {}
|
||||
} as GlobalSettings['sourceControlAi']
|
||||
})
|
||||
// Unrelated action field saves while the stale row reference is preserved.
|
||||
const preserved = apply(settings, {
|
||||
kind: 'source-control-update',
|
||||
changes: {
|
||||
actions: { 'commit-message': { commandInputTemplate: 'y' } }
|
||||
} as Partial<NonNullable<GlobalSettings['sourceControlAi']>>
|
||||
})
|
||||
expect(preserved.ok).toBe(true)
|
||||
if (!preserved.ok) {
|
||||
return
|
||||
}
|
||||
const action = preserved.patch.sourceControlAi?.actions?.['commit-message'] as {
|
||||
agentId?: unknown
|
||||
commandInputTemplate?: unknown
|
||||
}
|
||||
expect(action.agentId).toBe(stale)
|
||||
expect(action.commandInputTemplate).toBe('y')
|
||||
|
||||
// Changing the row to a live enabled identity works; unknown is rejected.
|
||||
const changed = apply(settings, {
|
||||
kind: 'source-control-update',
|
||||
changes: {
|
||||
actions: { 'commit-message': { agentId: live.id } }
|
||||
} as Partial<NonNullable<GlobalSettings['sourceControlAi']>>
|
||||
})
|
||||
expect(changed.ok).toBe(true)
|
||||
|
||||
const rejected = apply(settings, {
|
||||
kind: 'source-control-update',
|
||||
changes: {
|
||||
actions: { 'commit-message': { agentId: customId('claude', UUID_A) } }
|
||||
} as Partial<NonNullable<GlobalSettings['sourceControlAi']>>
|
||||
})
|
||||
expect(rejected).toMatchObject({ ok: false, owner: 'source-control-recipe' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
// Owner-specific agent-reference mutation engine (terminal quick commands,
|
||||
// commit-message agent choice, Source Control AI settings). Enforces the
|
||||
// field-level stale-reference write rule so unrelated edits save while a proven
|
||||
// stale reference is preserved, and a *changed* agent must be a currently
|
||||
// effectively enabled live identity.
|
||||
|
||||
import type {
|
||||
CommitMessageAiSettings,
|
||||
GlobalSettings,
|
||||
TerminalQuickCommand,
|
||||
TuiAgent
|
||||
} from '../../shared/types'
|
||||
import type { SourceControlAiSettings } from '../../shared/source-control-ai-types'
|
||||
import type { AgentReferenceMutationRequest } from '../../shared/agent-reference-snapshot'
|
||||
import { CUSTOM_AGENT_ID } from '../../shared/commit-message-agent-spec'
|
||||
import { isBuiltInTuiAgent } from '../../shared/tui-agent-config'
|
||||
import { isCustomTuiAgentId, type AgentCatalog } from '../../shared/custom-tui-agents'
|
||||
|
||||
export type AgentReferenceMutationError = {
|
||||
ok: false
|
||||
code:
|
||||
| 'reference_revision_conflict'
|
||||
| 'invalid_agent_reference'
|
||||
| 'invalid_reference_field'
|
||||
| 'agent_reference_payload_too_large'
|
||||
owner?: 'quick-command' | 'commit-message' | 'source-control-recipe'
|
||||
field?: string
|
||||
reason?: 'unknown_agent' | 'disabled_agent' | 'bounds' | 'conflict'
|
||||
}
|
||||
|
||||
export type AgentReferenceMutationApplication =
|
||||
| {
|
||||
ok: true
|
||||
patch: Partial<GlobalSettings>
|
||||
newReferenceRevision: number
|
||||
}
|
||||
| AgentReferenceMutationError
|
||||
|
||||
/** A changed agent reference must resolve to a currently effectively enabled
|
||||
* live identity: enabled built-in, or live custom whose own id and base are
|
||||
* both enabled. Stale/tombstoned ids never enter through a *change*. */
|
||||
function isEffectivelyEnabledLiveIdentity(agent: TuiAgent, catalog: AgentCatalog): boolean {
|
||||
if (isBuiltInTuiAgent(agent)) {
|
||||
return !catalog.disabledAgents.has(agent)
|
||||
}
|
||||
if (!isCustomTuiAgentId(agent)) {
|
||||
return false
|
||||
}
|
||||
const definition = catalog.liveById.get(agent)
|
||||
if (!definition) {
|
||||
return false
|
||||
}
|
||||
return !catalog.disabledAgents.has(agent) && !catalog.disabledAgents.has(definition.baseAgent)
|
||||
}
|
||||
|
||||
type AgentFieldDecision =
|
||||
| { ok: true; value: TuiAgent | typeof CUSTOM_AGENT_ID | null | undefined }
|
||||
| { ok: false; reason: 'unknown_agent' | 'disabled_agent' }
|
||||
|
||||
/** Field-level rule: undefined preserves stored; the exact stored value (even a
|
||||
* stale custom id) is a no-op; null clears; anything else must be enabled+live
|
||||
* (or the commit-message 'custom' sentinel where allowed). */
|
||||
function decideAgentField(args: {
|
||||
incoming: unknown
|
||||
stored: unknown
|
||||
catalog: AgentCatalog
|
||||
allowCustomSentinel: boolean
|
||||
}): AgentFieldDecision {
|
||||
const { incoming, stored, catalog, allowCustomSentinel } = args
|
||||
if (incoming === undefined) {
|
||||
return { ok: true, value: undefined }
|
||||
}
|
||||
if (incoming === null) {
|
||||
return { ok: true, value: null }
|
||||
}
|
||||
if (incoming === stored) {
|
||||
return { ok: true, value: stored as TuiAgent }
|
||||
}
|
||||
if (allowCustomSentinel && incoming === CUSTOM_AGENT_ID) {
|
||||
return { ok: true, value: CUSTOM_AGENT_ID }
|
||||
}
|
||||
if (typeof incoming !== 'string') {
|
||||
return { ok: false, reason: 'unknown_agent' }
|
||||
}
|
||||
if (isBuiltInTuiAgent(incoming)) {
|
||||
return catalog.disabledAgents.has(incoming)
|
||||
? { ok: false, reason: 'disabled_agent' }
|
||||
: { ok: true, value: incoming }
|
||||
}
|
||||
if (isCustomTuiAgentId(incoming)) {
|
||||
if (!catalog.liveById.has(incoming)) {
|
||||
return { ok: false, reason: 'unknown_agent' }
|
||||
}
|
||||
return isEffectivelyEnabledLiveIdentity(incoming, catalog)
|
||||
? { ok: true, value: incoming }
|
||||
: { ok: false, reason: 'disabled_agent' }
|
||||
}
|
||||
return { ok: false, reason: 'unknown_agent' }
|
||||
}
|
||||
|
||||
export type ApplyAgentReferenceMutationArgs = {
|
||||
settings: GlobalSettings
|
||||
request: AgentReferenceMutationRequest
|
||||
currentReferenceRevision: number
|
||||
catalog: AgentCatalog
|
||||
}
|
||||
|
||||
export function applyAgentReferenceMutation(
|
||||
args: ApplyAgentReferenceMutationArgs
|
||||
): AgentReferenceMutationApplication {
|
||||
const { settings, request, currentReferenceRevision, catalog } = args
|
||||
if (request.expectedReferenceRevision !== currentReferenceRevision) {
|
||||
return { ok: false, code: 'reference_revision_conflict' }
|
||||
}
|
||||
const newReferenceRevision = currentReferenceRevision + 1
|
||||
const mutation = request.mutation
|
||||
|
||||
switch (mutation.kind) {
|
||||
case 'quick-command-save': {
|
||||
const incoming = mutation.command
|
||||
if (
|
||||
!incoming ||
|
||||
typeof incoming !== 'object' ||
|
||||
typeof incoming.id !== 'string' ||
|
||||
incoming.id.length === 0 ||
|
||||
typeof incoming.label !== 'string'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_reference_field',
|
||||
owner: 'quick-command',
|
||||
reason: 'bounds'
|
||||
}
|
||||
}
|
||||
const commands = settings.terminalQuickCommands ?? []
|
||||
const existing = commands.find((command) => command.id === incoming.id)
|
||||
let toStore: TerminalQuickCommand = incoming
|
||||
if (incoming.action === 'agent-prompt') {
|
||||
const storedAgent =
|
||||
existing && existing.action === 'agent-prompt' ? existing.agent : undefined
|
||||
const decision = decideAgentField({
|
||||
incoming: incoming.agent,
|
||||
stored: storedAgent,
|
||||
catalog,
|
||||
allowCustomSentinel: false
|
||||
})
|
||||
if (!decision.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'quick-command',
|
||||
field: 'agent',
|
||||
reason: decision.reason
|
||||
}
|
||||
}
|
||||
// An agent-prompt quick command cannot exist without an agent: an
|
||||
// omitted field keeps the stored reference; there is nothing to clear to.
|
||||
const agent = decision.value === undefined ? storedAgent : decision.value
|
||||
if (agent === null || agent === undefined || agent === CUSTOM_AGENT_ID) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'quick-command',
|
||||
field: 'agent',
|
||||
reason: 'unknown_agent'
|
||||
}
|
||||
}
|
||||
toStore = { ...incoming, agent }
|
||||
}
|
||||
const next = existing
|
||||
? commands.map((command) => (command.id === incoming.id ? toStore : command))
|
||||
: [...commands, toStore]
|
||||
return {
|
||||
ok: true,
|
||||
patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision },
|
||||
newReferenceRevision
|
||||
}
|
||||
}
|
||||
case 'quick-command-delete': {
|
||||
const commands = settings.terminalQuickCommands ?? []
|
||||
const next = commands.filter((command) => command.id !== mutation.id)
|
||||
return {
|
||||
ok: true,
|
||||
patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision },
|
||||
newReferenceRevision
|
||||
}
|
||||
}
|
||||
case 'quick-commands-reorder': {
|
||||
const commands = settings.terminalQuickCommands ?? []
|
||||
const byId = new Map(commands.map((command) => [command.id, command]))
|
||||
if (
|
||||
mutation.orderedIds.length !== commands.length ||
|
||||
mutation.orderedIds.some((id) => !byId.has(id)) ||
|
||||
new Set(mutation.orderedIds).size !== mutation.orderedIds.length
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_reference_field',
|
||||
owner: 'quick-command',
|
||||
reason: 'conflict'
|
||||
}
|
||||
}
|
||||
const next = mutation.orderedIds.map((id) => byId.get(id) as TerminalQuickCommand)
|
||||
return {
|
||||
ok: true,
|
||||
patch: { terminalQuickCommands: next, agentReferenceRevision: newReferenceRevision },
|
||||
newReferenceRevision
|
||||
}
|
||||
}
|
||||
case 'commit-message-update': {
|
||||
const stored = settings.commitMessageAi
|
||||
const decision = decideAgentField({
|
||||
incoming: 'agentId' in mutation.changes ? mutation.changes.agentId : undefined,
|
||||
stored: stored?.agentId ?? null,
|
||||
catalog,
|
||||
allowCustomSentinel: true
|
||||
})
|
||||
if (!decision.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'commit-message',
|
||||
field: 'agentId',
|
||||
reason: decision.reason
|
||||
}
|
||||
}
|
||||
const next: CommitMessageAiSettings = {
|
||||
...(stored as CommitMessageAiSettings),
|
||||
...mutation.changes,
|
||||
agentId:
|
||||
decision.value === undefined
|
||||
? (stored?.agentId ?? null)
|
||||
: (decision.value as CommitMessageAiSettings['agentId'])
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
patch: { commitMessageAi: next, agentReferenceRevision: newReferenceRevision },
|
||||
newReferenceRevision
|
||||
}
|
||||
}
|
||||
case 'source-control-update': {
|
||||
const stored = settings.sourceControlAi
|
||||
const decision = decideAgentField({
|
||||
incoming: 'agentId' in mutation.changes ? mutation.changes.agentId : undefined,
|
||||
stored: stored?.agentId ?? null,
|
||||
catalog,
|
||||
allowCustomSentinel: true
|
||||
})
|
||||
if (!decision.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'source-control-recipe',
|
||||
field: 'agentId',
|
||||
reason: decision.reason
|
||||
}
|
||||
}
|
||||
// Per-action recipes apply the same field-level rule row by row.
|
||||
let nextActions = stored?.actions
|
||||
if (mutation.changes.actions !== undefined) {
|
||||
const incomingActions = mutation.changes.actions ?? {}
|
||||
const merged: NonNullable<SourceControlAiSettings['actions']> = {
|
||||
...stored?.actions
|
||||
}
|
||||
for (const [actionId, incomingAction] of Object.entries(incomingActions)) {
|
||||
const storedAction = stored?.actions?.[actionId as keyof typeof merged]
|
||||
if (incomingAction === undefined) {
|
||||
continue
|
||||
}
|
||||
const storedAgent =
|
||||
storedAction && typeof storedAction === 'object' && 'agentId' in storedAction
|
||||
? (storedAction as { agentId?: unknown }).agentId
|
||||
: undefined
|
||||
const incomingAgent =
|
||||
incomingAction && typeof incomingAction === 'object' && 'agentId' in incomingAction
|
||||
? (incomingAction as { agentId?: unknown }).agentId
|
||||
: undefined
|
||||
const actionDecision = decideAgentField({
|
||||
incoming: incomingAgent,
|
||||
stored: storedAgent ?? null,
|
||||
catalog,
|
||||
allowCustomSentinel: true
|
||||
})
|
||||
if (!actionDecision.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'invalid_agent_reference',
|
||||
owner: 'source-control-recipe',
|
||||
field: actionId,
|
||||
reason: actionDecision.reason
|
||||
}
|
||||
}
|
||||
merged[actionId as keyof typeof merged] = {
|
||||
...(storedAction as object),
|
||||
...(incomingAction as object),
|
||||
agentId:
|
||||
actionDecision.value === undefined
|
||||
? ((storedAgent ?? null) as TuiAgent | 'custom' | null)
|
||||
: (actionDecision.value as TuiAgent | 'custom' | null)
|
||||
} as NonNullable<SourceControlAiSettings['actions']>[keyof typeof merged]
|
||||
}
|
||||
nextActions = merged
|
||||
}
|
||||
const next: SourceControlAiSettings = {
|
||||
...(stored as SourceControlAiSettings),
|
||||
...mutation.changes,
|
||||
agentId:
|
||||
decision.value === undefined
|
||||
? (stored?.agentId ?? null)
|
||||
: (decision.value as SourceControlAiSettings['agentId']),
|
||||
...(nextActions !== undefined ? { actions: nextActions } : {})
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
patch: { sourceControlAi: next, agentReferenceRevision: newReferenceRevision },
|
||||
newReferenceRevision
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// U5: the shared spawn-success registration helper stages the admitted snapshot
|
||||
// (read host-private from the boundary, never the client receipt) keyed by launch
|
||||
// token, and no-ops when the snapshot is gone or the worktree id is empty.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
import { registerHostSessionLaunch } from './agent-session-launch-registration'
|
||||
import type { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'custom-agent:claude:reviewer',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Reviewer',
|
||||
mode: 'custom',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RECEIPT: AgentLaunchReceipt = {
|
||||
requestedAgent: 'custom-agent:claude:reviewer',
|
||||
baseAgent: 'claude',
|
||||
notices: [],
|
||||
launchToken: 'token-a',
|
||||
catalogRevision: 1,
|
||||
telemetry: { agentKind: 'claude-code', usedCustomAgent: true }
|
||||
}
|
||||
|
||||
/** A boundary stub exposing the two snapshot accessors the helper reads. `where`
|
||||
* selects whether the snapshot is post-settle (retained) or mid-spawn (pending). */
|
||||
function boundaryWith(
|
||||
snap: AgentLaunchSnapshot | null,
|
||||
where: 'retained' | 'pending' = 'retained'
|
||||
): AgentLaunchBoundary {
|
||||
const hit = (token: string): AgentLaunchSnapshot | null => (token === 'token-a' ? snap : null)
|
||||
return {
|
||||
retainedFor: (token: string) =>
|
||||
where === 'retained' && hit(token) ? { snapshot: hit(token) } : null,
|
||||
pendingSnapshotFor: (token: string) => (where === 'pending' ? hit(token) : null)
|
||||
} as unknown as AgentLaunchBoundary
|
||||
}
|
||||
|
||||
const OWNERSHIP = { worktreeId: 'wt-1', baseAgent: 'claude' as const, providerSessionId: 'sess-1' }
|
||||
|
||||
describe('registerHostSessionLaunch', () => {
|
||||
it('stages the retained snapshot so a later hook bind makes it resumable', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerHostSessionLaunch({
|
||||
boundary: boundaryWith(snapshot()),
|
||||
store,
|
||||
launchToken: 'token-a',
|
||||
worktreeId: 'wt-1',
|
||||
receipt: RECEIPT,
|
||||
paneKey: 'pane-a',
|
||||
terminalId: 'term-a'
|
||||
})
|
||||
// Staged, not yet resumable, until the hook binds a provider session.
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' })
|
||||
const record = store.resolveByOwnershipKey(OWNERSHIP)
|
||||
expect(record?.launchSnapshot).toEqual(snapshot())
|
||||
expect(record?.requestedAgent).toBe('custom-agent:claude:reviewer')
|
||||
})
|
||||
|
||||
it('stages a mid-spawn launch from the pending admission snapshot (pre-settle)', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerHostSessionLaunch({
|
||||
boundary: boundaryWith(snapshot(), 'pending'),
|
||||
store,
|
||||
launchToken: 'token-a',
|
||||
worktreeId: 'wt-1',
|
||||
receipt: RECEIPT
|
||||
})
|
||||
expect(
|
||||
store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' })
|
||||
).not.toBeNull()
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)?.launchSnapshot).toEqual(snapshot())
|
||||
})
|
||||
|
||||
it('no-ops when the admitted snapshot is no longer retained', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerHostSessionLaunch({
|
||||
boundary: boundaryWith(null),
|
||||
store,
|
||||
launchToken: 'token-a',
|
||||
worktreeId: 'wt-1',
|
||||
receipt: RECEIPT
|
||||
})
|
||||
expect(
|
||||
store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('no-ops for an empty worktree id (never resolvable by an ownership key)', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerHostSessionLaunch({
|
||||
boundary: boundaryWith(snapshot()),
|
||||
store,
|
||||
launchToken: 'token-a',
|
||||
worktreeId: '',
|
||||
receipt: RECEIPT
|
||||
})
|
||||
expect(
|
||||
store.bindProviderSessionByToken('token-a', { key: 'session_id', id: 'sess-1' })
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// Shared spawn-success registration of a launch's host-private resume attribution
|
||||
// (U5, §577). Every launch surface (desktop pty:spawn, mobile/paired runtime
|
||||
// terminal create, worktree-create agent terminal) calls this right after it
|
||||
// settles its admission token 'registered', so the immutable snapshot + token are
|
||||
// staged in the session record store keyed by launch token. A later provider hook
|
||||
// binds the session and promotes the staging to a durable, resumable record.
|
||||
//
|
||||
// The snapshot is read from the boundary's retained admitted record — it is
|
||||
// host-private and never travels on the client receipt.
|
||||
|
||||
import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract'
|
||||
import type { AgentLaunchBoundary } from './agent-launch-boundary'
|
||||
import type { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
|
||||
export type RegisterHostSessionLaunchArgs = {
|
||||
boundary: AgentLaunchBoundary
|
||||
store: AgentSessionRecordStore
|
||||
launchToken: string
|
||||
worktreeId: string
|
||||
receipt: AgentLaunchReceipt
|
||||
/** Optional attribution metadata: a stable pane key lets a pane teardown drop
|
||||
* unbound staging. Surfaces without one omit it; the token drives bind. */
|
||||
paneKey?: string
|
||||
terminalId?: string
|
||||
}
|
||||
|
||||
/** Stage the resume attribution for a freshly launched agent. Works whether the
|
||||
* caller has already settled the admission token 'registered' (retained record)
|
||||
* or is still mid-spawn (pending admission snapshot). A no-op when the admitted
|
||||
* snapshot is gone (e.g. the launch was never admitted) or the worktree id is
|
||||
* empty — a record with no worktree could never be resolved by an ownership key. */
|
||||
export function registerHostSessionLaunch(args: RegisterHostSessionLaunchArgs): void {
|
||||
if (!args.worktreeId) {
|
||||
return
|
||||
}
|
||||
const launchSnapshot =
|
||||
args.boundary.retainedFor(args.launchToken)?.snapshot ??
|
||||
args.boundary.pendingSnapshotFor(args.launchToken)
|
||||
if (!launchSnapshot) {
|
||||
return
|
||||
}
|
||||
args.store.register({
|
||||
...(args.paneKey ? { paneKey: args.paneKey } : {}),
|
||||
...(args.terminalId ? { terminalId: args.terminalId } : {}),
|
||||
worktreeId: args.worktreeId,
|
||||
requestedAgent: args.receipt.requestedAgent,
|
||||
baseAgent: args.receipt.baseAgent,
|
||||
launchSnapshot,
|
||||
launchToken: args.launchToken
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Host-wide singleton session record store. One instance per host so every launch
|
||||
// surface registers attribution and every resume/fork resolves against the same
|
||||
// private records. Durable persistence attaches at boot; the in-memory instance
|
||||
// backs registration/bind/resolve before that.
|
||||
|
||||
import { AgentSessionRecordStore } from './agent-session-record-store'
|
||||
|
||||
let store: AgentSessionRecordStore | null = null
|
||||
|
||||
export function getHostAgentSessionRecordStore(): AgentSessionRecordStore {
|
||||
if (!store) {
|
||||
store = new AgentSessionRecordStore()
|
||||
}
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
|
||||
// The module imports `safeStorage` at top for its Electron cipher factory; these
|
||||
// tests inject their own cipher, so a bare stub keeps the import resolvable.
|
||||
vi.mock('electron', () => ({
|
||||
safeStorage: {
|
||||
isEncryptionAvailable: () => false,
|
||||
encryptString: (value: string) => Buffer.from(value, 'utf-8'),
|
||||
decryptString: (value: Buffer) => value.toString('utf-8')
|
||||
}
|
||||
}))
|
||||
|
||||
import type { HostSessionLaunchRecord } from './agent-session-record-store'
|
||||
import {
|
||||
agentSessionRecordStorePath,
|
||||
decodeAgentSessionRecordStore,
|
||||
encodeAgentSessionRecordStore,
|
||||
loadAgentSessionRecordStoreState,
|
||||
writeAgentSessionRecordStoreState,
|
||||
type AgentSessionRecordCipher
|
||||
} from './agent-session-record-store-persistence'
|
||||
|
||||
function reversibleCipher(available: boolean): AgentSessionRecordCipher {
|
||||
return {
|
||||
available: () => available,
|
||||
encrypt: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
|
||||
decrypt: (ciphertext) => ciphertext.toString('utf-8').replace(/^enc:/, '')
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot: AgentLaunchSnapshot = {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: { SECRET_TOKEN: 'do-not-leak' },
|
||||
capturedEnvPolicy: 'full',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
}
|
||||
|
||||
const record: HostSessionLaunchRecord = {
|
||||
worktreeId: 'wt-1',
|
||||
requestedAgent: 'custom-agent:claude:reviewer',
|
||||
baseAgent: 'claude',
|
||||
providerSession: { key: 'session_id', id: 'sess-1' },
|
||||
launchSnapshot: snapshot,
|
||||
launchToken: 'secret-token',
|
||||
registeredAt: 1,
|
||||
updatedAt: 2
|
||||
}
|
||||
|
||||
describe('agent-session-record-store persistence envelope', () => {
|
||||
it('encrypts the records section and round-trips through decode', () => {
|
||||
const cipher = reversibleCipher(true)
|
||||
const encoded = encodeAgentSessionRecordStore({ records: [record] }, cipher)
|
||||
expect(encoded.records.format).toBe('electron-safe-storage-v1')
|
||||
const decoded = decodeAgentSessionRecordStore(encoded, cipher)
|
||||
expect(decoded.records).toEqual([record])
|
||||
})
|
||||
|
||||
it('falls back to hardened plaintext when encryption is unavailable', () => {
|
||||
const cipher = reversibleCipher(false)
|
||||
const encoded = encodeAgentSessionRecordStore({ records: [record] }, cipher)
|
||||
expect(encoded.records.format).toBe('plaintext-v1')
|
||||
expect(decodeAgentSessionRecordStore(encoded, cipher).records).toEqual([record])
|
||||
})
|
||||
|
||||
it('drops records rather than blocking boot when the cipher is unavailable at decode', () => {
|
||||
const encoded = encodeAgentSessionRecordStore({ records: [record] }, reversibleCipher(true))
|
||||
// Keychain reset: encrypted section can no longer be read.
|
||||
expect(decodeAgentSessionRecordStore(encoded, reversibleCipher(false)).records).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty state for an unknown version', () => {
|
||||
expect(decodeAgentSessionRecordStore({ version: 9 }, reversibleCipher(true))).toEqual({
|
||||
records: []
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-session-record-store persistence file I/O', () => {
|
||||
let dir: string
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'agent-session-records-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('writes an encrypted file whose bytes do not contain the plaintext secret', () => {
|
||||
const path = agentSessionRecordStorePath(dir)
|
||||
const cipher = reversibleCipher(true)
|
||||
writeAgentSessionRecordStoreState(path, { records: [record] }, cipher)
|
||||
const raw = readFileSync(path, 'utf-8')
|
||||
expect(raw).not.toContain('do-not-leak')
|
||||
expect(raw).not.toContain('secret-token')
|
||||
expect(loadAgentSessionRecordStoreState(path, cipher).records).toEqual([record])
|
||||
})
|
||||
|
||||
it('returns empty state when the file is absent', () => {
|
||||
expect(
|
||||
loadAgentSessionRecordStoreState(agentSessionRecordStorePath(dir), reversibleCipher(true))
|
||||
).toEqual({ records: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
// Host-private durable persistence for the session record store (U5). Records
|
||||
// carry the immutable launch snapshot (resolved argv + admitted agent env) and,
|
||||
// for legacy handoffs, the opaque replay config — both secret-bearing — plus the
|
||||
// launch token, so the whole record set is encrypted at rest via Electron
|
||||
// safeStorage (the secret-settings standard), with a permission-hardened plaintext
|
||||
// fallback only when OS-backed encryption is unavailable. Written with the same
|
||||
// atomic tmp+rename discipline as the launch-operation store. The encode/decode
|
||||
// core takes an injected cipher so the envelope round-trip is testable without
|
||||
// Electron. This file is never client-synced.
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { safeStorage } from 'electron'
|
||||
import { hardenExistingSecureFile, writeSecureJsonFile } from '../../shared/secure-file'
|
||||
import type {
|
||||
AgentSessionRecordStoreDurableState,
|
||||
HostSessionLaunchRecord
|
||||
} from './agent-session-record-store'
|
||||
import { getHostAgentSessionRecordStore } from './agent-session-record-store-host'
|
||||
|
||||
const STORE_FILENAME = 'agent-session-records.json'
|
||||
|
||||
export function agentSessionRecordStorePath(userDataPath: string): string {
|
||||
return join(userDataPath, STORE_FILENAME)
|
||||
}
|
||||
|
||||
/** Crypto boundary for the encrypted records section. Injected so the envelope
|
||||
* round-trip is unit-testable without an Electron/OS keychain. */
|
||||
export type AgentSessionRecordCipher = {
|
||||
available: () => boolean
|
||||
encrypt: (plaintext: string) => Buffer
|
||||
decrypt: (ciphertext: Buffer) => string
|
||||
}
|
||||
|
||||
export function electronSafeStorageCipher(): AgentSessionRecordCipher {
|
||||
return {
|
||||
available: () => safeStorage.isEncryptionAvailable(),
|
||||
encrypt: (plaintext) => safeStorage.encryptString(plaintext),
|
||||
decrypt: (ciphertext) => safeStorage.decryptString(ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
type PersistedRecordsSection =
|
||||
| { format: 'electron-safe-storage-v1'; ciphertext: string }
|
||||
| { format: 'plaintext-v1'; records: HostSessionLaunchRecord[] }
|
||||
|
||||
type PersistedFile = {
|
||||
version: 1
|
||||
records: PersistedRecordsSection
|
||||
}
|
||||
|
||||
export function encodeAgentSessionRecordStore(
|
||||
state: AgentSessionRecordStoreDurableState,
|
||||
cipher: AgentSessionRecordCipher
|
||||
): PersistedFile {
|
||||
const records = [...state.records]
|
||||
const section: PersistedRecordsSection = cipher.available()
|
||||
? {
|
||||
format: 'electron-safe-storage-v1',
|
||||
ciphertext: cipher.encrypt(JSON.stringify(records)).toString('base64')
|
||||
}
|
||||
: { format: 'plaintext-v1', records }
|
||||
return { version: 1, records: section }
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function decodeRecords(
|
||||
section: unknown,
|
||||
cipher: AgentSessionRecordCipher
|
||||
): HostSessionLaunchRecord[] {
|
||||
if (!isRecord(section)) {
|
||||
return []
|
||||
}
|
||||
if (section.format === 'plaintext-v1' && Array.isArray(section.records)) {
|
||||
return section.records as HostSessionLaunchRecord[]
|
||||
}
|
||||
if (
|
||||
section.format === 'electron-safe-storage-v1' &&
|
||||
typeof section.ciphertext === 'string' &&
|
||||
cipher.available()
|
||||
) {
|
||||
// A decrypt failure (keychain reset) drops only the records, never blocks
|
||||
// boot: those sessions then require an explicit current-settings relaunch
|
||||
// rather than a mis-attributed replay.
|
||||
const parsed = JSON.parse(cipher.decrypt(Buffer.from(section.ciphertext, 'base64')))
|
||||
return Array.isArray(parsed) ? (parsed as HostSessionLaunchRecord[]) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function decodeAgentSessionRecordStore(
|
||||
raw: unknown,
|
||||
cipher: AgentSessionRecordCipher
|
||||
): AgentSessionRecordStoreDurableState {
|
||||
if (!isRecord(raw) || raw.version !== 1) {
|
||||
return { records: [] }
|
||||
}
|
||||
try {
|
||||
return { records: decodeRecords(raw.records, cipher) }
|
||||
} catch {
|
||||
return { records: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAgentSessionRecordStoreState(
|
||||
path: string,
|
||||
cipher: AgentSessionRecordCipher
|
||||
): AgentSessionRecordStoreDurableState {
|
||||
if (!existsSync(path)) {
|
||||
return { records: [] }
|
||||
}
|
||||
try {
|
||||
hardenExistingSecureFile(path)
|
||||
return decodeAgentSessionRecordStore(JSON.parse(readFileSync(path, 'utf-8')), cipher)
|
||||
} catch {
|
||||
// A corrupt store must never block boot; start empty and let live sessions
|
||||
// rebind on their next hook.
|
||||
return { records: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export function writeAgentSessionRecordStoreState(
|
||||
path: string,
|
||||
state: AgentSessionRecordStoreDurableState,
|
||||
cipher: AgentSessionRecordCipher
|
||||
): void {
|
||||
writeSecureJsonFile(path, encodeAgentSessionRecordStore(state, cipher))
|
||||
}
|
||||
|
||||
/** Boot-time wiring: rehydrate durable records, then attach the write-back sink so
|
||||
* every later bind/ingest/forget is persisted. Called once from main-process
|
||||
* startup after the user data dir is stable. */
|
||||
export function initHostAgentSessionRecordStorePersistence(userDataPath: string): void {
|
||||
const path = agentSessionRecordStorePath(userDataPath)
|
||||
const cipher = electronSafeStorageCipher()
|
||||
const state = loadAgentSessionRecordStoreState(path, cipher)
|
||||
const store = getHostAgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom(state.records)
|
||||
store.setDurablePersistence((next) => {
|
||||
try {
|
||||
writeAgentSessionRecordStoreState(path, next, cipher)
|
||||
} catch {
|
||||
// A failed persist must not break an in-flight bind; the in-memory store
|
||||
// stays authoritative and the next mutation retries the write.
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
AgentSessionRecordStore,
|
||||
type HostSessionLaunchRecord,
|
||||
type StagedLaunchRegistration
|
||||
} from './agent-session-record-store'
|
||||
|
||||
const CUSTOM_CODEX_ID = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as const
|
||||
|
||||
function snapshot(overrides: Partial<AgentLaunchSnapshot> = {}): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
displayLabel: 'Original Codex',
|
||||
mode: 'custom',
|
||||
argv: ['codex', '--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function record(overrides: Partial<HostSessionLaunchRecord> = {}): HostSessionLaunchRecord {
|
||||
return {
|
||||
worktreeId: 'wt-source',
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
providerSession: {
|
||||
key: 'session_id',
|
||||
id: 'provider-session',
|
||||
transcriptPath: '/home/me/.codex/sessions/transcript.jsonl'
|
||||
},
|
||||
launchSnapshot: snapshot(),
|
||||
registeredAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(
|
||||
store: AgentSessionRecordStore,
|
||||
overrides: Partial<Parameters<AgentSessionRecordStore['resolveVaultSnapshotOwner']>[0]> = {}
|
||||
) {
|
||||
return store.resolveVaultSnapshotOwner({
|
||||
baseAgent: 'codex',
|
||||
scannedProviderSessionId: 'provider-session',
|
||||
scannedTranscriptPath: '/home/me/.codex/sessions/transcript.jsonl',
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux',
|
||||
preferredWorktreeId: 'wt-destination',
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('AgentSessionRecordStore Vault correlation', () => {
|
||||
it('uses a strong transcript match even when scanned and hook ids differ', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([record()])
|
||||
expect(resolve(store, { scannedProviderSessionId: 'scanner-id' })).toEqual({
|
||||
kind: 'found',
|
||||
sessionKey: {
|
||||
worktreeId: 'wt-source',
|
||||
baseAgent: 'codex',
|
||||
providerSessionId: 'provider-session'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('excludes a repeated provider id with a known different transcript', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([record()])
|
||||
expect(
|
||||
resolve(store, { scannedTranscriptPath: '/home/me/.codex/sessions/other.jsonl' })
|
||||
).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('prefers the destination worktree and otherwise refuses ambiguous owners', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
record({
|
||||
worktreeId: 'wt-destination',
|
||||
providerSession: { key: 'session_id', id: 'provider-session' }
|
||||
}),
|
||||
record({
|
||||
worktreeId: 'wt-other',
|
||||
providerSession: { key: 'session_id', id: 'provider-session' }
|
||||
})
|
||||
])
|
||||
expect(resolve(store, { scannedTranscriptPath: null })).toMatchObject({
|
||||
kind: 'found',
|
||||
sessionKey: { worktreeId: 'wt-destination' }
|
||||
})
|
||||
expect(resolve(store, { scannedTranscriptPath: null, preferredWorktreeId: 'wt-none' })).toEqual(
|
||||
{ kind: 'ambiguous' }
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a sole cross-worktree owner', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
record({ providerSession: { key: 'session_id', id: 'provider-session' } })
|
||||
])
|
||||
expect(resolve(store, { scannedTranscriptPath: null })).toMatchObject({
|
||||
kind: 'found',
|
||||
sessionKey: { worktreeId: 'wt-source' }
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a WSL UNC scan path to the hook-reported POSIX transcript', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
record({
|
||||
launchSnapshot: snapshot({
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'wsl',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'wsl:Ubuntu'
|
||||
}
|
||||
})
|
||||
})
|
||||
])
|
||||
expect(
|
||||
resolve(store, {
|
||||
scannedProviderSessionId: 'different-scanner-id',
|
||||
scannedTranscriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\transcript.jsonl',
|
||||
targetExecutionHostId: 'wsl:Ubuntu'
|
||||
})
|
||||
).toMatchObject({ kind: 'found' })
|
||||
expect(
|
||||
resolve(store, {
|
||||
scannedTranscriptPath: '\\\\wsl$\\Debian\\home\\me\\.codex\\sessions\\transcript.jsonl',
|
||||
targetExecutionHostId: 'wsl:Ubuntu'
|
||||
})
|
||||
).toEqual({ kind: 'missing' })
|
||||
|
||||
expect(
|
||||
store.resolveVaultSnapshotArguments({
|
||||
baseAgent: 'codex',
|
||||
scannedProviderSessionId: 'different-scanner-id',
|
||||
scannedTranscriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\transcript.jsonl',
|
||||
scannedExecutionHostId: 'local'
|
||||
})
|
||||
).toEqual(['--model', 'gpt-5.6-Sol', '-c', 'model_reasoning_effort=medium'])
|
||||
})
|
||||
|
||||
it('skips snapshotless, base-mismatched, and target-mismatched records', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
record({ launchSnapshot: undefined }),
|
||||
record({
|
||||
worktreeId: 'wt-base-mismatch',
|
||||
launchSnapshot: snapshot({ baseAgent: 'claude' })
|
||||
}),
|
||||
record({
|
||||
worktreeId: 'wt-other-target',
|
||||
launchSnapshot: snapshot({
|
||||
target: { ...snapshot().target, executionHostId: 'ssh:box', isRemote: true }
|
||||
})
|
||||
})
|
||||
])
|
||||
expect(resolve(store)).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('skips corrupt snapshot and provider metadata while retaining no replay authority', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.rebuildRecordsFrom([
|
||||
record({
|
||||
worktreeId: 'wt-bad-env',
|
||||
launchSnapshot: snapshot({ agentEnv: [] as unknown as Record<string, string> })
|
||||
}),
|
||||
record({
|
||||
worktreeId: 'wt-bad-provider-key',
|
||||
providerSession: { key: 'conversation_id', id: 'provider-session' }
|
||||
})
|
||||
])
|
||||
expect(resolve(store)).toEqual({ kind: 'missing' })
|
||||
})
|
||||
|
||||
it('updates indexes on overwrite, stale rollback, forget, and rehydrate', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const registration = (launchToken: string): Omit<StagedLaunchRegistration, 'registeredAt'> => ({
|
||||
worktreeId: 'wt-source',
|
||||
requestedAgent: CUSTOM_CODEX_ID,
|
||||
baseAgent: 'codex',
|
||||
launchSnapshot: snapshot(),
|
||||
launchToken,
|
||||
paneKey: launchToken,
|
||||
terminalId: launchToken
|
||||
})
|
||||
store.register(registration('old-token'))
|
||||
store.bindProviderSessionByToken('old-token', {
|
||||
key: 'session_id',
|
||||
id: 'provider-session',
|
||||
transcriptPath: '/old.jsonl'
|
||||
})
|
||||
store.register(registration('new-token'))
|
||||
store.bindProviderSessionByToken('new-token', {
|
||||
key: 'session_id',
|
||||
id: 'provider-session',
|
||||
transcriptPath: '/new.jsonl'
|
||||
})
|
||||
store.rollbackByToken('old-token')
|
||||
expect(resolve(store, { scannedTranscriptPath: '/new.jsonl' })).toMatchObject({ kind: 'found' })
|
||||
expect(resolve(store, { scannedTranscriptPath: '/old.jsonl' })).toEqual({ kind: 'missing' })
|
||||
|
||||
const durable = store.durableState()
|
||||
const rebuilt = new AgentSessionRecordStore()
|
||||
rebuilt.rebuildRecordsFrom(durable.records)
|
||||
expect(resolve(rebuilt, { scannedTranscriptPath: '/new.jsonl' })).toMatchObject({
|
||||
kind: 'found'
|
||||
})
|
||||
expect(
|
||||
rebuilt.forget({
|
||||
worktreeId: 'wt-source',
|
||||
baseAgent: 'codex',
|
||||
providerSessionId: 'provider-session'
|
||||
})
|
||||
).toBe(true)
|
||||
expect(resolve(rebuilt, { scannedTranscriptPath: '/new.jsonl' })).toEqual({ kind: 'missing' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,316 @@
|
||||
// U5: the host-private session record store's lifecycle invariants — spawn-time
|
||||
// staging, provider-session bind (by launch token) → durable resume record,
|
||||
// ownership-key resolution, incompatible/non-resumable bind rejection, spawn-
|
||||
// failure rollback, dispose-keeps-record, and the one-time legacy handoff.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
getAgentSessionOwnershipKey,
|
||||
type AgentProviderSessionMetadata,
|
||||
type AgentSessionOwnershipKey,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import {
|
||||
AgentSessionRecordStore,
|
||||
type AgentSessionRecordStoreDurableState,
|
||||
type StagedLaunchRegistration
|
||||
} from './agent-session-record-store'
|
||||
|
||||
function snapshot(overrides: Partial<AgentLaunchSnapshot> = {}): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
displayLabel: 'Claude',
|
||||
mode: 'built-in',
|
||||
argv: ['claude'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'darwin',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: false,
|
||||
executionHostId: 'local'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION: AgentProviderSessionMetadata = { key: 'session_id', id: 'sess-1' }
|
||||
|
||||
function registration(
|
||||
overrides: Partial<Omit<StagedLaunchRegistration, 'registeredAt'>> = {}
|
||||
): Omit<StagedLaunchRegistration, 'registeredAt'> {
|
||||
return {
|
||||
paneKey: 'pane-a',
|
||||
terminalId: 'term-a',
|
||||
worktreeId: 'wt-1',
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
launchSnapshot: snapshot(),
|
||||
launchToken: 'token-a',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const OWNERSHIP: AgentSessionOwnershipKey = {
|
||||
worktreeId: 'wt-1',
|
||||
baseAgent: 'claude',
|
||||
providerSessionId: 'sess-1'
|
||||
}
|
||||
|
||||
/** Register the default pane and bind its provider session by token. */
|
||||
function registerAndBind(store: AgentSessionRecordStore): void {
|
||||
store.register(registration())
|
||||
store.bindProviderSessionByToken('token-a', SESSION)
|
||||
}
|
||||
|
||||
describe('AgentSessionRecordStore lifecycle', () => {
|
||||
it('a staged registration is not resumable until a provider session binds', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(registration())
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
|
||||
const bound = store.bindProviderSessionByToken('token-a', SESSION)
|
||||
expect(bound).not.toBeNull()
|
||||
const record = store.resolveByOwnershipKey(OWNERSHIP)
|
||||
expect(record?.launchSnapshot).toEqual(snapshot())
|
||||
expect(record?.launchToken).toBe('token-a')
|
||||
expect(record?.requestedAgent).toBe('claude')
|
||||
})
|
||||
|
||||
it('preserves the requested custom identity while keying ownership on the base', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(
|
||||
registration({ requestedAgent: 'custom-agent:claude:reviewer', baseAgent: 'claude' })
|
||||
)
|
||||
store.bindProviderSessionByToken('token-a', SESSION)
|
||||
const record = store.resolveByOwnershipKey(OWNERSHIP)
|
||||
expect(record?.requestedAgent).toBe('custom-agent:claude:reviewer')
|
||||
expect(record?.baseAgent).toBe('claude')
|
||||
})
|
||||
|
||||
it('binding an unknown launch token returns null and stores nothing', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
expect(store.bindProviderSessionByToken('ghost-token', SESSION)).toBeNull()
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects an incompatible provider key type without rewriting the staged identity', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(registration())
|
||||
// Claude keys on session_id; a conversation_id hook is incompatible evidence.
|
||||
const bound = store.bindProviderSessionByToken('token-a', { key: 'conversation_id', id: 'x' })
|
||||
expect(bound).toBeNull()
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
// A later compatible hook still binds the same staged registration.
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).not.toBeNull()
|
||||
})
|
||||
|
||||
it('never binds a non-resumable base', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(registration({ baseAgent: 'cursor' }))
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull()
|
||||
})
|
||||
|
||||
it('a repeated hook for an already-bound launch is a no-op with no extra persist', () => {
|
||||
let persistCalls = 0
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.setDurablePersistence(() => {
|
||||
persistCalls += 1
|
||||
})
|
||||
store.register(registration())
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).not.toBeNull()
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull()
|
||||
expect(persistCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('rollback after bind removes the durable record so a failed spawn strands nothing', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerAndBind(store)
|
||||
store.rollbackByToken('token-a')
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
})
|
||||
|
||||
it('rollback before bind drops the staged registration and its token index', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(registration())
|
||||
store.rollbackByToken('token-a')
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull()
|
||||
})
|
||||
|
||||
it('dispose keeps the durable record so a slept session still resumes', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerAndBind(store)
|
||||
store.disposeStagingForPane('pane-a')
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)?.launchSnapshot).toEqual(snapshot())
|
||||
})
|
||||
|
||||
it('dispose clears an unbound pane staging so a late hook cannot bind a torn-down pane', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
// Registered but never bound (spawn failed / pane closed before the hook).
|
||||
store.register(registration())
|
||||
store.disposeStagingForPane('pane-a')
|
||||
expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull()
|
||||
})
|
||||
|
||||
it('two custom ids on one base/provider session resolve to one owner record', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.register(
|
||||
registration({ requestedAgent: 'custom-agent:claude:a', launchToken: 'token-a' })
|
||||
)
|
||||
store.bindProviderSessionByToken('token-a', SESSION)
|
||||
store.register(
|
||||
registration({
|
||||
paneKey: 'pane-b',
|
||||
terminalId: 'term-b',
|
||||
requestedAgent: 'custom-agent:claude:b',
|
||||
launchToken: 'token-b'
|
||||
})
|
||||
)
|
||||
store.bindProviderSessionByToken('token-b', SESSION)
|
||||
// Same ownership key: the later bind overwrites; still one record.
|
||||
expect(store.durableState().records).toHaveLength(1)
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)?.requestedAgent).toBe('custom-agent:claude:b')
|
||||
})
|
||||
|
||||
it('a fork binds a NEW provider session into its own record and never mutates the source', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
// Source session, bound to sess-1.
|
||||
registerAndBind(store)
|
||||
const source = store.resolveByOwnershipKey(OWNERSHIP)
|
||||
// Fork: its own launch token + a COPY of the source snapshot, but the forked
|
||||
// CLI reports a brand-new provider session id, so it keys a distinct record.
|
||||
store.register(
|
||||
registration({
|
||||
paneKey: 'pane-fork',
|
||||
terminalId: 'term-fork',
|
||||
requestedAgent: 'custom-agent:claude:fork',
|
||||
launchToken: 'token-fork'
|
||||
})
|
||||
)
|
||||
store.bindProviderSessionByToken('token-fork', { key: 'session_id', id: 'sess-2-fork' })
|
||||
// Source record is untouched (same identity, same token — no ownership claim).
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toEqual(source)
|
||||
// The fork owns a separate record under its new provider session id.
|
||||
const forkKey: AgentSessionOwnershipKey = {
|
||||
worktreeId: 'wt-1',
|
||||
baseAgent: 'claude',
|
||||
providerSessionId: 'sess-2-fork'
|
||||
}
|
||||
expect(store.resolveByOwnershipKey(forkKey)?.requestedAgent).toBe('custom-agent:claude:fork')
|
||||
expect(store.durableState().records).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('forget removes the durable record', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerAndBind(store)
|
||||
expect(store.forget(OWNERSHIP)).toBe(true)
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)).toBeNull()
|
||||
expect(store.forget(OWNERSHIP)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentSessionRecordStore legacy handoff', () => {
|
||||
const legacyConfig: SleepingAgentLaunchConfig = {
|
||||
agentArgs: '--resume sess-1',
|
||||
agentEnv: { FOO: 'bar' }
|
||||
}
|
||||
|
||||
it('ingests the legacy config once and keys it by ownership', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const record = store.ingestLegacyRecord({
|
||||
ownershipKey: OWNERSHIP,
|
||||
requestedAgent: 'claude',
|
||||
providerSession: SESSION,
|
||||
legacyLaunchConfig: legacyConfig,
|
||||
connectionId: 'ssh:box'
|
||||
})
|
||||
expect(record.legacyLaunchConfig).toEqual(legacyConfig)
|
||||
expect(record.legacyConnectionId).toBe('ssh:box')
|
||||
expect(record.launchSnapshot).toBeUndefined()
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)?.legacyLaunchConfig).toEqual(legacyConfig)
|
||||
})
|
||||
|
||||
it('never overwrites a host-owned record on a repeated handoff', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
registerAndBind(store)
|
||||
const returned = store.ingestLegacyRecord({
|
||||
ownershipKey: OWNERSHIP,
|
||||
requestedAgent: 'claude',
|
||||
providerSession: SESSION,
|
||||
legacyLaunchConfig: legacyConfig,
|
||||
connectionId: null
|
||||
})
|
||||
// The v1-snapshot record wins; the legacy blob is discarded.
|
||||
expect(returned.launchSnapshot).toEqual(snapshot())
|
||||
expect(returned.legacyLaunchConfig).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentSessionRecordStore durable persistence', () => {
|
||||
it('routes bind/ingest/forget through the sink and rehydrates by ownership key', () => {
|
||||
let persisted: AgentSessionRecordStoreDurableState = { records: [] }
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.setDurablePersistence((state) => {
|
||||
persisted = state
|
||||
})
|
||||
registerAndBind(store)
|
||||
expect(persisted.records).toHaveLength(1)
|
||||
|
||||
const rebuilt = new AgentSessionRecordStore()
|
||||
rebuilt.rebuildRecordsFrom(persisted.records)
|
||||
expect(rebuilt.resolveByOwnershipKey(OWNERSHIP)?.launchToken).toBe('token-a')
|
||||
})
|
||||
|
||||
it('register alone does not persist; only a bound record is durable', () => {
|
||||
let calls = 0
|
||||
const store = new AgentSessionRecordStore()
|
||||
store.setDurablePersistence(() => {
|
||||
calls += 1
|
||||
})
|
||||
store.register(registration())
|
||||
expect(calls).toBe(0)
|
||||
store.bindProviderSessionByToken('token-a', SESSION)
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('rehydrate keys records on the base+session, not the persisted array order', () => {
|
||||
const store = new AgentSessionRecordStore()
|
||||
const other = getAgentSessionOwnershipKey({
|
||||
worktreeId: 'wt-2',
|
||||
baseAgent: 'codex',
|
||||
providerSessionId: 'sess-2'
|
||||
})
|
||||
store.rebuildRecordsFrom([
|
||||
{
|
||||
worktreeId: 'wt-1',
|
||||
requestedAgent: 'claude',
|
||||
baseAgent: 'claude',
|
||||
providerSession: SESSION,
|
||||
launchSnapshot: snapshot(),
|
||||
registeredAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
worktreeId: 'wt-2',
|
||||
requestedAgent: 'codex',
|
||||
baseAgent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sess-2' },
|
||||
launchSnapshot: snapshot({ baseAgent: 'codex', requestedAgent: 'codex' }),
|
||||
registeredAt: 2,
|
||||
updatedAt: 2
|
||||
}
|
||||
])
|
||||
expect(store.resolveByOwnershipKey(OWNERSHIP)?.baseAgent).toBe('claude')
|
||||
expect(
|
||||
store.resolveByOwnershipKey({
|
||||
worktreeId: 'wt-2',
|
||||
baseAgent: 'codex',
|
||||
providerSessionId: 'sess-2'
|
||||
})?.baseAgent
|
||||
).toBe('codex')
|
||||
expect(other).toContain('codex')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
// Host-private launch-attribution + resume record store (U5). Holds the fields a
|
||||
// client record must never carry (ruling D1): the immutable `launchSnapshot`, the
|
||||
// opaque one-release `legacyLaunchConfig`, and the admission launch token. The
|
||||
// runtime/mobile/paired session DTO exposes only requested/base identity, provider
|
||||
// metadata, and notice/failure state; those live in the renderer store, not here.
|
||||
//
|
||||
// Two lifecycle stages, per plan §577/§579:
|
||||
// 1. Registration at spawn time by stable pane + terminal id, BEFORE the PTY can
|
||||
// emit output/hooks. The provider session is not known yet, so the record is
|
||||
// staged and cannot be resumed. Rolled back on spawn failure.
|
||||
// 2. Provider-session bind once a hook reports the session id: the staged record
|
||||
// is promoted to a durable record keyed by the {worktreeId, baseAgent,
|
||||
// providerSessionId} ownership key. A resume/fork request names that key and
|
||||
// the host loads the private record here. The record survives pane dispose so
|
||||
// a slept session still resumes; it is dropped only when explicitly forgotten.
|
||||
//
|
||||
// The store is a pure container: legacy-config validation and Agent Teams env
|
||||
// stripping live in the ingestion/adapter layer, never here.
|
||||
|
||||
import type { TuiAgent, BuiltInTuiAgent } from '../../shared/types'
|
||||
import type {
|
||||
AgentLaunchExecutionHostId,
|
||||
AgentLaunchSnapshot
|
||||
} from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
getAgentSessionOwnershipKey,
|
||||
isResumableTuiAgent,
|
||||
normalizeAgentProviderSession,
|
||||
providerSessionKeyForResumableBase,
|
||||
type AgentProviderSessionMetadata,
|
||||
type AgentSessionOwnershipKey,
|
||||
type ResumableTuiAgent,
|
||||
type SleepingAgentLaunchConfig
|
||||
} from '../../shared/agent-session-resume'
|
||||
import {
|
||||
AgentSessionVaultSnapshotIndex,
|
||||
type VaultSnapshotOwnerResolution
|
||||
} from './agent-session-vault-snapshot-index'
|
||||
|
||||
export type { VaultSnapshotOwnerResolution } from './agent-session-vault-snapshot-index'
|
||||
|
||||
/** A durable resume record, keyed by ownership key once a provider session binds.
|
||||
* `launchSnapshot` (v1 replay authority) and `legacyLaunchConfig` (opaque
|
||||
* one-release replay) are mutually exclusive in practice; a record with neither
|
||||
* resolves current settings at resume (the snapshotless migration window). */
|
||||
export type HostSessionLaunchRecord = {
|
||||
worktreeId: string
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: ResumableTuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
launchSnapshot?: AgentLaunchSnapshot
|
||||
legacyLaunchConfig?: SleepingAgentLaunchConfig
|
||||
/** Recorded execution owner of a legacy record's sleeping pane. Opaque legacy
|
||||
* replay re-checks it against the current spawn's owner on every resume (plan
|
||||
* §573); v1-snapshot records carry provenance in the snapshot target instead. */
|
||||
legacyConnectionId?: string | null
|
||||
launchToken?: string
|
||||
registeredAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** A spawn-time registration before any provider session is known. Keyed by
|
||||
* launch token; rolled back on spawn failure and promoted to a durable record
|
||||
* when the session binds. `baseAgent` may be non-resumable: such launches never
|
||||
* bind a session. `paneKey`/`terminalId` are optional attribution metadata (the
|
||||
* token drives bind/rollback); `paneKey` lets a pane teardown drop unbound
|
||||
* staging, and surfaces without a stable pane key simply omit it. */
|
||||
export type StagedLaunchRegistration = {
|
||||
paneKey?: string
|
||||
terminalId?: string
|
||||
worktreeId: string
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: BuiltInTuiAgent
|
||||
launchSnapshot: AgentLaunchSnapshot
|
||||
launchToken: string
|
||||
registeredAt: number
|
||||
}
|
||||
|
||||
/** The one-time legacy handoff: the renderer surrenders a pre-upgrade launch
|
||||
* config on first resume over trusted desktop IPC. The host reconstructs the
|
||||
* record from the ownership key it already holds and owns the config thereafter. */
|
||||
export type LegacySessionRecordHandoff = {
|
||||
ownershipKey: AgentSessionOwnershipKey
|
||||
requestedAgent: TuiAgent
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
legacyLaunchConfig: SleepingAgentLaunchConfig
|
||||
/** Recorded execution owner of the sleeping pane, kept for later provenance
|
||||
* re-checks once the host owns the config. */
|
||||
connectionId: string | null
|
||||
}
|
||||
|
||||
/** The durable half snapshotted for the host-private sink: the ownership-keyed
|
||||
* records only. Staging is in-flight and rebuilt from live terminals on restart
|
||||
* via reconciliation, so it is never persisted. */
|
||||
export type AgentSessionRecordStoreDurableState = {
|
||||
records: readonly HostSessionLaunchRecord[]
|
||||
}
|
||||
|
||||
export class AgentSessionRecordStore {
|
||||
// Spawn-time registrations, keyed by launch token (the stable handle both the
|
||||
// spawn caller and the hook carry), before a session binds.
|
||||
private readonly staging = new Map<string, StagedLaunchRegistration>()
|
||||
// Durable resume records, keyed by ownership key.
|
||||
private readonly records = new Map<string, HostSessionLaunchRecord>()
|
||||
private readonly vaultIndex = new AgentSessionVaultSnapshotIndex()
|
||||
// launchToken -> ownership key of the record it bound to, so a spawn-failure
|
||||
// rollback of an already-bound launch removes its durable record too.
|
||||
private readonly ownershipByToken = new Map<string, string>()
|
||||
private readonly now: () => number
|
||||
private onDurableMutation: ((state: AgentSessionRecordStoreDurableState) => void) | null = null
|
||||
|
||||
constructor(deps?: { now?: () => number }) {
|
||||
this.now = deps?.now ?? (() => Date.now())
|
||||
}
|
||||
|
||||
/** Attach (or replace) the durable sink. Not called during rehydrate, so the
|
||||
* load path never writes back the state it just read. */
|
||||
setDurablePersistence(sink: (state: AgentSessionRecordStoreDurableState) => void): void {
|
||||
this.onDurableMutation = sink
|
||||
}
|
||||
|
||||
durableState(): AgentSessionRecordStoreDurableState {
|
||||
return { records: [...this.records.values()] }
|
||||
}
|
||||
|
||||
private persistDurable(): void {
|
||||
this.onDurableMutation?.(this.durableState())
|
||||
}
|
||||
|
||||
private deleteDurableRecord(ownershipKey: string): boolean {
|
||||
const record = this.records.get(ownershipKey)
|
||||
if (!record) {
|
||||
return false
|
||||
}
|
||||
this.vaultIndex.remove(ownershipKey, record)
|
||||
this.records.delete(ownershipKey)
|
||||
if (record.launchToken && this.ownershipByToken.get(record.launchToken) === ownershipKey) {
|
||||
this.ownershipByToken.delete(record.launchToken)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** §577 spawn-time registration, keyed by launch token. Held in staging; not
|
||||
* resumable until a hook binds its provider session. */
|
||||
register(registration: Omit<StagedLaunchRegistration, 'registeredAt'>): void {
|
||||
this.staging.set(registration.launchToken, { ...registration, registeredAt: this.now() })
|
||||
}
|
||||
|
||||
/** Drop a staged registration on spawn failure. If it was already promoted, the
|
||||
* bound durable record is removed too so a failed spawn strands nothing. */
|
||||
rollbackByToken(launchToken: string): void {
|
||||
this.staging.delete(launchToken)
|
||||
const ownershipKey = this.ownershipByToken.get(launchToken)
|
||||
if (ownershipKey) {
|
||||
this.ownershipByToken.delete(launchToken)
|
||||
const record = this.records.get(ownershipKey)
|
||||
if (record?.launchToken === launchToken && this.deleteDurableRecord(ownershipKey)) {
|
||||
this.persistDurable()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the in-flight staging for a pane when its PTY ends. The durable record
|
||||
* (if the session bound) is intentionally KEPT so a slept session resumes; only
|
||||
* the unbound staging handle is cleared. Staging is small (bounded by concurrent
|
||||
* unbound launches), so a scan is cheaper than a second index. */
|
||||
disposeStagingForPane(paneKey: string): void {
|
||||
for (const [token, staged] of this.staging) {
|
||||
if (staged.paneKey === paneKey) {
|
||||
this.staging.delete(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Promote a staged registration to a durable resume record once a hook reports
|
||||
* the provider session for its launch token. The record's OWN base agent (host
|
||||
* attribution) — never the hook's provider evidence — drives the ownership key,
|
||||
* and the hook's session is accepted only when its key type matches that base.
|
||||
* An incompatible provider type is rejected (returns null) without rewriting the
|
||||
* staged identity; a non-resumable base can never bind. A successful bind
|
||||
* consumes its staging entry, so a repeated hook for the same launch is a null
|
||||
* no-op (an incompatible attempt keeps staging so a later compatible hook wins). */
|
||||
bindProviderSessionByToken(
|
||||
launchToken: string,
|
||||
providerSession: AgentProviderSessionMetadata
|
||||
): HostSessionLaunchRecord | null {
|
||||
const staged = this.staging.get(launchToken)
|
||||
if (!staged) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!isResumableTuiAgent(staged.baseAgent) ||
|
||||
providerSession.key !== providerSessionKeyForResumableBase(staged.baseAgent)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const ownershipKey = getAgentSessionOwnershipKey({
|
||||
worktreeId: staged.worktreeId,
|
||||
baseAgent: staged.baseAgent,
|
||||
providerSessionId: providerSession.id
|
||||
})
|
||||
const record: HostSessionLaunchRecord = {
|
||||
worktreeId: staged.worktreeId,
|
||||
requestedAgent: staged.requestedAgent,
|
||||
baseAgent: staged.baseAgent,
|
||||
providerSession,
|
||||
launchSnapshot: staged.launchSnapshot,
|
||||
launchToken: staged.launchToken,
|
||||
registeredAt: staged.registeredAt,
|
||||
updatedAt: this.now()
|
||||
}
|
||||
const replaced = this.records.get(ownershipKey)
|
||||
if (replaced) {
|
||||
this.vaultIndex.remove(ownershipKey, replaced)
|
||||
if (replaced.launchToken && replaced.launchToken !== launchToken) {
|
||||
this.ownershipByToken.delete(replaced.launchToken)
|
||||
}
|
||||
}
|
||||
this.records.set(ownershipKey, record)
|
||||
this.vaultIndex.add(ownershipKey, record)
|
||||
this.ownershipByToken.set(launchToken, ownershipKey)
|
||||
// Consume the staging entry so repeated hook events for the same launch are a
|
||||
// cheap no-op (no duplicate durable write); rollback still finds the bound
|
||||
// record via the ownership index.
|
||||
this.staging.delete(launchToken)
|
||||
this.persistDurable()
|
||||
return record
|
||||
}
|
||||
|
||||
/** Resolve the private record a resume/fork request names. */
|
||||
resolveByOwnershipKey(key: AgentSessionOwnershipKey): HostSessionLaunchRecord | null {
|
||||
return this.records.get(getAgentSessionOwnershipKey(key)) ?? null
|
||||
}
|
||||
|
||||
/** Correlate a freshly scanned Vault row to one eligible v1 snapshot owner. */
|
||||
resolveVaultSnapshotOwner(args: {
|
||||
baseAgent: ResumableTuiAgent
|
||||
scannedProviderSessionId: string
|
||||
scannedTranscriptPath?: string | null
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
targetPlatform: NodeJS.Platform
|
||||
preferredWorktreeId?: string | null
|
||||
}): VaultSnapshotOwnerResolution {
|
||||
return this.vaultIndex.resolve(args, this.records)
|
||||
}
|
||||
|
||||
/** Return only the captured non-executable argv after conservative Vault
|
||||
* correlation. This is the narrow disclosure used by expanded details. */
|
||||
resolveVaultSnapshotArguments(args: {
|
||||
baseAgent: ResumableTuiAgent
|
||||
scannedProviderSessionId: string
|
||||
scannedTranscriptPath?: string | null
|
||||
scannedExecutionHostId: string
|
||||
}): readonly string[] | null {
|
||||
const owner = this.vaultIndex.resolveForDiscoveredHost(args, this.records)
|
||||
if (owner.kind !== 'found') {
|
||||
return null
|
||||
}
|
||||
const record = this.resolveByOwnershipKey(owner.sessionKey)
|
||||
return record?.launchSnapshot ? record.launchSnapshot.argv.slice(1) : null
|
||||
}
|
||||
|
||||
/** Requested identities of every durable resume record, for the tombstone
|
||||
* reference index's `session` owner (plan §266). Each bound resumable session
|
||||
* registers here, so a custom id still named here keeps its tombstone retained
|
||||
* until the session is forgotten. */
|
||||
referencedRequestedAgents(): TuiAgent[] {
|
||||
return [...this.records.values()].map((record) => record.requestedAgent)
|
||||
}
|
||||
|
||||
/** Count durable resume records whose base harness is `base`, for §973
|
||||
* base-disable impact. Records are keyed by their host-attributed base, so a
|
||||
* derivative launch (baseAgent === base) is counted alongside a direct base
|
||||
* launch — every session that will block when the harness is disabled. */
|
||||
countRecordsByBase(base: BuiltInTuiAgent): number {
|
||||
let count = 0
|
||||
for (const record of this.records.values()) {
|
||||
if (record.baseAgent === base) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/** Accept the one-time legacy launch config the renderer surrenders on first
|
||||
* resume. Ignored when the host already owns a record for the key (already
|
||||
* handed over): "renderer hands it over once; host owns it thereafter". */
|
||||
ingestLegacyRecord(handoff: LegacySessionRecordHandoff): HostSessionLaunchRecord {
|
||||
const ownershipKey = getAgentSessionOwnershipKey(handoff.ownershipKey)
|
||||
const existing = this.records.get(ownershipKey)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const now = this.now()
|
||||
const record: HostSessionLaunchRecord = {
|
||||
worktreeId: handoff.ownershipKey.worktreeId,
|
||||
requestedAgent: handoff.requestedAgent,
|
||||
baseAgent: handoff.ownershipKey.baseAgent,
|
||||
providerSession: handoff.providerSession,
|
||||
legacyLaunchConfig: handoff.legacyLaunchConfig,
|
||||
legacyConnectionId: handoff.connectionId,
|
||||
registeredAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
this.records.set(ownershipKey, record)
|
||||
this.vaultIndex.add(ownershipKey, record)
|
||||
this.persistDurable()
|
||||
return record
|
||||
}
|
||||
|
||||
/** Owner-authorized forget: drop the durable record entirely. */
|
||||
forget(key: AgentSessionOwnershipKey): boolean {
|
||||
const deleted = this.deleteDurableRecord(getAgentSessionOwnershipKey(key))
|
||||
if (deleted) {
|
||||
this.persistDurable()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
/** Rehydrate durable records at startup. Not routed through the sink. */
|
||||
rebuildRecordsFrom(records: Iterable<HostSessionLaunchRecord>): void {
|
||||
this.records.clear()
|
||||
this.vaultIndex.clear()
|
||||
this.ownershipByToken.clear()
|
||||
for (const record of records) {
|
||||
const providerSession = normalizeAgentProviderSession(record?.providerSession)
|
||||
if (
|
||||
typeof record?.worktreeId !== 'string' ||
|
||||
!record.worktreeId ||
|
||||
!isResumableTuiAgent(record.baseAgent) ||
|
||||
!providerSession
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const ownershipKey = getAgentSessionOwnershipKey({
|
||||
worktreeId: record.worktreeId,
|
||||
baseAgent: record.baseAgent,
|
||||
providerSessionId: providerSession.id
|
||||
})
|
||||
this.records.set(ownershipKey, record)
|
||||
this.vaultIndex.add(ownershipKey, record)
|
||||
if (record.launchToken) {
|
||||
this.ownershipByToken.set(record.launchToken, ownershipKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { canonicalAgentSessionTranscriptIdentity } from './agent-session-transcript-identity'
|
||||
|
||||
describe('canonicalAgentSessionTranscriptIdentity', () => {
|
||||
it('normalizes POSIX and Windows transcript identities', () => {
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: '/home/me/a/../session.jsonl',
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
).toBe('posix:/home/me/session.jsonl')
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: 'C:\\Users\\ME\\session.jsonl',
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'win32'
|
||||
})
|
||||
).toBe('windows:c:/users/me/session.jsonl')
|
||||
})
|
||||
|
||||
it('maps WSL UNC paths to POSIX only for the target distro', () => {
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: '\\\\wsl$\\Ubuntu\\home\\me\\session.jsonl',
|
||||
targetExecutionHostId: 'wsl:Ubuntu',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
).toBe('posix:/home/me/session.jsonl')
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: '\\\\wsl.localhost\\Debian\\home\\me\\session.jsonl',
|
||||
targetExecutionHostId: 'wsl:Ubuntu',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('compares UNC distro names with decoded execution-host ids', () => {
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: '\\\\wsl$\\Ubuntu 22.04\\home\\me\\session.jsonl',
|
||||
targetExecutionHostId: 'wsl:Ubuntu%2022.04',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
).toBe('posix:/home/me/session.jsonl')
|
||||
})
|
||||
|
||||
it('drops relative and malformed path evidence', () => {
|
||||
expect(
|
||||
canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: 'relative/session.jsonl',
|
||||
targetExecutionHostId: 'local',
|
||||
targetPlatform: 'linux'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { posix, win32 } from 'node:path'
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
|
||||
function wslDistroFromExecutionHostId(
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
): string | null {
|
||||
if (!targetExecutionHostId.startsWith('wsl:')) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(targetExecutionHostId.slice('wsl:'.length)) || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function transcriptPathConflictsWithWslTarget(
|
||||
transcriptPath: string,
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
): boolean {
|
||||
if (!targetExecutionHostId.startsWith('wsl:')) {
|
||||
return false
|
||||
}
|
||||
const targetDistro = wslDistroFromExecutionHostId(targetExecutionHostId)
|
||||
const unc = parseWslUncPath(transcriptPath.trim())
|
||||
return Boolean(unc && (!targetDistro || unc.distro.toLowerCase() !== targetDistro.toLowerCase()))
|
||||
}
|
||||
|
||||
function usableAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
|
||||
return (
|
||||
value.length > 0 &&
|
||||
!value.includes('\0') &&
|
||||
(platform === 'win32' ? win32.isAbsolute(value) : posix.isAbsolute(value))
|
||||
)
|
||||
}
|
||||
|
||||
/** Canonical host-private transcript identity used only by correlation indexes. */
|
||||
export function canonicalAgentSessionTranscriptIdentity(args: {
|
||||
transcriptPath: string
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
targetPlatform: NodeJS.Platform
|
||||
}): string | null {
|
||||
const raw = args.transcriptPath.trim()
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (args.targetExecutionHostId.startsWith('wsl:')) {
|
||||
const targetDistro = wslDistroFromExecutionHostId(args.targetExecutionHostId)
|
||||
if (!targetDistro) {
|
||||
return null
|
||||
}
|
||||
const unc = parseWslUncPath(raw)
|
||||
if (unc && unc.distro.toLowerCase() !== targetDistro.toLowerCase()) {
|
||||
return null
|
||||
}
|
||||
const linuxPath = unc?.linuxPath ?? raw
|
||||
if (!usableAbsolutePath(linuxPath, 'linux')) {
|
||||
return null
|
||||
}
|
||||
return `posix:${posix.normalize(linuxPath)}`
|
||||
}
|
||||
|
||||
if (!usableAbsolutePath(raw, args.targetPlatform)) {
|
||||
return null
|
||||
}
|
||||
return args.targetPlatform === 'win32'
|
||||
? `windows:${win32.normalize(raw).replace(/\\/g, '/').toLowerCase()}`
|
||||
: `posix:${posix.normalize(raw)}`
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import {
|
||||
isResumableTuiAgent,
|
||||
normalizeAgentProviderSession,
|
||||
providerSessionKeyForResumableBase,
|
||||
type AgentSessionOwnershipKey,
|
||||
type ResumableTuiAgent
|
||||
} from '../../shared/agent-session-resume'
|
||||
import { isTuiAgent } from '../../shared/tui-agent-config'
|
||||
import {
|
||||
canonicalAgentSessionTranscriptIdentity,
|
||||
transcriptPathConflictsWithWslTarget
|
||||
} from './agent-session-transcript-identity'
|
||||
import type { HostSessionLaunchRecord } from './agent-session-record-store'
|
||||
import {
|
||||
AgentSessionVaultTargetIndex,
|
||||
vaultSessionKeyForRecord,
|
||||
type VaultSnapshotScanIdentity
|
||||
} from './agent-session-vault-target-index'
|
||||
|
||||
export type VaultSnapshotOwnerResolution =
|
||||
| { kind: 'found'; sessionKey: AgentSessionOwnershipKey }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'ambiguous' }
|
||||
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
const SNAPSHOT_MODES = new Set(['built-in', 'custom', 'safe-fallback'])
|
||||
const CAPTURED_ENV_POLICIES = new Set(['full', 'withheld', 'none'])
|
||||
const STARTUP_SHELLS = new Set(['posix', 'powershell', 'cmd'])
|
||||
|
||||
function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every((entry) => typeof entry === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isExecutionHostId(value: unknown): value is AgentLaunchExecutionHostId {
|
||||
if (value === 'local') {
|
||||
return true
|
||||
}
|
||||
if (typeof value !== 'string' || !/^(?:ssh|runtime|wsl):.+$/.test(value)) {
|
||||
return false
|
||||
}
|
||||
if (!value.startsWith('wsl:')) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value.slice('wsl:'.length)).length > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isSnapshotIndexEligible(record: HostSessionLaunchRecord): boolean {
|
||||
const snapshot = record.launchSnapshot
|
||||
const providerSession = normalizeAgentProviderSession(record.providerSession)
|
||||
return Boolean(
|
||||
typeof record.worktreeId === 'string' &&
|
||||
record.worktreeId.length > 0 &&
|
||||
isTuiAgent(record.requestedAgent) &&
|
||||
isResumableTuiAgent(record.baseAgent) &&
|
||||
providerSession &&
|
||||
providerSession.key === providerSessionKeyForResumableBase(record.baseAgent) &&
|
||||
snapshot &&
|
||||
snapshot.version === 1 &&
|
||||
isTuiAgent(snapshot.requestedAgent) &&
|
||||
snapshot.baseAgent === record.baseAgent &&
|
||||
typeof snapshot.displayLabel === 'string' &&
|
||||
SNAPSHOT_MODES.has(snapshot.mode) &&
|
||||
Array.isArray(snapshot.argv) &&
|
||||
snapshot.argv.length > 0 &&
|
||||
snapshot.argv.every((value) => typeof value === 'string') &&
|
||||
snapshot.argv[0].length > 0 &&
|
||||
isStringRecord(snapshot.agentEnv) &&
|
||||
CAPTURED_ENV_POLICIES.has(snapshot.capturedEnvPolicy) &&
|
||||
snapshot.target &&
|
||||
isExecutionHostId(snapshot.target.executionHostId) &&
|
||||
typeof snapshot.target.executionHostId === 'string' &&
|
||||
NODE_PLATFORMS.has(snapshot.target.platform) &&
|
||||
(snapshot.target.execution === 'native' || snapshot.target.execution === 'wsl') &&
|
||||
STARTUP_SHELLS.has(snapshot.target.shell) &&
|
||||
typeof snapshot.target.isRemote === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function providerIndexKey(
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId,
|
||||
baseAgent: ResumableTuiAgent,
|
||||
providerSessionId: string
|
||||
): string {
|
||||
return `${targetExecutionHostId}\0${baseAgent}\0${providerSessionId}`
|
||||
}
|
||||
|
||||
function transcriptIndexKey(
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId,
|
||||
baseAgent: ResumableTuiAgent,
|
||||
transcriptIdentity: string
|
||||
): string {
|
||||
return `${targetExecutionHostId}\0${baseAgent}\0${transcriptIdentity}`
|
||||
}
|
||||
|
||||
/** Derived, in-memory-only indexes for Vault-to-private-record correlation. */
|
||||
export class AgentSessionVaultSnapshotIndex {
|
||||
private readonly ownershipByProvider = new Map<string, Set<string>>()
|
||||
private readonly ownershipByTranscript = new Map<string, Set<string>>()
|
||||
private readonly targetIndex = new AgentSessionVaultTargetIndex()
|
||||
|
||||
clear(): void {
|
||||
this.ownershipByProvider.clear()
|
||||
this.ownershipByTranscript.clear()
|
||||
this.targetIndex.clear()
|
||||
}
|
||||
|
||||
add(ownershipKey: string, record: HostSessionLaunchRecord): void {
|
||||
if (!isSnapshotIndexEligible(record) || !record.launchSnapshot) {
|
||||
return
|
||||
}
|
||||
const target = record.launchSnapshot.target
|
||||
this.targetIndex.add(record.baseAgent, target)
|
||||
this.addIndexValue(
|
||||
this.ownershipByProvider,
|
||||
providerIndexKey(target.executionHostId, record.baseAgent, record.providerSession.id),
|
||||
ownershipKey
|
||||
)
|
||||
const transcriptIdentity = this.recordTranscriptIdentity(record)
|
||||
if (transcriptIdentity) {
|
||||
this.addIndexValue(
|
||||
this.ownershipByTranscript,
|
||||
transcriptIndexKey(target.executionHostId, record.baseAgent, transcriptIdentity),
|
||||
ownershipKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
remove(ownershipKey: string, record: HostSessionLaunchRecord): void {
|
||||
if (!isSnapshotIndexEligible(record) || !record.launchSnapshot) {
|
||||
return
|
||||
}
|
||||
const target = record.launchSnapshot.target
|
||||
this.targetIndex.remove(record.baseAgent, target)
|
||||
this.deleteIndexValue(
|
||||
this.ownershipByProvider,
|
||||
providerIndexKey(target.executionHostId, record.baseAgent, record.providerSession.id),
|
||||
ownershipKey
|
||||
)
|
||||
const transcriptIdentity = this.recordTranscriptIdentity(record)
|
||||
if (transcriptIdentity) {
|
||||
this.deleteIndexValue(
|
||||
this.ownershipByTranscript,
|
||||
transcriptIndexKey(target.executionHostId, record.baseAgent, transcriptIdentity),
|
||||
ownershipKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
resolve(
|
||||
args: {
|
||||
baseAgent: ResumableTuiAgent
|
||||
scannedProviderSessionId: string
|
||||
scannedTranscriptPath?: string | null
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId
|
||||
targetPlatform: NodeJS.Platform
|
||||
preferredWorktreeId?: string | null
|
||||
},
|
||||
records: ReadonlyMap<string, HostSessionLaunchRecord>
|
||||
): VaultSnapshotOwnerResolution {
|
||||
if (
|
||||
args.scannedTranscriptPath &&
|
||||
transcriptPathConflictsWithWslTarget(args.scannedTranscriptPath, args.targetExecutionHostId)
|
||||
) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
const transcriptIdentity = args.scannedTranscriptPath
|
||||
? canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath: args.scannedTranscriptPath,
|
||||
targetExecutionHostId: args.targetExecutionHostId,
|
||||
targetPlatform: args.targetPlatform
|
||||
})
|
||||
: null
|
||||
const pathCandidates = transcriptIdentity
|
||||
? this.ownershipByTranscript.get(
|
||||
transcriptIndexKey(args.targetExecutionHostId, args.baseAgent, transcriptIdentity)
|
||||
)
|
||||
: undefined
|
||||
if (pathCandidates && pathCandidates.size > 0) {
|
||||
return this.selectOwner(pathCandidates, args.preferredWorktreeId, records)
|
||||
}
|
||||
|
||||
const idCandidates = this.ownershipByProvider.get(
|
||||
providerIndexKey(args.targetExecutionHostId, args.baseAgent, args.scannedProviderSessionId)
|
||||
)
|
||||
if (!idCandidates || idCandidates.size === 0) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
const survivors = new Set<string>()
|
||||
for (const ownershipKey of idCandidates) {
|
||||
const record = records.get(ownershipKey)
|
||||
if (!record?.launchSnapshot || !isSnapshotIndexEligible(record)) {
|
||||
continue
|
||||
}
|
||||
const recordIdentity = this.recordTranscriptIdentity(record)
|
||||
// A known different transcript proves a repeated provider id is not this row.
|
||||
if (transcriptIdentity && recordIdentity && transcriptIdentity !== recordIdentity) {
|
||||
continue
|
||||
}
|
||||
survivors.add(ownershipKey)
|
||||
}
|
||||
return this.selectOwner(survivors, args.preferredWorktreeId, records)
|
||||
}
|
||||
|
||||
/** Resolve display-only snapshot data without accepting a client-authored
|
||||
* target. A local Vault scan may represent either native or WSL storage. */
|
||||
resolveForDiscoveredHost(
|
||||
args: VaultSnapshotScanIdentity,
|
||||
records: ReadonlyMap<string, HostSessionLaunchRecord>
|
||||
): VaultSnapshotOwnerResolution {
|
||||
const targets = this.targetIndex.matching(args.baseAgent, args.scannedExecutionHostId)
|
||||
if (targets.length === 0) {
|
||||
return { kind: 'missing' }
|
||||
}
|
||||
const found = new Map<string, AgentSessionOwnershipKey>()
|
||||
for (const target of targets) {
|
||||
const resolution = this.resolve(
|
||||
{
|
||||
baseAgent: args.baseAgent,
|
||||
scannedProviderSessionId: args.scannedProviderSessionId,
|
||||
scannedTranscriptPath: args.scannedTranscriptPath,
|
||||
targetExecutionHostId: target.executionHostId,
|
||||
targetPlatform: target.platform
|
||||
},
|
||||
records
|
||||
)
|
||||
if (resolution.kind === 'ambiguous') {
|
||||
return resolution
|
||||
}
|
||||
if (resolution.kind === 'found') {
|
||||
found.set(JSON.stringify(resolution.sessionKey), resolution.sessionKey)
|
||||
}
|
||||
}
|
||||
if (found.size === 1) {
|
||||
return { kind: 'found', sessionKey: [...found.values()][0] }
|
||||
}
|
||||
return found.size === 0 ? { kind: 'missing' } : { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
private recordTranscriptIdentity(record: HostSessionLaunchRecord): string | null {
|
||||
const snapshot = record.launchSnapshot
|
||||
const transcriptPath = record.providerSession.transcriptPath
|
||||
return snapshot && transcriptPath
|
||||
? canonicalAgentSessionTranscriptIdentity({
|
||||
transcriptPath,
|
||||
targetExecutionHostId: snapshot.target.executionHostId,
|
||||
targetPlatform: snapshot.target.platform
|
||||
})
|
||||
: null
|
||||
}
|
||||
|
||||
private selectOwner(
|
||||
ownershipKeys: ReadonlySet<string>,
|
||||
preferredWorktreeId: string | null | undefined,
|
||||
records: ReadonlyMap<string, HostSessionLaunchRecord>
|
||||
): VaultSnapshotOwnerResolution {
|
||||
const candidates = [...ownershipKeys].flatMap((ownershipKey) => {
|
||||
const record = records.get(ownershipKey)
|
||||
return record && isSnapshotIndexEligible(record) ? [record] : []
|
||||
})
|
||||
if (preferredWorktreeId) {
|
||||
const preferred = candidates.filter((record) => record.worktreeId === preferredWorktreeId)
|
||||
if (preferred.length === 1) {
|
||||
return { kind: 'found', sessionKey: vaultSessionKeyForRecord(preferred[0]) }
|
||||
}
|
||||
if (preferred.length > 1) {
|
||||
return { kind: 'ambiguous' }
|
||||
}
|
||||
}
|
||||
if (candidates.length === 1) {
|
||||
return { kind: 'found', sessionKey: vaultSessionKeyForRecord(candidates[0]) }
|
||||
}
|
||||
return candidates.length === 0 ? { kind: 'missing' } : { kind: 'ambiguous' }
|
||||
}
|
||||
|
||||
private addIndexValue(index: Map<string, Set<string>>, key: string, ownershipKey: string): void {
|
||||
const values = index.get(key) ?? new Set<string>()
|
||||
values.add(ownershipKey)
|
||||
index.set(key, values)
|
||||
}
|
||||
|
||||
private deleteIndexValue(
|
||||
index: Map<string, Set<string>>,
|
||||
key: string,
|
||||
ownershipKey: string
|
||||
): void {
|
||||
const values = index.get(key)
|
||||
if (!values) {
|
||||
return
|
||||
}
|
||||
values.delete(ownershipKey)
|
||||
if (values.size === 0) {
|
||||
index.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract'
|
||||
import type { AgentSessionOwnershipKey, ResumableTuiAgent } from '../../shared/agent-session-resume'
|
||||
|
||||
export type IndexedSnapshotTarget = {
|
||||
executionHostId: AgentLaunchExecutionHostId
|
||||
platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export type VaultSnapshotScanIdentity = {
|
||||
baseAgent: ResumableTuiAgent
|
||||
scannedProviderSessionId: string
|
||||
scannedTranscriptPath?: string | null
|
||||
scannedExecutionHostId: string
|
||||
}
|
||||
|
||||
export function vaultSessionKeyForRecord(record: {
|
||||
worktreeId: string
|
||||
baseAgent: ResumableTuiAgent
|
||||
providerSession: { id: string }
|
||||
}): AgentSessionOwnershipKey {
|
||||
return {
|
||||
worktreeId: record.worktreeId,
|
||||
baseAgent: record.baseAgent,
|
||||
providerSessionId: record.providerSession.id
|
||||
}
|
||||
}
|
||||
|
||||
/** Reference-counted target inventory for the private Vault correlation index. */
|
||||
export class AgentSessionVaultTargetIndex {
|
||||
private readonly targetsByBase = new Map<ResumableTuiAgent, Map<string, number>>()
|
||||
|
||||
clear(): void {
|
||||
this.targetsByBase.clear()
|
||||
}
|
||||
|
||||
add(baseAgent: ResumableTuiAgent, target: IndexedSnapshotTarget): void {
|
||||
const targets = this.targetsByBase.get(baseAgent) ?? new Map<string, number>()
|
||||
const key = targetKey(target)
|
||||
targets.set(key, (targets.get(key) ?? 0) + 1)
|
||||
this.targetsByBase.set(baseAgent, targets)
|
||||
}
|
||||
|
||||
remove(baseAgent: ResumableTuiAgent, target: IndexedSnapshotTarget): void {
|
||||
const targets = this.targetsByBase.get(baseAgent)
|
||||
if (!targets) {
|
||||
return
|
||||
}
|
||||
const key = targetKey(target)
|
||||
const count = targets.get(key) ?? 0
|
||||
if (count <= 1) {
|
||||
targets.delete(key)
|
||||
} else {
|
||||
targets.set(key, count - 1)
|
||||
}
|
||||
if (targets.size === 0) {
|
||||
this.targetsByBase.delete(baseAgent)
|
||||
}
|
||||
}
|
||||
|
||||
matching(baseAgent: ResumableTuiAgent, scannedExecutionHostId: string): IndexedSnapshotTarget[] {
|
||||
const targets = this.targetsByBase.get(baseAgent)
|
||||
if (!targets) {
|
||||
return []
|
||||
}
|
||||
return [...targets.keys()]
|
||||
.map((key) => JSON.parse(key) as IndexedSnapshotTarget)
|
||||
.filter((target) =>
|
||||
targetMatchesDiscoveredHost(target.executionHostId, scannedExecutionHostId)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function targetKey(target: IndexedSnapshotTarget): string {
|
||||
return JSON.stringify({
|
||||
executionHostId: target.executionHostId,
|
||||
platform: target.platform
|
||||
} satisfies IndexedSnapshotTarget)
|
||||
}
|
||||
|
||||
function targetMatchesDiscoveredHost(
|
||||
targetExecutionHostId: AgentLaunchExecutionHostId,
|
||||
scannedExecutionHostId: string
|
||||
): boolean {
|
||||
if (scannedExecutionHostId === 'local') {
|
||||
return targetExecutionHostId === 'local' || targetExecutionHostId.startsWith('wsl:')
|
||||
}
|
||||
return targetExecutionHostId === scannedExecutionHostId
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AgentTombstoneReferenceIndex,
|
||||
type AgentReferenceOwnerScanner
|
||||
} from './agent-tombstone-reference-index'
|
||||
import type { AgentReferenceOwnerKind } from '../../shared/agent-reference-snapshot'
|
||||
import type { CustomTuiAgentId } from '../../shared/types'
|
||||
|
||||
const customA = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as CustomTuiAgentId
|
||||
const customB = 'custom-agent:claude:fedcba98-7654-4321-8fed-cba987654321' as CustomTuiAgentId
|
||||
const customCodex = 'custom-agent:codex:11111111-2222-4333-8444-555566667777' as CustomTuiAgentId
|
||||
|
||||
function scannerOf(
|
||||
owner: AgentReferenceOwnerKind,
|
||||
ids: readonly unknown[]
|
||||
): AgentReferenceOwnerScanner {
|
||||
return { owner, scan: () => ({ ok: true, referencedIds: ids }) }
|
||||
}
|
||||
|
||||
function failingScanner(owner: AgentReferenceOwnerKind): AgentReferenceOwnerScanner {
|
||||
return { owner, scan: () => ({ ok: false }) }
|
||||
}
|
||||
|
||||
describe('AgentTombstoneReferenceIndex — custom-id GC counting (invariant across the raw-id refactor)', () => {
|
||||
it('counts every occurrence of a custom id across owners', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', [customA]))
|
||||
// A quick-command list can reference the same id twice; both count.
|
||||
index.register(scannerOf('quick-command', [customA, customA, null]))
|
||||
index.register(scannerOf('automation', [customB]))
|
||||
expect(index.countReferences(customA)).toBe(3)
|
||||
expect(index.countReferences(customB)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns unknown when any owner scan fails, so GC always retains conservatively', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', [customA]))
|
||||
index.register(failingScanner('automation'))
|
||||
expect(index.countReferences(customA)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('summarizes per owner and reports -1 for an unreadable owner', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('quick-command', [customA, customA]))
|
||||
index.register(failingScanner('automation'))
|
||||
index.register(scannerOf('default', [customB]))
|
||||
const summary = index.summarizeReferences(customA)
|
||||
expect(summary).toContainEqual({ owner: 'quick-command', count: 2 })
|
||||
expect(summary).toContainEqual({ owner: 'automation', count: -1 })
|
||||
// An owner with zero references for this id is omitted.
|
||||
expect(summary.some((entry) => entry.owner === 'default')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentTombstoneReferenceIndex.countMatchingReferences — base-disable impact (§973)', () => {
|
||||
// Disabling base 'claude' blocks the base id itself and its derivatives.
|
||||
const matchesClaudeAndDerivatives = (value: unknown): boolean =>
|
||||
value === 'claude' || value === customA || value === customB
|
||||
|
||||
it('counts the base id and its derivatives across owners', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', ['claude']))
|
||||
index.register(scannerOf('quick-command', [customA, customCodex, 'codex']))
|
||||
index.register(scannerOf('automation', [customB]))
|
||||
const result = index.countMatchingReferences(matchesClaudeAndDerivatives)
|
||||
// 'claude' + customA + customB = 3; the unrelated codex references are ignored.
|
||||
expect(result).toEqual({ count: 3, complete: true })
|
||||
})
|
||||
|
||||
it('excludes owners counted separately (sessions) without affecting completeness', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', ['claude']))
|
||||
index.register(scannerOf('session', [customA, customB]))
|
||||
const result = index.countMatchingReferences(matchesClaudeAndDerivatives, {
|
||||
excludeOwners: new Set(['session'])
|
||||
})
|
||||
expect(result).toEqual({ count: 1, complete: true })
|
||||
})
|
||||
|
||||
it('returns the readable partial with complete=false when a non-excluded owner is unreadable', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', ['claude']))
|
||||
index.register(failingScanner('automation'))
|
||||
const result = index.countMatchingReferences(matchesClaudeAndDerivatives)
|
||||
expect(result).toEqual({ count: 1, complete: false })
|
||||
})
|
||||
|
||||
it('an unreadable EXCLUDED owner does not taint completeness', () => {
|
||||
const index = new AgentTombstoneReferenceIndex()
|
||||
index.register(scannerOf('default', ['claude']))
|
||||
index.register(failingScanner('session'))
|
||||
const result = index.countMatchingReferences(matchesClaudeAndDerivatives, {
|
||||
excludeOwners: new Set(['session'])
|
||||
})
|
||||
expect(result).toEqual({ count: 1, complete: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
// Authoritative reference index over every persisted owner of an agent
|
||||
// reference. Tombstones are reference-counted recovery records: one is pruned
|
||||
// only after an authoritative recheck proves zero references in every owner
|
||||
// store, and an unavailable/corrupt owner store means "retain". Owners added by
|
||||
// later feature units (worktree pending launches, background attempts,
|
||||
// orchestration dispatches, sleeping sessions) register additional scanners
|
||||
// here rather than growing a parallel index.
|
||||
//
|
||||
// Scanners enumerate the RAW referenced ids they hold; the index applies the
|
||||
// counting policy. Tombstone GC and "Review references" count a specific custom
|
||||
// id (built-ins are never tombstoned); base-disable impact (§973) counts a
|
||||
// caller-supplied matcher (a base plus its derivatives). Keeping the filter here
|
||||
// lets one scan answer both without each owner knowing either policy.
|
||||
|
||||
import type { CustomTuiAgentId } from '../../shared/types'
|
||||
import type {
|
||||
AgentReferenceOwnerKind,
|
||||
AgentReferenceSummary
|
||||
} from '../../shared/agent-reference-snapshot'
|
||||
|
||||
export type { AgentReferenceSummary }
|
||||
|
||||
export type AgentReferenceScanResult =
|
||||
| { ok: true; referencedIds: readonly unknown[] }
|
||||
| { ok: false }
|
||||
|
||||
export type AgentReferenceOwnerScanner = {
|
||||
owner: AgentReferenceOwnerKind
|
||||
/** Return every id this owner store currently references (raw, unfiltered), or
|
||||
* ok:false when the store cannot be read (conservative retain). Never throw. */
|
||||
scan: () => AgentReferenceScanResult
|
||||
}
|
||||
|
||||
/** Partial count under a matcher: `count` sums readable owners; `complete` is
|
||||
* false when any (non-excluded) owner store could not be read, so the true
|
||||
* total may be higher. */
|
||||
export type MatchingReferenceCount = { count: number; complete: boolean }
|
||||
|
||||
export class AgentTombstoneReferenceIndex {
|
||||
private readonly scanners: AgentReferenceOwnerScanner[] = []
|
||||
|
||||
register(scanner: AgentReferenceOwnerScanner): void {
|
||||
this.scanners.push(scanner)
|
||||
}
|
||||
|
||||
/** Authoritative recheck across every registered owner for a single custom id.
|
||||
* Returns 'unknown' when any owner scan fails, which callers must treat as
|
||||
* "retain" (tombstone GC semantics — a partial count must never prune). */
|
||||
countReferences(id: CustomTuiAgentId): number | 'unknown' {
|
||||
let total = 0
|
||||
for (const scanner of this.scanners) {
|
||||
const result = scanner.scan()
|
||||
if (!result.ok) {
|
||||
return 'unknown'
|
||||
}
|
||||
total += countMatches(result.referencedIds, (value) => value === id)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** Per-owner counts for delete confirmation and "Review references". Owners
|
||||
* whose scan failed report count -1 so the UI can say "unknown". */
|
||||
summarizeReferences(id: CustomTuiAgentId): AgentReferenceSummary[] {
|
||||
const byOwner = new Map<AgentReferenceOwnerScanner['owner'], number>()
|
||||
for (const scanner of this.scanners) {
|
||||
const result = scanner.scan()
|
||||
if (!result.ok) {
|
||||
byOwner.set(scanner.owner, -1)
|
||||
continue
|
||||
}
|
||||
const count = countMatches(result.referencedIds, (value) => value === id)
|
||||
const existing = byOwner.get(scanner.owner)
|
||||
if (existing === -1) {
|
||||
continue
|
||||
}
|
||||
byOwner.set(scanner.owner, (existing ?? 0) + count)
|
||||
}
|
||||
const summaries: AgentReferenceSummary[] = []
|
||||
for (const [owner, count] of byOwner) {
|
||||
if (count !== 0) {
|
||||
summaries.push({ owner, count })
|
||||
}
|
||||
}
|
||||
return summaries
|
||||
}
|
||||
|
||||
/** Count references matching an arbitrary predicate — the base-disable impact
|
||||
* path (§973), where `matches` accepts the base id and any of its derivatives.
|
||||
* Unlike `countReferences`, an unreadable owner does NOT collapse the whole
|
||||
* result: it returns the readable partial plus `complete: false` so the caller
|
||||
* can render "at least N". `excludeOwners` skips owners counted separately
|
||||
* (the caller reports sessions via the record store's base count instead). */
|
||||
countMatchingReferences(
|
||||
matches: (value: unknown) => boolean,
|
||||
options?: { excludeOwners?: ReadonlySet<AgentReferenceOwnerKind> }
|
||||
): MatchingReferenceCount {
|
||||
let count = 0
|
||||
let complete = true
|
||||
for (const scanner of this.scanners) {
|
||||
if (options?.excludeOwners?.has(scanner.owner)) {
|
||||
continue
|
||||
}
|
||||
const result = scanner.scan()
|
||||
if (!result.ok) {
|
||||
complete = false
|
||||
continue
|
||||
}
|
||||
count += countMatches(result.referencedIds, matches)
|
||||
}
|
||||
return { count, complete }
|
||||
}
|
||||
}
|
||||
|
||||
function countMatches(values: readonly unknown[], matches: (value: unknown) => boolean): number {
|
||||
let count = 0
|
||||
for (const value of values) {
|
||||
if (matches(value)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Injected-attempts integration for the generic background Forget/Retry surface
|
||||
// (U6, ledger #13). Wires the SAME shared orchestrators the runtime methods use
|
||||
// (runForgetUnknownAgentLaunch / runWorktreeRetryAgentLaunch) to a real background
|
||||
// attempt store + operation store, proving the G6 oracles: owner-authorized Forget
|
||||
// frees exactly one reservation and never spawns/kills; Retry follows the
|
||||
// persisted-state gating discipline. No production producer is synthesized — every
|
||||
// attempt here is injected.
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract'
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import type {
|
||||
WorktreeRetryAgentLaunchResult,
|
||||
WorktreeRetryInFlight
|
||||
} from './agent-launch-worktree-retry'
|
||||
import {
|
||||
AgentLaunchOperationStore,
|
||||
agentLaunchIdempotencyKey,
|
||||
type PendingAgentLaunchSnapshot
|
||||
} from './agent-launch-operation-store'
|
||||
import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation'
|
||||
import { runForgetUnknownAgentLaunch } from './agent-launch-worktree-forget'
|
||||
import { runWorktreeRetryAgentLaunch } from './agent-launch-worktree-retry'
|
||||
import { BackgroundAgentLaunchStore } from './background-agent-launch-store'
|
||||
|
||||
const ATTEMPT_ID = 'attempt-bg-1'
|
||||
const WORKTREE_ID = 'repo-1:wt-a'
|
||||
const OPERATION_ID = 'op-bg-1'
|
||||
const LAUNCH_TOKEN = 'token-bg-1'
|
||||
const CLIENT_MUTATION_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const FAILURE_ID = 'failure-bg-1'
|
||||
|
||||
function snapshot(): AgentLaunchSnapshot {
|
||||
return {
|
||||
version: 1,
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
baseAgent: 'codex',
|
||||
displayLabel: 'Custom',
|
||||
mode: 'custom',
|
||||
argv: ['codex'],
|
||||
agentEnv: {},
|
||||
capturedEnvPolicy: 'none',
|
||||
target: {
|
||||
platform: 'linux',
|
||||
execution: 'native',
|
||||
shell: 'posix',
|
||||
isRemote: true,
|
||||
executionHostId: 'ssh:host'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function unknownFailure(): PersistedAgentLaunchFailure {
|
||||
return {
|
||||
code: 'launch_state_unknown',
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
version: 1,
|
||||
failureId: FAILURE_ID,
|
||||
intent: 'background',
|
||||
occurredAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function spawnFailedFailure(): PersistedAgentLaunchFailure {
|
||||
return {
|
||||
code: 'spawn_failed',
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
baseAgent: 'codex',
|
||||
version: 1,
|
||||
failureId: FAILURE_ID,
|
||||
intent: 'background',
|
||||
occurredAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
function pending(): PendingAgentLaunchSnapshot {
|
||||
return {
|
||||
operationId: OPERATION_ID,
|
||||
idempotencyKey: agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: ATTEMPT_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
}),
|
||||
scope: ATTEMPT_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID,
|
||||
payloadDigest: 'digest',
|
||||
launchToken: LAUNCH_TOKEN,
|
||||
intent: 'background',
|
||||
snapshot: snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the exact forget deps the runtime method wires, over real stores. */
|
||||
function buildForgetHarness() {
|
||||
const opStore = new AgentLaunchOperationStore()
|
||||
const bgStore = new BackgroundAgentLaunchStore({ now: () => 5000 })
|
||||
bgStore.create({
|
||||
attemptId: ATTEMPT_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
operationId: OPERATION_ID,
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
baseAgent: 'codex'
|
||||
})
|
||||
bgStore.markUnknown(ATTEMPT_ID, unknownFailure())
|
||||
opStore.beginPending(pending())
|
||||
const releaseReservation = vi.fn<(launchToken: string) => void>()
|
||||
return { opStore, bgStore, releaseReservation }
|
||||
}
|
||||
|
||||
function forgetDeps(harness: ReturnType<typeof buildForgetHarness>) {
|
||||
const { opStore, bgStore, releaseReservation } = harness
|
||||
return {
|
||||
operationStore: opStore,
|
||||
idempotencyKeyFor: (clientMutationId: string) =>
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: ATTEMPT_ID,
|
||||
clientMutationId
|
||||
}),
|
||||
loadPendingSnapshot: () => opStore.findPendingByScope(ATTEMPT_ID),
|
||||
loadFailureCode: () => bgStore.get(ATTEMPT_ID)?.failure?.code,
|
||||
releaseReservation,
|
||||
clearPublicState: () => {
|
||||
bgStore.forget(ATTEMPT_ID)
|
||||
},
|
||||
now: () => 5000
|
||||
}
|
||||
}
|
||||
|
||||
describe('background Forget (ledger #13)', () => {
|
||||
it('frees exactly one reservation, settles forgotten, retains the failure, never spawns/kills', () => {
|
||||
const harness = buildForgetHarness()
|
||||
const result = runForgetUnknownAgentLaunch(forgetDeps(harness), {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({ status: 'forgotten' })
|
||||
// Exactly one reservation freed (structurally there is no spawn/kill dep).
|
||||
expect(harness.releaseReservation).toHaveBeenCalledTimes(1)
|
||||
expect(harness.releaseReservation).toHaveBeenCalledWith(LAUNCH_TOKEN)
|
||||
// The attempt is forgotten, keeps its unknown failure, and stamps forgottenAt.
|
||||
const attempt = harness.bgStore.get(ATTEMPT_ID)
|
||||
expect(attempt?.state).toBe('forgotten')
|
||||
expect(attempt?.failure?.code).toBe('launch_state_unknown')
|
||||
expect(attempt?.forgottenAt).toBe(5000)
|
||||
// Private pending attribution removed.
|
||||
expect(harness.opStore.findPendingByScope(ATTEMPT_ID)).toBeNull()
|
||||
})
|
||||
|
||||
it('replays forgotten on a double submit without re-releasing', () => {
|
||||
const harness = buildForgetHarness()
|
||||
const params = {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedOperationId: OPERATION_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
}
|
||||
expect(runForgetUnknownAgentLaunch(forgetDeps(harness), params)).toEqual({
|
||||
status: 'forgotten'
|
||||
})
|
||||
harness.releaseReservation.mockClear()
|
||||
expect(runForgetUnknownAgentLaunch(forgetDeps(harness), params)).toEqual({
|
||||
status: 'forgotten'
|
||||
})
|
||||
expect(harness.releaseReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a stale operation id without mutation', () => {
|
||||
const harness = buildForgetHarness()
|
||||
const result = runForgetUnknownAgentLaunch(forgetDeps(harness), {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedOperationId: 'op-stale',
|
||||
clientMutationId: CLIENT_MUTATION_ID
|
||||
})
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
expect(harness.releaseReservation).not.toHaveBeenCalled()
|
||||
expect(harness.bgStore.get(ATTEMPT_ID)?.state).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
function buildRetryHarness(failure: PersistedAgentLaunchFailure) {
|
||||
const opStore = new AgentLaunchOperationStore()
|
||||
const bgStore = new BackgroundAgentLaunchStore()
|
||||
bgStore.create({
|
||||
attemptId: ATTEMPT_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
operationId: OPERATION_ID,
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
baseAgent: 'codex'
|
||||
})
|
||||
if (failure.code === 'launch_state_unknown') {
|
||||
bgStore.markUnknown(ATTEMPT_ID, failure)
|
||||
} else {
|
||||
bgStore.settleFailed(ATTEMPT_ID, failure)
|
||||
}
|
||||
const runLaunch = vi.fn().mockResolvedValue({
|
||||
status: 'launched',
|
||||
receipt: {
|
||||
requestedAgent: 'custom-agent:codex:x',
|
||||
baseAgent: 'codex',
|
||||
notices: [],
|
||||
launchToken: 'token-retry',
|
||||
catalogRevision: 1
|
||||
}
|
||||
})
|
||||
const inFlight = new Map<string, WorktreeRetryInFlight>()
|
||||
const deps = {
|
||||
operationStore: opStore,
|
||||
idempotencyKeyFor: (clientMutationId: string) =>
|
||||
agentLaunchIdempotencyKey({
|
||||
principal: { kind: 'local' },
|
||||
scope: ATTEMPT_ID,
|
||||
clientMutationId
|
||||
}),
|
||||
findInFlight: (key: string) => inFlight.get(key) ?? null,
|
||||
registerInFlight: (
|
||||
key: string,
|
||||
digest: string,
|
||||
promise: Promise<WorktreeRetryAgentLaunchResult>
|
||||
): void => {
|
||||
inFlight.set(key, { payloadDigest: digest, promise })
|
||||
},
|
||||
resolveSettled: () => ({
|
||||
status: 'blocked' as const,
|
||||
failure: { code: 'launch_state_unknown' as const }
|
||||
}),
|
||||
loadDurableFailure: () => bgStore.get(ATTEMPT_ID)?.failure ?? null,
|
||||
resolveRecoveryGate: () =>
|
||||
retryRecoveryGateForFailureCode(bgStore.get(ATTEMPT_ID)?.failure?.code),
|
||||
runLaunch
|
||||
}
|
||||
return { deps, runLaunch, bgStore }
|
||||
}
|
||||
|
||||
describe('background Retry gating (ledger #13)', () => {
|
||||
it('blocks a retry while launch_state_unknown WITHOUT running the launch', async () => {
|
||||
const { deps, runLaunch } = buildRetryHarness(unknownFailure())
|
||||
const result = await runWorktreeRetryAgentLaunch(deps, {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedFailureId: FAILURE_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID,
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
expect(result).toEqual({ status: 'blocked', failure: { code: 'launch_state_unknown' } })
|
||||
expect(runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the launch for a retryable settled failure', async () => {
|
||||
const { deps, runLaunch } = buildRetryHarness(spawnFailedFailure())
|
||||
const result = await runWorktreeRetryAgentLaunch(deps, {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedFailureId: FAILURE_ID,
|
||||
clientMutationId: CLIENT_MUTATION_ID,
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
expect(result).toMatchObject({ status: 'launched' })
|
||||
expect(runLaunch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a stale failure id without running the launch', async () => {
|
||||
const { deps, runLaunch } = buildRetryHarness(spawnFailedFailure())
|
||||
const result = await runWorktreeRetryAgentLaunch(deps, {
|
||||
scope: ATTEMPT_ID,
|
||||
expectedFailureId: 'wrong-id',
|
||||
clientMutationId: CLIENT_MUTATION_ID,
|
||||
action: { kind: 'retry-same' }
|
||||
})
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
requestError: { code: 'stale_agent_launch_failure' }
|
||||
})
|
||||
expect(runLaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
beginBackgroundDeclarationLaunch,
|
||||
settleBackgroundDeclarationResolution,
|
||||
settleBackgroundDeclarationSpawn,
|
||||
type BackgroundDeclarationDeps
|
||||
} from './background-agent-launch-spawn-declaration'
|
||||
import type { AgentLaunchSpawnResolution } from './agent-launch-spawn'
|
||||
import type { AgentLaunchReceipt } from '../../shared/agent-launch-contract'
|
||||
import type { AgentStartupPlan } from '../../shared/tui-agent-startup'
|
||||
import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store'
|
||||
|
||||
const WORKTREE_ID = 'repo-1:wt-a'
|
||||
const REQUESTED = 'custom-agent:codex:deleted'
|
||||
|
||||
function buildDeps(): BackgroundDeclarationDeps & {
|
||||
created: BackgroundAgentLaunchCreateInput[]
|
||||
settledFailed: { attemptId: string; code: string }[]
|
||||
settledLaunched: string[]
|
||||
rolledBack: string[]
|
||||
} {
|
||||
const created: BackgroundAgentLaunchCreateInput[] = []
|
||||
const settledFailed: { attemptId: string; code: string }[] = []
|
||||
const settledLaunched: string[] = []
|
||||
const rolledBack: string[] = []
|
||||
let attemptSeq = 0
|
||||
let opSeq = 0
|
||||
let failureSeq = 0
|
||||
return {
|
||||
created,
|
||||
settledFailed,
|
||||
settledLaunched,
|
||||
rolledBack,
|
||||
createAttempt: (input) => created.push(input),
|
||||
settleLaunched: (attemptId) => settledLaunched.push(attemptId),
|
||||
settleFailed: (attemptId, failure) => settledFailed.push({ attemptId, code: failure.code }),
|
||||
rollback: (attemptId) => rolledBack.push(attemptId),
|
||||
mintAttemptId: () => `attempt-${(attemptSeq += 1)}`,
|
||||
mintOperationId: () => `op-${(opSeq += 1)}`,
|
||||
mintFailureId: () => `failure-${(failureSeq += 1)}`,
|
||||
now: () => 1000
|
||||
}
|
||||
}
|
||||
|
||||
function launchedResolution(): AgentLaunchSpawnResolution {
|
||||
const receipt: AgentLaunchReceipt = {
|
||||
requestedAgent: REQUESTED,
|
||||
baseAgent: 'codex',
|
||||
notices: [],
|
||||
launchToken: 'token-1',
|
||||
catalogRevision: 1,
|
||||
telemetry: { agentKind: 'codex', usedCustomAgent: true }
|
||||
}
|
||||
return { ok: true, plan: {} as AgentStartupPlan, receipt }
|
||||
}
|
||||
|
||||
describe('background declaration handler', () => {
|
||||
it('creates the attempt BEFORE resolution with the declared identity and a background intent', () => {
|
||||
const deps = buildDeps()
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
|
||||
expect(deps.created).toHaveLength(1)
|
||||
expect(deps.created[0]).toEqual({
|
||||
attemptId: launch.attemptId,
|
||||
worktreeId: WORKTREE_ID,
|
||||
operationId: 'op-1',
|
||||
// The stale custom id is preserved verbatim so the failed attempt names it.
|
||||
requestedAgent: REQUESTED,
|
||||
baseAgent: null
|
||||
})
|
||||
// The host mints its own intent + attempt-keyed scope; the client never sends either.
|
||||
expect(launch.intent).toEqual({
|
||||
kind: 'background',
|
||||
attemptId: launch.attemptId,
|
||||
worktreeId: WORKTREE_ID
|
||||
})
|
||||
expect(launch.scope).toBe(launch.attemptId)
|
||||
})
|
||||
|
||||
it('records a durable failed attempt for a resolution failure (survives reload) and retains it', () => {
|
||||
const deps = buildDeps()
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, {
|
||||
ok: false,
|
||||
failure: { code: 'unknown_agent', requestedAgent: REQUESTED }
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({ proceed: false, attemptRetained: true })
|
||||
expect(deps.settledFailed).toEqual([{ attemptId: launch.attemptId, code: 'unknown_agent' }])
|
||||
expect(deps.rolledBack).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls the attempt back for a pre-attempt capacity rejection (admission creates no attempt)', () => {
|
||||
const deps = buildDeps()
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, {
|
||||
ok: false,
|
||||
failure: { code: 'launch_capacity_exceeded' }
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({ proceed: false, attemptRetained: false })
|
||||
expect(deps.rolledBack).toEqual([launch.attemptId])
|
||||
expect(deps.settledFailed).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls the attempt back for a request error', () => {
|
||||
const deps = buildDeps()
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
const outcome = settleBackgroundDeclarationResolution(deps, launch.attemptId, {
|
||||
ok: false,
|
||||
requestError: { code: 'idempotency_conflict' }
|
||||
})
|
||||
|
||||
expect(outcome).toEqual({ proceed: false, attemptRetained: false })
|
||||
expect(deps.rolledBack).toEqual([launch.attemptId])
|
||||
expect(deps.settledFailed).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the attempt pending on a successful resolution and settles it on the provider events', () => {
|
||||
const deps = buildDeps()
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
const outcome = settleBackgroundDeclarationResolution(
|
||||
deps,
|
||||
launch.attemptId,
|
||||
launchedResolution()
|
||||
)
|
||||
expect(outcome).toEqual({ proceed: true, attemptRetained: true })
|
||||
// No settle at resolution time — the attempt stays pending until spawn.
|
||||
expect(deps.settledLaunched).toEqual([])
|
||||
expect(deps.settledFailed).toEqual([])
|
||||
|
||||
settleBackgroundDeclarationSpawn(deps, launch, 'registered', REQUESTED)
|
||||
expect(deps.settledLaunched).toEqual([launch.attemptId])
|
||||
|
||||
// A later spawn-failure settle on a different attempt records spawn_failed.
|
||||
const other = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
settleBackgroundDeclarationSpawn(deps, other, 'failed', REQUESTED)
|
||||
expect(deps.settledFailed).toEqual([{ attemptId: other.attemptId, code: 'spawn_failed' }])
|
||||
})
|
||||
})
|
||||
|
||||
// Exercise the injected now() so the persisted-failure envelope timestamp is covered.
|
||||
it('stamps the host-minted persisted failure envelope', () => {
|
||||
const deps = buildDeps()
|
||||
const spy = vi.spyOn(deps, 'settleFailed')
|
||||
const launch = beginBackgroundDeclarationLaunch(deps, {
|
||||
worktreeId: WORKTREE_ID,
|
||||
requestedAgent: REQUESTED
|
||||
})
|
||||
settleBackgroundDeclarationResolution(deps, launch.attemptId, {
|
||||
ok: false,
|
||||
failure: { code: 'missing_variable', variable: 'worktreePath' }
|
||||
})
|
||||
expect(spy).toHaveBeenCalledWith(launch.attemptId, {
|
||||
code: 'missing_variable',
|
||||
variable: 'worktreePath',
|
||||
version: 1,
|
||||
failureId: 'failure-1',
|
||||
intent: 'background',
|
||||
occurredAt: 1000
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
// Host handler for the ids-free background DECLARATION on a pty:spawn agentLaunch
|
||||
// request (U6; ledger #8/#13). The client NEVER sends a LaunchIntent or an
|
||||
// attemptId — it only declares `unattended: {kind:'background'}`; the HOST mints
|
||||
// the attemptId, creates the generic background attempt BEFORE resolution, builds
|
||||
// its own LaunchIntent {kind:'background', attemptId, worktreeId}, and settles or
|
||||
// rolls the attempt back with the spawn outcome, returning the attemptId in-band
|
||||
// so a future renderer producer can correlate it to its worktree deep link.
|
||||
//
|
||||
// There is NO production sender in U6 (ruling #13): launchAgentBackgroundSession
|
||||
// is automation-owned and github-background is WorktreeMeta-owned, so no genuine
|
||||
// ownerless background surface exists yet and none is synthesized. The first real
|
||||
// sender (a U9 deep-link launch) reuses this handler unchanged. Pure and
|
||||
// injectable; the pty:spawn caller owns the resolver call and the PTY spawn.
|
||||
|
||||
import type { LaunchIntent } from '../../shared/agent-launch-host-contract'
|
||||
import type {
|
||||
AgentLaunchFailure,
|
||||
PersistedAgentLaunchFailure
|
||||
} from '../../shared/agent-launch-contract'
|
||||
import type { TuiAgent } from '../../shared/types'
|
||||
import type { AgentLaunchSpawnResolution } from './agent-launch-spawn'
|
||||
import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store'
|
||||
|
||||
export type BackgroundDeclarationDeps = {
|
||||
/** Create the attempt in the generic background store (before resolution). */
|
||||
createAttempt: (input: BackgroundAgentLaunchCreateInput) => void
|
||||
/** Settle a launched attempt at the registration event. */
|
||||
settleLaunched: (attemptId: string) => void
|
||||
/** Record a durable `failed` attempt (a resolution failure that is neither a
|
||||
* request error nor a pre-attempt capacity rejection). */
|
||||
settleFailed: (attemptId: string, failure: PersistedAgentLaunchFailure) => void
|
||||
/** Drop the attempt entirely — used for a request error or capacity rejection
|
||||
* so neither ever enters attempt history. */
|
||||
rollback: (attemptId: string) => void
|
||||
mintAttemptId: () => string
|
||||
mintOperationId: () => string
|
||||
mintFailureId: () => string
|
||||
now?: () => number
|
||||
}
|
||||
|
||||
export type BackgroundDeclarationLaunch = {
|
||||
attemptId: string
|
||||
intent: Extract<LaunchIntent, { kind: 'background' }>
|
||||
/** The op-store/idempotency scope for this launch is the attempt id, never the
|
||||
* worktree — a worktree may host several unattended attempts at once. */
|
||||
scope: string
|
||||
}
|
||||
|
||||
/** Create the generic background attempt BEFORE resolution and hand back the
|
||||
* host-minted intent + scope for the resolver. `requestedAgent` is the client's
|
||||
* declared selection identity, preserved verbatim (a stale custom id keeps the
|
||||
* requested-vs-fallback distinction, so its failed attempt names the right id and
|
||||
* survives reload). `baseAgent` is unknown until resolution, so it starts null. */
|
||||
export function beginBackgroundDeclarationLaunch(
|
||||
deps: BackgroundDeclarationDeps,
|
||||
input: { worktreeId: string; requestedAgent: TuiAgent }
|
||||
): BackgroundDeclarationLaunch {
|
||||
const attemptId = deps.mintAttemptId()
|
||||
deps.createAttempt({
|
||||
attemptId,
|
||||
worktreeId: input.worktreeId,
|
||||
operationId: deps.mintOperationId(),
|
||||
requestedAgent: input.requestedAgent,
|
||||
baseAgent: null
|
||||
})
|
||||
return {
|
||||
attemptId,
|
||||
intent: { kind: 'background', attemptId, worktreeId: input.worktreeId },
|
||||
scope: attemptId
|
||||
}
|
||||
}
|
||||
|
||||
export type BackgroundDeclarationResolutionOutcome = {
|
||||
/** True only for a successful resolution — the caller proceeds to spawn and the
|
||||
* spawn/registration seam settles the still-`pending` attempt. */
|
||||
proceed: boolean
|
||||
/** True when the attempt is retained (settled `failed`); false when it was
|
||||
* rolled back. Drives whether the caller echoes `backgroundAttemptId`. */
|
||||
attemptRetained: boolean
|
||||
}
|
||||
|
||||
/** Settle the attempt from the PRE-SPAWN resolution outcome. A request error or a
|
||||
* pre-attempt capacity rejection rolls the attempt back (§U6: request errors and
|
||||
* capacity rejection stay out of attempt history — "admission rejection creates
|
||||
* no generic attempt"). Any other resolution failure records a durable `failed`
|
||||
* attempt that survives reload (oracle 11). A success leaves the attempt
|
||||
* `pending` for the spawn/registration seam. */
|
||||
export function settleBackgroundDeclarationResolution(
|
||||
deps: BackgroundDeclarationDeps,
|
||||
attemptId: string,
|
||||
resolution: AgentLaunchSpawnResolution
|
||||
): BackgroundDeclarationResolutionOutcome {
|
||||
if (resolution.ok) {
|
||||
return { proceed: true, attemptRetained: true }
|
||||
}
|
||||
if ('requestError' in resolution) {
|
||||
deps.rollback(attemptId)
|
||||
return { proceed: false, attemptRetained: false }
|
||||
}
|
||||
if (resolution.failure.code === 'launch_capacity_exceeded') {
|
||||
deps.rollback(attemptId)
|
||||
return { proceed: false, attemptRetained: false }
|
||||
}
|
||||
deps.settleFailed(attemptId, persistBackgroundFailure(deps, resolution.failure))
|
||||
return { proceed: false, attemptRetained: true }
|
||||
}
|
||||
|
||||
/** Wrap a bare AgentLaunchFailure in the host-minted persisted envelope. The
|
||||
* intent is always `background` here — this handler only ever mints one kind. */
|
||||
export function persistBackgroundFailure(
|
||||
deps: BackgroundDeclarationDeps,
|
||||
failure: AgentLaunchFailure
|
||||
): PersistedAgentLaunchFailure {
|
||||
const nowFn = deps.now ?? Date.now
|
||||
return {
|
||||
...failure,
|
||||
version: 1,
|
||||
failureId: deps.mintFailureId(),
|
||||
intent: 'background',
|
||||
occurredAt: nowFn()
|
||||
}
|
||||
}
|
||||
|
||||
/** Settle the `pending` attempt from the SPAWN outcome (a provider event):
|
||||
* registration → `launched`; a spawn/registration throw → durable `failed`
|
||||
* (`spawn_failed`). Called from the caller's shared launch/settle seam. */
|
||||
export function settleBackgroundDeclarationSpawn(
|
||||
deps: BackgroundDeclarationDeps,
|
||||
launch: BackgroundDeclarationLaunch,
|
||||
settlement: 'registered' | 'failed',
|
||||
requestedAgent: TuiAgent
|
||||
): void {
|
||||
if (settlement === 'registered') {
|
||||
deps.settleLaunched(launch.attemptId)
|
||||
return
|
||||
}
|
||||
deps.settleFailed(
|
||||
launch.attemptId,
|
||||
persistBackgroundFailure(deps, { code: 'spawn_failed', requestedAgent })
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Host-wide singleton generic background-attempt store. One instance per host so
|
||||
// every background launch producer records its attempt and the reconciler/
|
||||
// tombstone index read the same private records. Durable persistence attaches at
|
||||
// boot; the in-memory instance backs create/settle/forget before that.
|
||||
|
||||
import { BackgroundAgentLaunchStore } from './background-agent-launch-store'
|
||||
|
||||
let store: BackgroundAgentLaunchStore | null = null
|
||||
|
||||
export function getHostBackgroundAgentLaunchStore(): BackgroundAgentLaunchStore {
|
||||
if (!store) {
|
||||
store = new BackgroundAgentLaunchStore()
|
||||
}
|
||||
return store
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { BackgroundAgentLaunchStore } from './background-agent-launch-store'
|
||||
import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store'
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import { parsePersistedAgentLaunchFailure } from '../../shared/agent-launch-failure-schema'
|
||||
import { retryRecoveryGateForFailureCode } from './agent-launch-reconciliation'
|
||||
|
||||
// The only keys a persisted launch failure may carry; anything outside this set
|
||||
// (an env key/value, argv element, label, or path) would be a leak.
|
||||
const ALLOWED_FAILURE_KEYS = new Set([
|
||||
'code',
|
||||
'requestedAgent',
|
||||
'baseAgent',
|
||||
'variable',
|
||||
'field',
|
||||
'shell',
|
||||
'reason',
|
||||
'version',
|
||||
'failureId',
|
||||
'intent',
|
||||
'occurredAt'
|
||||
])
|
||||
|
||||
function createInput(
|
||||
overrides: Partial<BackgroundAgentLaunchCreateInput> = {}
|
||||
): BackgroundAgentLaunchCreateInput {
|
||||
return {
|
||||
attemptId: 'attempt-1',
|
||||
worktreeId: 'repo-a::/srv/app',
|
||||
operationId: 'op-1',
|
||||
requestedAgent: 'codex',
|
||||
baseAgent: 'codex',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function failure(
|
||||
code: PersistedAgentLaunchFailure['code'],
|
||||
overrides: Partial<PersistedAgentLaunchFailure> = {}
|
||||
): PersistedAgentLaunchFailure {
|
||||
return {
|
||||
code,
|
||||
requestedAgent: 'codex',
|
||||
baseAgent: 'codex',
|
||||
version: 1,
|
||||
failureId: `fail-${code}`,
|
||||
intent: 'background',
|
||||
occurredAt: 10,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('BackgroundAgentLaunchStore', () => {
|
||||
it('creates an attempt in pending before resolution and is idempotent on attemptId', () => {
|
||||
const store = new BackgroundAgentLaunchStore({ now: () => 1 })
|
||||
const created = store.create(createInput())
|
||||
expect(created).toMatchObject({ state: 'pending', failure: null, forgottenAt: null })
|
||||
// A replay of the same attempt id returns the existing record unchanged.
|
||||
const replay = store.create(createInput({ requestedAgent: 'claude' }))
|
||||
expect(replay.requestedAgent).toBe('codex')
|
||||
expect(store.all()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('settles launched, clearing any prior failure', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.settleFailed('attempt-1', failure('spawn_failed'))
|
||||
store.settleLaunched('attempt-1')
|
||||
expect(store.get('attempt-1')).toMatchObject({ state: 'launched', failure: null })
|
||||
})
|
||||
|
||||
it('settles failed with the durable code+hint failure', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.settleFailed('attempt-1', failure('spawn_failed'))
|
||||
expect(store.get('attempt-1')).toMatchObject({
|
||||
state: 'failed',
|
||||
failure: { code: 'spawn_failed' }
|
||||
})
|
||||
})
|
||||
|
||||
it('markUnknown keeps the attempt pending and coexists with the unknown failure', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.markUnknown('attempt-1', failure('launch_state_unknown'))
|
||||
const attempt = store.get('attempt-1')
|
||||
expect(attempt?.state).toBe('pending')
|
||||
expect(attempt?.failure?.code).toBe('launch_state_unknown')
|
||||
})
|
||||
|
||||
it('keeps the launch_state_unknown failureId stable across reconcile re-runs', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.markUnknown('attempt-1', failure('launch_state_unknown', { failureId: 'first' }))
|
||||
store.markUnknown('attempt-1', failure('launch_state_unknown', { failureId: 'second' }))
|
||||
// A churning failureId would reset the client's expectedFailureId guard.
|
||||
expect(store.get('attempt-1')?.failure?.failureId).toBe('first')
|
||||
})
|
||||
|
||||
it('forgets only from launch_state_unknown, retaining the failure and stamping forgottenAt', () => {
|
||||
const store = new BackgroundAgentLaunchStore({ now: () => 77 })
|
||||
store.create(createInput())
|
||||
// Cannot forget a plain pending attempt (no unknown failure).
|
||||
expect(store.forget('attempt-1')).toBe(false)
|
||||
store.markUnknown('attempt-1', failure('launch_state_unknown'))
|
||||
expect(store.forget('attempt-1')).toBe(true)
|
||||
expect(store.get('attempt-1')).toMatchObject({
|
||||
state: 'forgotten',
|
||||
forgottenAt: 77,
|
||||
failure: { code: 'launch_state_unknown' }
|
||||
})
|
||||
// A second forget is a no-op (no longer unknown).
|
||||
expect(store.forget('attempt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('cannot forget a failed (not unknown) attempt', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.settleFailed('attempt-1', failure('spawn_failed'))
|
||||
expect(store.forget('attempt-1')).toBe(false)
|
||||
})
|
||||
|
||||
it('projects attempts filtered to a worktree', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput({ attemptId: 'a', worktreeId: 'wt-1' }))
|
||||
store.create(createInput({ attemptId: 'b', worktreeId: 'wt-1' }))
|
||||
store.create(createInput({ attemptId: 'c', worktreeId: 'wt-2' }))
|
||||
expect(store.listForWorktree('wt-1').map((a) => a.attemptId)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('exposes referenced requested agents including forgotten attempts', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput({ attemptId: 'a', requestedAgent: 'custom-agent:codex:1' }))
|
||||
store.markUnknown('a', failure('launch_state_unknown'))
|
||||
store.forget('a')
|
||||
expect(store.referencedRequestedAgents()).toContain('custom-agent:codex:1')
|
||||
})
|
||||
|
||||
it('drives the durable sink on every mutation and rebuilds without writing back', () => {
|
||||
const sink = vi.fn()
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.setDurablePersistence(sink)
|
||||
store.create(createInput())
|
||||
store.settleFailed('attempt-1', failure('spawn_failed'))
|
||||
expect(sink).toHaveBeenCalledTimes(2)
|
||||
const snapshot = store.durableState()
|
||||
|
||||
const rebuilt = new BackgroundAgentLaunchStore()
|
||||
const rebuiltSink = vi.fn()
|
||||
rebuilt.setDurablePersistence(rebuiltSink)
|
||||
rebuilt.rebuildFrom(snapshot.attempts)
|
||||
// Rehydrate must not echo back into the sink.
|
||||
expect(rebuiltSink).not.toHaveBeenCalled()
|
||||
expect(rebuilt.get('attempt-1')?.state).toBe('failed')
|
||||
})
|
||||
|
||||
it('durable-state round trip keeps the attempt failure secret-free and re-normalizable (G6)', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.settleFailed('attempt-1', failure('missing_variable', { field: 'env', shell: 'posix' }))
|
||||
|
||||
// The durable snapshot serialized to its on-disk form.
|
||||
const onDisk = JSON.stringify(store.durableState())
|
||||
const parsed = JSON.parse(onDisk) as ReturnType<BackgroundAgentLaunchStore['durableState']>
|
||||
const persistedFailure = parsed.attempts[0].failure
|
||||
// No secret text can appear in the failure record: an env key/value, argv
|
||||
// element, command, label or path would have to surface as a substring.
|
||||
const persistedFailureText = JSON.stringify(persistedFailure)
|
||||
for (const marker of ['agentEnv', 'agentArgs', 'argv', 'command', 'label', 'path']) {
|
||||
expect(persistedFailureText).not.toContain(marker)
|
||||
}
|
||||
// The stored failure carries only whitelisted keys and re-normalizes.
|
||||
for (const key of Object.keys(persistedFailure ?? {})) {
|
||||
expect(ALLOWED_FAILURE_KEYS.has(key)).toBe(true)
|
||||
}
|
||||
expect(parsePersistedAgentLaunchFailure(persistedFailure)).not.toBeNull()
|
||||
// A tampered on-disk blob with secret text fails normalization.
|
||||
expect(
|
||||
parsePersistedAgentLaunchFailure({ ...persistedFailure, agentEnv: { TOKEN: 'x' } })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('reload from disk keeps an unknown-state attempt non-retryable (G6)', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
store.markUnknown('attempt-1', failure('launch_state_unknown'))
|
||||
|
||||
// Persist to disk and rebuild a fresh store from the reloaded snapshot.
|
||||
const reloaded = JSON.parse(JSON.stringify(store.durableState())) as ReturnType<
|
||||
BackgroundAgentLaunchStore['durableState']
|
||||
>
|
||||
const rebuilt = new BackgroundAgentLaunchStore()
|
||||
rebuilt.rebuildFrom(reloaded.attempts)
|
||||
|
||||
const attempt = rebuilt.get('attempt-1')
|
||||
// Coexistence survives the reload: still pending with the unknown failure.
|
||||
expect(attempt?.state).toBe('pending')
|
||||
expect(attempt?.failure?.code).toBe('launch_state_unknown')
|
||||
// The retry gate still blocks — no auto-relaunch without an explicit owner
|
||||
// action, so the reconciler cannot re-dispatch the attempt.
|
||||
expect(retryRecoveryGateForFailureCode(attempt?.failure?.code).kind).toBe(
|
||||
'launch_state_unknown'
|
||||
)
|
||||
})
|
||||
|
||||
it('persistenceForAttempt binds the reconcile slice to one attempt', () => {
|
||||
const store = new BackgroundAgentLaunchStore()
|
||||
store.create(createInput())
|
||||
const persistence = store.persistenceForAttempt('attempt-1')
|
||||
persistence.markUnknown(failure('launch_state_unknown'))
|
||||
expect(store.get('attempt-1')?.failure?.code).toBe('launch_state_unknown')
|
||||
persistence.settleLaunched()
|
||||
expect(store.get('attempt-1')?.state).toBe('launched')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
// Host-private store for GENERIC background agent-launch attempts (U6). The
|
||||
// owner record for unattended launches that have no automation run or
|
||||
// orchestration dispatch to land in. Kept SEPARATE from the interactive
|
||||
// two-stage worktree pending launch (plan §U6) so a background failure survives
|
||||
// reload, points at its worktree, and reconciles through the shared tri-state
|
||||
// reconciler without conflating with WorktreeMeta.
|
||||
//
|
||||
// State model mirrors WorktreeMeta.pendingAgentLaunch + agentLaunchFailure:
|
||||
// pending → created before resolution; may coexist with a
|
||||
// launch_state_unknown failure (the reservation and
|
||||
// private snapshot survive until proof or Forget).
|
||||
// launched → settled success; failure cleared.
|
||||
// failed → durable spawn/invalid failure; retryable.
|
||||
// forgotten → owner forgot an unknown attempt; failure retained,
|
||||
// forgottenAt stamped; the reservation is freed.
|
||||
//
|
||||
// Pure container: admission/liveness/persistence orchestration live in the
|
||||
// runtime; this store only owns the record lifecycle and its durable sink.
|
||||
|
||||
import type {
|
||||
BackgroundAgentLaunchAttempt,
|
||||
BackgroundAgentLaunchState
|
||||
} from '../../shared/background-agent-launch'
|
||||
import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract'
|
||||
import type { TuiAgent, BuiltInTuiAgent } from '../../shared/types'
|
||||
import type { ReconcileScopePersistence } from './agent-launch-worktree-reconcile-writer'
|
||||
|
||||
/** Fields fixed when an attempt is created (before resolution). */
|
||||
export type BackgroundAgentLaunchCreateInput = {
|
||||
attemptId: string
|
||||
worktreeId: string
|
||||
operationId: string
|
||||
requestedAgent: TuiAgent
|
||||
baseAgent: BuiltInTuiAgent | null
|
||||
}
|
||||
|
||||
/** The durable half snapshotted for the host-private sink: every attempt. */
|
||||
export type BackgroundAgentLaunchStoreDurableState = {
|
||||
attempts: readonly BackgroundAgentLaunchAttempt[]
|
||||
}
|
||||
|
||||
export class BackgroundAgentLaunchStore {
|
||||
private readonly attempts = new Map<string, BackgroundAgentLaunchAttempt>()
|
||||
private readonly now: () => number
|
||||
private onDurableMutation: ((state: BackgroundAgentLaunchStoreDurableState) => void) | null = null
|
||||
|
||||
constructor(deps?: { now?: () => number }) {
|
||||
this.now = deps?.now ?? (() => Date.now())
|
||||
}
|
||||
|
||||
/** Attach (or replace) the durable sink. Not called during rehydrate, so the
|
||||
* load path never writes back the state it just read. */
|
||||
setDurablePersistence(sink: (state: BackgroundAgentLaunchStoreDurableState) => void): void {
|
||||
this.onDurableMutation = sink
|
||||
}
|
||||
|
||||
durableState(): BackgroundAgentLaunchStoreDurableState {
|
||||
return { attempts: [...this.attempts.values()] }
|
||||
}
|
||||
|
||||
private persistDurable(): void {
|
||||
this.onDurableMutation?.(this.durableState())
|
||||
}
|
||||
|
||||
/** Create the attempt BEFORE resolution. Idempotent on attemptId: a repeated
|
||||
* create (idempotency replay) returns the existing record unchanged. */
|
||||
create(input: BackgroundAgentLaunchCreateInput): BackgroundAgentLaunchAttempt {
|
||||
const existing = this.attempts.get(input.attemptId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const at = this.now()
|
||||
const attempt: BackgroundAgentLaunchAttempt = {
|
||||
attemptId: input.attemptId,
|
||||
worktreeId: input.worktreeId,
|
||||
operationId: input.operationId,
|
||||
requestedAgent: input.requestedAgent,
|
||||
baseAgent: input.baseAgent,
|
||||
state: 'pending',
|
||||
failure: null,
|
||||
createdAt: at,
|
||||
updatedAt: at,
|
||||
forgottenAt: null
|
||||
}
|
||||
this.attempts.set(attempt.attemptId, attempt)
|
||||
this.persistDurable()
|
||||
return attempt
|
||||
}
|
||||
|
||||
get(attemptId: string): BackgroundAgentLaunchAttempt | null {
|
||||
return this.attempts.get(attemptId) ?? null
|
||||
}
|
||||
|
||||
/** Client-safe projection filtered to one worktree (Worktree.backgroundAgentLaunches). */
|
||||
listForWorktree(worktreeId: string): BackgroundAgentLaunchAttempt[] {
|
||||
return [...this.attempts.values()].filter((a) => a.worktreeId === worktreeId)
|
||||
}
|
||||
|
||||
all(): BackgroundAgentLaunchAttempt[] {
|
||||
return [...this.attempts.values()]
|
||||
}
|
||||
|
||||
/** Requested identities of every live attempt, for the tombstone reference
|
||||
* index's background owner (§217). A forgotten attempt still references its
|
||||
* id until pruned, keeping a deleted custom id's tombstone retained. */
|
||||
referencedRequestedAgents(): TuiAgent[] {
|
||||
return [...this.attempts.values()].map((a) => a.requestedAgent)
|
||||
}
|
||||
|
||||
private transition(
|
||||
attemptId: string,
|
||||
state: BackgroundAgentLaunchState,
|
||||
failure: PersistedAgentLaunchFailure | null,
|
||||
forgottenAt: number | null
|
||||
): BackgroundAgentLaunchAttempt | null {
|
||||
const attempt = this.attempts.get(attemptId)
|
||||
if (!attempt) {
|
||||
return null
|
||||
}
|
||||
const next: BackgroundAgentLaunchAttempt = {
|
||||
...attempt,
|
||||
state,
|
||||
failure,
|
||||
forgottenAt,
|
||||
updatedAt: this.now()
|
||||
}
|
||||
this.attempts.set(attemptId, next)
|
||||
this.persistDurable()
|
||||
return next
|
||||
}
|
||||
|
||||
settleLaunched(attemptId: string): void {
|
||||
this.transition(attemptId, 'launched', null, null)
|
||||
}
|
||||
|
||||
settleFailed(attemptId: string, failure: PersistedAgentLaunchFailure): void {
|
||||
this.transition(attemptId, 'failed', failure, null)
|
||||
}
|
||||
|
||||
/** Coexistence rule: keep the attempt `pending` and record ONLY the durable
|
||||
* unknown failure. Keeps an existing launch_state_unknown failureId stable so
|
||||
* the client's expectedFailureId guard does not churn across reconcile re-runs. */
|
||||
markUnknown(attemptId: string, failure: PersistedAgentLaunchFailure): void {
|
||||
const attempt = this.attempts.get(attemptId)
|
||||
if (!attempt) {
|
||||
return
|
||||
}
|
||||
const stableFailure =
|
||||
attempt.failure?.code === 'launch_state_unknown'
|
||||
? { ...failure, failureId: attempt.failure.failureId }
|
||||
: failure
|
||||
this.transition(attemptId, 'pending', stableFailure, null)
|
||||
}
|
||||
|
||||
/** Owner-authorized Forget of an unknown attempt. Retains the failure, stamps
|
||||
* forgottenAt, moves to `forgotten`. Never spawns/kills — the caller frees the
|
||||
* admission reservation separately. Only valid from a launch_state_unknown
|
||||
* attempt; other states return false without mutation. */
|
||||
forget(attemptId: string): boolean {
|
||||
const attempt = this.attempts.get(attemptId)
|
||||
// Valid only from the coexistence state (pending + unknown failure). A
|
||||
// forgotten attempt retains its unknown failure, so also gate on `pending`
|
||||
// to reject a second forget.
|
||||
if (
|
||||
!attempt ||
|
||||
attempt.state !== 'pending' ||
|
||||
attempt.failure?.code !== 'launch_state_unknown'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
this.transition(attemptId, 'forgotten', attempt.failure, this.now())
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drop an attempt entirely (retention pruning; never a recovery path). */
|
||||
delete(attemptId: string): boolean {
|
||||
const deleted = this.attempts.delete(attemptId)
|
||||
if (deleted) {
|
||||
this.persistDurable()
|
||||
}
|
||||
return deleted
|
||||
}
|
||||
|
||||
/** The reconcile persistence slice for one attempt, bound so the shared writer
|
||||
* drives settle/markUnknown by scope=attemptId. */
|
||||
persistenceForAttempt(attemptId: string): ReconcileScopePersistence {
|
||||
return {
|
||||
settleLaunched: () => this.settleLaunched(attemptId),
|
||||
settleFailed: (failure) => this.settleFailed(attemptId, failure),
|
||||
markUnknown: (failure) => this.markUnknown(attemptId, failure)
|
||||
}
|
||||
}
|
||||
|
||||
/** Rehydrate attempts at startup. Not routed through the sink. */
|
||||
rebuildFrom(attempts: Iterable<BackgroundAgentLaunchAttempt>): void {
|
||||
this.attempts.clear()
|
||||
for (const attempt of attempts) {
|
||||
this.attempts.set(attempt.attemptId, attempt)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { CustomTuiAgentId } from '../../shared/types'
|
||||
import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch'
|
||||
import {
|
||||
composeAgentLaunchEnv,
|
||||
measurePosixArgEnvBytes,
|
||||
measureWindowsEnvironmentBlockCodeUnits,
|
||||
ORCA_PROTECTED_ENV_KEYS
|
||||
} from './compose-agent-launch-env'
|
||||
import { checkEnvPayloadTooLarge } from './agent-launch-payload-caps'
|
||||
import {
|
||||
catalogOf,
|
||||
customAgent,
|
||||
customId,
|
||||
requestOf,
|
||||
settingsOf
|
||||
} from './agent-launch-test-catalog'
|
||||
|
||||
const CID: CustomTuiAgentId = customId('claude', '00000000-0000-4000-8000-0000000000e1')
|
||||
|
||||
function envOf(outcome: ResolveAgentLaunchOutcome): Record<string, string> {
|
||||
if (!outcome.ok) {
|
||||
throw new Error(`expected launch, got ${JSON.stringify(outcome)}`)
|
||||
}
|
||||
return { ...outcome.launch.agentEnv }
|
||||
}
|
||||
|
||||
describe('composeAgentLaunchEnv layering', () => {
|
||||
it('merges Path/PATH case variants case-insensitively on win32, last layer wins', () => {
|
||||
const env = composeAgentLaunchEnv({
|
||||
platform: 'win32',
|
||||
inherited: { Path: 'a' },
|
||||
agentEnv: { PATH: 'b' }
|
||||
})
|
||||
const keys = Object.keys(env)
|
||||
expect(keys).toEqual(['PATH'])
|
||||
expect(env.PATH).toBe('b')
|
||||
})
|
||||
|
||||
it('keeps distinct-case keys separate on posix', () => {
|
||||
const env = composeAgentLaunchEnv({
|
||||
platform: 'linux',
|
||||
inherited: { Path: 'a' },
|
||||
agentEnv: { PATH: 'b' }
|
||||
})
|
||||
expect(env.Path).toBe('a')
|
||||
expect(env.PATH).toBe('b')
|
||||
})
|
||||
|
||||
it('regenerates every protected-key case variant last', () => {
|
||||
const env = composeAgentLaunchEnv({
|
||||
platform: 'linux',
|
||||
inherited: { orca_pane_key: 'spoof', ORCA_PANE_KEY: 'stale', Orca_Pane_Key: 'stale2' },
|
||||
orcaControl: { ORCA_PANE_KEY: 'fresh' }
|
||||
})
|
||||
const paneKeys = Object.keys(env).filter((key) => key.toLowerCase() === 'orca_pane_key')
|
||||
expect(paneKeys).toEqual(['ORCA_PANE_KEY'])
|
||||
expect(env.ORCA_PANE_KEY).toBe('fresh')
|
||||
})
|
||||
|
||||
it('deletes an inherited protected key even without a fresh replacement', () => {
|
||||
const env = composeAgentLaunchEnv({
|
||||
platform: 'linux',
|
||||
inherited: { ORCA_AGENT_HOOK_TOKEN: 'stale' }
|
||||
})
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
it('recomputes shadow keys from the effective user env before protected keys', () => {
|
||||
const env = composeAgentLaunchEnv({
|
||||
platform: 'linux',
|
||||
agentEnv: { CODEX_HOME: '/custom' },
|
||||
deriveShadowKeys: (effective) => ({ ORCA_CODEX_HOME_SHADOW: effective.CODEX_HOME ?? '' })
|
||||
})
|
||||
expect(env.ORCA_CODEX_HOME_SHADOW).toBe('/custom')
|
||||
})
|
||||
|
||||
it('never inherits a protected key from a user-overridable provider key list', () => {
|
||||
for (const key of ORCA_PROTECTED_ENV_KEYS) {
|
||||
expect(key.startsWith('ORCA_')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolver env admission', () => {
|
||||
it('a custom launch never inherits base agentDefaultEnv', () => {
|
||||
const outcome = resolveAgentLaunch(
|
||||
requestOf({ selection: { kind: 'agent', agent: CID } }),
|
||||
catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' } })] }),
|
||||
settingsOf({ agentDefaultEnv: { claude: { FOO: '1' } } })
|
||||
)
|
||||
const env = envOf(outcome)
|
||||
expect(env).toEqual({ BAR: '2' })
|
||||
expect(env.FOO).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a safe-fallback launch carries no env at all', () => {
|
||||
const outcome = resolveAgentLaunch(
|
||||
requestOf({
|
||||
selection: { kind: 'agent', agent: CID },
|
||||
intent: { kind: 'interactive', client: 'desktop' },
|
||||
reference: { kind: 'persisted', owner: 'default' }
|
||||
}),
|
||||
catalogOf({
|
||||
customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' } })],
|
||||
disabledTuiAgents: [CID]
|
||||
}),
|
||||
settingsOf({ agentDefaultEnv: { claude: { FOO: '1' } } })
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
throw new Error('expected safe-fallback launch')
|
||||
}
|
||||
expect(Object.keys(outcome.launch.agentEnv)).toEqual([])
|
||||
expect(outcome.launch.policy.env).toBe('none')
|
||||
})
|
||||
|
||||
it('withholds custom env for a mobile client without syncEnv and surfaces a notice', () => {
|
||||
const outcome = resolveAgentLaunch(
|
||||
requestOf({
|
||||
selection: { kind: 'agent', agent: CID },
|
||||
intent: { kind: 'interactive', client: 'mobile' }
|
||||
}),
|
||||
catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' }, syncEnv: false })] }),
|
||||
settingsOf()
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
throw new Error('expected launch')
|
||||
}
|
||||
expect(Object.keys(outcome.launch.agentEnv)).toEqual([])
|
||||
expect(outcome.launch.policy.env).toBe('withheld')
|
||||
expect(outcome.launch.notices.map((notice) => notice.code)).toContain('env_withheld')
|
||||
})
|
||||
|
||||
it('admits custom env for a mobile client when syncEnv is true', () => {
|
||||
const outcome = resolveAgentLaunch(
|
||||
requestOf({
|
||||
selection: { kind: 'agent', agent: CID },
|
||||
intent: { kind: 'interactive', client: 'mobile' }
|
||||
}),
|
||||
catalogOf({ customTuiAgents: [customAgent({ id: CID, env: { BAR: '2' }, syncEnv: true })] }),
|
||||
settingsOf()
|
||||
)
|
||||
expect(envOf(outcome)).toEqual({ BAR: '2' })
|
||||
if (outcome.ok) {
|
||||
expect(outcome.launch.policy.env).toBe('full')
|
||||
}
|
||||
})
|
||||
|
||||
it('reports env policy none for an empty custom env on desktop', () => {
|
||||
const outcome = resolveAgentLaunch(
|
||||
requestOf({ selection: { kind: 'agent', agent: CID } }),
|
||||
catalogOf({ customTuiAgents: [customAgent({ id: CID, env: {} })] }),
|
||||
settingsOf()
|
||||
)
|
||||
if (outcome.ok) {
|
||||
expect(outcome.launch.policy.env).toBe('none')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('env payload caps', () => {
|
||||
const posixTarget = {
|
||||
platform: 'linux' as NodeJS.Platform,
|
||||
execution: 'native' as const,
|
||||
isRemote: false
|
||||
}
|
||||
const winTarget = {
|
||||
platform: 'win32' as NodeJS.Platform,
|
||||
execution: 'native' as const,
|
||||
isRemote: false
|
||||
}
|
||||
|
||||
it('measures a native-windows environment block including terminators', () => {
|
||||
// "A=b" (3) + entry NUL (1) + final block terminator (1) = 5 code units.
|
||||
expect(measureWindowsEnvironmentBlockCodeUnits({ A: 'b' })).toBe(5)
|
||||
})
|
||||
|
||||
it('fails closed on an oversized native-windows environment block', () => {
|
||||
const env = { BIG: 'x'.repeat(40_000) }
|
||||
expect(checkEnvPayloadTooLarge(['claude'], env, winTarget)).toMatchObject({
|
||||
code: 'invalid_agent_env',
|
||||
reason: 'environment_block_too_large'
|
||||
})
|
||||
})
|
||||
|
||||
it('fails closed on an oversized POSIX combined argv+env payload', () => {
|
||||
const env = { BIG: 'x'.repeat(140_000) }
|
||||
expect(measurePosixArgEnvBytes(['claude'], env)).toBeGreaterThan(131_072)
|
||||
expect(checkEnvPayloadTooLarge(['claude'], env, posixTarget)).toMatchObject({
|
||||
code: 'invalid_agent_env',
|
||||
reason: 'arg_env_too_large'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts an env within the 16 KiB per-agent bound', () => {
|
||||
const env = { OK: 'x'.repeat(8_000) }
|
||||
expect(checkEnvPayloadTooLarge(['claude'], env, winTarget)).toBeNull()
|
||||
expect(checkEnvPayloadTooLarge(['claude'], env, posixTarget)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
// Shared case-aware environment composer for agent launches. Layers inherited,
|
||||
// provider-default, and admitted custom-agent env, then reapplies the fresh Orca
|
||||
// control/attribution keys LAST after deleting every case variant so a stale or
|
||||
// spoofed pane/hook/token value can never survive. Concrete provider-runtime and
|
||||
// Orca-minted layers are wired in U3; U2 uses this to measure the effective env
|
||||
// for payload caps and to prove custom/safe-fallback env never inherits base env.
|
||||
|
||||
import { utf8ByteLength } from '../../shared/custom-tui-agent-fields'
|
||||
|
||||
/** Combined final shell argv + effective environment UTF-8 budget for
|
||||
* POSIX/WSL/SSH targets (no writer cap exists at HEAD). */
|
||||
export const POSIX_ARG_ENV_SAFE_MAX_BYTES = 131_072
|
||||
/** Command-only UTF-8 budget shared by POSIX/WSL/SSH startup writers. */
|
||||
export const POSIX_STARTUP_COMMAND_MAX_BYTES = 131_072
|
||||
/** Native-Windows CreateProcess environment block ceiling, measured as
|
||||
* case-folded `key=value\0…\0` UTF-16 code units including the final terminator. */
|
||||
export const WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS = 32_767
|
||||
|
||||
// Fresh Orca attribution/control keys the host regenerates on every PTY launch.
|
||||
// Enumerated from `rg -oNI 'ORCA_[A-Z0-9_]+' src/main src/shared`; only the
|
||||
// identity/hook/attribution/control keys the host itself mints per launch belong
|
||||
// here — user-overridable provider keys (CODEX_HOME, GROK_HOME, OPENCODE_CONFIG_DIR,
|
||||
// …) are intentionally excluded so R29 keeps them user-settable. Custom-agent env
|
||||
// can never contain these (validation rejects any `orca_`-prefixed key), so this
|
||||
// deletion targets the inherited process env and provider layers.
|
||||
export const ORCA_PROTECTED_ENV_KEYS = [
|
||||
'ORCA_PANE_KEY',
|
||||
'ORCA_TAB_ID',
|
||||
'ORCA_TERMINAL_HANDLE',
|
||||
'ORCA_WORKTREE_ID',
|
||||
'ORCA_WORKTREE_PATH',
|
||||
'ORCA_ROOT_PATH',
|
||||
'ORCA_WORKSPACE_ID',
|
||||
'ORCA_WORKSPACE_NAME',
|
||||
'ORCA_PROFILE_ID',
|
||||
'ORCA_AGENT_MODE',
|
||||
'ORCA_AGENT_LAUNCH_TOKEN',
|
||||
'ORCA_AGENT_HOOK_PORT',
|
||||
'ORCA_AGENT_HOOK_TOKEN',
|
||||
'ORCA_AGENT_HOOK_ENDPOINT',
|
||||
'ORCA_AGENT_HOOK_ENV',
|
||||
'ORCA_AGENT_HOOK_VERSION',
|
||||
'ORCA_ATTRIBUTION_SHIM_DIR',
|
||||
'ORCA_ENABLE_GIT_ATTRIBUTION',
|
||||
'ORCA_GIT_COMMIT_TRAILER',
|
||||
'ORCA_SHELL_READY_MARKER',
|
||||
'ORCA_USER_DATA_PATH',
|
||||
'ORCA_AGENT_TEAMS_SHIM_DIR',
|
||||
'ORCA_AGENT_TEAMS_TEAM_ID',
|
||||
'ORCA_AGENT_TEAMS_TOKEN'
|
||||
] as const
|
||||
|
||||
export type EnvLayer = Readonly<Record<string, string>>
|
||||
|
||||
/** Recomputes base-specific derived shadow keys (e.g. a provider's HOME shadow)
|
||||
* from the effective user-overridable env. Concrete providers land in U3; the
|
||||
* hook keeps the composer reusable without importing provider modules. */
|
||||
export type DeriveShadowKeys = (effective: Readonly<Record<string, string>>) => EnvLayer
|
||||
|
||||
export type ComposeAgentLaunchEnvInput = {
|
||||
platform: NodeJS.Platform
|
||||
/** Inherited/process env (U3 supplies the real one). */
|
||||
inherited?: EnvLayer
|
||||
/** Non-user provider runtime defaults (U3). */
|
||||
providerDefaults?: EnvLayer
|
||||
/** Admitted custom-agent env; empty for built-in/safe-fallback launches. */
|
||||
agentEnv?: EnvLayer
|
||||
/** Fresh Orca control/attribution values minted per launch (U3). */
|
||||
orcaControl?: EnvLayer
|
||||
deriveShadowKeys?: DeriveShadowKeys
|
||||
}
|
||||
|
||||
function nullProtoEnv(): Record<string, string> {
|
||||
return Object.create(null) as Record<string, string>
|
||||
}
|
||||
|
||||
function deleteCaseVariants(target: Record<string, string>, key: string): void {
|
||||
const lower = key.toLowerCase()
|
||||
for (const existing of Object.keys(target)) {
|
||||
if (existing.toLowerCase() === lower) {
|
||||
delete target[existing]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyLayer(
|
||||
target: Record<string, string>,
|
||||
layer: EnvLayer | undefined,
|
||||
caseInsensitive: boolean
|
||||
): void {
|
||||
if (!layer) {
|
||||
return
|
||||
}
|
||||
// Own-property iteration only; layers may be null-prototype objects.
|
||||
for (const key of Object.keys(layer)) {
|
||||
const value = layer[key]
|
||||
if (caseInsensitive) {
|
||||
// Windows env is case-insensitive: drop any colliding variant before
|
||||
// setting so the later layer wins exactly once.
|
||||
deleteCaseVariants(target, key)
|
||||
} else {
|
||||
delete target[key]
|
||||
}
|
||||
target[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose the effective launch env from ordered layers. Windows merges keys
|
||||
* case-insensitively; every platform reapplies protected Orca keys last after
|
||||
* deleting all case variants. */
|
||||
export function composeAgentLaunchEnv(input: ComposeAgentLaunchEnvInput): Record<string, string> {
|
||||
const caseInsensitive = input.platform === 'win32'
|
||||
const env = nullProtoEnv()
|
||||
applyLayer(env, input.inherited, caseInsensitive)
|
||||
applyLayer(env, input.providerDefaults, caseInsensitive)
|
||||
applyLayer(env, input.agentEnv, caseInsensitive)
|
||||
|
||||
if (input.deriveShadowKeys) {
|
||||
// Shadow keys derive from user-overridable values, so recompute after the
|
||||
// agent env layer and before the protected keys reclaim their names.
|
||||
applyLayer(env, input.deriveShadowKeys(env), caseInsensitive)
|
||||
}
|
||||
|
||||
// Protected keys win over every prior case variant on all platforms so a
|
||||
// stale/spoofed pane/hook/token value can never leak past the host's own.
|
||||
for (const protectedKey of ORCA_PROTECTED_ENV_KEYS) {
|
||||
deleteCaseVariants(env, protectedKey)
|
||||
}
|
||||
applyLayer(env, input.orcaControl, caseInsensitive)
|
||||
return env
|
||||
}
|
||||
|
||||
/** Native-Windows environment-block size in UTF-16 code units: each entry is
|
||||
* `key=value\0`, with one extra terminating NUL after the final entry. */
|
||||
export function measureWindowsEnvironmentBlockCodeUnits(env: EnvLayer): number {
|
||||
let codeUnits = 0
|
||||
for (const key of Object.keys(env)) {
|
||||
codeUnits += `${key}=${env[key]}`.length + 1
|
||||
}
|
||||
return codeUnits + 1
|
||||
}
|
||||
|
||||
/** Combined UTF-8 byte size of the final shell argv plus the effective env, used
|
||||
* for the POSIX/WSL/SSH payload cap. */
|
||||
export function measurePosixArgEnvBytes(argv: readonly string[], env: EnvLayer): number {
|
||||
let bytes = 0
|
||||
for (const arg of argv) {
|
||||
bytes += utf8ByteLength(arg) + 1
|
||||
}
|
||||
for (const key of Object.keys(env)) {
|
||||
bytes += utf8ByteLength(`${key}=${env[key]}`) + 1
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldRejectLegacyCustomAgentLaunch } from './legacy-launch-custom-agent-guard'
|
||||
|
||||
const CUSTOM_ID = 'custom-agent:claude:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
describe('shouldRejectLegacyCustomAgentLaunch (U7 legacy built-in path)', () => {
|
||||
it('rejects a remote client naming a custom id with no agentLaunch', () => {
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: false,
|
||||
requestClientKind: 'mobile',
|
||||
requestedAgentId: CUSTOM_ID
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: false,
|
||||
requestClientKind: 'runtime',
|
||||
requestedAgentId: CUSTOM_ID
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a remote client naming a BUILT-IN id on the legacy path', () => {
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: false,
|
||||
requestClientKind: 'mobile',
|
||||
requestedAgentId: 'claude'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never rejects a trusted in-process caller (undefined clientKind) even with a custom id', () => {
|
||||
// Headless automation and desktop pass no authenticated clientKind and keep the
|
||||
// legacy path with their custom automation.agentId.
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: false,
|
||||
requestClientKind: undefined,
|
||||
requestedAgentId: CUSTOM_ID
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('never rejects when a host-atomic agentLaunch drives the launch (custom id is host-resolved)', () => {
|
||||
// A custom createdWithAgent alongside agentLaunch is legitimate attribution.
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: true,
|
||||
requestClientKind: 'mobile',
|
||||
requestedAgentId: CUSTOM_ID
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('does not reject when no agent id is present', () => {
|
||||
expect(
|
||||
shouldRejectLegacyCustomAgentLaunch({
|
||||
hasAgentLaunch: false,
|
||||
requestClientKind: 'mobile',
|
||||
requestedAgentId: undefined
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user