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/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/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/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-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/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f..c8ade53e907 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -6,6 +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 '../../../shared/agent-session-caller-env' export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 'orchestration dispatch': async ({ flags, client, cwd, json }) => { @@ -42,9 +43,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/message-check-handler.ts b/src/cli/handlers/orchestration/message-check-handler.ts index f2af827ee04..6ace43eac31 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 pane key it inherited 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/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/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index e693d99079d..b97500db89b 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -2,15 +2,28 @@ 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 { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../../../shared/agent-session-caller-env' +/** + * 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 { + // 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) if (explicit) { return explicit @@ -35,7 +48,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') { @@ -157,11 +170,16 @@ function getClientErrorMessage(err: unknown): string | undefined { return typeof message === 'string' ? message : undefined } +/** How check output names its caller: the handle, or the session's address. */ +export function orchestrationCallerLabel(handle: string | undefined): string { + return handle ?? injectedSessionAddress() ?? 'unknown' +} + export async function resolveCoordinatorTerminalHandle( flags: Map, cwd: string, client: RuntimeClient -): Promise { +): Promise { return await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from', { validateEnvHandle: true }) @@ -204,7 +222,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/cli/index-orchestration.test.ts b/src/cli/index-orchestration.test.ts index c4e4438f2e9..bc7366f5b11 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 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(() => {}) + 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 33566dcf837..ede5fd9f9b4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,8 @@ 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' +import { refuseConflictingSessionCallerFlags } from './session-caller-flags' export { COMMAND_SPECS } from './specs' export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors' @@ -110,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') @@ -232,5 +238,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/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts new file mode 100644 index 00000000000..59bcae5b845 --- /dev/null +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -0,0 +1,615 @@ +/** + * 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 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' + +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 { 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' + +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' } +] + +/** 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, + json = true +): Promise { + const handler = ORCHESTRATION_HANDLERS[`orchestration ${command}`] + 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. + 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 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_inherited_pane', + ORCA_PANE_KEY: 'tab_inherited: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(asSessionWithInheritedPane) + + 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() + // An inherited 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 caller, 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 an inherited pane handle too: the session, not the pane, is the caller', + async () => { + 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() + } + ) + + 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.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(asSessionWithInheritedPane) + + 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() + + 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' }) + + 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 + } + 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') + 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 () => { + asSessionWithInheritedPane() + 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('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) => { + asSessionWithInheritedPane() + 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({ + ORCA_AGENT_SESSION_ID: ` ${SESSION} `, + ORCA_TERMINAL_HANDLE: 'term_inherited_pane' + }) + + expect(envelope.orchestrationCompatibilityEvidence).toEqual({ + terminalHandle: 'term_inherited_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('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 caller, before any request', + async ({ command, flag }) => { + asSessionWithInheritedPane() + 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 }) => { + asSessionWithInheritedPane() + 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[] = [] + 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' + ]) + + asSessionWithInheritedPane() + expect(await verbsThatGuess()).toEqual([]) + }) +}) 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/cli/session-caller-flags.ts b/src/cli/session-caller-flags.ts new file mode 100644 index 00000000000..cbefa9ee2b8 --- /dev/null +++ b/src/cli/session-caller-flags.ts @@ -0,0 +1,57 @@ +/** + * 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 { + injectedSessionAddress, + readInjectedAgentSessionId +} from '../shared/agent-session-caller-env' +import { ORCA_SESSION_ADDRESS_PREFIX } from '../shared/orca-session-address-prefix' + +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 ` + + `${ORCA_SESSION_ADDRESS_PREFIX}${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. Plain + * 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 === `${ORCA_SESSION_ADDRESS_PREFIX}${sessionId}` || + value === injectedSessionAddress(env) + ) +} 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/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.'] }, { 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..cdb843ad5be 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: expect.stringMatching(/^[^:;]*[\\/]cli[\\/]bin[\\/]orca-dev$/) + }) + 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/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/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/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/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;') }) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 201efc0b0c5..4dcd49e9645 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,10 @@ import { structuredWorkerProcessIncarnation } 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', () => { expect( @@ -28,11 +32,16 @@ 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_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) }) }) - 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 +53,12 @@ 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_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) }) const handle = mintStructuredWorkerHandle() @@ -61,7 +74,8 @@ describe('buildCodexStructuredChildEnvironment', () => { try { const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) - expect(env.ORCA_CLI_COMMAND).toBe('orca') + expect(env.ORCA_AGENT_SESSION_ID).toBe(sessionId) + 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 { @@ -69,3 +83,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: expect.stringMatching(DEV_CLI_LAUNCHER), + path: expect.stringMatching(DEV_CLI_BIN_FIRST) + }) + } finally { + await connection.close() + } + }) +}) 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/codex/codex-structured-session-adapter.test.ts b/src/main/codex/codex-structured-session-adapter.test.ts index 2dd4b8d78af..5c2fcc4e086 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,12 @@ 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_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[:;]/) }) 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..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,6 +413,17 @@ describe('registerPtyHandlers', () => { ]) ) }) + 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, { + 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']) + ) + }) 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. @@ -443,7 +454,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. + expect(env.ORCA_CLI_COMMAND).toBe(join(shimDir, 'orca')) } finally { Object.defineProperty(process, 'platform', { configurable: true, @@ -460,6 +472,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-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index c36fa3e40f5..bed6953e2b1 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,16 @@ 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 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/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 && diff --git a/src/main/ipc/pty/host-env/pi-agent.ts b/src/main/ipc/pty/host-env/pi-agent.ts index 545d04d063d..d2dd8803fa4 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,16 @@ 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) + // 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/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/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/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-orchestration-dispatch-authority.ts b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts index d61374c3466..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 } 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,8 +263,7 @@ 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 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..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 @@ -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 { releaseRestoredStructuredPointerClaims } from './orchestration/structured-pointer-claim-restore' +import { + handleLessCoordinatorSessionId, + structuredSessionAddressTarget, + structuredSessionMailTarget, + structuredSessionIdleEdgeMailboxes, + structuredWorkerMailSessionId +} from './orchestration/structured-session-mail-target' import { resolveTerminalIdentityFromProbes, type RuntimeTerminalIdentity @@ -187,6 +195,24 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM this.orchestrationStructuredMailboxPointerDelivery.onJournalActivity(sessionId) } + /** + * 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) + * is pointed again. Workers and chats alike: this is not per-dispatch. + */ + onStructuredSessionStatusForMail(summary: { + sessionId: string + status: 'working' | 'attention' | 'idle' | null + }): void { + if (summary.status === 'working' || summary.status === 'attention') { + return + } + this.notifyStructuredSessionJournalActivity(summary.sessionId) + const openDb = () => this.getExistingOrchestrationDb() + const deliver = (mailbox: string) => this.deliverPendingMessagesForHandle(mailbox) + structuredSessionIdleEdgeMailboxes(summary.sessionId, openDb).forEach(deliver) + } + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ forgetStructuredSessionMail(sessionId: string): void { this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) @@ -205,6 +231,10 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM if (mailboxHandle.startsWith('run:')) { return this.resolveStructuredCoordinatorMailboxTarget(mailboxHandle.slice('run:'.length)) } + const addressed = structuredSessionAddressTarget(mailboxHandle, this._orchestrationDb) + if (addressed !== undefined) { + return addressed + } if (!mailboxHandle.startsWith('dispatch:')) { return this.resolveStructuredWorkerDirectMailboxTarget(mailboxHandle) } @@ -213,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 } /** @@ -228,12 +258,17 @@ 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, this._orchestrationDb) + } + const coordinator = run?.coordinator_handle 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 } /** @@ -255,17 +290,27 @@ 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 { + 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/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index cf7e8e56db1..4f97af0de25 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -165,6 +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) => { + this.onStructuredSessionStatusForMail(summary) 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..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,6 +222,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), resolveStructuredTarget: (mailboxHandle) => this.resolveStructuredMailboxTarget(mailboxHandle), + getCliCommand: localOrchestrationCliCommand, host: createStructuredMailboxPointerHost() }) 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/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/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/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/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/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/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/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts index 048fc7732e4..0b58ba2b283 100644 --- a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -2,8 +2,10 @@ 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 { formatMessagePointer } from './formatter' import { structuredSessionGateFacts } from './structured-session-pointer-delivery' import type { StructuredWorkerIdentity } from '../structured-worker-identity' @@ -73,31 +75,61 @@ function attentionJournal(): AgentJournalRenderItem[] { function harness(options: { journal: AgentJournalRenderItem[] | null - dispatchState?: 'accepted' | 'rejected' | 'unknown' + 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 + /** 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 + /** 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 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) { + return null + } + journal = options.wakeTo + return released + }) 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, + markAsUndelivered, getStructuredPointerOperation: (key: string) => stored.get(key), putStructuredPointerOperation: (row: { mailbox_handle: string }) => stored.set(row.mailbox_handle, row), @@ -108,15 +140,20 @@ 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, - send + send, + ...('wakeTo' in options ? { wake } : {}) } }) return { delivery, markAsDelivered, + markAsUndelivered, + released, + wake, send: sendMock, stored, setJournal: (next: AgentJournalRenderItem[] | null) => { @@ -252,15 +289,97 @@ 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, 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. The text stays the PTY lane's. + 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: formatMessagePointer(1, 'dispatch:d1', 'orca-dev').trim() + }) + expect(markAsDelivered).toHaveBeenCalledWith(['m2']) + }) + + it('counts a turn the provider admitted but has not echoed as pointed', async () => { + 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 () => { // A rejection consumes no mail and nothing else redrives this mailbox, so leaving it unparked // stranded the worker until unrelated mail happened to arrive. @@ -297,6 +416,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() { @@ -312,7 +469,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, @@ -328,6 +485,7 @@ describe('forgetting one settled worker', () => { ? { sessionId, dispatchId: mailboxHandle.slice('dispatch:'.length) } : null }, + getCliCommand: () => 'orca', host: { readGateFacts: () => structuredSessionGateFacts(journal), currentFence: () => 4, diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts index 62a555d4119..468430c7bf0 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 type { OrchestrationCliCommand } from './cli-command' import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter @@ -44,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 = { @@ -61,6 +66,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 = { @@ -73,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 @@ -151,20 +163,16 @@ 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; its own `check` replays that batch and names its ack. + 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 } @@ -182,6 +190,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) @@ -198,7 +221,12 @@ 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, this.deps.getCliCommand()).trim() + } + ] } const staged = unread.map((message) => message.id) const operation = resolveStructuredPointerOperation({ @@ -224,22 +252,56 @@ 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) + 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 after an accepted dispatch. + * 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 91bf1158e07..8cc5412e1aa 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,7 @@ 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'], + // 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( @@ -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: [] } + }) + 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 b722952df92..f4f39a0bc23 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 { structuredSessionGateFacts, type StructuredSessionGateFacts @@ -56,8 +59,32 @@ 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 (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) + }, + readGateFacts(sessionId) { return readStructuredSessionGateFacts(sessionId) }, @@ -94,12 +121,26 @@ 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; its settlement says whether a turn ran. const state = result.value.submission.dispatchState - return { - kind: 'sent', - state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : '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/orchestration/structured-pointer-claim-restore.test.ts b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts new file mode 100644 index 00000000000..c373a4b0720 --- /dev/null +++ b/src/main/runtime/orchestration/structured-pointer-claim-restore.test.ts @@ -0,0 +1,107 @@ +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' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' + +const SESSION = testOrcaSessionId('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, coordinatorOrcaSessionId: 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 +} 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 new file mode 100644 index 00000000000..4352cdb17b1 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -0,0 +1,94 @@ +/** + * 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. + * + * 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, + * a conversation has ended only when its chat was closed. + */ + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +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 { 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 } + /** 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 = + /** `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' | '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 { + const live = lineageLiveSession(store, record.sessionId) + if (!live) { + return { kind: 'ended', reason: 'continuation-missing' } + } + if (!structuredWorkerHostScope(live.location)) { + return { kind: 'other-host' } + } + 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' } + } + const visible = store.getVisibleSessionTabIndex?.() + 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', session: live } +} + +/** + * 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 addressableSessionParty( + sessionId: OrcaSessionId, + 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 + } +} 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..5ed9c1ce26f --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -0,0 +1,531 @@ +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, + 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 } + +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 { resolveOrcaSessionParty } = await import('./orchestration-party') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('../structured-worker-identity') + +const CHAT = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT) + +/** 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 { + // 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(), + ...extra + }) as MailTargetProbe +} + +function chatCoordinatedRun(): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id +} + +beforeEach(() => { + db = new OrchestrationDb(':memory:') + hostRef.current = null +}) + +afterEach(() => { + db.close() +}) + +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. + 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('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() + + 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('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 + .prepare('UPDATE runs SET consumer_generation = consumer_generation + 1 WHERE id = ?') + .run(runId) + expect(probe().target(`run:${runId}`)).toBeNull() + }) + + 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', + coordinatorOrcaSessionId: CHAT + }).id + expect(probe().target(`run:${runId}`)).toBeNull() + }) +}) + +describe('a session addressed directly', () => { + it('owns its `session:` mailbox', () => { + installStore(chatRecord()) + expect(probe().target(CHAT_ADDRESS)).toEqual({ sessionId: CHAT, dispatchId: null }) + }) + + it('claims nothing for a malformed session address', () => { + installStore(chatRecord()) + expect(probe().target('session:term_abc')).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(), + cancelMessageWaiters: 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_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_ADDRESS]) + }) +}) + +describe('the idle edge after a restart, before any orchestration call', () => { + 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 stored = new OrchestrationDb(join(userData, 'orchestration.db')) + const runId = stored.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT + }).id + stored.close() + const delivered: string[] = [] + 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', () => { + installStore(chatRecord()) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const deliver = vi.fn() + probe({ + _orchestrationDb: null, + getExistingOrchestrationDb: () => { + 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 = testOrcaSessionId('clear-fedcba9876543210fedcba9876543210fedcba98') + const SUCCESSOR = testOrcaSessionId('clear-0123456789abcdef0123456789abcdef01234567') + + 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 + } + + /** 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.visible.sessionIds.push(lineage.at(-1)!) + } + + function idleEdge(sessionId: string): string[] { + const delivered: string[] = [] + probe({ + deliverPendingMessagesForHandle: (handle: string) => delivered.push(handle), + notifyStructuredSessionJournalActivity: vi.fn() + }).onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + return delivered + } + + function runCreatedBy(sessionId: OrcaSessionId): string { + return db.createRun({ + objective: 'o', + coordinatorHandle: null, + coordinatorPaneKey: null, + 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. + installLineage(SUCCESSOR) + chatCoordinatedRun() + const ownRun = runCreatedBy(SUCCESSOR) + const generation = db.getRunRaw(ownRun)!.consumer_generation + + expect(idleEdge(SUCCESSOR)).toContain(`run:${ownRun}`) + idleEdge(SUCCESSOR) + + const successor = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(ownRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + expect(db.getCurrentRunForCoordinator(successor)?.id).toBe(ownRun) + }) + + 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}`) + + expect(db.getRunRaw(runId)).toMatchObject({ + coordinator_orca_session_id: CHAT, + consumer_generation: generation + }) + for (const member of [CHAT, MIDDLE, SUCCESSOR]) { + expect(db.getCurrentRunForCoordinator(resolveOrcaSessionParty(member, db))?.id).toBe(runId) + } + expect(probe().target(`run:${runId}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) + }) + + 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 = resolveOrcaSessionParty(SUCCESSOR, db) + expect(db.getRunRaw(middleRun)).toMatchObject({ + coordinator_orca_session_id: successor.orcaSessionId, + consumer_generation: generation + }) + 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(resolveOrcaSessionParty(member, db)).toMatchObject({ + orcaSessionId: CHAT, + address: CHAT_ADDRESS + }) + expect(probe().target(`session:${member}`)).toEqual({ + sessionId: SUCCESSOR, + dispatchId: null + }) + } + const direct = db.insertMessage({ + from: 'term_peer', + to: CHAT_ADDRESS, + subject: 'hi', + type: 'status' + }) + expect(idleEdge(SUCCESSOR)).toEqual([CHAT_ADDRESS]) + 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 new file mode 100644 index 00000000000..dd6ad617ba4 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -0,0 +1,128 @@ +/** + * 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 { + ORCA_SESSION_ADDRESS_PREFIX, + isOrcaSessionId, + parseOrcaSessionAddress, + type OrcaSessionId +} 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' +import type { RunRow } from './types' + +/** + * 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 + * a session here, and only by an Orca session id that still counts (see + * `currentRunCoordinatorOrcaSessionId`). + */ +export function handleLessCoordinatorSessionId( + run: Pick< + RunRow, + | 'coordinator_handle' + | 'coordinator_orca_session_id' + | 'coordinator_orca_session_id_generation' + | 'consumer_generation' + > +): OrcaSessionId | null { + if (run.coordinator_handle !== null) { + return null + } + return currentRunCoordinatorOrcaSessionId(run) +} + +/** + * 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 structuredSessionMailTarget( + sessionId: string, + db: OrchestrationDb | null | undefined, + store: AgentSessionRecordReader | null = readAgentSessionRecordStore() +): StructuredPointerTarget | null { + const record = store?.getRecord(sessionId) + const reach = store && record ? structuredSessionMailReach(store, record, db) : null + return reach?.kind === 'reachable' + ? { sessionId: reach.session.sessionId, dispatchId: null } + : 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. + */ +export function structuredSessionAddressTarget( + mailboxHandle: string, + db: OrchestrationDb | null | undefined +): StructuredPointerTarget | null | undefined { + if (!mailboxHandle.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + return undefined + } + const sessionId = parseOrcaSessionAddress(mailboxHandle) + return sessionId ? structuredSessionMailTarget(sessionId, db) : 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) is found again. + */ +export function structuredSessionOwnedMailboxes(sessionId: string, db: OrchestrationDb): string[] { + const party = isOrcaSessionId(sessionId) ? addressableSessionParty(sessionId, db) : null + if (!party) { + return [] + } + const mailboxes = db.runsBoundToCoordinator(party).map((run) => `run:${run.id}`) + if (db.getUnreadDirectMessageTypes(party.address).length > 0) { + mailboxes.push(party.address) + } + return mailboxes +} + +/** 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 | null +): string[] { + let db: OrchestrationDb | null + 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 [] + } + return db ? structuredSessionOwnedMailboxes(sessionId, db) : [] +} diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts index 4071d17a073..ee0a010f939 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,21 @@ 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' +export function structuredDispatchDelivered( + state: StructuredDispatchState +): state is 'accepted' | 'pending' { + 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/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-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 493f9d092c3..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 @@ -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 } @@ -19,8 +21,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 +85,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({ @@ -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') }) @@ -224,10 +234,16 @@ describe('structured worker session hold', () => { }) describe('structured worker dispatch preamble', () => { - function hostWithSubmission(submission: Record) { + function hostWithSubmission( + 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 } }) + send: async () => ({ ok: true, value: { clientMessageId: 'c1', submission } }), + waitForSendSettlement: async () => + settlesTo ? { value: { clientMessageId: 'c1', submission: settlesTo } } : undefined } as never } @@ -240,6 +256,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 } 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 861cfadeac6..84844c4db61 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-lineage' +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, db) + 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..b7e17523689 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -0,0 +1,118 @@ +/** + * 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 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 { + 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 { + lookupOrcaAgentSession, + 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. */ +export type SessionRecipient = { sessionId: OrcaSessionId; address: OrcaSessionAddress } + +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(ORCA_SESSION_ADDRESS_PREFIX) || isOrcaSessionId(recipient) +} + +/** + * 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(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 sessionId = isOrcaSessionId(recipient) ? recipient : null + const found = sessionId && store ? lookupOrcaAgentSession(store, sessionId) : null + if (found?.kind === 'provider-id') { + return providerIdRefusal(recipient, found.orcaSessionId) + } + return sessionId && found?.kind === 'found' + ? { sessionId, address: formatOrcaSessionAddress(sessionId) } + : null +} + +/** Null when mail to this session can be stored and delivered here; otherwise why not. */ +export function refuseUndeliverableSessionRecipient( + recipient: SessionRecipient, + store: AgentSessionRecordReader | null, + db: OrchestrationDb +): 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, db) + 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.notLive, + message: + 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.` + } + } + 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 ${ORCA_SESSION_ADDRESS_PREFIX}${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..0d2253481b4 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' @@ -30,6 +29,8 @@ import { resolveOrcaSessionParty, resolveOrchestrationParty } from '../orchestration/orchestration-party' +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' @@ -149,10 +150,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 } @@ -163,16 +164,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 +182,6 @@ async function readSessionRecord( ) } -function sessionRecordStore(): { - getRecord: (sessionId: string) => AgentSessionRecord | null - listRecords: () => AgentSessionRecord[] -} | 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/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index bbf199e7f50..f8f2de266d4 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -9,9 +9,11 @@ import { ADDRESS_X, ADDRESS_Y, createSessionCallerHarness, - orchestrationRequest, idOf, + isRecord, + orchestrationRequest, resultOf, + PROVIDER_ID_X, SESSION_X, SESSION_Y, sessionRecord, @@ -26,6 +28,198 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' type Row = Record +describe('a send addressed to an agent session', () => { + let h: SessionCallerHarness + let visible: string[] + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + visible = [SESSION_X, SESSION_Y] + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, + listRecords: () => [...h.records.values()], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: visible }) + } + } + } + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function send(to: string): Promise { + const response: unknown = await h.dispatch( + orchestrationRequest('orchestration.send', { from: 'term_worker', to, subject: 'hello' }) + ) + 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 () => { + // 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(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: ADDRESS_X } } + }) + }) + + 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 = idOf(resultOf(created).run) + expect(await send(ADDRESS_X)).toMatchObject({ + ok: true, + result: { message: { to_handle: `run:${runId}` } } + }) + }) + + 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([]) + }) + + 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 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(errorMessage(sent)).toContain('its chat was closed') + expect(h.db.getInbox(100)).toEqual([]) + }) + + 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' + }) + 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: '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(errorMessage(sent)).toContain('worker identity') + }) + + 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' } + }) + }) +}) + +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' }] }) + }) +}) + describe('mail sent to a session address reaches the mailbox that session reads', () => { let h: SessionCallerHarness @@ -70,18 +264,35 @@ describe('mail sent to a session address reaches the mailbox that session reads' }) }) - it('refuses an Orca session this host does not run', async () => { + 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 of [ADDRESS_X, ADDRESS_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: 'terminal_not_found' } }) + expect(response).toMatchObject({ ok: false, error: { code } }) } }) 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..a53a66bff3d --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -0,0 +1,679 @@ +// 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 { 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' +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 +} 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' +const WORKER_2_PANE = 'tab_worker2:cccccccc-cccc-4ccc-8ccc-cccccccccccc' + +type FakeConnection = Omit & { + closed: boolean + handlers: CodexAppServerConnectionHandlers + threadId: string | null + turns: { clientUserMessageId: string; text: string }[] +} + +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, + threadId: null, + turns: [], + pid: 4321, + closed: false, + request: async (method, params) => { + const input = isRecord(params) ? params : {} + 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' as const, + wslDistro: null, + workspaceId: WORKSPACE, + workspaceKind: 'git-worktree' as const + }, + provider: 'codex' as const, + agent: 'codex' as const, + 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 }) + }) + } + } +} + +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 resultOf(response) +} + +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, + worker: { handle: string; paneKey: string } = { handle: 'term_worker', paneKey: WORKER_PANE } +): Promise { + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId, + startOptions: {} + }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: worker.handle, + paneKey: worker.paneKey, + processIncarnation: `runtime_test:${worker.handle}:1`, + worktreeId: 'repo::worker', + effects: [], + setupState: 'not_applicable' + }) + db.markWorkerDispatchReady(started.dispatch.id) + await call( + 'orchestration.send', + { + from: worker.handle, + 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 = idOf(created.run) + const task = await call( + 'orchestration.taskCreate', + { spec: 'build it' }, + { + sessionId: COORDINATOR + } + ) + 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-')) + 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 : handle === 'term_worker_2' ? WORKER_2_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, + // 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 }) +}) + +// 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(-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, localOrchestrationCliCommand()).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 () => { + 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), WAIT) + expect(turnText(chat.turns[0]!)).toBe(ptyPointer(`run:${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), 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([]), + WAIT + ) + expect(chat.turns).toHaveLength(1) + 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), WAIT) + 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), WAIT) + // The PTY lane's text: `check` itself replays the held batch and names its ack. + 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. + 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('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() + // 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), WAIT) + const revived = connectionFor(COORDINATOR) + 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)]) + }) + + 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), WAIT) + expect(chat.turns[1]!.text).toMatch(POINTER) + expect(chat.turns[1]!.text).toContain(runId) + }) +}) + +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_orca_session_id: 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_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_orca_session_id).toBe(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("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { + 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_orca_session_id: root, + coordinator_orca_session_id_generation: db.getRunRaw(first)!.consumer_generation + }) + expect(boundOrcaSessionId(first)).toBe(root) + const second = idOf( + (await call('orchestration.runCreate', { objective: 'second' }, { sessionId: middle })).run + ) + 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_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 } }) + }) + + 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) + + 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), WAIT) + // Direct mail is not in a Run, so the pointer names no `--run`. + 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' }] }) + }) + + 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_caller_not_live' } }) + expect(db.getInbox(100)).toEqual([]) + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.test.ts b/src/main/runtime/structured-session-child-identity-env.test.ts new file mode 100644 index 00000000000..1289e979ad6 --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.test.ts @@ -0,0 +1,193 @@ +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' + +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 { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerHostScope, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from './structured-worker-identity' + +const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' +const USER_DATA = '/data/orca' +const RESOURCES = '/app/Resources' +const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') + +const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') + +function pinPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { configurable: true, value: platform }) +} + +function registerWorker(): string { + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + return handle +} + +beforeEach(() => { + shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() + shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) + Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) +}) + +afterEach(() => { + structuredWorkerIdentities.clear() + Object.defineProperty(process, 'platform', platformDescriptor) + if (resourcesDescriptor) { + Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) + } else { + Reflect.deleteProperty(process, 'resourcesPath') + } +}) + +describe('structuredSessionChildIdentityEnv', () => { + 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: `${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: 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() + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(childEnv).toEqual({ PATH: '/usr/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 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.ORCA_AGENT_SESSION_ID).toBe(SESSION_ID) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + }) + + 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, 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(join(SHIM_DIR, '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`) + expect(env.ORCA_CLI_COMMAND).toBe(join(RESOURCES, 'bin', 'orca')) + }) + + 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() + // 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', () => { + 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`) + expect(env.ORCA_CLI_COMMAND).toBe(join(USER_DATA, 'cli', 'bin', 'orca-dev')) + }) + }) + + 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('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. + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + const env = structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) + expect(env.ORCA_PANE_KEY).toBeUndefined() + expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) + }) + + it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { + // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that + // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — + // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. + expect( + structuredWorkerHostScope({ + executionHostId: 'local', + workspaceId: 'wt_1', + workspaceKind: 'git-worktree', + wslDistro: 'Ubuntu' + }) + ).toBeNull() + pinPlatform('linux') + installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) + registerWorker() + expect( + structuredSessionChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }).ORCA_CLI_COMMAND + ).not.toBe('orca-ide') + }) +}) diff --git a/src/main/runtime/structured-session-child-identity-env.ts b/src/main/runtime/structured-session-child-identity-env.ts new file mode 100644 index 00000000000..62a901860cf --- /dev/null +++ b/src/main/runtime/structured-session-child-identity-env.ts @@ -0,0 +1,91 @@ +/** + * 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. + * + * 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. + * + * 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. 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, + * 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. + * + * `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. + */ + +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' + +export function structuredSessionChildIdentityEnv( + sessionId: string, + childEnv: Record +): Record { + const identity = structuredWorkerIdentities.getBySessionId(sessionId) + const env: Record = { + ...childEnv, + ...(identity ? { ORCA_TERMINAL_HANDLE: identity.handle } : {}), + [ORCA_AGENT_SESSION_ID_ENV]: sessionId, + [ORCA_STRUCTURED_SESSION_ENV]: '1' + } + applyThisAppCli(env) + return 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. + */ +function applyThisAppCli(env: Record): void { + delete env.ORCA_CLI_COMMAND + if (!hasAppEnvironment()) { + return + } + const app = getAppEnvironment() + const userDataPath = app.getPath('userData') + env.ORCA_USER_DATA_PATH = userDataPath + const launcher = prependOrcaCliDirToChildPath(env, { + isPackaged: app.isPackaged(), + 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/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 new file mode 100644 index 00000000000..2079dc44eb6 --- /dev/null +++ b/src/main/runtime/structured-session-cli-login-shell.test.ts @@ -0,0 +1,38 @@ +/** + * 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 { 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 bash login shell', () => { + let harness: LoginShellHarness + + beforeEach(() => { + harness = createLoginShellHarness() + }) + + afterEach(() => { + harness.dispose() + }) + + 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 () => { + 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 }) + } +} 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) + }) +}) diff --git a/src/main/runtime/structured-worker-child-identity-env.test.ts b/src/main/runtime/structured-worker-child-identity-env.test.ts deleted file mode 100644 index e966dd55824..00000000000 --- a/src/main/runtime/structured-worker-child-identity-env.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' - -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 { - mintStructuredWorkerHandle, - mintStructuredWorkerPaneKey, - structuredWorkerHostScope, - structuredWorkerIdentities, - structuredWorkerProcessIncarnation -} from './structured-worker-identity' - -const SESSION_ID = 'f7a1c0de-1111-4222-8333-444455556666' -const USER_DATA = '/data/orca' -const RESOURCES = '/app/Resources' -const SHIM_DIR = join(USER_DATA, 'linux-orca-cli-shim') - -const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')! -const resourcesDescriptor = Object.getOwnPropertyDescriptor(process, 'resourcesPath') - -function pinPlatform(platform: NodeJS.Platform): void { - Object.defineProperty(process, 'platform', { configurable: true, value: platform }) -} - -function registerWorker(): string { - const handle = mintStructuredWorkerHandle() - structuredWorkerIdentities.register({ - handle, - sessionId: SESSION_ID, - agent: 'claude', - paneKey: mintStructuredWorkerPaneKey(SESSION_ID), - processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), - worktreeId: 'wt_1', - hostScope: { kind: 'local', hostId: 'local' } - }) - return handle -} - -beforeEach(() => { - shim.ensureLinuxTerminalOrcaCliShimDir.mockReset() - shim.ensureLinuxTerminalOrcaCliShimDir.mockReturnValue(SHIM_DIR) - Object.defineProperty(process, 'resourcesPath', { configurable: true, value: RESOURCES }) -}) - -afterEach(() => { - structuredWorkerIdentities.clear() - Object.defineProperty(process, 'platform', platformDescriptor) - if (resourcesDescriptor) { - Object.defineProperty(process, 'resourcesPath', resourcesDescriptor) - } else { - Reflect.deleteProperty(process, 'resourcesPath') - } -}) - -describe('structuredWorkerChildIdentityEnv', () => { - 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. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - const childEnv = { PATH: '/usr/bin' } - const env = structuredWorkerChildIdentityEnv(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() - 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() - }) - - 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 = structuredWorkerChildIdentityEnv(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('gives a packaged-macOS worker the bundled CLI dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(RESOURCES, 'bin')}:/usr/bin`) - }) - - 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 = structuredWorkerChildIdentityEnv(SESSION_ID, { Path: 'C:\\Windows' }) - expect(env.Path).toBe(`${join(RESOURCES, 'bin')};C:\\Windows`) - expect(env.PATH).toBeUndefined() - }) - - it('gives an unpackaged worker the dev launcher dir', () => { - pinPlatform('darwin') - installFakeAppEnvironment({ isPackaged: () => false, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.PATH).toBe(`${join(USER_DATA, 'cli', 'bin')}:/usr/bin`) - }) - - 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. - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - const env = structuredWorkerChildIdentityEnv(SESSION_ID, { PATH: '/usr/bin' }) - expect(env.ORCA_PANE_KEY).toBeUndefined() - expect(Object.keys(env).filter((key) => key.includes('PANE'))).toEqual([]) - }) - - it('never names the WSL-scoped launcher, because a structured worker cannot run in WSL', () => { - // `orca-ide` is the literal the PTY lane exports for WSL only. A structured session that - // resolves to a WSL distro is refused a host scope, so it never becomes a worker at all — - // which is why the bare-`orca` shim, not the literal, is the right fix on Linux. - expect( - structuredWorkerHostScope({ - executionHostId: 'local', - workspaceId: 'wt_1', - workspaceKind: 'git-worktree', - wslDistro: 'Ubuntu' - }) - ).toBeNull() - pinPlatform('linux') - installFakeAppEnvironment({ isPackaged: () => true, getPath: () => USER_DATA }) - registerWorker() - expect( - structuredWorkerChildIdentityEnv(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-worker-child-identity-env.ts deleted file mode 100644 index 6cf9f46e910..00000000000 --- a/src/main/runtime/structured-worker-child-identity-env.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * The orchestration identity — and the CLI reachability — a structured worker's own child needs - * to speak for itself. - * - * 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. - * - * `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. - * - * 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. - * - * 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 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 { 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( - 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, - ORCA_CLI_COMMAND: 'orca' - } - applyOrcaCliPath(env) - return 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. - */ -function applyOrcaCliPath(env: Record): void { - if (!hasAppEnvironment()) { - return - } - const app = getAppEnvironment() - prependOrcaCliDirToChildPath(env, { - isPackaged: app.isPackaged(), - userDataPath: app.getPath('userData'), - resourcesPath: process.resourcesPath ?? null - }) -} 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.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', 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..5f66128dc5f --- /dev/null +++ b/src/shared/agent-session-caller-env.ts @@ -0,0 +1,38 @@ +/** + * 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( + env: Readonly> = process.env +): string | undefined { + 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}` +} 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 339ccb01f43..d0098ea2ef9 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,17 +1,19 @@ 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), 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 * 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 @@ -24,7 +26,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..c30dc5a6139 100644 --- a/src/shared/structured-session-marker.ts +++ b/src/shared/structured-session-marker.ts @@ -1,13 +1,18 @@ /** - * The marker a structured chat session's child carries when it has NO orchestration identity. + * Marks a structured session's child. It names nothing — no handle, no pane key, no token. * - * 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. + * 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. + * + * 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 } diff --git a/src/shared/structured-worker-handle.ts b/src/shared/structured-worker-handle.ts new file mode 100644 index 00000000000..c9fd3927c02 --- /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): handle is string { + return typeof handle === 'string' && handle.startsWith(STRUCTURED_WORKER_HANDLE_PREFIX) +}