From a65b491f63ddd4673865c245f5aa6299922ef4a4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:11:09 -0700 Subject: [PATCH 01/49] feat(orchestration): inject the Orca session id into structured children and let the CLI act as it Every structured session's child (native Claude, native Codex, and the terminal view) carries ORCA_AGENT_SESSION_ID and reaches the Orca CLI. The CLI sends the id in the orchestration envelope; when present it is the caller, and a caller flag naming anyone else is refused before any request. The id is stripped from inherited PTY env and from the SSH host-CLI passthrough, and crosses into WSL so the host can refuse the cross-host claim. --- .../vitest-caller-identity-env-setup.ts | 8 +++ config/tsconfig.node.json | 1 + config/vitest.config.ts | 3 +- .../orchestration/message-check-handler.ts | 12 ++-- .../orchestration/message-send-handler.ts | 7 ++- .../orchestration/question-handler.ts | 4 +- .../handlers/orchestration/run-handlers.ts | 4 +- .../orchestration/terminal-identity.ts | 55 +++++++++++++++++- .../orchestration/worker-list-run-scope.ts | 8 ++- .../orchestration-compatibility-envelope.ts | 7 ++- .../claude-structured-launch-resolution.ts | 7 +-- .../codex-structured-child-environment.ts | 7 +-- src/main/ipc/pty/host-env/pi-agent.ts | 14 +++-- src/main/ipc/pty/host-env/spawn-env-keys.ts | 9 +++ src/main/ipc/pty/ipc/spawn-options.ts | 4 +- src/main/ipc/pty/runtime/spawn-options.ts | 4 +- src/main/pty/wsl-orca-env.ts | 4 ++ ...stration-structured-worker-session.test.ts | 6 +- .../rpc/orchestration-session-caller.test.ts | 2 +- .../rpc/orchestration-session-caller.ts | 2 + ...ctured-session-child-identity-env.test.ts} | 18 +++--- ... structured-session-child-identity-env.ts} | 57 ++++++++++--------- .../structured-worker-identity.test.ts | 4 +- .../runtime/structured-worker-identity.ts | 15 ++--- .../ssh/ssh-remote-cli-host-passthrough.ts | 6 ++ src/shared/agent-session-caller-env.ts | 17 ++++++ src/shared/orca-session-address.ts | 13 +++-- src/shared/structured-session-marker.ts | 19 +++++-- src/shared/structured-worker-handle.ts | 7 +++ 29 files changed, 234 insertions(+), 90 deletions(-) create mode 100644 config/scripts/vitest-caller-identity-env-setup.ts rename src/main/runtime/{structured-worker-child-identity-env.test.ts => structured-session-child-identity-env.test.ts} (88%) rename src/main/runtime/{structured-worker-child-identity-env.ts => structured-session-child-identity-env.ts} (50%) create mode 100644 src/shared/agent-session-caller-env.ts create mode 100644 src/shared/structured-worker-handle.ts diff --git a/config/scripts/vitest-caller-identity-env-setup.ts b/config/scripts/vitest-caller-identity-env-setup.ts new file mode 100644 index 00000000000..1d14998e46c --- /dev/null +++ b/config/scripts/vitest-caller-identity-env-setup.ts @@ -0,0 +1,8 @@ +/** + * Why: a structured chat exports its own orchestration caller identity to every child, including a + * test runner it launches. Inherited, it would decide which CLI identity branch a test exercises + * depending on who ran the suite; suites that need one set it themselves. + */ +for (const name of ['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) { + delete process.env[name] +} diff --git a/config/tsconfig.node.json b/config/tsconfig.node.json index c42b937a467..a998460a457 100644 --- a/config/tsconfig.node.json +++ b/config/tsconfig.node.json @@ -4,6 +4,7 @@ "../electron.vite.config.*", "./build-plugins/**/*", "./scripts/vitest-host-ports-setup.ts", + "./scripts/vitest-caller-identity-env-setup.ts", "../src/main/**/*", "../src/renderer/src/lib/skill-freshness-display-status.ts", "../src/preload/**/*", diff --git a/config/vitest.config.ts b/config/vitest.config.ts index beaf82f4e12..2514067e7cc 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -26,7 +26,8 @@ export default defineConfig({ setupFiles: [ resolve('config/scripts/happy-dom-offscreen-canvas.ts'), resolve('config/scripts/happy-dom-mutation-observer-retention.ts'), - resolve('config/scripts/vitest-host-ports-setup.ts') + resolve('config/scripts/vitest-host-ports-setup.ts'), + resolve('config/scripts/vitest-caller-identity-env-setup.ts') ], include: [ 'src/**/*.test.ts', diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index f2af827ee04..26cefcc06ea 100644 --- a/src/cli/handlers/orchestration/message-check-handler.ts +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -13,7 +13,7 @@ import { startCheckKeepalive } from './check-keepalive' import { callOrchestrationMutation } from './mutation-request' import { getOptionalPositiveIntegerValueFlag } from './numeric-flags' import { flushOrchestrationStdout, resolveCompatibilityCliCommand } from './runtime-compatibility' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { orchestrationCallerLabel, resolveOrchestrationTerminalHandle } from './terminal-identity' type CheckResult = { messages: MessageSummary[] @@ -41,12 +41,16 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') + // Why: a session names itself by its id alone; a terminal view's pane is not its identity. + const paneKey = + explicitTerminal || terminal === undefined ? undefined : process.env.ORCA_PANE_KEY + const callerLabel = orchestrationCallerLabel(terminal) const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null let result: Awaited>> try { result = await callOrchestrationMutation(client, flags, 'orchestration.check', { terminal, - terminalPaneKey: explicitTerminal ? undefined : process.env.ORCA_PANE_KEY || undefined, + terminalPaneKey: paneKey || undefined, // Why: old runtimes degrade peek to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, @@ -68,9 +72,9 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { } result = { ...result, - result: prepareOrchestrationCheckOutput(result.result, terminal, flags.has('format')) + result: prepareOrchestrationCheckOutput(result.result, callerLabel, flags.has('format')) } - printResult(result, json, (value) => formatOrchestrationCheckText(value, terminal)) + printResult(result, json, (value) => formatOrchestrationCheckText(value, callerLabel)) const compatibilityAck = result.result.legacyCompatibility?.ackMessageIds if (compatibilityAck && compatibilityAck.length > 0) { await flushOrchestrationStdout() diff --git a/src/cli/handlers/orchestration/message-send-handler.ts b/src/cli/handlers/orchestration/message-send-handler.ts index 088d1361e69..f79a846328b 100644 --- a/src/cli/handlers/orchestration/message-send-handler.ts +++ b/src/cli/handlers/orchestration/message-send-handler.ts @@ -2,6 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' +import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' import { requireWorkerDoneSettlement } from '../orchestration-worker-settlement' import { getOptionalStructuredMessagePayload } from './message-payload' import { callOrchestrationMutation } from './mutation-request' @@ -75,7 +76,8 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { if ( (type === 'worker_done' || type === 'heartbeat') && !getOptionalStringFlag(flags, 'from') && - !process.env.ORCA_TERMINAL_HANDLE + !process.env.ORCA_TERMINAL_HANDLE && + !readInjectedAgentSessionId() ) { // Why: focus isn't lifecycle authority — an identity-less subprocess must fail closed rather than guess the worker. throwNoActiveSenderTerminal() @@ -94,7 +96,8 @@ export const ORCHESTRATION_SEND_HANDLER: Record = { threadId: getOptionalStringFlag(flags, 'thread-id'), payload: getOptionalStructuredMessagePayload(flags), // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. - senderPaneKey: process.env.ORCA_PANE_KEY || undefined, + // A session names itself by its id alone. + senderPaneKey: from === undefined ? undefined : process.env.ORCA_PANE_KEY || undefined, waitForLifecycleSettlement: type === 'worker_done' ? true : undefined, devMode: isDevCliInvocation() } diff --git a/src/cli/handlers/orchestration/question-handler.ts b/src/cli/handlers/orchestration/question-handler.ts index bd9e239b6d5..67aaeb16e4d 100644 --- a/src/cli/handlers/orchestration/question-handler.ts +++ b/src/cli/handlers/orchestration/question-handler.ts @@ -103,8 +103,8 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record = { resolveOrchestrationCliExecutable(), 'orchestration', 'ask', - '--from', - from, + // A session's resume is flagless: its injected id names it again. + ...(from ? ['--from', from] : []), ...(dispatchCapability ? ['--dispatch-capability', dispatchCapability] : []), '--resume', messageId, diff --git a/src/cli/handlers/orchestration/run-handlers.ts b/src/cli/handlers/orchestration/run-handlers.ts index 7c77913df15..d8abe3533c2 100644 --- a/src/cli/handlers/orchestration/run-handlers.ts +++ b/src/cli/handlers/orchestration/run-handlers.ts @@ -39,7 +39,9 @@ export const ORCHESTRATION_RUN_HANDLERS: Record = { run: { id: string; objective: string } | null }>('orchestration.runCurrent', { from }) printResult(result, json, (r) => - r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + r.run + ? `${r.run.id} ${r.run.objective}` + : `No Run is bound to this ${from === undefined ? 'session' : 'terminal'}.` ) }, diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index e693d99079d..f9d72d76e3a 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -3,14 +3,25 @@ import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker' +import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +/** + * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: + * the orchestration envelope carries that id and the host binds the caller param to it, so nothing + * is resolved or guessed here. + */ export async function resolveOrchestrationTerminalHandle( flags: Map, cwd: string, client: RuntimeClient, flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} -): Promise { +): Promise { + if (resolveInjectedSessionCaller(flags, flagName)) { + return undefined + } const explicit = getOptionalStringFlag(flags, flagName) if (explicit) { return explicit @@ -157,11 +168,51 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } +/** + * The injected session id when this command runs as an agent session. The id wins over every other + * identity this process carries; a caller flag may restate that same session but never name + * another, and a conflicting one is refused here, before any request is sent. + */ +export function resolveInjectedSessionCaller( + flags: Map, + flagName: 'from' | 'terminal' +): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined + } + const declared = getOptionalStringFlag(flags, flagName) + if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { + throw new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + + `session:${sessionId}. No request was sent.` + ) + } + return sessionId +} + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string): boolean { + if (normalizeOrchestrationActor(value)?.id === sessionId) { + return true + } + const ownHandle = process.env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) && value === ownHandle +} + +/** How check output names its caller: the handle, or the session's address. */ +export function orchestrationCallerLabel(handle: string | undefined): string { + const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined + return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, client: RuntimeClient -): Promise { +): Promise { return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { validateEnvHandle: true }) diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 13037616153..83bfcd5b9e0 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,6 +1,9 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { resolveOrchestrationTerminalHandle } from './terminal-identity' +import { + resolveInjectedSessionCaller, + resolveOrchestrationTerminalHandle +} from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -20,6 +23,9 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } + // Outside the catch: a --terminal naming someone other than this session is refused, never + // widened into an unscoped listing. + resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/runtime/orchestration-compatibility-envelope.ts b/src/cli/runtime/orchestration-compatibility-envelope.ts index 8172e556dc1..7d6c0dfda80 100644 --- a/src/cli/runtime/orchestration-compatibility-envelope.ts +++ b/src/cli/runtime/orchestration-compatibility-envelope.ts @@ -1,12 +1,17 @@ import { randomUUID } from 'node:crypto' +import { readInjectedAgentSessionId } from '../../shared/agent-session-caller-env' import { readOrchestrationCompatibilityEvidence } from '../../shared/orchestration-compatibility-evidence' import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' export function createOrchestrationCompatibilityEnvelope( env: NodeJS.ProcessEnv ): RuntimeOrchestrationEnvelope { + const evidence = readOrchestrationCompatibilityEvidence(env) + // Read here, from this CLI's own environment only: the SSH paths build evidence from a remote + // shell's environment, where a session id could never name a session on this host. + const agentSessionId = readInjectedAgentSessionId(env) return { compatibilityInvocationId: randomUUID(), - orchestrationCompatibilityEvidence: readOrchestrationCompatibilityEvidence(env) + orchestrationCompatibilityEvidence: agentSessionId ? { ...evidence, agentSessionId } : evidence } } diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index a2b3d41bbfd..9821b6ab8ae 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../shared/agent-session-jou import { agentSessionProviderHandleChainHead } from '../../shared/agent-session-provider-handle' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' import { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, @@ -283,9 +283,8 @@ export function createClaudeStructuredLaunchResolver( (await deps.resolvePermissionMode?.()) ?? 'default' ) const { command, env } = await resolveClaudeStructuredInvocation(deps, (base) => - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - structuredWorkerChildIdentityEnv(record.sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + structuredSessionChildIdentityEnv(record.sessionId, { ...base, // The turn translator relies on Claude's authoritative idle frame when no result arrives. [CLAUDE_SESSION_STATE_EVENTS_ENV]: '1' diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 72bf17a1bce..38ea91abe26 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,6 +1,6 @@ import type { CodexStructuredLaunch } from './codex-structured-session-state' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { structuredWorkerChildIdentityEnv } from '../runtime/structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from '../runtime/structured-session-child-identity-env' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, @@ -8,9 +8,8 @@ export function buildCodexStructuredChildEnvironment( sessionId: string ): Record { return { - // Only a dispatched structured worker gets the orchestration identity and the Orca CLI on - // PATH; an ordinary chat session's env passes through untouched. - ...structuredWorkerChildIdentityEnv(sessionId, { + // Every structured session speaks orchestration as itself: its injected id and the Orca CLI. + ...structuredSessionChildIdentityEnv(sessionId, { ...launch.env, ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}) }), diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 545d04d063d..aa8a7d2a21c 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -8,7 +8,11 @@ import { type PiAgentKind } from '../../../../shared/pi-agent-kind' import { readSessionShellStartupEnvVar } from '../../../pty/shell-startup-env' -import { AGENT_HOOK_RUNTIME_ENV_KEYS, CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS } from './spawn-env-keys' +import { + AGENT_HOOK_RUNTIME_ENV_KEYS, + CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, + ORCA_AGENT_SESSION_CALLER_ENV_KEYS +} from './spawn-env-keys' export function readEnvWithProcessFallback( baseEnv: Record, @@ -133,13 +137,15 @@ export function getInheritedAgentHookEnvKeysToDelete( return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) } -export function getInheritedClaudeSessionStampEnvKeysToDelete( +export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (deliberately spawning a nested Claude child) keeps it. - return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) + // provides a stamp (a nested Claude child, a structured session's terminal view) keeps it. + return [...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS].filter( + (key) => env[key] === undefined + ) } // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. diff --git a/src/main/ipc/pty/host-env/spawn-env-keys.ts b/src/main/ipc/pty/host-env/spawn-env-keys.ts index cabbde7b0ec..ed070c7d043 100644 --- a/src/main/ipc/pty/host-env/spawn-env-keys.ts +++ b/src/main/ipc/pty/host-env/spawn-env-keys.ts @@ -1,3 +1,6 @@ +import { ORCA_AGENT_SESSION_ID_ENV } from '../../../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../../../shared/structured-session-marker' + export const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_AGENT_HOOK_PORT', 'ORCA_AGENT_HOOK_TOKEN', @@ -15,3 +18,9 @@ export const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID' ] as const + +// Why: Orca writes these only into a structured session's own spawn, so an inherited value means a pty host launched from inside one — every pane would claim that session as its orchestration caller. +export const ORCA_AGENT_SESSION_CALLER_ENV_KEYS = [ + ORCA_AGENT_SESSION_ID_ENV, + ORCA_STRUCTURED_SESSION_ENV +] as const diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index a2adf48771b..eedbcefcf2a 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -7,7 +7,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { beginPtySpawnForWorktree } from '../host-env/fresh-spawn-routing' @@ -43,7 +43,7 @@ export async function buildPtyIpcSpawnOptions( // Why: disable old hosts without removing ORCA_REAL_* while their Windows shim remains on PATH. ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.spawnEnv) : [], - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.spawnEnv), + getInheritedAgentSessionStampEnvKeysToDelete(ctx.spawnEnv), ctx.skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 67c633560e9..0a8b8cff801 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -9,7 +9,7 @@ import { mergePtyEnvDeletions, removeCodexHomeDeletionRequests, getInheritedAgentHookEnvKeysToDelete, - getInheritedClaudeSessionStampEnvKeysToDelete + getInheritedAgentSessionStampEnvKeysToDelete } from '../host-env/pi-agent' import { promoteAgentTeamsShimPath, deleteRequestedEnvKeys } from '../host-env/path' import { @@ -73,7 +73,7 @@ export async function buildRuntimePtySpawnOptions( ctx.isDaemonHostSpawn || args.connectionId ? LEGACY_TERMINAL_SHIM_REMOTE_ENV_KEYS : [], ctx.isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(ctx.env) : [], // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. - getInheritedClaudeSessionStampEnvKeysToDelete(ctx.env) + getInheritedAgentSessionStampEnvKeysToDelete(ctx.env) ) if (ctx.skipCodexHomeEnv) { ctx.spawnOptions.envToDelete = mergePtyEnvDeletions( diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 04035a957ca..5e89e4eb350 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -9,6 +9,7 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' const WSLENV_ENTRY_SEPARATOR = ':' @@ -74,6 +75,9 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', + // Why: a structured session's terminal view in a WSL shell must still present its id, so the + // host refuses the cross-host claim instead of the pane handle silently becoming its caller. + `${ORCA_AGENT_SESSION_ID_ENV}/u`, 'ORCA_USER_DATA_PATH/p', // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 493f9d092c3..501897b8731 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -19,8 +19,8 @@ const { } = await import('./orchestration-structured-worker-session') const { isUnknownWorkerStartOutcome } = await import('./orchestration/worker/worker-topology') const { structuredWorkerIdentities } = await import('../../structured-worker-identity') -const { structuredWorkerChildIdentityEnv } = - await import('../../structured-worker-child-identity-env') +const { structuredSessionChildIdentityEnv } = + await import('../../structured-session-child-identity-env') function installHost() { const hold = vi.fn(async () => {}) @@ -83,7 +83,7 @@ describe('structured worker session hold', () => { createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => { // `attach` is what spawns the provider child, and the child's env is read from the registry // at spawn time. Registering afterwards ships a worker with no ORCA_TERMINAL_HANDLE. - envAtSpawn = structuredWorkerChildIdentityEnv(args.envelope.sessionId, {}) + envAtSpawn = structuredSessionChildIdentityEnv(args.envelope.sessionId, {}) return { ok: true, value: { sessionId: args.envelope.sessionId } } }) const created = await createStructuredWorkerSession({ diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d96e214d442..c91ad0ff169 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,7 +43,6 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', - 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -59,6 +58,7 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index e4bd09f2619..deeeb397ca3 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,6 +50,8 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', + // The preview names its caller as the coordinator, exactly as the dispatch it previews does. + 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts similarity index 88% rename from src/main/runtime/structured-worker-child-identity-env.test.ts rename to src/main/runtime/structured-session-child-identity-env.test.ts index e966dd55824..7fe19c491e5 100644 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,7 +5,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -56,7 +56,7 @@ afterEach(() => { } }) -describe('structuredWorkerChildIdentityEnv', () => { +describe('structuredSessionChildIdentityEnv', () => { it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection @@ -66,7 +66,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(SESSION_ID, childEnv) + const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() @@ -82,7 +82,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const handle = registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(env.ORCA_CLI_COMMAND).toBe('orca') expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) @@ -92,7 +92,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) }) @@ -100,7 +100,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('win32') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) expect(env.PATH).toBeUndefined() }) @@ -109,7 +109,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('darwin') installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) }) @@ -119,7 +119,7 @@ describe('structuredWorkerChildIdentityEnv', () => { pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.ORCA_PANE_KEY).toBeUndefined() expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) }) @@ -140,7 +140,7 @@ describe('structuredWorkerChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) registerWorker() expect( - structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND ).not.toBe('orca-ide') }) }) diff --git a/src/main/runtime/structured-worker-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts similarity index 50% rename from src/main/runtime/structured-worker-child-identity-env.ts rename to src/main/runtime/structured-session-child-identity-env.ts index 6cf9f46e910..f2ab92b3d66 100644 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -1,62 +1,67 @@ /** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. + * The orchestration identity — and the CLI reachability — a structured session's own child needs to + * speak for itself. Both providers' native launches (Claude, Codex) build their child env here. * - * Without `ORCA_TERMINAL_HANDLE` the worker's Bash tool has nothing to pass as `--from`, and - * `resolveOrchestrationTerminalHandle` falls back to a cwd lookup that returns whichever leaf in - * the worktree comes first. Two attacks follow from that: a bare `check` reads and consumes a - * SIBLING's dispatch mailbox, and a bare `send --type worker_done` can settle a sibling's - * context-only dispatch, a tier that has no capability token to reject on. + * Every structured session carries `ORCA_AGENT_SESSION_ID`, the id Orca minted for it — never the + * provider's, which rotates on `/clear`. The CLI sends it as the caller, so a bare + * `orca orchestration check` acts as this session instead of guessing a terminal: with no pane of + * its own, every guess landed on a sibling, and a destructive `check` consumed that sibling's mail. + * Identity by session id assumes one machine and one user; crossing a host boundary (SSH, a paired + * peer) re-opens that decision, and the host refuses a session claim from across one. + * + * A dispatched structured worker also keeps the `structworker_` handle it was minted, for the + * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it + * back to that handle, so the worker keeps one identity. * * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY worker gets that treatment from `buildPtyHostEnv`; a structured worker has no PTY, - * so it applies the SAME function here rather than a second, drifting copy of the rule. + * dir. A PTY agent gets that treatment from `buildPtyHostEnv`; a structured session has no PTY, so + * it applies the SAME function here rather than a second, drifting copy of the rule. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, * agent-row and mobile-projection pipelines, every one of which assumes a pane key names a live * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today - * precisely because a structured session emits no hook agent status. The CLI needs none of it once - * the handle is present. + * precisely because a structured session emits no hook agent status. * - * A session that is not a dispatched worker gets ONE variable, `ORCA_STRUCTURED_SESSION`, and it - * names nothing: no handle, no pane key, no session id, no token. Its only meaning is "this child - * is a structured session with no orchestration identity", which is what a verb needs in order to - * REFUSE rather than guess one. Because it names nothing it cannot be replayed, cannot impersonate, - * and cannot flow into the hook, agent-row or mobile-projection pipelines the way a pane key would - * — which is why it is a different decision from withholding `ORCA_PANE_KEY`, not a reversal of it. - * Without it, `check` fell through to the active-terminal guess and destructively consumed a - * SIBLING pane's oldest unread batch; `requireUnambiguous` only narrows that, because with exactly - * one terminal pane in the worktree the guess still resolves — to a sibling. + * The identity-less marker (`ORCA_STRUCTURED_SESSION`) is no longer written: a child with an id is + * never identity-less, so a marker inherited from an Orca launched inside an older session is inert. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. */ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { structuredWorkerIdentities } from './structured-worker-identity' -export function structuredWorkerChildIdentityEnv( +export function structuredSessionChildIdentityEnv( sessionId: string, childEnv: Record ): Record { const identity = structuredWorkerIdentities.getBySessionId(sessionId) - if (!identity) { - return { ...childEnv, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - } const env: Record = { ...childEnv, - ORCA_TERMINAL_HANDLE: identity.handle, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, ORCA_CLI_COMMAND: 'orca' } applyOrcaCliPath(env) return env } +/** + * The same session id for its terminal view, so switching views never changes who the session is. + * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. + */ +export function structuredSessionTerminalViewEnv( + sessionId: string | undefined +): Record { + return sessionId ? { [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : {} +} + /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts index 98989a85891..21d56d7fa37 100644 --- a/src/main/runtime/structured-worker-identity.test.ts +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -5,7 +5,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' -import { structuredWorkerChildIdentityEnv } from './structured-worker-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { StructuredWorkerIdentityRegistry, isStructuredWorkerHandle, @@ -294,7 +294,7 @@ describe('structured workers stay outside the PTY-only fail-closed paths', () => hostScope: { kind: 'local', hostId: 'local' } }) try { - const env = structuredWorkerChildIdentityEnv(SESSION_ID, {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, {}) // Registered, so this is a populated env — not the empty one an unregistered session gets, // which would satisfy the pane-key assertion for the wrong reason. expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index c32d6c37f15..77357cf1e99 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -26,14 +26,19 @@ import { } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' +import { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope } from './orchestration/worker-terminal-process-liveness' -// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the -// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. -export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +export { + STRUCTURED_WORKER_HANDLE_PREFIX, + isStructuredWorkerHandle +} from '../../shared/structured-worker-handle' export const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' export type StructuredWorkerIdentity = { @@ -47,10 +52,6 @@ export type StructuredWorkerIdentity = { hostScope: WorkerTerminalHostScope } -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { - return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) -} - export function mintStructuredWorkerHandle(): string { return `${STRUCTURED_WORKER_HANDLE_PREFIX}${randomUUID()}` } diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.ts index d62a23d8e30..4490343504f 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.ts @@ -13,6 +13,8 @@ import { ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV, ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV } from '../../shared/orchestration-compatibility-evidence' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { REMOTE_ARTIFACT_INPUT_ENV, sshArtifactSourceKey, @@ -131,6 +133,10 @@ export function buildHostCliEnv(args: { delete env[ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION_ENV] delete env[ORCHESTRATION_COMPATIBILITY_ATTACHMENT_ENV] delete env[REMOTE_ARTIFACT_INPUT_ENV] + // Why: a remote command must never claim a local agent session. The host's env carries one only + // when Orca was launched inside a session, and identity by session id is same-host only. + delete env[ORCA_AGENT_SESSION_ID_ENV] + delete env[ORCA_STRUCTURED_SESSION_ENV] if (args.runtimeAuthority) { env[ORCHESTRATION_COMPATIBILITY_HOST_KIND_ENV] = 'ssh' env[ORCHESTRATION_COMPATIBILITY_HOST_ID_ENV] = args.runtimeAuthority.targetId diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts new file mode 100644 index 00000000000..d0f5e860559 --- /dev/null +++ b/src/shared/agent-session-caller-env.ts @@ -0,0 +1,17 @@ +/** + * The Orca-minted agent session id, injected into a structured session's own child processes, in + * native chat and in terminal view alike. When it is present it IS the orchestration caller: the + * CLI sends it in the orchestration envelope and the host resolves it to the session's actor, so no + * terminal is resolved or guessed on its behalf. + * + * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, + * WSL) re-opens that decision, which is why nothing forwards the id across one as a caller. + */ +export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' + +export function readInjectedAgentSessionId( + env: Readonly> = process.env +): string | undefined { + const value = env[ORCA_AGENT_SESSION_ID_ENV]?.trim() + return value ? value : undefined +} diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index 339ccb01f43..9762b95d63c 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,11 +1,12 @@ import { isAgentSessionId } from './agent-session-record' +import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' /** - * The Orca session id is the id Orca minted for a structured session (its session record id), never - * the provider's own session id. Orchestration stores, bare, the one the agent is addressed by: for - * a `/clear`ed chat, its lineage root's, not the live session's. Mail addresses the session as - * `session:`, beside `run:` and `dispatch:`, and derives that spelling here rather than - * storing it. + * The Orca session id is the id Orca minted for a structured session (its session record id, the + * value of `ORCA_AGENT_SESSION_ID`), never the provider's own session id. Orchestration stores, + * bare, the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live + * session's. Mail addresses the session as `session:`, beside `run:` and `dispatch:`, + * and derives that spelling here rather than storing it. * * Where the session runs is not part of the id; it is read from the session record when needed. PTY * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id @@ -24,7 +25,7 @@ export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) // share the session-id charset. A handle is never a session, so one handed over by mistake must not // become a durable Orca session id. -const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const +const TERMINAL_HANDLE_PREFIXES = ['term_', STRUCTURED_WORKER_HANDLE_PREFIX] as const export function isOrcaSessionId(id: string): id is OrcaSessionId { return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 36c90f432ee..1725a6ef337 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,13 +1,20 @@ +import { readInjectedAgentSessionId } from './agent-session-caller-env' + /** - * The marker a structured chat session's child carries when it has NO orchestration identity. + * The marker a structured chat session's child carried when it had NO orchestration identity. * - * It names nothing on purpose — no handle, no pane key, no session id, no token — so it grants no - * authority and cannot be replayed or impersonated. Its only job is to let a CLI verb that would - * otherwise GUESS an implicit terminal refuse instead: a structured session has no pane, so every - * guess resolves to a sibling, and `orchestration check` is destructive by default. + * Current hosts no longer write it: every structured child carries its injected session id + * instead. The reader stays for a child spawned by an Orca that predates injection, which can still + * reach a newer CLI through a global install — it must refuse, not guess, because a structured + * session has no pane, so every implicit-terminal guess resolves to a sibling and + * `orchestration check` is destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' +/** An injected session id is an identity, so a child carrying one is never identity-less. */ export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { - return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 + return ( + (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 && + readInjectedAgentSessionId(env) === undefined + ) } diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts new file mode 100644 index 00000000000..e36df813570 --- /dev/null +++ b/src/shared/structured-worker-handle.ts @@ -0,0 +1,7 @@ +// Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the +// renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. +export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' + +export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { + return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) +} From b5d592a7fa2e8d77309e90f5a04195bbb8b93a10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:21:40 -0700 Subject: [PATCH 02/49] test(orchestration): pin session id injection for native Claude, native Codex, the terminal view, WSL, PTY inheritance and SSH --- .../claude-stream-json-connection.test.ts | 5 + ...laude-structured-launch-resolution.test.ts | 13 ++ ...codex-structured-child-environment.test.ts | 79 +++++++++++- .../codex-structured-session-adapter.test.ts | 6 +- .../pty-daemon-spawn-agent-home-env.test.ts | 19 +++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 17 +++ src/main/providers/provider-dispatch.test.ts | 8 +- src/main/pty/wsl-orca-env.test.ts | 20 +++ ...ca-runtime-agent-session-operation.test.ts | 34 +++++ ...uctured-session-child-identity-env.test.ts | 118 ++++++++++++------ .../structured-session-child-identity-env.ts | 8 +- .../ssh-remote-cli-host-passthrough.test.ts | 22 ++++ 12 files changed, 295 insertions(+), 54 deletions(-) diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index 51ffa085163..18124dc3fe3 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -163,9 +163,12 @@ describe('Claude stream-json connection', () => { // An inherited value wins over the SDK's default, so clear it to pin the default. vi.stubEnv('CLAUDE_CODE_ENTRYPOINT', undefined) vi.stubEnv('ORCA_CONNECTION_MARKER', 'inherited') + // An Orca launched inside another structured session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') const scenario = scriptScenario([HOLD_OPEN]) const connection = await open( launchFor(scenario, { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', CLAUDE_CONFIG_DIR: '/accounts/managed/home', ANTHROPIC_AUTH_TOKEN: 'configured-token', ORCA_AGENT_SESSION_SPAWN_TOKEN: 'spawn-9', @@ -184,6 +187,8 @@ describe('Claude stream-json connection', () => { expect(env.ANTHROPIC_AUTH_TOKEN).toBe('configured-token') expect(env.ORCA_AGENT_SESSION_SPAWN_TOKEN).toBe('spawn-9') expect(env.ORCA_CONNECTION_MARKER).toBe('inherited') + // The session's own id reaches the spawned child over the inherited one. + expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') expect(env.ANTHROPIC_API_KEY).toBeUndefined() expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index c54d1a5179f..96c4a2a6b44 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -161,6 +161,19 @@ describe('claude structured launch resolution', () => { expect(launch.options.sessionId).toBeUndefined() }) + it('names the child by the Orca session id, over any id the configured overlay carries', async () => { + // The Orca-minted id, never the provider's: the provider id rotates on /clear. + const launch = await resolverFor(record(), () => ({ + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }))({ identity: IDENTITY }) + + expect(launch.env).toMatchObject({ + ORCA_AGENT_SESSION_ID: SESSION_ID, + ORCA_CLI_COMMAND: 'orca' + }) + expect(launch.env?.ORCA_AGENT_SESSION_ID).not.toBe(launch.providerSessionId) + }) + it('forces session-state events on when the inherited overlay disables them', async () => { const launch = await resolverFor(record(), () => ({ [CLAUDE_SESSION_STATE_EVENTS_ENV]: '0' diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 201efc0b0c5..bddb4c69aeb 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { openCodexAppServerConnection } from './codex-app-server-connection' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -9,6 +9,8 @@ import { structuredWorkerProcessIncarnation } from '../runtime/structured-worker-identity' +const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ + describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { expect( @@ -28,11 +30,14 @@ describe('buildCodexStructuredChildEnvironment', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/pinned/home', [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-not-a-worker', + ORCA_CLI_COMMAND: 'orca', + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) }) - it('adds the orchestration handle only for a registered structured worker', () => { + it('names every session by its id, and adds the handle only for a registered worker', () => { const launch = { command: 'codex', args: ['app-server'], @@ -44,8 +49,10 @@ describe('buildCodexStructuredChildEnvironment', () => { const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', - // No identity yet, so the child carries only the refuse-rather-than-guess marker. - [ORCA_STRUCTURED_SESSION_ENV]: '1' + // Not a worker, so no handle: the id alone names this chat as a caller. + ORCA_AGENT_SESSION_ID: sessionId, + ORCA_CLI_COMMAND: 'orca', + PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) const handle = mintStructuredWorkerHandle() @@ -61,6 +68,7 @@ describe('buildCodexStructuredChildEnvironment', () => { try { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) expect(env.ORCA_CLI_COMMAND).toBe('orca') // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. expect(env.ORCA_PANE_KEY).toBeUndefined() @@ -69,3 +77,62 @@ describe('buildCodexStructuredChildEnvironment', () => { } }) }) + +/** A real child speaking Codex's JSONL framing, answering with the environment it was spawned with. */ +const ENV_REPORTING_APP_SERVER = String.raw` + const readline = require('node:readline') + const send = (payload) => process.stdout.write(JSON.stringify(payload) + '\n') + readline.createInterface({ input: process.stdin }).on('line', (line) => { + const message = JSON.parse(line) + if (message.method === 'initialize') return send({ id: message.id, result: {} }) + if (message.method === 'test/env') { + return send({ + id: message.id, + result: { + sessionId: process.env.ORCA_AGENT_SESSION_ID ?? null, + cliCommand: process.env.ORCA_CLI_COMMAND ?? null, + path: process.env.PATH ?? process.env.Path ?? null + } + }) + } + }) +` + +describe('the spawned Codex child', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it("runs with its own session id and this app's CLI, over an id inherited by Orca itself", async () => { + // The builder's output is an overlay on process.env, so only the spawned child proves the id + // survives the merge — an Orca launched inside another session inherits that session's id. + vi.stubEnv('ORCA_AGENT_SESSION_ID', 'a0b1c2d3-0000-4000-8000-00000000abcd') + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + const env = buildCodexStructuredChildEnvironment( + { + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + cwd: process.cwd(), + codexHome: null, + resumeThreadId: null, + env: {} + }, + 'spawn-token', + sessionId + ) + const connection = await openCodexAppServerConnection({ + command: process.execPath, + args: ['-e', ENV_REPORTING_APP_SERVER], + env + }) + try { + await expect(connection.request('test/env')).resolves.toEqual({ + sessionId, + cliCommand: 'orca', + path: expect.stringMatching(DEV_CLI_BIN_FIRST) + }) + } finally { + await connection.close() + } + }) +}) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2dd4b8d78af..48332a34069 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -6,7 +6,6 @@ import { } from './codex-app-server-connection' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' -import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { CodexStructuredSessionAdapter, type CodexStructuredLaunch, @@ -35,7 +34,10 @@ describe('CodexStructuredSessionAdapter.acquire', () => { expect(codex.connections[0].launch.env).toEqual({ [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home', - [ORCA_STRUCTURED_SESSION_ENV]: '1' + ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_CLI_COMMAND: 'orca', + // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. + PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) expect(codex.connections[0].launch.cwd).toBe('/work/repo') expect(codex.connections[0].calls[0]).toEqual({ diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index f73f11e7f93..8895666706e 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -413,6 +413,25 @@ describe('registerPtyHandlers', () => { ]) ) }) + it("strips an inherited agent session id and keeps a terminal view's own", async () => { + // Why: a daemon forked by an Orca launched inside a structured session inherits its id, + // and every daemon pane would present that session as its orchestration caller. + const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(inherited.envToDelete).toEqual( + expect.arrayContaining(['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) + ) + const own = await daemonSpawnAndGetOptions( + { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, + undefined, + undefined, + { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } + ) + expect(own.envToDelete ?? []).not.toContain('ORCA_AGENT_SESSION_ID') + expect(own.env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') + }) it('preserves an explicitly requested Claude child-session stamp', async () => { // Why: only inherited values are poison; a caller deliberately spawning a // nested Claude child passes the stamp in args.env and must keep it. diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5..e269bc7f6df 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -351,6 +351,23 @@ describe('registerPtyHandlers', () => { expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() }) + it('strips an inherited agent session id so a pane never claims that session', async () => { + // Why: an Orca launched inside a structured session inherits its id; every pane would then + // present that session as its orchestration caller instead of its own terminal. + const env = await spawnAndGetEnv(undefined, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + ORCA_STRUCTURED_SESSION: '1' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + }) + it('keeps the session id a terminal view is spawned with', async () => { + const env = await spawnAndGetEnv( + { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, + { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } + ) + expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') + }) it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { const env = await spawnAndGetEnv( { CLAUDE_CODE_CHILD_SESSION: '1' }, diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index cc63cfb9d09..5667609973b 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -155,8 +155,8 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - // Why: the relay host can be launched from a Claude session too, so the stamps are - // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // Why: the relay host can be launched from a Claude or structured session too, so the + // stamps are stripped on the SSH path as well; a remote pane never names a local session. Compared as a set — envToDelete is consumed by // membership only, so a reordering of the merge sources must not fail this. const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( @@ -167,7 +167,9 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_BRIDGE_SESSION_ID', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', - 'ORCA_PI_TITLE_MARKER_OWNED' + 'ORCA_PI_TITLE_MARKER_OWNED', + 'ORCA_AGENT_SESSION_ID', + 'ORCA_STRUCTURED_SESSION' ].sort() ) expect(mockSshProvider.spawn).toHaveBeenCalledWith( diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index e56146ea991..130b858f24c 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -16,6 +16,26 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') }) + it("carries a terminal view's session id into the guest untranslated, beside the WSL stamp", () => { + // Crossing is what makes the claim refusable: without it the pane handle would silently become + // the caller inside WSL, and the host could not tell the session was asking at all. + const env: Record = { + ORCA_TERMINAL_HANDLE: 'term_wsl', + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' + } + stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu') + + addOrcaWslInteropEnv(env) + + expect(env.WSLENV?.split(':')).toEqual( + expect.arrayContaining([ + 'ORCA_AGENT_SESSION_ID/u', + 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', + 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u' + ]) + ) + }) + // Why this is published at all: the wrapper tree is content-addressed, so the // in-guest login script cannot rebuild its path from ORCA_USER_DATA_PATH -- it // cannot derive the hash segment. Without this the guest finds no wrapper and diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index 8d7d6fa6812..e22d6de28e8 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -266,6 +266,40 @@ describe('agent-session create operation ledger', () => { ) }) + it("spawns a structured session's terminal view with its session id, never persisting it", async () => { + // Switching a chat to terminal view must not change who it is as an orchestration caller. The + // id rides the spawn env only: the launch config persists, and a relaunch from it would replay + // the id without the handoff that binds the terminal to the session. + const sessionId = 'f7a1c0de-1111-4222-8333-444455556666' + const runtime = createRuntime() + const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) + const resume = { + kind: 'explicit' as const, + worktree: 'id:worktree-1', + agent: 'claude' as const, + providerSession: { key: 'session_id' as const, id: 'provider-session-1' } + } + + await runtime.ensureAgentSession( + resume, + {}, + { + spawnToken: 'spawn-9', + providerRoot: '/accounts/claude', + sessionId + } + ) + await runtime.ensureAgentSession(resume) + + const [handoff, plain] = createTerminal.mock.calls.map(([, opts]) => opts) + expect(handoff).toMatchObject({ + structuredAgentSessionId: sessionId, + env: expect.objectContaining({ ORCA_AGENT_SESSION_ID: sessionId }) + }) + expect(handoff?.launchConfig?.agentEnv).not.toHaveProperty('ORCA_AGENT_SESSION_ID') + expect(plain?.env ?? {}).not.toHaveProperty('ORCA_AGENT_SESSION_ID') + }) + it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 7fe19c491e5..0b0581496c4 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,7 +5,10 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' +import { + structuredSessionChildIdentityEnv, + withStructuredSessionTerminalViewEnv +} from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -57,60 +60,95 @@ afterEach(() => { }) describe('structuredSessionChildIdentityEnv', () => { - it('marks an ordinary chat session as having NO identity, and grants it nothing', () => { - // The marker names nothing — no handle, no pane key, no session id, no token — so it cannot be - // replayed or impersonated, and it does not reach the hook, agent-row or mobile-projection - // pipelines a pane key would. Its only job is to let the CLI REFUSE instead of guessing: this - // session has no pane, so every implicit-terminal guess resolved to a sibling, and a - // destructive `check` then consumed that sibling's mail. + it("gives an ordinary chat session its own id and this app's CLI, and no terminal identity", () => { + // The id names the caller, so a bare `orca orchestration check` acts as this session instead of + // guessing a terminal — every guess landed on a sibling pane, and `check` consumed its mail. pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const childEnv = { PATH: '/usr/bin' } const env = structuredSessionChildIdentityEnv(SESSION_ID, childEnv) - expect(env).toEqual({ PATH: '/usr/bin', ORCA_STRUCTURED_SESSION: '1' }) + expect(env).toEqual({ + PATH: `${SHIM_DIR}:/usr/bin`, + ORCA_AGENT_SESSION_ID: SESSION_ID, + ORCA_CLI_COMMAND: 'orca' + }) + // A chat names itself by its id alone: no handle, no pane key, no identity-less marker. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_CLI_COMMAND).toBeUndefined() - // Still no CLI reachability granted, so packaged builds keep today's exposure. - expect(childEnv.PATH).toBe('/usr/bin') - expect(shim.ensureLinuxTerminalOrcaCliShimDir).not.toHaveBeenCalled() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + expect(childEnv).toEqual({ PATH: '/usr/bin' }) }) - it('gives a packaged-Linux worker the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { - // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI - // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const handle = registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) - expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') - expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + it('replaces an id inherited from an Orca launched inside another session', () => { + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd', + PATH: '/usr/bin' + }) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) }) - it('gives a packaged-macOS worker the bundled CLI dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() + it('gives a structured worker its id and keeps the handle it was minted', () => { + // For orchestration the id wins and the host maps it back to this handle, so the worker keeps + // one identity; the handle stays for the handle-based surfaces outside orchestration. + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const handle = registerWorker() const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) }) - it('gives a packaged-Windows worker the bundled CLI dir under the env block spelling', () => { - pinPlatform('win32') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) - expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) - expect(env.PATH).toBeUndefined() + describe.each(['chat', 'worker'] as const)("reaches this app's CLI as a %s", (kind) => { + beforeEach(() => { + if (kind === 'worker') { + registerWorker() + } + }) + + it('on packaged Linux, through the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { + // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI + // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) + expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) + }) + + it('on packaged macOS, through the bundled CLI dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + }) + + it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { + pinPlatform('win32') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) + expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) + expect(env.PATH).toBeUndefined() + }) + + it('unpackaged, through the dev launcher dir', () => { + pinPlatform('darwin') + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + }) }) - it('gives an unpackaged worker the dev launcher dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) - registerWorker() - const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { + expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ + CLAUDE_CONFIG_DIR: '/c', + ORCA_AGENT_SESSION_ID: SESSION_ID + }) + expect(withStructuredSessionTerminalViewEnv(undefined, SESSION_ID)).toEqual({ + ORCA_AGENT_SESSION_ID: SESSION_ID + }) + const plain = { CLAUDE_CONFIG_DIR: '/c' } + expect(withStructuredSessionTerminalViewEnv(plain, undefined)).toBe(plain) + expect(withStructuredSessionTerminalViewEnv(undefined, undefined)).toBeUndefined() }) it('never puts a pane key in the child environment', () => { diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index f2ab92b3d66..36ea752e793 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -55,11 +55,13 @@ export function structuredSessionChildIdentityEnv( /** * The same session id for its terminal view, so switching views never changes who the session is. * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. + * A terminal that is not a session's view keeps its env exactly as given. */ -export function structuredSessionTerminalViewEnv( +export function withStructuredSessionTerminalViewEnv( + env: Record | undefined, sessionId: string | undefined -): Record { - return sessionId ? { [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : {} +): Record | undefined { + return sessionId ? { ...env, [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : env } /** diff --git a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts index 53334e3cd12..811917cf715 100644 --- a/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts +++ b/src/main/ssh/ssh-remote-cli-host-passthrough.test.ts @@ -129,6 +129,28 @@ describe('buildHostCliEnv', () => { expect(env.ORCA_CLI_COMMAND).toBe('orca') }) + it('never lets a remote command claim a local agent session', () => { + // The host's env carries a session id when Orca was launched inside a structured session; the + // remote shell's own is from another machine. Session identity is same-host only. + const env = buildHostCliEnv({ + hostEnv: { + ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666', + ORCA_STRUCTURED_SESSION: '1' + }, + remoteEnv: { + ORCA_TERMINAL_HANDLE: 'term_remote', + ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' + }, + userDataPath: '/host/user-data', + remoteCwd: '/srv/repo' + }) + + expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() + expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() + // The remote command still speaks as its own terminal. + expect(env.ORCA_TERMINAL_HANDLE).toBe('term_remote') + }) + it('namespaces identical remote artifact paths by stable SSH target', () => { const artifactInput = { sourceKey: '/srv/repo/report.html', From 90486a2157639b1c8eb671eb5a5a754e10432427 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:26:45 -0700 Subject: [PATCH 03/49] test(orchestration): pin one caller precedence rule across every CLI verb that names its caller Adds the per-verb table (flagless acts as the session; a conflicting --from or --terminal is refused before any request; the session's own spellings are accepted), the enumerated guess population with its positive control, the structured worker's own handle, the identity-less refusal for an older child, the unchanged terminal agent, and the envelope. dispatch-show's --from only fills preview text, so it passes through unfenced and a session's flagless preview names the address the real dispatch writes. --- .../orchestration/dispatch-handlers.ts | 9 +- .../orchestration/terminal-identity.ts | 20 +- .../orchestration/worker-list-run-scope.ts | 8 +- .../orchestration-session-caller-cli.test.ts | 476 ++++++++++++++++++ .../rpc/orchestration-session-caller.test.ts | 2 +- .../rpc/orchestration-session-caller.ts | 2 - src/shared/structured-worker-handle.ts | 2 +- 7 files changed, 499 insertions(+), 20 deletions(-) create mode 100644 src/cli/orchestration-session-caller-cli.test.ts diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f..5162ca6e291 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,7 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { @@ -42,9 +42,12 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record = { 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { const showPreamble = flags.has('preamble') ? true : undefined - // Why: a preview must embed the same real coordinator handle as an actual dispatch. + // Why: a preview must embed the same real coordinator handle as an actual dispatch. Its --from + // only fills preview text and names no caller, so it passes through unfenced. const from = showPreamble - ? await resolveCoordinatorTerminalHandle(flags, cwd, client) + ? (getOptionalStringFlag(flags, 'from') ?? + injectedSessionAddress() ?? + (await resolveCoordinatorTerminalHandle(flags, cwd, client))) : undefined const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } | null diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index f9d72d76e3a..732fb13a77f 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -173,7 +173,7 @@ function getClientErrorMessage(err: unknown): string | undefined { * identity this process carries; a caller flag may restate that same session but never name * another, and a conflicting one is refused here, before any request is sent. */ -export function resolveInjectedSessionCaller( +function resolveInjectedSessionCaller( flags: Map, flagName: 'from' | 'terminal' ): string | undefined { @@ -195,17 +195,25 @@ export function resolveInjectedSessionCaller( /** The session's own spellings, plus the handle a structured worker session was minted. */ function namesInjectedSession(value: string, sessionId: string): boolean { - if (normalizeOrchestrationActor(value)?.id === sessionId) { - return true + return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() +} + +/** + * The address the host gives this session: a structured worker keeps the handle it was minted, any + * other session is `session:`. Only for text that must match what the host writes. + */ +export function injectedSessionAddress(): string | undefined { + const sessionId = readInjectedAgentSessionId() + if (!sessionId) { + return undefined } const ownHandle = process.env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) && value === ownHandle + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` } /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { - const sessionId = handle === undefined ? readInjectedAgentSessionId() : undefined - return handle ?? (sessionId ? `session:${sessionId}` : 'unknown') + return handle ?? injectedSessionAddress() ?? 'unknown' } export async function resolveCoordinatorTerminalHandle( diff --git a/src/cli/handlers/orchestration/worker-list-run-scope.ts b/src/cli/handlers/orchestration/worker-list-run-scope.ts index 83bfcd5b9e0..13037616153 100644 --- a/src/cli/handlers/orchestration/worker-list-run-scope.ts +++ b/src/cli/handlers/orchestration/worker-list-run-scope.ts @@ -1,9 +1,6 @@ import { getOptionalStringFlag } from '../../flags' import type { RuntimeClient } from '../../runtime-client' -import { - resolveInjectedSessionCaller, - resolveOrchestrationTerminalHandle -} from './terminal-identity' +import { resolveOrchestrationTerminalHandle } from './terminal-identity' /** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */ export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' } @@ -23,9 +20,6 @@ export async function resolveWorkerListRunScope( if (explicit) { return { run: explicit, source: 'flag' } } - // Outside the catch: a --terminal naming someone other than this session is refused, never - // widened into an unscoped listing. - resolveInjectedSessionCaller(flags, 'terminal') try { const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', { diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts new file mode 100644 index 00000000000..93a76645a30 --- /dev/null +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -0,0 +1,476 @@ +/** + * A command that runs inside a structured agent session is that session: the injected + * `ORCA_AGENT_SESSION_ID` names the caller, and nothing resolves or guesses a terminal for it. + * + * One rule for every verb that names a caller: a caller flag may restate the session, but a flag + * naming anyone else is refused before any request — never silently dropped, never allowed to win. + * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. + * + * The session env here is the hardest case, a chat in terminal view: it also carries its pane's + * `ORCA_TERMINAL_HANDLE` and `ORCA_PANE_KEY`, and the implicit-terminal guess has a sibling to find. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.hoisted(() => vi.fn()) +const getTerminalHandleMock = vi.hoisted(() => vi.fn()) + +vi.mock('./format', () => ({ printResult: vi.fn() })) +vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' + +const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' +const IDENTITY_ENV = [ + 'ORCA_AGENT_SESSION_ID', + 'ORCA_TERMINAL_HANDLE', + 'ORCA_PANE_KEY', + 'ORCA_STRUCTURED_SESSION' +] as const +const originalEnv = Object.fromEntries(IDENTITY_ENV.map((name) => [name, process.env[name]])) + +/** Enough of every receipt shape that each handler finishes after its RPC. */ +const RESULT = { + result: { + run: { id: 'run_1', objective: 'o', consumer_generation: 1 }, + runs: [], + nextCursor: null, + messages: [], + count: 0, + message: { id: 'msg_1' }, + lifecycle: { action: 'completed' }, + dispatch: { id: 'dispatch_1', task_id: 'task_1', status: 'dispatched' }, + gate: { id: 'gate_1', task_id: 'task_1', status: 'pending', resolution: 'r' }, + gates: [], + task: { id: 'task_1', status: 'pending' }, + tasks: [], + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false, + state: 'ready', + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'dispatch_1', + effects: [], + residualResources: [], + workers: [], + counts: {} + } +} + +type Verb = { + command: string + flags: Record + /** The flag that names the caller, when the verb has one. */ + callerFlag?: 'from' | 'terminal' + method: string + callerParam: 'from' | 'terminal' | 'callerTerminalHandle' +} + +/** Every verb whose request names its caller: the host's caller-param map, from the CLI side. + * `dispatch-show` is not one: its --from only fills preview text, so it is pinned on its own. */ +const CALLER_VERBS: Verb[] = [ + { + command: 'run-create', + flags: { objective: 'o' }, + callerFlag: 'from', + method: 'runCreate', + callerParam: 'from' + }, + { + command: 'run-use', + flags: { id: 'run_1' }, + callerFlag: 'from', + method: 'runUse', + callerParam: 'from' + }, + { + command: 'run-current', + flags: {}, + callerFlag: 'from', + method: 'runCurrent', + callerParam: 'from' + }, + { command: 'check', flags: {}, callerFlag: 'terminal', method: 'check', callerParam: 'terminal' }, + { + command: 'send', + flags: { to: 'term_worker', subject: 's', body: 'b' }, + callerFlag: 'from', + method: 'send', + callerParam: 'from' + }, + { + command: 'reply', + flags: { id: 'msg_1', body: 'b' }, + callerFlag: 'from', + method: 'reply', + callerParam: 'from' + }, + { + command: 'ask', + flags: { to: 'term_worker', question: 'q' }, + callerFlag: 'from', + method: 'ask', + callerParam: 'from' + }, + { + command: 'dispatch', + flags: { task: 'task_1', to: 'term_worker' }, + callerFlag: 'from', + method: 'dispatch', + callerParam: 'from' + }, + { + command: 'gate-create', + flags: { task: 'task_1', question: 'q' }, + callerFlag: 'from', + method: 'gateCreate', + callerParam: 'from' + }, + { + command: 'gate-resolve', + flags: { id: 'gate_1', resolution: 'r' }, + callerFlag: 'from', + method: 'gateResolve', + callerParam: 'from' + }, + { command: 'gate-list', flags: {}, callerFlag: 'from', method: 'gateList', callerParam: 'from' }, + { + command: 'task-create', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'taskCreate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-list', + flags: {}, + callerFlag: 'from', + method: 'taskList', + callerParam: 'callerTerminalHandle' + }, + { + command: 'task-update', + flags: { id: 'task_1', status: 'completed' }, + callerFlag: 'from', + method: 'taskUpdate', + callerParam: 'callerTerminalHandle' + }, + { + command: 'worker-start', + flags: { spec: 's' }, + callerFlag: 'from', + method: 'workerStart', + callerParam: 'from' + }, + // No caller flag: its spec takes none. It asks runCurrent for the caller's Run. + { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } +] + +function flagMap(flags: Record): Map { + return new Map(Object.entries(flags)) +} + +async function invoke( + command: string, + flags: Map, + json = true +): Promise { + const handler = ORCHESTRATION_HANDLERS[`orchestration ${command}`] + if (!handler) { + throw new Error(`no handler for ${command}`) + } + await handler({ + flags, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only `call`; RuntimeClient is a class, so a structural double cannot satisfy it without the cast. + client: { call: callMock } as never, + cwd: '/tmp/repo', + json + }) +} + +function isParams(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function callsTo(method: string): Record[] { + return callMock.mock.calls + .filter(([name]) => name === `orchestration.${method}`) + .map(([, params]) => (isParams(params) ? params : {})) +} + +function setEnv(env: Partial>): void { + for (const name of IDENTITY_ENV) { + const value = env[name] + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +} + +/** A chat in terminal view: its id, plus the pane identity that view inherits. */ +function asSessionInTerminalView(): void { + setEnv({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_TERMINAL_HANDLE: 'term_view_pane', + ORCA_PANE_KEY: 'tab_view:11111111-1111-4111-8111-111111111111' + }) +} + +beforeEach(() => { + callMock.mockReset().mockResolvedValue(RESULT) + getTerminalHandleMock.mockReset().mockResolvedValue('term_sibling') + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + setEnv(originalEnv) + process.exitCode = undefined +}) + +describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (verb) => { + beforeEach(asSessionInTerminalView) + + it('acts as the session: no terminal is resolved, guessed or sent', async () => { + await invoke(verb.command, flagMap(verb.flags)) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + // A terminal view's pane is not the session's identity. + expect(params?.terminalPaneKey).toBeUndefined() + expect(params?.senderPaneKey).toBeUndefined() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock.mock.calls.map(([name]) => name)).not.toEqual( + expect.arrayContaining([expect.stringMatching(/^terminal\./)]) + ) + }) + + it.runIf(verb.callerFlag !== undefined)( + 'refuses a caller flag naming another actor, before any request', + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_sibling' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ + code: 'consumer_fenced', + message: expect.stringContaining(`agent session ${SESSION}`) + }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined)( + "refuses its own terminal view's pane handle too: the session, not the pane, is the caller", + async () => { + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_view_pane' }) + + await expect(invoke(verb.command, flags)).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + } + ) + + it.runIf(verb.callerFlag !== undefined).each([`session:${SESSION}`, SESSION])( + 'accepts a caller flag that restates the session (%s)', + async (restated) => { + await invoke(verb.command, flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: restated })) + + const [params] = callsTo(verb.method) + expect(params, 'the verb reached its method').toBeDefined() + expect(params?.[verb.callerParam]).toBeUndefined() + } + ) +}) + +describe('the identity a session presents', () => { + it("lets a structured worker restate its own minted handle, and nobody else's", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + + await invoke('send', flagMap({ from: 'structworker_self', to: 'run:run_1', subject: 's' })) + expect(callsTo('send')[0]?.from).toBeUndefined() + + callMock.mockClear() + await expect( + invoke('send', flagMap({ from: 'structworker_other', to: 'run:run_1', subject: 's' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + }) + + it("sends a structured worker's lifecycle report as the session, not refused as identity-less", async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION }) + + await invoke( + 'send', + flagMap({ to: 'run:run_1', subject: 'done', type: 'worker_done', outcome: 'succeeded' }) + ) + + expect(callsTo('send')[0]).toMatchObject({ type: 'worker_done' }) + expect(callsTo('send')[0]?.from).toBeUndefined() + }) + + it('never treats a session that has an id as identity-less, even beside the old marker', async () => { + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_STRUCTURED_SESSION: '1' }) + + await invoke('check', flagMap({})) + + expect(callsTo('check')).toHaveLength(1) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('keeps the identity-less refusal, without --from advice, for a child that has no id', async () => { + setEnv({ ORCA_STRUCTURED_SESSION: '1' }) + + await expect(invoke('reply', flagMap({ id: 'msg_1', body: 'b' }))).rejects.toMatchObject({ + code: 'no_active_sender_terminal', + message: expect.not.stringContaining('Pass --from') + }) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(callMock).not.toHaveBeenCalled() + }) + + it('leaves a terminal agent exactly as it was: its own handle is the caller', async () => { + setEnv({ ORCA_TERMINAL_HANDLE: 'term_pty', ORCA_PANE_KEY: 'tab_pty:1:2' }) + callMock.mockImplementation(async (name: string) => + name === 'terminal.resolveIdentity' ? { result: { identity: { live: true } } } : RESULT + ) + + await invoke('run-create', flagMap({ objective: 'o' })) + await invoke('check', flagMap({})) + + expect(callsTo('runCreate')[0]?.from).toBe('term_pty') + expect(callsTo('check')[0]).toMatchObject({ + terminal: 'term_pty', + terminalPaneKey: 'tab_pty:1:2' + }) + }) + + it('previews a dispatch with the coordinator address the real dispatch would write', async () => { + const preview = async (flags: Record) => { + callMock.mockClear() + await invoke('dispatch-show', flagMap({ task: 'task_1', preamble: true, ...flags })) + return callsTo('dispatchShow')[0]?.from + } + asSessionInTerminalView() + expect(await preview({})).toBe(`session:${SESSION}`) + // Not a caller flag: it names the text to preview, so it is never fenced. + expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') + setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) + expect(await preview({})).toBe('structworker_self') + expect(getTerminalHandleMock).not.toHaveBeenCalled() + }) + + it('resumes a timed-out ask as the session, without naming a terminal', async () => { + asSessionInTerminalView() + callMock.mockResolvedValue({ result: { ...RESULT.result, answer: null, timedOut: true } }) + const errors = vi.mocked(console.error) + + await invoke('ask', flagMap({ to: 'term_worker', question: 'q' }), false) + + const advice = errors.mock.calls.map(([line]) => String(line)).join('\n') + expect(advice).toContain('--resume msg_1') + expect(advice).not.toContain('--from') + }) +}) + +describe('the orchestration envelope', () => { + it('carries the injected id beside whatever terminal evidence the process also has', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: ` ${SESSION} `, + ORCA_TERMINAL_HANDLE: 'term_view_pane' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + terminalHandle: 'term_view_pane', + agentSessionId: SESSION + }) + }) + + it('claims no session without an injected id', () => { + expect( + createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ' ' }) + .orchestrationCompatibilityEvidence + ).toBeUndefined() + }) + + it('keeps a WSL stamp beside the id, so the host can refuse the cross-host claim', () => { + const envelope = createOrchestrationCompatibilityEnvelope({ + ORCA_AGENT_SESSION_ID: SESSION, + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND: 'wsl', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_ID: 'local', + ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION: 'Ubuntu' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + agentSessionId: SESSION, + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) +}) + +describe('every orchestration verb, enumerated', () => { + /** Enough flags for any verb to get past its own validation to identity resolution. */ + const EVERY_REQUIRED_FLAG = { + objective: 'o', + id: 'id_1', + task: 'task_1', + spec: 's', + question: 'q', + resolution: 'r', + subject: 's', + body: 'b', + to: 'term_worker', + status: 'completed', + preamble: true, + request: 'req_1' + } as const + + /** Runs every verb once; returns the ones that guessed an implicit terminal. */ + async function verbsThatGuess(): Promise { + const guessed: string[] = [] + for (const command of Object.keys(ORCHESTRATION_HANDLERS)) { + const verb = command.replace('orchestration ', '') + getTerminalHandleMock.mockClear() + callMock.mockClear() + await invoke(verb, flagMap(EVERY_REQUIRED_FLAG)).catch(() => undefined) + if (getTerminalHandleMock.mock.calls.length > 0) { + guessed.push(verb) + } + } + return guessed.sort() + } + + it('guesses a terminal for no verb when the session id is present', async () => { + // Positive control first: with no identity at all the same harness sees the guess, so an empty + // result below is about the id, not a harness that cannot observe a guess. + setEnv({}) + const population = await verbsThatGuess() + expect(population).toEqual([ + 'ask', + 'check', + 'dispatch', + 'dispatch-show', + 'gate-create', + 'gate-list', + 'gate-resolve', + 'reply', + 'run-create', + 'run-current', + 'run-use', + 'send', + 'task-create', + 'task-list', + 'task-update', + 'worker-list', + 'worker-start' + ]) + + asSessionInTerminalView() + expect(await verbsThatGuess()).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index c91ad0ff169..d96e214d442 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -43,6 +43,7 @@ const PARTY_NAMING_FIELDS = ['to', 'from', 'terminal', 'callerTerminalHandle'] a const NAMES_NO_RESOLVED_PARTY: Readonly> = { 'orchestration.run from': 'retired; refused before any handler', 'orchestration.runShow from': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow from': '`from` only fills the preview preamble text', 'orchestration.workerStart terminal': 'adopts an existing PTY pane, which a session never has', 'orchestration.federationAttachStart terminal': 'names the remote worker terminal', 'orchestration.workerTerminalUserInput terminal': 'names the worker terminal' @@ -58,7 +59,6 @@ const MINIMAL_PARAMS: Readonly>> = { 'orchestration.reply': { id: 'msg_missing', body: 'b' }, 'orchestration.ask': { question: 'q', to: 'term_worker' }, 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, - 'orchestration.dispatchShow': { task: 'task_missing', preamble: true }, 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, 'orchestration.gateList': {}, diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index deeeb397ca3..e4bd09f2619 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -50,8 +50,6 @@ export const ORCHESTRATION_CALLER_PARAM: Readonly> = 'orchestration.reply': 'from', 'orchestration.ask': 'from', 'orchestration.dispatch': 'from', - // The preview names its caller as the coordinator, exactly as the dispatch it previews does. - 'orchestration.dispatchShow': 'from', 'orchestration.gateCreate': 'from', 'orchestration.gateResolve': 'from', 'orchestration.gateList': 'from', diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts index e36df813570..c9fd3927c02 100644 --- a/src/shared/structured-worker-handle.ts +++ b/src/shared/structured-worker-handle.ts @@ -2,6 +2,6 @@ // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' -export function isStructuredWorkerHandle(handle: string | null | undefined): boolean { +export function isStructuredWorkerHandle(handle: string | null | undefined): handle is string { return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) } From 91313691f27f2a26b32d6ba7047f009e05309bb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:27:36 -0700 Subject: [PATCH 04/49] refactor(orchestration): keep the identity-less marker reader to the marker; the id is checked first --- src/shared/structured-session-marker.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 1725a6ef337..923f75624a1 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,20 +1,14 @@ -import { readInjectedAgentSessionId } from './agent-session-caller-env' - /** * The marker a structured chat session's child carried when it had NO orchestration identity. * - * Current hosts no longer write it: every structured child carries its injected session id - * instead. The reader stays for a child spawned by an Orca that predates injection, which can still - * reach a newer CLI through a global install — it must refuse, not guess, because a structured - * session has no pane, so every implicit-terminal guess resolves to a sibling and + * Current hosts no longer write it: every structured child carries its injected session id, which + * the CLI checks first. The reader stays for a child spawned by an Orca that predates injection, + * which can still reach a newer CLI through a global install — it must refuse, not guess, because a + * structured session has no pane, so every implicit-terminal guess resolves to a sibling and * `orchestration check` is destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' -/** An injected session id is an identity, so a child carrying one is never identity-less. */ export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { - return ( - (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 && - readInjectedAgentSessionId(env) === undefined - ) + return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 } From d13bbad380e04f63f8ef750a3e01904ec0096f98 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:59:50 -0700 Subject: [PATCH 05/49] test(orchestration): pin that a host refusal of the session surfaces verbatim from the CLI --- .../orchestration-session-caller-cli.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 93a76645a30..501eb3188c5 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -20,6 +20,8 @@ vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' +import { formatCliError, reportCliError } from './cli-error' +import { RuntimeRpcFailureError } from './runtime/types' const SESSION = 'f7a1c0de-1111-4222-8333-444455556666' const IDENTITY_ENV = [ @@ -378,6 +380,55 @@ describe('the identity a session presents', () => { }) }) +describe('a host refusal of the session', () => { + const WORKER_GONE = new RuntimeRpcFailureError({ + id: 'rpc_1', + ok: false, + error: { + code: 'session_caller_not_live', + message: `Agent session ${SESSION} is a structured worker whose worker identity this host no longer has, so it cannot act in orchestration. No effects were applied.`, + data: { effectsApplied: false } + }, + _meta: { runtimeId: 'runtime_1' } + }) + + it.each(['check', 'run-current', 'worker-list'])( + 'surfaces from %s verbatim, never widened, retried or turned into a terminal guess', + async (command) => { + asSessionInTerminalView() + callMock.mockRejectedValue(WORKER_GONE) + + await expect(invoke(command, flagMap({}))).rejects.toBe(WORKER_GONE) + expect(getTerminalHandleMock).not.toHaveBeenCalled() + expect(formatCliError(WORKER_GONE)).toBe(WORKER_GONE.message) + } + ) + + it('keeps the Orca id a provider-id refusal names, for a JSON reader to branch on', () => { + const providerId = new RuntimeRpcFailureError({ + id: 'rpc_1', + ok: false, + error: { + code: 'session_caller_provider_id', + message: 'provider id', + data: { effectsApplied: false, orcaSessionId: SESSION } + }, + _meta: { runtimeId: 'runtime_1' } + }) + const printed: string[] = [] + vi.mocked(console.log).mockImplementation((line: string) => { + printed.push(line) + }) + + reportCliError(providerId, true) + + expect(JSON.parse(printed.join('\n'))).toMatchObject({ + ok: false, + error: { code: 'session_caller_provider_id', data: { orcaSessionId: SESSION } } + }) + }) +}) + describe('the orchestration envelope', () => { it('carries the injected id beside whatever terminal evidence the process also has', () => { const envelope = createOrchestrationCompatibilityEnvelope({ From 3c2613fa84406b09f045e4dbb259bf2bd65481c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:15:52 -0700 Subject: [PATCH 06/49] fix(orchestration): keep the identity-less marker beside the id for CLIs that predate it A CLI older than the id, reached through a global install when a shell rc resets PATH, would otherwise guess a sibling's terminal in a chat that no longer carries the marker. It refuses on the marker instead; a current CLI checks the id first, so the marker never makes a session with an id identity-less. --- .../codex-structured-child-environment.test.ts | 2 ++ .../codex/codex-structured-session-adapter.test.ts | 1 + .../structured-session-child-identity-env.test.ts | 5 +++-- .../structured-session-child-identity-env.ts | 8 ++++++-- src/shared/agent-session-caller-env.ts | 3 ++- src/shared/structured-session-marker.ts | 13 +++++++------ 6 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index bddb4c69aeb..b674fd72ef5 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -31,6 +31,7 @@ describe('buildCodexStructuredChildEnvironment', () => { CODEX_HOME: '/pinned/home', [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', ORCA_AGENT_SESSION_ID: 'session-not-a-worker', + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) @@ -51,6 +52,7 @@ describe('buildCodexStructuredChildEnvironment', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', // Not a worker, so no handle: the id alone names this chat as a caller. ORCA_AGENT_SESSION_ID: sessionId, + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 48332a34069..c81f67538d9 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -35,6 +35,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-9', CODEX_HOME: '/codex/home', ORCA_AGENT_SESSION_ID: 'session-1', + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca', // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 0b0581496c4..6087c1b5356 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -70,12 +70,13 @@ describe('structuredSessionChildIdentityEnv', () => { expect(env).toEqual({ PATH: `${SHIM_DIR}:/usr/bin`, ORCA_AGENT_SESSION_ID: SESSION_ID, + // For a CLI that predates the id, which refuses on it instead of guessing a sibling. + ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: 'orca' }) - // A chat names itself by its id alone: no handle, no pane key, no identity-less marker. + // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() expect(childEnv).toEqual({ PATH: '/usr/bin' }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 36ea752e793..28f15fa24df 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -25,8 +25,10 @@ * PTY leaf. It would also open `selectExactWorkerProviderSession`, which is fail-closed today * precisely because a structured session emits no hook agent status. * - * The identity-less marker (`ORCA_STRUCTURED_SESSION`) is no longer written: a child with an id is - * never identity-less, so a marker inherited from an Orca launched inside an older session is inert. + * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a + * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; + * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a + * session with an id identity-less. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -34,6 +36,7 @@ import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' +import { ORCA_STRUCTURED_SESSION_ENV } from '../../shared/structured-session-marker' import { prependOrcaCliDirToChildPath } from '../cli/orca-cli-child-path' import { structuredWorkerIdentities } from './structured-worker-identity' @@ -46,6 +49,7 @@ export function structuredSessionChildIdentityEnv( ...childEnv, ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), [ORCA_AGENT_SESSION_ID_ENV]: sessionId, + [ORCA_STRUCTURED_SESSION_ENV]: '1', ORCA_CLI_COMMAND: 'orca' } applyOrcaCliPath(env) diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index d0f5e860559..7968dff7e13 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -5,7 +5,8 @@ * terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, - * WSL) re-opens that decision, which is why nothing forwards the id across one as a caller. + * WSL) re-opens that decision: the host refuses a claim that arrives across one, and the SSH + * passthrough never carries the id. */ export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 923f75624a1..3c849db8f9a 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,11 +1,12 @@ /** - * The marker a structured chat session's child carried when it had NO orchestration identity. + * Marks a structured session's child. It names nothing — no handle, no pane key, no token. * - * Current hosts no longer write it: every structured child carries its injected session id, which - * the CLI checks first. The reader stays for a child spawned by an Orca that predates injection, - * which can still reach a newer CLI through a global install — it must refuse, not guess, because a - * structured session has no pane, so every implicit-terminal guess resolves to a sibling and - * `orchestration check` is destructive by default. + * Every structured child now also carries its injected session id, and a current CLI checks the id + * first, so for it the marker only matters when the id is absent: a child spawned by an Orca that + * predates injection. The marker is still written for the opposite case, a CLI that predates the + * id, which refuses on it. Either way the answer is refuse, never guess: a structured session has + * no pane, so every implicit-terminal guess resolves to a sibling, and `orchestration check` is + * destructive by default. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' From 88bd39f39b553e463decfb1ba2329dbcb6d8b490 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:52:23 -0700 Subject: [PATCH 07/49] fix(orchestration): refuse a conflicting --from on gate-list and task-list scoped by --run A --run listing needs no caller, so both handlers skipped the resolver and a --from naming another actor was dropped silently under a session. The conflict check now runs on that branch too; terminal callers are unchanged. --- .../handlers/orchestration/gate-handlers.ts | 9 +++++-- .../handlers/orchestration/task-handlers.ts | 7 ++++-- .../orchestration/terminal-identity.ts | 12 ++++++++++ .../orchestration-session-caller-cli.test.ts | 24 +++++++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index f9ec4c6583f..9af0faa1be9 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,7 +2,10 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { + refuseConflictingSessionCaller, + resolveCoordinatorTerminalHandle +} from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -37,7 +40,9 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run + ? refuseConflictingSessionCaller(flags, 'from') + : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index c4c943261e8..9533b1e5f8a 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,7 +4,10 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { + refuseConflictingSessionCaller, + resolveCoordinatorTerminalHandle +} from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -39,7 +42,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? undefined + ? refuseConflictingSessionCaller(flags, 'from') : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 732fb13a77f..9766bd37214 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -193,6 +193,18 @@ function resolveInjectedSessionCaller( return sessionId } +/** + * For a listing scoped by `--run`, which needs no caller: a session still refuses a caller flag + * naming someone else rather than dropping it. + */ +export function refuseConflictingSessionCaller( + flags: Map, + flagName: 'from' | 'terminal' +): undefined { + resolveInjectedSessionCaller(flags, flagName) + return undefined +} + /** The session's own spellings, plus the handle a structured worker session was minted. */ function namesInjectedSession(value: string, sessionId: string): boolean { return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 501eb3188c5..7118dcac4ab 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -290,6 +290,30 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v ) }) +describe.each([ + { command: 'gate-list', method: 'gateList', callerParam: 'from' }, + { command: 'task-list', method: 'taskList', callerParam: 'callerTerminalHandle' } +])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { + beforeEach(asSessionInTerminalView) + + it('needs no caller, but refuses a --from naming another actor, before any request', async () => { + await invoke(command, flagMap({ run: 'run_1' })) + expect(callsTo(method)[0]).toMatchObject({ run: 'run_1' }) + expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() + + callMock.mockClear() + await expect( + invoke(command, flagMap({ run: 'run_1', from: 'term_sibling' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('accepts a --from that restates the session', async () => { + await invoke(command, flagMap({ run: 'run_1', from: `session:${SESSION}` })) + expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() + }) +}) + describe('the identity a session presents', () => { it("lets a structured worker restate its own minted handle, and nobody else's", async () => { setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) From b8fcfb361402358231bc425a499f7d0ca61db5aa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:45:15 -0700 Subject: [PATCH 08/49] fix(orchestration): name this app's CLI by absolute path for a structured session's login shells A provider can run each command in a login shell: Codex runs zsh -lc, and the profile rebuilds PATH, putting a global install (possibly an older Orca) ahead of the directory Orca prepended. ORCA_CLI_COMMAND, which an agent resolves the CLI from first, is now the absolute launcher in that directory (the native launcher on Windows), so no shell's startup files can swap it. The PATH prepend stays for shells that read no profile. Found by the live coordinator run of the next PR. --- ...laude-structured-launch-resolution.test.ts | 2 +- src/main/cli/orca-cli-child-path.ts | 16 ++++- ...codex-structured-child-environment.test.ts | 10 +-- .../codex-structured-session-adapter.test.ts | 2 +- ...uctured-session-child-identity-env.test.ts | 10 ++- .../structured-session-child-identity-env.ts | 25 ++++--- ...structured-session-cli-login-shell.test.ts | 70 +++++++++++++++++++ 7 files changed, 113 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/structured-session-cli-login-shell.test.ts diff --git a/src/main/claude/claude-structured-launch-resolution.test.ts b/src/main/claude/claude-structured-launch-resolution.test.ts index 96c4a2a6b44..cdb843ad5be 100644 --- a/src/main/claude/claude-structured-launch-resolution.test.ts +++ b/src/main/claude/claude-structured-launch-resolution.test.ts @@ -169,7 +169,7 @@ describe('claude structured launch resolution', () => { expect(launch.env).toMatchObject({ ORCA_AGENT_SESSION_ID: SESSION_ID, - ORCA_CLI_COMMAND: 'orca' + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/) }) expect(launch.env?.ORCA_AGENT_SESSION_ID).not.toBe(launch.providerSessionId) }) diff --git a/src/main/cli/orca-cli-child-path.ts b/src/main/cli/orca-cli-child-path.ts index f053e54c3d5..2a8136c367f 100644 --- a/src/main/cli/orca-cli-child-path.ts +++ b/src/main/cli/orca-cli-child-path.ts @@ -17,6 +17,8 @@ import { delimiter, join } from 'node:path' import { readInheritedPath } from '../ipc/pty/host-env/path' import { resolvePathEnvKey } from '../pty/windows-environment-path' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' +import { getBundledLauncherPath } from './bundled-cli-launcher-path' +import { DEV_COMMAND_NAME } from './cli-install-constants' export type OrcaCliChildPathOptions = { isPackaged: boolean @@ -26,11 +28,16 @@ export type OrcaCliChildPathOptions = { platform?: NodeJS.Platform } -/** Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. */ +/** + * Mutates `env` in place, prepending the directory that makes bare `orca` this app's CLI. Returns + * the absolute launcher in that directory, or null when none was prepended: a child whose shell + * rebuilds PATH (a login shell reordering it behind a global install) can still name this app's + * CLI by path. + */ export function prependOrcaCliDirToChildPath( env: Record, opts: OrcaCliChildPathOptions -): void { +): string | null { const platform = opts.platform ?? process.platform // Why: matches node:path's `delimiter` for the running platform, but stays correct when a test // drives a foreign platform through the seam. @@ -43,6 +50,7 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${devCliBin}${pathDelimiter}${inheritedPath}` : devCliBin + return join(devCliBin, platform === 'win32' ? `${DEV_COMMAND_NAME}.cmd` : DEV_COMMAND_NAME) } else if (platform === 'linux') { // Why: bare-`orca` shim scoped to Orca PTYs — Linux CLI installs as `orca-ide` to avoid shadowing GNOME's /usr/bin/orca screen reader (stablyai/orca#7904). const shimDir = ensureLinuxTerminalOrcaCliShimDir({ userDataPath: opts.userDataPath }) @@ -51,6 +59,7 @@ export function prependOrcaCliDirToChildPath( .split(pathDelimiter) .filter((entry) => entry.length > 0 && entry !== shimDir) env.PATH = [shimDir, ...inheritedEntries].join(pathDelimiter) + return join(shimDir, 'orca') } } else if (opts.resourcesPath && (platform === 'darwin' || platform === 'win32')) { // Why: global CLI registration is optional, but agents in Orca-managed PTYs must always reach this app's bundled CLI. @@ -59,5 +68,8 @@ export function prependOrcaCliDirToChildPath( env[resolvePathEnvKey(env, platform)] = inheritedPath ? `${bundledCliBin}${pathDelimiter}${inheritedPath}` : bundledCliBin + // Why the native launcher on Windows: `orca.cmd` refuses message bodies cmd.exe would mangle. + return getBundledLauncherPath(platform, opts.resourcesPath) } + return null } diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index b674fd72ef5..dfbe00d7021 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -10,6 +10,8 @@ import { } from '../runtime/structured-worker-identity' const DEV_CLI_BIN_FIRST = /^[^:;]*[\\/]cli[\\/]bin[:;]/ +// The dev launcher by absolute path: a login shell's profile cannot reorder it behind a global. +const DEV_CLI_LAUNCHER = /^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/ describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { @@ -32,7 +34,7 @@ describe('buildCodexStructuredChildEnvironment', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token', ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -53,7 +55,7 @@ describe('buildCodexStructuredChildEnvironment', () => { // Not a worker, so no handle: the id alone names this chat as a caller. ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -71,7 +73,7 @@ describe('buildCodexStructuredChildEnvironment', () => { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_CLI_COMMAND).toMatch(DEV_CLI_LAUNCHER) // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. expect(env.ORCA_PANE_KEY).toBeUndefined() } finally { @@ -130,7 +132,7 @@ describe('the spawned Codex child', () => { try { await expect(connection.request('test/env')).resolves.toEqual({ sessionId, - cliCommand: 'orca', + cliCommand: expect.stringMatching(DEV_CLI_LAUNCHER), path: expect.stringMatching(DEV_CLI_BIN_FIRST) }) } finally { diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index c81f67538d9..e78c8d3d5cf 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -36,7 +36,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { CODEX_HOME: '/codex/home', ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca', + ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 6087c1b5356..3456f8af723 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -72,7 +72,7 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_AGENT_SESSION_ID: SESSION_ID, // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: 'orca' + ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca') }) // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() @@ -106,13 +106,13 @@ describe('structuredSessionChildIdentityEnv', () => { } }) - it('on packaged Linux, through the bare-orca shim its ORCA_CLI_COMMAND assumes', () => { + it('on packaged Linux, through the bare-orca shim, named by absolute path', () => { // Without this the child's first `orca orchestration check` execs GNOME Orca — the CLI // installs as `orca-ide` on Linux (stablyai/orca#7904) — and the dispatch hangs to timeout. pinPlatform('linux') installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin:/bin' }) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_CLI_COMMAND).toBe(join(SHIM_DIR, 'orca')) expect(env.PATH).toBe(`${SHIM_DIR}:/usr/bin:/bin`) }) @@ -121,6 +121,7 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) }) it('on packaged Windows, through the bundled CLI dir under the env block spelling', () => { @@ -129,6 +130,8 @@ describe('structuredSessionChildIdentityEnv', () => { const env = structuredSessionChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) expect(env.PATH).toBeUndefined() + // The native launcher: `orca.cmd` refuses message bodies cmd.exe would mangle. + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca.exe')) }) it('unpackaged, through the dev launcher dir', () => { @@ -136,6 +139,7 @@ describe('structuredSessionChildIdentityEnv', () => { installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) + expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) }) }) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 28f15fa24df..287491863f9 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -13,11 +13,15 @@ * handle-based surfaces outside orchestration; for orchestration the id wins and the host maps it * back to that handle, so the worker keeps one identity. * - * `ORCA_CLI_COMMAND: 'orca'` is honest ONLY because of the PATH prepend below. Orca's Linux CLI - * installs as `orca-ide` so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and - * on packaged macOS/Windows the bundled launcher is reachable only from the app's own resources - * dir. A PTY agent gets that treatment from `buildPtyHostEnv`; a structured session has no PTY, so - * it applies the SAME function here rather than a second, drifting copy of the rule. + * The PATH prepend below makes bare `orca` this app's CLI, the SAME function `buildPtyHostEnv` + * applies, rather than a second, drifting copy of the rule. Orca's Linux CLI installs as `orca-ide` + * so it never claims GNOME Orca's /usr/bin/orca (stablyai/orca#7904), and on packaged macOS/Windows + * the bundled launcher is reachable only from the app's own resources dir. + * + * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each + * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global + * install — possibly an older Orca — ahead of this app's. The absolute path is what an agent resolves + * the CLI from, so it survives any shell's startup files, on every platform. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -49,10 +53,9 @@ export function structuredSessionChildIdentityEnv( ...childEnv, ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), [ORCA_AGENT_SESSION_ID_ENV]: sessionId, - [ORCA_STRUCTURED_SESSION_ENV]: '1', - ORCA_CLI_COMMAND: 'orca' + [ORCA_STRUCTURED_SESSION_ENV]: '1' } - applyOrcaCliPath(env) + env.ORCA_CLI_COMMAND = applyOrcaCliPath(env) ?? 'orca' return env } @@ -72,12 +75,12 @@ export function withStructuredSessionTerminalViewEnv( * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. */ -function applyOrcaCliPath(env: Record): void { +function applyOrcaCliPath(env: Record): string | null { if (!hasAppEnvironment()) { - return + return null } const app = getAppEnvironment() - prependOrcaCliDirToChildPath(env, { + return prependOrcaCliDirToChildPath(env, { isPackaged: app.isPackaged(), userDataPath: app.getPath('userData'), resourcesPath: process.resourcesPath ?? null diff --git a/src/main/runtime/structured-session-cli-login-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.test.ts new file mode 100644 index 00000000000..9d2d7ceab23 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -0,0 +1,70 @@ +/** + * A provider can run every command in a login shell: Codex runs `/bin/zsh -lc `. The + * login profile rebuilds PATH — macOS's path_helper, a user's `.zprofile` — so the directory Orca + * prepended ends up behind a global install, and bare `orca` becomes that install, possibly an + * older Orca. `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file + * can reorder. Real shells, with a profile that puts a stand-in global `orca` first. + */ + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + +function writeStub(path: string, says: string): void { + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) + chmodSync(path, 0o755) +} + +describe.runIf(process.platform !== 'win32')('a structured session in a login shell', () => { + let root: string + let env: Record + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) + const home = join(root, 'home') + const globalBin = join(root, 'global-bin') + const userData = join(root, 'user-data') + const appCliBin = join(userData, 'cli', 'bin') + for (const dir of [home, globalBin, appCliBin]) { + mkdirSync(dir, { recursive: true }) + } + // A global install that the user's profile puts first, as `/usr/local/bin` often is. + writeStub(join(globalBin, 'orca'), 'global') + writeStub(join(appCliBin, 'orca'), 'app') + writeStub(join(appCliBin, 'orca-dev'), 'app') + const prependGlobal = `export PATH="${globalBin}:$PATH"\n` + writeFileSync(join(home, '.zprofile'), prependGlobal) + writeFileSync(join(home, '.bash_profile'), prependGlobal) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) + env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + }) + + afterEach(() => { + rmSync(root, { recursive: true, force: true }) + }) + + async function run(shell: string, script: string): Promise { + const result = await runProcess({ program: shell, args: ['-lc', script], env }) + return result.stdout + } + + it.each(['/bin/zsh', '/bin/bash'])( + "resolves this app's CLI through ORCA_CLI_COMMAND in `%s -lc`", + async (shell) => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await run(shell, 'orca')).toBe('global') + expect(await run(shell, '"$ORCA_CLI_COMMAND"')).toBe('app') + } + ) + + it("keeps bare `orca` this app's CLI in a shell that reads no login profile", async () => { + const result = await runProcess({ program: '/bin/zsh', args: ['-c', 'orca'], env }) + expect(result.stdout).toBe('app') + }) +}) From 9894864ad0c64a4475eb9a6215adfefa5a482a79 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:59:04 -0700 Subject: [PATCH 09/49] test(orchestration): pin a structured worker's CLI command as this app's absolute launcher --- .../orchestration-structured-worker-session.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 501897b8731..fd2699cbe2e 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -1,4 +1,6 @@ +import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getAppEnvironment } from '../../../../shared/app-environment' import { dispatchWriteFailureReason } from '../../../../shared/structured-agent-session-dispatch-rejection' const hostRef: { current: unknown } = { current: null } @@ -94,7 +96,15 @@ describe('structured worker session hold', () => { onJournalActivity: () => {} }) expect(envAtSpawn?.ORCA_TERMINAL_HANDLE).toBe(created.identity.handle) - expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe('orca') + // This app's own launcher by absolute path, so a login shell's profile cannot swap in a global. + expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe( + join( + getAppEnvironment().getPath('userData'), + 'cli', + 'bin', + process.platform === 'win32' ? 'orca-dev.cmd' : 'orca-dev' + ) + ) expect(envAtSpawn?.ORCA_PANE_KEY).toBeUndefined() releaseStructuredWorkerSession('d_spawn') }) From adc822f825bd7d431affd3f242a542e8d5d77738 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:56:58 -0700 Subject: [PATCH 10/49] test(orchestration): run the zsh login-shell arm in the real-shell lane that installs zsh The ordinary Linux unit lane has no /bin/zsh, so the zsh arm failed there with ENOENT. It moves to a live-shell file registered in the shell-contracts lane; the bash arm keeps running in every lane. The lane guard's detector now also sees a zsh spawned through the ProcessSpec program field, which is how this test escaped it. --- .github/workflows/pr.yml | 1 + .github/workflows/unit-tests.yml | 1 + .../scripts/pr-workflow-parallelism.test.mjs | 3 +- ...session-cli-login-shell.live-shell.test.ts | 34 +++++++++ ...structured-session-cli-login-shell.test.ts | 76 ++++++------------- ...ctured-session-login-shell-test-harness.ts | 48 ++++++++++++ 6 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts create mode 100644 src/main/runtime/structured-session-login-shell-test-harness.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 1b52aac7571..3e7047766ab 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -576,6 +576,7 @@ jobs: src/main/zsh-scoped-histfile.live-shell.test.ts \ src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ src/shared/pty-reply-echo-shapes.node-pty.test.ts \ diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 4ed5063e318..89b2aa8e1d5 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -59,6 +59,7 @@ jobs: --exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \ --exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \ --exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \ + --exclude=src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \ --exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \ --exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \ --exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \ diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index 0ed60ffb4bb..0ef82632474 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -22,6 +22,7 @@ const shellContractFiles = [ 'src/main/zsh-scoped-histfile.live-shell.test.ts', 'src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts', 'src/main/zsh-wrapper-version-mismatch.live-shell.test.ts', + 'src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts', 'src/shared/posix-command-path-lookup.test.ts' ] const patchedNodePtyContractFiles = [ @@ -40,7 +41,7 @@ const testFilePatterns = [ // rather than calling spawnSync('zsh') themselves. Without this branch the rule // silently stops noticing the very tests that need the lane's zsh install. const realZshUsage = - /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|program:\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath|from '[^']*zsh-startup-hook-pty-harness'/ describe('PR workflow parallelism', () => { it('cancels superseded runs for the same pull request', () => { diff --git a/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts new file mode 100644 index 00000000000..c5a9eb769f1 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts @@ -0,0 +1,34 @@ +/** + * The zsh arm of `structured-session-cli-login-shell.test.ts`: Codex's own shell on macOS. Runs in + * the real-shell lane, which installs zsh; the ordinary unit lane has none. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' + +describe.runIf(process.platform !== 'win32')('a structured session in a zsh login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + it("resolves this app's CLI through ORCA_CLI_COMMAND in `zsh -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/zsh', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) + + it("keeps bare `orca` this app's CLI in a zsh that reads no login profile", async () => { + expect(await harness.run({ program: '/bin/zsh', args: ['-c', 'orca'] })).toBe('app') + }) +}) diff --git a/src/main/runtime/structured-session-cli-login-shell.test.ts b/src/main/runtime/structured-session-cli-login-shell.test.ts index 9d2d7ceab23..2079dc44eb6 100644 --- a/src/main/runtime/structured-session-cli-login-shell.test.ts +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -1,70 +1,38 @@ /** - * A provider can run every command in a login shell: Codex runs `/bin/zsh -lc `. The - * login profile rebuilds PATH — macOS's path_helper, a user's `.zprofile` — so the directory Orca - * prepended ends up behind a global install, and bare `orca` becomes that install, possibly an - * older Orca. `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file - * can reorder. Real shells, with a profile that puts a stand-in global `orca` first. + * A provider can run every command in a login shell: Codex runs ` -lc `. The login + * profile rebuilds PATH — macOS's path_helper, a user's profile — so the directory Orca prepended + * ends up behind a global install, and bare `orca` becomes that install, possibly an older Orca. + * `ORCA_CLI_COMMAND` names this app's launcher by absolute path, which no startup file can reorder. + * The zsh arm lives in `structured-session-cli-login-shell.live-shell.test.ts`, in the real-shell + * lane that installs zsh. */ -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' -import { runProcess } from '../../shared/child-process/run-process' -import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' +import { + createLoginShellHarness, + type LoginShellHarness +} from './structured-session-login-shell-test-harness' -const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' - -function writeStub(path: string, says: string): void { - writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) - chmodSync(path, 0o755) -} - -describe.runIf(process.platform !== 'win32')('a structured session in a login shell', () => { - let root: string - let env: Record +describe.runIf(process.platform !== 'win32')('a structured session in a bash login shell', () => { + let harness: LoginShellHarness beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) - const home = join(root, 'home') - const globalBin = join(root, 'global-bin') - const userData = join(root, 'user-data') - const appCliBin = join(userData, 'cli', 'bin') - for (const dir of [home, globalBin, appCliBin]) { - mkdirSync(dir, { recursive: true }) - } - // A global install that the user's profile puts first, as `/usr/local/bin` often is. - writeStub(join(globalBin, 'orca'), 'global') - writeStub(join(appCliBin, 'orca'), 'app') - writeStub(join(appCliBin, 'orca-dev'), 'app') - const prependGlobal = `export PATH="${globalBin}:$PATH"\n` - writeFileSync(join(home, '.zprofile'), prependGlobal) - writeFileSync(join(home, '.bash_profile'), prependGlobal) - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) - env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + harness = createLoginShellHarness() }) afterEach(() => { - rmSync(root, { recursive: true, force: true }) + harness.dispose() }) - async function run(shell: string, script: string): Promise { - const result = await runProcess({ program: shell, args: ['-lc', script], env }) - return result.stdout - } - - it.each(['/bin/zsh', '/bin/bash'])( - "resolves this app's CLI through ORCA_CLI_COMMAND in `%s -lc`", - async (shell) => { - // Positive control: the profile really does put the global install first for a bare name. - expect(await run(shell, 'orca')).toBe('global') - expect(await run(shell, '"$ORCA_CLI_COMMAND"')).toBe('app') - } - ) + it("resolves this app's CLI through ORCA_CLI_COMMAND in `bash -lc`", async () => { + // Positive control: the profile really does put the global install first for a bare name. + expect(await harness.run({ program: '/bin/bash', args: ['-lc', 'orca'] })).toBe('global') + expect(await harness.run({ program: '/bin/bash', args: ['-lc', '"$ORCA_CLI_COMMAND"'] })).toBe( + 'app' + ) + }) it("keeps bare `orca` this app's CLI in a shell that reads no login profile", async () => { - const result = await runProcess({ program: '/bin/zsh', args: ['-c', 'orca'], env }) - expect(result.stdout).toBe('app') + expect(await harness.run({ program: '/bin/bash', args: ['-c', 'orca'] })).toBe('app') }) }) diff --git a/src/main/runtime/structured-session-login-shell-test-harness.ts b/src/main/runtime/structured-session-login-shell-test-harness.ts new file mode 100644 index 00000000000..943dfe0f4d3 --- /dev/null +++ b/src/main/runtime/structured-session-login-shell-test-harness.ts @@ -0,0 +1,48 @@ +/** + * A structured session's child env beside a HOME whose login profiles put a stand-in global `orca` + * first, as `/usr/local/bin` often is. Shared by the bash suite (every lane) and the zsh suite + * (the real-shell lane, which installs zsh). + */ + +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' +import { runProcess } from '../../shared/child-process/run-process' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + +export type LoginShellHarness = { + /** Runs a shell with the structured session's env and returns its stdout. */ + run: (spec: { program: string; args: [flag: '-lc' | '-c', script: string] }) => Promise + dispose: () => void +} + +function writeStub(path: string, says: string): void { + writeFileSync(path, `#!/bin/sh\nprintf '%s' '${says}'\n`) + chmodSync(path, 0o755) +} + +export function createLoginShellHarness(): LoginShellHarness { + const root = mkdtempSync(join(tmpdir(), 'orca-login-shell-cli-')) + const home = join(root, 'home') + const globalBin = join(root, 'global-bin') + const userData = join(root, 'user-data') + const appCliBin = join(userData, 'cli', 'bin') + for (const dir of [home, globalBin, appCliBin]) { + mkdirSync(dir, { recursive: true }) + } + writeStub(join(globalBin, 'orca'), 'global') + writeStub(join(appCliBin, 'orca'), 'app') + writeStub(join(appCliBin, 'orca-dev'), 'app') + const prependGlobal = `export PATH="${globalBin}:$PATH"\n` + writeFileSync(join(home, '.zprofile'), prependGlobal) + writeFileSync(join(home, '.bash_profile'), prependGlobal) + installFakeAppEnvironment({ isPackaged: () => false, getPath: () => userData }) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { HOME: home, PATH: '/usr/bin:/bin' }) + return { + run: async (spec) => (await runProcess({ ...spec, env })).stdout, + dispose: () => rmSync(root, { recursive: true, force: true }) + } +} From f7d19374d25d0065451f610ae236fae72125466a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:57:26 -0700 Subject: [PATCH 11/49] fix(orchestration): omit a structured child's CLI command when no launcher resolves, and pin its instance A bare `orca` fallback named GNOME's screen reader on packaged Linux, and an inherited value named another app's CLI. The builder now deletes any inherited value, sets the absolute launcher only when one resolved, and pins ORCA_USER_DATA_PATH so a current CLI dials the instance that minted the id. Renames the marker reader to hasStructuredSessionMarker and records why the terminal view carries the id without the marker. --- .../orchestration/terminal-identity.ts | 6 +-- ...codex-structured-child-environment.test.ts | 2 + .../codex-structured-session-adapter.test.ts | 1 + ...uctured-session-child-identity-env.test.ts | 22 ++++++++++- .../structured-session-child-identity-env.ts | 37 ++++++++++++++----- src/shared/structured-session-marker.ts | 5 ++- 6 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 9766bd37214..fce1696b036 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -2,7 +2,7 @@ import type { RuntimeClient } from '../../runtime-client' import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' -import { isStructuredSessionWithoutIdentity } from '../../../shared/structured-session-marker' +import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' @@ -46,7 +46,7 @@ export async function resolveOrchestrationTerminalHandle( // default, so that guess consumed another pane's oldest unread batch and marked it read, and the // rightful worker never saw its mail. Refusing is the only honest answer: this child genuinely // cannot infer its own identity. - if (isStructuredSessionWithoutIdentity()) { + if (hasStructuredSessionMarker()) { throw structuredSessionRefusal(flagName) } if (flagName === 'from') { @@ -275,7 +275,7 @@ export function throwNoActiveSenderTerminal(): never { // place left that would tell an identity-less session to pass a handle it does not have. A stale // ORCA_TERMINAL_HANDLE is a different case — that caller HAS an identity, so it keeps the advice // to re-run under a live one. - if (isStructuredSessionWithoutIdentity() && !process.env.ORCA_TERMINAL_HANDLE) { + if (hasStructuredSessionMarker() && !process.env.ORCA_TERMINAL_HANDLE) { throw structuredSessionRefusal('from') } throw new RuntimeClientError( diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index dfbe00d7021..4dcd49e9645 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -35,6 +35,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: 'session-not-a-worker', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) @@ -56,6 +57,7 @@ describe('buildCodexStructuredChildEnvironment', () => { ORCA_AGENT_SESSION_ID: sessionId, ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(DEV_CLI_LAUNCHER), + ORCA_USER_DATA_PATH: expect.any(String), PATH: expect.stringMatching(DEV_CLI_BIN_FIRST) }) diff --git a/src/main/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index e78c8d3d5cf..5c2fcc4e086 100644 --- a/src/main/codex/codex-structured-session-adapter.test.ts +++ b/src/main/codex/codex-structured-session-adapter.test.ts @@ -37,6 +37,7 @@ describe('CodexStructuredSessionAdapter.acquire', () => { ORCA_AGENT_SESSION_ID: 'session-1', ORCA_STRUCTURED_SESSION: '1', ORCA_CLI_COMMAND: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/), + ORCA_USER_DATA_PATH: expect.any(String), // The test host is unpackaged, so this app's CLI is the dev launcher dir, first on PATH. PATH: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[:;]/) }) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 3456f8af723..34f746979a4 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -72,7 +72,9 @@ describe('structuredSessionChildIdentityEnv', () => { ORCA_AGENT_SESSION_ID: SESSION_ID, // For a CLI that predates the id, which refuses on it instead of guessing a sibling. ORCA_STRUCTURED_SESSION: '1', - ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca') + ORCA_CLI_COMMAND: join(SHIM_DIR, 'orca'), + // The instance that minted the id, so any current CLI dials it rather than the default. + ORCA_USER_DATA_PATH: USER_DATA }) // A chat names itself by its id alone: no handle, no pane key. expect(env.ORCA_TERMINAL_HANDLE).toBeUndefined() @@ -143,6 +145,24 @@ describe('structuredSessionChildIdentityEnv', () => { }) }) + it('omits the CLI command when no launcher resolves, never naming a bare `orca`', () => { + // On packaged Linux the shim can fail to resolve (no bundled launcher, an unverified AppImage); + // a bare `orca` there is GNOME's screen reader, and an inherited value names another app's CLI. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(null) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const env = structuredSessionChildIdentityEnv(SESSION_ID, { + PATH: '/usr/bin', + ORCA_CLI_COMMAND: '/Applications/Other Orca.app/Contents/Resources/bin/orca', + ORCA_USER_DATA_PATH: '/data/other-orca' + }) + expect(env).not.toHaveProperty('ORCA_CLI_COMMAND') + expect(env.PATH).toBe('/usr/bin') + expect(env.ORCA_USER_DATA_PATH).toBe(USER_DATA) + expect(console.warn).toHaveBeenCalledOnce() + }) + it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ CLAUDE_CONFIG_DIR: '/c', diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index 287491863f9..af17056b23d 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -20,8 +20,13 @@ * * `ORCA_CLI_COMMAND` is the absolute launcher in that directory, because a provider can run each * command in a login shell (Codex runs `zsh -lc`), whose profile rebuilds PATH and puts a global - * install — possibly an older Orca — ahead of this app's. The absolute path is what an agent resolves - * the CLI from, so it survives any shell's startup files, on every platform. + * install — possibly an older Orca — ahead of this app's. A current CLI reached that way re-runs + * itself as this launcher (`src/cli/session-cli-reexec.ts`), so an agent that types bare `orca` still acts + * through this app's CLI. When no launcher resolves the key is omitted rather than set to a bare + * name: on Linux a bare `orca` is GNOME's screen reader, and an inherited value names another app. + * + * `ORCA_USER_DATA_PATH` pins this instance beside the identity, so any current CLI — the session's + * own or a global one — dials the Orca that minted the id instead of the production default. * * Deliberately NOT `ORCA_PANE_KEY`. Claude structured sessions run hooks, and a pane key in their * environment starts flowing into hook-emitted agent-status payloads and the hook-attestation, @@ -32,7 +37,9 @@ * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a - * session with an id identity-less. + * session with an id identity-less. The terminal view deliberately gets the id WITHOUT the marker: + * there an older CLI has the view's own pane handle and legitimately acts as that pane, and the + * marker would make it refuse its own pane. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -55,14 +62,16 @@ export function structuredSessionChildIdentityEnv( [ORCA_AGENT_SESSION_ID_ENV]: sessionId, [ORCA_STRUCTURED_SESSION_ENV]: '1' } - env.ORCA_CLI_COMMAND = applyOrcaCliPath(env) ?? 'orca' + applyThisAppCli(env) return env } /** * The same session id for its terminal view, so switching views never changes who the session is. * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. - * A terminal that is not a session's view keeps its env exactly as given. + * A terminal that is not a session's view keeps its env exactly as given. No marker (see above), and + * no CLI command: the PTY lane names this app's launcher for every local terminal, and this env also + * crosses to SSH hosts, where a local path means nothing. */ export function withStructuredSessionTerminalViewEnv( env: Record | undefined, @@ -75,14 +84,24 @@ export function withStructuredSessionTerminalViewEnv( * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. */ -function applyOrcaCliPath(env: Record): string | null { +function applyThisAppCli(env: Record): void { + delete env.ORCA_CLI_COMMAND if (!hasAppEnvironment()) { - return null + return } const app = getAppEnvironment() - return prependOrcaCliDirToChildPath(env, { + const userDataPath = app.getPath('userData') + env.ORCA_USER_DATA_PATH = userDataPath + const launcher = prependOrcaCliDirToChildPath(env, { isPackaged: app.isPackaged(), - userDataPath: app.getPath('userData'), + userDataPath, resourcesPath: process.resourcesPath ?? null }) + if (launcher) { + env.ORCA_CLI_COMMAND = launcher + } else { + console.warn( + "[structured-session] This app's CLI launcher did not resolve; the session's child has no ORCA_CLI_COMMAND." + ) + } } diff --git a/src/shared/structured-session-marker.ts b/src/shared/structured-session-marker.ts index 3c849db8f9a..c30dc5a6139 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -7,9 +7,12 @@ * id, which refuses on it. Either way the answer is refuse, never guess: a structured session has * no pane, so every implicit-terminal guess resolves to a sibling, and `orchestration check` is * destructive by default. + * + * The reader answers only "does this process carry the marker": a current CLI reaches it after the + * id check has already returned, which is what makes a marked child with an id act as its session. */ export const ORCA_STRUCTURED_SESSION_ENV = 'ORCA_STRUCTURED_SESSION' -export function isStructuredSessionWithoutIdentity(env: NodeJS.ProcessEnv = process.env): boolean { +export function hasStructuredSessionMarker(env: NodeJS.ProcessEnv = process.env): boolean { return (env[ORCA_STRUCTURED_SESSION_ENV] ?? '').length > 0 } From 389efb44aab0779f849bdc497b37bcb2b84cbcbb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:59:27 -0700 Subject: [PATCH 12/49] fix(terminal): name this app's CLI launcher by absolute path in every local terminal ORCA_CLI_COMMAND meant three things by lane: an absolute launcher for a structured session, a bare name for WSL, and nothing for any other terminal, so a structured session's terminal view lost it. Local terminals now get the same absolute launcher the structured lane gets; WSL keeps its guest command name, and a terminal whose launcher does not resolve still gets none. --- .../pty-daemon-spawn-agent-home-env.test.ts | 4 +++- src/main/ipc/pty/host-env/assembly.ts | 21 ++++++++++++------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 8895666706e..273f08580f3 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -462,7 +462,8 @@ describe('registerPtyHandlers', () => { // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - expect(env.ORCA_CLI_COMMAND).toBeUndefined() + // The same absolute spelling a structured session gets, so a terminal view keeps it too. + expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -479,6 +480,7 @@ describe('registerPtyHandlers', () => { try { const env = await daemonSpawnAndGetEnv({ PATH: '/usr/bin' }) expect(env.PATH.split(delimiter)[0]).toBe(join('/tmp/orca-resources', 'bin')) + expect(env.ORCA_CLI_COMMAND?.startsWith(join('/tmp/orca-resources', 'bin'))).toBe(true) } finally { if (resourcesPathDescriptor) { Object.defineProperty(process, 'resourcesPath', resourcesPathDescriptor) diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index dedd44e70e3..80826e3f29c 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -274,19 +274,24 @@ export function buildPtyHostEnv( // Why: WSL shells need the managed userData root for shell-ready wrappers; dev-mode terminals need the same export so `orca` targets the live dev instance. if (opts.isWsl) { baseEnv.ORCA_USER_DATA_PATH = opts.userDataPath - // Why: managed WSL registration uses `orca-ide`; exposing that literal scopes agent guidance to WSL without a bare-orca shim. - baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' - } else { - if (!opts.isPackaged) { - baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath - } - delete baseEnv.ORCA_CLI_COMMAND + } else if (!opts.isPackaged) { + baseEnv.ORCA_USER_DATA_PATH ??= opts.userDataPath } - prependOrcaCliDirToChildPath(baseEnv, { + const launcher = prependOrcaCliDirToChildPath(baseEnv, { isPackaged: opts.isPackaged, userDataPath: opts.userDataPath, resourcesPath: opts.resourcesPath }) + if (opts.isWsl) { + // Why: managed WSL registration uses `orca-ide`; a guest cannot run the host launcher's path. + baseEnv.ORCA_CLI_COMMAND = opts.isPackaged ? 'orca-ide' : 'orca-dev' + } else if (launcher) { + // Why the absolute launcher, the same spelling a structured session gets: a login shell can + // reorder PATH behind a global install, and a current CLI re-runs itself as this one. + baseEnv.ORCA_CLI_COMMAND = launcher + } else { + delete baseEnv.ORCA_CLI_COMMAND + } if ( opts.routeBrowserOpensToClient === true && From 6d6210e185af2440960f2139c0f0f0e839063133 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:06:33 -0700 Subject: [PATCH 13/49] feat(cli): hand a command to the session's own CLI when another Orca CLI was invoked A login shell can reorder PATH behind a global install, and an agent or its helper script can run bare `orca`, so the binary that answered depended on the agent following instructions. Orca's packaged launchers and bare-orca shims now export ORCA_CLI_SELF (outermost wins). At the CLI entry, when it names a different launcher than ORCA_CLI_COMMAND, the command re-runs once through the named launcher with ORCA_CLI_REEXEC=1 and exits with its status; both variables are consumed so no child inherits them. Dev launchers export no self on purpose, WSL and SSH names never qualify, and a launcher that cannot start leaves the command to run here. The Windows launcher no longer rewrites ORCA_CLI_COMMAND; the legacy ask protocol normalizes its resume command itself. --- .../windows-cli-launcher/OrcaCliLauncher.cs | 14 +- resources/darwin/bin/orca | 2 + resources/linux/bin/orca-ide | 2 + .../orchestration-windows-ask-cli.test.ts | 21 ++ .../orchestration/runtime-compatibility.ts | 12 +- src/cli/index.ts | 5 +- src/cli/session-cli-reexec.test.ts | 182 ++++++++++++++++++ src/cli/session-cli-reexec.ts | 146 ++++++++++++++ src/main/cli/cli-self-export.test.ts | 50 +++++ src/main/cli/cli-self-export.ts | 7 + src/main/cli/linux-bare-orca-dispatcher.ts | 8 +- .../cli/linux-terminal-orca-cli-shim.test.ts | 4 + src/main/cli/linux-terminal-orca-cli-shim.ts | 3 +- src/main/cli/windows-launcher-asset.test.ts | 9 +- 14 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 src/cli/session-cli-reexec.test.ts create mode 100644 src/cli/session-cli-reexec.ts create mode 100644 src/main/cli/cli-self-export.test.ts create mode 100644 src/main/cli/cli-self-export.ts diff --git a/native/windows-cli-launcher/OrcaCliLauncher.cs b/native/windows-cli-launcher/OrcaCliLauncher.cs index 6261775859b..70b34cbbcca 100644 --- a/native/windows-cli-launcher/OrcaCliLauncher.cs +++ b/native/windows-cli-launcher/OrcaCliLauncher.cs @@ -48,11 +48,15 @@ private static int Main(string[] args) MoveEnvironmentVariable("NODE_REPL_EXTERNAL_MODULE", "ORCA_NODE_REPL_EXTERNAL_MODULE"); Environment.SetEnvironmentVariable("ELECTRON_RUN_AS_NODE", "1"); Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1"); - string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND"); - Environment.SetEnvironmentVariable( - "ORCA_CLI_COMMAND", - requestedCliCommand == "orca-ide" ? "orca-ide" : "orca" - ); + // Why: names this launcher as the entry the caller ran, and leaves ORCA_CLI_COMMAND as + // the session set it, so the CLI can hand off to the session's own launcher. + if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("ORCA_CLI_SELF"))) + { + Environment.SetEnvironmentVariable( + "ORCA_CLI_SELF", + typeof(OrcaCliLauncher).Assembly.Location + ); + } using (Process child = Process.Start(startInfo)) { diff --git a/resources/darwin/bin/orca b/resources/darwin/bin/orca index 4d01b9928ca..ab0b0edb226 100755 --- a/resources/darwin/bin/orca +++ b/resources/darwin/bin/orca @@ -25,6 +25,8 @@ ELECTRON="$CONTENTS/MacOS/Orca" # launcher model instead of requiring a separate npm-distributed binary. CLI="$CONTENTS/Resources/app.asar.unpacked/out/cli/index.js" +# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. +export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/resources/linux/bin/orca-ide b/resources/linux/bin/orca-ide index f191f27c770..8cbcaf35970 100755 --- a/resources/linux/bin/orca-ide +++ b/resources/linux/bin/orca-ide @@ -33,6 +33,8 @@ fi # launcher model used on macOS instead of maintaining a separate Node binary. CLI="$RESOURCES_DIR/app.asar.unpacked/out/cli/index.js" +# Why: names the entry the caller ran, so the CLI can hand off to a session's own launcher. +export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}" export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}" export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}" unset NODE_OPTIONS diff --git a/src/cli/handlers/orchestration-windows-ask-cli.test.ts b/src/cli/handlers/orchestration-windows-ask-cli.test.ts index ec7f346c0bd..c29300a7074 100644 --- a/src/cli/handlers/orchestration-windows-ask-cli.test.ts +++ b/src/cli/handlers/orchestration-windows-ask-cli.test.ts @@ -64,6 +64,27 @@ describe('packaged Windows legacy ask protocol', () => { } ) + it("names `orca` when a session's ORCA_CLI_COMMAND is the launcher's absolute path", async () => { + process.env.ORCA_CLI_COMMAND = 'C:\\Program Files\\Orca\\resources\\bin\\orca.exe' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_question', + threadId: 'msg_question', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk(new Map([['resume', 'msg_question']])) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + expect.objectContaining({ compatibilityWindowsCommand: 'orca' }), + expect.any(Object) + ) + }) + it('resumes the committed question without another exit-75 handoff', async () => { process.env.ORCA_CLI_COMMAND = 'orca' callMock.mockResolvedValue({ diff --git a/src/cli/handlers/orchestration/runtime-compatibility.ts b/src/cli/handlers/orchestration/runtime-compatibility.ts index e4076534daa..8335f0c34e5 100644 --- a/src/cli/handlers/orchestration/runtime-compatibility.ts +++ b/src/cli/handlers/orchestration/runtime-compatibility.ts @@ -1,5 +1,3 @@ -import { RuntimeClientError } from '../../runtime-client' - export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-dev' { const configured = process.env.ORCA_CLI_COMMAND if (configured === 'orca' || configured === 'orca-ide' || configured === 'orca-dev') { @@ -8,18 +6,12 @@ export function resolveCompatibilityCliCommand(): 'orca' | 'orca-ide' | 'orca-de return process.platform === 'linux' ? 'orca-ide' : 'orca' } +/** The resume command a legacy host prints: `orca-ide` only when WSL asked for it, else `orca`. */ export function resolvePackagedWindowsCompatibilityCommand(): 'orca' | 'orca-ide' | undefined { if (process.env.ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER !== '1') { return undefined } - const command = process.env.ORCA_CLI_COMMAND - if (command === 'orca' || command === 'orca-ide') { - return command - } - throw new RuntimeClientError( - 'invalid_argument', - 'The packaged Orca launcher did not provide a valid resume command. No question was created.' - ) + return process.env.ORCA_CLI_COMMAND === 'orca-ide' ? 'orca-ide' : 'orca' } export async function flushOrchestrationStdout(): Promise { diff --git a/src/cli/index.ts b/src/cli/index.ts index 33566dcf837..f88d7ff2221 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { printHelp } from './help' import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' +import { runAsSessionCli } from './session-cli-reexec' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -232,5 +233,7 @@ async function runAgentTeamsTmuxShim(argv: string[]): Promise { } if (require.main === module) { - void main() + // Why here and not in main(): main() is also called in-process by tests and by wrappers that + // require this module, where exiting or consuming process.env would hit the caller's process. + void runAsSessionCli(() => main()) } diff --git a/src/cli/session-cli-reexec.test.ts b/src/cli/session-cli-reexec.test.ts new file mode 100644 index 00000000000..383895fe283 --- /dev/null +++ b/src/cli/session-cli-reexec.test.ts @@ -0,0 +1,182 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + ORCA_CLI_REEXEC_ENV, + ORCA_CLI_SELF_ENV, + runAsSessionCli, + takeSessionCliReexec +} from './session-cli-reexec' + +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-session-cli-reexec-')) +}) + +afterEach(() => { + vi.restoreAllMocks() + rmSync(dir, { recursive: true, force: true }) +}) + +function writeScript(name: string, body: string): string { + const path = join(dir, name) + writeFileSync(path, `#!/usr/bin/env bash\n${body}`) + chmodSync(path, 0o755) + return path +} + +class Exited extends Error { + constructor(readonly code: number) { + super(`exit ${code}`) + } +} + +function exitSpy(): (code: number) => never { + return (code: number) => { + throw new Exited(code) + } +} + +describe('takeSessionCliReexec', () => { + it('hands off when the invoked CLI is not the launcher the session named', () => { + const invoked = writeScript('global-orca', 'exit 0\n') + const named = writeScript('session-orca', 'exit 0\n') + const env: NodeJS.ProcessEnv = { + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: invoked, + ORCA_AGENT_SESSION_ID: 'session-1', + ELECTRON_RUN_AS_NODE: '1', + ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER: '1', + ORCA_NODE_OPTIONS: '--max-old-space-size=4096', + ORCA_NODE_REPL_EXTERNAL_MODULE: '' + } + + const reexec = takeSessionCliReexec({ env, argv: ['orchestration', 'check'] }) + + expect(reexec).toEqual({ + target: named, + argv: ['orchestration', 'check'], + // What the invoked launcher was handed, so the named one sees the caller's own environment. + env: { + ORCA_CLI_COMMAND: named, + ORCA_AGENT_SESSION_ID: 'session-1', + NODE_OPTIONS: '--max-old-space-size=4096', + [ORCA_CLI_REEXEC_ENV]: '1' + } + }) + // Consumed: nothing this CLI starts inherits the identity of the launcher that ran it. + expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) + }) + + it('stays when the invoked launcher is the named one, through a symlink', () => { + const named = writeScript('session-orca', 'exit 0\n') + const link = join(dir, 'usr-local-bin-orca') + symlinkSync(named, link) + + expect( + takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: link } }) + ).toBeNull() + }) + + it('makes at most one hop, and consumes the guard so no child inherits it', () => { + const env: NodeJS.ProcessEnv = { + ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n'), + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + [ORCA_CLI_REEXEC_ENV]: '1' + } + + expect(takeSessionCliReexec({ env })).toBeNull() + expect(env).not.toHaveProperty(ORCA_CLI_REEXEC_ENV) + expect(env).not.toHaveProperty(ORCA_CLI_SELF_ENV) + }) + + it('stays when no Orca launcher named itself: a dev launcher, or an older one', () => { + expect( + takeSessionCliReexec({ env: { ORCA_CLI_COMMAND: writeScript('session-orca', 'exit 0\n') } }) + ).toBeNull() + }) + + it.each([ + ['a WSL guest command name', 'orca-ide'], + ["the SSH host's relay command", 'orca'] + ])('never resolves %s against the working directory', (_label, command) => { + writeScript(command, 'exit 0\n') + vi.spyOn(process, 'cwd').mockReturnValue(dir) + + expect( + takeSessionCliReexec({ + env: { ORCA_CLI_COMMAND: command, [ORCA_CLI_SELF_ENV]: writeScript('global', 'exit 0\n') } + }) + ).toBeNull() + }) + + it('stays when the named launcher no longer exists', () => { + expect( + takeSessionCliReexec({ + env: { + ORCA_CLI_COMMAND: join(dir, 'gone', 'orca'), + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') + } + }) + ).toBeNull() + }) +}) + +describe.skipIf(process.platform === 'win32')('runAsSessionCli', () => { + it("runs the command through the session's launcher and exits with its status", async () => { + const report = join(dir, 'report') + const named = writeScript( + 'session-orca', + `printf '%s|%s|%s' "$*" "$ORCA_CLI_REEXEC" "\${NODE_OPTIONS-}" > '${report}'\nexit 7\n` + ) + const run = vi.fn(async () => {}) + + await expect( + runAsSessionCli(run, { + env: { + ...process.env, + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n'), + ORCA_NODE_OPTIONS: '--no-warnings' + }, + argv: ['orchestration', 'check', '--wait'], + exit: exitSpy() + }) + ).rejects.toEqual(new Exited(7)) + + expect(readFileSync(report, 'utf8')).toBe('orchestration check --wait|1|--no-warnings') + expect(run).not.toHaveBeenCalled() + }) + + it('runs the command here when it is already the named CLI', async () => { + const named = writeScript('session-orca', 'exit 0\n') + const run = vi.fn(async () => {}) + + await runAsSessionCli(run, { + env: { ORCA_CLI_COMMAND: named, [ORCA_CLI_SELF_ENV]: named }, + exit: exitSpy() + }) + + expect(run).toHaveBeenCalledOnce() + }) + + it('runs the command here, and says so, when the named CLI cannot start', async () => { + const named = join(dir, 'not-executable') + writeFileSync(named, 'not a program') + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const run = vi.fn(async () => {}) + + await runAsSessionCli(run, { + env: { + ORCA_CLI_COMMAND: named, + [ORCA_CLI_SELF_ENV]: writeScript('global-orca', 'exit 0\n') + }, + exit: exitSpy() + }) + + expect(run).toHaveBeenCalledOnce() + expect(String(stderr.mock.calls[0]?.[0])).toContain("could not run this session's CLI") + }) +}) diff --git a/src/cli/session-cli-reexec.ts b/src/cli/session-cli-reexec.ts new file mode 100644 index 00000000000..5b31646e322 --- /dev/null +++ b/src/cli/session-cli-reexec.ts @@ -0,0 +1,146 @@ +/** + * Hands a command to the CLI the session named, when a different Orca CLI was the one invoked. + * + * Orca puts the absolute launcher of its own CLI in `ORCA_CLI_COMMAND` for every local terminal and + * structured session. An agent may still reach another install — a login shell reorders PATH behind + * a global `orca`, a helper script hardcodes `orca`, a user types `/usr/local/bin/orca` — and that + * CLI can be older than the session's identity or dial a different instance. So a current CLI that + * is not the named launcher re-runs the command through it, once, and exits with its status. Which + * binary answers stops depending on the agent following instructions. + * + * Identity comes from `ORCA_CLI_SELF`, which Orca's packaged launchers and bare-`orca` shims export + * (the outermost one wins); this entry's own argv names the JS file, never a launcher. A dev launcher + * exports none on purpose: it pins its own instance, so running one is a deliberate choice of + * instance, often from another instance's terminal. `ORCA_CLI_REEXEC=1` bounds the handoff to one + * hop and is also the escape hatch. Both variables are consumed here, so no child of the CLI — an + * Orca app it starts, a terminal that app opens — inherits a stale identity or a disabled handoff. + * + * WSL and SSH never qualify: they carry a guest command name or `orca`, not a host path, and a + * relative command is never resolved against the working directory. + */ + +import { realpathSync } from 'node:fs' +import { constants as osConstants } from 'node:os' +import { posix, resolve, win32 } from 'node:path' + +export const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' +export const ORCA_CLI_REEXEC_ENV = 'ORCA_CLI_REEXEC' + +/** Set by the launcher that started this process; the next launcher sets them again itself. */ +const LAUNCHER_OWNED_ENV = ['ELECTRON_RUN_AS_NODE', 'ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER'] as const +/** Stashed by every launcher so Electron's node bootstrap never sees them; the next one re-stashes. */ +const LAUNCHER_STASHED_ENV = [ + ['ORCA_NODE_OPTIONS', 'NODE_OPTIONS'], + ['ORCA_NODE_REPL_EXTERNAL_MODULE', 'NODE_REPL_EXTERNAL_MODULE'] +] as const + +export type SessionCliReexec = { + target: string + argv: readonly string[] + env: NodeJS.ProcessEnv +} + +type ReexecOptions = { + env?: NodeJS.ProcessEnv + argv?: readonly string[] + platform?: NodeJS.Platform +} + +/** The CLI entry: hand off to the session's own CLI when this is a different one, else `run`. */ +export async function runAsSessionCli( + run: () => Promise, + options: ReexecOptions & { exit?: (code: number) => never } = {} +): Promise { + const reexec = takeSessionCliReexec(options) + if (reexec) { + await runSessionCliReexec(reexec, options.exit) + } + await run() +} + +/** + * Removes the launcher handoff variables from `env` and returns the re-exec this process owes, or + * null when it is already the named CLI, cannot tell, or is itself the one hop. + */ +export function takeSessionCliReexec(options: ReexecOptions = {}): SessionCliReexec | null { + const env = options.env ?? process.env + const platform = options.platform ?? process.platform + const self = env[ORCA_CLI_SELF_ENV]?.trim() + const alreadyHandedOff = env[ORCA_CLI_REEXEC_ENV] === '1' + delete env[ORCA_CLI_SELF_ENV] + delete env[ORCA_CLI_REEXEC_ENV] + if (alreadyHandedOff || !self) { + return null + } + const named = env.ORCA_CLI_COMMAND?.trim() + if (!named || !(platform === 'win32' ? win32 : posix).isAbsolute(named)) { + return null + } + const target = tryRealpath(named) + const current = tryRealpath(self) + if (target === null || current === null || samePath(target, current, platform)) { + return null + } + return { + target: named, + argv: [...(options.argv ?? process.argv.slice(2))], + env: buildHandoffEnv(env) + } +} + +/** The environment the invoked launcher was given, plus the one-hop guard. */ +function buildHandoffEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const handoff: NodeJS.ProcessEnv = { ...env } + for (const key of LAUNCHER_OWNED_ENV) { + delete handoff[key] + } + for (const [stash, original] of LAUNCHER_STASHED_ENV) { + const value = handoff[stash] + delete handoff[stash] + if (value) { + handoff[original] = value + } + } + handoff[ORCA_CLI_REEXEC_ENV] = '1' + return handoff +} + +function tryRealpath(path: string): string | null { + try { + return realpathSync(resolve(path)) + } catch { + return null + } +} + +function samePath(left: string, right: string, platform: NodeJS.Platform): boolean { + return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right +} + +/** + * Runs the handoff and exits with its status. Returns only when the named CLI could not be started, + * so the command still runs here — the behavior before the handoff existed — rather than failing. + */ +export async function runSessionCliReexec( + reexec: SessionCliReexec, + exit: (code: number) => never = process.exit +): Promise { + const { runProcessSync } = await import('../shared/child-process/run-process.js') + let result: { code: number | null; signal: NodeJS.Signals | null } + try { + result = runProcessSync({ + program: reexec.target, + args: reexec.argv, + env: reexec.env, + stdio: 'inherit', + timeoutMs: null + }) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + process.stderr.write( + `orca: could not run this session's CLI (${reexec.target}): ${reason}. Running this one.\n` + ) + return + } + exit(result.code ?? (result.signal ? 128 + (osConstants.signals[result.signal] ?? 0) : 1)) +} diff --git a/src/main/cli/cli-self-export.test.ts b/src/main/cli/cli-self-export.test.ts new file mode 100644 index 00000000000..af24a3cf925 --- /dev/null +++ b/src/main/cli/cli-self-export.test.ts @@ -0,0 +1,50 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' + +const ORCA_CLI_SELF_ENV = 'ORCA_CLI_SELF' +let dir: string + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-cli-self-export-')) +}) + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }) +}) + +function writeScript(name: string, body: string): string { + const path = join(dir, name) + writeFileSync(path, `#!/usr/bin/env bash\n${body}`) + chmodSync(path, 0o755) + return path +} + +describe.skipIf(process.platform === 'win32')('the launcher self export', () => { + it('names the outermost Orca script, so a shim that execs a launcher stays the entry', () => { + const report = join(dir, 'report') + const launcher = writeScript( + 'orca-ide', + `${ORCA_CLI_SELF_EXPORT}printf '%s' "$ORCA_CLI_SELF" > '${report}'\n` + ) + const shim = writeScript('orca', `${ORCA_CLI_SELF_EXPORT}exec '${launcher}' "$@"\n`) + const env = { ...process.env } + delete env[ORCA_CLI_SELF_ENV] + + expect(spawnSync(shim, [], { env }).status).toBe(0) + expect(readFileSync(report, 'utf8')).toBe(shim) + + expect(spawnSync(launcher, [], { env }).status).toBe(0) + expect(readFileSync(report, 'utf8')).toBe(launcher) + }) + + it.each(['resources/darwin/bin/orca', 'resources/linux/bin/orca-ide'])( + 'is the line the packaged %s launcher runs', + (path) => { + expect(readFileSync(join(process.cwd(), path), 'utf8')).toContain(ORCA_CLI_SELF_EXPORT) + } + ) +}) diff --git a/src/main/cli/cli-self-export.ts b/src/main/cli/cli-self-export.ts new file mode 100644 index 00000000000..499da55677b --- /dev/null +++ b/src/main/cli/cli-self-export.ts @@ -0,0 +1,7 @@ +/** + * The bash line an Orca CLI launcher or shim runs to name itself as the entry the caller invoked. + * The outermost Orca script wins, so a shim that execs a launcher keeps the shim's own path; the CLI + * compares it with the session's `ORCA_CLI_COMMAND` and consumes it (src/cli/session-cli-reexec.ts). + * Kept identical to the line in the packaged launchers under resources/. + */ +export const ORCA_CLI_SELF_EXPORT = 'export ORCA_CLI_SELF="${ORCA_CLI_SELF:-${BASH_SOURCE[0]}}"\n' diff --git a/src/main/cli/linux-bare-orca-dispatcher.ts b/src/main/cli/linux-bare-orca-dispatcher.ts index 6c4b273fcd8..d7993590de6 100644 --- a/src/main/cli/linux-bare-orca-dispatcher.ts +++ b/src/main/cli/linux-bare-orca-dispatcher.ts @@ -15,6 +15,7 @@ import { pruneAppImageExtractedRoots } from './appimage-extraction-pruning' import { withAppImageRegistrationLock } from './appimage-registration-lock' import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { quoteShell } from './cli-install-path-format' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' // Why: marks a dispatcher this function wrote so repeat serve starts overwrite // our own file idempotently but never clobber a user's own ~/.local/bin/orca. @@ -74,9 +75,12 @@ export async function installLinuxBareOrcaDispatcher( : { state: 'skipped-foreign', dispatcherPath, target: null } } -/** Bare-`orca` script that execs the one Linux CLI launcher. */ +/** + * Bare-`orca` script that execs the one Linux CLI launcher. It names itself as the CLI entry, so a + * session that names this script as its CLI does not hand off to the launcher behind it. + */ export function buildBareOrcaCliScript(launcherPath: string): string { - return `#!/usr/bin/env bash\nexec ${quoteShell(launcherPath)} "$@"\n` + return `#!/usr/bin/env bash\n${ORCA_CLI_SELF_EXPORT}exec ${quoteShell(launcherPath)} "$@"\n` } /** diff --git a/src/main/cli/linux-terminal-orca-cli-shim.test.ts b/src/main/cli/linux-terminal-orca-cli-shim.test.ts index 905d4807368..1820668e384 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.test.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.test.ts @@ -11,6 +11,7 @@ vi.mock('electron', () => ({ import { resolveAppImageLauncherEndpointPath } from './appimage-stable-launcher' import { ensureLinuxTerminalOrcaCliShimDir } from './linux-terminal-orca-cli-shim' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const created: string[] = [] const canFenceAppImageRuntime = process.platform === 'linux' && existsSync('/proc/self/stat') @@ -57,6 +58,8 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { const content = readFileSync(join(shimDir!, 'orca'), 'utf8') // Single-quoted so a resources path with shell metacharacters can't break out. expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`) + // A session names this shim as its CLI, so the shim, not the launcher behind it, is the entry. + expect(content).toContain(ORCA_CLI_SELF_EXPORT) const mode = statSync(join(shimDir!, 'orca')).mode & 0o777 expect(mode & 0o111).not.toBe(0) }) @@ -115,6 +118,7 @@ describe('ensureLinuxTerminalOrcaCliShimDir', () => { expect(content).toContain(liveLauncherPath) expect(content).toContain('runtime_pid=') expect(content).toContain('/proc/$runtime_pid/stat') + expect(content).toContain(ORCA_CLI_SELF_EXPORT) expect(existsSync(resolveAppImageLauncherEndpointPath(cacheRootPath, 'live'))).toBe(false) await expect( runProcess({ program: shimPath, args: [], timeoutMs: 3_000 }) diff --git a/src/main/cli/linux-terminal-orca-cli-shim.ts b/src/main/cli/linux-terminal-orca-cli-shim.ts index 7697bac01ac..7e484836673 100644 --- a/src/main/cli/linux-terminal-orca-cli-shim.ts +++ b/src/main/cli/linux-terminal-orca-cli-shim.ts @@ -23,6 +23,7 @@ import { import { getBundledLauncherPath } from './bundled-cli-launcher-path' import { buildBareOrcaCliScript } from './linux-bare-orca-dispatcher' import { quoteShell } from './cli-install-path-format' +import { ORCA_CLI_SELF_EXPORT } from './cli-self-export' const SHIM_DIR_NAME = 'linux-orca-cli-shim' @@ -203,7 +204,7 @@ runtime_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$runtime_root" 2>/dev/null)" | launcher_identity="$(stat -Lc '%d:%i:%s:%Y:%Z' -- "$launcher" 2>/dev/null)" || fail [[ "$launcher_identity" == "$expected_launcher_identity" ]] || fail [[ -f "$launcher" && -x "$launcher" ]] || fail -exec "$launcher" "$@" +${ORCA_CLI_SELF_EXPORT}exec "$launcher" "$@" ` } diff --git a/src/main/cli/windows-launcher-asset.test.ts b/src/main/cli/windows-launcher-asset.test.ts index 2eec4fee577..91986d4e43d 100644 --- a/src/main/cli/windows-launcher-asset.test.ts +++ b/src/main/cli/windows-launcher-asset.test.ts @@ -21,10 +21,11 @@ describe('packaged Windows CLI launcher asset', () => { expect(source).toContain( 'Environment.SetEnvironmentVariable("ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER", "1");' ) - expect(source).toContain( - 'string requestedCliCommand = Environment.GetEnvironmentVariable("ORCA_CLI_COMMAND");' - ) - expect(source).toContain('requestedCliCommand == "orca-ide" ? "orca-ide" : "orca"') + // It names itself as the CLI entry and leaves the session's ORCA_CLI_COMMAND untouched, so the + // CLI can compare the two and hand off to the session's own launcher. + expect(source).toContain('"ORCA_CLI_SELF",') + expect(source).toContain('typeof(OrcaCliLauncher).Assembly.Location') + expect(source).not.toMatch(/SetEnvironmentVariable\(\s*"ORCA_CLI_COMMAND"/) expect(source).toContain('child.WaitForExit();') expect(source).toContain('return child.ExitCode;') }) From 7d80cb0a580c2c1585e51bc8316909c3eec50399 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:10:44 -0700 Subject: [PATCH 14/49] refactor(orchestration): declare which flag names the caller on each spec and refuse at the CLI entry Each handler hand-classified its --from/--terminal as the caller or a target, and the refusal of a conflicting caller flag ran inside the caller resolver plus two standalone calls for --run listings, so a new verb that read its flag raw would pass a sibling's handle to a pre-session host. Specs now declare identityFlagRoles, the CLI entry refuses a conflicting caller flag once from the spec, the resolver only applies the id-wins rule, and a test fails any orchestration verb that accepts --from or --terminal without classifying it. --- src/cli/command-spec.ts | 5 + .../orchestration/dispatch-handlers.ts | 3 +- .../handlers/orchestration/gate-handlers.ts | 9 +- .../handlers/orchestration/task-handlers.ts | 7 +- .../orchestration/terminal-identity.ts | 61 +----------- src/cli/index-orchestration.test.ts | 19 ++++ src/cli/index.ts | 5 + .../orchestration-session-caller-cli.test.ts | 95 +++++++++++++++---- src/cli/session-caller-flags.ts | 63 ++++++++++++ src/cli/specs/orchestration-worker-specs.ts | 1 + src/cli/specs/orchestration.ts | 33 +++++-- 11 files changed, 206 insertions(+), 95 deletions(-) create mode 100644 src/cli/session-caller-flags.ts diff --git a/src/cli/command-spec.ts b/src/cli/command-spec.ts index 1922888a292..dbb28a66d71 100644 --- a/src/cli/command-spec.ts +++ b/src/cli/command-spec.ts @@ -15,8 +15,13 @@ export type CommandSpec = { positionalArgs?: string[] examples?: string[] notes?: string[] + // Why: `--from`/`--terminal` names either the acting caller or a target, and only the spec can + // say which. An agent session refuses a caller flag naming anyone else before dispatch. + identityFlagRoles?: Partial> } +export type IdentityFlag = 'from' | 'terminal' + export function specPaths(spec: CommandSpec): string[][] { return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path] } diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index 5162ca6e291..5f28d4bbcd3 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -5,7 +5,8 @@ import { RuntimeClientError } from '../../runtime-client' import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-contract' import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' -import { injectedSessionAddress, resolveCoordinatorTerminalHandle } from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' +import { injectedSessionAddress } from '../../session-caller-flags' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/orchestration/gate-handlers.ts b/src/cli/handlers/orchestration/gate-handlers.ts index 9af0faa1be9..f9ec4c6583f 100644 --- a/src/cli/handlers/orchestration/gate-handlers.ts +++ b/src/cli/handlers/orchestration/gate-handlers.ts @@ -2,10 +2,7 @@ import type { CommandHandler } from '../../dispatch' import { printResult } from '../../format' import { getOptionalJsonFlag, getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-create': async ({ flags, client, cwd, json }) => { @@ -40,9 +37,7 @@ export const ORCHESTRATION_GATE_HANDLERS: Record = { 'orchestration gate-list': async ({ flags, client, cwd, json }) => { const run = getOptionalStringFlag(flags, 'run') // Why: named runs remain inspectable without a pane; only implicit runs resolve identity. - const from = run - ? refuseConflictingSessionCaller(flags, 'from') - : await resolveCoordinatorTerminalHandle(flags, cwd, client) + const from = run ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ gates: { id: string; task_id: string; question: string; status: string }[] count: number diff --git a/src/cli/handlers/orchestration/task-handlers.ts b/src/cli/handlers/orchestration/task-handlers.ts index 9533b1e5f8a..c4c943261e8 100644 --- a/src/cli/handlers/orchestration/task-handlers.ts +++ b/src/cli/handlers/orchestration/task-handlers.ts @@ -4,10 +4,7 @@ import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { abbreviateOrchestrationTasks } from '../../../shared/orchestration-task-summary' import { callOrchestrationMutation } from './mutation-request' -import { - refuseConflictingSessionCaller, - resolveCoordinatorTerminalHandle -} from './terminal-identity' +import { resolveCoordinatorTerminalHandle } from './terminal-identity' const TASK_STATUS_VALUES = [ 'pending', @@ -42,7 +39,7 @@ export const ORCHESTRATION_TASK_HANDLERS: Record = { const brief = flags.has('brief') const run = getOptionalStringFlag(flags, 'run') const callerTerminalHandle = run - ? refuseConflictingSessionCaller(flags, 'from') + ? undefined : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index fce1696b036..572b9096bce 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -4,8 +4,7 @@ import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' -import { normalizeOrchestrationActor } from '../../../shared/orchestration-actor' -import { isStructuredWorkerHandle } from '../../../shared/structured-worker-handle' +import { injectedSessionAddress } from '../../session-caller-flags' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: @@ -19,7 +18,8 @@ export async function resolveOrchestrationTerminalHandle( flagName: 'from' | 'terminal', options: { validateEnvHandle?: boolean } = {} ): Promise { - if (resolveInjectedSessionCaller(flags, flagName)) { + // A caller flag naming anyone else was already refused at the CLI entry, from the command's spec. + if (readInjectedAgentSessionId()) { return undefined } const explicit = getOptionalStringFlag(flags, flagName) @@ -168,61 +168,6 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } -/** - * The injected session id when this command runs as an agent session. The id wins over every other - * identity this process carries; a caller flag may restate that same session but never name - * another, and a conflicting one is refused here, before any request is sent. - */ -function resolveInjectedSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): string | undefined { - const sessionId = readInjectedAgentSessionId() - if (!sessionId) { - return undefined - } - const declared = getOptionalStringFlag(flags, flagName) - if (declared !== undefined && !namesInjectedSession(declared, sessionId)) { - throw new RuntimeClientError( - 'consumer_fenced', - `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + - `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + - `session:${sessionId}. No request was sent.` - ) - } - return sessionId -} - -/** - * For a listing scoped by `--run`, which needs no caller: a session still refuses a caller flag - * naming someone else rather than dropping it. - */ -export function refuseConflictingSessionCaller( - flags: Map, - flagName: 'from' | 'terminal' -): undefined { - resolveInjectedSessionCaller(flags, flagName) - return undefined -} - -/** The session's own spellings, plus the handle a structured worker session was minted. */ -function namesInjectedSession(value: string, sessionId: string): boolean { - return normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress() -} - -/** - * The address the host gives this session: a structured worker keeps the handle it was minted, any - * other session is `session:`. Only for text that must match what the host writes. - */ -export function injectedSessionAddress(): string | undefined { - const sessionId = readInjectedAgentSessionId() - if (!sessionId) { - return undefined - } - const ownHandle = process.env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` -} - /** How check output names its caller: the handle, or the session's address. */ export function orchestrationCallerLabel(handle: string | undefined): string { return handle ?? injectedSessionAddress() ?? 'unknown' diff --git a/src/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index c4e4438f2e9..52fc7222e46 100644 --- a/src/cli/index-orchestration.test.ts +++ b/src/cli/index-orchestration.test.ts @@ -85,6 +85,25 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) + it("refuses an agent session's caller flag naming another actor before any request", async () => { + // One chokepoint for every verb: the spec says which flag names the caller. + process.env.ORCA_AGENT_SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + await main(['orchestration', 'check', '--terminal', 'term_sibling', '--json'], '/tmp/repo') + } finally { + delete process.env.ORCA_AGENT_SESSION_ID + } + + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + ok: false, + error: { code: 'consumer_fenced' } + }) + process.exitCode = undefined + }) + it('rejects no-flag orchestration reset before calling the runtime', async () => { await main(['orchestration', 'reset'], '/tmp/repo') diff --git a/src/cli/index.ts b/src/cli/index.ts index f88d7ff2221..ede5fd9f9b4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,6 +21,7 @@ import type { RuntimeClient } from './runtime-client' import { COMMAND_SPECS } from './specs' import { resolveOrchestrationCliExecutable } from './runtime/orchestration-recovery-command' import { runAsSessionCli } from './session-cli-reexec' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -111,6 +112,10 @@ export async function main( // lookup so users do not get misleading "Orca is not running" failures for // simple command typos or unsupported flags. validateCommandAndFlags(COMMAND_SPECS, parsed) + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, parsed.commandPath), + parsed.flags + ) const RuntimeClientClass = await loadRuntimeClientClass() const ignoreRemoteSelection = shouldIgnoreRemoteSelection(parsed.commandPath) const pairingCode = ignoreRemoteSelection ? null : parsed.flags.get('pairing-code') diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 7118dcac4ab..9ca398e35b2 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -19,6 +19,9 @@ vi.mock('./format', () => ({ printResult: vi.fn() })) vi.mock('./selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' +import { findCommandSpec } from './args' +import { COMMAND_SPECS } from './specs' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' import { createOrchestrationCompatibilityEnvelope } from './runtime/orchestration-compatibility-envelope' import { formatCliError, reportCliError } from './cli-error' import { RuntimeRpcFailureError } from './runtime/types' @@ -171,10 +174,27 @@ const CALLER_VERBS: Verb[] = [ { command: 'worker-list', flags: {}, method: 'runCurrent', callerParam: 'from' } ] +/** Enough flags for any verb to get past its own validation to identity resolution. */ +const EVERY_REQUIRED_FLAG = { + objective: 'o', + id: 'id_1', + task: 'task_1', + spec: 's', + question: 'q', + resolution: 'r', + subject: 's', + body: 'b', + to: 'term_worker', + status: 'completed', + preamble: true, + request: 'req_1' +} as const + function flagMap(flags: Record): Map { return new Map(Object.entries(flags)) } +/** What `main()` does between parsing and dispatch: the spec-driven caller check, then the handler. */ async function invoke( command: string, flags: Map, @@ -184,6 +204,10 @@ async function invoke( if (!handler) { throw new Error(`no handler for ${command}`) } + refuseConflictingSessionCallerFlags( + findCommandSpec(COMMAND_SPECS, ['orchestration', command]), + flags + ) await handler({ flags, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these handlers read only `call`; RuntimeClient is a class, so a structural double cannot satisfy it without the cast. @@ -488,23 +512,62 @@ describe('the orchestration envelope', () => { }) }) -describe('every orchestration verb, enumerated', () => { - /** Enough flags for any verb to get past its own validation to identity resolution. */ - const EVERY_REQUIRED_FLAG = { - objective: 'o', - id: 'id_1', - task: 'task_1', - spec: 's', - question: 'q', - resolution: 'r', - subject: 's', - body: 'b', - to: 'term_worker', - status: 'completed', - preamble: true, - request: 'req_1' - } as const +describe('which flag names the caller, declared on every spec', () => { + const ORCHESTRATION_SPECS = COMMAND_SPECS.filter((spec) => spec.path[0] === 'orchestration') + it('classifies every --from and --terminal an orchestration verb accepts', () => { + // A new verb cannot take either flag without saying whether it names the caller, so the entry + // check covers it by construction instead of each handler remembering to refuse. + const unclassified = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.allowedFlags.includes(flag) && !spec.identityFlagRoles?.[flag]) + .map((flag) => `${spec.path.join(' ')} --${flag}`) + ) + expect(unclassified).toEqual([]) + }) + + const callerFlagVerbs = ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'caller') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + + it('covers the verbs whose requests name a caller', () => { + expect(callerFlagVerbs.length).toBeGreaterThanOrEqual(CALLER_VERBS.length) + }) + + it.each(callerFlagVerbs)( + '$command refuses --$flag naming another actor, before any request', + async ({ command, flag }) => { + asSessionInTerminalView() + await expect( + invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + expect(callMock).not.toHaveBeenCalled() + expect(getTerminalHandleMock).not.toHaveBeenCalled() + } + ) + + it.each( + ORCHESTRATION_SPECS.flatMap((spec) => + (['from', 'terminal'] as const) + .filter((flag) => spec.identityFlagRoles?.[flag] === 'target') + .map((flag) => ({ command: spec.path[1] ?? '', flag })) + ) + )('$command passes a --$flag target through unfenced', async ({ command, flag }) => { + asSessionInTerminalView() + await invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })).catch( + (error: unknown) => { + expect(error).not.toMatchObject({ code: 'consumer_fenced' }) + } + ) + expect(callMock.mock.calls.flatMap(([, params]) => Object.values(params ?? {}))).toContain( + 'term_sibling' + ) + }) +}) + +describe('every orchestration verb, enumerated', () => { /** Runs every verb once; returns the ones that guessed an implicit terminal. */ async function verbsThatGuess(): Promise { const guessed: string[] = [] diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts new file mode 100644 index 00000000000..b136bccb4bc --- /dev/null +++ b/src/cli/session-caller-flags.ts @@ -0,0 +1,63 @@ +/** + * An agent session is its own orchestration caller. A flag that names the caller may restate that + * session, but one naming anyone else is refused before any handler runs — never dropped, never + * allowed to win. Against a host that predates session callers this is the only guard: such a host + * would honor the flag as the caller, which is how a chat consumed a sibling's mail (#21097). + * + * Whether `--from`/`--terminal` names the caller or a target is declared on the command's spec + * (`identityFlagRoles`) and checked once, here, at the CLI entry, so a handler cannot forget it. + */ + +import type { CommandSpec, IdentityFlag } from './command-spec' +import { RuntimeClientError } from './runtime/types' +import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' +import { normalizeOrchestrationActor } from '../shared/orchestration-actor' +import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' + +export function refuseConflictingSessionCallerFlags( + spec: CommandSpec | undefined, + flags: ReadonlyMap, + env: NodeJS.ProcessEnv = process.env +): void { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId || !spec?.identityFlagRoles) { + return + } + for (const flagName of IDENTITY_FLAGS) { + const declared = flags.get(flagName) + if ( + spec.identityFlagRoles[flagName] === 'caller' && + typeof declared === 'string' && + !namesInjectedSession(declared, sessionId, env) + ) { + throw new RuntimeClientError( + 'consumer_fenced', + `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + + `session:${sessionId}. No request was sent.` + ) + } + } +} + +const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] + +/** The session's own spellings, plus the handle a structured worker session was minted. */ +function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { + return ( + normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress(env) + ) +} + +/** + * The address the host gives this session: a structured worker keeps the handle it was minted, any + * other session is `session:`. Only for text that must match what the host writes. + */ +export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): string | undefined { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId) { + return undefined + } + const ownHandle = env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` +} diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 1448667e54a..04423ffc6fb 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -31,6 +31,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller', terminal: 'target' }, notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index 458afa2e525..8c4418f38ba 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -9,6 +9,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-create --objective [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ 'A Run is a namespace and home inbox. It never schedules or places workers.', '--retry-request is only for exact recovery after an unknown mutation result.' @@ -20,6 +21,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration run-use --id [--from ] [--takeover-legacy] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'takeover-legacy', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: [ '--takeover-legacy must run in the live coordinator agent terminal it binds; it preserves existing worker assignments.' ] @@ -28,7 +30,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'run-current'], summary: 'Show the Run bound to this coordinator terminal', usage: 'orca orchestration run-current [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'from'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'run-list'], @@ -67,6 +70,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'report-path', 'phase' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'Valid --type values: status, dispatch, worker_done, merge_ready, escalation, handoff, decision_gate, question, heartbeat.', 'To answer a worker question, use orchestration reply --id --body with the same Orca CLI executable.', @@ -109,6 +113,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'timeout-ms', 'retry-request' ], + identityFlagRoles: { terminal: 'caller' }, notes: [ 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".', '--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Without --wait it has no effect on consuming checks. Only --peek and --all filter their rows.', @@ -121,13 +126,15 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Reply to a message', usage: 'orca orchestration reply --id --body [--run ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'inbox'], summary: 'Show messages across (or for) recipients', usage: 'orca orchestration inbox [--limit ] [--terminal ] [--full] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'] + allowedFlags: [...GLOBAL_FLAGS, 'limit', 'terminal', 'full'], + identityFlagRoles: { terminal: 'target' } }, { path: ['orchestration', 'task-create'], @@ -144,7 +151,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'run', 'from', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'task-list'], @@ -152,6 +160,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-list [--status ] [--ready] [--brief] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--brief collapses whitespace and caps each spec at 160 characters.'] }, { @@ -160,6 +169,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' }, notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] }, ...ORCHESTRATION_WORKER_COMMAND_SPECS, @@ -178,7 +188,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'dry-run', 'return-preamble', 'retry-request' - ] + ], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'request-show'], @@ -196,7 +207,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Show dispatch context for a task', usage: 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'], + identityFlagRoles: { from: 'target' } }, { path: ['orchestration', 'ask'], @@ -215,6 +227,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'from', 'retry-request' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'From an active Dispatch, a new question defaults to its owning Run mailbox.', 'Timeout leaves the question pending; resume with the original message ID.' @@ -234,6 +247,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'max-concurrent', 'worktree' ], + identityFlagRoles: { from: 'caller' }, notes: [ 'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.', 'Use the lightweight Run, Task, and worker-start primitives described by the current skill.' @@ -254,14 +268,16 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ summary: 'Create a decision gate blocking a task', usage: 'orca orchestration gate-create --task --question [--options ] [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-resolve'], summary: 'Resolve a pending decision gate', usage: 'orca orchestration gate-resolve --id --resolution [--from ] [--retry-request ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'from', 'retry-request'], + identityFlagRoles: { from: 'caller' } }, { path: ['orchestration', 'gate-list'], @@ -269,6 +285,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca orchestration gate-list [--task ] [--status ] [--run ] [--from ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'task', 'status', 'run', 'from'], + identityFlagRoles: { from: 'caller' }, notes: ['--run inspects a named Run without binding; otherwise gates are scoped to the caller.'] }, { From 7c31cf26ac6f6346993d286a861cc4b7826819f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:24:16 -0700 Subject: [PATCH 15/49] perf(cli): keep the session caller check off the actor codec's module graph The check runs at the CLI entry for every command, and the actor codec pulls zod through the session record. Compare the session's own spellings as plain strings instead. --- src/cli/session-caller-flags.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index b136bccb4bc..1ef820d7fee 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -11,7 +11,6 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' -import { normalizeOrchestrationActor } from '../shared/orchestration-actor' import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' export function refuseConflictingSessionCallerFlags( @@ -42,10 +41,13 @@ export function refuseConflictingSessionCallerFlags( const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] -/** The session's own spellings, plus the handle a structured worker session was minted. */ +/** + * The session's own spellings, plus the handle a structured worker session was minted. Plain + * strings: this runs at the CLI entry for every command, before the actor codec's module graph. + */ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { return ( - normalizeOrchestrationActor(value)?.id === sessionId || value === injectedSessionAddress(env) + value === sessionId || value === `session:${sessionId}` || value === injectedSessionAddress(env) ) } From 905e2d848d355f92bac444dda9195a08ccc025d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:39:58 -0700 Subject: [PATCH 16/49] refactor(cli): spell a session's address from the one prefix constant, off the codec's module graph The Orca session address prefix moves to a leaf module with no imports, re-exported by the address codec, so the CLI entry check derives `session:` from that constant instead of re-typing it and still stays off the codec's zod graph. Prose and test names say caller or Orca session id, not actor. --- src/cli/index-orchestration.test.ts | 2 +- src/cli/orchestration-session-caller-cli.test.ts | 6 +++--- src/cli/session-caller-flags.ts | 13 +++++++++---- src/shared/agent-session-caller-env.ts | 2 +- src/shared/orca-session-address-prefix.ts | 2 ++ src/shared/orca-session-address.ts | 3 ++- 6 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 src/shared/orca-session-address-prefix.ts diff --git a/src/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index 52fc7222e46..bc7366f5b11 100644 --- a/src/cli/index-orchestration.test.ts +++ b/src/cli/index-orchestration.test.ts @@ -85,7 +85,7 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) - it("refuses an agent session's caller flag naming another actor before any request", async () => { + it("refuses an agent session's caller flag naming another caller before any request", async () => { // One chokepoint for every verb: the spec says which flag names the caller. process.env.ORCA_AGENT_SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 9ca398e35b2..f0588dc80d7 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -279,7 +279,7 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v }) it.runIf(verb.callerFlag !== undefined)( - 'refuses a caller flag naming another actor, before any request', + 'refuses a caller flag naming another caller, before any request', async () => { const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_sibling' }) @@ -320,7 +320,7 @@ describe.each([ ])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { beforeEach(asSessionInTerminalView) - it('needs no caller, but refuses a --from naming another actor, before any request', async () => { + it('needs no caller, but refuses a --from naming another caller, before any request', async () => { await invoke(command, flagMap({ run: 'run_1' })) expect(callsTo(method)[0]).toMatchObject({ run: 'run_1' }) expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() @@ -537,7 +537,7 @@ describe('which flag names the caller, declared on every spec', () => { }) it.each(callerFlagVerbs)( - '$command refuses --$flag naming another actor, before any request', + '$command refuses --$flag naming another caller, before any request', async ({ command, flag }) => { asSessionInTerminalView() await expect( diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index 1ef820d7fee..df3f893dd29 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -12,6 +12,7 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' export function refuseConflictingSessionCallerFlags( spec: CommandSpec | undefined, @@ -33,7 +34,7 @@ export function refuseConflictingSessionCallerFlags( 'consumer_fenced', `This command runs as agent session ${sessionId}, so --${flagName} ${declared} would act as a ` + `different caller. Drop --${flagName}: this session's orchestration commands already act as ` + - `session:${sessionId}. No request was sent.` + `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}. No request was sent.` ) } } @@ -43,11 +44,13 @@ const IDENTITY_FLAGS: readonly IdentityFlag[] = ['from', 'terminal'] /** * The session's own spellings, plus the handle a structured worker session was minted. Plain - * strings: this runs at the CLI entry for every command, before the actor codec's module graph. + * strings: this runs at the CLI entry for every command, before the address codec's module graph. */ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.ProcessEnv): boolean { return ( - value === sessionId || value === `session:${sessionId}` || value === injectedSessionAddress(env) + value === sessionId || + value === `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` || + value === injectedSessionAddress(env) ) } @@ -61,5 +64,7 @@ export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): st return undefined } const ownHandle = env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) ? ownHandle : `session:${sessionId}` + return isStructuredWorkerHandle(ownHandle) + ? ownHandle + : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` } diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index 7968dff7e13..e13da348b5a 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -1,7 +1,7 @@ /** * The Orca-minted agent session id, injected into a structured session's own child processes, in * native chat and in terminal view alike. When it is present it IS the orchestration caller: the - * CLI sends it in the orchestration envelope and the host resolves it to the session's actor, so no + * CLI sends it in the orchestration envelope and the host resolves the session it names, so no * terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, diff --git a/src/shared/orca-session-address-prefix.ts b/src/shared/orca-session-address-prefix.ts new file mode 100644 index 00000000000..984aec9d789 --- /dev/null +++ b/src/shared/orca-session-address-prefix.ts @@ -0,0 +1,2 @@ +// A leaf with no imports, so the CLI entry can spell a session address without the codec's zod graph. +export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index 9762b95d63c..d0098ea2ef9 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,5 +1,6 @@ import { isAgentSessionId } from './agent-session-record' import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' +import { ORCA_SESSION_ADDRESS_PREFIX } from './orca-session-address-prefix' /** * The Orca session id is the id Orca minted for a structured session (its session record id, the @@ -12,7 +13,7 @@ import { STRUCTURED_WORKER_HANDLE_PREFIX } from './structured-worker-handle' * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id * would be inherited by the pane's next occupant. */ -export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' +export { ORCA_SESSION_ADDRESS_PREFIX } declare const orcaSessionIdBrand: unique symbol declare const orcaSessionAddressBrand: unique symbol From 7a0b03752a8e5d72f670bbeb8949155c9de0745b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:53:19 -0700 Subject: [PATCH 17/49] refactor(orchestration): drop the session id's terminal-view spawn now that the handoff is gone The terminal handoff was removed, so no terminal is ever a structured session: - delete the terminal-view identity env and its WSL passthrough, and their tests; - strip the session caller keys from every terminal's env unconditionally; - the CLI's own-address spelling moves beside the injected id in src/shared, with a test pinning it to the address the host's party resolver gives that session. --- .../orchestration/dispatch-handlers.ts | 2 +- .../orchestration/message-check-handler.ts | 2 +- .../orchestration/terminal-identity.ts | 6 ++- .../orchestration-session-caller-cli.test.ts | 39 ++++++++------- src/cli/session-caller-flags.ts | 21 ++------ .../pty-daemon-spawn-agent-home-env.test.ts | 12 +---- .../ipc/pty-spawn-env-terminal-basics.test.ts | 7 --- src/main/ipc/pty/host-env/pi-agent.ts | 11 ++-- src/main/pty/wsl-orca-env.test.ts | 20 -------- src/main/pty/wsl-orca-env.ts | 4 -- ...ca-runtime-agent-session-operation.test.ts | 34 ------------- .../orchestration-party-cli-address.test.ts | 50 +++++++++++++++++++ ...uctured-session-child-identity-env.test.ts | 18 +------ .../structured-session-child-identity-env.ts | 18 +------ src/shared/agent-session-caller-env.ts | 28 +++++++++-- 15 files changed, 114 insertions(+), 158 deletions(-) create mode 100644 src/main/runtime/orchestration/orchestration-party-cli-address.test.ts diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index 5f28d4bbcd3..c8ade53e907 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -6,7 +6,7 @@ import { orchestrationMigrationData } from '../../../shared/orchestration-rpc-co import { callOrchestrationMutation } from './mutation-request' import { isDevCliInvocation } from './runtime-compatibility' import { resolveCoordinatorTerminalHandle } from './terminal-identity' -import { injectedSessionAddress } from '../../session-caller-flags' +import { injectedSessionAddress } from '../../../shared/agent-session-caller-env' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { diff --git a/src/cli/handlers/orchestration/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index 26cefcc06ea..6ace43eac31 100644 --- a/src/cli/handlers/orchestration/message-check-handler.ts +++ b/src/cli/handlers/orchestration/message-check-handler.ts @@ -41,7 +41,7 @@ export const ORCHESTRATION_CHECK_HANDLER: Record = { const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const explicitTerminal = getOptionalStringFlag(flags, 'terminal') const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal') - // Why: a session names itself by its id alone; a terminal view's pane is not its identity. + // Why: a session names itself by its id alone; a pane key it inherited is not its identity. const paneKey = explicitTerminal || terminal === undefined ? undefined : process.env.ORCA_PANE_KEY const callerLabel = orchestrationCallerLabel(terminal) diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 572b9096bce..b97500db89b 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -3,8 +3,10 @@ import { getOptionalStringFlag } from '../../flags' import { RuntimeClientError } from '../../runtime-client' import { getTerminalHandle } from '../../selectors' import { hasStructuredSessionMarker } from '../../../shared/structured-session-marker' -import { readInjectedAgentSessionId } from '../../../shared/agent-session-caller-env' -import { injectedSessionAddress } from '../../session-caller-flags' +import { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../../../shared/agent-session-caller-env' /** * The caller's terminal handle, or `undefined` when an injected agent session id names the caller: diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index f0588dc80d7..59bcae5b845 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -6,8 +6,9 @@ * naming anyone else is refused before any request — never silently dropped, never allowed to win. * The #21097 accident was a chat that named a sibling's terminal and consumed that sibling's mail. * - * The session env here is the hardest case, a chat in terminal view: it also carries its pane's - * `ORCA_TERMINAL_HANDLE` and `ORCA_PANE_KEY`, and the implicit-terminal guess has a sibling to find. + * The session env here is the hardest case, a chat that inherited a pane's `ORCA_TERMINAL_HANDLE` + * and `ORCA_PANE_KEY` (an Orca launched from an Orca terminal), and the implicit-terminal guess has + * a sibling to find. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -238,12 +239,12 @@ function setEnv(env: Partial>): vo } } -/** A chat in terminal view: its id, plus the pane identity that view inherits. */ -function asSessionInTerminalView(): void { +/** A chat with its id, plus a pane identity it inherited from the Orca that launched it. */ +function asSessionWithInheritedPane(): void { setEnv({ ORCA_AGENT_SESSION_ID: SESSION, - ORCA_TERMINAL_HANDLE: 'term_view_pane', - ORCA_PANE_KEY: 'tab_view:11111111-1111-4111-8111-111111111111' + ORCA_TERMINAL_HANDLE: 'term_inherited_pane', + ORCA_PANE_KEY: 'tab_inherited:11111111-1111-4111-8111-111111111111' }) } @@ -261,7 +262,7 @@ afterEach(() => { }) describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (verb) => { - beforeEach(asSessionInTerminalView) + beforeEach(asSessionWithInheritedPane) it('acts as the session: no terminal is resolved, guessed or sent', async () => { await invoke(verb.command, flagMap(verb.flags)) @@ -269,7 +270,7 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v const [params] = callsTo(verb.method) expect(params, 'the verb reached its method').toBeDefined() expect(params?.[verb.callerParam]).toBeUndefined() - // A terminal view's pane is not the session's identity. + // An inherited pane is not the session's identity. expect(params?.terminalPaneKey).toBeUndefined() expect(params?.senderPaneKey).toBeUndefined() expect(getTerminalHandleMock).not.toHaveBeenCalled() @@ -293,9 +294,9 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v ) it.runIf(verb.callerFlag !== undefined)( - "refuses its own terminal view's pane handle too: the session, not the pane, is the caller", + 'refuses an inherited pane handle too: the session, not the pane, is the caller', async () => { - const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_view_pane' }) + const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: 'term_inherited_pane' }) await expect(invoke(verb.command, flags)).rejects.toMatchObject({ code: 'consumer_fenced' }) expect(callMock).not.toHaveBeenCalled() @@ -318,7 +319,7 @@ describe.each([ { command: 'gate-list', method: 'gateList', callerParam: 'from' }, { command: 'task-list', method: 'taskList', callerParam: 'callerTerminalHandle' } ])('orchestration $command --run run as an agent session', ({ command, method, callerParam }) => { - beforeEach(asSessionInTerminalView) + beforeEach(asSessionWithInheritedPane) it('needs no caller, but refuses a --from naming another caller, before any request', async () => { await invoke(command, flagMap({ run: 'run_1' })) @@ -406,7 +407,7 @@ describe('the identity a session presents', () => { await invoke('dispatch-show', flagMap({ task: 'task_1', preamble: true, ...flags })) return callsTo('dispatchShow')[0]?.from } - asSessionInTerminalView() + asSessionWithInheritedPane() expect(await preview({})).toBe(`session:${SESSION}`) // Not a caller flag: it names the text to preview, so it is never fenced. expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') @@ -416,7 +417,7 @@ describe('the identity a session presents', () => { }) it('resumes a timed-out ask as the session, without naming a terminal', async () => { - asSessionInTerminalView() + asSessionWithInheritedPane() callMock.mockResolvedValue({ result: { ...RESULT.result, answer: null, timedOut: true } }) const errors = vi.mocked(console.error) @@ -443,7 +444,7 @@ describe('a host refusal of the session', () => { it.each(['check', 'run-current', 'worker-list'])( 'surfaces from %s verbatim, never widened, retried or turned into a terminal guess', async (command) => { - asSessionInTerminalView() + asSessionWithInheritedPane() callMock.mockRejectedValue(WORKER_GONE) await expect(invoke(command, flagMap({}))).rejects.toBe(WORKER_GONE) @@ -481,11 +482,11 @@ describe('the orchestration envelope', () => { it('carries the injected id beside whatever terminal evidence the process also has', () => { const envelope = createOrchestrationCompatibilityEnvelope({ ORCA_AGENT_SESSION_ID: ` ${SESSION} `, - ORCA_TERMINAL_HANDLE: 'term_view_pane' + ORCA_TERMINAL_HANDLE: 'term_inherited_pane' }) expect(envelope.orchestrationCompatibilityEvidence).toEqual({ - terminalHandle: 'term_view_pane', + terminalHandle: 'term_inherited_pane', agentSessionId: SESSION }) }) @@ -539,7 +540,7 @@ describe('which flag names the caller, declared on every spec', () => { it.each(callerFlagVerbs)( '$command refuses --$flag naming another caller, before any request', async ({ command, flag }) => { - asSessionInTerminalView() + asSessionWithInheritedPane() await expect( invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })) ).rejects.toMatchObject({ code: 'consumer_fenced' }) @@ -555,7 +556,7 @@ describe('which flag names the caller, declared on every spec', () => { .map((flag) => ({ command: spec.path[1] ?? '', flag })) ) )('$command passes a --$flag target through unfenced', async ({ command, flag }) => { - asSessionInTerminalView() + asSessionWithInheritedPane() await invoke(command, flagMap({ ...EVERY_REQUIRED_FLAG, [flag]: 'term_sibling' })).catch( (error: unknown) => { expect(error).not.toMatchObject({ code: 'consumer_fenced' }) @@ -608,7 +609,7 @@ describe('every orchestration verb, enumerated', () => { 'worker-start' ]) - asSessionInTerminalView() + asSessionWithInheritedPane() expect(await verbsThatGuess()).toEqual([]) }) }) diff --git a/src/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts index df3f893dd29..cbefa9ee2b8 100644 --- a/src/cli/session-caller-flags.ts +++ b/src/cli/session-caller-flags.ts @@ -10,8 +10,10 @@ import type { CommandSpec, IdentityFlag } from './command-spec' import { RuntimeClientError } from './runtime/types' -import { readInjectedAgentSessionId } from '../shared/agent-session-caller-env' -import { isStructuredWorkerHandle } from '../shared/structured-worker-handle' +import { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../shared/agent-session-caller-env' import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' export function refuseConflictingSessionCallerFlags( @@ -53,18 +55,3 @@ function namesInjectedSession(value: string, sessionId: string, env: NodeJS.Proc value === injectedSessionAddress(env) ) } - -/** - * The address the host gives this session: a structured worker keeps the handle it was minted, any - * other session is `session:`. Only for text that must match what the host writes. - */ -export function injectedSessionAddress(env: NodeJS.ProcessEnv = process.env): string | undefined { - const sessionId = readInjectedAgentSessionId(env) - if (!sessionId) { - return undefined - } - const ownHandle = env.ORCA_TERMINAL_HANDLE - return isStructuredWorkerHandle(ownHandle) - ? ownHandle - : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` -} diff --git a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts index 273f08580f3..f6ffae641d3 100644 --- a/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts +++ b/src/main/ipc/pty-daemon-spawn-agent-home-env.test.ts @@ -413,7 +413,7 @@ describe('registerPtyHandlers', () => { ]) ) }) - it("strips an inherited agent session id and keeps a terminal view's own", async () => { + it('strips an inherited agent session id', async () => { // Why: a daemon forked by an Orca launched inside a structured session inherits its id, // and every daemon pane would present that session as its orchestration caller. const inherited = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { @@ -423,14 +423,6 @@ describe('registerPtyHandlers', () => { expect(inherited.envToDelete).toEqual( expect.arrayContaining(['ORCA_AGENT_SESSION_ID', 'ORCA_STRUCTURED_SESSION']) ) - const own = await daemonSpawnAndGetOptions( - { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, - undefined, - undefined, - { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } - ) - expect(own.envToDelete ?? []).not.toContain('ORCA_AGENT_SESSION_ID') - expect(own.env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') }) it('preserves an explicitly requested Claude child-session stamp', async () => { // Why: only inherited values are poison; a caller deliberately spawning a @@ -462,7 +454,7 @@ describe('registerPtyHandlers', () => { // Why: bare `orca` must resolve to the Orca CLI before /usr/bin/orca (the GNOME screen reader) in Orca terminals (#7904). expect(entries.indexOf(shimDir)).toBeGreaterThanOrEqual(0) expect(entries.indexOf(shimDir)).toBeLessThan(entries.indexOf('/usr/bin')) - // The same absolute spelling a structured session gets, so a terminal view keeps it too. + // The same absolute spelling a structured session gets. expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) } finally { Object.defineProperty(process, 'platform', { diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index e269bc7f6df..bed6953e2b1 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -361,13 +361,6 @@ describe('registerPtyHandlers', () => { expect(env.ORCA_AGENT_SESSION_ID).toBeUndefined() expect(env.ORCA_STRUCTURED_SESSION).toBeUndefined() }) - it('keeps the session id a terminal view is spawned with', async () => { - const env = await spawnAndGetEnv( - { ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' }, - { ORCA_AGENT_SESSION_ID: 'a0b1c2d3-0000-4000-8000-00000000abcd' } - ) - expect(env.ORCA_AGENT_SESSION_ID).toBe('f7a1c0de-1111-4222-8333-444455556666') - }) it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { const env = await spawnAndGetEnv( { CLAUDE_CODE_CHILD_SESSION: '1' }, diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index aa8a7d2a21c..d2dd8803fa4 100644 --- a/src/main/ipc/pty/host-env/pi-agent.ts +++ b/src/main/ipc/pty/host-env/pi-agent.ts @@ -141,11 +141,12 @@ export function getInheritedAgentSessionStampEnvKeysToDelete( spawnEnv: Record | undefined ): string[] { const env = spawnEnv ?? {} - // Why: strip only values inherited from the pty host; a caller that explicitly - // provides a stamp (a nested Claude child, a structured session's terminal view) keeps it. - return [...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS, ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS].filter( - (key) => env[key] === undefined - ) + // Why: a caller that explicitly provides a Claude stamp (a nested Claude child) keeps it; no + // terminal is a structured session, so the session caller keys always go. + return [ + ...CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined), + ...ORCA_AGENT_SESSION_CALLER_ENV_KEYS + ] } // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. diff --git a/src/main/pty/wsl-orca-env.test.ts b/src/main/pty/wsl-orca-env.test.ts index 130b858f24c..e56146ea991 100644 --- a/src/main/pty/wsl-orca-env.test.ts +++ b/src/main/pty/wsl-orca-env.test.ts @@ -16,26 +16,6 @@ describe('addOrcaWslInteropEnv', () => { expect(env.WSLENV).toBe('ORCA_TERMINAL_HANDLE/u:ORCA_SHELL_READY_ROOT/p') }) - it("carries a terminal view's session id into the guest untranslated, beside the WSL stamp", () => { - // Crossing is what makes the claim refusable: without it the pane handle would silently become - // the caller inside WSL, and the host could not tell the session was asking at all. - const env: Record = { - ORCA_TERMINAL_HANDLE: 'term_wsl', - ORCA_AGENT_SESSION_ID: 'f7a1c0de-1111-4222-8333-444455556666' - } - stampWslOrchestrationCompatibilityHost(env, 'local', 'Ubuntu') - - addOrcaWslInteropEnv(env) - - expect(env.WSLENV?.split(':')).toEqual( - expect.arrayContaining([ - 'ORCA_AGENT_SESSION_ID/u', - 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_KIND/u', - 'ORCA_ORCHESTRATION_COMPATIBILITY_HOST_INCARNATION/u' - ]) - ) - }) - // Why this is published at all: the wrapper tree is content-addressed, so the // in-guest login script cannot rebuild its path from ORCA_USER_DATA_PATH -- it // cannot derive the hash segment. Without this the guest finds no wrapper and diff --git a/src/main/pty/wsl-orca-env.ts b/src/main/pty/wsl-orca-env.ts index 5e89e4eb350..04035a957ca 100644 --- a/src/main/pty/wsl-orca-env.ts +++ b/src/main/pty/wsl-orca-env.ts @@ -9,7 +9,6 @@ import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../shared/setup-agent-sequencing' import { getShellReadyWrapperRoot } from '../providers/local-pty-shell-ready-wrapper-root' -import { ORCA_AGENT_SESSION_ID_ENV } from '../../shared/agent-session-caller-env' import { ORCA_IMAGE_PROTOCOL_ENV } from '../../shared/terminal-image-protocol' const WSLENV_ENTRY_SEPARATOR = ':' @@ -75,9 +74,6 @@ export function addOrcaWslInteropEnv(env: Record): void { // Why: wsl.exe only imports selected Windows env vars, so WSL needs the wrapper root, pane identity, and hook/OMP coordinates at start. const passthroughEntries = [ 'ORCA_TERMINAL_HANDLE/u', - // Why: a structured session's terminal view in a WSL shell must still present its id, so the - // host refuses the cross-host claim instead of the pane handle silently becoming its caller. - `${ORCA_AGENT_SESSION_ID_ENV}/u`, 'ORCA_USER_DATA_PATH/p', // Why /p: the guest reads the content-addressed wrapper tree through /mnt/c, // and it cannot derive the hash segment from ORCA_USER_DATA_PATH alone. diff --git a/src/main/runtime/orca-runtime-agent-session-operation.test.ts b/src/main/runtime/orca-runtime-agent-session-operation.test.ts index e22d6de28e8..8d7d6fa6812 100644 --- a/src/main/runtime/orca-runtime-agent-session-operation.test.ts +++ b/src/main/runtime/orca-runtime-agent-session-operation.test.ts @@ -266,40 +266,6 @@ describe('agent-session create operation ledger', () => { ) }) - it("spawns a structured session's terminal view with its session id, never persisting it", async () => { - // Switching a chat to terminal view must not change who it is as an orchestration caller. The - // id rides the spawn env only: the launch config persists, and a relaunch from it would replay - // the id without the handoff that binds the terminal to the session. - const sessionId = 'f7a1c0de-1111-4222-8333-444455556666' - const runtime = createRuntime() - const createTerminal = vi.spyOn(runtime, 'createTerminal').mockResolvedValue(terminal()) - const resume = { - kind: 'explicit' as const, - worktree: 'id:worktree-1', - agent: 'claude' as const, - providerSession: { key: 'session_id' as const, id: 'provider-session-1' } - } - - await runtime.ensureAgentSession( - resume, - {}, - { - spawnToken: 'spawn-9', - providerRoot: '/accounts/claude', - sessionId - } - ) - await runtime.ensureAgentSession(resume) - - const [handoff, plain] = createTerminal.mock.calls.map(([, opts]) => opts) - expect(handoff).toMatchObject({ - structuredAgentSessionId: sessionId, - env: expect.objectContaining({ ORCA_AGENT_SESSION_ID: sessionId }) - }) - expect(handoff?.launchConfig?.agentEnv).not.toHaveProperty('ORCA_AGENT_SESSION_ID') - expect(plain?.env ?? {}).not.toHaveProperty('ORCA_AGENT_SESSION_ID') - }) - it('selects nested SSH legacy fallback before reading a Pi transcript path locally', async () => { const runtime = createRuntime() const internal = runtime as unknown as { diff --git a/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts new file mode 100644 index 00000000000..f6f1c1e2574 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-party-cli-address.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { injectedSessionAddress } from '../../../shared/agent-session-caller-env' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { resolveOrcaSessionParty } from './orchestration-party' + +// The CLI spells its own address without the host resolver (it runs before the codec's module +// graph), so it must land on the one mailbox address the resolver gives the same session. +const CHAT = testOrcaSessionId('f7a1c0de-1111-4222-8333-444455556666') +const WORKER = testOrcaSessionId('a0b1c2d3-0000-4000-8000-00000000abcd') + +afterEach(() => { + structuredWorkerIdentities.clear() +}) + +describe("the CLI's own address", () => { + it("is a chat's session address, even beside a pane handle it inherited", () => { + const hostAddress = resolveOrcaSessionParty(CHAT, null).address + expect(injectedSessionAddress({ ORCA_AGENT_SESSION_ID: CHAT })).toBe(hostAddress) + expect( + injectedSessionAddress({ + ORCA_AGENT_SESSION_ID: CHAT, + ORCA_TERMINAL_HANDLE: 'term_inherited' + }) + ).toBe(hostAddress) + }) + + it("is a structured worker's minted handle, as the host resolves it", () => { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: WORKER, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const hostAddress = resolveOrcaSessionParty(WORKER, null).address + expect(hostAddress).toBe(handle) + expect( + injectedSessionAddress({ ORCA_AGENT_SESSION_ID: WORKER, ORCA_TERMINAL_HANDLE: handle }) + ).toBe(hostAddress) + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts index 34f746979a4..1289e979ad6 100644 --- a/src/main/runtime/structured-session-child-identity-env.test.ts +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -5,10 +5,7 @@ import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-p const shim = vi.hoisted(() => ({ ensureLinuxTerminalOrcaCliShimDir: vi.fn() })) vi.mock('../cli/linux-terminal-orca-cli-shim', () => shim) -import { - structuredSessionChildIdentityEnv, - withStructuredSessionTerminalViewEnv -} from './structured-session-child-identity-env' +import { structuredSessionChildIdentityEnv } from './structured-session-child-identity-env' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -163,19 +160,6 @@ describe('structuredSessionChildIdentityEnv', () => { expect(console.warn).toHaveBeenCalledOnce() }) - it("gives the terminal view the same id, and leaves any other terminal's env as given", () => { - expect(withStructuredSessionTerminalViewEnv({ CLAUDE_CONFIG_DIR: '/c' }, SESSION_ID)).toEqual({ - CLAUDE_CONFIG_DIR: '/c', - ORCA_AGENT_SESSION_ID: SESSION_ID - }) - expect(withStructuredSessionTerminalViewEnv(undefined, SESSION_ID)).toEqual({ - ORCA_AGENT_SESSION_ID: SESSION_ID - }) - const plain = { CLAUDE_CONFIG_DIR: '/c' } - expect(withStructuredSessionTerminalViewEnv(plain, undefined)).toBe(plain) - expect(withStructuredSessionTerminalViewEnv(undefined, undefined)).toBeUndefined() - }) - it('never puts a pane key in the child environment', () => { // A pane key here flows into hook-emitted agent statuses and the attestation, agent-row and // mobile-projection pipelines, all of which assume it names a live PTY leaf. diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts index af17056b23d..62a901860cf 100644 --- a/src/main/runtime/structured-session-child-identity-env.ts +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -37,9 +37,7 @@ * `ORCA_STRUCTURED_SESSION` stays beside the id for a CLI that predates it — one reached through a * global install when a shell rc resets PATH — which would otherwise guess a sibling's terminal; * such a CLI refuses on the marker. A current CLI checks the id first, so the marker never makes a - * session with an id identity-less. The terminal view deliberately gets the id WITHOUT the marker: - * there an older CLI has the view's own pane handle and legitimately acts as that pane, and the - * marker would make it refuse its own pane. + * session with an id identity-less. * * The handle is read from the registry at spawn time, so an in-host recovery respawn re-bakes the * SAME handle rather than a stale or fresh one. @@ -66,20 +64,6 @@ export function structuredSessionChildIdentityEnv( return env } -/** - * The same session id for its terminal view, so switching views never changes who the session is. - * Same-host only, as above: the host refuses the claim from a terminal that runs in WSL or over SSH. - * A terminal that is not a session's view keeps its env exactly as given. No marker (see above), and - * no CLI command: the PTY lane names this app's launcher for every local terminal, and this env also - * crosses to SSH hosts, where a local path means nothing. - */ -export function withStructuredSessionTerminalViewEnv( - env: Record | undefined, - sessionId: string | undefined -): Record | undefined { - return sessionId ? { ...env, [ORCA_AGENT_SESSION_ID_ENV]: sessionId } : env -} - /** * A host with no app environment installed — a plain-Node fork, or a unit test — has no userData * root to resolve, and inventing one would write a shim into the wrong directory. diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index e13da348b5a..5f66128dc5f 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -1,13 +1,15 @@ /** - * The Orca-minted agent session id, injected into a structured session's own child processes, in - * native chat and in terminal view alike. When it is present it IS the orchestration caller: the - * CLI sends it in the orchestration envelope and the host resolves the session it names, so no - * terminal is resolved or guessed on its behalf. + * The Orca-minted agent session id, injected into a structured session's own child processes. When + * it is present it IS the orchestration caller: the CLI sends it in the orchestration envelope and + * the host resolves the session it names, so no terminal is resolved or guessed on its behalf. * * Identity by session id assumes one machine and one user. A host boundary (SSH, a paired peer, * WSL) re-opens that decision: the host refuses a claim that arrives across one, and the SSH * passthrough never carries the id. */ +import { ORCA_SESSION_ADDRESS_PREFIX } from './orca-session-address-prefix' +import { isStructuredWorkerHandle } from './structured-worker-handle' + export const ORCA_AGENT_SESSION_ID_ENV = 'ORCA_AGENT_SESSION_ID' export function readInjectedAgentSessionId( @@ -16,3 +18,21 @@ export function readInjectedAgentSessionId( const value = env[ORCA_AGENT_SESSION_ID_ENV]?.trim() return value ? value : undefined } + +/** + * The address the host gives this session (`mailboxAddressOf` on its resolved party): a structured + * worker keeps the handle it was minted, any other session is `session:`. Only for text that + * must match what the host writes; the CLI spells it without the host resolver. + */ +export function injectedSessionAddress( + env: Readonly> = process.env +): string | undefined { + const sessionId = readInjectedAgentSessionId(env) + if (!sessionId) { + return undefined + } + const ownHandle = env.ORCA_TERMINAL_HANDLE + return isStructuredWorkerHandle(ownHandle) + ? ownHandle + : `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` +} From c6c2b947bbf8ff93f1cdae90abc0aedf45b9a64e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:47:41 -0700 Subject: [PATCH 18/49] feat(orchestration): deliver worker results to a structured chat coordinator Resolve a Run's handle-less session coordinator and a session: mailbox to the live session, wake an evicted session for the delivery, redrive a session's own mail on its idle edge, route a terminal view's pointer through its PTY, and accept session: (or a bare Orca session id) as a recipient. --- ...rca-runtime-get-pty-record-for-pane-key.ts | 44 ++++++- .../runtime/orca-runtime-get-worktree-ps.ts | 4 + .../orca-runtime-stop-requested-pty-ids.ts | 7 +- .../orchestration/mailbox-delivery-target.ts | 9 +- .../runtime/orchestration/mailbox-owner.ts | 38 +++++- .../structured-mailbox-pointer-delivery.ts | 20 ++++ .../structured-mailbox-pointer-host.ts | 19 +++ .../structured-session-mail-address.ts | 108 +++++++++++++++++ .../structured-session-mail-target.ts | 75 ++++++++++++ src/main/runtime/rpc/errors.ts | 4 +- .../messaging/recipient-routing.ts | 51 +++++--- .../orchestration/messaging/send-methods.ts | 5 + .../messaging/session-recipient.ts | 110 ++++++++++++++++++ .../rpc/orchestration-session-caller.ts | 28 ++--- .../orchestration-session-recipient-codes.ts | 14 +++ 15 files changed, 491 insertions(+), 45 deletions(-) create mode 100644 src/main/runtime/orchestration/structured-session-mail-address.ts create mode 100644 src/main/runtime/orchestration/structured-session-mail-target.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts create mode 100644 src/shared/orchestration-session-recipient-codes.ts diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 56455551349..17b948770af 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -7,6 +7,14 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerIdentities } from './structured-worker-identity' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' +import { + handleLessCoordinatorSessionId, + readAgentSessionRecordStore, + structuredSessionMailTarget, + structuredSessionMailView, + structuredSessionOwnedMailboxes +} from './orchestration/structured-session-mail-target' +import { parseOrchestrationActor } from '../../shared/orchestration-actor' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -187,6 +195,31 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM this.orchestrationStructuredMailboxPointerDelivery.onJournalActivity(sessionId) } + /** + * A structured session went idle: retry what is parked on it, and re-derive the mailboxes it owns + * so mail it could not take earlier (evicted, closed, in its terminal view) is pointed again. + */ + deliverStructuredSessionMail(sessionId: string): void { + this.notifyStructuredSessionJournalActivity(sessionId) + const db = this._orchestrationDb + for (const mailbox of db ? structuredSessionOwnedMailboxes(sessionId, db) : []) { + this.deliverPendingMessagesForHandle(mailbox) + } + } + + /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ + getTerminalViewHandleForSession(sessionId: string): string | null { + if (structuredSessionMailView(sessionId, readAgentSessionRecordStore()) !== 'terminal-view') { + return null + } + for (const pty of this.ptysById.values()) { + if (pty.connected && agentSessionPtyWriteGate.boundSessionId(pty.ptyId) === sessionId) { + return pty.paneKey ? this.getTerminalHandleForPaneKey(pty.paneKey) : null + } + } + return null + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -205,6 +238,10 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } + const addressed = parseOrchestrationActor(mailboxHandle) + if (addressed) { + return structuredSessionMailTarget(addressed.id, readAgentSessionRecordStore()) + } if (!mailboxHandle.startsWith('dispatch:')) { return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } @@ -228,7 +265,12 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM protected resolveStructuredCoordinatorMailboxTarget( runId: string ): StructuredPointerTarget | null { - const coordinator = this._orchestrationDb?.getRun?.(runId)?.coordinator_handle + const run = this._orchestrationDb?.getRun?.(runId) + const sessionId = run ? handleLessCoordinatorSessionId(run) : null + if (sessionId) { + return structuredSessionMailTarget(sessionId, readAgentSessionRecordStore()) + } + const coordinator = run?.coordinator_handle if (!coordinator) { return null } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cf7e8e56db1..cd4d3f4ede0 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -165,6 +165,10 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. onSessionStatusChanged: (summary, options) => { + // The idle edge is where a structured session can take orchestration mail, whatever it is. + if (summary.status !== 'working' && summary.status !== 'attention') { + this.deliverStructuredSessionMail(summary.sessionId) + } void maybeAutoRenameWorkspaceOnFirstStructuredTurn( summary, options, diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4989245fdf6..46dad7b17b4 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -2,6 +2,7 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { isStructuredWorkerHandle } from './structured-worker-identity' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' @@ -183,7 +184,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId onRoutedMessageTypes: (mailboxHandle, types) => this.messageWaiters.notifyRouted(mailboxHandle, types), onForeignMailboxRouted: (mailboxHandle, messageType) => - this.notifyMessageArrived(mailboxHandle, messageType) + this.notifyMessageArrived(mailboxHandle, messageType), + getBoundSessionIdForPty: (ptyId) => agentSessionPtyWriteGate.boundSessionId(ptyId) }) protected readonly orchestrationMailboxDeliveryTarget = new OrchestrationMailboxDeliveryTarget({ @@ -193,7 +195,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId isStructuredWorkerHandle: (handle) => isStructuredWorkerHandle(handle), canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), - isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) + isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), + getTerminalViewHandleForSession: (sessionId) => this.getTerminalViewHandleForSession(sessionId) }) protected readonly orchestrationMailboxPointerDelivery = new OrchestrationMailboxPointerDelivery({ diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index 5d412bb12e8..eb2282def0b 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -1,5 +1,6 @@ import type { OrchestrationDb } from './db' import type { OrchestrationMailboxLeaf } from './mailbox-owner' +import { handleLessCoordinatorSessionId } from './structured-session-mail-target' type OrchestrationMailboxDeliveryTargetDependencies = { getDb: () => OrchestrationDb | null @@ -10,6 +11,8 @@ type OrchestrationMailboxDeliveryTargetDependencies = { canProbePtyLiveness: () => boolean controllerKnowsPtyIsLive: (ptyId: string) => boolean isLeafPtyProvenAbsent: (ptyId: string) => Promise + /** The terminal of a structured session's terminal view, while a TUI owns that session. */ + getTerminalViewHandleForSession?: (sessionId: string) => string | null } export class OrchestrationMailboxDeliveryTarget { @@ -31,8 +34,12 @@ export class OrchestrationMailboxDeliveryTarget { const remote = dispatchId && !dispatch ? db?.getRemoteDispatchAttachment?.(dispatchId) : undefined const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key + const run = runId ? db?.getRun(runId) : undefined + const coordinatorSessionId = run ? handleLessCoordinatorSessionId(run) : null const ownerHandle = runId - ? db?.getRun(runId)?.coordinator_handle + ? coordinatorSessionId + ? this.deps.getTerminalViewHandleForSession?.(coordinatorSessionId) + : run?.coordinator_handle : ((paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null) ?? dispatch?.assignee_handle ?? remote?.terminal_handle) diff --git a/src/main/runtime/orchestration/mailbox-owner.ts b/src/main/runtime/orchestration/mailbox-owner.ts index ae315511b15..217d858121e 100644 --- a/src/main/runtime/orchestration/mailbox-owner.ts +++ b/src/main/runtime/orchestration/mailbox-owner.ts @@ -1,5 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import type { OrchestrationDb } from './db' +import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' +import { sessionOrchestrationIdentity } from './structured-session-mail-address' export type OrchestrationMailboxLeaf = { tabId: string @@ -30,6 +32,8 @@ type OrchestrationMailboxOwnerDependencies = { getTerminalProcessIncarnation: (terminalHandle: string) => string | null onRoutedMessageTypes: (mailboxHandle: string, types: readonly string[]) => void onForeignMailboxRouted: (mailboxHandle: string, messageType: string) => void + /** The structured session a PTY is the terminal view of, if any. */ + getBoundSessionIdForPty?: (ptyId: string) => string | null } export class OrchestrationMailboxOwner { @@ -57,17 +61,23 @@ export class OrchestrationMailboxOwner { return null } const paneKey = `${leaf.tabId}:${leaf.leafId}` - const run = db.getCurrentRunForPane?.(paneKey) + const session = this.sessionMailOwner(db, leaf) + const sessionRun = session ? db.getCurrentRunForCoordinator?.(session) : null + const run = sessionRun ?? db.getCurrentRunForPane?.(paneKey) if (run) { - return this.resolveRunMailbox(db, leaf, terminalHandle, run.id, requestedMailbox, options) + const address = sessionRun && session ? session.address : terminalHandle + return this.resolveRunMailbox(db, leaf, address, run.id, requestedMailbox, options) } - const dispatch = db.getActiveDispatchForIdentity?.(terminalHandle, paneKey) + const sessionDispatch = session + ? db.getActiveDispatchForIdentity?.(session.address, session.paneKey ?? undefined) + : null + const dispatch = sessionDispatch ?? db.getActiveDispatchForIdentity?.(terminalHandle, paneKey) if (dispatch) { return this.resolveDispatchMailbox( db, leaf, - terminalHandle, + sessionDispatch && session ? session.address : terminalHandle, dispatch.id, dispatch.run_id, requestedMailbox, @@ -92,6 +102,19 @@ export class OrchestrationMailboxOwner { return !requestedMailbox || requestedMailbox === terminalHandle ? terminalHandle : null } + /** + * A terminal view speaks for its session (the CLI there acts as the session), so its Run and + * Dispatch are the session's. A PTY-born worker adopted into a session still owns its mail by + * terminal, which is why callers fall back to the pane when the session owns nothing. + */ + private sessionMailOwner( + db: OrchestrationDb, + leaf: OrchestrationMailboxLeaf + ): OrchestrationCallerIdentity | null { + const sessionId = leaf.ptyId ? this.deps.getBoundSessionIdForPty?.(leaf.ptyId) : null + return sessionId ? sessionOrchestrationIdentity(sessionId, db) : null + } + routeForeignDirectMessages(leaf: OrchestrationMailboxLeaf): RoutedOrchestrationMailbox[] { const db = this.deps.getDb() if (!db) { @@ -112,10 +135,13 @@ export class OrchestrationMailboxOwner { if (!ownerRunId) { return [] } + const session = this.sessionMailOwner(db, leaf) + const sessionOwnsRun = + session !== null && db.getCurrentRunForCoordinator?.(session)?.id === ownerRunId const routed = db.routeForeignDirectMessagesToOwnedMailboxes?.( - terminalHandle, + sessionOwnsRun ? session.address : terminalHandle, ownerRunId, - paneKey + sessionOwnsRun ? (session.paneKey ?? undefined) : paneKey ) if (routed?.hasMore) { this.scheduleDirectReconciliation(leaf) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 62a555d4119..5db68e53a02 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -61,6 +61,11 @@ export type StructuredMailboxPointerHost = { }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null + /** + * Holds the session for one attempt, resuming its provider child if the host evicted it; the + * returned release hands it back to the host's release clock. Null when it cannot be resumed. + */ + wake?: (sessionId: string) => Promise<(() => void) | null> } type StructuredPointerDeliveryDependencies = { @@ -182,6 +187,21 @@ export class OrchestrationStructuredMailboxPointerDelivery< target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], reservedTypes: ReadonlySet | undefined + ): Promise { + const release = await this.deps.host.wake?.(target.sessionId) + try { + await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes) + } finally { + release?.() + } + } + + private async attemptAwake( + db: OrchestrationDb, + mailboxHandle: string, + target: StructuredPointerTarget, + unread: readonly { id: string; type: string; sequence: number }[], + reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId const session = this.deps.host.readGateFacts(sessionId) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index b722952df92..14e3e185e08 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -56,8 +56,27 @@ export function readStructuredSessionGateFacts( } } +let wakeHolds = 0 + export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHost { return { + // Mail is what wakes a session nobody is looking at: the host evicts an unheld chat after its + // last turn, and without this its coordinator mail would wait forever for a re-attach. + async wake(sessionId) { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + const holderId = `orchestration:mail:${++wakeHolds}` + try { + await host.hold(sessionId, holderId) + } catch { + // A lease another owner holds, or a resume the provider refused; delivery retains. + return null + } + return () => host.release(sessionId, holderId) + }, + readGateFacts(sessionId) { return readStructuredSessionGateFacts(sessionId) }, diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts new file mode 100644 index 00000000000..beccaed7ff4 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -0,0 +1,108 @@ +/** + * A structured agent session as a mail address: `session:`, the Orca-minted id every agent is + * told is its public address. Recipient routing and pointer delivery both read these rules off the + * durable session record, so the two can never disagree about which sessions mail can reach. + * + * A released lease does not end a session. The host evicts a chat nobody is looking at 15s after + * its last turn and hands its lease back, and mail must wake it again (resume on demand). For mail, + * a session has ended only when its chat was closed or `/clear` replaced it with another session. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { formatOrchestrationActor } from '../../../shared/orchestration-actor' +import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { OrchestrationDb } from './db' +import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' + +export type AgentSessionRecordReader = { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] + /** Absent on a store that predates tab visibility; every session then counts as open. */ + getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } +} + +export type OrcaAgentSessionLookup = + | { kind: 'found'; record: AgentSessionRecord } + /** The id is a provider's own session id, which rotates on `/clear`; this names the Orca id. */ + | { kind: 'provider-id'; orcaSessionId: string } + | { kind: 'unknown' } + +export function lookupOrcaAgentSession( + store: AgentSessionRecordReader, + id: string +): OrcaAgentSessionLookup { + const record = store.getRecord(id) + if (record) { + return { kind: 'found', record } + } + const owner = store + .listRecords() + .find((candidate) => + candidate.providerHandleChain.some(({ handle }) => + handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id + ) + ) + return owner ? { kind: 'provider-id', orcaSessionId: owner.sessionId } : { kind: 'unknown' } +} + +export type StructuredSessionMailReach = + | { kind: 'reachable' } + | { kind: 'other-host' } + | { kind: 'ended'; reason: 'closed' } + | { kind: 'ended'; reason: 'replaced'; replacementSessionId: string } + +export function structuredSessionMailReach( + store: AgentSessionRecordReader, + record: AgentSessionRecord +): StructuredSessionMailReach { + if (!structuredWorkerHostScope(record.location)) { + return { kind: 'other-host' } + } + const command = record.conversationCommand + if ( + command?.command === 'clear' && + command.phase === 'committed' && + command.replacementSessionId + ) { + return { kind: 'ended', reason: 'replaced', replacementSessionId: command.replacementSessionId } + } + const visible = store.getVisibleSessionTabIndex?.() + if (visible?.present && !visible.sessionIds.includes(record.sessionId)) { + // Why: reviving a chat the user closed would run turns nobody can see; its mail waits instead. + return { kind: 'ended', reason: 'closed' } + } + return { kind: 'reachable' } +} + +/** + * Which view carries a pointer to the session right now. A terminal view owns the session while a + * TUI holds its lease, and its PTY takes the pointer; otherwise the host sends a session turn, + * resuming an evicted session for it. + */ +export function structuredSessionDeliveryView( + record: AgentSessionRecord +): 'session-turn' | 'terminal-view' { + return record.lease.runtimeKind === 'tui' && record.lease.claimStatus !== 'released' + ? 'terminal-view' + : 'session-turn' +} + +/** + * Who a session is to orchestration: its actor, plus the handle and pane a structured worker was + * minted. The caller resolver and mail delivery both take it from here, so a session is matched the + * same way whether it is sending, checking, or being delivered to. + */ +export function sessionOrchestrationIdentity( + sessionId: string, + db: OrchestrationDb | null | undefined +): OrchestrationCallerIdentity & { actor: string } { + const actor = formatOrchestrationActor({ kind: 'session', id: sessionId }) + const worker = resolveStructuredWorkerIdentityForSession(sessionId, db) + return { + actor, + address: worker?.handle ?? actor, + terminalHandle: worker?.handle ?? null, + paneKey: worker?.paneKey ?? null + } +} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts new file mode 100644 index 00000000000..49e2ee4985d --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -0,0 +1,75 @@ +/** + * Where a mailbox owned by a structured session is delivered, for sessions that are not structured + * workers: a chat that coordinates a Run (`run:` with no coordinator handle) and a session + * addressed directly at `session:`. + * + * The session is resolved here, never a pane: its native view takes the pointer as a session turn + * (the structured lane), its terminal view as bytes typed into the PTY that owns it (the PTY lane). + * Exactly one view answers for a session at a time, so the two lanes never both claim a mailbox. + */ + +import { parseOrchestrationActor } from '../../../shared/orchestration-actor' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrchestrationDb } from './db' +import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' +import { + sessionOrchestrationIdentity, + structuredSessionDeliveryView, + structuredSessionMailReach, + type AgentSessionRecordReader +} from './structured-session-mail-address' +import type { RunRow } from './types' + +export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +/** + * The session a Run's coordinator binding names when that binding has no handle. A structured + * worker coordinates by its own handle and resolves through it; an actor beside any handle is + * stale (see `runBoundToCoordinator`), so only a handle-less binding names a session here. + */ +export function handleLessCoordinatorSessionId( + run: Pick +): string | null { + if (run.coordinator_handle !== null) { + return null + } + return parseOrchestrationActor(run.coordinator_actor)?.id ?? null +} + +/** Which view of the session takes a pointer now, or null when mail cannot reach it here. */ +export function structuredSessionMailView( + sessionId: string, + store: AgentSessionRecordReader | null +): 'session-turn' | 'terminal-view' | null { + const record = store?.getRecord(sessionId) + if (!store || !record || structuredSessionMailReach(store, record).kind !== 'reachable') { + return null + } + return structuredSessionDeliveryView(record) +} + +/** The structured-lane target for a session whose native view takes the pointer. */ +export function structuredSessionMailTarget( + sessionId: string, + store: AgentSessionRecordReader | null +): StructuredPointerTarget | null { + return structuredSessionMailView(sessionId, store) === 'session-turn' + ? { sessionId, dispatchId: null } + : null +} + +/** + * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. + * Re-derived from the database on each idle edge rather than remembered, so mail that arrived + * while the session could not take it (closed, evicted, in the other view) is found again. + */ +export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { + const identity = sessionOrchestrationIdentity(sessionId, db) + const mailboxes = db.runsBoundToCoordinator(identity).map((run) => `run:${run.id}`) + if (db.getUnreadDirectMessageTypes(identity.address).length > 0) { + mailboxes.push(identity.address) + } + return mailboxes +} diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index ec7f7a0559d..e25a9328ecd 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -4,6 +4,7 @@ // auditable in one place instead of spread across per-method branches. import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' +import { ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES } from '../../../shared/orchestration-session-recipient-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' @@ -155,7 +156,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, // stop offering the action) — flattened to runtime_error it can only be guessed at. ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES), - ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES) + ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES), + ...Object.values(ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index 861cfadeac6..e4dca5c6a23 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -2,9 +2,13 @@ import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../../../orc import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' -import { readStructuredAgentSessionRecord } from '../../../../structured-worker-authority' -import { structuredWorkerHostScope } from '../../../../structured-worker-identity' import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' +import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-mail-target' +import { + readSessionRecipient, + refuseUndeliverableSessionRecipient, + type SessionRecipientRefusal +} from './session-recipient' const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatched'] @@ -45,7 +49,11 @@ export type BareRecipientResolution = } | { ok: false - code: 'terminal_not_found' | 'recipient_ambiguous' | 'recipient_run_mismatch' + code: + | 'terminal_not_found' + | 'recipient_ambiguous' + | 'recipient_run_mismatch' + | SessionRecipientRefusal['code'] message: string warning: SendRecipientWarning } @@ -59,7 +67,12 @@ export function resolveBareOrchestrationRecipient(params: { legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { const { runtime, db } = params - const party = resolveOrchestrationParty(params.handle, db) + const sessionStore = readAgentSessionRecordStore() + const session = readSessionRecipient(params.handle, sessionStore) + if (session && 'code' in session) { + return refused(params.handle, session) + } + const party = resolveOrchestrationParty(session?.address ?? params.handle, db) const handle = party.address const paneKey = party.terminalHandle === null @@ -103,6 +116,13 @@ export function resolveBareOrchestrationRecipient(params: { return mismatch ?? { ok: true, to: `run:${selectedRunId}`, runId: selectedRunId } } + if (session) { + const refusal = refuseUndeliverableSessionRecipient(session, sessionStore) + return refusal + ? refused(params.handle, refusal) + : { ok: true, to: handle, runId: params.senderRunId } + } + if (paneKey) { return { ok: true, @@ -116,19 +136,7 @@ export function resolveBareOrchestrationRecipient(params: { } } - const chatSessionId = party.terminalHandle === null ? party.orcaSessionId : null - if (chatSessionId !== null) { - const record = readStructuredAgentSessionRecord(chatSessionId) - // Unlike a terminal handle, a session address outlives its process, so its direct mail is durable. - if (record && structuredWorkerHostScope(record.location)) { - return { ok: true, to: handle, runId: params.senderRunId } - } - } - - const message = - chatSessionId !== null - ? `Agent session ${chatSessionId} does not run on this host and has no durable Run/Dispatch mailbox.` - : `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` + const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` return { ok: false, code: 'terminal_not_found', @@ -137,6 +145,15 @@ export function resolveBareOrchestrationRecipient(params: { } } +function refused(recipient: string, refusal: SessionRecipientRefusal): BareRecipientResolution { + return { + ok: false, + code: refusal.code, + message: refusal.message, + warning: { code: 'recipient_unreachable', recipient, message: refusal.message } + } +} + function selectDispatch( dispatches: DispatchContextRow[], explicitRunId: string | undefined diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 28f5a4d67d5..6e26bbe24d0 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -16,6 +16,7 @@ import { } from '../../../orchestration-mutation-executor' import { replayMutationNudge } from './mutation-replay-nudge' import { sendRemoteMessage } from './send-remote' +import { mayNameSession } from './session-recipient' import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' @@ -131,6 +132,10 @@ export const ORCHESTRATION_SEND_METHODS = [ const sendWarnings: SendRecipientWarning[] = [] let messageRunId = routing.run?.id if (!isGroupAddress(to) && !to.startsWith('run:') && !to.startsWith('dispatch:')) { + if (mayNameSession(to)) { + // Recipient routing reads the session record store, which the host opens lazily. + await runtime.ensureStructuredAgentSessionHost().catch(() => undefined) + } const recipient = resolveBareOrchestrationRecipient({ runtime, db, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts new file mode 100644 index 00000000000..3b7d8c2c114 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -0,0 +1,110 @@ +/** + * An agent session named as a recipient: `session:`, or a bare Orca session id. Any session on + * this host can be addressed, not only one that coordinates a Run: an agent's id is its public + * address, and a user telling one agent to message another's id is a supported workflow. + * + * Mail that no Run or Dispatch owns is stored at `session:` and pointed at the session as a + * turn. A released lease is not a refusal (delivery resumes an evicted chat); a closed or replaced + * chat, another host, and an unknown id are, before anything is stored. + */ + +import { + formatOrchestrationActor, + parseOrchestrationActor, + sessionOrchestrationActor +} from '../../../../../../shared/orchestration-actor' +import { ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-recipient-codes' +import { + lookupOrcaAgentSession, + structuredSessionMailReach, + type AgentSessionRecordReader +} from '../../../../orchestration/structured-session-mail-address' + +const SESSION_PREFIX = 'session:' + +export type SessionRecipient = { sessionId: string; address: string } + +export type SessionRecipientRefusal = { + code: (typeof CODES)[keyof typeof CODES] + message: string +} + +/** Whether a recipient may name a session, so the caller can install the session host first. */ +export function mayNameSession(recipient: string): boolean { + return recipient.startsWith(SESSION_PREFIX) || sessionOrchestrationActor(recipient) !== null +} + +/** + * The session a recipient names. A bare string names a session only when it is an Orca session id + * this host has a record for; anything else stays a terminal handle, exactly as before. + */ +export function readSessionRecipient( + recipient: string, + store: AgentSessionRecordReader | null +): SessionRecipient | SessionRecipientRefusal | null { + if (recipient.startsWith(SESSION_PREFIX)) { + const actor = parseOrchestrationActor(recipient) + return actor + ? { sessionId: actor.id, address: formatOrchestrationActor(actor) } + : { + code: CODES.unknown, + message: `${recipient} does not name an Orca agent session id. No message was sent.` + } + } + const actor = sessionOrchestrationActor(recipient) + const found = actor && store ? lookupOrcaAgentSession(store, actor.id) : null + if (found?.kind === 'provider-id') { + return providerIdRefusal(recipient, found.orcaSessionId) + } + return actor && found?.kind === 'found' + ? { sessionId: actor.id, address: formatOrchestrationActor(actor) } + : null +} + +/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +export function refuseUndeliverableSessionRecipient( + recipient: SessionRecipient, + store: AgentSessionRecordReader | null +): SessionRecipientRefusal | null { + const { sessionId } = recipient + if (!store) { + return { + code: CODES.unknown, + message: `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No message was sent.` + } + } + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'provider-id') { + return providerIdRefusal(sessionId, found.orcaSessionId) + } + if (found.kind === 'unknown') { + return { + code: CODES.unknown, + message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` + } + } + const reach = structuredSessionMailReach(store, found.record) + if (reach.kind === 'other-host') { + return { + code: CODES.hostBoundary, + message: `Agent session ${sessionId} runs on another host; mail reaches a session only on the host that runs it. Send from that host. No message was sent.` + } + } + if (reach.kind === 'ended') { + return { + code: CODES.ended, + message: + reach.reason === 'replaced' + ? `Agent session ${sessionId} was cleared and continues as session:${reach.replacementSessionId}. Send there instead. No message was sent.` + : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + } + } + return null +} + +function providerIdRefusal(id: string, orcaSessionId: string): SessionRecipientRefusal { + return { + code: CODES.providerId, + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is session:${orcaSessionId}; use that instead. No message was sent.` + } +} diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index e4bd09f2619..4329fe2bbc2 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -30,6 +30,10 @@ import { resolveOrcaSessionParty, resolveOrchestrationParty } from '../orchestration/orchestration-party' +import { + lookupOrcaAgentSession, + type AgentSessionRecordReader +} from '../orchestration/structured-session-mail-address' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' @@ -163,16 +167,15 @@ async function readSessionRecord( NO_EFFECTS ) } - const record = store.getRecord(sessionId) - if (record) { - return record + const found = lookupOrcaAgentSession(store, sessionId) + if (found.kind === 'found') { + return found.record } - const owner = store.listRecords().find((candidate) => namesProviderSession(candidate, sessionId)) - if (owner) { + if (found.kind === 'provider-id') { throw new OrchestrationError( CODES.providerId, - `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${owner.sessionId}; use that instead. No effects were applied.`, - { ...NO_EFFECTS, orcaSessionId: owner.sessionId } + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${found.orcaSessionId}; use that instead. No effects were applied.`, + { ...NO_EFFECTS, orcaSessionId: found.orcaSessionId } ) } throw new OrchestrationError( @@ -182,19 +185,10 @@ async function readSessionRecord( ) } -function sessionRecordStore(): { - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords: () => AgentSessionRecord[] -} | null { +function sessionRecordStore(): AgentSessionRecordReader | null { return getStructuredAgentSessionHost()?.deps.store ?? null } -function namesProviderSession(record: AgentSessionRecord, id: string): boolean { - return record.providerHandleChain.some(({ handle }) => - handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id - ) -} - function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { if (!structuredWorkerHostScope(record.location)) { throw hostBoundary( diff --git a/src/shared/orchestration-session-recipient-codes.ts b/src/shared/orchestration-session-recipient-codes.ts new file mode 100644 index 00000000000..b0ddb6003c8 --- /dev/null +++ b/src/shared/orchestration-session-recipient-codes.ts @@ -0,0 +1,14 @@ +/** + * Refusals for mail addressed to an agent session (`session:`, or a bare Orca session id). + * Each is issued at recipient routing, before any message is stored. + */ +export const ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES = { + /** The session runs on another host; mail reaches a session only on the host that runs it. */ + hostBoundary: 'session_recipient_host_boundary', + /** No Orca agent session with that id exists on this host, or it cannot be verified. */ + unknown: 'session_recipient_unknown', + /** The id is a provider's own session id, which rotates on `/clear`; the refusal names the Orca id. */ + providerId: 'session_recipient_provider_id', + /** The session's chat was closed, or `/clear` replaced it with another session. */ + ended: 'session_recipient_ended' +} as const From 02165cb1fd5a971946239ebbb73a488efd93725d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:08:16 -0700 Subject: [PATCH 19/49] test(orchestration): pin coordinator delivery through the real session host, wake, idempotence and session addresses --- ...rca-runtime-get-pty-record-for-pane-key.ts | 43 +- .../runtime/orca-runtime-get-worktree-ps.ts | 5 +- ...tructured-mailbox-pointer-delivery.test.ts | 53 +- .../structured-session-mail-target.test.ts | 216 +++++++++ .../structured-session-mail-target.ts | 14 + .../structured-chat-coordinator-mail.test.ts | 454 ++++++++++++++++++ 6 files changed, 762 insertions(+), 23 deletions(-) create mode 100644 src/main/runtime/orchestration/structured-session-mail-target.test.ts create mode 100644 src/main/runtime/structured-chat-coordinator-mail.test.ts diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 17b948770af..0ec619250d7 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -10,11 +10,11 @@ import type { StructuredPointerTarget } from './orchestration/structured-mailbox import { handleLessCoordinatorSessionId, readAgentSessionRecordStore, + structuredSessionAddressTarget, structuredSessionMailTarget, structuredSessionMailView, structuredSessionOwnedMailboxes } from './orchestration/structured-session-mail-target' -import { parseOrchestrationActor } from '../../shared/orchestration-actor' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -196,28 +196,35 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM } /** - * A structured session went idle: retry what is parked on it, and re-derive the mailboxes it owns - * so mail it could not take earlier (evicted, closed, in its terminal view) is pointed again. + * Every structured session's status change reaches here. At its idle edge, retry what is parked + * on it and re-derive the mailboxes it owns, so mail it could not take earlier (evicted, closed, + * in its terminal view) is pointed again. Workers and chats alike: this is not per-dispatch. */ - deliverStructuredSessionMail(sessionId: string): void { - this.notifyStructuredSessionJournalActivity(sessionId) + onStructuredSessionStatusForMail(summary: { + sessionId: string + status: 'working' | 'attention' | 'idle' | null + }): void { + if (summary.status === 'working' || summary.status === 'attention') { + return + } + this.notifyStructuredSessionJournalActivity(summary.sessionId) const db = this._orchestrationDb - for (const mailbox of db ? structuredSessionOwnedMailboxes(sessionId, db) : []) { + for (const mailbox of db ? structuredSessionOwnedMailboxes(summary.sessionId, db) : []) { this.deliverPendingMessagesForHandle(mailbox) } } /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ getTerminalViewHandleForSession(sessionId: string): string | null { - if (structuredSessionMailView(sessionId, readAgentSessionRecordStore()) !== 'terminal-view') { - return null - } - for (const pty of this.ptysById.values()) { - if (pty.connected && agentSessionPtyWriteGate.boundSessionId(pty.ptyId) === sessionId) { - return pty.paneKey ? this.getTerminalHandleForPaneKey(pty.paneKey) : null - } - } - return null + const view = structuredSessionMailView(sessionId, readAgentSessionRecordStore()) + const pty = [...this.ptysById.values()].find( + (candidate) => + candidate.connected && + agentSessionPtyWriteGate.boundSessionId(candidate.ptyId) === sessionId + ) + return view === 'terminal-view' && pty?.paneKey + ? this.getTerminalHandleForPaneKey(pty.paneKey) + : null } /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ @@ -238,9 +245,9 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } - const addressed = parseOrchestrationActor(mailboxHandle) - if (addressed) { - return structuredSessionMailTarget(addressed.id, readAgentSessionRecordStore()) + const addressed = structuredSessionAddressTarget(mailboxHandle) + if (addressed !== undefined) { + return addressed } if (!mailboxHandle.startsWith('dispatch:')) { return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cd4d3f4ede0..4f97af0de25 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -165,10 +165,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. onSessionStatusChanged: (summary, options) => { - // The idle edge is where a structured session can take orchestration mail, whatever it is. - if (summary.status !== 'working' && summary.status !== 'attention') { - this.deliverStructuredSessionMail(summary.sessionId) - } + this.onStructuredSessionStatusForMail(summary) void maybeAutoRenameWorkspaceOnFirstStructuredTurn( summary, options, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 048fc7732e4..527e31fa8ad 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -80,6 +80,8 @@ function harness(options: { /** The mailbox this worker owns; its own handle for direct peer mail outside a dispatch. */ mailbox?: string dispatchId?: string | null + /** Models the host resuming an evicted session: the journal it re-attaches, or null if refused. */ + wakeTo?: AgentJournalRenderItem[] | null }) { const mailbox = options.mailbox ?? 'dispatch:d1' const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId @@ -90,6 +92,14 @@ function harness(options: { state: options.dispatchState ?? ('accepted' as const) })) const sendMock = vi.mocked(send) + const released = vi.fn() + const wake = vi.fn(async () => { + if (!options.wakeTo) { + return null + } + journal = options.wakeTo + return released + }) const stored = new Map() const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), @@ -111,12 +121,15 @@ function harness(options: { host: { readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, - send + send, + ...('wakeTo' in options ? { wake } : {}) } }) return { delivery, markAsDelivered, + released, + wake, send: sendMock, stored, setJournal: (next: AgentJournalRenderItem[] | null) => { @@ -297,6 +310,44 @@ describe('structured mailbox pointer delivery', () => { }) }) +describe('a session the host evicted', () => { + it('is woken for the delivery, sent the pointer, and handed back to the release clock', async () => { + // The coordinator case: a chat nobody is looking at is evicted 15s after its last turn, so a + // worker's result usually arrives to a session with no journal attached and no provider child. + const { delivery, send, wake, released, markAsDelivered } = harness({ + journal: null, + mailbox: 'run:run_1', + dispatchId: null, + wakeTo: idleJournal() + }) + expect(delivery.deliverForHandle('run:run_1')).toBe(true) + await flush() + expect(wake).toHaveBeenCalledWith(IDENTITY.sessionId) + expect(send).toHaveBeenCalledTimes(1) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + expect(released).toHaveBeenCalledTimes(1) + expect(released.mock.invocationCallOrder[0]).toBeGreaterThan(send.mock.invocationCallOrder[0]!) + }) + + it('retains the mail when the session cannot be resumed', async () => { + const { delivery, send, markAsDelivered, setJournal } = harness({ + journal: null, + mailbox: 'run:run_1', + dispatchId: null, + wakeTo: null + }) + delivery.deliverForHandle('run:run_1') + await flush() + expect(send).not.toHaveBeenCalled() + expect(markAsDelivered).not.toHaveBeenCalled() + // Parked on the session, so its next re-attach retries. + setJournal(idleJournal()) + delivery.onJournalActivity(IDENTITY.sessionId) + await flush() + expect(send).toHaveBeenCalledTimes(1) + }) +}) + describe('forgetting one settled worker', () => { /** Two workers, each mid-turn and so each parked on its OWN session's journal edge. */ function twoWorkerHarness() { diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts new file mode 100644 index 00000000000..bfbc7a1c8f2 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { OrcaRuntimeWithGetPtyRecordForPaneKey } = + await import('../orca-runtime-get-pty-record-for-pane-key') +const { OrchestrationDb } = await import('./db') +const { agentSessionPtyWriteGate } = await import('../agent-session-pty-write-gate') + +const CHAT = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const CHAT_ACTOR = `session:${CHAT}` +const TERMINAL_VIEW_PANE = 'tab_view:77777777-7777-4777-8777-777777777777' + +/** The real methods through the real prototype chain; a re-declared copy would pin nothing. */ +class MailTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { + target(mailboxHandle: string): unknown { + return this.resolveStructuredMailboxTarget(mailboxHandle) + } +} + +type Store = { + records: Map + visible: { present: boolean; sessionIds: string[] } +} + +function chatRecord( + lease: Partial = {}, + extra: Partial = {} +): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: CHAT, runtimeKind: 'native', ...lease }) + ), + ...extra + } +} + +function installStore(record: AgentSessionRecord | null, visible = true): Store { + const store: Store = { + records: new Map(record ? [[record.sessionId, record]] : []), + visible: { present: true, sessionIds: visible && record ? [record.sessionId] : [] } + } + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => store.records.get(sessionId) ?? null, + listRecords: () => [...store.records.values()], + getVisibleSessionTabIndex: () => store.visible + } + } + } + return store +} + +let db: InstanceType + +function probe(extra: Record = {}): MailTargetProbe { + return Object.assign(Object.create(MailTargetProbe.prototype), { + _orchestrationDb: db, + ptysById: new Map(), + ...extra + }) as MailTargetProbe +} + +function chatCoordinatedRun(): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_ACTOR + }).id +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + hostRef.current = null +}) + +afterEach(() => { + agentSessionPtyWriteGate.detachRecordLookup() + db.close() +}) + +describe('a Run whose coordinator is a chat (a session actor, no handle)', () => { + it('delivers its mailbox to that session', () => { + // The defect this pins: the resolver read only `coordinator_handle`, which a chat never has, so + // neither lane claimed the Run mailbox and a worker's result never reached the chat. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('still delivers once the host has evicted the chat, so the delivery can wake it', () => { + installStore(chatRecord({ claimStatus: 'released', ownerProcess: null })) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('leaves the mailbox to the PTY lane while the chat is in its terminal view', () => { + installStore(chatRecord({ runtimeKind: 'tui' })) + const runId = chatCoordinatedRun() + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('does not deliver to a chat that was closed, replaced by /clear, or runs on another host', () => { + const runId = chatCoordinatedRun() + installStore(chatRecord(), false) + expect(probe().target(`run:${runId}`)).toBeNull() + + installStore( + chatRecord( + {}, + { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64', + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + ) + ) + expect(probe().target(`run:${runId}`)).toBeNull() + + const remote = chatRecord() + installStore({ ...remote, location: { ...remote.location, executionHostId: 'ssh:box' } }) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + it('ignores an actor left beside a PTY handle; the handle owns the Run', () => { + installStore(chatRecord()) + const runId = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_c:11111111-1111-4111-8111-111111111111', + coordinatorActor: CHAT_ACTOR + }).id + expect(probe().target(`run:${runId}`)).toBeNull() + }) +}) + +describe('a session addressed directly', () => { + it('owns its `session:` mailbox', () => { + installStore(chatRecord()) + expect(probe().target(CHAT_ACTOR)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('claims nothing for a malformed session address', () => { + installStore(chatRecord()) + expect(probe().target('session:term_abc')).toBeNull() + }) +}) + +describe('a chat in its terminal view', () => { + it('is reached through the PTY bound to it', () => { + installStore(chatRecord({ runtimeKind: 'tui' })) + agentSessionPtyWriteGate.attachRecordLookup(() => null) + agentSessionPtyWriteGate.bindPty('pty-view', CHAT) + const runtime = probe({ + ptysById: new Map([ + ['pty-view', { ptyId: 'pty-view', connected: true, paneKey: TERMINAL_VIEW_PANE }] + ]), + getTerminalHandleForPaneKey: (paneKey: string) => + paneKey === TERMINAL_VIEW_PANE ? 'term_view' : null + }) + expect(runtime.getTerminalViewHandleForSession(CHAT)).toBe('term_view') + // Its native view does not answer at the same time. + installStore(chatRecord()) + expect(runtime.getTerminalViewHandleForSession(CHAT)).toBeNull() + }) +}) + +describe('the idle edge of a structured session', () => { + it('re-derives and delivers the mailboxes the session owns, and nothing while it works', () => { + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.insertMessage({ + from: 'term_worker', + to: `run:${runId}`, + subject: 'done', + runId, + type: 'status' + }) + const delivered: string[] = [] + const runtime = probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'working' }) + expect(delivered).toEqual([]) + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([`run:${runId}`]) + }) + + it('points direct mail at a session that coordinates nothing', () => { + installStore(chatRecord()) + db.insertMessage({ from: 'term_peer', to: CHAT_ACTOR, subject: 'hi', type: 'status' }) + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([CHAT_ACTOR]) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 49e2ee4985d..94d84646254 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -60,6 +60,20 @@ export function structuredSessionMailTarget( : null } +/** + * The target of a `session:` mailbox; `undefined` when the handle is not a session address at + * all, so other address forms keep their own resolution. + */ +export function structuredSessionAddressTarget( + mailboxHandle: string +): StructuredPointerTarget | null | undefined { + if (!mailboxHandle.startsWith('session:')) { + return undefined + } + const actor = parseOrchestrationActor(mailboxHandle) + return actor ? structuredSessionMailTarget(actor.id, readAgentSessionRecordStore()) : null +} + /** * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. * Re-derived from the database on each idle edge rather than remembered, so mail that arrived diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts new file mode 100644 index 00000000000..dd6e4e6b60a --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -0,0 +1,454 @@ +// A worker's result reaching the structured chat that coordinates it, end to end in one process. +// +// Real: the structured agent-session host, its record store, journal, lease and Codex adapter; the +// orchestration database, RPC dispatcher and methods; the runtime's pointer lanes. Fake: only the +// Codex app-server child, which answers the JSON-RPC calls the real one does. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + CodexAppServerConnection, + CodexAppServerConnectionHandlers, + openCodexAppServerConnection +} from '../codex/codex-app-server-connection' +import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { OrcaRuntimeService } from './orca-runtime' +import { OrchestrationDb } from './orchestration/db' +import type { RpcRequest } from './rpc/core' +import { RpcDispatcher } from './rpc/dispatcher' +import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +const WORKSPACE = 'workspace-1' +const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + +type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + threadId: string | null + turns: { clientUserMessageId: string; text: string }[] +} + +function fakeCodex() { + const connections: FakeConnection[] = [] + let turnCounter = 0 + const openConnection = (async (_launch, handlers = {}) => { + const connection: FakeConnection = { + handlers, + threadId: null, + turns: [], + pid: 4321, + closed: false, + request: async (method, params) => { + const input = (params ?? {}) as Record + if (method === 'thread/start') { + connection.threadId = `thread-${connections.length}` + return { thread: { id: connection.threadId } } + } + if (method === 'thread/resume') { + connection.threadId = String(input.threadId) + return { thread: { id: connection.threadId } } + } + if (method === 'turn/start') { + turnCounter += 1 + connection.turns.push({ + clientUserMessageId: String(input.clientUserMessageId), + text: JSON.stringify(input.input) + }) + return { turn: { id: `turn-${turnCounter}` } } + } + if (method === 'model/list') { + return { + data: [ + { + model: 'gpt-live', + displayName: 'GPT Live', + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: 'medium', description: 'Balanced' }], + defaultReasoningEffort: 'medium', + isDefault: true + } + ], + nextCursor: null + } + } + return {} + }, + notify: () => {}, + respond: () => {}, + respondWithError: () => {}, + close: async () => { + connection.closed = true + return true + } + } + connections.push(connection) + return connection + }) as typeof openCodexAppServerConnection + return { connections, openConnection } +} + +let operations = 0 +function operationId(): string { + operations += 1 + return `${Date.now()}-${operations.toString(16).padStart(32, '0')}` +} + +function attachParams(sessionId: string) { + const params = { + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex', + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, + runtimeKind: 'native' as const + } + const envelope = { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: null, + payloadFingerprint: '' + } + return { + ...params, + envelope: { + ...envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId, + fields: attachFingerprintFields({ ...params, envelope } as never) + }) + } + } +} + +let codex: ReturnType +let root: string +let runtime: OrcaRuntimeService +let db: OrchestrationDb +let host: StructuredAgentSessionHost +let dispatcher: RpcDispatcher +let requests = 0 + +function request( + method: string, + params: Record, + options: { sessionId?: string; capability?: string } = {} +): RpcRequest { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}), + ...(options.capability ? { orchestrationCapability: options.capability } : {}) + } +} + +async function call( + method: string, + params: Record, + options?: { sessionId?: string; capability?: string } +): Promise> { + const response = await dispatcher.dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return response.result as Record +} + +async function openChat(sessionId: string): Promise { + const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) + expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) + await host.setSessionTabVisibility(sessionId, true) + threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) + return connectionFor(sessionId) +} + +const threadBySession = new Map() + +function connectionFor(sessionId: string): FakeConnection { + const connection = codex.connections.findLast( + (candidate) => candidate.threadId === threadBySession.get(sessionId) + ) + if (!connection) { + throw new Error(`no app-server for ${sessionId}`) + } + return connection +} + +/** Codex's own sequence for a turn: it starts, echoes the user message, and completes. */ +async function settleTurn(sessionId: string, turnIndex: number): Promise { + const connection = connectionFor(sessionId) + const turn = connection.turns[turnIndex]! + const turnId = `turn-${turnIndex + 1}` + const notify = (method: string, params: unknown) => + connection.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: turnId } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: `echo-${turn.clientUserMessageId}`, + clientId: turn.clientUserMessageId, + content: [{ type: 'text', text: 'pointer' }] + } + }) + notify('turn/completed', { turn: { id: turnId } }) + await host.flushStreamedEvents(sessionId) +} + +function userTexts(sessionId: string): string[] { + return host + .journalSnapshot(sessionId) + .items.flatMap((item: AgentJournalRenderItem) => + item.body?.kind === 'message' && item.body.role === 'user' + ? item.body.blocks.map((block) => (block.type === 'text' ? block.text : '')) + : [] + ) +} + +/** A capability-backed terminal worker under the coordinator's Run, and its worker_done. */ +async function finishWorker(taskId: string): Promise { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: WORKER_PANE, + processIncarnation: 'runtime_test:term_worker:1', + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + await call( + 'orchestration.send', + { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) + }, + { capability } + ) +} + +async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string }> { + const created = await call( + 'orchestration.runCreate', + { objective: 'ship' }, + { + sessionId: COORDINATOR + } + ) + const runId = (created.run as { id: string }).id + const task = await call( + 'orchestration.taskCreate', + { spec: 'build it' }, + { + sessionId: COORDINATOR + } + ) + return { runId, taskId: (task.task as { id: string }).id } +} + +beforeEach(async () => { + operations = 0 + root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) + codex = fakeCodex() + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? WORKER_PANE : null + ) + host = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async (workspaceId) => `/repos/${workspaceId}`, + resolveCodexCommand: () => '/usr/local/bin/codex', + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + resolveEnvironment: async () => ({ PATH: '/usr/bin' }), + openCodexConnection: codex.openConnection, + readProcessStartTime: async () => 1_700_000_000_000, + releaseGraceMs: 60_000, + // The same call the runtime's own host install makes on every status change. + onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) + }) + dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime() + db.close() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +const POINTER = /You have 1 orchestration message\. Run `orca orchestration check --run run_/ + +describe('a worker result reaches the structured chat that coordinates it', () => { + it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + + await finishWorker(taskId) + + // No user action: the result itself sends the chat a turn through the host's send. + await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + expect(chat.turns[0]!.text).toMatch(POINTER) + expect(chat.turns[0]!.text).toContain(runId) + await settleTurn(COORDINATOR, 0) + expect(userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + + const checked = await call('orchestration.check', {}, { sessionId: COORDINATOR }) + expect(checked).toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done', from_handle: 'term_worker' }] + }) + }) + + it('does not send a second pointer when the delivery is retried', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + + // A pending send is not an acknowledgement, so the mail is retained and retried on every edge + // until the host confirms it: before the echo, and again at the turn's idle edge. + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await settleTurn(COORDINATOR, 0) + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await vi.waitFor(() => + expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]) + ) + expect(chat.turns).toHaveLength(1) + expect(userTexts(COORDINATOR)).toHaveLength(1) + }) + + it('wakes a coordinator the host evicted, and delivers once it is back', async () => { + await openChat(COORDINATOR) + const { taskId } = await coordinatorRunAndTask() + // What the release clock does to a chat nobody is looking at: child stopped, lease released. + await host.close(COORDINATOR) + expect(host.hasSession(COORDINATOR)).toBe(false) + const before = codex.connections.length + + await finishWorker(taskId) + + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1)) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1)) + expect(revived.turns[0]!.text).toMatch(POINTER) + await settleTurn(COORDINATOR, 0) + expect(userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) + }) + + it('points mail at the idle edge when it arrived mid-turn', async () => { + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const first = await host.send( + { callerKey: 'test-surface' }, + { + envelope: { + sessionId: COORDINATOR, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(COORDINATOR)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: COORDINATOR, + fields: { + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + }) + }, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'go' }] } + } + ) + expect(first).toMatchObject({ ok: true }) + const notify = (method: string, params: unknown) => + chat.handlers.onNotification?.(method, params) + notify('turn/started', { turn: { id: 'turn-1' } }) + notify('item/completed', { + item: { + type: 'userMessage', + id: 'echo-go', + clientId: chat.turns[0]!.clientUserMessageId, + content: [{ type: 'text', text: 'go' }] + } + }) + await host.flushStreamedEvents(COORDINATOR) + + await finishWorker(taskId) + await new Promise((resolve) => setTimeout(resolve, 20)) + // The coordinator is mid-turn, so nothing is folded into that turn. + expect(chat.turns).toHaveLength(1) + + notify('turn/completed', { turn: { id: 'turn-1' } }) + await host.flushStreamedEvents(COORDINATOR) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2)) + expect(chat.turns[1]!.text).toMatch(POINTER) + expect(chat.turns[1]!.text).toContain(runId) + }) +}) + +describe('any live session is addressable by its id', () => { + it('lands mail sent to `session:` as a turn in that chat, which a flagless check reads', async () => { + const peer = await openChat(PEER_CHAT) + + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + + await vi.waitFor(() => expect(peer.turns).toHaveLength(1)) + // Direct mail is not in a Run, so the pointer names no `--run`. + expect(peer.turns[0]!.text).toContain('Run `orca orchestration check`.') + await settleTurn(PEER_CHAT, 0) + const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) + expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) + }) + + it('refuses mail to a chat that was closed, before storing it', async () => { + await openChat(PEER_CHAT) + await host.setSessionTabVisibility(PEER_CHAT, false) + const response = await dispatcher.dispatch( + request('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'ping' + }) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'session_recipient_ended' } }) + expect(db.getInbox(100)).toEqual([]) + }) +}) From 194554544326c515df77a8cd0ddf0fd360ffa753 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:09:45 -0700 Subject: [PATCH 20/49] test(orchestration): type the coordinator mail fixture's attach params --- src/main/runtime/structured-chat-coordinator-mail.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index dd6e4e6b60a..de0f7413339 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -108,13 +108,13 @@ function operationId(): string { function attachParams(sessionId: string) { const params = { location: { - executionHostId: 'local', + executionHostId: 'local' as const, wslDistro: null, workspaceId: WORKSPACE, workspaceKind: 'git-worktree' as const }, provider: 'codex' as const, - agent: 'codex', + agent: 'codex' as const, accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' }, runtimeKind: 'native' as const } @@ -297,7 +297,6 @@ beforeEach(async () => { resolveEnvironment: async () => ({ PATH: '/usr/bin' }), openCodexConnection: codex.openConnection, readProcessStartTime: async () => 1_700_000_000_000, - releaseGraceMs: 60_000, // The same call the runtime's own host install makes on every status change. onSessionStatusChanged: (summary) => runtime.onStructuredSessionStatusForMail(summary) }) From 26943093938753cc7d73ff0090e03f7ae3daaa2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:05:47 -0700 Subject: [PATCH 21/49] fix(orchestration): refuse session recipients with the caller codes, and treat a worker without its identity as undeliverable --- ...rca-runtime-get-pty-record-for-pane-key.ts | 14 +- .../structured-session-mail-address.ts | 15 +- .../structured-session-mail-target.ts | 28 +++- src/main/runtime/rpc/errors.ts | 4 +- .../messaging/recipient-routing.ts | 2 +- .../messaging/session-recipient.ts | 15 +- .../orchestration-session-recipient.test.ts | 146 ++++++++++-------- .../structured-chat-coordinator-mail.test.ts | 2 +- .../orchestration-session-recipient-codes.ts | 14 -- 9 files changed, 133 insertions(+), 107 deletions(-) delete mode 100644 src/shared/orchestration-session-recipient-codes.ts diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 0ec619250d7..39244cf472c 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -9,7 +9,7 @@ import { structuredWorkerIdentities } from './structured-worker-identity' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' import { handleLessCoordinatorSessionId, - readAgentSessionRecordStore, + findConnectedPtyBoundToSession, structuredSessionAddressTarget, structuredSessionMailTarget, structuredSessionMailView, @@ -216,12 +216,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ getTerminalViewHandleForSession(sessionId: string): string | null { - const view = structuredSessionMailView(sessionId, readAgentSessionRecordStore()) - const pty = [...this.ptysById.values()].find( - (candidate) => - candidate.connected && - agentSessionPtyWriteGate.boundSessionId(candidate.ptyId) === sessionId - ) + const view = structuredSessionMailView(sessionId, this._orchestrationDb) + const pty = findConnectedPtyBoundToSession(this.ptysById.values(), sessionId) return view === 'terminal-view' && pty?.paneKey ? this.getTerminalHandleForPaneKey(pty.paneKey) : null @@ -245,7 +241,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } - const addressed = structuredSessionAddressTarget(mailboxHandle) + const addressed = structuredSessionAddressTarget(mailboxHandle, this._orchestrationDb) if (addressed !== undefined) { return addressed } @@ -275,7 +271,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM const run = this._orchestrationDb?.getRun?.(runId) const sessionId = run ? handleLessCoordinatorSessionId(run) : null if (sessionId) { - return structuredSessionMailTarget(sessionId, readAgentSessionRecordStore()) + return structuredSessionMailTarget(sessionId, this._orchestrationDb) } const coordinator = run?.coordinator_handle if (!coordinator) { diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index beccaed7ff4..1807d8e174d 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -13,6 +13,7 @@ import { formatOrchestrationActor } from '../../../shared/orchestration-actor' import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { OrchestrationDb } from './db' +import { isRecordedStructuredWorkerActor } from './db/schema/structured-worker-actor-backfill' import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' export type AgentSessionRecordReader = { @@ -49,16 +50,26 @@ export function lookupOrcaAgentSession( export type StructuredSessionMailReach = | { kind: 'reachable' } | { kind: 'other-host' } - | { kind: 'ended'; reason: 'closed' } + | { kind: 'ended'; reason: 'closed' | 'worker-identity-lost' } | { kind: 'ended'; reason: 'replaced'; replacementSessionId: string } export function structuredSessionMailReach( store: AgentSessionRecordReader, - record: AgentSessionRecord + record: AgentSessionRecord, + db: OrchestrationDb | null | undefined ): StructuredSessionMailReach { if (!structuredWorkerHostScope(record.location)) { return { kind: 'other-host' } } + const actor = formatOrchestrationActor({ kind: 'session', id: record.sessionId }) + if ( + db && + !resolveStructuredWorkerIdentityForSession(record.sessionId, db) && + isRecordedStructuredWorkerActor(db.db, actor) + ) { + // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. + return { kind: 'ended', reason: 'worker-identity-lost' } + } const command = record.conversationCommand if ( command?.command === 'clear' && diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 94d84646254..7a7b9011366 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -10,6 +10,7 @@ import { parseOrchestrationActor } from '../../../shared/orchestration-actor' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' import { @@ -20,6 +21,19 @@ import { } from './structured-session-mail-address' import type { RunRow } from './types' +/** The live PTY the write gate binds to a session: its terminal view, when a TUI owns it. */ +export function findConnectedPtyBoundToSession( + ptys: Iterable, + sessionId: string +): T | undefined { + for (const pty of ptys) { + if (pty.connected && agentSessionPtyWriteGate.boundSessionId(pty.ptyId) === sessionId) { + return pty + } + } + return undefined +} + export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { return getStructuredAgentSessionHost()?.deps.store ?? null } @@ -41,10 +55,11 @@ export function handleLessCoordinatorSessionId( /** Which view of the session takes a pointer now, or null when mail cannot reach it here. */ export function structuredSessionMailView( sessionId: string, - store: AgentSessionRecordReader | null + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() ): 'session-turn' | 'terminal-view' | null { const record = store?.getRecord(sessionId) - if (!store || !record || structuredSessionMailReach(store, record).kind !== 'reachable') { + if (!store || !record || structuredSessionMailReach(store, record, db).kind !== 'reachable') { return null } return structuredSessionDeliveryView(record) @@ -53,9 +68,9 @@ export function structuredSessionMailView( /** The structured-lane target for a session whose native view takes the pointer. */ export function structuredSessionMailTarget( sessionId: string, - store: AgentSessionRecordReader | null + db: OrchestrationDb | null | undefined ): StructuredPointerTarget | null { - return structuredSessionMailView(sessionId, store) === 'session-turn' + return structuredSessionMailView(sessionId, db) === 'session-turn' ? { sessionId, dispatchId: null } : null } @@ -65,13 +80,14 @@ export function structuredSessionMailTarget( * all, so other address forms keep their own resolution. */ export function structuredSessionAddressTarget( - mailboxHandle: string + mailboxHandle: string, + db: OrchestrationDb | null | undefined ): StructuredPointerTarget | null | undefined { if (!mailboxHandle.startsWith('session:')) { return undefined } const actor = parseOrchestrationActor(mailboxHandle) - return actor ? structuredSessionMailTarget(actor.id, readAgentSessionRecordStore()) : null + return actor ? structuredSessionMailTarget(actor.id, db) : null } /** diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index e25a9328ecd..ec7f7a0559d 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -4,7 +4,6 @@ // auditable in one place instead of spread across per-method branches. import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' -import { ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES } from '../../../shared/orchestration-session-recipient-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' @@ -156,8 +155,7 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, // stop offering the action) — flattened to runtime_error it can only be guessed at. ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES), - ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES), - ...Object.values(ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES) + ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index e4dca5c6a23..eba0f7451c5 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -117,7 +117,7 @@ export function resolveBareOrchestrationRecipient(params: { } if (session) { - const refusal = refuseUndeliverableSessionRecipient(session, sessionStore) + const refusal = refuseUndeliverableSessionRecipient(session, sessionStore, db) return refusal ? refused(params.handle, refusal) : { ok: true, to: handle, runId: params.senderRunId } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index 3b7d8c2c114..d24cbdadf43 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -13,12 +13,14 @@ import { parseOrchestrationActor, sessionOrchestrationActor } from '../../../../../../shared/orchestration-actor' -import { ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-recipient-codes' +// The caller codes, reused: each names the same fact about a session, whichever side of the mail it is on. +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-caller-codes' import { lookupOrcaAgentSession, structuredSessionMailReach, type AgentSessionRecordReader } from '../../../../orchestration/structured-session-mail-address' +import type { OrchestrationDb } from '../../../../orchestration/db' const SESSION_PREFIX = 'session:' @@ -64,7 +66,8 @@ export function readSessionRecipient( /** Null when mail to this session can be stored and delivered here; otherwise why not. */ export function refuseUndeliverableSessionRecipient( recipient: SessionRecipient, - store: AgentSessionRecordReader | null + store: AgentSessionRecordReader | null, + db: OrchestrationDb ): SessionRecipientRefusal | null { const { sessionId } = recipient if (!store) { @@ -83,7 +86,7 @@ export function refuseUndeliverableSessionRecipient( message: `No Orca agent session ${sessionId} exists on this host. No message was sent.` } } - const reach = structuredSessionMailReach(store, found.record) + const reach = structuredSessionMailReach(store, found.record, db) if (reach.kind === 'other-host') { return { code: CODES.hostBoundary, @@ -92,11 +95,13 @@ export function refuseUndeliverableSessionRecipient( } if (reach.kind === 'ended') { return { - code: CODES.ended, + code: CODES.notLive, message: reach.reason === 'replaced' ? `Agent session ${sessionId} was cleared and continues as session:${reach.replacementSessionId}. Send there instead. No message was sent.` - : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` + : reach.reason === 'worker-identity-lost' + ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` + : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` } } return null diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index bbf199e7f50..ead47a4ac59 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -6,16 +6,13 @@ import { structuredWorkerProcessIncarnation } from '../structured-worker-identity' import { - ADDRESS_X, - ADDRESS_Y, + ACTOR_X, createSessionCallerHarness, orchestrationRequest, - idOf, - resultOf, + PROVIDER_ID_X, SESSION_X, SESSION_Y, sessionRecord, - WORKER_HANDLE, type SessionCallerHarness } from './orchestration-session-caller-test-fixture' @@ -26,11 +23,15 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' type Row = Record -describe('mail sent to a session address reaches the mailbox that session reads', () => { +describe('a send addressed to an agent session', () => { let h: SessionCallerHarness + let visible: string[] beforeEach(() => { h = createSessionCallerHarness(hostRef) + visible = [SESSION_X, SESSION_Y] + const host = hostRef.current as { deps: { store: Row } } + host.deps.store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: visible }) }) afterEach(() => { @@ -38,84 +39,97 @@ describe('mail sent to a session address reaches the mailbox that session reads' vi.restoreAllMocks() }) - async function as(sessionId: string | undefined, method: string, params: Row): Promise { - return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) - } - - function sendFromTerminal(to: string): Promise { - return as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to, subject: 'hello' }) + async function send(to: string): Promise<{ ok: boolean; result?: Row; error?: Row }> { + const response = await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + return response as never } - it("files it under the chat's current Run, as a terminal coordinator's pane does", async () => { - await as(SESSION_X, 'orchestration.runCreate', { objective: 'first' }) - const current = idOf( - (await as(SESSION_X, 'orchestration.runCreate', { objective: 'next' })).run - ) + it('stores mail to a live session that coordinates nothing at its own address, and points it', async () => { + // The refusal this replaces: "Terminal session: has no live pane or durable Run/Dispatch + // mailbox." An agent's id is its public address, coordinator or not. + const deliver = vi.spyOn(h.runtime, 'deliverPendingMessagesForHandle') + const sent = await send(ACTOR_X) + expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: ACTOR_X } } }) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(ACTOR_X, expect.anything())) + }) - const { message } = await sendFromTerminal(ADDRESS_X) + it('accepts a bare Orca session id and normalizes it', async () => { + expect(await send(SESSION_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: ACTOR_X } } + }) + }) - expect(message).toMatchObject({ to_handle: `run:${current}`, run_id: current }) - expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ - runId: current, - messages: [{ subject: 'hello' }] + it('keeps routing a coordinating session to its Run mailbox', async () => { + const created = await h.dispatch( + orchestrationRequest('orchestration.runCreate', { objective: 'o' }, { sessionId: SESSION_X }) + ) + const runId = ((created as { result: { run: { id: string } } }).result.run as { id: string }).id + expect(await send(ACTOR_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: `run:${runId}` } } }) }) - it('delivers it to a chat with no Run, which reads its direct mailbox', async () => { - const { message } = await sendFromTerminal(ADDRESS_X) + it.each([ + [ + 'an unknown session', + () => `session:0b0b0b0b-1111-4222-8333-444444444444`, + 'session_caller_unknown' + ], + ['a malformed session address', () => 'session:term_abc', 'session_caller_unknown'], + ['a provider id', () => `session:${PROVIDER_ID_X}`, 'session_caller_provider_id'], + ['a bare provider id', () => PROVIDER_ID_X, 'session_caller_provider_id'] + ])('refuses %s before storing anything', async (_label, to, code) => { + const sent = await send(to()) + expect(sent).toMatchObject({ ok: false, error: { code } }) + expect(h.db.getInbox(100)).toEqual([]) + }) - expect(message).toMatchObject({ to_handle: ADDRESS_X }) - expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ - messages: [{ subject: 'hello' }] + it('refuses a session on another host', async () => { + h.records.set(SESSION_Y, sessionRecord(SESSION_Y, { location: { executionHostId: 'ssh:box' } })) + expect(await send(`session:${SESSION_Y}`)).toMatchObject({ + ok: false, + error: { code: 'session_caller_host_boundary' } }) + expect(h.db.getInbox(100)).toEqual([]) }) - it('refuses an Orca session this host does not run', async () => { - h.records.set( - SESSION_X, - sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) - ) - h.records.delete(SESSION_Y) - - for (const to of [ADDRESS_X, ADDRESS_Y]) { - const response = await h.dispatch( - orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) - ) - expect(response).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) - } + it('refuses a session whose chat was closed, naming why', async () => { + visible = [SESSION_X] + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(String(sent.error?.message)).toContain('its chat was closed') + expect(h.db.getInbox(100)).toEqual([]) }) - it("routes a structured worker's session address to the Dispatch it is working", async () => { - const handle = mintStructuredWorkerHandle() - const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) - structuredWorkerIdentities.register({ - handle, - sessionId: SESSION_Y, - agent: 'claude', - paneKey, - processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), - worktreeId: 'wt_1', - hostScope: { kind: 'local', hostId: 'local' } + it('refuses a structured worker whose worker identity is gone: nothing could ever read it', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' }) - const runId = idOf((await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })).run) - const dispatch = h.db.createDispatchContext({ - taskId: h.db.createTask({ runId, spec: 'work' }).id, - assigneeHandle: handle, - assigneePaneKey: paneKey, + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), - creator: { kind: 'session', orcaSessionId: SESSION_X }, + creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER }) + structuredWorkerIdentities.clear() + const sent = await send(`session:${SESSION_Y}`) + expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) + expect(String(sent.error?.message)).toContain('worker identity') + }) - const { message } = await as(SESSION_X, 'orchestration.send', { - to: ADDRESS_Y, - subject: 'to the worker' - }) - - expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}`, run_id: runId }) - expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ - dispatchId: dispatch.id, - messages: [{ subject: 'to the worker' }] + it('leaves a bare string that is no session a terminal handle, as before', async () => { + expect(await send('0b0b0b0b-1111-4222-8333-444444444444')).toMatchObject({ + ok: false, + error: { code: 'terminal_not_found' } }) }) }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index de0f7413339..55631d2ecab 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -447,7 +447,7 @@ describe('any live session is addressable by its id', () => { subject: 'ping' }) ) - expect(response).toMatchObject({ ok: false, error: { code: 'session_recipient_ended' } }) + expect(response).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) expect(db.getInbox(100)).toEqual([]) }) }) diff --git a/src/shared/orchestration-session-recipient-codes.ts b/src/shared/orchestration-session-recipient-codes.ts deleted file mode 100644 index b0ddb6003c8..00000000000 --- a/src/shared/orchestration-session-recipient-codes.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Refusals for mail addressed to an agent session (`session:`, or a bare Orca session id). - * Each is issued at recipient routing, before any message is stored. - */ -export const ORCHESTRATION_SESSION_RECIPIENT_ERROR_CODES = { - /** The session runs on another host; mail reaches a session only on the host that runs it. */ - hostBoundary: 'session_recipient_host_boundary', - /** No Orca agent session with that id exists on this host, or it cannot be verified. */ - unknown: 'session_recipient_unknown', - /** The id is a provider's own session id, which rotates on `/clear`; the refusal names the Orca id. */ - providerId: 'session_recipient_provider_id', - /** The session's chat was closed, or `/clear` replaced it with another session. */ - ended: 'session_recipient_ended' -} as const From ba0d182c90e30992e51f724a167b669ab7cf7ee7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:09:18 -0700 Subject: [PATCH 22/49] fix(orchestration): wait out a pending worker preamble instead of discarding the started worker --- ...stration-structured-worker-session.test.ts | 22 +++++++++++++++++-- ...orchestration-structured-worker-session.ts | 11 +++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index fd2699cbe2e..88f894f42b4 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -234,10 +234,15 @@ describe('structured worker session hold', () => { }) describe('structured worker dispatch preamble', () => { - function hostWithSubmission(submission: Record) { + function hostWithSubmission( + submission: Record, + settlesTo?: Record + ) { return { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, - send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }) + send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), + waitForSendSettlement: async () => + settlesTo ? { value: { clientMessageId: 'c1', submission: settlesTo } } : undefined } as never } @@ -250,6 +255,19 @@ describe('structured worker dispatch preamble', () => { ).resolves.toBeUndefined() }) + it('waits out a pending submission and reports it delivered once the provider echoes it', async () => { + // A live provider answers `pending` first: admitted, not yet echoed. Treating that as unknown + // failed every real structured worker start and discarded a worker whose turn had begun. + await expect( + send( + hostWithSubmission( + { dispatchState: 'pending', reason: null }, + { dispatchState: 'accepted', reason: null } + ) + ) + ).resolves.toBeUndefined() + }) + it('never claims delivery for a submission the provider never acknowledged', async () => { // `dispatchSafely` turns ANY thrown adapter call — provider child dead, transport dropped — // into `unknown`, and `performSend` still returns ok. Reporting that as `dispatch_input: diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts index 5bde461a059..6704013e354 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -244,7 +244,16 @@ export async function sendStructuredWorkerPreamble(args: { if (!result.ok) { throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) } - const submission = result.value.submission + // `pending` is the normal first answer from a live provider: admitted, not yet echoed. Wait for + // the echo the same way a chat client does; tearing the worker down here killed a started turn. + const submission = + result.value.submission.dispatchState === 'pending' + ? (( + await args.host + .waitForSendSettlement(args.sessionId, result.value.clientMessageId) + .catch(() => undefined) + )?.value.submission ?? result.value.submission) + : result.value.submission if (submission.dispatchState === 'accepted') { return } From 324130efb509ae2dc374ae733da0ea2bae877db2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:29:22 -0700 Subject: [PATCH 23/49] test(orchestration): pin a chat's terminal view reading the chat's coordinator mail --- ...uctured-session-terminal-view-mail.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts diff --git a/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts b/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts new file mode 100644 index 00000000000..5e1c697d849 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationDb } from './db' +import { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' +import { OrchestrationMailboxOwner, type OrchestrationMailboxLeaf } from './mailbox-owner' + +const CHAT = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const CHAT_ACTOR = `session:${CHAT}` +const VIEW_TAB = 'tab_view' +const VIEW_LEAF = '77777777-7777-4777-8777-777777777777' + +let db: OrchestrationDb + +beforeEach(() => { + db = new OrchestrationDb(':memory:') +}) + +afterEach(() => { + db.close() +}) + +const viewLeaf: OrchestrationMailboxLeaf = { + tabId: VIEW_TAB, + leafId: VIEW_LEAF, + ptyId: 'pty-view', + writable: true, + lastAgentStatus: 'idle', + lastAgentStatusObservedLive: true, + lastOscTitle: null +} + +function owner(boundSessionId: string | null): OrchestrationMailboxOwner { + return new OrchestrationMailboxOwner({ + getDb: () => db, + getLeaf: () => viewLeaf, + getLeafKey: (tabId, leafId) => `${tabId}::${leafId}`, + getTerminalHandleForLeafKey: () => 'term_view', + getTerminalProcessIncarnation: () => null, + onRoutedMessageTypes: vi.fn(), + onForeignMailboxRouted: vi.fn(), + getBoundSessionIdForPty: (ptyId) => (ptyId === 'pty-view' ? boundSessionId : null) + }) +} + +function chatRun(): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_ACTOR + }).id +} + +describe("a chat's terminal view reads the chat's mail", () => { + it('owns the Run the chat coordinates, which no pane binding names', () => { + // The CLI in a terminal view acts as the session, so the Run is bound by actor and the pane + // path finds nothing: without this the idle edge of the view never pointed coordinator mail. + const runId = chatRun() + expect(owner(CHAT).resolve(viewLeaf)).toBe(`run:${runId}`) + expect(owner(CHAT).resolve(viewLeaf, `run:${runId}`)).toBe(`run:${runId}`) + expect(owner(null).resolve(viewLeaf)).toBe('term_view') + }) + + it('falls back to the pane for a terminal whose session owns nothing', () => { + const runId = db.createRun({ + objective: 'o', + coordinatorHandle: 'term_view', + coordinatorPaneKey: `${VIEW_TAB}:${VIEW_LEAF}` + }).id + expect(owner(CHAT).resolve(viewLeaf)).toBe(`run:${runId}`) + }) + + it("resolves a chat-coordinated Run's mailbox to the terminal view's handle", () => { + const runId = chatRun() + const target = new OrchestrationMailboxDeliveryTarget({ + getDb: () => db, + getTerminalHandleForPaneKey: () => null, + hasTerminalHandle: (handle) => handle === 'term_view', + isStructuredWorkerHandle: () => false, + canProbePtyLiveness: () => false, + controllerKnowsPtyIsLive: () => true, + isLeafPtyProvenAbsent: async () => false, + getTerminalViewHandleForSession: (sessionId) => (sessionId === CHAT ? 'term_view' : null) + }) + expect(target.resolveTerminalHandle(`run:${runId}`)).toBe('term_view') + }) +}) From d50b9af45074ab19385dcc7dd692a3dfd51c14b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:33:06 -0700 Subject: [PATCH 24/49] test(orchestration): read coordinator mail fixtures through checked guards instead of assertions --- .../structured-session-mail-target.test.ts | 1 + ...stration-structured-worker-session.test.ts | 1 + .../orchestration-session-recipient.test.ts | 33 ++++++++++++++----- .../structured-chat-coordinator-mail.test.ts | 12 ++++--- 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index bfbc7a1c8f2..14c65a0f4f9 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -64,6 +64,7 @@ function installStore(record: AgentSessionRecord | null, visible = true): Store let db: InstanceType function probe(extra: Record = {}): MailTargetProbe { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a prototype-only probe; every field the methods read is assigned below. return Object.assign(Object.create(MailTargetProbe.prototype), { _orchestrationDb: db, ptysById: new Map(), diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 88f894f42b4..9607f4278f4 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -238,6 +238,7 @@ describe('structured worker dispatch preamble', () => { submission: Record, settlesTo?: Record ) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the preamble reads only these host members. return { deps: { store: { getRecord: () => ({ lease: { runtimeFence: 7 } }) } }, send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index ead47a4ac59..1393cbba94f 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -8,7 +8,10 @@ import { import { ACTOR_X, createSessionCallerHarness, + idOf, + isRecord, orchestrationRequest, + resultOf, PROVIDER_ID_X, SESSION_X, SESSION_Y, @@ -30,8 +33,15 @@ describe('a send addressed to an agent session', () => { beforeEach(() => { h = createSessionCallerHarness(hostRef) visible = [SESSION_X, SESSION_Y] - const host = hostRef.current as { deps: { store: Row } } - host.deps.store.getVisibleSessionTabIndex = () => ({ present: true, sessionIds: visible }) + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, + listRecords: () => [...h.records.values()], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible }) + } + } + } }) afterEach(() => { @@ -39,11 +49,18 @@ describe('a send addressed to an agent session', () => { vi.restoreAllMocks() }) - async function send(to: string): Promise<{ ok: boolean; result?: Row; error?: Row }> { - const response = await h.dispatch( + async function send(to: string): Promise { + const response: unknown = await h.dispatch( orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) ) - return response as never + if (!isRecord(response)) { + throw new Error('expected an RPC response object') + } + return response + } + + function errorMessage(response: Row): string { + return isRecord(response.error) ? String(response.error.message) : '' } it('stores mail to a live session that coordinates nothing at its own address, and points it', async () => { @@ -66,7 +83,7 @@ describe('a send addressed to an agent session', () => { const created = await h.dispatch( orchestrationRequest('orchestration.runCreate', { objective: 'o' }, { sessionId: SESSION_X }) ) - const runId = ((created as { result: { run: { id: string } } }).result.run as { id: string }).id + const runId = idOf(resultOf(created).run) expect(await send(ACTOR_X)).toMatchObject({ ok: true, result: { message: { to_handle: `run:${runId}` } } @@ -101,7 +118,7 @@ describe('a send addressed to an agent session', () => { visible = [SESSION_X] const sent = await send(`session:${SESSION_Y}`) expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) - expect(String(sent.error?.message)).toContain('its chat was closed') + expect(errorMessage(sent)).toContain('its chat was closed') expect(h.db.getInbox(100)).toEqual([]) }) @@ -123,7 +140,7 @@ describe('a send addressed to an agent session', () => { structuredWorkerIdentities.clear() const sent = await send(`session:${SESSION_Y}`) expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) - expect(String(sent.error?.message)).toContain('worker identity') + expect(errorMessage(sent)).toContain('worker identity') }) it('leaves a bare string that is no session a terminal handle, as before', async () => { diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 55631d2ecab..b9bd87a305d 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -23,6 +23,7 @@ import { OrchestrationDb } from './orchestration/db' import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' +import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -43,6 +44,7 @@ type FakeConnection = Omit & { function fakeCodex() { const connections: FakeConnection[] = [] let turnCounter = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a fake answering the JSON-RPC calls the adapter makes, as the shipped integration test does. const openConnection = (async (_launch, handlers = {}) => { const connection: FakeConnection = { handlers, @@ -51,7 +53,7 @@ function fakeCodex() { pid: 4321, closed: false, request: async (method, params) => { - const input = (params ?? {}) as Record + const input = isRecord(params) ? params : {} if (method === 'thread/start') { connection.threadId = `thread-${connections.length}` return { thread: { id: connection.threadId } } @@ -131,7 +133,7 @@ function attachParams(sessionId: string) { payloadFingerprint: computeAgentSessionPayloadFingerprint({ method: 'agentSession.attach', sessionId, - fields: attachFingerprintFields({ ...params, envelope } as never) + fields: attachFingerprintFields({ ...params, envelope }) }) } } @@ -174,7 +176,7 @@ async function call( if (!response.ok) { throw new Error(`${method} failed: ${JSON.stringify(response)}`) } - return response.result as Record + return resultOf(response) } async function openChat(sessionId: string): Promise { @@ -265,7 +267,7 @@ async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string sessionId: COORDINATOR } ) - const runId = (created.run as { id: string }).id + const runId = idOf(created.run) const task = await call( 'orchestration.taskCreate', { spec: 'build it' }, @@ -273,7 +275,7 @@ async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string sessionId: COORDINATOR } ) - return { runId, taskId: (task.task as { id: string }).id } + return { runId, taskId: idOf(task.task) } } beforeEach(async () => { From be2271526ff633bfbab0da09d4ef9303129b26e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 01:51:10 -0700 Subject: [PATCH 25/49] fix(orchestration): name a structured session's CLI by $ORCA_CLI_COMMAND in its pointer turn --- src/main/runtime/orchestration/cli-command.ts | 7 +++++++ src/main/runtime/orchestration/formatter.ts | 4 ++-- .../structured-mailbox-pointer-delivery.ts | 12 +++++++++++- .../runtime/structured-chat-coordinator-mail.test.ts | 7 +++++-- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index 809be9a4b88..f59cad3deba 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -4,6 +4,13 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' +/** + * How text addressed to a structured session names this app's CLI. Its env carries the absolute + * launcher in `ORCA_CLI_COMMAND`; a bare `orca` can resolve elsewhere once a login shell (Codex runs + * `zsh -lc`) rebuilds PATH ahead of the directory Orca prepended. + */ +export const STRUCTURED_SESSION_CLI_COMMAND = '"$ORCA_CLI_COMMAND"' + export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 2dd4f86777b..02e0f8a867d 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,6 +1,6 @@ import type { MessageRow } from './types' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' -import type { OrchestrationCliCommand } from './cli-command' +import type { OrchestrationCliCommand, STRUCTURED_SESSION_CLI_COMMAND } from './cli-command' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) @@ -112,7 +112,7 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { export function formatMessagePointer( count: number, mailboxHandle?: string, - cliCommand: OrchestrationCliCommand = 'orca' + cliCommand: OrchestrationCliCommand | typeof STRUCTURED_SESSION_CLI_COMMAND = 'orca' ): string { const noun = count === 1 ? 'message' : 'messages' const runFlag = mailboxHandle?.startsWith('run:') diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 5db68e53a02..2bff5e220e1 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -15,6 +15,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' +import { STRUCTURED_SESSION_CLI_COMMAND } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter @@ -218,7 +219,16 @@ export class OrchestrationStructuredMailboxPointerDelivery< const body: AgentJournalMessageItem = { kind: 'message', role: 'user', - blocks: [{ type: 'text', text: formatMessagePointer(unread.length, mailboxHandle).trim() }] + blocks: [ + { + type: 'text', + text: formatMessagePointer( + unread.length, + mailboxHandle, + STRUCTURED_SESSION_CLI_COMMAND + ).trim() + } + ] } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index b9bd87a305d..9e204b11dff 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -312,7 +312,9 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -const POINTER = /You have 1 orchestration message\. Run `orca orchestration check --run run_/ +// The session's own CLI by its env var: a bare `orca` can resolve elsewhere in a login shell. +const POINTER = + /You have 1 orchestration message\. Run `\\?"\$ORCA_CLI_COMMAND\\?" orchestration check --run run_/ describe('a worker result reaches the structured chat that coordinates it', () => { it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { @@ -433,7 +435,8 @@ describe('any live session is addressable by its id', () => { await vi.waitFor(() => expect(peer.turns).toHaveLength(1)) // Direct mail is not in a Run, so the pointer names no `--run`. - expect(peer.turns[0]!.text).toContain('Run `orca orchestration check`.') + expect(peer.turns[0]!.text).toContain('orchestration check`.') + expect(peer.turns[0]!.text).toContain('$ORCA_CLI_COMMAND') await settleTurn(PEER_CHAT, 0) const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) From 95c4407586be2e943da541958f3b23593071b1d4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:03:36 -0700 Subject: [PATCH 26/49] fix(orchestration): render the pointer's CLI invocation for the shell the session runs in --- src/main/runtime/orchestration/cli-command.ts | 23 +++++++++-- src/main/runtime/orchestration/formatter.ts | 4 +- ...tructured-mailbox-pointer-delivery.test.ts | 2 + .../structured-mailbox-pointer-delivery.ts | 6 ++- .../structured-mailbox-pointer-host.ts | 9 +++++ .../structured-session-cli-invocation.test.ts | 38 +++++++++++++++++++ 6 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 src/main/runtime/orchestration/structured-session-cli-invocation.test.ts diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index f59cad3deba..bf9cfaf0f16 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -4,12 +4,27 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' +/** The CLI invocation text a structured session's shell can run, one form per shell family. */ +export type StructuredSessionCliInvocation = '"$ORCA_CLI_COMMAND"' | '& $env:ORCA_CLI_COMMAND' + /** - * How text addressed to a structured session names this app's CLI. Its env carries the absolute - * launcher in `ORCA_CLI_COMMAND`; a bare `orca` can resolve elsewhere once a login shell (Codex runs - * `zsh -lc`) rebuilds PATH ahead of the directory Orca prepended. + * How text addressed to a structured session invokes this app's CLI, in the shell that session's + * commands actually run in. The env carries the absolute launcher in `ORCA_CLI_COMMAND`; a bare + * `orca` can resolve elsewhere once a profile-loading shell rebuilds PATH ahead of Orca's entry. + * + * - Codex on Windows runs PowerShell (pwsh, else Windows PowerShell) and loads its profile, so the + * env var is read as `$env:…` and invoked with `&`. Codex falls back to cmd only when no + * PowerShell exists at all, which Orca cannot see from here. + * - Claude on Windows runs its commands in Git Bash; macOS and Linux shells are POSIX for both. */ -export const STRUCTURED_SESSION_CLI_COMMAND = '"$ORCA_CLI_COMMAND"' +export function structuredSessionCliInvocation(session: { + platform: NodeJS.Platform + provider: 'claude' | 'codex' +}): StructuredSessionCliInvocation { + return session.platform === 'win32' && session.provider === 'codex' + ? '& $env:ORCA_CLI_COMMAND' + : '"$ORCA_CLI_COMMAND"' +} export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 02e0f8a867d..00b3eb85ed9 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,6 +1,6 @@ import type { MessageRow } from './types' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' -import type { OrchestrationCliCommand, STRUCTURED_SESSION_CLI_COMMAND } from './cli-command' +import type { OrchestrationCliCommand, StructuredSessionCliInvocation } from './cli-command' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) @@ -112,7 +112,7 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { export function formatMessagePointer( count: number, mailboxHandle?: string, - cliCommand: OrchestrationCliCommand | typeof STRUCTURED_SESSION_CLI_COMMAND = 'orca' + cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca' ): string { const noun = count === 1 ? 'message' : 'messages' const runFlag = mailboxHandle?.startsWith('run:') diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 527e31fa8ad..8d89d8a0aac 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -121,6 +121,7 @@ function harness(options: { host: { readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, + cliInvocation: () => '"$ORCA_CLI_COMMAND"', send, ...('wakeTo' in options ? { wake } : {}) } @@ -382,6 +383,7 @@ describe('forgetting one settled worker', () => { host: { readGateFacts: () => structuredSessionGateFacts(journal), currentFence: () => 4, + cliInvocation: () => '"$ORCA_CLI_COMMAND"', send } }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 2bff5e220e1..fb2b5ac7033 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -15,7 +15,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' -import { STRUCTURED_SESSION_CLI_COMMAND } from './cli-command' +import type { StructuredSessionCliInvocation } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter @@ -62,6 +62,8 @@ export type StructuredMailboxPointerHost = { }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null + /** How this session's shell invokes this app's CLI; see `structuredSessionCliInvocation`. */ + cliInvocation: (sessionId: string) => StructuredSessionCliInvocation /** * Holds the session for one attempt, resuming its provider child if the host evicted it; the * returned release hands it back to the host's release clock. Null when it cannot be resumed. @@ -225,7 +227,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< text: formatMessagePointer( unread.length, mailboxHandle, - STRUCTURED_SESSION_CLI_COMMAND + this.deps.host.cliInvocation(sessionId) ).trim() } ] diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 14e3e185e08..b05a52a6867 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -9,6 +9,7 @@ import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import { structuredSessionCliInvocation } from './cli-command' import { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -81,6 +82,14 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo return readStructuredSessionGateFacts(sessionId) }, + cliInvocation(sessionId) { + const provider = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.provider + return structuredSessionCliInvocation({ + platform: process.platform, + provider: provider ?? 'claude' + }) + }, + currentFence(sessionId) { return ( getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null diff --git a/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts b/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts new file mode 100644 index 00000000000..79d633f561e --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { structuredSessionCliInvocation } from './cli-command' +import { formatMessagePointer } from './formatter' + +describe('how a structured session invokes this app’s CLI, per shell', () => { + it.each([ + ['darwin', 'claude', '"$ORCA_CLI_COMMAND"'], + ['darwin', 'codex', '"$ORCA_CLI_COMMAND"'], + ['linux', 'claude', '"$ORCA_CLI_COMMAND"'], + ['linux', 'codex', '"$ORCA_CLI_COMMAND"'], + // Claude's command tool runs in Git Bash on Windows: still a POSIX expansion. + ['win32', 'claude', '"$ORCA_CLI_COMMAND"'], + // Codex's defaults to PowerShell on Windows, where "$X" is a PS variable and a quoted string + // followed by arguments does not parse; the env var is `$env:X`, invoked with `&`. + ['win32', 'codex', '& $env:ORCA_CLI_COMMAND'] + ] as const)('%s / %s → %s', (platform, provider, invocation) => { + expect(structuredSessionCliInvocation({ platform, provider })).toBe(invocation) + }) + + it('renders a pointer each shell can run as written', () => { + expect( + formatMessagePointer( + 1, + 'run:run_1', + structuredSessionCliInvocation({ platform: 'win32', provider: 'codex' }) + ).trim() + ).toBe( + 'You have 1 orchestration message. Run `& $env:ORCA_CLI_COMMAND orchestration check --run run_1`.' + ) + expect( + formatMessagePointer( + 1, + 'session:s1', + structuredSessionCliInvocation({ platform: 'darwin', provider: 'codex' }) + ).trim() + ).toBe('You have 1 orchestration message. Run `"$ORCA_CLI_COMMAND" orchestration check`.') + }) +}) From 9aa80a521455919e88ff0151f090b4701123883d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:04:44 -0700 Subject: [PATCH 27/49] fix(orchestration): address a session recipient where its check reads, so a structured worker gets its mail --- .../messaging/session-recipient.ts | 7 +- .../orchestration-session-recipient.test.ts | 67 +++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index d24cbdadf43..f7fc293b4a4 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -24,7 +24,8 @@ import type { OrchestrationDb } from '../../../../orchestration/db' const SESSION_PREFIX = 'session:' -export type SessionRecipient = { sessionId: string; address: string } +/** `actor` is the `session:` spelling; the mailbox mail lands in is the session's identity address. */ +export type SessionRecipient = { sessionId: string; actor: string } export type SessionRecipientRefusal = { code: (typeof CODES)[keyof typeof CODES] @@ -47,7 +48,7 @@ export function readSessionRecipient( if (recipient.startsWith(SESSION_PREFIX)) { const actor = parseOrchestrationActor(recipient) return actor - ? { sessionId: actor.id, address: formatOrchestrationActor(actor) } + ? { sessionId: actor.id, actor: formatOrchestrationActor(actor) } : { code: CODES.unknown, message: `${recipient} does not name an Orca agent session id. No message was sent.` @@ -59,7 +60,7 @@ export function readSessionRecipient( return providerIdRefusal(recipient, found.orcaSessionId) } return actor && found?.kind === 'found' - ? { sessionId: actor.id, address: formatOrchestrationActor(actor) } + ? { sessionId: actor.id, actor: formatOrchestrationActor(actor) } : null } diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index 1393cbba94f..4e63ee0af5d 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -150,3 +150,70 @@ describe('a send addressed to an agent session', () => { }) }) }) + +describe('a live structured worker addressed by its session id', () => { + let h: SessionCallerHarness + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function sendTo(to: string): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + ) + } + + async function flaglessCheck(): Promise { + return resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: SESSION_Y }) + ) + ) + } + + it('lands in its Dispatch mailbox, which its flagless check reads', async () => { + // The defect this pins: the mail was stored at `session:`, pointed at the worker, and its + // `check` — which reads the worker's handle and Dispatch mailboxes — returned nothing. + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(await sendTo(`session:${SESSION_Y}`)).toMatchObject({ + message: { to_handle: `dispatch:${dispatch.id}` } + }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) + + it('lands in its own handle mailbox between Dispatches, which its flagless check reads', async () => { + expect(await sendTo(SESSION_Y)).toMatchObject({ message: { to_handle: handle } }) + expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) + }) +}) From f5841fbf123a69544e0b00ba50109761666167a0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:06:44 -0700 Subject: [PATCH 28/49] test(orchestration): pin the runtime's own idle-edge redrive wiring; say a released session is not running, not ended --- ...ctured-session-mail-redrive-wiring.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/runtime/structured-session-mail-redrive-wiring.test.ts diff --git a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts new file mode 100644 index 00000000000..64bd1db62d0 --- /dev/null +++ b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts @@ -0,0 +1,46 @@ +// The production wiring of the idle-edge mail redrive: the host the runtime installs must report +// every status change to the runtime's mail redrive. The integration test installs its own callback, +// so without this nothing pins the line that connects the two in the real app. + +import { describe, expect, it, vi } from 'vitest' +import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' +import type * as StructuredAgentSessionRuntime from './structured-agent-session-runtime' +import type { StructuredAgentSessionRuntimeDeps } from './structured-agent-session-runtime' + +const installed = vi.hoisted((): { deps: StructuredAgentSessionRuntimeDeps | null } => ({ + deps: null +})) + +vi.mock('./structured-agent-session-runtime', async (importOriginal) => ({ + ...(await importOriginal()), + ensureStructuredAgentSessionHost: vi.fn(async (deps: StructuredAgentSessionRuntimeDeps) => { + installed.deps = deps + return {} + }) +})) + +const { OrcaRuntimeService } = await import('./orca-runtime') + +describe("the runtime's own structured host install", () => { + it('reports every session status change to the mail redrive', async () => { + const runtime = new OrcaRuntimeService() + const redrive = vi + .spyOn(runtime, 'onStructuredSessionStatusForMail') + .mockImplementation(() => {}) + await runtime.ensureStructuredAgentSessionHost() + const summary: AgentSessionStatusSummary = { + sessionId: 'claude_1234abcd', + workspaceId: 'workspace-1', + agent: 'claude', + status: 'idle', + latestPrompt: '', + updatedAt: 0 + } + try { + installed.deps?.onSessionStatusChanged?.(summary, { replay: false }) + } catch { + // The same callback's rename half needs a store this bare runtime does not have. + } + expect(redrive).toHaveBeenCalledWith(summary) + }) +}) From 882893a182c35cff8c51d360ada3a179307879f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:23:44 -0700 Subject: [PATCH 29/49] fix(orchestration): point mail that has not been pointed, not mail nobody has acked, naming the ack a held batch needs --- .../db/messages/role-mailbox-delivery.ts | 27 ++++++++++ src/main/runtime/orchestration/formatter.ts | 7 ++- ...tructured-mailbox-pointer-delivery.test.ts | 54 ++++++++++++++++--- .../structured-mailbox-pointer-delivery.ts | 34 ++++++------ .../structured-mailbox-pointer-host.test.ts | 6 +-- .../structured-mailbox-pointer-host.ts | 5 +- .../structured-session-pointer-delivery.ts | 15 +++--- .../structured-chat-coordinator-mail.test.ts | 53 +++++++++++++++--- 8 files changed, 157 insertions(+), 44 deletions(-) diff --git a/src/main/runtime/orchestration/db/messages/role-mailbox-delivery.ts b/src/main/runtime/orchestration/db/messages/role-mailbox-delivery.ts index 3b5f5dad94f..7bb8f6656a3 100644 --- a/src/main/runtime/orchestration/db/messages/role-mailbox-delivery.ts +++ b/src/main/runtime/orchestration/db/messages/role-mailbox-delivery.ts @@ -184,6 +184,31 @@ export function hasOutstandingMailboxDelivery( ) } +/** The batch a consumer has read and not yet acknowledged on this mailbox, if any. */ +export function getOutstandingMailboxDelivery( + this: OrchestrationDb, + mailboxHandle: string +): { id: string; messageIds: ReadonlySet } | undefined { + const row: unknown = this.db + .prepare('SELECT id, message_ids FROM outstanding_deliveries WHERE mailbox_handle = ? LIMIT 1') + .get(mailboxHandle) + if ( + !row || + typeof row !== 'object' || + !('id' in row) || + typeof row.id !== 'string' || + !('message_ids' in row) || + typeof row.message_ids !== 'string' + ) { + return undefined + } + const ids: unknown = JSON.parse(row.message_ids) + return { + id: row.id, + messageIds: new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []) + } +} + export function fenceUnacknowledgedMailboxDeliveries( this: OrchestrationDb, mailboxHandle: string @@ -201,6 +226,7 @@ export type RoleMailboxDeliveryMethods = { getOrCreateMailboxDelivery: typeof getOrCreateMailboxDelivery acknowledgeMailboxDelivery: typeof acknowledgeMailboxDelivery hasOutstandingMailboxDelivery: typeof hasOutstandingMailboxDelivery + getOutstandingMailboxDelivery: typeof getOutstandingMailboxDelivery fenceUnacknowledgedMailboxDeliveries: typeof fenceUnacknowledgedMailboxDeliveries } @@ -211,6 +237,7 @@ export function attachRoleMailboxDelivery(ctor: { prototype: object }): void { getOrCreateMailboxDelivery, acknowledgeMailboxDelivery, hasOutstandingMailboxDelivery, + getOutstandingMailboxDelivery, fenceUnacknowledgedMailboxDeliveries }) } diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 00b3eb85ed9..6b5a363d322 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -112,11 +112,14 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { export function formatMessagePointer( count: number, mailboxHandle?: string, - cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca' + cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca', + /** The unacknowledged batch the reader holds; `check` replays it until acked. */ + ackDeliveryId?: string ): string { const noun = count === 1 ? 'message' : 'messages' const runFlag = mailboxHandle?.startsWith('run:') ? ` --run ${mailboxHandle.slice('run:'.length)}` : '' - return `\nYou have ${count} orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}\`.\n` + const ackFlag = ackDeliveryId ? ` --ack ${ackDeliveryId}` : '' + return `\nYou have ${count} ${ackDeliveryId ? 'new ' : ''}orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}${ackFlag}\`.\n` } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 8d89d8a0aac..47f0a24718c 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -73,10 +73,12 @@ function attentionJournal(): AgentJournalRenderItem[] { function harness(options: { journal: AgentJournalRenderItem[] | null - dispatchState?: 'accepted' | 'rejected' | 'unknown' + dispatchState?: 'accepted' | 'pending' | 'rejected' | 'unknown' /** The coordinator of this worker's Run is mid-batch: it checked and has not acked yet. */ outstandingRunDelivery?: boolean outstandingOwnDelivery?: boolean + /** Undelivered unread rows on the mailbox, oldest first. */ + unreadIds?: string[] /** The mailbox this worker owns; its own handle for direct peer mail outside a dispatch. */ mailbox?: string dispatchId?: string | null @@ -103,10 +105,18 @@ function harness(options: { const stored = new Map() const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), - hasOutstandingMailboxDelivery: (handle: string) => + // The reader holds `m1` unacknowledged, on whichever mailbox the option names. + getOutstandingMailboxDelivery: (handle: string) => ((options.outstandingRunDelivery ?? false) && handle.startsWith('run:')) || - ((options.outstandingOwnDelivery ?? false) && !handle.startsWith('run:')), - getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], + ((options.outstandingOwnDelivery ?? false) && !handle.startsWith('run:')) + ? { id: 'delivery_held', messageIds: new Set(['m1']) } + : undefined, + getUndeliveredUnreadMessages: () => + (options.unreadIds ?? ['m1']).map((id, index) => ({ + id, + type: 'status', + sequence: index + 3 + })), markAsDelivered, getStructuredPointerOperation: (key: string) => stored.get(key), putStructuredPointerOperation: (row: { mailbox_handle: string }) => @@ -266,15 +276,43 @@ describe('structured mailbox pointer delivery', () => { expect(markAsDelivered).toHaveBeenCalledWith(['m1']) }) - it('does not re-nudge a mailbox still holding its own unacked batch', async () => { - // The other half of the same gate: the consumer already has this batch, so a second nudge - // spends a whole provider turn telling it something it was told. + it('does not re-point mail the reader already holds unacknowledged', async () => { + // It already has this batch, so a second pointer spends a whole provider turn telling it + // something it was told. const { delivery, send } = harness({ journal: idleJournal(), outstandingOwnDelivery: true }) delivery.deliverForHandle('dispatch:d1') await flush() expect(send).not.toHaveBeenCalled() }) + it('points newer mail while the reader holds an unacknowledged batch, naming its ack', async () => { + // The strand this pins: a chat reads a result, ends its turn without acking, and the gate on + // "an unacknowledged batch exists" silenced every later result. `check` replays that batch + // until acked, so the pointer must name the ack to reach the new mail. + const { delivery, send, markAsDelivered } = harness({ + journal: idleJournal(), + outstandingOwnDelivery: true, + unreadIds: ['m1', 'm2'] + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ + text: expect.stringMatching(/You have 1 new orchestration message\. .*--ack delivery_held`/) + }) + expect(markAsDelivered).toHaveBeenCalledWith(['m2']) + }) + + it('counts a turn the provider admitted but has not echoed as pointed', async () => { + const { delivery, markAsDelivered } = harness({ + journal: idleJournal(), + dispatchState: 'pending' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + }) + it('retries a rejected nudge on the next journal edge', async () => { // A rejection consumes no mail and nothing else redrives this mailbox, so leaving it unparked // stranded the worker until unrelated mail happened to arrive. @@ -364,7 +402,7 @@ describe('forgetting one settled worker', () => { })) const db = { getDispatchContextById: () => ({ run_id: 'run_1' }), - hasOutstandingMailboxDelivery: () => false, + getOutstandingMailboxDelivery: () => undefined, getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], markAsDelivered: vi.fn(), getStructuredPointerOperation: () => undefined, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index fb2b5ac7033..28745264cf0 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -159,26 +159,23 @@ export class OrchestrationStructuredMailboxPointerDelivery< if (!db || this.inFlight.has(mailboxHandle)) { return } - // Don't re-nudge a mailbox whose consumer still holds an unacknowledged batch. The lookup is - // keyed on the exact handle being nudged, so a coordinator's own `run:` delivery is invisible - // to a worker's `dispatch:` gate and cannot suppress the nudges a coordinator sends its - // workers. Worth more here than in the PTY lane: a structured nudge costs a whole provider - // turn, not a line of text into a composer. - if (db.hasOutstandingMailboxDelivery?.(mailboxHandle)) { - return - } + // Eligibility is "not yet pointed" (`delivered_at`), never "has the consumer acked": a chat that + // reads a batch and ends its turn without acking must still be pointed at the NEXT result. The + // batch it holds is excluded, and the pointer names the `--ack` that releases it, since `check` + // replays an unacknowledged batch instead of returning newer mail. + const outstanding = db.getOutstandingMailboxDelivery?.(mailboxHandle) const unread = selectOrchestrationPointerBatch({ db, mailboxHandle, waiters: this.deps.getMessageWaiters(mailboxHandle), reservedTypes - }) + }).filter((message) => !outstanding?.messageIds.has(message.id)) if (unread.length === 0) { return } this.inFlight.add(mailboxHandle) try { - await this.attempt(db, mailboxHandle, target, unread, reservedTypes) + await this.attempt(db, mailboxHandle, target, unread, reservedTypes, outstanding?.id) } finally { this.inFlight.delete(mailboxHandle) } @@ -189,11 +186,12 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined + reservedTypes: ReadonlySet | undefined, + ackDeliveryId: string | undefined ): Promise { const release = await this.deps.host.wake?.(target.sessionId) try { - await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes) + await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes, ackDeliveryId) } finally { release?.() } @@ -204,7 +202,8 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined + reservedTypes: ReadonlySet | undefined, + ackDeliveryId: string | undefined ): Promise { const sessionId = target.sessionId const session = this.deps.host.readGateFacts(sessionId) @@ -227,7 +226,8 @@ export class OrchestrationStructuredMailboxPointerDelivery< text: formatMessagePointer( unread.length, mailboxHandle, - this.deps.host.cliInvocation(sessionId) + this.deps.host.cliInvocation(sessionId), + ackDeliveryId ).trim() } ] @@ -259,7 +259,9 @@ export class OrchestrationStructuredMailboxPointerDelivery< this.retain( mailboxHandle, sessionId, - retainReasonForDispatch(outcome.state as Exclude), + retainReasonForDispatch( + outcome.state as Exclude + ), reservedTypes ) return @@ -271,7 +273,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } /** - * No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch. + * No `markAsUndelivered` is owed: rows are marked delivered only once the host took the turn. * * Every reason parks for the session's next journal edge. `unknown` may mean the nudge already * sits in the provider's input queue, so an immediate retry can stack duplicate nudges; diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 91bf1158e07..56f83b1dac1 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -80,9 +80,9 @@ describe('structured mailbox pointer host', () => { it.each([ ['accepted', 'accepted'], ['rejected', 'rejected'], - // Neither is an acknowledgement, and only `accepted` may consume mail: both have to reach the - // caller as `unknown` so the pointer is retained for the next journal edge. - ['pending', 'unknown'], + // Admitted and awaiting its echo: the turn exists, so the lane counts the rows as pointed. + ['pending', 'pending'], + // A failed or unanswered call: the lane retains for the next journal edge. ['unknown', 'unknown'] ])('maps a %s submission to %s', async (dispatchState, expected) => { const send = vi.fn( diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index b05a52a6867..1a671413750 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -122,11 +122,12 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo ? { kind: 'unattached' } : { kind: 'sent', state: 'rejected' } } - // `pending` is not yet an acknowledgement; only `accepted` may consume mail. + // `pending` is admitted and awaiting its echo: the turn exists, so the rows count as pointed. const state = result.value.submission.dispatchState return { kind: 'sent', - state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' + state: + state === 'accepted' || state === 'pending' || state === 'rejected' ? state : 'unknown' } } } diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 4071d17a073..c2138f44f9f 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -29,7 +29,7 @@ export type StructuredPointerDecision = | { deliver: false; retain: StructuredPointerRetainReason } /** The dispatch states both provider adapters converge on. */ -export type StructuredDispatchState = 'accepted' | 'rejected' | 'unknown' +export type StructuredDispatchState = 'accepted' | 'pending' | 'rejected' | 'unknown' /** * What the delivery gate needs to know about a session, read once per attempt. @@ -92,18 +92,19 @@ export function decideStructuredSessionPointerDelivery(input: { } /** - * Only an accepted dispatch may mark mail delivered. + * Whether the pointer has been POINTED: the provider took the turn. `accepted` is echoed and + * `pending` is admitted and awaiting its echo; both mean the turn exists, so marking the rows + * delivered stops them being pointed again. Neither consumes mail: `read` is only set by `check`. * - * `unknown` covers a dead provider child and a slow acknowledgement alike — the - * adapters cannot tell them apart — so it must retain. Treating it as delivered - * would drop mail whenever a child died mid-send. + * `unknown` covers a dead provider child and a failed call alike — the adapters cannot tell them + * apart — so it must retain. Treating it as delivered would drop mail whenever a child died mid-send. */ export function structuredDispatchDelivered(state: StructuredDispatchState): boolean { - return state === 'accepted' + return state === 'accepted' || state === 'pending' } export function retainReasonForDispatch( - state: Exclude + state: Exclude ): StructuredPointerRetainReason { return state === 'rejected' ? 'dispatch-rejected' : 'dispatch-unknown' } diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 9e204b11dff..e1265998106 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -33,6 +33,7 @@ const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' const WORKSPACE = 'workspace-1' const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' +const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' type FakeConnection = Omit & { closed: boolean @@ -230,7 +231,10 @@ function userTexts(sessionId: string): string[] { } /** A capability-backed terminal worker under the coordinator's Run, and its worker_done. */ -async function finishWorker(taskId: string): Promise { +async function finishWorker( + taskId: string, + worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } +): Promise { const started = db.createStartingWorkerDispatch({ creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER, @@ -239,9 +243,9 @@ async function finishWorker(taskId: string): Promise { }) const capability = db.prepareStartingWorkerAuthority({ dispatchId: started.dispatch.id, - handle: 'term_worker', - paneKey: WORKER_PANE, - processIncarnation: 'runtime_test:term_worker:1', + handle: worker.handle, + paneKey: worker.paneKey, + processIncarnation: `runtime_test:${worker.handle}:1`, worktreeId: 'repo::worker', effects: [], setupState: 'not_applicable' @@ -250,7 +254,7 @@ async function finishWorker(taskId: string): Promise { await call( 'orchestration.send', { - from: 'term_worker', + from: worker.handle, subject: 'Done', type: 'worker_done', payload: JSON.stringify({ taskId, dispatchId: started.dispatch.id, outcome: 'succeeded' }) @@ -287,7 +291,7 @@ beforeEach(async () => { runtime.setOrchestrationDb(db) vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => - handle === 'term_worker' ? WORKER_PANE : null + handle === 'term_worker' ? WORKER_PANE : handle === 'term_worker_2' ? WORKER_2_PANE : null ) host = await ensureStructuredAgentSessionHost({ stateDirectory: root, @@ -356,6 +360,43 @@ describe('a worker result reaches the structured chat that coordinates it', () = expect(userTexts(COORDINATOR)).toHaveLength(1) }) + it('points the next result at a coordinator that read the last one without acking', async () => { + // The strand this pins: a flagless `check` opens a delivery that `check` replays until acked, + // and a lane gated on "an unacknowledged batch exists" never pointed the chat at a later result. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + await settleTurn(COORDINATOR, 0) + const first = await call('orchestration.check', {}, { sessionId: COORDINATOR }) + const heldDelivery = String(first.deliveryId) + expect(first).toMatchObject({ count: 1, messages: [{ type: 'worker_done' }] }) + + const second = await call( + 'orchestration.taskCreate', + { spec: 'more' }, + { sessionId: COORDINATOR } + ) + await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2)) + expect(chat.turns[1]!.text).toContain('1 new orchestration message') + expect(chat.turns[1]!.text).toContain(`--ack ${heldDelivery}`) + await settleTurn(COORDINATOR, 1) + + // Exactly once per new message: a retry and the idle edge point nothing further. + runtime.deliverPendingMessagesForHandle(`run:${runId}`) + await new Promise((resolve) => setTimeout(resolve, 20)) + expect(chat.turns).toHaveLength(2) + + const acked = await call( + 'orchestration.check', + { ack: heldDelivery }, + { sessionId: COORDINATOR } + ) + expect(acked).toMatchObject({ acknowledged: heldDelivery, count: 1 }) + expect(acked.messages).not.toEqual(first.messages) + }) + it('wakes a coordinator the host evicted, and delivers once it is back', async () => { await openChat(COORDINATOR) const { taskId } = await coordinatorRunAndTask() From b5306a29276ce253eca2f3f097c629f390775541 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:27:11 -0700 Subject: [PATCH 30/49] feat(orchestration): hand a /clear-replaced chat's Runs and unread mail to the session that replaced it --- ...rca-runtime-get-pty-record-for-pane-key.ts | 11 +-- .../db/messages/direct-mailbox-routing.ts | 22 ++++- .../structured-session-mail-target.test.ts | 93 ++++++++++++++++++- .../structured-session-mail-target.ts | 72 +++++++++++++- 4 files changed, 188 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 39244cf472c..ae1c6246532 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -12,8 +12,8 @@ import { findConnectedPtyBoundToSession, structuredSessionAddressTarget, structuredSessionMailTarget, - structuredSessionMailView, - structuredSessionOwnedMailboxes + structuredSessionIdleEdgeMail, + structuredSessionMailView } from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, @@ -208,10 +208,9 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return } this.notifyStructuredSessionJournalActivity(summary.sessionId) - const db = this._orchestrationDb - for (const mailbox of db ? structuredSessionOwnedMailboxes(summary.sessionId, db) : []) { - this.deliverPendingMessagesForHandle(mailbox) - } + const idle = structuredSessionIdleEdgeMail(summary.sessionId, this._orchestrationDb) + idle.reboundRunIds.forEach((runId) => this.cancelMessageWaiters(`run:${runId}`)) + idle.mailboxes.forEach((mailbox) => this.deliverPendingMessagesForHandle(mailbox)) } /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ diff --git a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts index c1e5ee99562..dc8ff67eef4 100644 --- a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts @@ -179,6 +179,25 @@ export type DirectMailboxRoutingMethods = { routeUnreadDirectMessagesToDispatchMailbox: typeof routeUnreadDirectMessagesToDispatchMailbox routeUnreadDispatchMailboxToRunMailbox: typeof routeUnreadDispatchMailboxToRunMailbox getLatestUnreadMessageSequence: typeof getLatestUnreadMessageSequence + readdressUnreadSessionMail: typeof readdressUnreadSessionMail +} + +/** + * Moves a session's unread direct mail to the session that replaced it, as not yet pointed: the + * pointer, if any, went to a session nobody will run again. Read and acknowledgment state stay. + */ +export function readdressUnreadSessionMail( + this: OrchestrationDb, + fromAddress: string, + toAddress: string +): number { + const result = this.db + .prepare( + `UPDATE messages SET to_handle = ?, delivered_at = NULL + WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'` + ) + .run(toAddress, fromAddress) + return Number(result.changes) } export function attachDirectMailboxRouting(ctor: { prototype: object }): void { @@ -189,6 +208,7 @@ export function attachDirectMailboxRouting(ctor: { prototype: object }): void { routeUnreadDirectMessagesToRunMailbox, routeUnreadDirectMessagesToDispatchMailbox, routeUnreadDispatchMailboxToRunMailbox, - getLatestUnreadMessageSequence + getLatestUnreadMessageSequence, + readdressUnreadSessionMail }) } diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 14c65a0f4f9..3257e0f8570 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -196,7 +196,8 @@ describe('the idle edge of a structured session', () => { const delivered: string[] = [] const runtime = probe({ deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), - notifyStructuredSessionJournalActivity: vi.fn() + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() }) runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'working' }) expect(delivered).toEqual([]) @@ -210,8 +211,96 @@ describe('the idle edge of a structured session', () => { const delivered: string[] = [] probe({ deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), - notifyStructuredSessionJournalActivity: vi.fn() + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) expect(delivered).toEqual([CHAT_ACTOR]) }) }) + +describe('a coordinator chat replaced by /clear', () => { + const SUCCESSOR = 'clear-0123456789abcdef0123456789abcdef01234567' + const SUCCESSOR_ACTOR = `session:${SUCCESSOR}` + + function clearedInto(successor: string): AgentSessionRecord { + return chatRecord( + { claimStatus: 'released', ownerProcess: null }, + { + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: successor, + operationId: 'op', + callerKey: 'caller', + phase: 'committed' + } + } + ) + } + + it("hands the predecessor's Runs and unread mail to the session that replaced it", () => { + // The strand this pins: the Run still named the pre-/clear session, so its results were pointed + // at nobody and the new chat — acting as its own new id — could not see the Run at all. + const store = installStore(clearedInto(SUCCESSOR)) + const successorRecord = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: SUCCESSOR, runtimeKind: 'native' }) + ) + store.records.set(SUCCESSOR, successorRecord) + store.visible.sessionIds.push(SUCCESSOR) + const runId = chatCoordinatedRun() + const generation = db.getRunRaw(runId)!.consumer_generation + const direct = db.insertMessage({ + from: 'term_peer', + to: CHAT_ACTOR, + subject: 'hi', + type: 'status' + }) + db.markAsDelivered([direct.id]) + const delivered: string[] = [] + const cancelMessageWaiters = vi.fn() + const runtime = probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters + }) + + runtime.onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: null }) + + // The ordinary bind: a coordinator takeover, with its generation bump. + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: null, + coordinator_actor: SUCCESSOR_ACTOR, + consumer_generation: generation + 1 + }) + expect( + db.getCurrentRunForCoordinator({ + actor: SUCCESSOR_ACTOR, + terminalHandle: null, + paneKey: null + })?.id + ).toBe(runId) + expect(cancelMessageWaiters).toHaveBeenCalledWith(`run:${runId}`) + // Re-addressed and not yet pointed: its old pointer went to a session nobody runs. + expect(db.getMessageById(direct.id)).toMatchObject({ + to_handle: SUCCESSOR_ACTOR, + delivered_at: null, + read: 0 + }) + expect(delivered).toEqual(expect.arrayContaining([`run:${runId}`, SUCCESSOR_ACTOR])) + + // Idempotent: a later edge finds nothing left to adopt. + runtime.onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) + expect(db.getRunRaw(runId)!.consumer_generation).toBe(generation + 1) + }) + + it('leaves Runs alone for a session nothing was cleared into', () => { + installStore(chatRecord()) + const runId = chatCoordinatedRun() + probe({ + deliverPendingMessagesForHandle: vi.fn(), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) + expect(db.getRunRaw(runId)!.coordinator_actor).toBe(CHAT_ACTOR) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 7a7b9011366..6f10d67962c 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -8,7 +8,10 @@ * Exactly one view answers for a session at a time, so the two lanes never both claim a mailbox. */ -import { parseOrchestrationActor } from '../../../shared/orchestration-actor' +import { + formatOrchestrationActor, + parseOrchestrationActor +} from '../../../shared/orchestration-actor' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' @@ -90,6 +93,60 @@ export function structuredSessionAddressTarget( return actor ? structuredSessionMailTarget(actor.id, db) : null } +/** + * `/clear` replaces a chat with a new Orca session and the conversation continues there, so what + * the old session coordinated and was sent follows it: its Runs are rebound through the ordinary + * bind (a coordinator takeover: generation bump, fenced deliveries, rerouted coordinator mail), and + * its unread direct mail is re-addressed. Re-derived on the successor's status edges rather than + * fired once at the clear, so nothing between the clear and the rebind can strand it. Returns the + * Runs it rebound. + */ +export function adoptClearedPredecessorMail( + sessionId: string, + db: OrchestrationDb, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): string[] { + const records = store?.listRecords() ?? [] + const lineage = new Set([sessionId]) + const predecessors: string[] = [] + for (let grew = true; grew;) { + grew = false + for (const record of records) { + const cleared = record.conversationCommand + if ( + cleared?.command === 'clear' && + cleared.phase === 'committed' && + cleared.replacementSessionId !== undefined && + lineage.has(cleared.replacementSessionId) && + !lineage.has(record.sessionId) + ) { + lineage.add(record.sessionId) + predecessors.push(record.sessionId) + grew = true + } + } + } + const successor = formatOrchestrationActor({ kind: 'session', id: sessionId }) + const rebound: string[] = [] + for (const predecessor of predecessors) { + const actor = formatOrchestrationActor({ kind: 'session', id: predecessor }) + for (const run of db.runsBoundToCoordinator({ actor, terminalHandle: null, paneKey: null })) { + if ( + db.bindRun({ + runId: run.id, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: successor + }) + ) { + rebound.push(run.id) + } + } + db.readdressUnreadSessionMail(actor, successor) + } + return rebound +} + /** * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. * Re-derived from the database on each idle edge rather than remembered, so mail that arrived @@ -103,3 +160,16 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra } return mailboxes } + +/** What a session's idle edge owes its mail: Runs adopted from a `/clear` predecessor, then every + * mailbox the session owns, re-derived. */ +export function structuredSessionIdleEdgeMail( + sessionId: string, + db: OrchestrationDb | null +): { reboundRunIds: string[]; mailboxes: string[] } { + if (!db) { + return { reboundRunIds: [], mailboxes: [] } + } + const reboundRunIds = adoptClearedPredecessorMail(sessionId, db) + return { reboundRunIds, mailboxes: structuredSessionOwnedMailboxes(sessionId, db) } +} From 48020c727bce8ce8ccf0618e22085d81be74bfb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:32:10 -0700 Subject: [PATCH 31/49] feat(orchestration): adopt a /clear predecessor's Runs at the clear's commit, the edge its replacement's own status misses --- .../structured-agent-session-host-types.ts | 2 ++ .../structured-conversation-command.test.ts | 30 +++++++++++++++++++ .../structured-conversation-command.ts | 6 ++++ .../runtime/orca-runtime-get-worktree-ps.ts | 3 ++ .../structured-agent-session-runtime.ts | 2 ++ ...ctured-session-mail-redrive-wiring.test.ts | 7 +++++ 6 files changed, 50 insertions(+) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 574ced3cc38..76dfacb5113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -82,6 +82,8 @@ export type StructuredAgentSessionHostDeps = { summary: AgentSessionStatusSummary, options: { replay: boolean } ) => void + /** A committed `/clear` replaced `sessionId` with a new session that continues the conversation. */ + onConversationReplaced?: (input: { sessionId: string; replacementSessionId: string }) => void /** The agent-status store every held session's projection is written to and, on close, * removed from. Both production hosts pass one — the desktop and headless `orcad`; absent, * every reader of that store simply lists no structured session. */ diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index a261c6dd151..1fe7e7a96ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -229,6 +229,36 @@ describe('host conversation commands', () => { expect(adapter.dispatch).not.toHaveBeenCalled() }) + it('reports the replacement only once the clear has committed', async () => { + // Orchestration moves the old session's Runs to its replacement at this edge; the + // replacement's own first status edge fires before the commit and cannot see it yet. + let phaseAtReport: string | undefined + const replaced = vi.fn(() => { + phaseAtReport = store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.phase + }) + host.deps.onConversationReplaced = replaced + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result.ok && result.value.replacementSessionId).toBeTruthy() + expect(replaced).toHaveBeenCalledTimes(1) + expect(replaced).toHaveBeenCalledWith({ + sessionId: HOST_TEST_SESSION, + replacementSessionId: result.ok ? result.value.replacementSessionId : undefined + }) + expect(phaseAtReport).toBe('committed') + }) + + it('reports no replacement for a compaction or a refused clear', async () => { + const replaced = vi.fn() + host.deps.onConversationReplaced = replaced + await host.conversationCommand(caller, commandParams('compact')) + vi.spyOn(host, 'attach').mockResolvedValueOnce({ + ok: false, + refusal: { code: 'structured_agent_session_unsupported', message: 'Unavailable' } + }) + await host.conversationCommand(caller, commandParams('clear')) + expect(replaced).not.toHaveBeenCalled() + }) + it('leaves the source usable when replacement creation is definitely refused', async () => { vi.spyOn(host, 'attach').mockResolvedValueOnce({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index 022343d9407..ce3dcefa9bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -240,6 +240,12 @@ export function runStructuredConversationCommand( ...(error ? { error: error.slice(0, 4096) } : {}) } await store.setConversationCommand(sessionId, ctx.fence, completed) + if (completed.replacementSessionId) { + context.deps.onConversationReplaced?.({ + sessionId, + replacementSessionId: completed.replacementSessionId + }) + } return { ok: true, value: completed } } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4f97af0de25..9630277f299 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -164,6 +164,9 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. + // The replacement's first status edge precedes the commit, so the commit is the adoption edge. + onConversationReplaced: ({ replacementSessionId }) => + this.onStructuredSessionStatusForMail({ sessionId: replacementSessionId, status: null }), onSessionStatusChanged: (summary, options) => { this.onStructuredSessionStatusForMail(summary) void maybeAutoRenameWorkspaceOnFirstStructuredTurn( diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index a51da3f1c3c..bd34e30251c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -102,6 +102,7 @@ export type StructuredAgentSessionRuntimeDeps = { /** Every structured-session status projection, for host-side reactions such as the first-work * workspace rename that CLI agents get from their hooks. */ onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] + onConversationReplaced?: StructuredAgentSessionHostDeps['onConversationReplaced'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] reapOrphanChildren?: typeof stopOrphanAgentSessionChildren @@ -311,6 +312,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), + ...(deps.onConversationReplaced ? { onConversationReplaced: deps.onConversationReplaced } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) diff --git a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts index 64bd1db62d0..1eb0d364aea 100644 --- a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts +++ b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts @@ -42,5 +42,12 @@ describe("the runtime's own structured host install", () => { // The same callback's rename half needs a store this bare runtime does not have. } expect(redrive).toHaveBeenCalledWith(summary) + + // A committed `/clear` is the replacement's adoption edge: its own first status edge came first. + installed.deps?.onConversationReplaced?.({ + sessionId: 'claude_1234abcd', + replacementSessionId: 'clear-1234abcd' + }) + expect(redrive).toHaveBeenCalledWith({ sessionId: 'clear-1234abcd', status: null }) }) }) From eab204c41ca801f90a89cee6983b67ad845ea0d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:33:19 -0700 Subject: [PATCH 32/49] fix(orchestration): log a wake that could not resume a session, instead of retaining silently --- .../orchestration/structured-mailbox-pointer-host.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 1a671413750..00fb11b639a 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -71,8 +71,13 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo const holderId = `orchestration:mail:${++wakeHolds}` try { await host.hold(sessionId, holderId) - } catch { - // A lease another owner holds, or a resume the provider refused; delivery retains. + } catch (error) { + // A lease another owner holds, or a resume the provider refused; delivery retains. Logged: + // a coordinator that never wakes is otherwise indistinguishable from one with no mail. + console.warn('[orchestration] could not wake a structured session for its mail', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) return null } return () => host.release(sessionId, holderId) From 21383793fbb85185e1cc2060f761411a973c6c0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:42:55 -0700 Subject: [PATCH 33/49] test(orchestration): give coordinator mail waits a budget that holds under a loaded parallel run --- .../structured-chat-coordinator-mail.test.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index e1265998106..d69a3e9883e 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -317,6 +317,9 @@ afterEach(async () => { }) // The session's own CLI by its env var: a bare `orca` can resolve elsewhere in a login shell. +// Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. +const WAIT = { timeout: 10_000 } + const POINTER = /You have 1 orchestration message\. Run `\\?"\$ORCA_CLI_COMMAND\\?" orchestration check --run run_/ @@ -328,7 +331,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) // No user action: the result itself sends the chat a turn through the host's send. - await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) expect(chat.turns[0]!.text).toMatch(POINTER) expect(chat.turns[0]!.text).toContain(runId) await settleTurn(COORDINATOR, 0) @@ -346,15 +349,16 @@ describe('a worker result reaches the structured chat that coordinates it', () = const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await finishWorker(taskId) - await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) // A pending send is not an acknowledgement, so the mail is retained and retried on every edge // until the host confirms it: before the echo, and again at the turn's idle edge. runtime.deliverPendingMessagesForHandle(`run:${runId}`) await settleTurn(COORDINATOR, 0) runtime.deliverPendingMessagesForHandle(`run:${runId}`) - await vi.waitFor(() => - expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT ) expect(chat.turns).toHaveLength(1) expect(userTexts(COORDINATOR)).toHaveLength(1) @@ -366,7 +370,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = const chat = await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await finishWorker(taskId) - await vi.waitFor(() => expect(chat.turns).toHaveLength(1)) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) await settleTurn(COORDINATOR, 0) const first = await call('orchestration.check', {}, { sessionId: COORDINATOR }) const heldDelivery = String(first.deliveryId) @@ -378,7 +382,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = { sessionId: COORDINATOR } ) await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) - await vi.waitFor(() => expect(chat.turns).toHaveLength(2)) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) expect(chat.turns[1]!.text).toContain('1 new orchestration message') expect(chat.turns[1]!.text).toContain(`--ack ${heldDelivery}`) await settleTurn(COORDINATOR, 1) @@ -407,9 +411,9 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) - await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1)) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) const revived = connectionFor(COORDINATOR) - await vi.waitFor(() => expect(revived.turns).toHaveLength(1)) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) expect(revived.turns[0]!.text).toMatch(POINTER) await settleTurn(COORDINATOR, 0) expect(userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) @@ -457,7 +461,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = notify('turn/completed', { turn: { id: 'turn-1' } }) await host.flushStreamedEvents(COORDINATOR) - await vi.waitFor(() => expect(chat.turns).toHaveLength(2)) + await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) expect(chat.turns[1]!.text).toMatch(POINTER) expect(chat.turns[1]!.text).toContain(runId) }) @@ -474,7 +478,7 @@ describe('any live session is addressable by its id', () => { }) expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) - await vi.waitFor(() => expect(peer.turns).toHaveLength(1)) + await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) // Direct mail is not in a Run, so the pointer names no `--run`. expect(peer.turns[0]!.text).toContain('orchestration check`.') expect(peer.turns[0]!.text).toContain('$ORCA_CLI_COMMAND') From 2fc1b29bb9a4b91c9e15454ab1f6b4de74f697f6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 02:46:32 -0700 Subject: [PATCH 34/49] refactor(orchestration): narrow a retained pointer's dispatch state by type guard instead of a cast --- .../orchestration/structured-mailbox-pointer-delivery.ts | 9 +-------- .../orchestration/structured-session-pointer-delivery.ts | 4 +++- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 28745264cf0..f16f2f08002 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -256,14 +256,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< if (outcome.state === 'rejected') { db.deleteStructuredPointerOperation(mailboxHandle) } - this.retain( - mailboxHandle, - sessionId, - retainReasonForDispatch( - outcome.state as Exclude - ), - reservedTypes - ) + this.retain(mailboxHandle, sessionId, retainReasonForDispatch(outcome.state), reservedTypes) return } db.markAsDelivered(staged) diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index c2138f44f9f..ee0a010f939 100644 --- a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -99,7 +99,9 @@ export function decideStructuredSessionPointerDelivery(input: { * `unknown` covers a dead provider child and a failed call alike — the adapters cannot tell them * apart — so it must retain. Treating it as delivered would drop mail whenever a child died mid-send. */ -export function structuredDispatchDelivered(state: StructuredDispatchState): boolean { +export function structuredDispatchDelivered( + state: StructuredDispatchState +): state is 'accepted' | 'pending' { return state === 'accepted' || state === 'pending' } From 09b7b13208eb0c81a2ef93b0ab920725c0807f95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:17:25 -0700 Subject: [PATCH 35/49] fix(orchestration): give back a pointer whose admitted turn never ran A pending send stamped its rows delivered and dropped its operation row, so a provider that died before echoing left the last result pointed at nobody. The lane now awaits the admitted turn's settlement: accepted consumes the claim, anything else returns the rows and drops this send's operation row, so the re-point on the next edge is a new send rather than a replay of unknown. --- ...tructured-mailbox-pointer-delivery.test.ts | 79 +++++++++++++++++-- .../structured-mailbox-pointer-delivery.ts | 47 ++++++++++- .../structured-mailbox-pointer-host.test.ts | 47 ++++++++++- .../structured-mailbox-pointer-host.ts | 28 +++++-- .../structured-chat-coordinator-mail.test.ts | 26 ++++++ 5 files changed, 211 insertions(+), 16 deletions(-) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 47f0a24718c..5cda0dae160 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' import { OrchestrationStructuredMailboxPointerDelivery, - type StructuredMailboxPointerHost + type StructuredMailboxPointerHost, + type StructuredPointerSettlement } from './structured-mailbox-pointer-delivery' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -74,6 +75,8 @@ function attentionJournal(): AgentJournalRenderItem[] { function harness(options: { journal: AgentJournalRenderItem[] | null dispatchState?: 'accepted' | 'pending' | 'rejected' | 'unknown' + /** For a `pending` dispatch: how the admitted turn settles; never, when omitted. */ + settlement?: Promise /** The coordinator of this worker's Run is mid-batch: it checked and has not acked yet. */ outstandingRunDelivery?: boolean outstandingOwnDelivery?: boolean @@ -89,11 +92,18 @@ function harness(options: { const dispatchId = options.dispatchId === undefined ? 'd1' : options.dispatchId let journal = options.journal const markAsDelivered = vi.fn() - const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => ({ - kind: 'sent' as const, - state: options.dispatchState ?? ('accepted' as const) - })) + const send: StructuredMailboxPointerHost['send'] = vi.fn(async () => { + const state = options.dispatchState ?? 'accepted' + return state === 'pending' + ? { + kind: 'sent' as const, + state, + settlement: options.settlement ?? new Promise(() => {}) + } + : { kind: 'sent' as const, state } + }) const sendMock = vi.mocked(send) + const markAsUndelivered = vi.fn() const released = vi.fn() const wake = vi.fn(async () => { if (!options.wakeTo) { @@ -118,6 +128,7 @@ function harness(options: { sequence: index + 3 })), markAsDelivered, + markAsUndelivered, getStructuredPointerOperation: (key: string) => stored.get(key), putStructuredPointerOperation: (row: { mailbox_handle: string }) => stored.set(row.mailbox_handle, row), @@ -139,6 +150,7 @@ function harness(options: { return { delivery, markAsDelivered, + markAsUndelivered, released, wake, send: sendMock, @@ -304,13 +316,68 @@ describe('structured mailbox pointer delivery', () => { }) it('counts a turn the provider admitted but has not echoed as pointed', async () => { - const { delivery, markAsDelivered } = harness({ + const { delivery, markAsDelivered, markAsUndelivered, stored } = harness({ journal: idleJournal(), dispatchState: 'pending' }) delivery.deliverForHandle('dispatch:d1') await flush() expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + // Unsettled, the claim is still open: its operation id is what a replay would reuse. + expect(markAsUndelivered).not.toHaveBeenCalled() + expect(stored.has('dispatch:d1')).toBe(true) + }) + + it('consumes an admitted pointer once its turn is echoed', async () => { + const { delivery, markAsUndelivered, stored } = harness({ + journal: idleJournal(), + dispatchState: 'pending', + settlement: Promise.resolve('accepted') + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(markAsUndelivered).not.toHaveBeenCalled() + expect(stored.has('dispatch:d1')).toBe(false) + }) + + it.each(['unknown', 'rejected'] as const)( + 'gives back an admitted pointer that settles %s, and re-points it as a new send', + async (settled) => { + const { delivery, send, markAsDelivered, markAsUndelivered } = harness({ + journal: idleJournal(), + dispatchState: 'pending', + settlement: Promise.resolve(settled) + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + expect(markAsUndelivered).toHaveBeenCalledWith(['m1']) + const first = send.mock.calls[0]![0].operationId + // A recorded send replays its verdict and never reaches the provider twice, so the + // re-point must mint: under the old id it would replay `unknown` and land nothing. + delivery.onJournalActivity('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[1]![0].operationId).not.toBe(first) + } + ) + + it('leaves a newer batch`s operation row alone when an older pointer settles', async () => { + let settle: (value: StructuredPointerSettlement) => void = () => {} + const { delivery, stored } = harness({ + journal: idleJournal(), + dispatchState: 'pending', + settlement: new Promise((resolve) => { + settle = resolve + }) + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const newer = { mailbox_handle: 'dispatch:d1', operation_id: 'newer' } + stored.set('dispatch:d1', newer) + settle('unknown') + await flush() + expect(stored.get('dispatch:d1')).toBe(newer) }) it('retries a rejected nudge on the next journal edge', async () => { diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index f16f2f08002..c21a9595dc4 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -45,8 +45,12 @@ type ParkedPointerDelivery = { reservedTypes: ReadonlySet | undefined } +/** How an admitted pointer finally settled; `unknown` when no turn is known to have run. */ +export type StructuredPointerSettlement = Exclude + export type StructuredPointerSendOutcome = - | { kind: 'sent'; state: StructuredDispatchState } + | { kind: 'sent'; state: StructuredPointerSettlement } + | { kind: 'sent'; state: 'pending'; settlement: Promise } | { kind: 'unattached' } export type StructuredMailboxPointerHost = { @@ -260,13 +264,52 @@ export class OrchestrationStructuredMailboxPointerDelivery< return } db.markAsDelivered(staged) + if (outcome.state === 'pending') { + // Admitted is a claim, not a turn: it is consumed with the echo or given back, never kept. + const operationId = operation.operationId + void outcome.settlement + .then((settled) => + this.settlePendingPointer(mailboxHandle, sessionId, staged, operationId, settled) + ) + .catch((error: unknown) => { + console.warn('[orchestration] could not settle a pending pointer', { + mailboxHandle, + error: error instanceof Error ? error.message : String(error) + }) + }) + return + } // The nudge landed as its own turn, so the next settle edge is the natural retry point for // anything that arrives while it runs. db.deleteStructuredPointerOperation(mailboxHandle) } + private settlePendingPointer( + mailboxHandle: string, + sessionId: string, + staged: readonly string[], + operationId: string, + settled: StructuredPointerSettlement + ): void { + const db = this.deps.getDb() + if (!db) { + return + } + // Only this send's row: a newer batch may have minted its own since. + if (db.getStructuredPointerOperation(mailboxHandle)?.operation_id === operationId) { + db.deleteStructuredPointerOperation(mailboxHandle) + } + if (settled === 'accepted') { + return + } + // The row is dropped above because a recorded send replays its verdict and never reaches the + // provider twice: re-pointing under this id would replay `unknown` instead of landing a turn. + db.markAsUndelivered([...staged]) + this.retain(mailboxHandle, sessionId, retainReasonForDispatch(settled), undefined) + } + /** - * No `markAsUndelivered` is owed: rows are marked delivered only once the host took the turn. + * Nothing is owed back here: rows are stamped only once the host admitted the turn. * * Every reason parks for the session's next journal edge. `unknown` may mean the nudge already * sits in the provider's input queue, so an immediate retry can stack duplicate nudges; diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index 56f83b1dac1..fcad0f4996d 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -80,8 +80,6 @@ describe('structured mailbox pointer host', () => { it.each([ ['accepted', 'accepted'], ['rejected', 'rejected'], - // Admitted and awaiting its echo: the turn exists, so the lane counts the rows as pointed. - ['pending', 'pending'], // A failed or unanswered call: the lane retains for the next journal edge. ['unknown', 'unknown'] ])('maps a %s submission to %s', async (dispatchState, expected) => { @@ -107,6 +105,51 @@ describe('structured mailbox pointer host', () => { expect(send.mock.calls[0]![1]!.retryUnknown).toBeUndefined() }) + it.each([ + [ + 'an echoed turn', + async () => ({ value: { submission: { dispatchState: 'accepted' } } }), + 'accepted' + ], + [ + 'a provider that died first', + async () => ({ value: { submission: { dispatchState: 'unknown' } } }), + 'unknown' + ], + ['a wait that gave up', async () => undefined, 'unknown'], + [ + 'a send that disappeared', + async () => { + throw new Error('gone') + }, + 'unknown' + ] + ] as const)('settles an admitted pointer from %s', async (_label, wait, expected) => { + // Admitted is only a claim: the lane consumes the rows on `accepted` and gives them back on + // anything else, so every way the wait can end must reach it as a verdict. + const waitForSendSettlement = vi.fn(wait) + hostRef.current = { + send: async () => ({ + ok: true, + value: { clientMessageId: 'op1', submission: { dispatchState: 'pending' } } + }), + waitForSendSettlement + } + const outcome = await createStructuredMailboxPointerHost().send({ + sessionId: 's1', + dispatchId: 'd1', + operationId: 'op1', + expectedRuntimeFence: 1, + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [] } + } as never) + expect(outcome).toMatchObject({ kind: 'sent', state: 'pending' }) + await expect( + outcome.kind === 'sent' && outcome.state === 'pending' ? outcome.settlement : null + ).resolves.toBe(expected) + expect(waitForSendSettlement).toHaveBeenCalledWith('s1', 'op1') + }) + it('scopes direct peer mail to the session when there is no dispatch to scope to', async () => { // Direct mail is addressed to the worker's own handle, so there may be no dispatch at all. // The ledger is keyed on (callerKey, operationId): a key derived from the session keeps that diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 00fb11b639a..908a23c45ca 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -8,7 +8,10 @@ import { AGENT_SESSION_NOT_ATTACHED } from '../../native-chat/agent-session-wire/structured-agent-session-mutation-admission' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import type { StructuredMailboxPointerHost } from './structured-mailbox-pointer-delivery' +import type { + StructuredMailboxPointerHost, + StructuredPointerSettlement +} from './structured-mailbox-pointer-delivery' import { structuredSessionCliInvocation } from './cli-command' import { structuredSessionGateFacts, @@ -127,13 +130,26 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo ? { kind: 'unattached' } : { kind: 'sent', state: 'rejected' } } - // `pending` is admitted and awaiting its echo: the turn exists, so the rows count as pointed. + // `pending` is admitted and awaiting its echo; its settlement says whether a turn ran. const state = result.value.submission.dispatchState - return { - kind: 'sent', - state: - state === 'accepted' || state === 'pending' || state === 'rejected' ? state : 'unknown' + if (state === 'pending') { + return { + kind: 'sent', + state, + settlement: host + .waitForSendSettlement(input.sessionId, result.value.clientMessageId) + .then( + (settled) => pointerSettlement(settled?.value.submission.dispatchState), + () => 'unknown' as const + ) + } } + return { kind: 'sent', state: pointerSettlement(state) } } } } + +/** No verdict — the wait gave up, or the generation closed with the turn unechoed — is unknown. */ +function pointerSettlement(state: string | undefined): StructuredPointerSettlement { + return state === 'accepted' || state === 'rejected' ? state : 'unknown' +} diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index d69a3e9883e..42517a3eb44 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -401,6 +401,32 @@ describe('a worker result reaches the structured chat that coordinates it', () = expect(acked.messages).not.toEqual(first.messages) }) + it('gives back a pointer whose provider died before the echo, and points it again', async () => { + // Admitted is not a turn: a provider that dies before echoing never ran the pointer, and a row + // left stamped "pointed" would never be pointed again — the last result would strand silently. + const chat = await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + await finishWorker(taskId) + await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toEqual([]), + WAIT + ) + + chat.handlers.onExit?.(new Error('provider died before the echo')) + await vi.waitFor( + () => expect(db.getUndeliveredUnreadMessages(`run:${runId}`, undefined, {})).toHaveLength(1), + WAIT + ) + + const before = codex.connections.length + runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) + await vi.waitFor(() => expect(codex.connections.length).toBe(before + 1), WAIT) + const revived = connectionFor(COORDINATOR) + await vi.waitFor(() => expect(revived.turns).toHaveLength(1), WAIT) + expect(revived.turns[0]!.text).toMatch(POINTER) + }) + it('wakes a coordinator the host evicted, and delivers once it is back', async () => { await openChat(COORDINATOR) const { taskId } = await coordinatorRunAndTask() From c3c5534ca4e8d5cd02bb443a65cbd42ae4ddc8ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:23:34 -0700 Subject: [PATCH 36/49] fix(native-chat): keep a committed /clear from failing on its replacement observer The observer runs after the clear's durable commit; a throwing adoption turned a committed clear into a failed RPC. It is now best-effort and logged, like the status feed's observer, and the successor's idle edges re-derive the adoption. --- .../structured-conversation-command.test.ts | 16 ++++++++++++++++ .../structured-conversation-command.ts | 13 +++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 1fe7e7a96ea..d6b174f4d3f 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -247,6 +247,22 @@ describe('host conversation commands', () => { expect(phaseAtReport).toBe('committed') }) + it('keeps a committed clear committed when the replacement observer throws', async () => { + // Adoption is bookkeeping behind a commit the user already has; the idle edge re-derives it. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + host.deps.onConversationReplaced = vi.fn(() => { + throw new Error('adoption failed') + }) + const result = await host.conversationCommand(caller, commandParams('clear')) + expect(result).toMatchObject({ ok: true, value: { phase: 'committed' } }) + expect(result.ok && result.value.replacementSessionId).toBeTruthy() + expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.phase).toBe('committed') + expect(warn).toHaveBeenCalledWith( + '[structured-conversation-command] replacement observer failed', + expect.any(Error) + ) + }) + it('reports no replacement for a compaction or a refused clear', async () => { const replaced = vi.fn() host.deps.onConversationReplaced = replaced diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index ce3dcefa9bb..c98ee014244 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -241,10 +241,15 @@ export function runStructuredConversationCommand( } await store.setConversationCommand(sessionId, ctx.fence, completed) if (completed.replacementSessionId) { - context.deps.onConversationReplaced?.({ - sessionId, - replacementSessionId: completed.replacementSessionId - }) + try { + context.deps.onConversationReplaced?.({ + sessionId, + replacementSessionId: completed.replacementSessionId + }) + } catch (error) { + // The clear already committed; an observer must not fail it. Idle edges re-derive. + console.warn('[structured-conversation-command] replacement observer failed', error) + } } return { ok: true, value: completed } } From 6dbbe28b03f5f48ca458dceda479f2afcf47cebe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:23:35 -0700 Subject: [PATCH 37/49] test(orchestration): pin /clear adoption across a chain of clears and a predecessor's check A session cleared twice before any edge hands both predecessors' Runs and mail to the end of the chain. A predecessor still live in the clear's tail reads none of the re-addressed mail: a session's direct mailbox is consume-on-read and holds no replayable batch. --- .../structured-session-mail-target.test.ts | 54 +++++++++++++++++++ .../structured-chat-coordinator-mail.test.ts | 36 +++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 3257e0f8570..8753ca45cdc 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -293,6 +293,60 @@ describe('a coordinator chat replaced by /clear', () => { expect(db.getRunRaw(runId)!.consumer_generation).toBe(generation + 1) }) + it('follows a chain of clears to the session at its end', () => { + // CHAT was cleared into MIDDLE, which was cleared into SUCCESSOR before any edge adopted. + const MIDDLE = 'clear-fedcba9876543210fedcba9876543210fedcba98' + const store = installStore(clearedInto(MIDDLE)) + store.records.set(MIDDLE, { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: MIDDLE, + runtimeKind: 'native', + claimStatus: 'released', + ownerProcess: null + }) + ), + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: SUCCESSOR, + operationId: 'op-2', + callerKey: 'caller', + phase: 'committed' + } + }) + store.records.set( + SUCCESSOR, + agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: SUCCESSOR, runtimeKind: 'native' }) + ) + ) + const runId = chatCoordinatedRun() + const fromFirst = db.insertMessage({ + from: 'term_peer', + to: CHAT_ACTOR, + subject: 'a', + type: 'status' + }) + const fromMiddle = db.insertMessage({ + from: 'term_peer', + to: `session:${MIDDLE}`, + subject: 'b', + type: 'status' + }) + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn(), + cancelMessageWaiters: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) + + expect(db.getRunRaw(runId)!.coordinator_actor).toBe(SUCCESSOR_ACTOR) + expect(db.getMessageById(fromFirst.id)!.to_handle).toBe(SUCCESSOR_ACTOR) + expect(db.getMessageById(fromMiddle.id)!.to_handle).toBe(SUCCESSOR_ACTOR) + expect(delivered).toEqual(expect.arrayContaining([`run:${runId}`, SUCCESSOR_ACTOR])) + }) + it('leaves Runs alone for a session nothing was cleared into', () => { installStore(chatRecord()) const runId = chatCoordinatedRun() diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 42517a3eb44..f1496d4c39d 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -513,6 +513,42 @@ describe('any live session is addressable by its id', () => { expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) }) + it('leaves a /clear predecessor nothing to read or acknowledge of its replacement`s mail', async () => { + // The clear's tail: the old chat is still live after adoption moved its unread mail. A direct + // mailbox is consume-on-read and holds no replayable batch, so the old session's `check` sees + // only its own address, and there is no delivery for a stale `--ack` to name. + const predecessor = await openChat(PEER_CHAT) + await openChat(COORDINATOR) + await call('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'read' + }) + await vi.waitFor(() => expect(predecessor.turns).toHaveLength(1), WAIT) + await settleTurn(PEER_CHAT, 0) + await expect(call('orchestration.check', {}, { sessionId: PEER_CHAT })).resolves.toMatchObject({ + count: 1 + }) + await call('orchestration.send', { + from: 'term_worker', + to: `session:${PEER_CHAT}`, + subject: 'moved' + }) + + db.readdressUnreadSessionMail(`session:${PEER_CHAT}`, `session:${COORDINATOR}`) + + await expect(call('orchestration.check', {}, { sessionId: PEER_CHAT })).resolves.toMatchObject({ + count: 0 + }) + expect(db.db.prepare('SELECT mailbox_handle FROM deliveries').all()).toEqual([]) + await expect( + call('orchestration.check', {}, { sessionId: COORDINATOR }) + ).resolves.toMatchObject({ + count: 1, + messages: [{ subject: 'moved' }] + }) + }) + it('refuses mail to a chat that was closed, before storing it', async () => { await openChat(PEER_CHAT) await host.setSessionTabVisibility(PEER_CHAT, false) From a25126420a4a169bc34e00dc801a58c89d4a53f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:25:53 -0700 Subject: [PATCH 38/49] test(orchestration): type the pending-settlement host test's send input without an assertion --- .../orchestration/structured-mailbox-pointer-host.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts index fcad0f4996d..8cc5412e1aa 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.test.ts @@ -142,7 +142,7 @@ describe('structured mailbox pointer host', () => { expectedRuntimeFence: 1, payloadFingerprint: 'fp', body: { kind: 'message', role: 'user', blocks: [] } - } as never) + }) expect(outcome).toMatchObject({ kind: 'sent', state: 'pending' }) await expect( outcome.kind === 'sent' && outcome.state === 'pending' ? outcome.settlement : null From 760ab6d718dd28d9341fc79125ba6250a3ea7fe2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:34:25 -0700 Subject: [PATCH 39/49] fix(orchestration): keep a chat's orchestration address across /clear by deriving its lineage A chat's orchestration address is now the first session of its /clear lineage. Every session of the lineage resolves to that one actor when it acts and when it is reached, and delivery goes to the lineage's live session. Nothing is rewritten at a clear, so the predecessor-adoption path is gone: the commit-edge observer, the idle-edge rebind, and the unread-mail re-address. That path could unbind the successor's own Run and orphan all but one Run of a chain. The idle edge now opens the orchestration database through its lazy getter and logs when it cannot, instead of reading a field that stays null until the first orchestration call after a restart. --- .../structured-agent-session-host-types.ts | 2 - .../structured-conversation-command.test.ts | 46 --- .../structured-conversation-command.ts | 11 - ...rca-runtime-get-pty-record-for-pane-key.ts | 21 +- .../runtime/orca-runtime-get-worktree-ps.ts | 3 - .../db/messages/direct-mailbox-routing.ts | 22 +- .../structured-session-mail-address.ts | 142 +++++++-- .../structured-session-mail-target.test.ts | 271 ++++++++++-------- .../structured-session-mail-target.ts | 111 ++----- .../messaging/recipient-routing.ts | 2 +- .../messaging/session-recipient.ts | 11 +- .../rpc/orchestration-session-caller.ts | 9 +- .../structured-agent-session-runtime.ts | 2 - .../structured-chat-coordinator-mail.test.ts | 134 ++++++--- ...ctured-session-mail-redrive-wiring.test.ts | 7 - 15 files changed, 412 insertions(+), 382 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 76dfacb5113..574ced3cc38 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -82,8 +82,6 @@ export type StructuredAgentSessionHostDeps = { summary: AgentSessionStatusSummary, options: { replay: boolean } ) => void - /** A committed `/clear` replaced `sessionId` with a new session that continues the conversation. */ - onConversationReplaced?: (input: { sessionId: string; replacementSessionId: string }) => void /** The agent-status store every held session's projection is written to and, on close, * removed from. Both production hosts pass one — the desktop and headless `orcad`; absent, * every reader of that store simply lists no structured session. */ diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index d6b174f4d3f..a261c6dd151 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -229,52 +229,6 @@ describe('host conversation commands', () => { expect(adapter.dispatch).not.toHaveBeenCalled() }) - it('reports the replacement only once the clear has committed', async () => { - // Orchestration moves the old session's Runs to its replacement at this edge; the - // replacement's own first status edge fires before the commit and cannot see it yet. - let phaseAtReport: string | undefined - const replaced = vi.fn(() => { - phaseAtReport = store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.phase - }) - host.deps.onConversationReplaced = replaced - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result.ok && result.value.replacementSessionId).toBeTruthy() - expect(replaced).toHaveBeenCalledTimes(1) - expect(replaced).toHaveBeenCalledWith({ - sessionId: HOST_TEST_SESSION, - replacementSessionId: result.ok ? result.value.replacementSessionId : undefined - }) - expect(phaseAtReport).toBe('committed') - }) - - it('keeps a committed clear committed when the replacement observer throws', async () => { - // Adoption is bookkeeping behind a commit the user already has; the idle edge re-derives it. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - host.deps.onConversationReplaced = vi.fn(() => { - throw new Error('adoption failed') - }) - const result = await host.conversationCommand(caller, commandParams('clear')) - expect(result).toMatchObject({ ok: true, value: { phase: 'committed' } }) - expect(result.ok && result.value.replacementSessionId).toBeTruthy() - expect(store.getRecord(HOST_TEST_SESSION)?.conversationCommand?.phase).toBe('committed') - expect(warn).toHaveBeenCalledWith( - '[structured-conversation-command] replacement observer failed', - expect.any(Error) - ) - }) - - it('reports no replacement for a compaction or a refused clear', async () => { - const replaced = vi.fn() - host.deps.onConversationReplaced = replaced - await host.conversationCommand(caller, commandParams('compact')) - vi.spyOn(host, 'attach').mockResolvedValueOnce({ - ok: false, - refusal: { code: 'structured_agent_session_unsupported', message: 'Unavailable' } - }) - await host.conversationCommand(caller, commandParams('clear')) - expect(replaced).not.toHaveBeenCalled() - }) - it('leaves the source usable when replacement creation is definitely refused', async () => { vi.spyOn(host, 'attach').mockResolvedValueOnce({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts index c98ee014244..022343d9407 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.ts @@ -240,17 +240,6 @@ export function runStructuredConversationCommand( ...(error ? { error: error.slice(0, 4096) } : {}) } await store.setConversationCommand(sessionId, ctx.fence, completed) - if (completed.replacementSessionId) { - try { - context.deps.onConversationReplaced?.({ - sessionId, - replacementSessionId: completed.replacementSessionId - }) - } catch (error) { - // The clear already committed; an observer must not fail it. Idle edges re-derive. - console.warn('[structured-conversation-command] replacement observer failed', error) - } - } return { ok: true, value: completed } } } diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index ae1c6246532..59b2a994a15 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -11,9 +11,9 @@ import { handleLessCoordinatorSessionId, findConnectedPtyBoundToSession, structuredSessionAddressTarget, + structuredSessionMailDestination, structuredSessionMailTarget, - structuredSessionIdleEdgeMail, - structuredSessionMailView + structuredSessionIdleEdgeMailboxes } from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, @@ -208,18 +208,19 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return } this.notifyStructuredSessionJournalActivity(summary.sessionId) - const idle = structuredSessionIdleEdgeMail(summary.sessionId, this._orchestrationDb) - idle.reboundRunIds.forEach((runId) => this.cancelMessageWaiters(`run:${runId}`)) - idle.mailboxes.forEach((mailbox) => this.deliverPendingMessagesForHandle(mailbox)) + const openDb = () => this.getOrchestrationDb() + const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox) + structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) } /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ getTerminalViewHandleForSession(sessionId: string): string | null { - const view = structuredSessionMailView(sessionId, this._orchestrationDb) - const pty = findConnectedPtyBoundToSession(this.ptysById.values(), sessionId) - return view === 'terminal-view' && pty?.paneKey - ? this.getTerminalHandleForPaneKey(pty.paneKey) - : null + const destination = structuredSessionMailDestination(sessionId, this._orchestrationDb) + const pty = + destination?.view === 'terminal-view' + ? findConnectedPtyBoundToSession(this.ptysById.values(), destination.sessionId) + : undefined + return pty?.paneKey ? this.getTerminalHandleForPaneKey(pty.paneKey) : null } /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 9630277f299..4f97af0de25 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -164,9 +164,6 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis resolveAgentAccountHome: (agent) => this.resolveStructuredAgentAccountHome(agent), // Structured chat has no agent CLI hooks, so this projection is what the first-work // workspace rename listens to instead of `agentStatus:set`. - // The replacement's first status edge precedes the commit, so the commit is the adoption edge. - onConversationReplaced: ({ replacementSessionId }) => - this.onStructuredSessionStatusForMail({ sessionId: replacementSessionId, status: null }), onSessionStatusChanged: (summary, options) => { this.onStructuredSessionStatusForMail(summary) void maybeAutoRenameWorkspaceOnFirstStructuredTurn( diff --git a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts index dc8ff67eef4..c1e5ee99562 100644 --- a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts @@ -179,25 +179,6 @@ export type DirectMailboxRoutingMethods = { routeUnreadDirectMessagesToDispatchMailbox: typeof routeUnreadDirectMessagesToDispatchMailbox routeUnreadDispatchMailboxToRunMailbox: typeof routeUnreadDispatchMailboxToRunMailbox getLatestUnreadMessageSequence: typeof getLatestUnreadMessageSequence - readdressUnreadSessionMail: typeof readdressUnreadSessionMail -} - -/** - * Moves a session's unread direct mail to the session that replaced it, as not yet pointed: the - * pointer, if any, went to a session nobody will run again. Read and acknowledgment state stay. - */ -export function readdressUnreadSessionMail( - this: OrchestrationDb, - fromAddress: string, - toAddress: string -): number { - const result = this.db - .prepare( - `UPDATE messages SET to_handle = ?, delivered_at = NULL - WHERE to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery'` - ) - .run(toAddress, fromAddress) - return Number(result.changes) } export function attachDirectMailboxRouting(ctor: { prototype: object }): void { @@ -208,7 +189,6 @@ export function attachDirectMailboxRouting(ctor: { prototype: object }): void { routeUnreadDirectMessagesToRunMailbox, routeUnreadDirectMessagesToDispatchMailbox, routeUnreadDispatchMailboxToRunMailbox, - getLatestUnreadMessageSequence, - readdressUnreadSessionMail + getLatestUnreadMessageSequence }) } diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index 1807d8e174d..eed4be40a8b 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -3,17 +3,28 @@ * told is its public address. Recipient routing and pointer delivery both read these rules off the * durable session record, so the two can never disagree about which sessions mail can reach. * + * The address names a conversation, not one session of it. `/clear` continues a chat in a new + * session, and the conversation keeps the address of its first session (its lineage root): a Run it + * coordinates, mail sent to it, and what it sends all stay under that one spelling, and any session + * of the lineage names it. Derived from the records every time; nothing is rewritten at a clear. + * * A released lease does not end a session. The host evicts a chat nobody is looking at 15s after * its last turn and hands its lease back, and mail must wake it again (resume on demand). For mail, - * a session has ended only when its chat was closed or `/clear` replaced it with another session. + * a conversation has ended only when its chat was closed. */ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { formatOrchestrationActor } from '../../../shared/orchestration-actor' -import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' +import { + formatOrchestrationActor, + parseOrchestrationActor +} from '../../../shared/orchestration-actor' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { + isRecordedStructuredWorkerSession, + resolveStructuredWorkerIdentityForSession +} from '../structured-worker-authority' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { OrchestrationDb } from './db' -import { isRecordedStructuredWorkerActor } from './db/schema/structured-worker-actor-backfill' import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' export type AgentSessionRecordReader = { @@ -23,6 +34,57 @@ export type AgentSessionRecordReader = { getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } } +export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +/** The session a committed `/clear` continued this one in, if any. */ +function clearedInto(record: AgentSessionRecord): string | null { + const command = record.conversationCommand + return command?.command === 'clear' && command.phase === 'committed' + ? (command.replacementSessionId ?? null) + : null +} + +export type StructuredSessionLineage = { + /** The conversation's first session: its address for as long as the conversation lasts. */ + rootSessionId: string + /** The session running the conversation now; null when the chain names a session with no record. */ + live: AgentSessionRecord | null +} + +/** Any session of a `/clear` lineage, resolved to the lineage's root and its live end. */ +export function structuredSessionLineage( + store: AgentSessionRecordReader, + sessionId: string +): StructuredSessionLineage { + const clearedFrom = new Map() + for (const record of store.listRecords()) { + const next = clearedInto(record) + if (next) { + clearedFrom.set(next, record.sessionId) + } + } + // A clear chain is acyclic by construction; the visited sets only bound a corrupt store. + let rootSessionId = sessionId + const earlier = new Set([sessionId]) + let prior = clearedFrom.get(rootSessionId) + while (prior && !earlier.has(prior)) { + earlier.add(prior) + rootSessionId = prior + prior = clearedFrom.get(rootSessionId) + } + let live = store.getRecord(sessionId) + const later = new Set([sessionId]) + let next = live ? clearedInto(live) : null + while (live && next && !later.has(next)) { + later.add(next) + live = store.getRecord(next) + next = live ? clearedInto(live) : null + } + return { rootSessionId, live } +} + export type OrcaAgentSessionLookup = | { kind: 'found'; record: AgentSessionRecord } /** The id is a provider's own session id, which rotates on `/clear`; this names the Orca id. */ @@ -48,42 +110,35 @@ export function lookupOrcaAgentSession( } export type StructuredSessionMailReach = - | { kind: 'reachable' } + /** `session` is the conversation's live session, the one its mail reaches now. */ + | { kind: 'reachable'; session: AgentSessionRecord } | { kind: 'other-host' } - | { kind: 'ended'; reason: 'closed' | 'worker-identity-lost' } - | { kind: 'ended'; reason: 'replaced'; replacementSessionId: string } + | { kind: 'ended'; reason: 'closed' | 'worker-identity-lost' | 'continuation-missing' } +/** Whether mail to `record`'s conversation can reach the session that runs it now. */ export function structuredSessionMailReach( store: AgentSessionRecordReader, record: AgentSessionRecord, db: OrchestrationDb | null | undefined ): StructuredSessionMailReach { - if (!structuredWorkerHostScope(record.location)) { + const live = structuredSessionLineage(store, record.sessionId).live + if (!live) { + return { kind: 'ended', reason: 'continuation-missing' } + } + if (!structuredWorkerHostScope(live.location)) { return { kind: 'other-host' } } - const actor = formatOrchestrationActor({ kind: 'session', id: record.sessionId }) - if ( - db && - !resolveStructuredWorkerIdentityForSession(record.sessionId, db) && - isRecordedStructuredWorkerActor(db.db, actor) - ) { + const identity = sessionOrchestrationIdentity(live.sessionId, db, store) + if (db && hasLostStructuredWorkerIdentity(identity, db)) { // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. return { kind: 'ended', reason: 'worker-identity-lost' } } - const command = record.conversationCommand - if ( - command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId - ) { - return { kind: 'ended', reason: 'replaced', replacementSessionId: command.replacementSessionId } - } const visible = store.getVisibleSessionTabIndex?.() - if (visible?.present && !visible.sessionIds.includes(record.sessionId)) { + if (visible?.present && !visible.sessionIds.includes(live.sessionId)) { // Why: reviving a chat the user closed would run turns nobody can see; its mail waits instead. return { kind: 'ended', reason: 'closed' } } - return { kind: 'reachable' } + return { kind: 'reachable', session: live } } /** @@ -100,16 +155,26 @@ export function structuredSessionDeliveryView( } /** - * Who a session is to orchestration: its actor, plus the handle and pane a structured worker was - * minted. The caller resolver and mail delivery both take it from here, so a session is matched the - * same way whether it is sending, checking, or being delivered to. + * Who a session is to orchestration: its conversation's actor, plus the handle and pane a structured + * worker was minted. The caller resolver and mail delivery both take it from here, so a session is + * matched the same way whether it is sending, checking, or being delivered to. Without a record + * store there is no lineage to read, and the id stands for itself. */ export function sessionOrchestrationIdentity( sessionId: string, - db: OrchestrationDb | null | undefined + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() ): OrchestrationCallerIdentity & { actor: string } { - const actor = formatOrchestrationActor({ kind: 'session', id: sessionId }) - const worker = resolveStructuredWorkerIdentityForSession(sessionId, db) + const lineage = store ? structuredSessionLineage(store, sessionId) : null + const actor = formatOrchestrationActor({ + kind: 'session', + id: lineage?.rootSessionId ?? sessionId + }) + // A worker identity is minted for one session, so it is looked up on the one running now. + const worker = resolveStructuredWorkerIdentityForSession( + lineage?.live?.sessionId ?? sessionId, + db + ) return { actor, address: worker?.handle ?? actor, @@ -117,3 +182,20 @@ export function sessionOrchestrationIdentity( paneKey: worker?.paneKey ?? null } } + +/** + * A structured worker whose worker identity this host no longer has. It may not act handle-less (that + * would split one worker into two identities), so mail to it could never be read either. Checked on + * the conversation, whose root session is the one a Dispatch assigned. + */ +export function hasLostStructuredWorkerIdentity( + identity: OrchestrationCallerIdentity & { actor: string }, + db: OrchestrationDb +): boolean { + const conversation = parseOrchestrationActor(identity.actor) + return ( + identity.terminalHandle === null && + conversation !== null && + isRecordedStructuredWorkerSession(conversation.id, db) + ) +} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 8753ca45cdc..d57d355fb44 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -15,6 +15,7 @@ const { OrcaRuntimeWithGetPtyRecordForPaneKey } = await import('../orca-runtime-get-pty-record-for-pane-key') const { OrchestrationDb } = await import('./db') const { agentSessionPtyWriteGate } = await import('../agent-session-pty-write-gate') +const { sessionOrchestrationIdentity } = await import('./structured-session-mail-address') const CHAT = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const CHAT_ACTOR = `session:${CHAT}` @@ -112,7 +113,7 @@ describe('a Run whose coordinator is a chat (a session actor, no handle)', () => expect(probe().target(`run:${runId}`)).toBeNull() }) - it('does not deliver to a chat that was closed, replaced by /clear, or runs on another host', () => { + it('does not deliver to a chat that was closed, cleared into no known session, or runs on another host', () => { const runId = chatCoordinatedRun() installStore(chatRecord(), false) expect(probe().target(`run:${runId}`)).toBeNull() @@ -218,143 +219,173 @@ describe('the idle edge of a structured session', () => { }) }) -describe('a coordinator chat replaced by /clear', () => { +describe('the idle edge after a restart, before any orchestration call', () => { + it('opens the orchestration database itself, so mail stored before the restart is redriven', () => { + // The strand this pins: the edge read the raw database field, null until the first + // orchestration RPC opened it, so a restarted chat's idle edges silently redrove nothing. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + const delivered: string[] = [] + probe({ + _orchestrationDb: null, + getOrchestrationDb: () => db, + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([`run:${runId}`]) + }) + + it('says so when the database cannot be opened, instead of skipping silently', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deliver = vi.fn() + probe({ + _orchestrationDb: null, + getOrchestrationDb: () => { + throw new Error('userData unavailable') + }, + deliverPendingMessagesForHandle: deliver, + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(deliver).not.toHaveBeenCalled() + expect(warn).toHaveBeenCalledWith( + '[orchestration] skipped a structured session mail edge: no database', + { sessionId: CHAT, error: 'userData unavailable' } + ) + warn.mockRestore() + }) +}) + +describe('a coordinator chat continued by /clear', () => { + const MIDDLE = 'clear-fedcba9876543210fedcba9876543210fedcba98' const SUCCESSOR = 'clear-0123456789abcdef0123456789abcdef01234567' - const SUCCESSOR_ACTOR = `session:${SUCCESSOR}` - - function clearedInto(successor: string): AgentSessionRecord { - return chatRecord( - { claimStatus: 'released', ownerProcess: null }, - { - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: successor, - operationId: 'op', - callerKey: 'caller', - phase: 'committed' - } - } + + function sessionRecord(sessionId: string, clearedInto?: string): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeKind: 'native', + ...(clearedInto ? { claimStatus: 'released' as const, ownerProcess: null } : {}) + }) ) + return clearedInto + ? { + ...record, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: clearedInto, + operationId: `op-${sessionId}`, + callerKey: 'caller', + phase: 'committed' + } + } + : record } - it("hands the predecessor's Runs and unread mail to the session that replaced it", () => { - // The strand this pins: the Run still named the pre-/clear session, so its results were pointed - // at nobody and the new chat — acting as its own new id — could not see the Run at all. - const store = installStore(clearedInto(SUCCESSOR)) - const successorRecord = agentSessionRecordFixture( - agentSessionLeaseFixture({ sessionId: SUCCESSOR, runtimeKind: 'native' }) + /** CHAT cleared into each of `chain` in turn; the last one is live and its tab is open. */ + function installLineage(...chain: string[]): void { + const lineage = [CHAT, ...chain] + const store = installStore(null) + lineage.forEach((sessionId, index) => + store.records.set(sessionId, sessionRecord(sessionId, lineage[index + 1])) ) - store.records.set(SUCCESSOR, successorRecord) - store.visible.sessionIds.push(SUCCESSOR) - const runId = chatCoordinatedRun() - const generation = db.getRunRaw(runId)!.consumer_generation - const direct = db.insertMessage({ - from: 'term_peer', - to: CHAT_ACTOR, - subject: 'hi', - type: 'status' - }) - db.markAsDelivered([direct.id]) + store.visible.sessionIds.push(lineage.at(-1)!) + } + + function idleEdge(sessionId: string): string[] { const delivered: string[] = [] - const cancelMessageWaiters = vi.fn() - const runtime = probe({ + probe({ deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), - notifyStructuredSessionJournalActivity: vi.fn(), - cancelMessageWaiters + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + return delivered + } + + function runCreatedBy(sessionId: string): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: sessionOrchestrationIdentity(sessionId, db).actor + }).id + } + + it("never unbinds the successor's own Run", () => { + // The strand this pins: the successor's own run-create, then its idle edge rebinding the + // predecessor's Run through an exclusive bind, which unbound the Run the successor created. + installLineage(SUCCESSOR) + chatCoordinatedRun() + const ownRun = runCreatedBy(SUCCESSOR) + const generation = db.getRunRaw(ownRun)!.consumer_generation + + expect(idleEdge(SUCCESSOR)).toContain(`run:${ownRun}`) + idleEdge(SUCCESSOR) + + const successor = sessionOrchestrationIdentity(SUCCESSOR, db) + expect(db.getRunRaw(ownRun)).toMatchObject({ + coordinator_actor: successor.actor, + consumer_generation: generation }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(ownRun) + }) - runtime.onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: null }) + it('keeps a Run bound, unrewritten, across a chain of clears, and delivers it to the live end', () => { + installLineage(MIDDLE) + const runId = chatCoordinatedRun() + const generation = db.getRunRaw(runId)!.consumer_generation + idleEdge(MIDDLE) + installLineage(MIDDLE, SUCCESSOR) + expect(idleEdge(SUCCESSOR)).toContain(`run:${runId}`) - // The ordinary bind: a coordinator takeover, with its generation bump. expect(db.getRunRaw(runId)).toMatchObject({ - coordinator_handle: null, - coordinator_actor: SUCCESSOR_ACTOR, - consumer_generation: generation + 1 - }) - expect( - db.getCurrentRunForCoordinator({ - actor: SUCCESSOR_ACTOR, - terminalHandle: null, - paneKey: null - })?.id - ).toBe(runId) - expect(cancelMessageWaiters).toHaveBeenCalledWith(`run:${runId}`) - // Re-addressed and not yet pointed: its old pointer went to a session nobody runs. - expect(db.getMessageById(direct.id)).toMatchObject({ - to_handle: SUCCESSOR_ACTOR, - delivered_at: null, - read: 0 + coordinator_actor: CHAT_ACTOR, + consumer_generation: generation }) - expect(delivered).toEqual(expect.arrayContaining([`run:${runId}`, SUCCESSOR_ACTOR])) - - // Idempotent: a later edge finds nothing left to adopt. - runtime.onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) - expect(db.getRunRaw(runId)!.consumer_generation).toBe(generation + 1) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(db.getCurrentRunForCoordinator(sessionOrchestrationIdentity(member, db))?.id).toBe( + runId + ) + } + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) }) - it('follows a chain of clears to the session at its end', () => { - // CHAT was cleared into MIDDLE, which was cleared into SUCCESSOR before any edge adopted. - const MIDDLE = 'clear-fedcba9876543210fedcba9876543210fedcba98' - const store = installStore(clearedInto(MIDDLE)) - store.records.set(MIDDLE, { - ...agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId: MIDDLE, - runtimeKind: 'native', - claimStatus: 'released', - ownerProcess: null - }) - ), - conversationCommand: { - command: 'clear', - state: 'completed', - replacementSessionId: SUCCESSOR, - operationId: 'op-2', - callerKey: 'caller', - phase: 'committed' - } + it('keeps the Run a middle session created bound after the next clear', () => { + // The chain strand: adopting each predecessor's Run in turn unbound all but the last adopted. + installLineage(MIDDLE) + runCreatedBy(CHAT) + const middleRun = runCreatedBy(MIDDLE) + const generation = db.getRunRaw(middleRun)!.consumer_generation + installLineage(MIDDLE, SUCCESSOR) + idleEdge(SUCCESSOR) + + const successor = sessionOrchestrationIdentity(SUCCESSOR, db) + expect(db.getRunRaw(middleRun)).toMatchObject({ + coordinator_actor: successor.actor, + consumer_generation: generation }) - store.records.set( - SUCCESSOR, - agentSessionRecordFixture( - agentSessionLeaseFixture({ sessionId: SUCCESSOR, runtimeKind: 'native' }) - ) - ) - const runId = chatCoordinatedRun() - const fromFirst = db.insertMessage({ + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(middleRun) + }) + + it('reaches the live session through any spelling of the conversation, and stores one', () => { + installLineage(MIDDLE, SUCCESSOR) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(sessionOrchestrationIdentity(member, db)).toMatchObject({ + actor: CHAT_ACTOR, + address: CHAT_ACTOR + }) + expect(probe().target(`session:${member}`)).toEqual({ + sessionId: SUCCESSOR, + dispatchId: null + }) + } + const direct = db.insertMessage({ from: 'term_peer', to: CHAT_ACTOR, - subject: 'a', - type: 'status' - }) - const fromMiddle = db.insertMessage({ - from: 'term_peer', - to: `session:${MIDDLE}`, - subject: 'b', + subject: 'hi', type: 'status' }) - const delivered: string[] = [] - probe({ - deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), - notifyStructuredSessionJournalActivity: vi.fn(), - cancelMessageWaiters: vi.fn() - }).onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) - - expect(db.getRunRaw(runId)!.coordinator_actor).toBe(SUCCESSOR_ACTOR) - expect(db.getMessageById(fromFirst.id)!.to_handle).toBe(SUCCESSOR_ACTOR) - expect(db.getMessageById(fromMiddle.id)!.to_handle).toBe(SUCCESSOR_ACTOR) - expect(delivered).toEqual(expect.arrayContaining([`run:${runId}`, SUCCESSOR_ACTOR])) - }) - - it('leaves Runs alone for a session nothing was cleared into', () => { - installStore(chatRecord()) - const runId = chatCoordinatedRun() - probe({ - deliverPendingMessagesForHandle: vi.fn(), - notifyStructuredSessionJournalActivity: vi.fn(), - cancelMessageWaiters: vi.fn() - }).onStructuredSessionStatusForMail({ sessionId: SUCCESSOR, status: 'idle' }) - expect(db.getRunRaw(runId)!.coordinator_actor).toBe(CHAT_ACTOR) + expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ACTOR]) + expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ACTOR, read: 0 }) }) }) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 6f10d67962c..edc84ab784f 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -8,15 +8,12 @@ * Exactly one view answers for a session at a time, so the two lanes never both claim a mailbox. */ -import { - formatOrchestrationActor, - parseOrchestrationActor -} from '../../../shared/orchestration-actor' -import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import { parseOrchestrationActor } from '../../../shared/orchestration-actor' import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' import { + readAgentSessionRecordStore, sessionOrchestrationIdentity, structuredSessionDeliveryView, structuredSessionMailReach, @@ -37,10 +34,6 @@ export function findConnectedPtyBoundToSession OrchestrationDb +): string[] { + let db: OrchestrationDb + try { + db = openDb() + } catch (error) { + console.warn('[orchestration] skipped a structured session mail edge: no database', { + sessionId, + error: error instanceof Error ? error.message : String(error) + }) + return [] } - const reboundRunIds = adoptClearedPredecessorMail(sessionId, db) - return { reboundRunIds, mailboxes: structuredSessionOwnedMailboxes(sessionId, db) } + return structuredSessionOwnedMailboxes(sessionId, db) } diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index eba0f7451c5..a6823b94f60 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -3,7 +3,7 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' -import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-mail-target' +import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-mail-address' import { readSessionRecipient, refuseUndeliverableSessionRecipient, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index f7fc293b4a4..f9a31a4c84e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -3,9 +3,10 @@ * this host can be addressed, not only one that coordinates a Run: an agent's id is its public * address, and a user telling one agent to message another's id is a supported workflow. * - * Mail that no Run or Dispatch owns is stored at `session:` and pointed at the session as a - * turn. A released lease is not a refusal (delivery resumes an evicted chat); a closed or replaced - * chat, another host, and an unknown id are, before anything is stored. + * Mail that no Run or Dispatch owns is stored at the conversation's `session:` and pointed + * at its live session as a turn, so any session of a `/clear` lineage is a valid spelling. A + * released lease is not a refusal (delivery resumes an evicted chat); a closed chat, another host, + * and an unknown id are, before anything is stored. */ import { @@ -98,8 +99,8 @@ export function refuseUndeliverableSessionRecipient( return { code: CODES.notLive, message: - reach.reason === 'replaced' - ? `Agent session ${sessionId} was cleared and continues as session:${reach.replacementSessionId}. Send there instead. No message was sent.` + reach.reason === 'continuation-missing' + ? `Agent session ${sessionId} was cleared, and this host has no record of the session that continues it. No message was sent.` : reach.reason === 'worker-identity-lost' ? `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it can never read that mail. No message was sent.` : `Agent session ${sessionId} has ended: its chat was closed. No message was sent.` diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 4329fe2bbc2..507fa1ac8c4 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -19,7 +19,6 @@ import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lea import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' -import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../orca-runtime' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' @@ -153,10 +152,10 @@ async function readSessionRecord( runtime: OrcaRuntimeService, sessionId: string ): Promise { - let store: ReturnType + let store: ReturnType try { await runtime.ensureStructuredAgentSessionHost() - store = sessionRecordStore() + store = readAgentSessionRecordStore() } catch { store = null } @@ -185,10 +184,6 @@ async function readSessionRecord( ) } -function sessionRecordStore(): AgentSessionRecordReader | null { - return getStructuredAgentSessionHost()?.deps.store ?? null -} - function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { if (!structuredWorkerHostScope(record.location)) { throw hostBoundary( diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index bd34e30251c..a51da3f1c3c 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -102,7 +102,6 @@ export type StructuredAgentSessionRuntimeDeps = { /** Every structured-session status projection, for host-side reactions such as the first-work * workspace rename that CLI agents get from their hooks. */ onSessionStatusChanged?: StructuredAgentSessionHostDeps['onSessionStatusChanged'] - onConversationReplaced?: StructuredAgentSessionHostDeps['onConversationReplaced'] /** The agent-status store; see `StructuredAgentSessionHostDeps.statusSink`. */ statusSink?: StructuredAgentSessionHostDeps['statusSink'] reapOrphanChildren?: typeof stopOrphanAgentSessionChildren @@ -312,7 +311,6 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), - ...(deps.onConversationReplaced ? { onConversationReplaced: deps.onConversationReplaced } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index f1496d4c39d..ae77bb96c5d 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -282,6 +282,36 @@ async function coordinatorRunAndTask(): Promise<{ runId: string; taskId: string return { runId, taskId: idOf(task.task) } } +/** `/clear` as the chat surface runs it: the conversation continues in a new session. */ +async function clearChat(sessionId: string): Promise { + const command = 'clear' as const + const cleared = await host.conversationCommand( + { callerKey: 'test-surface' }, + { + command, + envelope: { + sessionId, + clientOperationId: operationId(), + expectedRuntimeFence: host.deps.store.getRecord(sessionId)!.lease.runtimeFence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.conversationCommand', + sessionId, + fields: { command } + }) + } + } + ) + const successor = cleared.ok ? cleared.value.replacementSessionId : undefined + if (!successor) { + throw new Error(`clear failed: ${JSON.stringify(cleared)}`) + } + // The surface swaps the tab over to the session that continues the chat. + await host.setSessionTabVisibility(sessionId, false) + await host.setSessionTabVisibility(successor, true) + threadBySession.set(successor, codex.connections.at(-1)!.threadId!) + return successor +} + beforeEach(async () => { operations = 0 root = await mkdtemp(join(tmpdir(), 'orca-structured-coordinator-mail-')) @@ -493,6 +523,74 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) }) +describe('a /clear keeps the chat its orchestration address', () => { + it("delivers the conversation's Run to the session that continues it, and acts as it", async () => { + await openChat(COORDINATOR) + const { runId, taskId } = await coordinatorRunAndTask() + const generation = db.getRunRaw(runId)!.consumer_generation + const successor = await clearChat(COORDINATOR) + const next = connectionFor(successor) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + await finishWorker(taskId) + await vi.waitFor(() => expect(next.turns).toHaveLength(1), WAIT) + expect(next.turns[0]!.text).toMatch(POINTER) + await settleTurn(successor, 0) + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + runId, + count: 1, + messages: [{ type: 'worker_done' }] + }) + // Nothing was rewritten: the Run is bound exactly as the first session bound it. + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_actor: `session:${COORDINATOR}`, + consumer_generation: generation + }) + }) + + it("stores the successor's own Run under the conversation's address, through a chain of clears", async () => { + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const created = await call( + 'orchestration.runCreate', + { objective: 'next' }, + { sessionId: middle } + ) + const runId = idOf(created.run) + expect(db.getRunRaw(runId)!.coordinator_actor).toBe(`session:${COORDINATOR}`) + const successor = await clearChat(middle) + runtime.onStructuredSessionStatusForMail({ sessionId: successor, status: 'idle' }) + + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: runId } }) + expect(db.getRunRaw(runId)!.coordinator_actor).toBe(`session:${COORDINATOR}`) + }) + + it('lands mail sent to any session of the conversation in the live one', async () => { + await openChat(PEER_CHAT) + const middle = await clearChat(PEER_CHAT) + const successor = await clearChat(middle) + const next = connectionFor(successor) + + for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { + const sent = await call('orchestration.send', { + from: 'term_worker', + to: `session:${spelling}`, + subject: `ping ${index}` + }) + expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + await vi.waitFor(() => expect(next.turns).toHaveLength(index + 1), WAIT) + await settleTurn(successor, index) + } + await expect(call('orchestration.check', {}, { sessionId: successor })).resolves.toMatchObject({ + count: 3 + }) + }) +}) + describe('any live session is addressable by its id', () => { it('lands mail sent to `session:` as a turn in that chat, which a flagless check reads', async () => { const peer = await openChat(PEER_CHAT) @@ -513,42 +611,6 @@ describe('any live session is addressable by its id', () => { expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) }) - it('leaves a /clear predecessor nothing to read or acknowledge of its replacement`s mail', async () => { - // The clear's tail: the old chat is still live after adoption moved its unread mail. A direct - // mailbox is consume-on-read and holds no replayable batch, so the old session's `check` sees - // only its own address, and there is no delivery for a stale `--ack` to name. - const predecessor = await openChat(PEER_CHAT) - await openChat(COORDINATOR) - await call('orchestration.send', { - from: 'term_worker', - to: `session:${PEER_CHAT}`, - subject: 'read' - }) - await vi.waitFor(() => expect(predecessor.turns).toHaveLength(1), WAIT) - await settleTurn(PEER_CHAT, 0) - await expect(call('orchestration.check', {}, { sessionId: PEER_CHAT })).resolves.toMatchObject({ - count: 1 - }) - await call('orchestration.send', { - from: 'term_worker', - to: `session:${PEER_CHAT}`, - subject: 'moved' - }) - - db.readdressUnreadSessionMail(`session:${PEER_CHAT}`, `session:${COORDINATOR}`) - - await expect(call('orchestration.check', {}, { sessionId: PEER_CHAT })).resolves.toMatchObject({ - count: 0 - }) - expect(db.db.prepare('SELECT mailbox_handle FROM deliveries').all()).toEqual([]) - await expect( - call('orchestration.check', {}, { sessionId: COORDINATOR }) - ).resolves.toMatchObject({ - count: 1, - messages: [{ subject: 'moved' }] - }) - }) - it('refuses mail to a chat that was closed, before storing it', async () => { await openChat(PEER_CHAT) await host.setSessionTabVisibility(PEER_CHAT, false) diff --git a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts index 1eb0d364aea..64bd1db62d0 100644 --- a/src/main/runtime/structured-session-mail-redrive-wiring.test.ts +++ b/src/main/runtime/structured-session-mail-redrive-wiring.test.ts @@ -42,12 +42,5 @@ describe("the runtime's own structured host install", () => { // The same callback's rename half needs a store this bare runtime does not have. } expect(redrive).toHaveBeenCalledWith(summary) - - // A committed `/clear` is the replacement's adoption edge: its own first status edge came first. - installed.deps?.onConversationReplaced?.({ - sessionId: 'claude_1234abcd', - replacementSessionId: 'clear-1234abcd' - }) - expect(redrive).toHaveBeenCalledWith({ sessionId: 'clear-1234abcd', status: null }) }) }) From fdba7c0b265bb07ec9f82ef3a99b1e15f9725c95 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:34:43 -0700 Subject: [PATCH 40/49] fix(orchestration): give back a structured pointer claim an earlier process left open A pointer the host admits as pending stamps its batch delivered, and only an in-memory settlement waiter gives it back if no turn ran. A process that died in that window left the batch stamped with nothing to release it, so the last result on that mailbox was never pointed again. When the database opens, every surviving pointer operation row is from an earlier process: its stamped batch is found by the row's fingerprint, released, and the row dropped, before the restored-mailbox scan points it again. A row whose batch was never stamped keeps its id for the retry. --- ...rca-runtime-get-pty-record-for-pane-key.ts | 5 +- .../structured-pointer-operation-store.ts | 29 +++++ .../structured-pointer-claim-restore.test.ts | 106 ++++++++++++++++++ .../structured-pointer-claim-restore.ts | 68 +++++++++++ 4 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts create mode 100644 src/main/runtime/orchestration/structured-pointer-claim-restore.ts diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 59b2a994a15..40f605ef5f4 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -7,6 +7,7 @@ import { recognizeAgentProcess } from '../../shared/agent-process-recognition' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { structuredWorkerIdentities } from './structured-worker-identity' import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' +import { releaseRestoredStructuredPointerClaims } from './orchestration/structured-pointer-claim-restore' import { handleLessCoordinatorSessionId, findConnectedPtyBoundToSession, @@ -308,9 +309,11 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM } protected scheduleRestoredMessageRepoints(): void { + const db = this._orchestrationDb + // Before the scan, so a released batch is found as undelivered like any other. + releaseRestoredStructuredPointerClaims(db) let handles: Set try { - const db = this._orchestrationDb // Pointer-phase rows are excluded from the undelivered scan, so they need their own. handles = new Set([ ...(db?.getUndeliveredUnreadMailboxHandles?.() ?? []), diff --git a/src/main/runtime/orchestration/db/messages/structured-pointer-operation-store.ts b/src/main/runtime/orchestration/db/messages/structured-pointer-operation-store.ts index 54b51b18e6e..faf473afdb6 100644 --- a/src/main/runtime/orchestration/db/messages/structured-pointer-operation-store.ts +++ b/src/main/runtime/orchestration/db/messages/structured-pointer-operation-store.ts @@ -49,7 +49,34 @@ export function deleteStructuredPointerOperation( .run(mailboxHandle) } +export function listStructuredPointerOperations( + this: OrchestrationDb +): StructuredPointerOperationRow[] { + const rows = this.db.prepare('SELECT * FROM structured_pointer_operations').all() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return rows as StructuredPointerOperationRow[] +} + +/** Unread rows a pointer was sent for, in the order a pointer batch lists them. */ +export function getPointedUnreadMessages( + this: OrchestrationDb, + mailboxHandle: string +): { id: string; delivered_at: string }[] { + const rows = this.db + .prepare( + `SELECT id, delivered_at FROM messages + WHERE to_handle = ? AND read = 0 AND delivered_at IS NOT NULL + AND delivery_contract = 'current_delivery' + ORDER BY sequence` + ) + .all(mailboxHandle) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: both columns are TEXT and the WHERE clause excludes a NULL `delivered_at`. + return rows as { id: string; delivered_at: string }[] +} + export type StructuredPointerOperationStoreMethods = { + listStructuredPointerOperations: typeof listStructuredPointerOperations + getPointedUnreadMessages: typeof getPointedUnreadMessages getStructuredPointerOperation: typeof getStructuredPointerOperation putStructuredPointerOperation: typeof putStructuredPointerOperation deleteStructuredPointerOperation: typeof deleteStructuredPointerOperation @@ -57,6 +84,8 @@ export type StructuredPointerOperationStoreMethods = { export function attachStructuredPointerOperationStore(ctor: { prototype: object }): void { Object.assign(ctor.prototype, { + listStructuredPointerOperations, + getPointedUnreadMessages, getStructuredPointerOperation, putStructuredPointerOperation, deleteStructuredPointerOperation diff --git a/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts new file mode 100644 index 00000000000..631c0a57bf4 --- /dev/null +++ b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' +import { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orca-runtime-get-pty-record-for-pane-key' +import { releaseRestoredStructuredPointerClaims } from './structured-pointer-claim-restore' +import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' + +const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const BODY = { kind: 'message' as const, role: 'user' as const, blocks: [] } + +let db: OrchestrationDb +let mailbox: string + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + mailbox = `run:${db.createRun({ objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, coordinatorActor: `session:${SESSION}` }).id}` +}) + +afterEach(() => { + db.close() +}) + +function mail(count: number): string[] { + return Array.from( + { length: count }, + (_, index) => + db.insertMessage({ from: 'term_worker', to: mailbox, subject: `m${index}`, type: 'status' }) + .id + ) +} + +/** What the structured lane leaves when the host admits a pointer as pending: stamped, row kept. */ +function pendingPointer(ids: string[], stamp: string): void { + resolveStructuredPointerOperation({ + db, + mailboxHandle: mailbox, + sessionId: SESSION, + body: BODY, + messageIds: ids + }) + db.markAsDelivered(ids) + db.db + .prepare(`UPDATE messages SET delivered_at = ? WHERE id IN (${ids.map(() => '?').join(',')})`) + .run(stamp, ...ids) +} + +function undelivered(): string[] { + return db.getUndeliveredUnreadMessages(mailbox, undefined, {}).map((message) => message.id) +} + +describe('a structured pointer claim left by an earlier process', () => { + it('gives its batch back and drops the claim, so the mailbox is pointed again', () => { + // The strand this pins: a pointer admitted as pending stamps its batch delivered, and only an + // in-memory waiter gave it back; after a restart nothing ever pointed that mail again. + const batch = mail(2) + pendingPointer(batch, '2026-09-24 10:00:00') + + expect(releaseRestoredStructuredPointerClaims(db)).toEqual([mailbox]) + expect(undelivered()).toEqual(batch) + expect(db.getStructuredPointerOperation(mailbox)).toBeUndefined() + }) + + it('gives back only its own batch when an earlier pointer was stamped in the same second', () => { + const earlier = mail(1) + db.markAsDelivered(earlier) + db.db + .prepare('UPDATE messages SET delivered_at = ? WHERE id = ?') + .run('2026-09-24 10:00:00', earlier[0]) + const batch = mail(2) + pendingPointer(batch, '2026-09-24 10:00:00') + + releaseRestoredStructuredPointerClaims(db) + expect(undelivered()).toEqual(batch) + }) + + it('keeps a claim whose batch was never stamped: its id is the retry key', () => { + const batch = mail(1) + resolveStructuredPointerOperation({ + db, + mailboxHandle: mailbox, + sessionId: SESSION, + body: BODY, + messageIds: batch + }) + + expect(releaseRestoredStructuredPointerClaims(db)).toEqual([]) + expect(db.getStructuredPointerOperation(mailbox)).toBeDefined() + }) +}) + +describe('opening the orchestration database', () => { + it('releases restored structured pointer claims before scanning for undelivered mail', () => { + const batch = mail(1) + pendingPointer(batch, '2026-09-24 10:00:00') + const scheduled: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a prototype-only probe; every field the method reads is assigned below. + const runtime = Object.assign(Object.create(OrcaRuntimeWithGetPtyRecordForPaneKey.prototype), { + _orchestrationDb: db, + mailPointerRepointScheduler: { schedule: (handle: string) => scheduled.push(handle) } + }) as { scheduleRestoredMessageRepoints: () => void } + + runtime.scheduleRestoredMessageRepoints() + + expect(undelivered()).toEqual(batch) + expect(scheduled).toEqual([mailbox]) + }) +}) diff --git a/src/main/runtime/orchestration/structured-pointer-claim-restore.ts b/src/main/runtime/orchestration/structured-pointer-claim-restore.ts new file mode 100644 index 00000000000..b2a411e40fa --- /dev/null +++ b/src/main/runtime/orchestration/structured-pointer-claim-restore.ts @@ -0,0 +1,68 @@ +/** + * Gives back structured pointer claims an earlier process left open. + * + * A pointer the host admits as `pending` stamps its batch delivered, and an in-memory settlement + * waiter either keeps that (the turn ran) or gives it back, then drops the durable operation row. + * A row that is still here when the database opens was minted by an earlier process: its waiter + * died with it, and a restart settles its unechoed send without a turn, so nothing would ever give + * the batch back. The claim belongs to the process that made it, as a PTY pointer's belongs to its + * pane's process, and it is released on that mismatch so the mailbox is pointed again. + * + * The batch is found by the row's fingerprint among the mailbox's stamped, unread rows; one + * statement stamped it, so it shares one `delivered_at`. A row whose batch was never stamped (its + * send was never admitted) is left alone: its id is the idempotency key the retry reuses. + */ + +import type { OrchestrationDb } from './db' +import type { StructuredPointerOperationRow } from './db/messages/structured-pointer-operation-store' +import { structuredPointerBatchFingerprint } from './structured-pointer-operation-id' + +/** Run when the database opens; returns the mailboxes whose claim was released. */ +export function releaseRestoredStructuredPointerClaims(db: OrchestrationDb | null): string[] { + // A partial test double may lack the store. + if (!db?.listStructuredPointerOperations) { + return [] + } + const released: string[] = [] + try { + for (const operation of db.listStructuredPointerOperations()) { + const batch = stampedBatch(db.getPointedUnreadMessages(operation.mailbox_handle), operation) + if (batch) { + db.markAsUndelivered(batch) + db.deleteStructuredPointerOperation(operation.mailbox_handle) + released.push(operation.mailbox_handle) + } + } + } catch (error) { + console.warn('[orchestration] failed to release restored structured pointer claims', error) + } + if (released.length > 0) { + console.info('[orchestration] released structured pointer claims left by an earlier run', { + mailboxes: released.length + }) + } + return released +} + +function stampedBatch( + pointed: readonly { id: string; delivered_at: string }[], + operation: StructuredPointerOperationRow +): string[] | null { + const byStamp = new Map() + for (const row of pointed) { + byStamp.set(row.delivered_at, [...(byStamp.get(row.delivered_at) ?? []), row.id]) + } + for (const ids of byStamp.values()) { + // An earlier batch stamped in the same second shares the stamp and sorts first. + for (let start = 0; start < ids.length; start += 1) { + const candidate = ids.slice(start) + if ( + structuredPointerBatchFingerprint(operation.session_id, candidate) === + operation.batch_fingerprint + ) { + return candidate + } + } + } + return null +} From 955de22aeaeb49704d7803f4df9f75e361e635dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:40:08 -0700 Subject: [PATCH 41/49] test(orchestration): pin that a cleared chat's sends carry its conversation's address --- .../runtime/structured-chat-coordinator-mail.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index ae77bb96c5d..9d25c25b8dc 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -567,6 +567,15 @@ describe('a /clear keeps the chat its orchestration address', () => { call('orchestration.runCurrent', {}, { sessionId: successor }) ).resolves.toMatchObject({ run: { id: runId } }) expect(db.getRunRaw(runId)!.coordinator_actor).toBe(`session:${COORDINATOR}`) + // What it sends carries the same address. + await openChat(PEER_CHAT) + await expect( + call( + 'orchestration.send', + { to: `session:${PEER_CHAT}`, subject: 'hi' }, + { sessionId: successor } + ) + ).resolves.toMatchObject({ message: { from_handle: `session:${COORDINATOR}` } }) }) it('lands mail sent to any session of the conversation in the live one', async () => { From 543b6c328937811a66af80001569a7b47cffa2fc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:49:33 -0700 Subject: [PATCH 42/49] fix(orchestration): open the orchestration database at an idle edge only when it already exists A profile with no orchestration database has no mail to redrive, so a structured chat's idle edge no longer creates one, and says nothing. An existing database is still opened lazily there. --- .../orca-runtime-automation-operations.ts | 15 +++- ...rca-runtime-get-pty-record-for-pane-key.ts | 2 +- .../structured-session-mail-target.test.ts | 81 ++++++++++++++++--- .../structured-session-mail-target.ts | 11 +-- 4 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/main/runtime/orca-runtime-automation-operations.ts b/src/main/runtime/orca-runtime-automation-operations.ts index fe65979ed1e..5d005e6fb90 100644 --- a/src/main/runtime/orca-runtime-automation-operations.ts +++ b/src/main/runtime/orca-runtime-automation-operations.ts @@ -17,6 +17,7 @@ import { } from '../../shared/automation-list-scope' import { OrchestrationDb } from './orchestration/db' import { join } from 'node:path' +import { existsSync } from 'node:fs' import { getAppEnvironment } from '../../shared/app-environment' import type { LegacyWorkerTerminalRecoveryPlan } from './orchestration/orchestration-legacy-worker-terminal-recovery' import type { LegacyWorkerTerminalRecoveryResult } from './runtime-legacy-worker-terminal-recovery-types' @@ -151,14 +152,24 @@ export class OrcaRuntimeWithAutomationOperations extends OrcaRuntimeWithPtyForeg // to inject an in-memory DB without touching the filesystem. getOrchestrationDb(): OrchestrationDb { if (!this._orchestrationDb) { - const dbPath = join(getAppEnvironment().getPath('userData'), 'orchestration.db') - this._orchestrationDb = new OrchestrationDb(dbPath) + this._orchestrationDb = new OrchestrationDb(this.orchestrationDbPath()) this.ensureOrchestrationFederationRelay() this.scheduleRestoredMessageRepoints() } return this._orchestrationDb } + /** The database, opened only if it already exists: a profile without one has no mail to redrive. */ + getExistingOrchestrationDb(): OrchestrationDb | null { + return this._orchestrationDb || existsSync(this.orchestrationDbPath()) + ? this.getOrchestrationDb() + : null + } + + private orchestrationDbPath(): string { + return join(getAppEnvironment().getPath('userData'), 'orchestration.db') + } + setOrchestrationDb(db: OrchestrationDb): void { this.orchestrationFederation.resetForDatabaseChange() this.mailPointerRepointScheduler.clear() diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 40f605ef5f4..6cd8665a51f 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -209,7 +209,7 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM return } this.notifyStructuredSessionJournalActivity(summary.sessionId) - const openDb = () => this.getOrchestrationDb() + const openDb = () => this.getExistingOrchestrationDb() const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox) structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) } diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index d57d355fb44..9346dadff39 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -1,4 +1,13 @@ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getAppEnvironment, + hasAppEnvironment, + setAppEnvironment, + type AppEnvironment +} from '../../../shared/app-environment' import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' import { agentSessionLeaseFixture, @@ -220,19 +229,73 @@ describe('the idle edge of a structured session', () => { }) describe('the idle edge after a restart, before any orchestration call', () => { - it('opens the orchestration database itself, so mail stored before the restart is redriven', () => { + let userData: string + let previousEnvironment: AppEnvironment | null + + beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'orca-idle-edge-db-')) + previousEnvironment = hasAppEnvironment() ? getAppEnvironment() : null + setAppEnvironment({ + getPath: () => userData, + getAppPath: () => userData, + getVersion: () => '0.0.0-test', + isPackaged: () => false, + onWillQuit: () => {}, + exit: () => {}, + getAppMetrics: () => [] + }) + }) + + afterEach(() => { + if (previousEnvironment) { + setAppEnvironment(previousEnvironment) + } + rmSync(userData, { recursive: true, force: true }) + }) + + /** A runtime whose database has not been opened in this process yet. */ + function restarted(delivered: string[]): MailTargetProbe { + return probe({ + _orchestrationDb: null, + ensureOrchestrationFederationRelay: vi.fn(), + scheduleRestoredMessageRepoints: vi.fn(), + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }) + } + + it('opens an existing orchestration database itself, so mail stored before the restart is redriven', () => { // The strand this pins: the edge read the raw database field, null until the first // orchestration RPC opened it, so a restarted chat's idle edges silently redrove nothing. installStore(chatRecord()) - const runId = chatCoordinatedRun() + const stored = new OrchestrationDb(join(userData, 'orchestration.db')) + const runId = stored.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_ACTOR + }).id + stored.close() const delivered: string[] = [] - probe({ - _orchestrationDb: null, - getOrchestrationDb: () => db, - deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), - notifyStructuredSessionJournalActivity: vi.fn() - }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + const runtime = restarted(delivered) + + runtime.onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + expect(delivered).toEqual([`run:${runId}`]) + runtime.getOrchestrationDb().close() + }) + + it('creates no database for a profile that never orchestrated, and says nothing', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const delivered: string[] = [] + + restarted(delivered).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) + + expect(delivered).toEqual([]) + expect(existsSync(join(userData, 'orchestration.db'))).toBe(false) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() }) it('says so when the database cannot be opened, instead of skipping silently', () => { @@ -241,7 +304,7 @@ describe('the idle edge after a restart, before any orchestration call', () => { const deliver = vi.fn() probe({ _orchestrationDb: null, - getOrchestrationDb: () => { + getExistingOrchestrationDb: () => { throw new Error('userData unavailable') }, deliverPendingMessagesForHandle: deliver, diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index edc84ab784f..8fff5a62418 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -104,13 +104,14 @@ export function structuredSessionOwnedMailboxes(sessionId: string, db: Orchestra return mailboxes } -/** The mailboxes a session's idle edge re-derives, opening the database if nothing has yet: after a - * restart this edge is what redrives mail stored before it. */ +/** The mailboxes a session's idle edge re-derives, opening an existing database if nothing has + * yet: after a restart this edge is what redrives mail stored before it. No database file means + * no mail, so `openDb` answers null and nothing is created. */ export function structuredSessionIdleEdgeMailboxes( sessionId: string, - openDb: () => OrchestrationDb + openDb: () => OrchestrationDb | null ): string[] { - let db: OrchestrationDb + let db: OrchestrationDb | null try { db = openDb() } catch (error) { @@ -120,5 +121,5 @@ export function structuredSessionIdleEdgeMailboxes( }) return [] } - return structuredSessionOwnedMailboxes(sessionId, db) + return db ? structuredSessionOwnedMailboxes(sessionId, db) : [] } From c1550d431c16ab1b4cb03951a45511513b374f6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:47:59 -0700 Subject: [PATCH 43/49] fix(orchestration): read a chat-coordinated Run's session through the actor's generation A handle-less Run names its coordinator session only by an actor that still counts at the Run's current generation, the same rule every other binding read uses; an actor an older binary's rebind or unbind left behind no longer routes the Run's mail. A test pins that a cleared chat's run-create and run-use write its conversation's root actor at the Run's current generation. --- .../structured-session-mail-target.test.ts | 10 ++++++ .../structured-session-mail-target.ts | 15 ++++++--- .../structured-chat-coordinator-mail.test.ts | 31 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 9346dadff39..d5197cbe19c 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -149,6 +149,16 @@ describe('a Run whose coordinator is a chat (a session actor, no handle)', () => expect(probe().target(`run:${runId}`)).toBeNull() }) + it('does not deliver to an actor written at an earlier generation of the Run', () => { + // An older binary's rebind or unbind bumps the generation and leaves the actor behind. + installStore(chatRecord()) + const runId = chatCoordinatedRun() + db.db + .prepare('UPDATE runs SET consumer_generation = consumer_generation + 1 WHERE id = ?') + .run(runId) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + it('ignores an actor left beside a PTY handle; the handle owns the Run', () => { installStore(chatRecord()) const runId = db.createRun({ diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 8fff5a62418..287ea240be9 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -11,6 +11,7 @@ import { parseOrchestrationActor } from '../../../shared/orchestration-actor' import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' +import { currentRunCoordinatorActor } from './db/runs/run-coordinator-actor' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' import { readAgentSessionRecordStore, @@ -36,16 +37,22 @@ export function findConnectedPtyBoundToSession + run: Pick< + RunRow, + | 'coordinator_handle' + | 'coordinator_actor' + | 'coordinator_actor_generation' + | 'consumer_generation' + > ): string | null { if (run.coordinator_handle !== null) { return null } - return parseOrchestrationActor(run.coordinator_actor)?.id ?? null + return parseOrchestrationActor(currentRunCoordinatorActor(run))?.id ?? null } /** diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 9d25c25b8dc..0b047fc6de8 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,6 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' +import { currentRunCoordinatorActor } from './orchestration/db/runs/run-coordinator-actor' import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' @@ -578,6 +579,36 @@ describe('a /clear keeps the chat its orchestration address', () => { ).resolves.toMatchObject({ message: { from_handle: `session:${COORDINATOR}` } }) }) + it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { + const root = `session:${COORDINATOR}` + const boundActor = (runId: string): string | null => + currentRunCoordinatorActor(db.getRunRaw(runId)!) + await openChat(COORDINATOR) + const middle = await clearChat(COORDINATOR) + const first = idOf( + (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run + ) + expect(db.getRunRaw(first)).toMatchObject({ + coordinator_actor: root, + coordinator_actor_generation: db.getRunRaw(first)!.consumer_generation + }) + expect(boundActor(first)).toBe(root) + const second = idOf( + (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run + ) + expect(boundActor(first)).toBeNull() + + const successor = await clearChat(middle) + await call('orchestration.runUse', { id: first }, { sessionId: successor }) + const rebound = db.getRunRaw(first)! + expect(rebound.coordinator_actor).toBe(root) + expect(rebound.coordinator_actor_generation).toBe(rebound.consumer_generation) + expect(boundActor(second)).toBeNull() + await expect( + call('orchestration.runCurrent', {}, { sessionId: successor }) + ).resolves.toMatchObject({ run: { id: first } }) + }) + it('lands mail sent to any session of the conversation in the live one', async () => { await openChat(PEER_CHAT) const middle = await clearChat(PEER_CHAT) From 6231eef4d82454d7834356c36143e491c45be5f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:44:57 -0700 Subject: [PATCH 44/49] refactor(orchestration): address a session's conversation by its bare root Orca session id Carries the Orca session id rename into coordinator delivery. A session's orchestration identity holds its conversation's bare Orca session id, the /clear lineage root's, and its mail address is derived from it by formatOrcaSessionAddress; a Run a cleared chat creates or uses stores that bare root id. A session recipient carries the parsed id and its address as distinct types, a handle-less coordinator's session is read through currentRunCoordinatorOrcaSessionId, and session ids read from records or PTY bindings are checked with isOrcaSessionId before they become an identity. The session address prefix comes from the one exported constant. --- .../runtime/orchestration/mailbox-owner.ts | 5 +- .../structured-pointer-claim-restore.test.ts | 5 +- .../structured-session-mail-address.ts | 43 ++++++++-------- .../structured-session-mail-target.test.ts | 50 ++++++++++--------- .../structured-session-mail-target.ts | 29 +++++++---- ...uctured-session-terminal-view-mail.test.ts | 8 +-- .../messaging/session-recipient.ts | 37 +++++++------- .../orchestration-session-recipient.test.ts | 12 ++--- .../structured-chat-coordinator-mail.test.ts | 28 +++++------ 9 files changed, 117 insertions(+), 100 deletions(-) diff --git a/src/main/runtime/orchestration/mailbox-owner.ts b/src/main/runtime/orchestration/mailbox-owner.ts index 217d858121e..60ac5b9e52d 100644 --- a/src/main/runtime/orchestration/mailbox-owner.ts +++ b/src/main/runtime/orchestration/mailbox-owner.ts @@ -1,4 +1,5 @@ import type { AgentStatus } from '../../../shared/agent-detection' +import { isOrcaSessionId } from '../../../shared/orca-session-address' import type { OrchestrationDb } from './db' import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' import { sessionOrchestrationIdentity } from './structured-session-mail-address' @@ -112,7 +113,9 @@ export class OrchestrationMailboxOwner { leaf: OrchestrationMailboxLeaf ): OrchestrationCallerIdentity | null { const sessionId = leaf.ptyId ? this.deps.getBoundSessionIdForPty?.(leaf.ptyId) : null - return sessionId ? sessionOrchestrationIdentity(sessionId, db) : null + return sessionId && isOrcaSessionId(sessionId) + ? sessionOrchestrationIdentity(sessionId, db) + : null } routeForeignDirectMessages(leaf: OrchestrationMailboxLeaf): RoutedOrchestrationMailbox[] { diff --git a/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts index 631c0a57bf4..c373a4b0720 100644 --- a/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts +++ b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts @@ -3,8 +3,9 @@ import { OrchestrationDb } from './db' import { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orca-runtime-get-pty-record-for-pane-key' import { releaseRestoredStructuredPointerClaims } from './structured-pointer-claim-restore' import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' -const SESSION = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +const SESSION = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') const BODY = { kind: 'message' as const, role: 'user' as const, blocks: [] } let db: OrchestrationDb @@ -12,7 +13,7 @@ let mailbox: string beforeEach(() => { db = new OrchestrationDb(':memory:') - mailbox = `run:${db.createRun({ objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, coordinatorActor: `session:${SESSION}` }).id}` + mailbox = `run:${db.createRun({ objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, coordinatorOrcaSessionId: SESSION }).id}` }) afterEach(() => { diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index eed4be40a8b..4a987d77512 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -15,9 +15,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { - formatOrchestrationActor, - parseOrchestrationActor -} from '../../../shared/orchestration-actor' + formatOrcaSessionAddress, + isOrcaSessionId, + type OrcaSessionId +} from '../../../shared/orca-session-address' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import { isRecordedStructuredWorkerSession, @@ -128,8 +129,10 @@ export function structuredSessionMailReach( if (!structuredWorkerHostScope(live.location)) { return { kind: 'other-host' } } - const identity = sessionOrchestrationIdentity(live.sessionId, db, store) - if (db && hasLostStructuredWorkerIdentity(identity, db)) { + const identity = isOrcaSessionId(live.sessionId) + ? sessionOrchestrationIdentity(live.sessionId, db, store) + : null + if (db && identity && hasLostStructuredWorkerIdentity(identity, db)) { // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. return { kind: 'ended', reason: 'worker-identity-lost' } } @@ -155,29 +158,28 @@ export function structuredSessionDeliveryView( } /** - * Who a session is to orchestration: its conversation's actor, plus the handle and pane a structured - * worker was minted. The caller resolver and mail delivery both take it from here, so a session is - * matched the same way whether it is sending, checking, or being delivered to. Without a record - * store there is no lineage to read, and the id stands for itself. + * Who a session is to orchestration: its conversation's Orca session id (the lineage root's), plus + * the handle and pane a structured worker was minted. The caller resolver and mail delivery both take + * it from here, so a session is matched the same way whether it is sending, checking, or being + * delivered to. Without a record store there is no lineage to read, and the id stands for itself. */ export function sessionOrchestrationIdentity( - sessionId: string, + sessionId: OrcaSessionId, db: OrchestrationDb | null | undefined, store: AgentSessionRecordReader | null = readAgentSessionRecordStore() -): OrchestrationCallerIdentity & { actor: string } { +): OrchestrationCallerIdentity & { orcaSessionId: OrcaSessionId } { const lineage = store ? structuredSessionLineage(store, sessionId) : null - const actor = formatOrchestrationActor({ - kind: 'session', - id: lineage?.rootSessionId ?? sessionId - }) + const root = lineage?.rootSessionId ?? sessionId + // Record ids are minted as Orca session ids; one that is not cannot name the conversation. + const orcaSessionId = isOrcaSessionId(root) ? root : sessionId // A worker identity is minted for one session, so it is looked up on the one running now. const worker = resolveStructuredWorkerIdentityForSession( lineage?.live?.sessionId ?? sessionId, db ) return { - actor, - address: worker?.handle ?? actor, + orcaSessionId, + address: worker?.handle ?? formatOrcaSessionAddress(orcaSessionId), terminalHandle: worker?.handle ?? null, paneKey: worker?.paneKey ?? null } @@ -189,13 +191,12 @@ export function sessionOrchestrationIdentity( * the conversation, whose root session is the one a Dispatch assigned. */ export function hasLostStructuredWorkerIdentity( - identity: OrchestrationCallerIdentity & { actor: string }, + identity: OrchestrationCallerIdentity, db: OrchestrationDb ): boolean { - const conversation = parseOrchestrationActor(identity.actor) return ( identity.terminalHandle === null && - conversation !== null && - isRecordedStructuredWorkerSession(conversation.id, db) + identity.orcaSessionId !== null && + isRecordedStructuredWorkerSession(identity.orcaSessionId, db) ) } diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index d5197cbe19c..7ceca93908b 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -13,6 +13,8 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' const hostRef: { current: unknown } = { current: null } @@ -26,8 +28,8 @@ const { OrchestrationDb } = await import('./db') const { agentSessionPtyWriteGate } = await import('../agent-session-pty-write-gate') const { sessionOrchestrationIdentity } = await import('./structured-session-mail-address') -const CHAT = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' -const CHAT_ACTOR = `session:${CHAT}` +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT) const TERMINAL_VIEW_PANE = 'tab_view:77777777-7777-4777-8777-777777777777' /** The real methods through the real prototype chain; a re-declared copy would pin nothing. */ @@ -87,7 +89,7 @@ function chatCoordinatedRun(): string { objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_ACTOR + coordinatorOrcaSessionId: CHAT }).id } @@ -101,7 +103,7 @@ afterEach(() => { db.close() }) -describe('a Run whose coordinator is a chat (a session actor, no handle)', () => { +describe('a Run whose coordinator is a chat (an Orca session id, no handle)', () => { it('delivers its mailbox to that session', () => { // The defect this pins: the resolver read only `coordinator_handle`, which a chat never has, so // neither lane claimed the Run mailbox and a worker's result never reached the chat. @@ -149,8 +151,8 @@ describe('a Run whose coordinator is a chat (a session actor, no handle)', () => expect(probe().target(`run:${runId}`)).toBeNull() }) - it('does not deliver to an actor written at an earlier generation of the Run', () => { - // An older binary's rebind or unbind bumps the generation and leaves the actor behind. + it('does not deliver to an Orca session id written at an earlier generation of the Run', () => { + // An older binary's rebind or unbind bumps the generation and leaves the id behind. installStore(chatRecord()) const runId = chatCoordinatedRun() db.db @@ -159,13 +161,13 @@ describe('a Run whose coordinator is a chat (a session actor, no handle)', () => expect(probe().target(`run:${runId}`)).toBeNull() }) - it('ignores an actor left beside a PTY handle; the handle owns the Run', () => { + it('ignores an Orca session id left beside a PTY handle; the handle owns the Run', () => { installStore(chatRecord()) const runId = db.createRun({ objective: 'o', coordinatorHandle: 'term_coord', coordinatorPaneKey: 'tab_c:11111111-1111-4111-8111-111111111111', - coordinatorActor: CHAT_ACTOR + coordinatorOrcaSessionId: CHAT }).id expect(probe().target(`run:${runId}`)).toBeNull() }) @@ -174,7 +176,7 @@ describe('a Run whose coordinator is a chat (a session actor, no handle)', () => describe('a session addressed directly', () => { it('owns its `session:` mailbox', () => { installStore(chatRecord()) - expect(probe().target(CHAT_ACTOR)).toEqual({ sessionId: CHAT, dispatchId: null }) + expect(probe().target(CHAT_ADDRESS)).toEqual({ sessionId: CHAT, dispatchId: null }) }) it('claims nothing for a malformed session address', () => { @@ -227,14 +229,14 @@ describe('the idle edge of a structured session', () => { it('points direct mail at a session that coordinates nothing', () => { installStore(chatRecord()) - db.insertMessage({ from: 'term_peer', to: CHAT_ACTOR, subject: 'hi', type: 'status' }) + db.insertMessage({ from: 'term_peer', to: CHAT_ADDRESS, subject: 'hi', type: 'status' }) const delivered: string[] = [] probe({ deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), notifyStructuredSessionJournalActivity: vi.fn(), cancelMessageWaiters: vi.fn() }).onStructuredSessionStatusForMail({ sessionId: CHAT, status: 'idle' }) - expect(delivered).toEqual([CHAT_ACTOR]) + expect(delivered).toEqual([CHAT_ADDRESS]) }) }) @@ -283,7 +285,7 @@ describe('the idle edge after a restart, before any orchestration call', () => { objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_ACTOR + coordinatorOrcaSessionId: CHAT }).id stored.close() const delivered: string[] = [] @@ -330,8 +332,8 @@ describe('the idle edge after a restart, before any orchestration call', () => { }) describe('a coordinator chat continued by /clear', () => { - const MIDDLE = 'clear-fedcba9876543210fedcba9876543210fedcba98' - const SUCCESSOR = 'clear-0123456789abcdef0123456789abcdef01234567' + const MIDDLE = testOrcaSessionId('clear-fedcba9876543210fedcba9876543210fedcba98') + const SUCCESSOR = testOrcaSessionId('clear-0123456789abcdef0123456789abcdef01234567') function sessionRecord(sessionId: string, clearedInto?: string): AgentSessionRecord { const record = agentSessionRecordFixture( @@ -375,12 +377,12 @@ describe('a coordinator chat continued by /clear', () => { return delivered } - function runCreatedBy(sessionId: string): string { + function runCreatedBy(sessionId: OrcaSessionId): string { return db.createRun({ objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: sessionOrchestrationIdentity(sessionId, db).actor + coordinatorOrcaSessionId: sessionOrchestrationIdentity(sessionId, db).orcaSessionId }).id } @@ -397,7 +399,7 @@ describe('a coordinator chat continued by /clear', () => { const successor = sessionOrchestrationIdentity(SUCCESSOR, db) expect(db.getRunRaw(ownRun)).toMatchObject({ - coordinator_actor: successor.actor, + coordinator_orca_session_id: successor.orcaSessionId, consumer_generation: generation }) expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(ownRun) @@ -412,7 +414,7 @@ describe('a coordinator chat continued by /clear', () => { expect(idleEdge(SUCCESSOR)).toContain(`run:${runId}`) expect(db.getRunRaw(runId)).toMatchObject({ - coordinator_actor: CHAT_ACTOR, + coordinator_orca_session_id: CHAT, consumer_generation: generation }) for (const member of [CHAT, MIDDLE, SUCCESSOR]) { @@ -434,7 +436,7 @@ describe('a coordinator chat continued by /clear', () => { const successor = sessionOrchestrationIdentity(SUCCESSOR, db) expect(db.getRunRaw(middleRun)).toMatchObject({ - coordinator_actor: successor.actor, + coordinator_orca_session_id: successor.orcaSessionId, consumer_generation: generation }) expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(middleRun) @@ -444,8 +446,8 @@ describe('a coordinator chat continued by /clear', () => { installLineage(MIDDLE, SUCCESSOR) for (const member of [CHAT, MIDDLE, SUCCESSOR]) { expect(sessionOrchestrationIdentity(member, db)).toMatchObject({ - actor: CHAT_ACTOR, - address: CHAT_ACTOR + orcaSessionId: CHAT, + address: CHAT_ADDRESS }) expect(probe().target(`session:${member}`)).toEqual({ sessionId: SUCCESSOR, @@ -454,11 +456,11 @@ describe('a coordinator chat continued by /clear', () => { } const direct = db.insertMessage({ from: 'term_peer', - to: CHAT_ACTOR, + to: CHAT_ADDRESS, subject: 'hi', type: 'status' }) - expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ACTOR]) - expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ACTOR, read: 0 }) + expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ADDRESS]) + expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ADDRESS, read: 0 }) }) }) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 287ea240be9..2e538717fa6 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -8,10 +8,15 @@ * Exactly one view answers for a session at a time, so the two lanes never both claim a mailbox. */ -import { parseOrchestrationActor } from '../../../shared/orchestration-actor' +import { + ORCA_SESSION_ADDRESS_PREFIX, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} from '../../../shared/orca-session-address' import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' -import { currentRunCoordinatorActor } from './db/runs/run-coordinator-actor' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' import { readAgentSessionRecordStore, @@ -38,21 +43,22 @@ export function findConnectedPtyBoundToSession -): string | null { +): OrcaSessionId | null { if (run.coordinator_handle !== null) { return null } - return parseOrchestrationActor(currentRunCoordinatorActor(run))?.id ?? null + return currentRunCoordinatorOrcaSessionId(run) } /** @@ -90,11 +96,11 @@ export function structuredSessionAddressTarget( mailboxHandle: string, db: OrchestrationDb | null | undefined ): StructuredPointerTarget | null | undefined { - if (!mailboxHandle.startsWith('session:')) { + if (!mailboxHandle.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { return undefined } - const actor = parseOrchestrationActor(mailboxHandle) - return actor ? structuredSessionMailTarget(actor.id, db) : null + const sessionId = parseOrcaSessionAddress(mailboxHandle) + return sessionId ? structuredSessionMailTarget(sessionId, db) : null } /** @@ -103,6 +109,9 @@ export function structuredSessionAddressTarget( * while the session could not take it (closed, evicted, in the other view) is found again. */ export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { + if (!isOrcaSessionId(sessionId)) { + return [] + } const identity = sessionOrchestrationIdentity(sessionId, db) const mailboxes = db.runsBoundToCoordinator(identity).map((run) => `run:${run.id}`) if (db.getUnreadDirectMessageTypes(identity.address).length > 0) { diff --git a/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts b/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts index 5e1c697d849..16a9f4f4f40 100644 --- a/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts +++ b/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OrchestrationDb } from './db' import { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' import { OrchestrationMailboxOwner, type OrchestrationMailboxLeaf } from './mailbox-owner' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' -const CHAT = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' -const CHAT_ACTOR = `session:${CHAT}` +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') const VIEW_TAB = 'tab_view' const VIEW_LEAF = '77777777-7777-4777-8777-777777777777' @@ -46,13 +46,13 @@ function chatRun(): string { objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_ACTOR + coordinatorOrcaSessionId: CHAT }).id } describe("a chat's terminal view reads the chat's mail", () => { it('owns the Run the chat coordinates, which no pane binding names', () => { - // The CLI in a terminal view acts as the session, so the Run is bound by actor and the pane + // The CLI in a terminal view acts as the session, so the Run is bound by Orca session id and the pane // path finds nothing: without this the idle edge of the view never pointed coordinator mail. const runId = chatRun() expect(owner(CHAT).resolve(viewLeaf)).toBe(`run:${runId}`) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index f9a31a4c84e..483d388955f 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -10,10 +10,13 @@ */ import { - formatOrchestrationActor, - parseOrchestrationActor, - sessionOrchestrationActor -} from '../../../../../../shared/orchestration-actor' + ORCA_SESSION_ADDRESS_PREFIX, + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionAddress, + type OrcaSessionId +} from '../../../../../../shared/orca-session-address' // The caller codes, reused: each names the same fact about a session, whichever side of the mail it is on. import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-caller-codes' import { @@ -23,10 +26,8 @@ import { } from '../../../../orchestration/structured-session-mail-address' import type { OrchestrationDb } from '../../../../orchestration/db' -const SESSION_PREFIX = 'session:' - -/** `actor` is the `session:` spelling; the mailbox mail lands in is the session's identity address. */ -export type SessionRecipient = { sessionId: string; actor: string } +/** `address` is the named session's own spelling; the mailbox mail lands in is its identity address. */ +export type SessionRecipient = { sessionId: OrcaSessionId; address: OrcaSessionAddress } export type SessionRecipientRefusal = { code: (typeof CODES)[keyof typeof CODES] @@ -35,7 +36,7 @@ export type SessionRecipientRefusal = { /** Whether a recipient may name a session, so the caller can install the session host first. */ export function mayNameSession(recipient: string): boolean { - return recipient.startsWith(SESSION_PREFIX) || sessionOrchestrationActor(recipient) !== null + return recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX) || isOrcaSessionId(recipient) } /** @@ -46,22 +47,22 @@ export function readSessionRecipient( recipient: string, store: AgentSessionRecordReader | null ): SessionRecipient | SessionRecipientRefusal | null { - if (recipient.startsWith(SESSION_PREFIX)) { - const actor = parseOrchestrationActor(recipient) - return actor - ? { sessionId: actor.id, actor: formatOrchestrationActor(actor) } + if (recipient.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + const sessionId = parseOrcaSessionAddress(recipient) + return sessionId + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } : { code: CODES.unknown, message: `${recipient} does not name an Orca agent session id. No message was sent.` } } - const actor = sessionOrchestrationActor(recipient) - const found = actor && store ? lookupOrcaAgentSession(store, actor.id) : null + const sessionId = isOrcaSessionId(recipient) ? recipient : null + const found = sessionId && store ? lookupOrcaAgentSession(store, sessionId) : null if (found?.kind === 'provider-id') { return providerIdRefusal(recipient, found.orcaSessionId) } - return actor && found?.kind === 'found' - ? { sessionId: actor.id, actor: formatOrchestrationActor(actor) } + return sessionId && found?.kind === 'found' + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } : null } @@ -112,6 +113,6 @@ export function refuseUndeliverableSessionRecipient( function providerIdRefusal(id: string, orcaSessionId: string): SessionRecipientRefusal { return { code: CODES.providerId, - message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is session:${orcaSessionId}; use that instead. No message was sent.` + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is ${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}; use that instead. No message was sent.` } } diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index 4e63ee0af5d..e21d31556d5 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -6,7 +6,7 @@ import { structuredWorkerProcessIncarnation } from '../structured-worker-identity' import { - ACTOR_X, + ADDRESS_X, createSessionCallerHarness, idOf, isRecord, @@ -67,15 +67,15 @@ describe('a send addressed to an agent session', () => { // The refusal this replaces: "Terminal session: has no live pane or durable Run/Dispatch // mailbox." An agent's id is its public address, coordinator or not. const deliver = vi.spyOn(h.runtime, 'deliverPendingMessagesForHandle') - const sent = await send(ACTOR_X) - expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: ACTOR_X } } }) - await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(ACTOR_X, expect.anything())) + const sent = await send(ADDRESS_X) + expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: ADDRESS_X } } }) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith(ADDRESS_X, expect.anything())) }) it('accepts a bare Orca session id and normalizes it', async () => { expect(await send(SESSION_X)).toMatchObject({ ok: true, - result: { message: { to_handle: ACTOR_X } } + result: { message: { to_handle: ADDRESS_X } } }) }) @@ -84,7 +84,7 @@ describe('a send addressed to an agent session', () => { orchestrationRequest('orchestration.runCreate', { objective: 'o' }, { sessionId: SESSION_X }) ) const runId = idOf(resultOf(created).run) - expect(await send(ACTOR_X)).toMatchObject({ + expect(await send(ADDRESS_X)).toMatchObject({ ok: true, result: { message: { to_handle: `run:${runId}` } } }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 0b047fc6de8..5d6627163d2 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,7 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' -import { currentRunCoordinatorActor } from './orchestration/db/runs/run-coordinator-actor' +import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' @@ -546,7 +546,7 @@ describe('a /clear keeps the chat its orchestration address', () => { }) // Nothing was rewritten: the Run is bound exactly as the first session bound it. expect(db.getRunRaw(runId)).toMatchObject({ - coordinator_actor: `session:${COORDINATOR}`, + coordinator_orca_session_id: COORDINATOR, consumer_generation: generation }) }) @@ -560,14 +560,14 @@ describe('a /clear keeps the chat its orchestration address', () => { { sessionId: middle } ) const runId = idOf(created.run) - expect(db.getRunRaw(runId)!.coordinator_actor).toBe(`session:${COORDINATOR}`) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) const successor = await clearChat(middle) runtime.onStructuredSessionStatusForMail({ sessionId: successor, status: 'idle' }) await expect( call('orchestration.runCurrent', {}, { sessionId: successor }) ).resolves.toMatchObject({ run: { id: runId } }) - expect(db.getRunRaw(runId)!.coordinator_actor).toBe(`session:${COORDINATOR}`) + expect(db.getRunRaw(runId)!.coordinator_orca_session_id).toBe(COORDINATOR) // What it sends carries the same address. await openChat(PEER_CHAT) await expect( @@ -580,30 +580,30 @@ describe('a /clear keeps the chat its orchestration address', () => { }) it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { - const root = `session:${COORDINATOR}` - const boundActor = (runId: string): string | null => - currentRunCoordinatorActor(db.getRunRaw(runId)!) + const root = COORDINATOR + const boundOrcaSessionId = (runId: string): string | null => + currentRunCoordinatorOrcaSessionId(db.getRunRaw(runId)!) await openChat(COORDINATOR) const middle = await clearChat(COORDINATOR) const first = idOf( (await call('orchestration.runCreate', { objective: 'first' }, { sessionId: middle })).run ) expect(db.getRunRaw(first)).toMatchObject({ - coordinator_actor: root, - coordinator_actor_generation: db.getRunRaw(first)!.consumer_generation + coordinator_orca_session_id: root, + coordinator_orca_session_id_generation: db.getRunRaw(first)!.consumer_generation }) - expect(boundActor(first)).toBe(root) + expect(boundOrcaSessionId(first)).toBe(root) const second = idOf( (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run ) - expect(boundActor(first)).toBeNull() + expect(boundOrcaSessionId(first)).toBeNull() const successor = await clearChat(middle) await call('orchestration.runUse', { id: first }, { sessionId: successor }) const rebound = db.getRunRaw(first)! - expect(rebound.coordinator_actor).toBe(root) - expect(rebound.coordinator_actor_generation).toBe(rebound.consumer_generation) - expect(boundActor(second)).toBeNull() + expect(rebound.coordinator_orca_session_id).toBe(root) + expect(rebound.coordinator_orca_session_id_generation).toBe(rebound.consumer_generation) + expect(boundOrcaSessionId(second)).toBeNull() await expect( call('orchestration.runCurrent', {}, { sessionId: successor }) ).resolves.toMatchObject({ run: { id: first } }) From 865818d48a7683fb6e05ca0cdef4271359b0ff7e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:10:32 -0700 Subject: [PATCH 45/49] refactor(orchestration): canonicalize a cleared session through the one id hook and the party resolver - canonicalOrcaSessionId now walks a session's /clear lineage to its root; the parallel session identity and lost-worker rule are deleted, so the caller resolver, recipient routing, reach and idle-edge mailboxes all resolve a session through resolveOrcaSessionParty. - A Dispatch row's assignee_orca_session_id goes through the same hook. - The terminal-view delivery lane is gone with the terminal handoff: no PTY is bound to a session, so a chat's mail is always a session turn. - Pins a send to a Run-less chat's session address after a restart, when the send must start the agent-session host before routing reads its record. --- ...rca-runtime-get-pty-record-for-pane-key.ts | 16 +- .../orca-runtime-stop-requested-pty-ids.ts | 7 +- .../canonical-orca-session-id.ts | 41 ++++- .../dispatch-context-store.ts | 4 +- .../worker-dispatch-authority.ts | 4 +- .../failed-start-dispatch-identity.ts | 4 +- .../dispatch-assignee-orca-session-id.ts | 11 ++ .../orchestration/mailbox-delivery-target.ts | 9 +- .../runtime/orchestration/mailbox-owner.ts | 41 +---- .../structured-session-lineage.ts | 44 +++++ .../structured-session-mail-address.ts | 150 +++--------------- .../structured-session-mail-target.test.ts | 60 +++---- .../structured-session-mail-target.ts | 63 +++----- ...uctured-session-terminal-view-mail.test.ts | 86 ---------- ...structured-worker-group-addressing.test.ts | 2 + .../orchestration-worker-mode-opacity.test.ts | 17 +- .../messaging/recipient-routing.ts | 2 +- .../messaging/session-recipient.ts | 4 +- .../rpc/orchestration-session-caller.ts | 6 +- .../orchestration-session-recipient.test.ts | 113 +++++++++++++ 20 files changed, 303 insertions(+), 381 deletions(-) create mode 100644 src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts create mode 100644 src/main/runtime/orchestration/structured-session-lineage.ts delete mode 100644 src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 6cd8665a51f..7fc560c1c69 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -10,9 +10,7 @@ import type { StructuredPointerTarget } from './orchestration/structured-mailbox import { releaseRestoredStructuredPointerClaims } from './orchestration/structured-pointer-claim-restore' import { handleLessCoordinatorSessionId, - findConnectedPtyBoundToSession, structuredSessionAddressTarget, - structuredSessionMailDestination, structuredSessionMailTarget, structuredSessionIdleEdgeMailboxes } from './orchestration/structured-session-mail-target' @@ -198,8 +196,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM /** * Every structured session's status change reaches here. At its idle edge, retry what is parked - * on it and re-derive the mailboxes it owns, so mail it could not take earlier (evicted, closed, - * in its terminal view) is pointed again. Workers and chats alike: this is not per-dispatch. + * on it and re-derive the mailboxes it owns, so mail it could not take earlier (evicted, closed) + * is pointed again. Workers and chats alike: this is not per-dispatch. */ onStructuredSessionStatusForMail(summary: { sessionId: string @@ -214,16 +212,6 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) } - /** The terminal of a session's terminal view, while a TUI owns it; the PTY lane types there. */ - getTerminalViewHandleForSession(sessionId: string): string | null { - const destination = structuredSessionMailDestination(sessionId, this._orchestrationDb) - const pty = - destination?.view === 'terminal-view' - ? findConnectedPtyBoundToSession(this.ptysById.values(), destination.sessionId) - : undefined - return pty?.paneKey ? this.getTerminalHandleForPaneKey(pty.paneKey) : null - } - /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 46dad7b17b4..4989245fdf6 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -2,7 +2,6 @@ import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' import { isStructuredWorkerHandle } from './structured-worker-identity' -import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' @@ -184,8 +183,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId onRoutedMessageTypes: (mailboxHandle, types) => this.messageWaiters.notifyRouted(mailboxHandle, types), onForeignMailboxRouted: (mailboxHandle, messageType) => - this.notifyMessageArrived(mailboxHandle, messageType), - getBoundSessionIdForPty: (ptyId) => agentSessionPtyWriteGate.boundSessionId(ptyId) + this.notifyMessageArrived(mailboxHandle, messageType) }) protected readonly orchestrationMailboxDeliveryTarget = new OrchestrationMailboxDeliveryTarget({ @@ -195,8 +193,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId isStructuredWorkerHandle: (handle) => isStructuredWorkerHandle(handle), canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), - isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId), - getTerminalViewHandleForSession: (sessionId) => this.getTerminalViewHandleForSession(sessionId) + isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) }) protected readonly orchestrationMailboxPointerDelivery = new OrchestrationMailboxPointerDelivery({ diff --git a/src/main/runtime/orchestration/canonical-orca-session-id.ts b/src/main/runtime/orchestration/canonical-orca-session-id.ts index 7889e257f48..4aee8cbea15 100644 --- a/src/main/runtime/orchestration/canonical-orca-session-id.ts +++ b/src/main/runtime/orchestration/canonical-orca-session-id.ts @@ -1,7 +1,38 @@ -import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { + clearedInto, + readAgentSessionRecordStore, + type AgentSessionRecordReader +} from './structured-session-lineage' -/** The Orca session id orchestration addresses a session by; every session-to-party step calls this. */ -export function canonicalOrcaSessionId(orcaSessionId: OrcaSessionId): OrcaSessionId { - // Later lineage canonicalization (a `/clear`ed session to its lineage root) plugs in here. - return orcaSessionId +/** + * The Orca session id orchestration addresses a session by: the first session of its `/clear` + * lineage, so a cleared chat keeps the address, Runs and mail it had. Every session-to-party step + * calls this. Without a record store there is no lineage to read, and the id stands for itself. + */ +export function canonicalOrcaSessionId( + orcaSessionId: OrcaSessionId, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): OrcaSessionId { + if (!store) { + return orcaSessionId + } + const clearedFrom = new Map() + for (const record of store.listRecords()) { + const next = clearedInto(record) + if (next) { + clearedFrom.set(next, record.sessionId) + } + } + // A clear chain is acyclic by construction; the visited set only bounds a corrupt store. + let root: string = orcaSessionId + const earlier = new Set([root]) + let prior = clearedFrom.get(root) + while (prior && !earlier.has(prior)) { + earlier.add(prior) + root = prior + prior = clearedFrom.get(root) + } + // Record ids are minted as Orca session ids; one that is not cannot name the conversation. + return isOrcaSessionId(root) ? root : orcaSessionId } diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 67be16e6cf2..b51e5b4a8aa 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,7 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function createDispatchContext( this: OrchestrationDb, @@ -66,7 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, - assigneeOrcaSessionId: structuredWorkerOrcaSessionIdForIncarnation(processIncarnation), + assigneeOrcaSessionId: dispatchAssigneeOrcaSessionId(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index bc7b2c0cc0c..733833c9aca 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -64,7 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, - structuredWorkerOrcaSessionIdForIncarnation(params.processIncarnation), + dispatchAssigneeOrcaSessionId(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 490cfbb1138..3d45c9a8630 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,6 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' +import { dispatchAssigneeOrcaSessionId } from '../../dispatch-assignee-orca-session-id' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -29,7 +29,7 @@ export function recordFailedStartDispatchIdentity( .run( resource.terminal_handle, resource.pane_key, - structuredWorkerOrcaSessionIdForIncarnation(resource.process_incarnation), + dispatchAssigneeOrcaSessionId(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts new file mode 100644 index 00000000000..52c75ece732 --- /dev/null +++ b/src/main/runtime/orchestration/dispatch-assignee-orca-session-id.ts @@ -0,0 +1,11 @@ +import type { OrcaSessionId } from '../../../shared/orca-session-address' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../structured-worker-identity' +import { canonicalOrcaSessionId } from './canonical-orca-session-id' + +/** The Orca session id a Dispatch row stores for the structured worker a process incarnation names. */ +export function dispatchAssigneeOrcaSessionId( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const orcaSessionId = structuredWorkerOrcaSessionIdForIncarnation(processIncarnation) + return orcaSessionId === null ? null : canonicalOrcaSessionId(orcaSessionId) +} diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index eb2282def0b..5d412bb12e8 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -1,6 +1,5 @@ import type { OrchestrationDb } from './db' import type { OrchestrationMailboxLeaf } from './mailbox-owner' -import { handleLessCoordinatorSessionId } from './structured-session-mail-target' type OrchestrationMailboxDeliveryTargetDependencies = { getDb: () => OrchestrationDb | null @@ -11,8 +10,6 @@ type OrchestrationMailboxDeliveryTargetDependencies = { canProbePtyLiveness: () => boolean controllerKnowsPtyIsLive: (ptyId: string) => boolean isLeafPtyProvenAbsent: (ptyId: string) => Promise - /** The terminal of a structured session's terminal view, while a TUI owns that session. */ - getTerminalViewHandleForSession?: (sessionId: string) => string | null } export class OrchestrationMailboxDeliveryTarget { @@ -34,12 +31,8 @@ export class OrchestrationMailboxDeliveryTarget { const remote = dispatchId && !dispatch ? db?.getRemoteDispatchAttachment?.(dispatchId) : undefined const paneKey = dispatch?.assignee_pane_key ?? remote?.pane_key - const run = runId ? db?.getRun(runId) : undefined - const coordinatorSessionId = run ? handleLessCoordinatorSessionId(run) : null const ownerHandle = runId - ? coordinatorSessionId - ? this.deps.getTerminalViewHandleForSession?.(coordinatorSessionId) - : run?.coordinator_handle + ? db?.getRun(runId)?.coordinator_handle : ((paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null) ?? dispatch?.assignee_handle ?? remote?.terminal_handle) diff --git a/src/main/runtime/orchestration/mailbox-owner.ts b/src/main/runtime/orchestration/mailbox-owner.ts index 60ac5b9e52d..ae315511b15 100644 --- a/src/main/runtime/orchestration/mailbox-owner.ts +++ b/src/main/runtime/orchestration/mailbox-owner.ts @@ -1,8 +1,5 @@ import type { AgentStatus } from '../../../shared/agent-detection' -import { isOrcaSessionId } from '../../../shared/orca-session-address' import type { OrchestrationDb } from './db' -import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' -import { sessionOrchestrationIdentity } from './structured-session-mail-address' export type OrchestrationMailboxLeaf = { tabId: string @@ -33,8 +30,6 @@ type OrchestrationMailboxOwnerDependencies = { getTerminalProcessIncarnation: (terminalHandle: string) => string | null onRoutedMessageTypes: (mailboxHandle: string, types: readonly string[]) => void onForeignMailboxRouted: (mailboxHandle: string, messageType: string) => void - /** The structured session a PTY is the terminal view of, if any. */ - getBoundSessionIdForPty?: (ptyId: string) => string | null } export class OrchestrationMailboxOwner { @@ -62,23 +57,17 @@ export class OrchestrationMailboxOwner { return null } const paneKey = `${leaf.tabId}:${leaf.leafId}` - const session = this.sessionMailOwner(db, leaf) - const sessionRun = session ? db.getCurrentRunForCoordinator?.(session) : null - const run = sessionRun ?? db.getCurrentRunForPane?.(paneKey) + const run = db.getCurrentRunForPane?.(paneKey) if (run) { - const address = sessionRun && session ? session.address : terminalHandle - return this.resolveRunMailbox(db, leaf, address, run.id, requestedMailbox, options) + return this.resolveRunMailbox(db, leaf, terminalHandle, run.id, requestedMailbox, options) } - const sessionDispatch = session - ? db.getActiveDispatchForIdentity?.(session.address, session.paneKey ?? undefined) - : null - const dispatch = sessionDispatch ?? db.getActiveDispatchForIdentity?.(terminalHandle, paneKey) + const dispatch = db.getActiveDispatchForIdentity?.(terminalHandle, paneKey) if (dispatch) { return this.resolveDispatchMailbox( db, leaf, - sessionDispatch && session ? session.address : terminalHandle, + terminalHandle, dispatch.id, dispatch.run_id, requestedMailbox, @@ -103,21 +92,6 @@ export class OrchestrationMailboxOwner { return !requestedMailbox || requestedMailbox === terminalHandle ? terminalHandle : null } - /** - * A terminal view speaks for its session (the CLI there acts as the session), so its Run and - * Dispatch are the session's. A PTY-born worker adopted into a session still owns its mail by - * terminal, which is why callers fall back to the pane when the session owns nothing. - */ - private sessionMailOwner( - db: OrchestrationDb, - leaf: OrchestrationMailboxLeaf - ): OrchestrationCallerIdentity | null { - const sessionId = leaf.ptyId ? this.deps.getBoundSessionIdForPty?.(leaf.ptyId) : null - return sessionId && isOrcaSessionId(sessionId) - ? sessionOrchestrationIdentity(sessionId, db) - : null - } - routeForeignDirectMessages(leaf: OrchestrationMailboxLeaf): RoutedOrchestrationMailbox[] { const db = this.deps.getDb() if (!db) { @@ -138,13 +112,10 @@ export class OrchestrationMailboxOwner { if (!ownerRunId) { return [] } - const session = this.sessionMailOwner(db, leaf) - const sessionOwnsRun = - session !== null && db.getCurrentRunForCoordinator?.(session)?.id === ownerRunId const routed = db.routeForeignDirectMessagesToOwnedMailboxes?.( - sessionOwnsRun ? session.address : terminalHandle, + terminalHandle, ownerRunId, - sessionOwnsRun ? (session.paneKey ?? undefined) : paneKey + paneKey ) if (routed?.hasMore) { this.scheduleDirectReconciliation(leaf) diff --git a/src/main/runtime/orchestration/structured-session-lineage.ts b/src/main/runtime/orchestration/structured-session-lineage.ts new file mode 100644 index 00000000000..8cf6e94f12d --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-lineage.ts @@ -0,0 +1,44 @@ +/** + * A structured session's `/clear` lineage, read off the durable session records. `/clear` continues + * a chat in a new session; the committed clear on the old record names the session that replaced it. + * Derived from the records every time; nothing is rewritten at a clear. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' + +export type AgentSessionRecordReader = { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] + /** Absent on a store that predates tab visibility; every session then counts as open. */ + getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } +} + +/** Null until the agent-session host is installed; callers that must see records ensure it first. */ +export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +/** The session a committed `/clear` continued this one in, if any. */ +export function clearedInto(record: AgentSessionRecord): string | null { + const command = record.conversationCommand + return command?.command === 'clear' && command.phase === 'committed' + ? (command.replacementSessionId ?? null) + : null +} + +/** The session running the lineage now; null when the chain names a session with no record. */ +export function lineageLiveSession( + store: AgentSessionRecordReader, + sessionId: string +): AgentSessionRecord | null { + let live = store.getRecord(sessionId) + const later = new Set([sessionId]) + let next = live ? clearedInto(live) : null + while (live && next && !later.has(next)) { + later.add(next) + live = store.getRecord(next) + next = live ? clearedInto(live) : null + } + return live +} diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index 4a987d77512..4352cdb17b1 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -3,10 +3,8 @@ * told is its public address. Recipient routing and pointer delivery both read these rules off the * durable session record, so the two can never disagree about which sessions mail can reach. * - * The address names a conversation, not one session of it. `/clear` continues a chat in a new - * session, and the conversation keeps the address of its first session (its lineage root): a Run it - * coordinates, mail sent to it, and what it sends all stay under that one spelling, and any session - * of the lineage names it. Derived from the records every time; nothing is rewritten at a clear. + * The address names a conversation, not one session of it: any session of a `/clear` lineage names + * the lineage root's address (`canonicalOrcaSessionId`), and mail reaches the lineage's live session. * * A released lease does not end a session. The host evicts a chat nobody is looking at 15s after * its last turn and hands its lease back, and mail must wake it again (resume on demand). For mail, @@ -14,77 +12,13 @@ */ import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - formatOrcaSessionAddress, - isOrcaSessionId, - type OrcaSessionId -} from '../../../shared/orca-session-address' -import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' -import { - isRecordedStructuredWorkerSession, - resolveStructuredWorkerIdentityForSession -} from '../structured-worker-authority' +import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { OrchestrationDb } from './db' -import type { OrchestrationCallerIdentity } from './orchestration-caller-identity' - -export type AgentSessionRecordReader = { - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords: () => AgentSessionRecord[] - /** Absent on a store that predates tab visibility; every session then counts as open. */ - getVisibleSessionTabIndex?: () => { present: boolean; sessionIds: string[] } -} - -export function readAgentSessionRecordStore(): AgentSessionRecordReader | null { - return getStructuredAgentSessionHost()?.deps.store ?? null -} - -/** The session a committed `/clear` continued this one in, if any. */ -function clearedInto(record: AgentSessionRecord): string | null { - const command = record.conversationCommand - return command?.command === 'clear' && command.phase === 'committed' - ? (command.replacementSessionId ?? null) - : null -} - -export type StructuredSessionLineage = { - /** The conversation's first session: its address for as long as the conversation lasts. */ - rootSessionId: string - /** The session running the conversation now; null when the chain names a session with no record. */ - live: AgentSessionRecord | null -} - -/** Any session of a `/clear` lineage, resolved to the lineage's root and its live end. */ -export function structuredSessionLineage( - store: AgentSessionRecordReader, - sessionId: string -): StructuredSessionLineage { - const clearedFrom = new Map() - for (const record of store.listRecords()) { - const next = clearedInto(record) - if (next) { - clearedFrom.set(next, record.sessionId) - } - } - // A clear chain is acyclic by construction; the visited sets only bound a corrupt store. - let rootSessionId = sessionId - const earlier = new Set([sessionId]) - let prior = clearedFrom.get(rootSessionId) - while (prior && !earlier.has(prior)) { - earlier.add(prior) - rootSessionId = prior - prior = clearedFrom.get(rootSessionId) - } - let live = store.getRecord(sessionId) - const later = new Set([sessionId]) - let next = live ? clearedInto(live) : null - while (live && next && !later.has(next)) { - later.add(next) - live = store.getRecord(next) - next = live ? clearedInto(live) : null - } - return { rootSessionId, live } -} +import { OrchestrationError } from './orchestration-error' +import { resolveOrcaSessionParty, type OrchestrationSessionParty } from './orchestration-party' +import { lineageLiveSession, type AgentSessionRecordReader } from './structured-session-lineage' export type OrcaAgentSessionLookup = | { kind: 'found'; record: AgentSessionRecord } @@ -122,17 +56,14 @@ export function structuredSessionMailReach( record: AgentSessionRecord, db: OrchestrationDb | null | undefined ): StructuredSessionMailReach { - const live = structuredSessionLineage(store, record.sessionId).live + const live = lineageLiveSession(store, record.sessionId) if (!live) { return { kind: 'ended', reason: 'continuation-missing' } } if (!structuredWorkerHostScope(live.location)) { return { kind: 'other-host' } } - const identity = isOrcaSessionId(live.sessionId) - ? sessionOrchestrationIdentity(live.sessionId, db, store) - : null - if (db && identity && hasLostStructuredWorkerIdentity(identity, db)) { + if (isOrcaSessionId(live.sessionId) && !addressableSessionParty(live.sessionId, db)) { // Why: it can no longer act (the caller resolver refuses it), so mail to it could never be read. return { kind: 'ended', reason: 'worker-identity-lost' } } @@ -145,58 +76,19 @@ export function structuredSessionMailReach( } /** - * Which view carries a pointer to the session right now. A terminal view owns the session while a - * TUI holds its lease, and its PTY takes the pointer; otherwise the host sends a session turn, - * resuming an evicted session for it. - */ -export function structuredSessionDeliveryView( - record: AgentSessionRecord -): 'session-turn' | 'terminal-view' { - return record.lease.runtimeKind === 'tui' && record.lease.claimStatus !== 'released' - ? 'terminal-view' - : 'session-turn' -} - -/** - * Who a session is to orchestration: its conversation's Orca session id (the lineage root's), plus - * the handle and pane a structured worker was minted. The caller resolver and mail delivery both take - * it from here, so a session is matched the same way whether it is sending, checking, or being - * delivered to. Without a record store there is no lineage to read, and the id stands for itself. + * The party a session resolves to, or null for a worker whose identity this host lost: the party + * resolver refuses it in every role, so it can never read mail and none is owed to it. */ -export function sessionOrchestrationIdentity( +export function addressableSessionParty( sessionId: OrcaSessionId, - db: OrchestrationDb | null | undefined, - store: AgentSessionRecordReader | null = readAgentSessionRecordStore() -): OrchestrationCallerIdentity & { orcaSessionId: OrcaSessionId } { - const lineage = store ? structuredSessionLineage(store, sessionId) : null - const root = lineage?.rootSessionId ?? sessionId - // Record ids are minted as Orca session ids; one that is not cannot name the conversation. - const orcaSessionId = isOrcaSessionId(root) ? root : sessionId - // A worker identity is minted for one session, so it is looked up on the one running now. - const worker = resolveStructuredWorkerIdentityForSession( - lineage?.live?.sessionId ?? sessionId, - db - ) - return { - orcaSessionId, - address: worker?.handle ?? formatOrcaSessionAddress(orcaSessionId), - terminalHandle: worker?.handle ?? null, - paneKey: worker?.paneKey ?? null + db: OrchestrationDb | null | undefined +): OrchestrationSessionParty | null { + try { + return resolveOrcaSessionParty(sessionId, db) + } catch (error) { + if (error instanceof OrchestrationError && error.code === CODES.notLive) { + return null + } + throw error } } - -/** - * A structured worker whose worker identity this host no longer has. It may not act handle-less (that - * would split one worker into two identities), so mail to it could never be read either. Checked on - * the conversation, whose root session is the one a Dispatch assigned. - */ -export function hasLostStructuredWorkerIdentity( - identity: OrchestrationCallerIdentity, - db: OrchestrationDb -): boolean { - return ( - identity.terminalHandle === null && - identity.orcaSessionId !== null && - isRecordedStructuredWorkerSession(identity.orcaSessionId, db) - ) -} diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 7ceca93908b..743b729d987 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -25,12 +25,15 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' const { OrcaRuntimeWithGetPtyRecordForPaneKey } = await import('../orca-runtime-get-pty-record-for-pane-key') const { OrchestrationDb } = await import('./db') -const { agentSessionPtyWriteGate } = await import('../agent-session-pty-write-gate') -const { sessionOrchestrationIdentity } = await import('./structured-session-mail-address') +const { resolveOrcaSessionParty } = await import('./orchestration-party') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} = await import('../structured-worker-identity') const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT) -const TERMINAL_VIEW_PANE = 'tab_view:77777777-7777-4777-8777-777777777777' /** The real methods through the real prototype chain; a re-declared copy would pin nothing. */ class MailTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { @@ -99,7 +102,6 @@ beforeEach(() => { }) afterEach(() => { - agentSessionPtyWriteGate.detachRecordLookup() db.close() }) @@ -118,12 +120,6 @@ describe('a Run whose coordinator is a chat (an Orca session id, no handle)', () expect(probe().target(`run:${runId}`)).toEqual({ sessionId: CHAT, dispatchId: null }) }) - it('leaves the mailbox to the PTY lane while the chat is in its terminal view', () => { - installStore(chatRecord({ runtimeKind: 'tui' })) - const runId = chatCoordinatedRun() - expect(probe().target(`run:${runId}`)).toBeNull() - }) - it('does not deliver to a chat that was closed, cleared into no known session, or runs on another host', () => { const runId = chatCoordinatedRun() installStore(chatRecord(), false) @@ -185,25 +181,6 @@ describe('a session addressed directly', () => { }) }) -describe('a chat in its terminal view', () => { - it('is reached through the PTY bound to it', () => { - installStore(chatRecord({ runtimeKind: 'tui' })) - agentSessionPtyWriteGate.attachRecordLookup(() => null) - agentSessionPtyWriteGate.bindPty('pty-view', CHAT) - const runtime = probe({ - ptysById: new Map([ - ['pty-view', { ptyId: 'pty-view', connected: true, paneKey: TERMINAL_VIEW_PANE }] - ]), - getTerminalHandleForPaneKey: (paneKey: string) => - paneKey === TERMINAL_VIEW_PANE ? 'term_view' : null - }) - expect(runtime.getTerminalViewHandleForSession(CHAT)).toBe('term_view') - // Its native view does not answer at the same time. - installStore(chatRecord()) - expect(runtime.getTerminalViewHandleForSession(CHAT)).toBeNull() - }) -}) - describe('the idle edge of a structured session', () => { it('re-derives and delivers the mailboxes the session owns, and nothing while it works', () => { installStore(chatRecord()) @@ -382,10 +359,23 @@ describe('a coordinator chat continued by /clear', () => { objective: 'o', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorOrcaSessionId: sessionOrchestrationIdentity(sessionId, db).orcaSessionId + coordinatorOrcaSessionId: resolveOrcaSessionParty(sessionId, db).orcaSessionId }).id } + it('stores a Dispatch assignee by the lineage root of the session its incarnation names', () => { + installLineage(SUCCESSOR) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SUCCESSOR), + processIncarnation: structuredWorkerProcessIncarnation(SUCCESSOR), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(db.getDispatchContextById(dispatch.id)?.assignee_orca_session_id).toBe(CHAT) + }) + it("never unbinds the successor's own Run", () => { // The strand this pins: the successor's own run-create, then its idle edge rebinding the // predecessor's Run through an exclusive bind, which unbound the Run the successor created. @@ -397,7 +387,7 @@ describe('a coordinator chat continued by /clear', () => { expect(idleEdge(SUCCESSOR)).toContain(`run:${ownRun}`) idleEdge(SUCCESSOR) - const successor = sessionOrchestrationIdentity(SUCCESSOR, db) + const successor = resolveOrcaSessionParty(SUCCESSOR, db) expect(db.getRunRaw(ownRun)).toMatchObject({ coordinator_orca_session_id: successor.orcaSessionId, consumer_generation: generation @@ -418,9 +408,7 @@ describe('a coordinator chat continued by /clear', () => { consumer_generation: generation }) for (const member of [CHAT, MIDDLE, SUCCESSOR]) { - expect(db.getCurrentRunForCoordinator(sessionOrchestrationIdentity(member, db))?.id).toBe( - runId - ) + expect(db.getCurrentRunForCoordinator(resolveOrcaSessionParty(member, db))?.id).toBe(runId) } expect(probe().target(`run:${runId}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) }) @@ -434,7 +422,7 @@ describe('a coordinator chat continued by /clear', () => { installLineage(MIDDLE, SUCCESSOR) idleEdge(SUCCESSOR) - const successor = sessionOrchestrationIdentity(SUCCESSOR, db) + const successor = resolveOrcaSessionParty(SUCCESSOR, db) expect(db.getRunRaw(middleRun)).toMatchObject({ coordinator_orca_session_id: successor.orcaSessionId, consumer_generation: generation @@ -445,7 +433,7 @@ describe('a coordinator chat continued by /clear', () => { it('reaches the live session through any spelling of the conversation, and stores one', () => { installLineage(MIDDLE, SUCCESSOR) for (const member of [CHAT, MIDDLE, SUCCESSOR]) { - expect(sessionOrchestrationIdentity(member, db)).toMatchObject({ + expect(resolveOrcaSessionParty(member, db)).toMatchObject({ orcaSessionId: CHAT, address: CHAT_ADDRESS }) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 2e538717fa6..23a0d1e5555 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -1,11 +1,8 @@ /** * Where a mailbox owned by a structured session is delivered, for sessions that are not structured * workers: a chat that coordinates a Run (`run:` with no coordinator handle) and a session - * addressed directly at `session:`. - * - * The session is resolved here, never a pane: its native view takes the pointer as a session turn - * (the structured lane), its terminal view as bytes typed into the PTY that owns it (the PTY lane). - * Exactly one view answers for a session at a time, so the two lanes never both claim a mailbox. + * addressed directly at `session:`. The session is resolved here, never a pane, and takes the + * pointer as a session turn. */ import { @@ -14,32 +11,19 @@ import { parseOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' -import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' import type { OrchestrationDb } from './db' import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' +import { + addressableSessionParty, + structuredSessionMailReach +} from './structured-session-mail-address' import { readAgentSessionRecordStore, - sessionOrchestrationIdentity, - structuredSessionDeliveryView, - structuredSessionMailReach, type AgentSessionRecordReader -} from './structured-session-mail-address' +} from './structured-session-lineage' import type { RunRow } from './types' -/** The live PTY the write gate binds to a session: its terminal view, when a TUI owns it. */ -export function findConnectedPtyBoundToSession( - ptys: Iterable, - sessionId: string -): T | undefined { - for (const pty of ptys) { - if (pty.connected && agentSessionPtyWriteGate.boundSessionId(pty.ptyId) === sessionId) { - return pty - } - } - return undefined -} - /** * The session a Run's coordinator binding names when that binding has no handle. A structured * worker coordinates by its own handle and resolves through it, so only a handle-less binding names @@ -62,29 +46,18 @@ export function handleLessCoordinatorSessionId( } /** - * The session that takes a pointer for `sessionId`'s conversation now (its live session, whichever - * session of the lineage was named) and through which view; null when mail cannot reach it here. + * The structured-lane target for `sessionId`'s conversation: its live session, whichever session of + * the lineage was named; null when mail cannot reach it here. */ -export function structuredSessionMailDestination( +export function structuredSessionMailTarget( sessionId: string, db: OrchestrationDb | null | undefined, store: AgentSessionRecordReader | null = readAgentSessionRecordStore() -): { sessionId: string; view: 'session-turn' | 'terminal-view' } | null { +): StructuredPointerTarget | null { const record = store?.getRecord(sessionId) const reach = store && record ? structuredSessionMailReach(store, record, db) : null return reach?.kind === 'reachable' - ? { sessionId: reach.session.sessionId, view: structuredSessionDeliveryView(reach.session) } - : null -} - -/** The structured-lane target for a session whose native view takes the pointer. */ -export function structuredSessionMailTarget( - sessionId: string, - db: OrchestrationDb | null | undefined -): StructuredPointerTarget | null { - const destination = structuredSessionMailDestination(sessionId, db) - return destination?.view === 'session-turn' - ? { sessionId: destination.sessionId, dispatchId: null } + ? { sessionId: reach.session.sessionId, dispatchId: null } : null } @@ -106,16 +79,16 @@ export function structuredSessionAddressTarget( /** * Every mailbox a session reads for itself: the Runs it coordinates and its own direct mail. * Re-derived from the database on each idle edge rather than remembered, so mail that arrived - * while the session could not take it (closed, evicted, in the other view) is found again. + * while the session could not take it (closed, evicted) is found again. */ export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { - if (!isOrcaSessionId(sessionId)) { + const party = isOrcaSessionId(sessionId) ? addressableSessionParty(sessionId, db) : null + if (!party) { return [] } - const identity = sessionOrchestrationIdentity(sessionId, db) - const mailboxes = db.runsBoundToCoordinator(identity).map((run) => `run:${run.id}`) - if (db.getUnreadDirectMessageTypes(identity.address).length > 0) { - mailboxes.push(identity.address) + const mailboxes = db.runsBoundToCoordinator(party).map((run) => `run:${run.id}`) + if (db.getUnreadDirectMessageTypes(party.address).length > 0) { + mailboxes.push(party.address) } return mailboxes } diff --git a/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts b/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts deleted file mode 100644 index 16a9f4f4f40..00000000000 --- a/src/main/runtime/orchestration/structured-session-terminal-view-mail.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { OrchestrationDb } from './db' -import { OrchestrationMailboxDeliveryTarget } from './mailbox-delivery-target' -import { OrchestrationMailboxOwner, type OrchestrationMailboxLeaf } from './mailbox-owner' -import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' - -const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') -const VIEW_TAB = 'tab_view' -const VIEW_LEAF = '77777777-7777-4777-8777-777777777777' - -let db: OrchestrationDb - -beforeEach(() => { - db = new OrchestrationDb(':memory:') -}) - -afterEach(() => { - db.close() -}) - -const viewLeaf: OrchestrationMailboxLeaf = { - tabId: VIEW_TAB, - leafId: VIEW_LEAF, - ptyId: 'pty-view', - writable: true, - lastAgentStatus: 'idle', - lastAgentStatusObservedLive: true, - lastOscTitle: null -} - -function owner(boundSessionId: string | null): OrchestrationMailboxOwner { - return new OrchestrationMailboxOwner({ - getDb: () => db, - getLeaf: () => viewLeaf, - getLeafKey: (tabId, leafId) => `${tabId}::${leafId}`, - getTerminalHandleForLeafKey: () => 'term_view', - getTerminalProcessIncarnation: () => null, - onRoutedMessageTypes: vi.fn(), - onForeignMailboxRouted: vi.fn(), - getBoundSessionIdForPty: (ptyId) => (ptyId === 'pty-view' ? boundSessionId : null) - }) -} - -function chatRun(): string { - return db.createRun({ - objective: 'o', - coordinatorHandle: null, - coordinatorPaneKey: null, - coordinatorOrcaSessionId: CHAT - }).id -} - -describe("a chat's terminal view reads the chat's mail", () => { - it('owns the Run the chat coordinates, which no pane binding names', () => { - // The CLI in a terminal view acts as the session, so the Run is bound by Orca session id and the pane - // path finds nothing: without this the idle edge of the view never pointed coordinator mail. - const runId = chatRun() - expect(owner(CHAT).resolve(viewLeaf)).toBe(`run:${runId}`) - expect(owner(CHAT).resolve(viewLeaf, `run:${runId}`)).toBe(`run:${runId}`) - expect(owner(null).resolve(viewLeaf)).toBe('term_view') - }) - - it('falls back to the pane for a terminal whose session owns nothing', () => { - const runId = db.createRun({ - objective: 'o', - coordinatorHandle: 'term_view', - coordinatorPaneKey: `${VIEW_TAB}:${VIEW_LEAF}` - }).id - expect(owner(CHAT).resolve(viewLeaf)).toBe(`run:${runId}`) - }) - - it("resolves a chat-coordinated Run's mailbox to the terminal view's handle", () => { - const runId = chatRun() - const target = new OrchestrationMailboxDeliveryTarget({ - getDb: () => db, - getTerminalHandleForPaneKey: () => null, - hasTerminalHandle: (handle) => handle === 'term_view', - isStructuredWorkerHandle: () => false, - canProbePtyLiveness: () => false, - controllerKnowsPtyIsLive: () => true, - isLeafPtyProvenAbsent: async () => false, - getTerminalViewHandleForSession: (sessionId) => (sessionId === CHAT ? 'term_view' : null) - }) - expect(target.resolveTerminalHandle(`run:${runId}`)).toBe('term_view') - }) -}) diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 31ee011193a..a25df315de4 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -55,6 +55,8 @@ function installHost(options: { hostRef.current = { deps: { store: { + // No committed /clear: each session is its own lineage's root. + listRecords: () => [], getRecord: (sessionId: string) => ({ sessionId, diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index aa8c7c58574..670ed8a57cb 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -12,7 +12,6 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { OrcaRuntimeService } from '../../orca-runtime' import { OrchestrationDb } from '../../orchestration/db' import { @@ -29,6 +28,12 @@ const STRUCTURED_HANDLE = 'structworker_worker' const TERMINAL_HANDLE = 'term_worker' const structuredPreambles: string[] = [] +// The session host the code under test reads; a structural fake, so no host type is claimed. +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) + +vi.mock('../../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) vi.mock('./orchestration/worker/worker-topology', async (importOriginal) => ({ ...(await importOriginal>()), @@ -69,7 +74,7 @@ function installStructuredCoordinator(handle: string, sessionId: string): string worktreeId: WORKTREE, hostScope: { kind: 'local', hostId: 'local' } }) - setStructuredAgentSessionHost({ + hostRef.current = { hasSession: () => true, deps: { store: { @@ -82,10 +87,12 @@ function installStructuredCoordinator(handle: string, sessionId: string): string deathEvidence: null, runtimeFence: 1 } - }) + }), + // No committed /clear: each session is its own lineage's root. + listRecords: () => [] } } - } as never) + } return paneKey } @@ -149,7 +156,7 @@ describe('a worker cannot tell which mode it is running in', () => { afterEach(() => { db.close() - setStructuredAgentSessionHost(null) + hostRef.current = null structuredWorkerIdentities.clear() vi.restoreAllMocks() }) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index a6823b94f60..84844c4db61 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -3,7 +3,7 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { resolveOrchestrationParty } from '../../../../orchestration/orchestration-party' -import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-mail-address' +import { readAgentSessionRecordStore } from '../../../../orchestration/structured-session-lineage' import { readSessionRecipient, refuseUndeliverableSessionRecipient, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index 483d388955f..b7e17523689 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -21,9 +21,9 @@ import { import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../../../../shared/orchestration-session-caller-codes' import { lookupOrcaAgentSession, - structuredSessionMailReach, - type AgentSessionRecordReader + structuredSessionMailReach } from '../../../../orchestration/structured-session-mail-address' +import type { AgentSessionRecordReader } from '../../../../orchestration/structured-session-lineage' import type { OrchestrationDb } from '../../../../orchestration/db' /** `address` is the named session's own spelling; the mailbox mail lands in is its identity address. */ diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 507fa1ac8c4..0d2253481b4 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -29,10 +29,8 @@ import { resolveOrcaSessionParty, resolveOrchestrationParty } from '../orchestration/orchestration-party' -import { - lookupOrcaAgentSession, - type AgentSessionRecordReader -} from '../orchestration/structured-session-mail-address' +import { lookupOrcaAgentSession } from '../orchestration/structured-session-mail-address' +import { readAgentSessionRecordStore } from '../orchestration/structured-session-lineage' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index e21d31556d5..f8f2de266d4 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -7,6 +7,7 @@ import { } from '../structured-worker-identity' import { ADDRESS_X, + ADDRESS_Y, createSessionCallerHarness, idOf, isRecord, @@ -16,6 +17,7 @@ import { SESSION_X, SESSION_Y, sessionRecord, + WORKER_HANDLE, type SessionCallerHarness } from './orchestration-session-caller-test-fixture' @@ -217,3 +219,114 @@ describe('a live structured worker addressed by its session id', () => { expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) }) }) + +describe('mail sent to a session address reaches the mailbox that session reads', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + function sendFromTerminal(to: string): Promise { + return as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to, subject: 'hello' }) + } + + it("files it under the chat's current Run, as a terminal coordinator's pane does", async () => { + await as(SESSION_X, 'orchestration.runCreate', { objective: 'first' }) + const current = idOf( + (await as(SESSION_X, 'orchestration.runCreate', { objective: 'next' })).run + ) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: `run:${current}`, run_id: current }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + runId: current, + messages: [{ subject: 'hello' }] + }) + }) + + it('delivers it to a chat with no Run, which reads its direct mailbox', async () => { + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + messages: [{ subject: 'hello' }] + }) + }) + + it('reaches a chat with no Run after a restart, once the send has started the session host', async () => { + // After an app restart the agent-session host starts lazily; routing reads the session record + // synchronously, so the send must start the host before it resolves the recipient. + const store = hostRef.current + hostRef.current = null + vi.mocked(h.runtime.ensureStructuredAgentSessionHost).mockImplementation(async () => { + hostRef.current = store + }) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + }) + + it('refuses an Orca session this host does not run, or has no record of', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + h.records.delete(SESSION_Y) + + for (const [to, code] of [ + [ADDRESS_X, 'session_caller_host_boundary'], + [ADDRESS_Y, 'session_caller_unknown'] + ]) { + const response = await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) + ) + expect(response).toMatchObject({ ok: false, error: { code } }) + } + }) + + it("routes a structured worker's session address to the Dispatch it is working", async () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const runId = idOf((await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })).run) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const { message } = await as(SESSION_X, 'orchestration.send', { + to: ADDRESS_Y, + subject: 'to the worker' + }) + + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}`, run_id: runId }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'to the worker' }] + }) + }) +}) From 6ebef0956ce92b3d80d4d0d143dd8d20505fabe5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:30:15 -0700 Subject: [PATCH 46/49] fix(orchestration): point a structured session with the PTY lane's exact text A chat or structured worker is now told what a terminal agent is told: the pointer is formatMessagePointer with the CLI name the PTY lane resolves for a local terminal (orca, or orca-dev in a dev build), with no shell-specific invocation and no ack lesson. The lane still excludes the batch a reader holds unacknowledged and points newer mail; that stays host-side, and the reader's own check replays the held batch and names its ack as it does for a terminal. --- ...me-get-orchestration-dispatch-authority.ts | 15 ++++++-- .../orca-runtime-stop-requested-pty-ids.ts | 1 + src/main/runtime/orchestration/cli-command.ts | 22 ----------- src/main/runtime/orchestration/formatter.ts | 9 ++--- ...tructured-mailbox-pointer-delivery.test.ts | 12 +++--- .../structured-mailbox-pointer-delivery.ts | 26 +++++-------- .../structured-mailbox-pointer-host.ts | 9 ----- .../structured-session-cli-invocation.test.ts | 38 ------------------- .../structured-chat-coordinator-mail.test.ts | 12 +++--- 9 files changed, 37 insertions(+), 107 deletions(-) delete mode 100644 src/main/runtime/orchestration/structured-session-cli-invocation.test.ts diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index d61374c3466..8d9275fbb81 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -18,7 +18,7 @@ import { resolveTerminalOrchestrationCliCommand, type OrchestrationCliCommand } from './orchestration/cli-command' -import { getAppEnvironment } from '../../shared/app-environment' +import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' @@ -263,11 +263,20 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim connectionId: pty.connectionId, isWsl: pty.isWsl, worktreeId: pty.worktreeId, - // Dev builds run the CLI as `orca-dev`; a packaged app must not advertise it. - runtimeCliCommand: getAppEnvironment().isPackaged() ? undefined : 'orca-dev', + runtimeCliCommand: runtimeOrchestrationCliCommand(), projectRuntime: this.store ? resolveLocalProjectRuntimeForWorktreeId(this.requireStore(), pty.worktreeId) : undefined }) } + + /** What a local, non-WSL terminal is told to run; a structured session is always one. */ + getLocalOrchestrationCliCommand(): OrchestrationCliCommand { + return runtimeOrchestrationCliCommand() ?? 'orca' + } +} + +/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ +function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { + return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined } diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 4989245fdf6..620aad8499e 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -221,6 +221,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), + getCliCommand: () => this.getLocalOrchestrationCliCommand(), host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index bf9cfaf0f16..809be9a4b88 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -4,28 +4,6 @@ import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' -/** The CLI invocation text a structured session's shell can run, one form per shell family. */ -export type StructuredSessionCliInvocation = '"$ORCA_CLI_COMMAND"' | '& $env:ORCA_CLI_COMMAND' - -/** - * How text addressed to a structured session invokes this app's CLI, in the shell that session's - * commands actually run in. The env carries the absolute launcher in `ORCA_CLI_COMMAND`; a bare - * `orca` can resolve elsewhere once a profile-loading shell rebuilds PATH ahead of Orca's entry. - * - * - Codex on Windows runs PowerShell (pwsh, else Windows PowerShell) and loads its profile, so the - * env var is read as `$env:…` and invoked with `&`. Codex falls back to cmd only when no - * PowerShell exists at all, which Orca cannot see from here. - * - Claude on Windows runs its commands in Git Bash; macOS and Linux shells are POSIX for both. - */ -export function structuredSessionCliInvocation(session: { - platform: NodeJS.Platform - provider: 'claude' | 'codex' -}): StructuredSessionCliInvocation { - return session.platform === 'win32' && session.provider === 'codex' - ? '& $env:ORCA_CLI_COMMAND' - : '"$ORCA_CLI_COMMAND"' -} - export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts index 6b5a363d322..2dd4f86777b 100644 --- a/src/main/runtime/orchestration/formatter.ts +++ b/src/main/runtime/orchestration/formatter.ts @@ -1,6 +1,6 @@ import type { MessageRow } from './types' import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../shared/orchestration-rpc-contract' -import type { OrchestrationCliCommand, StructuredSessionCliInvocation } from './cli-command' +import type { OrchestrationCliCommand } from './cli-command' const BANNER_WIDTH = 60 const SEPARATOR = '─'.repeat(BANNER_WIDTH) @@ -112,14 +112,11 @@ export function formatMessagesForInjection(messages: MessageRow[]): string { export function formatMessagePointer( count: number, mailboxHandle?: string, - cliCommand: OrchestrationCliCommand | StructuredSessionCliInvocation = 'orca', - /** The unacknowledged batch the reader holds; `check` replays it until acked. */ - ackDeliveryId?: string + cliCommand: OrchestrationCliCommand = 'orca' ): string { const noun = count === 1 ? 'message' : 'messages' const runFlag = mailboxHandle?.startsWith('run:') ? ` --run ${mailboxHandle.slice('run:'.length)}` : '' - const ackFlag = ackDeliveryId ? ` --ack ${ackDeliveryId}` : '' - return `\nYou have ${count} ${ackDeliveryId ? 'new ' : ''}orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}${ackFlag}\`.\n` + return `\nYou have ${count} orchestration ${noun}. Run \`${cliCommand} orchestration check${runFlag}\`.\n` } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 5cda0dae160..0b58ba2b283 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -5,6 +5,7 @@ import { type StructuredMailboxPointerHost, type StructuredPointerSettlement } from './structured-mailbox-pointer-delivery' +import { formatMessagePointer } from './formatter' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -139,10 +140,10 @@ function harness(options: { getMessageWaiters: () => undefined, resolveStructuredTarget: (mailboxHandle) => mailboxHandle === mailbox ? { sessionId: IDENTITY.sessionId, dispatchId } : null, + getCliCommand: () => 'orca-dev', host: { readGateFacts: () => (journal === null ? null : structuredSessionGateFacts(journal)), currentFence: () => 4, - cliInvocation: () => '"$ORCA_CLI_COMMAND"', send, ...('wakeTo' in options ? { wake } : {}) } @@ -297,10 +298,9 @@ describe('structured mailbox pointer delivery', () => { expect(send).not.toHaveBeenCalled() }) - it('points newer mail while the reader holds an unacknowledged batch, naming its ack', async () => { + it('points newer mail while the reader holds an unacknowledged batch, in the PTY pointer text', async () => { // The strand this pins: a chat reads a result, ends its turn without acking, and the gate on - // "an unacknowledged batch exists" silenced every later result. `check` replays that batch - // until acked, so the pointer must name the ack to reach the new mail. + // "an unacknowledged batch exists" silenced every later result. The text stays the PTY lane's. const { delivery, send, markAsDelivered } = harness({ journal: idleJournal(), outstandingOwnDelivery: true, @@ -310,7 +310,7 @@ describe('structured mailbox pointer delivery', () => { await flush() expect(send).toHaveBeenCalledTimes(1) expect(send.mock.calls[0]![0].body.blocks[0]).toMatchObject({ - text: expect.stringMatching(/You have 1 new orchestration message\. .*--ack delivery_held`/) + text: formatMessagePointer(1, 'dispatch:d1', 'orca-dev').trim() }) expect(markAsDelivered).toHaveBeenCalledWith(['m2']) }) @@ -485,10 +485,10 @@ describe('forgetting one settled worker', () => { ? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) } : null }, + getCliCommand: () => 'orca', host: { readGateFacts: () => structuredSessionGateFacts(journal), currentFence: () => 4, - cliInvocation: () => '"$ORCA_CLI_COMMAND"', send } }) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index c21a9595dc4..468430c7bf0 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -15,7 +15,7 @@ import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { OrchestrationDb } from './db' import { formatMessagePointer } from './formatter' -import type { StructuredSessionCliInvocation } from './cli-command' +import type { OrchestrationCliCommand } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter @@ -66,8 +66,6 @@ export type StructuredMailboxPointerHost = { }) => Promise /** Current lease fence; `null` when no record backs the session any more. */ currentFence: (sessionId: string) => number | null - /** How this session's shell invokes this app's CLI; see `structuredSessionCliInvocation`. */ - cliInvocation: (sessionId: string) => StructuredSessionCliInvocation /** * Holds the session for one attempt, resuming its provider child if the host evicted it; the * returned release hands it back to the host's release clock. Null when it cannot be resumed. @@ -85,6 +83,8 @@ type StructuredPointerDeliveryDependencies StructuredPointerTarget | null + /** The CLI name the PTY lane types for a local agent, so both lanes send the same pointer. */ + getCliCommand: () => OrchestrationCliCommand host: StructuredMailboxPointerHost onRetain?: (input: { mailboxHandle: string @@ -165,8 +165,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } // Eligibility is "not yet pointed" (`delivered_at`), never "has the consumer acked": a chat that // reads a batch and ends its turn without acking must still be pointed at the NEXT result. The - // batch it holds is excluded, and the pointer names the `--ack` that releases it, since `check` - // replays an unacknowledged batch instead of returning newer mail. + // batch it holds is excluded; its own `check` replays that batch and names its ack. const outstanding = db.getOutstandingMailboxDelivery?.(mailboxHandle) const unread = selectOrchestrationPointerBatch({ db, @@ -179,7 +178,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< } this.inFlight.add(mailboxHandle) try { - await this.attempt(db, mailboxHandle, target, unread, reservedTypes, outstanding?.id) + await this.attempt(db, mailboxHandle, target, unread, reservedTypes) } finally { this.inFlight.delete(mailboxHandle) } @@ -190,12 +189,11 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined, - ackDeliveryId: string | undefined + reservedTypes: ReadonlySet | undefined ): Promise { const release = await this.deps.host.wake?.(target.sessionId) try { - await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes, ackDeliveryId) + await this.attemptAwake(db, mailboxHandle, target, unread, reservedTypes) } finally { release?.() } @@ -206,8 +204,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< mailboxHandle: string, target: StructuredPointerTarget, unread: readonly { id: string; type: string; sequence: number }[], - reservedTypes: ReadonlySet | undefined, - ackDeliveryId: string | undefined + reservedTypes: ReadonlySet | undefined ): Promise { const sessionId = target.sessionId const session = this.deps.host.readGateFacts(sessionId) @@ -227,12 +224,7 @@ export class OrchestrationStructuredMailboxPointerDelivery< blocks: [ { type: 'text', - text: formatMessagePointer( - unread.length, - mailboxHandle, - this.deps.host.cliInvocation(sessionId), - ackDeliveryId - ).trim() + text: formatMessagePointer(unread.length, mailboxHandle, this.deps.getCliCommand()).trim() } ] } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts index 908a23c45ca..f4f39a0bc23 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -12,7 +12,6 @@ import type { StructuredMailboxPointerHost, StructuredPointerSettlement } from './structured-mailbox-pointer-delivery' -import { structuredSessionCliInvocation } from './cli-command' import { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -90,14 +89,6 @@ export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHo return readStructuredSessionGateFacts(sessionId) }, - cliInvocation(sessionId) { - const provider = getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.provider - return structuredSessionCliInvocation({ - platform: process.platform, - provider: provider ?? 'claude' - }) - }, - currentFence(sessionId) { return ( getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null diff --git a/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts b/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts deleted file mode 100644 index 79d633f561e..00000000000 --- a/src/main/runtime/orchestration/structured-session-cli-invocation.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { structuredSessionCliInvocation } from './cli-command' -import { formatMessagePointer } from './formatter' - -describe('how a structured session invokes this app’s CLI, per shell', () => { - it.each([ - ['darwin', 'claude', '"$ORCA_CLI_COMMAND"'], - ['darwin', 'codex', '"$ORCA_CLI_COMMAND"'], - ['linux', 'claude', '"$ORCA_CLI_COMMAND"'], - ['linux', 'codex', '"$ORCA_CLI_COMMAND"'], - // Claude's command tool runs in Git Bash on Windows: still a POSIX expansion. - ['win32', 'claude', '"$ORCA_CLI_COMMAND"'], - // Codex's defaults to PowerShell on Windows, where "$X" is a PS variable and a quoted string - // followed by arguments does not parse; the env var is `$env:X`, invoked with `&`. - ['win32', 'codex', '& $env:ORCA_CLI_COMMAND'] - ] as const)('%s / %s → %s', (platform, provider, invocation) => { - expect(structuredSessionCliInvocation({ platform, provider })).toBe(invocation) - }) - - it('renders a pointer each shell can run as written', () => { - expect( - formatMessagePointer( - 1, - 'run:run_1', - structuredSessionCliInvocation({ platform: 'win32', provider: 'codex' }) - ).trim() - ).toBe( - 'You have 1 orchestration message. Run `& $env:ORCA_CLI_COMMAND orchestration check --run run_1`.' - ) - expect( - formatMessagePointer( - 1, - 'session:s1', - structuredSessionCliInvocation({ platform: 'darwin', provider: 'codex' }) - ).trim() - ).toBe('You have 1 orchestration message. Run `"$ORCA_CLI_COMMAND" orchestration check`.') - }) -}) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 5d6627163d2..f584993b867 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,6 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' +import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -347,12 +348,12 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -// The session's own CLI by its env var: a bare `orca` can resolve elsewhere in a login shell. // Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. const WAIT = { timeout: 10_000 } +// The text the PTY lane types into a terminal coordinator, byte for byte. const POINTER = - /You have 1 orchestration message\. Run `\\?"\$ORCA_CLI_COMMAND\\?" orchestration check --run run_/ + /^You have 1 orchestration message\. Run `orca orchestration check --run run_\w+`\.$/ describe('a worker result reaches the structured chat that coordinates it', () => { it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { @@ -414,8 +415,8 @@ describe('a worker result reaches the structured chat that coordinates it', () = ) await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) - expect(chat.turns[1]!.text).toContain('1 new orchestration message') - expect(chat.turns[1]!.text).toContain(`--ack ${heldDelivery}`) + // The PTY lane's text: `check` itself replays the held batch and names its ack. + expect(chat.turns[1]!.text).toBe(formatMessagePointer(1, `run:${runId}`).trim()) await settleTurn(COORDINATOR, 1) // Exactly once per new message: a retry and the idle edge point nothing further. @@ -644,8 +645,7 @@ describe('any live session is addressable by its id', () => { await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) // Direct mail is not in a Run, so the pointer names no `--run`. - expect(peer.turns[0]!.text).toContain('orchestration check`.') - expect(peer.turns[0]!.text).toContain('$ORCA_CLI_COMMAND') + expect(peer.turns[0]!.text).toBe(formatMessagePointer(1, `session:${PEER_CHAT}`).trim()) await settleTurn(PEER_CHAT, 0) const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) From b7ef86a79e9327b32db3905ca803b40414dfd324 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:30:23 -0700 Subject: [PATCH 47/49] fix(orchestration): deliver a cleared structured worker's mail to its live successor A terminal keeps its handle across /clear; a structured worker's successor now does the same. Mail at the worker's handle, its dispatch mailbox, and a Run it coordinates resolved to the session minted for the worker, which /clear replaced. Each now walks the /clear lineage forward to the live session, which the caller resolver already treats as the worker. --- ...rca-runtime-get-pty-record-for-pane-key.ts | 23 ++++-- .../structured-session-mail-target.test.ts | 77 +++++++++++++++++++ .../structured-session-mail-target.ts | 22 +++++- 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts index 7fc560c1c69..b8e8854764d 100644 --- a/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts +++ b/src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts @@ -12,7 +12,8 @@ import { handleLessCoordinatorSessionId, structuredSessionAddressTarget, structuredSessionMailTarget, - structuredSessionIdleEdgeMailboxes + structuredSessionIdleEdgeMailboxes, + structuredWorkerMailSessionId } from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, @@ -242,8 +243,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (!assignee) { return null } - const identity = resolveStructuredWorkerAuthority(assignee, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId } : null + const sessionId = this.liveStructuredWorkerSessionId(assignee) + return sessionId ? { sessionId, dispatchId } : null } /** @@ -266,8 +267,8 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (!coordinator) { return null } - const identity = resolveStructuredWorkerAuthority(coordinator, this._orchestrationDb)?.identity - return identity ? { sessionId: identity.sessionId, dispatchId: null } : null + const workerSessionId = this.liveStructuredWorkerSessionId(coordinator) + return workerSessionId ? { sessionId: workerSessionId, dispatchId: null } : null } /** @@ -289,11 +290,19 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM // Answers null for anything that is not a live structured worker of THIS runtime, so `run:` // and PTY handles fall through to the PTY lane exactly as before. const identity = resolveStructuredWorkerAuthority(handle, db)?.identity - if (!identity) { + const sessionId = identity ? structuredWorkerMailSessionId(identity.sessionId) : null + if (!identity || !sessionId) { return null } const dispatchId = db?.findActiveDispatchForAssignee?.(handle, identity.paneKey)?.id ?? null - return { sessionId: identity.sessionId, dispatchId } + return { sessionId, dispatchId } + } + + /** The live session behind a structured worker handle of this runtime; see + * `structuredWorkerMailSessionId`. */ + private liveStructuredWorkerSessionId(handle: string): string | null { + const identity = resolveStructuredWorkerAuthority(handle, this._orchestrationDb)?.identity + return identity ? structuredWorkerMailSessionId(identity.sessionId) : null } protected scheduleRestoredMessageRepoints(): void { diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 743b729d987..5ed9c1ce26f 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -29,6 +29,7 @@ const { resolveOrcaSessionParty } = await import('./orchestration-party') const { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, + structuredWorkerIdentities, structuredWorkerProcessIncarnation } = await import('../structured-worker-identity') @@ -452,3 +453,79 @@ describe('a coordinator chat continued by /clear', () => { expect(db.getMessageById(direct.id)).toMatchObject({ to_handle: CHAT_ADDRESS, read: 0 }) }) }) + +describe('a structured worker continued by /clear', () => { + const WORKER = testOrcaSessionId('9c2e4a61-3f7b-4d8e-b105-6a2d8e4f1c93') + const WORKER_SUCCESSOR = testOrcaSessionId('clear-a1b2c3d4e5f60718293a4b5c6d7e8f9012345678') + + afterEach(() => { + structuredWorkerIdentities.clear() + }) + + /** A worker minted for WORKER, whose conversation `/clear` continued in WORKER_SUCCESSOR. */ + function clearedWorker(): { handle: string; paneKey: string } { + const store = installStore(null) + const minted = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER, runtimeKind: 'native' }) + ) + store.records.set(WORKER, { + ...minted, + conversationCommand: { + command: 'clear', + state: 'completed', + replacementSessionId: WORKER_SUCCESSOR, + operationId: 'op-worker', + callerKey: 'caller', + phase: 'committed' + } + }) + store.records.set( + WORKER_SUCCESSOR, + agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId: WORKER_SUCCESSOR, runtimeKind: 'native' }) + ) + ) + const identity = structuredWorkerIdentities.register({ + handle: mintStructuredWorkerHandle(), + sessionId: WORKER, + agent: 'codex', + paneKey: mintStructuredWorkerPaneKey(WORKER), + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return { handle: identity.handle, paneKey: identity.paneKey } + } + + it("delivers mail at the worker's handle to the live successor, as a terminal keeps its handle", () => { + // The strand this pins: the handle resolved to the session minted for it, which `/clear` + // replaced, so the worker's own mail was pointed at a session that no longer runs its turns. + const { handle } = clearedWorker() + expect(probe().target(handle)).toEqual({ sessionId: WORKER_SUCCESSOR, dispatchId: null }) + }) + + it("delivers the worker's dispatch mailbox and a Run it coordinates to the live successor", () => { + const { handle, paneKey } = clearedWorker() + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: chatCoordinatedRun(), spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(WORKER), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(probe().target(`dispatch:${dispatch.id}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: dispatch.id + }) + const workerRun = db.createRun({ + objective: 'o', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey + }).id + expect(probe().target(`run:${workerRun}`)).toEqual({ + sessionId: WORKER_SUCCESSOR, + dispatchId: null + }) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index 23a0d1e5555..dd6ad617ba4 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -1,8 +1,8 @@ /** - * Where a mailbox owned by a structured session is delivered, for sessions that are not structured - * workers: a chat that coordinates a Run (`run:` with no coordinator handle) and a session - * addressed directly at `session:`. The session is resolved here, never a pane, and takes the - * pointer as a session turn. + * Where a mailbox owned by a structured session is delivered: a chat that coordinates a Run + * (`run:` with no coordinator handle), a session addressed directly at `session:`, and the + * live session behind a structured worker's handle. The session is resolved here, never a pane, and + * takes the pointer as a session turn. */ import { @@ -13,12 +13,14 @@ import { } from '../../../shared/orca-session-address' import type { OrchestrationDb } from './db' import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { structuredWorkerHostScope } from '../structured-worker-identity' import type { StructuredPointerTarget } from './structured-mailbox-pointer-delivery' import { addressableSessionParty, structuredSessionMailReach } from './structured-session-mail-address' import { + lineageLiveSession, readAgentSessionRecordStore, type AgentSessionRecordReader } from './structured-session-lineage' @@ -61,6 +63,18 @@ export function structuredSessionMailTarget( : null } +/** + * The session a structured worker's mail reaches: the one minted for it, or that session's live + * `/clear` successor, which carries on as the worker the way a terminal keeps its handle. + */ +export function structuredWorkerMailSessionId( + mintedSessionId: string, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): string | null { + const live = store ? lineageLiveSession(store, mintedSessionId) : null + return live && structuredWorkerHostScope(live.location) ? live.sessionId : null +} + /** * The target of a `session:` mailbox; `undefined` when the handle is not a session address at * all, so other address forms keep their own resolution. From 98d839357aa54ecd5803d775272f2d29b78654b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:40:25 -0700 Subject: [PATCH 48/49] test(orchestration): compare a chat's pointer turn to the PTY lane's text for this build's CLI name --- .../structured-chat-coordinator-mail.test.ts | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index f584993b867..86d9210d9aa 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -351,9 +351,21 @@ afterEach(async () => { // Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. const WAIT = { timeout: 10_000 } -// The text the PTY lane types into a terminal coordinator, byte for byte. const POINTER = - /^You have 1 orchestration message\. Run `orca orchestration check --run run_\w+`\.$/ + /You have 1 orchestration message\. Run `orca(-dev)? orchestration check --run run_\w+`\./ + +/** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ +function ptyPointer(mailboxHandle: string): string { + return formatMessagePointer(1, mailboxHandle, runtime.getLocalOrchestrationCliCommand()).trim() +} + +/** The text of a turn the fake provider received. */ +function turnText(turn: { text: string }): string { + const input: unknown = JSON.parse(turn.text) + return Array.isArray(input) + ? input.map((item: unknown) => (isRecord(item) ? String(item.text) : '')).join('') + : '' +} describe('a worker result reaches the structured chat that coordinates it', () => { it('lands as a turn in the coordinator journal, and a flagless check returns the worker_done', async () => { @@ -364,8 +376,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = // No user action: the result itself sends the chat a turn through the host's send. await vi.waitFor(() => expect(chat.turns).toHaveLength(1), WAIT) - expect(chat.turns[0]!.text).toMatch(POINTER) - expect(chat.turns[0]!.text).toContain(runId) + expect(turnText(chat.turns[0]!)).toBe(ptyPointer(`run:${runId}`)) await settleTurn(COORDINATOR, 0) expect(userTexts(COORDINATOR)).toEqual([expect.stringMatching(POINTER)]) @@ -416,7 +427,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(idOf(second.task), { handle: 'term_worker_2', paneKey: WORKER_2_PANE }) await vi.waitFor(() => expect(chat.turns).toHaveLength(2), WAIT) // The PTY lane's text: `check` itself replays the held batch and names its ack. - expect(chat.turns[1]!.text).toBe(formatMessagePointer(1, `run:${runId}`).trim()) + expect(turnText(chat.turns[1]!)).toBe(ptyPointer(`run:${runId}`)) await settleTurn(COORDINATOR, 1) // Exactly once per new message: a retry and the idle edge point nothing further. @@ -645,7 +656,7 @@ describe('any live session is addressable by its id', () => { await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) // Direct mail is not in a Run, so the pointer names no `--run`. - expect(peer.turns[0]!.text).toBe(formatMessagePointer(1, `session:${PEER_CHAT}`).trim()) + expect(turnText(peer.turns[0]!)).toBe(ptyPointer(`session:${PEER_CHAT}`)) await settleTurn(PEER_CHAT, 0) const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) From e07dfca35339bca67a8e102aa2fc782f999b2361 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 14:11:37 -0700 Subject: [PATCH 49/49] refactor(orchestration): resolve the local CLI name once, for the PTY lane and the structured lane alike --- ...a-runtime-get-orchestration-dispatch-authority.ts | 12 +----------- .../runtime/orca-runtime-stop-requested-pty-ids.ts | 3 ++- src/main/runtime/orchestration/cli-command.ts | 11 +++++++++++ .../runtime/structured-chat-coordinator-mail.test.ts | 3 ++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index 8d9275fbb81..ba327c612b1 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -16,9 +16,9 @@ import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtim import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { resolveTerminalOrchestrationCliCommand, + runtimeOrchestrationCliCommand, type OrchestrationCliCommand } from './orchestration/cli-command' -import { getAppEnvironment, hasAppEnvironment } from '../../shared/app-environment' import type { FleetAgentStatusEvidence } from '../../shared/orchestration-fleet-agent-status-evidence' import { readOrchestrationFleetAgentStatusSnapshot } from './orchestration-fleet-agent-status-snapshot' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' @@ -269,14 +269,4 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim : undefined }) } - - /** What a local, non-WSL terminal is told to run; a structured session is always one. */ - getLocalOrchestrationCliCommand(): OrchestrationCliCommand { - return runtimeOrchestrationCliCommand() ?? 'orca' - } -} - -/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ -function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { - return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined } diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 620aad8499e..a7175383df0 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrchestrationStructuredMailboxPointerDelivery } from './orchestration/structured-mailbox-pointer-delivery' import { createStructuredMailboxPointerHost } from './orchestration/structured-mailbox-pointer-host' +import { localOrchestrationCliCommand } from './orchestration/cli-command' import { isStructuredWorkerHandle } from './structured-worker-identity' import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' @@ -221,7 +222,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), - getCliCommand: () => this.getLocalOrchestrationCliCommand(), + getCliCommand: localOrchestrationCliCommand, host: createStructuredMailboxPointerHost() }) diff --git a/src/main/runtime/orchestration/cli-command.ts b/src/main/runtime/orchestration/cli-command.ts index 809be9a4b88..af38b95a350 100644 --- a/src/main/runtime/orchestration/cli-command.ts +++ b/src/main/runtime/orchestration/cli-command.ts @@ -1,9 +1,20 @@ import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' import { isWslUncPath } from '../../../shared/wsl-paths' import { splitWorktreeIdForFilesystem } from '../../../shared/worktree/id' +import { getAppEnvironment, hasAppEnvironment } from '../../../shared/app-environment' export type OrchestrationCliCommand = 'orca' | 'orca-dev' | 'orca-ide' +/** Dev builds run the CLI as `orca-dev`; a packaged app, or a process with no app, must not advertise it. */ +export function runtimeOrchestrationCliCommand(): OrchestrationCliCommand | undefined { + return hasAppEnvironment() && !getAppEnvironment().isPackaged() ? 'orca-dev' : undefined +} + +/** What a local, non-WSL terminal is told to run; a structured session is always one. */ +export function localOrchestrationCliCommand(): OrchestrationCliCommand { + return runtimeOrchestrationCliCommand() ?? 'orca' +} + export function resolveTerminalOrchestrationCliCommand(args: { connectionId: string | null isWsl: boolean | null | undefined diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 86d9210d9aa..a53a66bff3d 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -20,6 +20,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' +import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' import type { RpcRequest } from './rpc/core' @@ -356,7 +357,7 @@ const POINTER = /** The text the PTY lane types into a local terminal for this mailbox, byte for byte. */ function ptyPointer(mailboxHandle: string): string { - return formatMessagePointer(1, mailboxHandle, runtime.getLocalOrchestrationCliCommand()).trim() + return formatMessagePointer(1, mailboxHandle, localOrchestrationCliCommand()).trim() } /** The text of a turn the fake provider received. */