diff --git a/src/cli/handlers/orchestration/terminal-identity.ts b/src/cli/handlers/orchestration/terminal-identity.ts index 5da11afac1d..2eb7be46484 100644 --- a/src/cli/handlers/orchestration/terminal-identity.ts +++ b/src/cli/handlers/orchestration/terminal-identity.ts @@ -133,7 +133,9 @@ async function resolveImplicitOrchestrationSender( client: RuntimeClient ): Promise { try { - return await getTerminalHandle(flags, cwd, client) + // Unambiguous: naming the sender is an identity claim, so an arbitrary pick would let this + // command speak as a sibling worker. + return await getTerminalHandle(flags, cwd, client, { requireUnambiguous: true }) } catch (err) { if (!isNoActiveTerminalError(err)) { throw err diff --git a/src/cli/handlers/orchestration/worker-launch-handler.ts b/src/cli/handlers/orchestration/worker-launch-handler.ts index a6161e61ef0..7aebe01052c 100644 --- a/src/cli/handlers/orchestration/worker-launch-handler.ts +++ b/src/cli/handlers/orchestration/worker-launch-handler.ts @@ -53,6 +53,8 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record retryOf: getOptionalStringFlag(flags, 'retry-of'), timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), run: getOptionalStringFlag(flags, 'run'), + // Omitted rather than false so the payload matches every other optional flag. + structured: flags.has('structured') ? flags.get('structured') !== 'false' : undefined, from: await resolveCoordinatorTerminalHandle(flags, cwd, client), devMode: isDevCliInvocation() }) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 0f90dc94508..0db6ef82ff4 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -206,14 +206,18 @@ export async function getBrowserWorktreeSelector( export async function getTerminalHandle( flags: Map, cwd: string, - client: RuntimeClient + client: RuntimeClient, + options: { requireUnambiguous?: boolean } = {} ): Promise { const explicit = getOptionalStringFlag(flags, 'terminal') if (explicit) { return explicit } const worktree = await getBrowserWorktreeSelector(flags, cwd, client) - const response = await client.call<{ handle: string }>('terminal.resolveActive', { worktree }) + const response = await client.call<{ handle: string }>('terminal.resolveActive', { + worktree, + ...(options.requireUnambiguous ? { requireUnambiguous: true } : {}) + }) return response.result.handle } diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 8bac305a87b..3de5ba03ddf 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -5,7 +5,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'worker-start'], summary: 'Start one supervised worker on the Run home or a connected Orca server', usage: - 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--model ] [--effort ] [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--structured] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'task', @@ -21,6 +21,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ 'model', 'effort', 'terminal', + 'structured', 'retry-of', 'timeout-ms', 'run', @@ -33,6 +34,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ '--model supports Claude, Codex, and Cursor opaque provider model ids; --effort requires --model. Neither can combine with --terminal.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', + '--structured starts the worker as a native structured chat session instead of a terminal agent. Local claude/codex only; it cannot combine with --terminal or a remote --on.', '--on selects only the worker server; the Run and this command remain on the current Orca server.', 'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.', '--retry-of links the replacement attempt but does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.', diff --git a/src/main/claude/claude-structured-launch-resolution.ts b/src/main/claude/claude-structured-launch-resolution.ts index 4f28f14ad65..f0f3e4922cd 100644 --- a/src/main/claude/claude-structured-launch-resolution.ts +++ b/src/main/claude/claude-structured-launch-resolution.ts @@ -4,6 +4,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 { CLAUDE_AUTH_ENV_CONFLICT_MESSAGE, CLAUDE_AUTH_SWITCH_IN_PROGRESS_MESSAGE, @@ -245,7 +246,9 @@ export function createClaudeStructuredLaunchResolver( platform: process.platform } ), - ...(overlay ? cloneDefinedEnv(overlay) : {}) + ...(overlay ? cloneDefinedEnv(overlay) : {}), + // Only a dispatched structured worker gets these; an ordinary chat session gets none. + ...structuredWorkerChildIdentityEnv(record.sessionId) }, { platform: process.platform } ) diff --git a/src/main/codex/codex-structured-child-environment.test.ts b/src/main/codex/codex-structured-child-environment.test.ts index 98e988ec7d5..4369803e177 100644 --- a/src/main/codex/codex-structured-child-environment.test.ts +++ b/src/main/codex/codex-structured-child-environment.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' import { CODEX_SPAWN_TOKEN_ENV } from './codex-structured-owner-identity' import { buildCodexStructuredChildEnvironment } from './codex-structured-child-environment' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../runtime/structured-worker-identity' describe('buildCodexStructuredChildEnvironment', () => { it('keeps shell exports while pinned launch values win', () => { @@ -14,7 +20,8 @@ describe('buildCodexStructuredChildEnvironment', () => { resumeThreadId: null, env: { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/shell/home' } }, - 'spawn-token' + 'spawn-token', + 'session-not-a-worker' ) ).toEqual({ EXAMPLE_GATEWAY_TOKEN: 'shell-exported', @@ -22,4 +29,39 @@ describe('buildCodexStructuredChildEnvironment', () => { [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token' }) }) + + it('adds the orchestration handle only for a registered structured worker', () => { + const launch = { + command: 'codex', + args: ['app-server'], + cwd: '/worktree', + codexHome: null, + resumeThreadId: null, + env: {} + } + const sessionId = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + expect(buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId)).toEqual({ + [CODEX_SPAWN_TOKEN_ENV]: 'spawn-token' + }) + + const handle = mintStructuredWorkerHandle() + structuredWorkerIdentities.register({ + handle, + sessionId, + agent: 'codex', + paneKey: mintStructuredWorkerPaneKey(sessionId), + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + try { + const env = buildCodexStructuredChildEnvironment(launch, 'spawn-token', sessionId) + expect(env.ORCA_TERMINAL_HANDLE).toBe(handle) + expect(env.ORCA_CLI_COMMAND).toBe('orca') + // A pane key here would leak into hook-emitted agent statuses, which assume a PTY leaf. + expect(env.ORCA_PANE_KEY).toBeUndefined() + } finally { + structuredWorkerIdentities.forget(handle) + } + }) }) diff --git a/src/main/codex/codex-structured-child-environment.ts b/src/main/codex/codex-structured-child-environment.ts index 88326eb3fc0..d49c44eac10 100644 --- a/src/main/codex/codex-structured-child-environment.ts +++ b/src/main/codex/codex-structured-child-environment.ts @@ -1,13 +1,17 @@ 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' export function buildCodexStructuredChildEnvironment( launch: CodexStructuredLaunch, - spawnToken: string + spawnToken: string, + sessionId: string ): Record { return { ...launch.env, ...(launch.codexHome ? { CODEX_HOME: launch.codexHome } : {}), + // Only a dispatched structured worker gets these; an ordinary chat session gets none. + ...structuredWorkerChildIdentityEnv(sessionId), [CODEX_SPAWN_TOKEN_ENV]: spawnToken } } diff --git a/src/main/codex/codex-structured-session-acquire.ts b/src/main/codex/codex-structured-session-acquire.ts index 04a48a6250e..78855842332 100644 --- a/src/main/codex/codex-structured-session-acquire.ts +++ b/src/main/codex/codex-structured-session-acquire.ts @@ -106,7 +106,7 @@ export async function acquireCodexStructuredSession(input: { command: launch.command, args: launch.args, cwd: launch.cwd, - env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken) + env: buildCodexStructuredChildEnvironment(launch, acquireInput.spawnToken, sessionId) }, { onNotification: (method, params) => diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index f6001d2faef..bfce80ee46d 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -1,4 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { + observeStructuredWorker, + resolveStructuredWorkerAuthority +} from './structured-worker-authority' +import type { RuntimeLeafRecord } from './runtime-terminal-state-records' import { OrcaRuntimeWithSubscribeToTerminalResize } from './orca-runtime-subscribe-to-terminal-resize' import type { RuntimeMobileSessionTabsResult, @@ -82,7 +87,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim // Why: when --terminal is omitted, the CLI auto-resolves to the active // terminal in the current worktree — matching browser's implicit active tab. - async resolveActiveTerminal(worktreeSelector?: string): Promise { + async resolveActiveTerminal( + worktreeSelector?: string, + options: { requireUnambiguous?: boolean } = {} + ): Promise { if (this.graphStatus !== 'ready') { const targetWorktreeId = worktreeSelector ? (await this.resolveWorktreeSelector(worktreeSelector)).id @@ -105,6 +113,10 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim const listed = await this.listTerminals(worktreeSelector, undefined, { includeVisualLayouts: false }) + // Same arbitrary pick, same misattribution: refuse for callers claiming their own identity. + if (options.requireUnambiguous && listed.terminals.length > 1) { + throw new Error('no_active_terminal') + } const first = listed.terminals[0]?.handle if (first) { return first @@ -117,8 +129,11 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim ? (await this.resolveWorktreeSelector(worktreeSelector)).id : null - // Prefer the tab's activeLeafId — this is the pane the user last focused - for (const tab of this.tabs.values()) { + // Prefer the tab's activeLeafId — this is the pane the user last focused. + // + // Skipped entirely for an identity claim: which pane the user last looked at says nothing + // about which terminal the CALLER is, so preferring it is still a guess. + for (const tab of options.requireUnambiguous ? [] : this.tabs.values()) { if (targetWorktreeId && tab.worktreeId !== targetWorktreeId) { continue } @@ -132,12 +147,28 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim } } - // Fallback: any leaf in the target worktree + // Fallback: any leaf in the target worktree. + // + // `requireUnambiguous` callers are asking "which terminal AM I", and an arbitrary + // iteration-order pick answers that with someone else's pane: a bare `check` then reads and + // consumes a sibling's dispatch mailbox, and a bare `worker_done` can settle a sibling's + // context-only dispatch, which has no capability token to reject on. Refusing is the only + // safe answer when more than one leaf could be meant. + const candidates: RuntimeLeafRecord[] = [] for (const leaf of this.leaves.values()) { if (targetWorktreeId && leaf.worktreeId !== targetWorktreeId) { continue } - return this.issueHandle(leaf) + if (!options.requireUnambiguous) { + return this.issueHandle(leaf) + } + candidates.push(leaf) + if (candidates.length > 1) { + break + } + } + if (candidates.length === 1) { + return this.issueHandle(candidates[0]!) } throw new Error('no_active_terminal') @@ -147,10 +178,25 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim // identity at dispatch time; null (best-effort) rather than throwing so // dispatch still works for handles without a resolvable pane. getTerminalPaneKey(handle: string): string | null { - return this.getPaneKeyForTerminalHandle(handle) + return ( + resolveStructuredWorkerAuthority(handle, this.getOrchestrationDbIfAvailable?.() ?? null) + ?.identity.paneKey ?? this.getPaneKeyForTerminalHandle(handle) + ) } getLiveTerminalPaneKey(handle: string): string | null { + const structured = resolveStructuredWorkerAuthority( + handle, + this.getOrchestrationDbIfAvailable?.() ?? null + ) + if (structured) { + // `resolveBareOrchestrationRecipient` routes direct mail through this, not through + // getTerminalPaneKey. The connected-gate below exists so mail is never routed to a corpse, + // so the structured answer needs a real liveness proof too, not just a registry hit. + return observeStructuredWorker(structured.identity).status === 'live' + ? structured.identity.paneKey + : null + } const runtimePty = this.getLivePtyForHandle(handle) if (runtimePty) { return runtimePty.pty.connected ? (runtimePty.pty.paneKey ?? null) : null 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 8e77f733dc6..86ca292b8ed 100644 --- a/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts +++ b/src/main/runtime/orca-runtime-get-orchestration-dispatch-authority.ts @@ -10,6 +10,7 @@ import type { ProjectExecutionRuntimeResolution } from '../../shared/project-exe import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { resolveTerminalOrchestrationCliCommand } from './orchestration/cli-command' +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller { /** Every pane key this PTY could be addressed by, including restored receipts. */ @@ -34,6 +35,26 @@ export class OrcaRuntimeWithGetOrchestrationDispatchAuthority extends OrcaRuntim getOrchestrationDispatchAuthority( terminalHandle: string ): OrchestrationCompatibilityTerminalAuthority | null { + const structured = resolveStructuredWorkerAuthority( + terminalHandle, + this.getOrchestrationDbIfAvailable?.() ?? null + ) + if (structured) { + return { + runtimeId: this.runtimeId, + terminalHandle, + // Both EMPTY on purpose. `verifyOrchestrationCompatibilityCaller` falls back to the + // restored-authority receipt keyed by ptyId when there is no launch token, so filling + // either of these in would silently open hook attestation to a session that has no PTY, + // no launch secret, and no hook to attest with. + ptyId: '', + worktreeId: structured.identity.worktreeId, + processIncarnation: structured.identity.processIncarnation, + paneKey: structured.identity.paneKey, + launchTokenHash: null, + hostScope: structured.identity.hostScope + } + } let ptyId: string | null try { ptyId = 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 32dd82fd48c..b7e2c52c428 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 @@ -4,6 +4,10 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' import { detectAgentStatusFromTitle, isClaudeManagementTitle } from '../../shared/agent-detection' import { recognizeAgentProcess } from '../../shared/agent-process-recognition' +import { agentSessionPtyWriteGate } from './agent-session-pty-write-gate' +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' +import { isSettledNativeOwner } from './orchestration/structured-session-pointer-delivery' +import type { StructuredPointerTarget } from './orchestration/structured-mailbox-pointer-delivery' export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneMobileSessionTabGroupLayout { protected getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { @@ -144,6 +148,67 @@ export class OrcaRuntimeWithGetPtyRecordForPaneKey extends OrcaRuntimeWithPruneM this.orchestrationMailboxNotifications.deliverForHandle(handle, reservedTypes) } + /** The structured idle edge: any journal movement is a chance to redrive parked mail. */ + notifyStructuredSessionJournalActivity(sessionId: string): void { + this.orchestrationStructuredMailboxPointerDelivery.onTurnSettled(sessionId) + } + + /** Settlement drops anything parked for the session; nothing will ever redrive it again. */ + forgetStructuredSessionMail(sessionId: string): void { + this.orchestrationStructuredMailboxPointerDelivery.forgetSession(sessionId) + } + + /** + * The session a mailbox must be nudged through, or null when a live PTY can take the bytes. + * + * DELIBERATELY only `dispatch:` mailboxes. `run:` mail is coordinator mail, and structured + * coordinators are out of scope: a coordinator blocks in `check --wait`, where a waiter preempts + * pointer delivery anyway. The asymmetry with the PTY lane — which serves both — is the scope + * line, not an oversight. + */ + protected resolveStructuredMailboxTarget(mailboxHandle: string): StructuredPointerTarget | null { + if (!mailboxHandle.startsWith('dispatch:')) { + return null + } + const dispatchId = mailboxHandle.slice('dispatch:'.length) + const assignee = this._orchestrationDb?.getDispatchContextById?.(dispatchId)?.assignee_handle + if (!assignee) { + return null + } + const identity = resolveStructuredWorkerAuthority(assignee, this._orchestrationDb)?.identity + if (identity) { + return { sessionId: identity.sessionId, dispatchId } + } + return this.resolveAdoptedStructuredMailboxTarget(assignee, dispatchId) + } + + /** + * A PTY-born worker whose pane was since adopted by native chat. + * + * Its bytes cannot land — every runtime write path re-admits through the same gate — so the + * pointer has to travel as a session turn instead. Only a SETTLED native owner qualifies: a + * mid-handoff lease may become a TUI again, and redirecting there races the takeover. + */ + protected resolveAdoptedStructuredMailboxTarget( + assignee: string, + dispatchId: string + ): StructuredPointerTarget | null { + let ptyId: string | null | undefined + try { + ptyId = this.getLiveLeafForHandle(assignee).leaf.ptyId + } catch { + return null + } + if (!ptyId) { + return null + } + const admission = agentSessionPtyWriteGate.admit(ptyId) + if (admission.admitted || !isSettledNativeOwner(admission.refusal)) { + return null + } + return { sessionId: admission.refusal.sessionId, dispatchId, refusal: admission.refusal } + } + protected scheduleRestoredMessageRepoints(): void { let handles: string[] try { diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index f428404de90..a3f22a1a83a 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -1,4 +1,5 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { resolveStructuredWorkerAuthority } from './structured-worker-authority' import { OrcaRuntimeWithAdoptTerminalOrphansFromInventory } from './orca-runtime-adopt-terminal-orphans-from-inventory' import type { RuntimeTerminalAgentStatus, @@ -119,6 +120,13 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd } getTerminalProcessIncarnation(handle: string): string | null { + const structured = resolveStructuredWorkerAuthority( + handle, + this.getOrchestrationDbIfAvailable?.() ?? null + ) + if (structured) { + return structured.identity.processIncarnation + } const live = this.getLivePtyForHandle(handle) const record = live?.record ?? this.handles.get(handle) if (!record?.ptyId) { 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 346006cd5fd..d76b8ad2be3 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -1,4 +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 { isStructuredWorkerHandle } from './structured-worker-identity' import { OrcaRuntimeWithRuntimeId } from './orca-runtime-runtime-id' import { RuntimeTerminalAgentPresence } from './runtime-terminal-agent-presence' import type { RuntimeNotifier } from './runtime-notifier-contract' @@ -183,6 +186,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getDb: () => this._orchestrationDb, getTerminalHandleForPaneKey: (paneKey) => this.getTerminalHandleForPaneKey(paneKey), hasTerminalHandle: (handle) => this.handles.has(handle), + isStructuredWorkerHandle: (handle) => isStructuredWorkerHandle(handle), canProbePtyLiveness: () => Boolean(this.ptyController?.probePtyLiveness), controllerKnowsPtyIsLive: (ptyId) => this.controllerKnowsPtyIsLive(ptyId), isLeafPtyProvenAbsent: (ptyId) => this.isLeafPtyProvenAbsent(ptyId) @@ -204,10 +208,20 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId writePty: (ptyId, data) => this.writeOrchestrationPointerPty(ptyId, data) }) + protected readonly orchestrationStructuredMailboxPointerDelivery = + new OrchestrationStructuredMailboxPointerDelivery({ + getDb: () => this._orchestrationDb, + getMessageWaiters: (mailboxHandle) => this.messageWaiters.get(mailboxHandle), + resolveStructuredTarget: (mailboxHandle) => + this.resolveStructuredMailboxTarget(mailboxHandle), + host: createStructuredMailboxPointerHost() + }) + protected readonly orchestrationMailboxNotifications = new OrchestrationMailboxNotificationCoordinator({ mailboxOwner: this.orchestrationMailboxOwner, pointerDelivery: this.orchestrationMailboxPointerDelivery, + structuredPointerDelivery: this.orchestrationStructuredMailboxPointerDelivery, getDb: () => this._orchestrationDb, getLiveLeafForHandle: (handle) => this.getLiveLeafForHandle(handle).leaf, getPaneKeyForHandle: (handle) => { diff --git a/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts b/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts index 8ccac863d22..035df6827d7 100644 --- a/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts +++ b/src/main/runtime/orca-runtime-subscribe-to-terminal-resize.ts @@ -1,4 +1,9 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. +import { + sessionIdFromStructuredWorkerIncarnation, + structuredWorkerIdentities +} from './structured-worker-identity' +import { observeStructuredWorker } from './rpc/methods/orchestration-structured-worker-lifecycle' import { OrcaRuntimeWithApplyMobileDisplayMode } from './orca-runtime-apply-mobile-display-mode' import { addListenerToMap } from './orca-runtime-core' import { notifyRuntimeListeners, withTimeoutResult } from './runtime-async-boundaries' @@ -150,6 +155,13 @@ export class OrcaRuntimeWithSubscribeToTerminalResize extends OrcaRuntimeWithApp processIncarnation: string, serializedHostScope: string | null ): Promise<'live' | 'exited' | 'unverifiable'> { + const structuredSessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) + if (structuredSessionId) { + // A structured session has no PTY, so the process table can only ever fail to find it — + // answering `exited` from that absence would release a running provider child. + const identity = structuredWorkerIdentities.getBySessionId(structuredSessionId) + return identity ? observeStructuredWorker(identity).status : 'unverifiable' + } const hostScope = parseWorkerTerminalHostScope(serializedHostScope) if (!hostScope || !this.ptyController?.listProcesses) { return 'unverifiable' diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 58fd52ebc6c..0166d662831 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -30,7 +30,12 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith const admission = agentSessionPtyWriteGate.admit(ptyId) if (!admission.admitted) { this.orchestrationPointerAdmissionByPtyId.delete(ptyId) - return this.ptyController?.write(ptyId, data) ?? false + // Refused means refused. Delegating to the controller only re-derives the same refusal + // one level down and reports `pty:writeUnavailable` to the renderer, whose handler runs + // transport RECOVERY — so a pointer that was declined on purpose, on a perfectly healthy + // pane, used to kick a user-input-failure flow on every redrive. A native-owned pane is + // served by the structured lane instead, which resolves before any byte is attempted. + return false } this.orchestrationPointerAdmissionByPtyId.set(ptyId, { sessionId: admission.sessionId, diff --git a/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts b/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts new file mode 100644 index 00000000000..989ad607bed --- /dev/null +++ b/src/main/runtime/orchestration/adopted-structured-pointer-delivery.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentSessionPtyWriteGate } from '../agent-session-pty-write-gate' +import { OrcaRuntimeWithWriteOrchestrationPointerPty } from '../orca-runtime-write-orchestration-pointer-pty' +import { OrcaRuntimeWithGetPtyRecordForPaneKey } from '../orca-runtime-get-pty-record-for-pane-key' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' + +const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const PTY_ID = 'pty_adopted' + +// Both methods are protected, and a subclass is the sanctioned way to reach them. The probes +// borrow the REAL implementations through the real prototype chain; a re-declared copy would pin +// nothing. +class PointerWriteProbe extends OrcaRuntimeWithWriteOrchestrationPointerPty { + probeWritePointer(ptyId: string, data: string): boolean | Promise { + return this.writeOrchestrationPointerPty(ptyId, data) + } +} + +class MailboxTargetProbe extends OrcaRuntimeWithGetPtyRecordForPaneKey { + probeResolveTarget(mailboxHandle: string): unknown { + return this.resolveStructuredMailboxTarget(mailboxHandle) + } +} + +/** A probe instance whose prototype chain is the real class, with only its state stubbed. */ +function probe( + prototype: TProbe, + state: TState +): TProbe & TState { + return Object.assign(Object.create(prototype), state) as TProbe & TState +} + +/** A pane bound to a session a settled NATIVE owner holds — the adopted-TUI state. */ +function bindNativeOwnedPane(overrides: Partial = {}): void { + agentSessionPtyWriteGate.attachRecordLookup( + (sessionId) => + ({ + sessionId, + location: { executionHostId: 'local', wslDistro: null }, + lease: { + sessionId, + runtimeKind: 'native', + claimStatus: 'live', + handoffStage: null, + unreconciled: false, + ownerProcess: { pid: 4242 }, + runtimeFence: 7, + ...overrides + } + }) as unknown as AgentSessionRecord + ) + agentSessionPtyWriteGate.bindPty(PTY_ID, SESSION_ID) +} + +afterEach(() => { + agentSessionPtyWriteGate.detachRecordLookup() + vi.restoreAllMocks() +}) + +describe('an orchestration pointer aimed at an adopted pane', () => { + it('reaches no provider and never reports a write failure to the renderer', () => { + bindNativeOwnedPane() + const write = vi.fn(() => true) + const writeWithSettlement = vi.fn(async () => true) + const stub = { + orchestrationPointerAdmissionByPtyId: new Map(), + ptyController: { write, writeWithSettlement } + } + // Zero bytes: the controller path would re-admit, refuse again, and fire + // `pty:writeUnavailable`, whose renderer handler runs transport RECOVERY on a healthy pane. + expect( + probe(PointerWriteProbe.prototype, stub).probeWritePointer( + PTY_ID, + 'You have 1 orchestration message.' + ) + ).toBe(false) + expect(write).not.toHaveBeenCalled() + expect(writeWithSettlement).not.toHaveBeenCalled() + }) + + it('still writes through when nothing owns the pane', () => { + const write = vi.fn(() => true) + const stub = { + orchestrationPointerAdmissionByPtyId: new Map(), + ptyController: { write } + } + expect( + probe(PointerWriteProbe.prototype, stub).probeWritePointer('pty_unbound', 'pointer') + ).toBe(true) + expect(write).toHaveBeenCalledTimes(1) + }) +}) + +describe('the mailbox target for an adopted pane', () => { + function targetStub() { + return probe(MailboxTargetProbe.prototype, { + _orchestrationDb: { + getDispatchContextById: () => ({ assignee_handle: 'term_adopted' }) + }, + getLiveLeafForHandle: () => ({ leaf: { ptyId: PTY_ID } }) + }) + } + + it('routes the mailbox to the owning session so the nudge travels as a turn', () => { + bindNativeOwnedPane() + const target = targetStub().probeResolveTarget('dispatch:d1') as { + sessionId: string + dispatchId: string + refusal?: { ownerRuntimeKind: string } + } | null + expect(target).toMatchObject({ sessionId: SESSION_ID, dispatchId: 'd1' }) + expect(target?.refusal?.ownerRuntimeKind).toBe('native') + }) + + it('leaves a mid-handoff lease to the PTY lane', () => { + bindNativeOwnedPane({ handoffStage: 'preparing' }) + expect(targetStub().probeResolveTarget('dispatch:d1')).toBeNull() + }) + + it('leaves an unowned pane to the PTY lane', () => { + expect(targetStub().probeResolveTarget('dispatch:d1')).toBeNull() + }) +}) diff --git a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts index 69f12d9bbd6..3ba643b1f73 100644 --- a/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/attach-orchestration-db-methods.ts @@ -29,6 +29,7 @@ import { attachDirectMailboxRouting } from './messages/direct-mailbox-routing' import { attachForeignDirectMailboxRouting } from './messages/foreign-direct-mailbox-routing' import { attachMessageInbox } from './messages/message-inbox' import { attachMessageInsert } from './messages/message-insert' +import { attachStructuredPointerOperationStore } from './messages/structured-pointer-operation-store' import { attachMutationReceiptStore } from './mutation-receipts/mutation-receipt-store' import { attachQuestionThreads } from './questions/question-threads' import { attachOrchestrationReset } from './reset/orchestration-reset' @@ -85,6 +86,7 @@ export function attachOrchestrationDbMethods(ctor: { prototype: object }): void attachLegacyCoordinatorMailTakeover(ctor) attachRunDelivery(ctor) attachMessageInsert(ctor) + attachStructuredPointerOperationStore(ctor) attachMessageInbox(ctor) attachDirectMailboxRouting(ctor) attachForeignDirectMailboxRouting(ctor) diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 56390138f0a..fbb5a9a1a37 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -7,4 +7,4 @@ export const LEGACY_CONTRACT_VERSION = 0 export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity. -export const SCHEMA_VERSION = 30 +export const SCHEMA_VERSION = 31 diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts b/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts index 9b7581d6acd..d5daffc2807 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer-boundary.test.ts @@ -99,7 +99,7 @@ describe('live-worker row insert boundary', () => { // first two drafts of this ratchet failed against their own tree. const exempt = [ 'src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts', - 'src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts', + 'src/main/runtime/orchestration/db/schema/migrate-v13-latest.ts', 'src/main/runtime/orchestration/db/reset/orchestration-reset.ts' ] for (const rel of exempt) { 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 new file mode 100644 index 00000000000..62e10822b41 --- /dev/null +++ b/src/main/runtime/orchestration/db/messages/structured-pointer-operation-store.ts @@ -0,0 +1,64 @@ +import type { OrchestrationDb } from '../orchestration-db' + +/** The live agent-session operation id backing one structured worker mailbox's pointer send. */ +export type StructuredPointerOperationRow = { + mailbox_handle: string + session_id: string + operation_id: string + body_fingerprint: string + minted_at_ms: number +} + +export function getStructuredPointerOperation( + this: OrchestrationDb, + mailboxHandle: string +): StructuredPointerOperationRow | undefined { + return this.db + .prepare('SELECT * FROM structured_pointer_operations WHERE mailbox_handle = ?') + .get(mailboxHandle) as StructuredPointerOperationRow | undefined +} + +export function putStructuredPointerOperation( + this: OrchestrationDb, + row: StructuredPointerOperationRow +): void { + this.db + .prepare( + `INSERT INTO structured_pointer_operations + (mailbox_handle, session_id, operation_id, body_fingerprint, minted_at_ms) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(mailbox_handle) DO UPDATE SET + session_id = excluded.session_id, operation_id = excluded.operation_id, + body_fingerprint = excluded.body_fingerprint, minted_at_ms = excluded.minted_at_ms` + ) + .run( + row.mailbox_handle, + row.session_id, + row.operation_id, + row.body_fingerprint, + row.minted_at_ms + ) +} + +export function deleteStructuredPointerOperation( + this: OrchestrationDb, + mailboxHandle: string +): void { + this.db + .prepare('DELETE FROM structured_pointer_operations WHERE mailbox_handle = ?') + .run(mailboxHandle) +} + +export type StructuredPointerOperationStoreMethods = { + getStructuredPointerOperation: typeof getStructuredPointerOperation + putStructuredPointerOperation: typeof putStructuredPointerOperation + deleteStructuredPointerOperation: typeof deleteStructuredPointerOperation +} + +export function attachStructuredPointerOperationStore(ctor: { prototype: object }): void { + Object.assign(ctor.prototype, { + getStructuredPointerOperation, + putStructuredPointerOperation, + deleteStructuredPointerOperation + }) +} diff --git a/src/main/runtime/orchestration/db/orchestration-db-methods.ts b/src/main/runtime/orchestration/db/orchestration-db-methods.ts index 7f25b209c54..928f15f4f22 100644 --- a/src/main/runtime/orchestration/db/orchestration-db-methods.ts +++ b/src/main/runtime/orchestration/db/orchestration-db-methods.ts @@ -57,6 +57,7 @@ import type { WorkerTerminalRecoveryMethods } from './worker-dispatch/worker-ter import type { WorkerTerminalArchiveMethods } from './worker-terminal/worker-terminal-archive' import type { WorkerTerminalListingMethods } from './worker-terminal/worker-terminal-listing' import type { WorkerTerminalReleaseMethods } from './worker-terminal/worker-terminal-release' +import type { StructuredPointerOperationStoreMethods } from './messages/structured-pointer-operation-store' import type { WorkerTerminalResourceStoreMethods } from './worker-terminal/worker-terminal-resource-store' import type { WorkerTerminalTransferMethods } from './worker-terminal/worker-terminal-transfer' @@ -107,6 +108,7 @@ export type OrchestrationDbMethods = CreateTablesMethods & FederationRelayImportMethods & RemoteQuestionStoreMethods & FederationRelayItemMethods & + StructuredPointerOperationStoreMethods & WorkerTerminalResourceStoreMethods & WorkerTerminalTransferMethods & WorkerTerminalReleaseMethods & diff --git a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts index 1b3172edf31..1a880d6f262 100644 --- a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts @@ -166,10 +166,21 @@ CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_identity CREATE INDEX IF NOT EXISTS idx_worker_terminal_resources_release ON worker_terminal_resources(release_state); +-- One live agent-session operation id per structured worker mailbox. Persisted because the id is +-- the send's idempotency key: re-minting it after a restart would re-deliver an already-queued +-- pointer as a second turn. +CREATE TABLE IF NOT EXISTS structured_pointer_operations ( + mailbox_handle TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + body_fingerprint TEXT NOT NULL, + minted_at_ms INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS worker_terminal_archives ( dispatch_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, - kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail')), + kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail', 'structured_journal')), content TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); diff --git a/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts b/src/main/runtime/orchestration/db/schema/migrate-v13-latest.ts similarity index 86% rename from src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts rename to src/main/runtime/orchestration/db/schema/migrate-v13-latest.ts index 654395bcd2c..960e18b4f44 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v13-v30.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v13-latest.ts @@ -5,7 +5,7 @@ import { } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' -export function applySchemaMigrationsV13ToV30(this: OrchestrationDb, current: number): void { +export function applySchemaMigrationsV13ToLatest(this: OrchestrationDb, current: number): void { if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) { this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT') } @@ -183,6 +183,33 @@ export function applySchemaMigrationsV13ToV30(this: OrchestrationDb, current: nu AND pane_key IS NOT NULL; `) } + if (current < 31) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS structured_pointer_operations ( + mailbox_handle TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + body_fingerprint TEXT NOT NULL, + minted_at_ms INTEGER NOT NULL + ); + `) + // A CHECK constraint cannot be widened in place, so the archive table is rebuilt to admit the + // structured journal kind alongside the two PTY-era kinds. + this.db.exec(` + CREATE TABLE IF NOT EXISTS worker_terminal_archives_v31 ( + dispatch_id TEXT PRIMARY KEY, + resource_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail', 'structured_journal')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT OR REPLACE INTO worker_terminal_archives_v31 + (dispatch_id, resource_id, kind, content, created_at) + SELECT dispatch_id, resource_id, kind, content, created_at FROM worker_terminal_archives; + DROP TABLE worker_terminal_archives; + ALTER TABLE worker_terminal_archives_v31 RENAME TO worker_terminal_archives; + `) + } this.db.exec(` CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_pane_leaf ON dispatch_contexts(${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL}) diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index b29debf6aa1..4d32469f526 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -1,7 +1,7 @@ import { resolveOrchestrationMigrationStartVersion } from '../../orchestration-schema-version-skew' import { SCHEMA_VERSION } from '../contract-constants' import type { OrchestrationDb } from '../orchestration-db' -import { applySchemaMigrationsV13ToV30 } from './migrate-v13-v30' +import { applySchemaMigrationsV13ToLatest } from './migrate-v13-latest' import { applySchemaMigrationsV2ToV12 } from './migrate-v2-v12' // Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing). @@ -15,7 +15,7 @@ export function migrate(this: OrchestrationDb): void { this.db.exec('BEGIN IMMEDIATE') try { applySchemaMigrationsV2ToV12.call(this, current) - applySchemaMigrationsV13ToV30.call(this, current) + applySchemaMigrationsV13ToLatest.call(this, current) this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') } catch (err) { diff --git a/src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts b/src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts new file mode 100644 index 00000000000..eef6fc5ede8 --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/structured-pointer-schema-migration.test.ts @@ -0,0 +1,67 @@ +import Database from '../../../../sqlite/sync-database' +import { describe, expect, it } from 'vitest' +import { OrchestrationDb } from '../orchestration-db' +import { SCHEMA_VERSION } from '../contract-constants' + +/** A pre-v31 database, with the narrow archive CHECK and no structured pointer table. */ +function seedLegacyDatabase(path: string): void { + const db = new Database(path) + db.exec(` + CREATE TABLE worker_terminal_archives ( + dispatch_id TEXT PRIMARY KEY, + resource_id TEXT NOT NULL, + kind TEXT NOT NULL CHECK(kind IN ('transcript_pin', 'terminal_tail')), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + INSERT INTO worker_terminal_archives (dispatch_id, resource_id, kind, content, created_at) + VALUES ('d_old', 'res_old', 'terminal_tail', '{"lines":["kept"]}', '2026-01-01 00:00:00'); + `) + db.pragma('user_version = 30') + db.close() +} + +describe('structured pointer schema migration', () => { + it('admits the structured archive kind and keeps existing rows', () => { + const path = `${process.env.TMPDIR ?? '/tmp'}/orca-structured-migration-${process.pid}-${Date.now()}.db` + seedLegacyDatabase(path) + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + const kept = db.db + .prepare('SELECT content FROM worker_terminal_archives WHERE dispatch_id = ?') + .get('d_old') as { content: string } + expect(kept.content).toContain('kept') + db.storeWorkerTerminalArchive({ + dispatchId: 'd_new', + resourceId: 'res_new', + kind: 'structured_journal', + content: '{"version":1}' + }) + expect(db.getWorkerTerminalArchive('d_new')?.kind).toBe('structured_journal') + } finally { + db.close() + } + }) + + it('creates the structured pointer operation store', () => { + const db = new OrchestrationDb(':memory:') + try { + expect(db.getStructuredPointerOperation('dispatch:d1')).toBeUndefined() + db.putStructuredPointerOperation({ + mailbox_handle: 'dispatch:d1', + session_id: 's1', + operation_id: '1757030400000-0123456789abcdef0123456789abcdef', + body_fingerprint: 'fp', + minted_at_ms: 1_757_030_400_000 + }) + expect(db.getStructuredPointerOperation('dispatch:d1')?.operation_id).toBe( + '1757030400000-0123456789abcdef0123456789abcdef' + ) + db.deleteStructuredPointerOperation('dispatch:d1') + expect(db.getStructuredPointerOperation('dispatch:d1')).toBeUndefined() + } finally { + db.close() + } + }) +}) diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts index 988f3b46f11..4bb4fcff823 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-archive.ts @@ -1,4 +1,5 @@ import type { + WorkerTerminalArchiveKind, WorkerTerminalResourceRow, WorkerTerminalArchiveRow, WorkerTerminalArchiveStatus, @@ -12,7 +13,7 @@ export function storeWorkerTerminalArchive( params: { dispatchId: string resourceId: string - kind: 'transcript_pin' | 'terminal_tail' + kind: WorkerTerminalArchiveKind content: string } ): void { @@ -31,7 +32,7 @@ export function commitWorkerTerminalArchiveForRelease( params: { dispatchId: string resourceId: string - kind?: 'transcript_pin' | 'terminal_tail' + kind?: WorkerTerminalArchiveKind content?: string archiveSource: 'transcript' | 'terminal' archiveStatus: Extract diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index f6611ea80a6..5c928aca889 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -106,6 +106,18 @@ export function getWorkerTerminalResourceByOwner( .get(dispatchId) as WorkerTerminalResourceRow | undefined } +export function getWorkerTerminalResourceByHandle( + this: OrchestrationDb, + terminalHandle: string +): WorkerTerminalResourceRow | undefined { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE terminal_handle = ? ORDER BY updated_at DESC LIMIT 1` + ) + .get(terminalHandle) as WorkerTerminalResourceRow | undefined +} + export function getWorkerTerminalResourceFormerlyOwnedBy( this: OrchestrationDb, dispatchId: string @@ -168,6 +180,7 @@ export type WorkerTerminalResourceStoreMethods = { backfillWorkerTerminalResources: typeof backfillWorkerTerminalResources createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource + getWorkerTerminalResourceByHandle: typeof getWorkerTerminalResourceByHandle getWorkerTerminalResourceByOwner: typeof getWorkerTerminalResourceByOwner getWorkerTerminalResourceFormerlyOwnedBy: typeof getWorkerTerminalResourceFormerlyOwnedBy transferWorkerTerminalResourceStatement: typeof transferWorkerTerminalResourceStatement @@ -178,6 +191,7 @@ export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): backfillWorkerTerminalResources, createWorkerTerminalResourceStatement, getWorkerTerminalResource, + getWorkerTerminalResourceByHandle, getWorkerTerminalResourceByOwner, getWorkerTerminalResourceFormerlyOwnedBy, transferWorkerTerminalResourceStatement diff --git a/src/main/runtime/orchestration/mailbox-delivery-target.ts b/src/main/runtime/orchestration/mailbox-delivery-target.ts index 3e47b020ff0..5d412bb12e8 100644 --- a/src/main/runtime/orchestration/mailbox-delivery-target.ts +++ b/src/main/runtime/orchestration/mailbox-delivery-target.ts @@ -5,6 +5,8 @@ type OrchestrationMailboxDeliveryTargetDependencies = { getDb: () => OrchestrationDb | null getTerminalHandleForPaneKey: (paneKey: string) => string | null hasTerminalHandle: (handle: string) => boolean + /** A structured worker has no PTY handle; its own lane delivers, so this must not claim it. */ + isStructuredWorkerHandle: (handle: string) => boolean canProbePtyLiveness: () => boolean controllerKnowsPtyIsLive: (ptyId: string) => boolean isLeafPtyProvenAbsent: (ptyId: string) => Promise @@ -19,6 +21,9 @@ export class OrchestrationMailboxDeliveryTarget { if (this.deps.hasTerminalHandle(handle)) { return handle } + if (this.deps.isStructuredWorkerHandle(handle)) { + return null + } const db = this.deps.getDb() const runId = handle.startsWith('run:') ? handle.slice('run:'.length) : '' const dispatchId = handle.startsWith('dispatch:') ? handle.slice('dispatch:'.length) : '' @@ -31,7 +36,23 @@ export class OrchestrationMailboxDeliveryTarget { : ((paneKey ? this.deps.getTerminalHandleForPaneKey(paneKey) : null) ?? dispatch?.assignee_handle ?? remote?.terminal_handle) - return ownerHandle && this.deps.hasTerminalHandle(ownerHandle) ? ownerHandle : null + if (!ownerHandle) { + return null + } + if (this.deps.isStructuredWorkerHandle(ownerHandle)) { + // The structured lane owns this mailbox; nothing here can type into it. + return null + } + if (!this.deps.hasTerminalHandle(ownerHandle)) { + // Why logged rather than silent: an unroutable owner is the shape of a lost mailbox, and a + // silent null is indistinguishable from "no mail". + console.warn('[orchestration] mailbox owner resolved to an unknown terminal', { + mailboxHandle: handle, + ownerHandle + }) + return null + } + return ownerHandle } deferForAbsenceProbe( diff --git a/src/main/runtime/orchestration/mailbox-notification-coordinator.ts b/src/main/runtime/orchestration/mailbox-notification-coordinator.ts index 4856a08843b..07cf3b5b7dc 100644 --- a/src/main/runtime/orchestration/mailbox-notification-coordinator.ts +++ b/src/main/runtime/orchestration/mailbox-notification-coordinator.ts @@ -8,10 +8,13 @@ import type { OrchestrationMailboxPointerDelivery, OrchestrationMessageWaiter } from './mailbox-pointer-delivery' +import type { OrchestrationStructuredMailboxPointerDelivery } from './structured-mailbox-pointer-delivery' type NotificationCoordinatorDependencies = { mailboxOwner: OrchestrationMailboxOwner pointerDelivery: OrchestrationMailboxPointerDelivery + /** Sibling lane for workers that ARE a structured session; it has no PTY to type into. */ + structuredPointerDelivery?: OrchestrationStructuredMailboxPointerDelivery getDb: () => OrchestrationDb | null getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf getPaneKeyForHandle: (handle: string) => string | undefined @@ -28,6 +31,9 @@ export class OrchestrationMailboxNotificationCoordinator< constructor(private readonly deps: NotificationCoordinatorDependencies) {} deliverForHandle(handle: string, reservedTypes?: ReadonlySet): void { + if (this.deps.structuredPointerDelivery?.deliverForHandle(handle, reservedTypes)) { + return + } this.deps.pointerDelivery.deliverForHandle(handle, reservedTypes) } diff --git a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts index c994101aeda..a3ce5752bea 100644 --- a/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts +++ b/src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.ts @@ -1,3 +1,4 @@ +import { sessionIdFromStructuredWorkerIncarnation } from '../structured-worker-identity' import { isPtyIncarnationId, type PtyIncarnationId } from '../../../shared/pty-incarnation' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { LegacyWorkerTerminalRecoveryRow } from './types' @@ -32,6 +33,11 @@ function parseProcessIncarnation( } const ptyId = value.slice(0, separator) const incarnationId = value.slice(separator + 1) + // A structured worker's incarnation names a session lineage, not a PTY; adopting it as one + // would hand a live chat session's dispatch to the PTY recovery path. + if (sessionIdFromStructuredWorkerIncarnation(value)) { + return null + } return ptyId && isPtyIncarnationId(incarnationId) ? { ptyId, incarnationId } : null } diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts new file mode 100644 index 00000000000..25d6f4140d0 --- /dev/null +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' +import { + OrchestrationStructuredMailboxPointerDelivery, + type StructuredMailboxPointerHost +} from './structured-mailbox-pointer-delivery' +import type { StructuredWorkerIdentity } from '../structured-worker-identity' + +const IDENTITY: StructuredWorkerIdentity = { + handle: 'structworker_1', + sessionId: 'session-1', + agent: 'claude', + paneKey: 'structured-agent-session-session-1:11111111-1111-4111-a111-111111111111', + processIncarnation: 'structured:session-1', + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } +} + +function idleJournal(): AgentJournalRenderItem[] { + return [ + { + itemId: 'i1', + observedAt: 1, + body: { kind: 'status', text: 'done', turnLifecycle: { state: 'completed', turnId: 't1' } } + } as unknown as AgentJournalRenderItem + ] +} + +function runningJournal(): AgentJournalRenderItem[] { + return [ + { + itemId: 'i1', + observedAt: 1, + body: { kind: 'status', text: 'working', turnLifecycle: { state: 'running', turnId: 't1' } } + } as unknown as AgentJournalRenderItem + ] +} + +function attentionJournal(): AgentJournalRenderItem[] { + return [ + { + itemId: 'i1', + observedAt: 1, + body: { + kind: 'question', + question: 'which?', + options: [], + resolution: { state: 'pending' } + } + } as unknown as AgentJournalRenderItem + ] +} + +function harness(options: { + journal: AgentJournalRenderItem[] | null + dispatchState?: 'accepted' | 'rejected' | 'unknown' + refusal?: AgentSessionPtyWriteRefusal +}) { + 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 sendMock = vi.mocked(send) + const stored = new Map() + const db = { + getDispatchContextById: () => ({ run_id: 'run_1' }), + hasOutstandingRunDelivery: () => false, + getUndeliveredUnreadMessages: () => [{ id: 'm1', type: 'status', sequence: 3 }], + markAsDelivered, + getStructuredPointerOperation: (key: string) => stored.get(key), + putStructuredPointerOperation: (row: { mailbox_handle: string }) => + stored.set(row.mailbox_handle, row), + deleteStructuredPointerOperation: (key: string) => stored.delete(key) + } + const delivery = new OrchestrationStructuredMailboxPointerDelivery({ + getDb: () => db as never, + getMessageWaiters: () => undefined, + resolveStructuredTarget: (mailboxHandle) => + mailboxHandle === 'dispatch:d1' + ? { + sessionId: IDENTITY.sessionId, + dispatchId: 'd1', + ...(options.refusal ? { refusal: options.refusal } : {}) + } + : null, + host: { + readJournalTail: () => (journal === null ? null : { items: journal, hasOlder: false }), + currentFence: () => 4, + send + } + }) + return { + delivery, + markAsDelivered, + send: sendMock, + stored, + setJournal: (next: AgentJournalRenderItem[] | null) => { + journal = next + } + } +} + +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +describe('structured mailbox pointer delivery', () => { + it('claims only mailboxes whose assignee is a structured worker', () => { + const { delivery } = harness({ journal: idleJournal() }) + expect(delivery.deliverForHandle('dispatch:d1')).toBe(true) + expect(delivery.deliverForHandle('run:run_1')).toBe(false) + }) + + it('sends the pointer as a turn and consumes mail on an accepted dispatch', async () => { + const { delivery, markAsDelivered, send } = harness({ journal: idleJournal() }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(send.mock.calls[0]![0].operationId).toMatch(/^\d{13}-[0-9a-f]{32}$/) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + }) + + it('retains mail when the dispatch settles unknown', async () => { + const { delivery, markAsDelivered } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(markAsDelivered).not.toHaveBeenCalled() + }) + + it('retains mail while a turn is running', async () => { + const { delivery, send, markAsDelivered } = harness({ journal: runningJournal() }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + expect(markAsDelivered).not.toHaveBeenCalled() + }) + + it('retains mail while a prompt is waiting for a human', async () => { + const { delivery, send } = harness({ journal: attentionJournal() }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + }) + + it('retains mail when the session is not attached', async () => { + const { delivery, send } = harness({ journal: null }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + }) + + it('retries a parked pointer when the journal moves', async () => { + const { delivery, send, setJournal, markAsDelivered } = harness({ journal: runningJournal() }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + setJournal(idleJournal()) + delivery.onTurnSettled('session-1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + }) + + it('reuses one operation id for the same batch and re-mints when it grows', async () => { + const { delivery, send, stored } = harness({ + journal: idleJournal(), + dispatchState: 'unknown' + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + const first = send.mock.calls[0]![0].operationId + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send.mock.calls[1]![0].operationId).toBe(first) + stored.clear() + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send.mock.calls[2]![0].operationId).not.toBe(first) + }) +}) + +describe('an adopted pane is redirected through its native owner', () => { + const settled: AgentSessionPtyWriteRefusal = { + code: 'agent_session_conflict', + sessionId: 'session-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7 + } + + it('sends through the session when the refusal names a settled native owner', async () => { + const { delivery, send, markAsDelivered } = harness({ + journal: idleJournal(), + refusal: settled + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).toHaveBeenCalledTimes(1) + expect(markAsDelivered).toHaveBeenCalledWith(['m1']) + }) + + it('retains rather than redirecting into a lease that is handing back to a TUI', async () => { + // Re-checked at SEND time: the owner can settle differently between resolve and send, and + // redirecting into a mid-handoff lease races the takeover. + const { delivery, send, markAsDelivered } = harness({ + journal: idleJournal(), + refusal: { ...settled, handoffStage: 'preparing' } + }) + delivery.deliverForHandle('dispatch:d1') + await flush() + expect(send).not.toHaveBeenCalled() + expect(markAsDelivered).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts new file mode 100644 index 00000000000..19198b1575e --- /dev/null +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-delivery.ts @@ -0,0 +1,259 @@ +/** + * The pointer-delivery lane for workers that ARE a structured agent session. + * + * The PTY lane types the nudge into a live pane and reads the idle edge off the terminal title. + * Neither exists here, so this is a sibling of `OrchestrationMailboxPointerDelivery` rather than a + * branch inside it: the eligibility rules (outstanding run delivery, waiters, reserved types, + * batch limit, watermark) are the same, and everything below them is different — the nudge is a + * session turn, the idle edge is the journal, and only an `accepted` dispatch may consume mail. + * + * Coordinators are deliberately out of scope: `run:` mail routes through a coordinator handle, and + * a coordinator blocks in `check --wait`, where a waiter preempts pointer delivery anyway. + */ + +import type { + AgentJournalMessageItem, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type OrchestrationDb } from './db' +import { formatMessagePointer } from './formatter' +import { + hasUnfilteredOrchestrationWaiter, + messageTypeHasOrchestrationWaiter, + type OrchestrationMessageWaiter +} from './mailbox-pointer-eligibility' +import { resolveStructuredPointerOperation } from './structured-pointer-operation-id' +import { + decideStructuredPointerDelivery, + decideStructuredSessionPointerDelivery, + retainReasonForDispatch, + retainWaitsForTurnSettle, + structuredDispatchDelivered, + type StructuredDispatchState, + type StructuredPointerRetainReason +} from './structured-session-pointer-delivery' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' + +const HISTORY_GATE_LIMIT = 40 + +export type StructuredPointerTarget = { + sessionId: string + dispatchId: string + /** Present only for an adopted pane, where a PTY write was refused in favour of this owner. */ + refusal?: AgentSessionPtyWriteRefusal +} + +export type StructuredPointerSendOutcome = + | { kind: 'sent'; state: StructuredDispatchState } + | { kind: 'unattached' } + +export type StructuredMailboxPointerHost = { + /** Tail of the journal used as the idle gate; `null` when the session is not attached. */ + readJournalTail: ( + sessionId: string, + limit: number + ) => { items: readonly AgentJournalRenderItem[]; hasOlder: boolean } | null + send: (input: { + sessionId: string + dispatchId: string + operationId: string + payloadFingerprint: string + expectedRuntimeFence: number + body: AgentJournalMessageItem + }) => Promise + /** Current lease fence; `null` when no record backs the session any more. */ + currentFence: (sessionId: string) => number | null +} + +type StructuredPointerDeliveryDependencies = { + getDb: () => OrchestrationDb | null + getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined + /** + * The session a mailbox must be nudged through, or null when a live PTY can take the bytes. + * + * Two shapes reach here. A NATIVE-BORN worker carries no refusal: it never had a PTY. An + * ADOPTED one does — its pane is bound to a session a native owner holds, so the PTY write is + * refused and the refusal is what proves the owner is settled enough to redirect to. + */ + resolveStructuredTarget: (mailboxHandle: string) => StructuredPointerTarget | null + host: StructuredMailboxPointerHost + onRetain?: (input: { + mailboxHandle: string + sessionId: string + reason: StructuredPointerRetainReason + }) => void +} + +export class OrchestrationStructuredMailboxPointerDelivery< + TWaiter extends OrchestrationMessageWaiter +> { + private readonly inFlight = new Set() + /** Mailboxes whose retry must wait for the session's next turn to settle. */ + private readonly parkedUntilTurnSettle = new Map | undefined>() + + constructor(private readonly deps: StructuredPointerDeliveryDependencies) {} + + deliverForHandle(mailboxHandle: string, reservedTypes?: ReadonlySet): boolean { + const target = this.deps.resolveStructuredTarget(mailboxHandle) + if (!target) { + return false + } + void this.deliver(mailboxHandle, target, reservedTypes).catch(() => { + // Durable mail stays available to an explicit check or the next settle edge. + }) + return true + } + + /** A turn settled on this session; anything parked on that edge may be retried. */ + onTurnSettled(sessionId: string): void { + for (const [mailboxHandle, reservedTypes] of Array.from(this.parkedUntilTurnSettle)) { + const target = this.deps.resolveStructuredTarget(mailboxHandle) + if (target?.sessionId !== sessionId) { + continue + } + this.parkedUntilTurnSettle.delete(mailboxHandle) + void this.deliver(mailboxHandle, target, reservedTypes).catch(() => undefined) + } + } + + /** + * The worker settled; drop what it had parked. + * + * Also prunes entries whose target no longer resolves at all: after settlement the identity is + * forgotten, so those can never be matched by session id again and would otherwise be immortal. + */ + forgetSession(sessionId: string): void { + for (const [mailboxHandle] of Array.from(this.parkedUntilTurnSettle)) { + const target = this.deps.resolveStructuredTarget(mailboxHandle) + if (!target || target.sessionId === sessionId) { + this.parkedUntilTurnSettle.delete(mailboxHandle) + } + } + } + + private async deliver( + mailboxHandle: string, + target: StructuredPointerTarget, + reservedTypes?: ReadonlySet + ): Promise { + const db = this.deps.getDb() + if (!db || this.inFlight.has(mailboxHandle)) { + return + } + const runId = db.getDispatchContextById?.(target.dispatchId)?.run_id + if (runId && db.hasOutstandingRunDelivery?.(runId)) { + return + } + const waiters = this.deps.getMessageWaiters(mailboxHandle) + if (hasUnfilteredOrchestrationWaiter(waiters)) { + return + } + const excludedTypes = new Set(reservedTypes) + for (const waiter of waiters ?? []) { + for (const type of waiter.typeFilter ?? []) { + excludedTypes.add(type) + } + } + const unread = db + .getUndeliveredUnreadMessages(mailboxHandle, undefined, { + excludeTypes: [...excludedTypes], + limit: ORCHESTRATION_DELIVERY_BATCH_LIMIT + }) + .filter( + (message) => + !reservedTypes?.has(message.type) && + !messageTypeHasOrchestrationWaiter(waiters, message.type) + ) + .slice(0, ORCHESTRATION_DELIVERY_BATCH_LIMIT) + if (unread.length === 0) { + return + } + this.inFlight.add(mailboxHandle) + try { + await this.attempt(db, mailboxHandle, target, unread, reservedTypes) + } finally { + this.inFlight.delete(mailboxHandle) + } + } + + private async attempt( + db: OrchestrationDb, + mailboxHandle: string, + target: StructuredPointerTarget, + unread: readonly { id: string; type: string; sequence: number }[], + reservedTypes: ReadonlySet | undefined + ): Promise { + const sessionId = target.sessionId + const tail = this.deps.host.readJournalTail(sessionId, HISTORY_GATE_LIMIT) + const gateInput = { + sessionAttached: tail !== null, + journalItems: tail?.items ?? [], + // A page that filled is a page that may have hidden a running turn's lifecycle item. + journalPageMayHaveMore: tail?.hasOlder === true + } + // Re-checked at send time, not just at resolve time: an adopted session's owner can change + // between the two, and redirecting into a lease that is handing back to a TUI races it. + const decision = target.refusal + ? decideStructuredPointerDelivery({ ...gateInput, refusal: target.refusal }) + : decideStructuredSessionPointerDelivery(gateInput) + if (!decision.deliver) { + this.retain(mailboxHandle, sessionId, decision.retain, reservedTypes) + return + } + const fence = this.deps.host.currentFence(sessionId) + if (fence === null) { + this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) + return + } + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: formatMessagePointer(unread.length, mailboxHandle).trim() }] + } + const operation = resolveStructuredPointerOperation({ + db, + mailboxHandle, + sessionId, + body + }) + const outcome = await this.deps.host.send({ + sessionId, + dispatchId: target.dispatchId, + operationId: operation.operationId, + payloadFingerprint: operation.payloadFingerprint, + expectedRuntimeFence: fence, + body + }) + if (outcome.kind === 'unattached') { + this.retain(mailboxHandle, sessionId, 'session-not-attached', reservedTypes) + return + } + if (!structuredDispatchDelivered(outcome.state)) { + this.retain( + mailboxHandle, + sessionId, + retainReasonForDispatch(outcome.state as Exclude), + reservedTypes + ) + return + } + const staged = unread.map((message) => message.id) + db.markAsDelivered(staged) + // 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) + } + + /** No `markAsUndelivered` is owed: rows are marked delivered only after an accepted dispatch. */ + private retain( + mailboxHandle: string, + sessionId: string, + reason: StructuredPointerRetainReason, + reservedTypes: ReadonlySet | undefined + ): void { + this.deps.onRetain?.({ mailboxHandle, sessionId, reason }) + if (retainWaitsForTurnSettle(reason)) { + this.parkedUntilTurnSettle.set(mailboxHandle, reservedTypes) + } + } +} diff --git a/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts new file mode 100644 index 00000000000..bfd22dc0223 --- /dev/null +++ b/src/main/runtime/orchestration/structured-mailbox-pointer-host.ts @@ -0,0 +1,75 @@ +/** + * The structured-session half of the structured pointer lane. + * + * Keeps every `getStructuredAgentSessionHost()` call in one place so the delivery policy above it + * stays pure and testable. Nothing here decides whether to deliver; it only performs the read and + * the send and reports what the host said. + */ + +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' + +/** Per-dispatch so one worker's nudges cannot exhaust the shared runtime operation-ledger budget. */ +export function structuredPointerCallerKey(dispatchId: string): string { + return `trusted-local:orchestration:${dispatchId}` +} + +export function createStructuredMailboxPointerHost(): StructuredMailboxPointerHost { + return { + readJournalTail(sessionId, limit) { + const host = getStructuredAgentSessionHost() + if (!host) { + return null + } + try { + const result = host.history({ sessionId, direction: 'tail', limit }) + return { items: result.page.items, hasOlder: result.page.hasOlder } + } catch (error) { + // Not attached is a retain reason, not a failure; anything else is still unreadable. + if ((error as Error)?.message !== AGENT_SESSION_NOT_ATTACHED.code) { + console.warn('[orchestration] structured journal tail unreadable', sessionId, error) + } + return null + } + }, + + currentFence(sessionId) { + return ( + getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId)?.lease.runtimeFence ?? null + ) + }, + + async send(input) { + const host = getStructuredAgentSessionHost() + if (!host) { + return { kind: 'unattached' } + } + const result = await host.send( + { callerKey: structuredPointerCallerKey(input.dispatchId) }, + { + envelope: { + sessionId: input.sessionId, + clientOperationId: input.operationId, + expectedRuntimeFence: input.expectedRuntimeFence, + payloadFingerprint: input.payloadFingerprint + }, + body: input.body, + // The recorded unknown is the only thing that unlocks a redispatch of the same id. + retryUnknown: true + } + ) + if (!result.ok) { + return result.refusal.code === AGENT_SESSION_NOT_ATTACHED.code + ? { kind: 'unattached' } + : { kind: 'sent', state: 'rejected' } + } + // `pending` is not yet an acknowledgement; only `accepted` may consume mail. + const state = result.value.submission.dispatchState + return { + kind: 'sent', + state: state === 'accepted' ? 'accepted' : state === 'rejected' ? 'rejected' : 'unknown' + } + } + } +} diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts new file mode 100644 index 00000000000..0e78068fa8a --- /dev/null +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { + mintAgentSessionOperationId, + resolveStructuredPointerOperation +} from './structured-pointer-operation-id' + +const OPERATION_ID_PATTERN = /^\d{13}-[0-9a-f]{32}$/ + +function body(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +function fakeDb() { + const rows = new Map() + return { + rows, + getStructuredPointerOperation: (handle: string) => rows.get(handle), + putStructuredPointerOperation: (row: { mailbox_handle: string; operation_id: string }) => + rows.set(row.mailbox_handle, row) + } as never +} + +describe('structured pointer operation id', () => { + it('mints ids the host will admit', () => { + // Orchestration's own msg_ ids do not match and are refused before the first send. + expect(mintAgentSessionOperationId(Date.now())).toMatch(OPERATION_ID_PATTERN) + }) + + it('reuses one id for the same batch', () => { + const db = fakeDb() + const first = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 1_000 + }) + const second = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 2_000 + }) + expect(second.operationId).toBe(first.operationId) + expect(second.payloadFingerprint).toBe(first.payloadFingerprint) + }) + + it('re-mints when the batch grows', () => { + const db = fakeDb() + const first = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 1_000 + }) + const grown = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('3 messages'), + now: 1_500 + }) + expect(grown.operationId).not.toBe(first.operationId) + }) + + it('re-mints once the host would refuse the id as expired', () => { + const db = fakeDb() + const first = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 1_000 + }) + const aged = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 1_000 + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + }) + expect(aged.operationId).not.toBe(first.operationId) + }) + + it('re-mints when the mailbox moves to a different session', () => { + const db = fakeDb() + const first = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's1', + body: body('2 messages'), + now: 1_000 + }) + const moved = resolveStructuredPointerOperation({ + db, + mailboxHandle: 'dispatch:d1', + sessionId: 's2', + body: body('2 messages'), + now: 1_100 + }) + expect(moved.operationId).not.toBe(first.operationId) + }) +}) diff --git a/src/main/runtime/orchestration/structured-pointer-operation-id.ts b/src/main/runtime/orchestration/structured-pointer-operation-id.ts new file mode 100644 index 00000000000..a1e967c7aec --- /dev/null +++ b/src/main/runtime/orchestration/structured-pointer-operation-id.ts @@ -0,0 +1,59 @@ +/** + * The agent-session operation id one structured worker mailbox's pointer send runs under. + * + * Orchestration's own `msg_` ids do not match the host's `^\d{13}-[0-9a-f]{32}$` shape and are + * refused before the first send, so the id is minted here instead. It is durable and reused across + * retries, because the id IS the send's idempotency key: a fresh id for the same nudge would land + * as a second turn. It is re-minted only when the send is genuinely a different call — the batch + * grew, the session changed — or when the host would reject it as too old to admit. + */ + +import { randomBytes } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import type { OrchestrationDb } from './db' + +export function mintAgentSessionOperationId(now: number): string { + return `${String(now).padStart(13, '0')}-${randomBytes(16).toString('hex')}` +} + +export function structuredPointerPayloadFingerprint( + sessionId: string, + body: AgentJournalMessageItem +): string { + return computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId, + fields: { body } + }) +} + +export function resolveStructuredPointerOperation(args: { + db: OrchestrationDb + mailboxHandle: string + sessionId: string + body: AgentJournalMessageItem + now?: number +}): { operationId: string; payloadFingerprint: string } { + const now = args.now ?? Date.now() + const payloadFingerprint = structuredPointerPayloadFingerprint(args.sessionId, args.body) + const stored = args.db.getStructuredPointerOperation(args.mailboxHandle) + if ( + stored && + stored.session_id === args.sessionId && + stored.body_fingerprint === payloadFingerprint && + now - stored.minted_at_ms < AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + ) { + return { operationId: stored.operation_id, payloadFingerprint } + } + const operationId = mintAgentSessionOperationId(now) + args.db.putStructuredPointerOperation({ + mailbox_handle: args.mailboxHandle, + session_id: args.sessionId, + operation_id: operationId, + body_fingerprint: payloadFingerprint, + minted_at_ms: now + }) + return { operationId, payloadFingerprint } +} diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts new file mode 100644 index 00000000000..6915337b378 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' +import { + decideStructuredPointerDelivery, + isSettledNativeOwner, + retainReasonForDispatch, + retainWaitsForTurnSettle, + structuredDispatchDelivered, + structuredSessionIsBetweenTurns +} from './structured-session-pointer-delivery' + +function refusal( + overrides: Partial = {} +): AgentSessionPtyWriteRefusal { + return { + code: 'agent_session_conflict', + sessionId: 'session-1', + ownerRuntimeKind: 'native', + handoffStage: null, + ownerPid: 4242, + runtimeFence: 7, + ...overrides + } +} + +function statusItem( + turnLifecycle: { turnId: string; state: 'running' } | undefined +): AgentJournalRenderItem { + return { + itemId: `item-${turnLifecycle?.turnId ?? 'plain'}`, + revision: 1, + body: { kind: 'status', text: 'working', ...(turnLifecycle ? { turnLifecycle } : {}) } + } as unknown as AgentJournalRenderItem +} + +describe('structured pointer owner admission', () => { + it('accepts only a settled native owner', () => { + expect(isSettledNativeOwner(refusal())).toBe(true) + }) + + it('refuses a tui owner', () => { + expect(isSettledNativeOwner(refusal({ ownerRuntimeKind: 'tui' }))).toBe(false) + }) + + it('refuses a native owner that is mid-handoff, so a to-tui takeover is not raced', () => { + expect(isSettledNativeOwner(refusal({ handoffStage: 'recovering' }))).toBe(false) + }) + + it('refuses a reconciling refusal even though it names a native owner', () => { + expect(isSettledNativeOwner(refusal({ code: 'execution_owner_reconciling' }))).toBe(false) + }) +}) + +describe('structured session turn gate', () => { + it('treats an empty journal as between turns', () => { + expect(structuredSessionIsBetweenTurns([])).toBe(true) + }) + + it('treats a running turn as unsettled', () => { + expect( + structuredSessionIsBetweenTurns([statusItem({ turnId: 'turn-1', state: 'running' })]) + ).toBe(false) + }) + + it('treats a tombstoned turn as settled, since settlement removes the running row', () => { + // A healthy completed turn leaves no turnLifecycle row behind at all. + expect(structuredSessionIsBetweenTurns([statusItem(undefined)])).toBe(true) + }) + + it('reads a full page carrying no lifecycle item as busy, because the running item may be paged out', () => { + expect(structuredSessionIsBetweenTurns([statusItem(undefined)], true)).toBe(false) + }) + + it('trusts a full page that still carries a lifecycle item', () => { + expect( + structuredSessionIsBetweenTurns([statusItem({ turnId: 'turn-1', state: 'running' })], true) + ).toBe(false) + expect(structuredSessionIsBetweenTurns([], false)).toBe(true) + }) +}) + +describe('decideStructuredPointerDelivery', () => { + it('delivers to a settled, attached, idle session', () => { + expect( + decideStructuredPointerDelivery({ + refusal: refusal(), + sessionAttached: true, + journalItems: [] + }) + ).toEqual({ deliver: true }) + }) + + it('retains when the session is not attached on this host', () => { + expect( + decideStructuredPointerDelivery({ + refusal: refusal(), + sessionAttached: false, + journalItems: [] + }) + ).toEqual({ deliver: false, retain: 'session-not-attached' }) + }) + + it('retains mid-turn rather than delegating the race to the provider', () => { + expect( + decideStructuredPointerDelivery({ + refusal: refusal(), + sessionAttached: true, + journalItems: [statusItem({ turnId: 'turn-1', state: 'running' })] + }) + ).toEqual({ deliver: false, retain: 'turn-unsettled' }) + }) + + it('retains when the owner is not a settled native session', () => { + expect( + decideStructuredPointerDelivery({ + refusal: refusal({ handoffStage: 'preparing' }), + sessionAttached: true, + journalItems: [] + }) + ).toEqual({ deliver: false, retain: 'owner-not-settled-native' }) + }) +}) + +describe('dispatch outcome classification', () => { + it('marks mail delivered only on an accepted dispatch', () => { + expect(structuredDispatchDelivered('accepted')).toBe(true) + expect(structuredDispatchDelivered('rejected')).toBe(false) + }) + + it('does not treat unknown as delivered, because a dead child settles unknown', () => { + expect(structuredDispatchDelivered('unknown')).toBe(false) + }) + + it('names the retain reason for each non-accepted dispatch', () => { + expect(retainReasonForDispatch('rejected')).toBe('dispatch-rejected') + expect(retainReasonForDispatch('unknown')).toBe('dispatch-unknown') + }) +}) + +describe('retry pacing', () => { + it('waits for a settle edge when the nudge may already be queued', () => { + expect(retainWaitsForTurnSettle('dispatch-unknown')).toBe(true) + expect(retainWaitsForTurnSettle('turn-unsettled')).toBe(true) + }) + + it('allows a plain retry for reasons that wrote nothing', () => { + expect(retainWaitsForTurnSettle('dispatch-rejected')).toBe(false) + expect(retainWaitsForTurnSettle('session-not-attached')).toBe(false) + expect(retainWaitsForTurnSettle('owner-not-settled-native')).toBe(false) + }) +}) diff --git a/src/main/runtime/orchestration/structured-session-pointer-delivery.ts b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts new file mode 100644 index 00000000000..e268eaaa817 --- /dev/null +++ b/src/main/runtime/orchestration/structured-session-pointer-delivery.ts @@ -0,0 +1,154 @@ +/** + * Delivery decisions for an orchestration mail pointer aimed at a host-owned + * structured ("native") agent session. + * + * A structured session has no PTY the pointer can be typed into, so the nudge + * travels as a session turn instead of as bytes. Everything here is pure: the + * caller supplies the refusal, the attachment fact and the journal, and gets + * back a decision it can act on. Orchestration's database stays the source of + * truth — no decision here ever consumes mail, it only says whether the nudge + * may be attempted now. + */ + +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionPtyWriteRefusal } from '../../../shared/agent-session-pty-write-admission' +import { + activeStructuredAgentSessionTurnId, + projectStructuredAgentSessionStatus +} from '../../../shared/structured-agent-session-projection' + +/** Every reason retains the pointer; none of them consume mail. */ +export type StructuredPointerRetainReason = + | 'owner-not-settled-native' + | 'session-not-attached' + | 'turn-unsettled' + | 'awaiting-human' + | 'dispatch-rejected' + | 'dispatch-unknown' + +export type StructuredPointerDecision = + | { deliver: true } + | { deliver: false; retain: StructuredPointerRetainReason } + +/** The dispatch states both provider adapters converge on. */ +export type StructuredDispatchState = 'accepted' | 'rejected' | 'unknown' + +/** + * A refusal names an owner this pointer may be redirected to only when that + * owner is native AND settled. A recovering or mid-handoff lease also reports + * `native`, but it may become a TUI again, so redirecting there races the + * takeover. + */ +export function isSettledNativeOwner(refusal: AgentSessionPtyWriteRefusal): boolean { + return ( + refusal.ownerRuntimeKind === 'native' && + refusal.code === 'agent_session_conflict' && + refusal.handoffStage === null + ) +} + +function containsTurnLifecycle(items: readonly AgentJournalRenderItem[]): boolean { + return items.some((item) => item.body?.kind === 'status' && Boolean(item.body.turnLifecycle)) +} + +/** + * Whether the session is between turns. + * + * Reuses the projection the chat view already reads, so the delivery gate and + * the visible "working" state can never disagree. A settled turn is tombstoned + * rather than rewritten to `completed`, so a healthy finished turn leaves + * nothing for the backward scan to find. + * + * `pageMayHaveMore` guards the one way a tail page lies: a running turn's + * lifecycle item can be pushed off the end by a burst of tool-call items, and a + * page carrying no lifecycle item at all is then indistinguishable from an idle + * session. That reads as busy, because delivering mid-turn is the failure this + * gate exists to prevent. + */ +export function structuredSessionIsBetweenTurns( + items: readonly AgentJournalRenderItem[], + pageMayHaveMore = false +): boolean { + if (activeStructuredAgentSessionTurnId(items) !== null) { + return false + } + return !pageMayHaveMore || containsTurnLifecycle(items) +} + +/** + * Decide whether the nudge may be sent right now. + * + * Mid-turn delivery is refused for both providers rather than delegated to + * them: Codex answers a mid-turn `turn/start` with `turn already running`, and + * Claude accepts the frame but cannot acknowledge it inside the dispatch ack + * window, settling `unknown` while the message is really queued. Waiting for + * the turn to settle is the one contract that holds for both, and it preserves + * orchestration's existing idle-edge-only delivery policy. + */ +export function decideStructuredPointerDelivery(input: { + refusal: AgentSessionPtyWriteRefusal + sessionAttached: boolean + journalItems: readonly AgentJournalRenderItem[] + /** True when the history page was filled, so older items may be unread. */ + journalPageMayHaveMore?: boolean +}): StructuredPointerDecision { + if (!isSettledNativeOwner(input.refusal)) { + return { deliver: false, retain: 'owner-not-settled-native' } + } + return decideStructuredSessionPointerDelivery(input) +} + +/** + * The same decision for a session that was BORN structured. + * + * There is no PTY write to be refused, so there is no refusal to read an owner off — the caller + * already knows the session is host-owned because it created it. Everything after that gate is + * identical, which is why the adopted-TUI path above delegates here rather than duplicating it. + */ +export function decideStructuredSessionPointerDelivery(input: { + sessionAttached: boolean + journalItems: readonly AgentJournalRenderItem[] + journalPageMayHaveMore?: boolean +}): StructuredPointerDecision { + if (!input.sessionAttached) { + return { deliver: false, retain: 'session-not-attached' } + } + // A pending approval or question has no running turn, so the between-turns test alone reads it + // as idle. Sending there queues a nudge behind a prompt only a human can clear. + if (projectStructuredAgentSessionStatus(input.journalItems) === 'attention') { + return { deliver: false, retain: 'awaiting-human' } + } + if (!structuredSessionIsBetweenTurns(input.journalItems, input.journalPageMayHaveMore)) { + return { deliver: false, retain: 'turn-unsettled' } + } + return { deliver: true } +} + +/** + * Only an accepted dispatch may mark mail delivered. + * + * `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. + */ +export function structuredDispatchDelivered(state: StructuredDispatchState): boolean { + return state === 'accepted' +} + +export function retainReasonForDispatch( + state: Exclude +): StructuredPointerRetainReason { + return state === 'rejected' ? 'dispatch-rejected' : 'dispatch-unknown' +} + +/** + * Whether a retained pointer should be retried on its own, or only when the + * session's next turn settles. + * + * `unknown` may mean the nudge is already sitting in the provider's input + * queue, so an immediate retry can stack duplicate nudges that each become a + * turn later. Those wait for a settle edge; the rest are cheap to re-attempt. + */ +export function retainWaitsForTurnSettle(reason: StructuredPointerRetainReason): boolean { + return reason === 'turn-unsettled' || reason === 'dispatch-unknown' || reason === 'awaiting-human' +} diff --git a/src/main/runtime/orchestration/structured-worker-journal-archive.ts b/src/main/runtime/orchestration/structured-worker-journal-archive.ts new file mode 100644 index 00000000000..85ba3c7a3eb --- /dev/null +++ b/src/main/runtime/orchestration/structured-worker-journal-archive.ts @@ -0,0 +1,73 @@ +/** + * Freezing and re-reading a structured worker's journal. + * + * The terminal path archives a redacted PTY tail; there is no PTY here, so the durable evidence is + * the journal projected into the same message shape `worker-read --source transcript` already + * serves. It gets its own archive kind because its identity is a session, not a transcript file on + * disk, and because the read side must be able to say which of the three it is holding. + */ + +import type { AgentType, NativeChatMessage } from '../../../shared/native-chat-types' +import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import { boundWorkerTranscriptMessages } from './worker-transcript-payload' + +// Same durable bound the terminal archive uses; a session journal can grow without limit. +const STRUCTURED_ARCHIVE_MAX_CHARS = 262_144 + +export type WorkerStructuredJournalArchive = { + version: 1 + agent: AgentType + processIncarnation: string + messages: NativeChatMessage[] + limited: boolean + warnings: string[] +} + +export function buildStructuredJournalArchive(input: { + agent: AgentType + processIncarnation: string + items: readonly AgentJournalRenderItem[] + hasOlder: boolean +}): WorkerStructuredJournalArchive { + const projected = projectStructuredItemsToNativeChat(input.items) + // Redacts dispatch capabilities and clips oversized blocks, exactly as the transcript path does. + const bounded = boundWorkerTranscriptMessages(projected) + const capped = capArchiveMessages(bounded.messages) + const warnings = [...bounded.warnings] + if (input.hasOlder) { + warnings.push('Older journal items were omitted from the bounded archive.') + } + if (capped.truncated) { + warnings.push('The oldest archived journal messages were dropped to fit the size bound.') + } + return { + version: 1, + agent: input.agent, + processIncarnation: input.processIncarnation, + messages: capped.messages, + limited: bounded.limited || input.hasOlder || capped.truncated, + warnings + } +} + +/** Newest-first accumulation, reversed once: the tail is the evidence that matters. */ +export function capArchiveMessages(messages: readonly NativeChatMessage[]): { + messages: NativeChatMessage[] + truncated: boolean +} { + const keptReversed: NativeChatMessage[] = [] + let budget = STRUCTURED_ARCHIVE_MAX_CHARS + let truncated = false + for (let index = messages.length - 1; index >= 0; index -= 1) { + const cost = JSON.stringify(messages[index]).length + if (cost > budget) { + truncated = true + break + } + keptReversed.push(messages[index]!) + budget -= cost + } + keptReversed.reverse() + return { messages: keptReversed, truncated } +} diff --git a/src/main/runtime/orchestration/worker-output-archive.ts b/src/main/runtime/orchestration/worker-output-archive.ts index 55d16467269..e7e0ff31a79 100644 --- a/src/main/runtime/orchestration/worker-output-archive.ts +++ b/src/main/runtime/orchestration/worker-output-archive.ts @@ -6,6 +6,10 @@ import { redactWorkerTerminalLines } from './worker-transcript-payload' import { readWorkerTranscript } from './worker-transcript-read' +import { captureStructuredWorkerArchive } from '../rpc/methods/orchestration-structured-worker-lifecycle' +import type { WorkerStructuredJournalArchive } from './structured-worker-journal-archive' +import type { StructuredWorkerIdentity } from '../structured-worker-identity' +import type { WorkerTerminalArchiveKind } from './worker-terminal-ownership' // Bound the durable copy of raw terminal output; the tail end is the evidence that matters. const TERMINAL_ARCHIVE_MAX_CHARS = 262_144 @@ -43,6 +47,11 @@ export type WorkerOutputArchiveCapture = content: WorkerTranscriptSnapshotArchive status: 'captured' } + | { + kind: 'structured_journal' + content: WorkerStructuredJournalArchive + status: 'captured' | 'empty' + } | { kind: 'terminal_tail'; content: WorkerTerminalTailArchive; status: 'captured' | 'empty' } // Freezes an inspectable output source before the live PTY is closed. Prefers the exact @@ -53,7 +62,20 @@ export async function captureWorkerOutputArchive(args: { dispatchId: string terminalHandle: string attachedAtMs: number + /** Present when the worker IS a structured session; its journal is the only output it has. */ + structuredWorker?: StructuredWorkerIdentity | null }): Promise { + if (args.structuredWorker) { + const content = captureStructuredWorkerArchive( + args.structuredWorker, + args.structuredWorker.agent ?? 'claude' + ) + return { + kind: 'structured_journal', + status: content.messages.length > 0 ? 'captured' : 'empty', + content + } + } const session = args.runtime.getExactWorkerProviderSession(args.terminalHandle, args.attachedAtMs) if (session) { const snapshot = await readWorkerTranscript({ @@ -140,3 +162,10 @@ export function boundArchiveLines(lines: string[]): { lines: string[]; truncated keptReversed.reverse() return { lines: keptReversed, truncated: true } } + +/** Errors at compile time if a capture kind is ever added that the durable row cannot store. */ +type AssertAssignable = TValue +export type WorkerOutputArchiveCaptureKind = AssertAssignable< + WorkerOutputArchiveCapture['kind'], + WorkerTerminalArchiveKind +> diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts index 5d5ff8f1dc3..2dc75c6634c 100644 --- a/src/main/runtime/orchestration/worker-terminal-ownership.ts +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -60,10 +60,19 @@ export type WorkerTerminalListState = export type WorkerDispatchListState = WorkerDispatchState | 'unsupervised' +/** + * The frozen output sources a released worker can be read back from. + * + * One name so widening it stays a single edit: the capture, the durable write, and the archived + * read all have to admit the same set, and a kind that reaches the row but not the read side is an + * archived worker that throws instead of answering. + */ +export type WorkerTerminalArchiveKind = 'transcript_pin' | 'terminal_tail' | 'structured_journal' + export type WorkerTerminalArchiveRow = { dispatch_id: string resource_id: string - kind: 'transcript_pin' | 'terminal_tail' + kind: WorkerTerminalArchiveKind content: string created_at: string } diff --git a/src/main/runtime/rpc/methods/orchestration-local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-local-worker-start.ts new file mode 100644 index 00000000000..1ff8b7833db --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-local-worker-start.ts @@ -0,0 +1,303 @@ +/** + * The local half of `orchestration.workerStart`: resolve the worktree, bring the worker into + * existence, prove its authority, and hand it the dispatch preamble. + * + * Split out of the method file so the federated branch and the transport guards stay readable, and + * so the structured worker path has somewhere to live that is not a third arm of one handler. + */ + +import type { TuiAgent } from '../../../../shared/tui-agent' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import { assertOrchestrationWorktreeCreationSupported } from './orchestration-folder-worktree-placement' +import type { RpcContext } from '../core' +import type { WorkerStartInput } from './orchestration-worker-start-schema' +import { + createExistingWorktreeWorkerTerminal, + createStructuredWorkerSessionForWorktree, + createWorkerWorktree, + monitorWorkerSetup, + requireWorkerAuthority, + type WorkerEffect, + type WorkerSetupReceipt +} from './orchestration-worker-topology' +import { + persistGatedSetupSpawnFailure, + persistWorkerReadinessStage, + persistWorkerSetupWaitOutcome +} from './orchestration-worker-setup-gate' +import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' +import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' +import { resolveDispatchCreator } from './orchestration-dispatch-creator' +import { + releaseStructuredWorkerSession, + sendStructuredWorkerPreamble +} from './orchestration-structured-worker-session' + +export async function startLocalWorker(args: { + params: WorkerStartInput + runtime: OrcaRuntimeService + db: OrchestrationDb + run: { id: string } + task: { id: string; spec: string } + readinessTimeoutMs: number + orchestrationMutation: RpcContext['orchestrationMutation'] +}) { + const { params, runtime, db, run, task, readinessTimeoutMs, orchestrationMutation } = args + const requestedWorktree = params.worktree ?? 'current' + const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' + const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) + + const coordinatorTerminal = await runtime.showTerminal(params.from) + const creationWorktree = createsWorktree + ? await runtime.showManagedWorktree(`id:${coordinatorTerminal.worktreeId}`) + : undefined + if (creationWorktree) { + await assertOrchestrationWorktreeCreationSupported({ + runtime, + repoSelector: params.repo ?? creationWorktree.repoId, + existingPlacement: 'current or an exact existing folder workspace' + }) + } + let resolvedWorktree = creationWorktree + ? undefined + : requestedWorktree === 'current' + ? await runtime.showManagedTerminalWorkspace(`id:${coordinatorTerminal.worktreeId}`) + : await runtime.showManagedTerminalWorkspace(requestedWorktree) + let explicitTerminal + if (params.terminal) { + explicitTerminal = await runtime.showTerminal(params.terminal) + if (explicitTerminal.worktreeId !== resolvedWorktree?.id) { + throw new OrchestrationError( + 'terminal_worktree_mismatch', + `Terminal ${params.terminal} does not belong to worktree ${resolvedWorktree?.id}.` + ) + } + if (!(await runtime.isTerminalRunningAgent(params.terminal))) { + throw new OrchestrationError( + 'agent_unconfigured', + `Terminal ${params.terminal} is not running a recognized agent.` + ) + } + } + + const startOptions = { + worktree: requestedWorktree, + resolvedWorktreeId: resolvedWorktree?.id ?? null, + name: params.name ?? null, + repo: params.repo ?? creationWorktree?.repoId ?? null, + baseBranch: params.baseBranch ?? null, + terminal: params.terminal ?? null, + agent: agent ?? null, + launch: launch.receipt, + timeoutMs: readinessTimeoutMs, + setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : 'existing_worktree' + } + const started = db.createStartingWorkerDispatch({ + creator: resolveDispatchCreator(runtime, params.from), + maxDepth: runtime.getNestedWorkerMaxDepth(), + taskId: task.id, + retryOf: params.retryOf, + startOptions, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: orchestrationMutation + }) + const effects: WorkerEffect[] = [] + if (resolvedWorktree) { + effects.push( + { kind: 'worktree', action: 'reused', id: resolvedWorktree.id }, + { kind: 'setup', action: 'not_applicable', state: 'not_applicable' } + ) + } + let terminalHandle = params.terminal + let structuredSession: Awaited< + ReturnType + > | null = null + let terminalRevealWarning: string | undefined + let failedStage = 'terminal_create' + let setupReceipt: WorkerSetupReceipt = { + requested: 'not_applicable', + effective: 'not_applicable', + source: 'existing_worktree', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_applicable' + } + try { + if (creationWorktree) { + failedStage = 'worktree_create' + const created = await createWorkerWorktree({ + runtime, + db, + dispatchId: started.dispatch.id, + requestedWorktree, + coordinatorWorktree: creationWorktree, + params, + agent: agent as TuiAgent, + launchPreferences: launch.preferences, + effects + }) + resolvedWorktree = created.worktree + terminalHandle = created.terminalHandle + setupReceipt = created.setupReceipt + } else if (!terminalHandle && params.structured) { + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'terminal_creating', + worktreeId: resolvedWorktree!.id, + effects + }) + structuredSession = await createStructuredWorkerSessionForWorktree({ + runtime, + worktreeId: resolvedWorktree!.id, + agent: agent as TuiAgent, + dispatchId: started.dispatch.id, + effects + }) + terminalHandle = structuredSession.identity.handle + } else if (!terminalHandle) { + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'terminal_creating', + worktreeId: resolvedWorktree!.id, + effects + }) + const terminal = await createExistingWorktreeWorkerTerminal({ + runtime, + worktreeId: resolvedWorktree!.id, + agent: agent as TuiAgent, + launchPreferences: launch.preferences, + taskId: task.id, + effects + }) + terminalHandle = terminal.handle + terminalRevealWarning = terminal.warning + } else { + effects.push({ + kind: 'terminal', + role: 'agent', + action: 'reused', + id: terminalHandle + }) + } + if (!resolvedWorktree || !terminalHandle) { + throw new Error('Worker topology did not resolve an agent terminal and worktree.') + } + const setupStage = { + db, + dispatchId: started.dispatch.id, + worktreeId: resolvedWorktree.id, + terminalHandle, + setup: setupReceipt, + effects + } + if (persistGatedSetupSpawnFailure(setupStage)) { + failedStage = 'setup_start' + throw new Error('Setup terminal failed to start before the gated agent launch.') + } + persistWorkerReadinessStage(setupStage) + + failedStage = 'agent_readiness' + // A structured session is ready the moment its attach returns ok: there is no boot-to-idle + // gap and no terminal title to read an idle edge from. + if (!structuredSession) { + const wait = await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: readinessTimeoutMs + }) + persistWorkerSetupWaitOutcome({ ...setupStage, wait }) + if (!wait.satisfied) { + if (setupReceipt.state === 'failed') { + failedStage = 'setup_wait' + } + throw new Error( + wait.blockedReason + ? `Agent startup blocked: ${wait.blockedReason}` + : `Agent did not become ready (${wait.status}).` + ) + } + } + const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: terminalHandle, + ...terminalAuthority, + worktreeId: resolvedWorktree.id, + effects, + setupState: setupReceipt.state, + terminalOwnership: params.terminal ? 'external' : 'created' + }) + + failedStage = 'dispatch_input' + const preamble = buildDispatchPreamble({ + // Never for a structured worker: dispatching requires `showTerminal(--from)`, which cannot + // resolve a `structworker_` handle, so advertising the verb would only burn a turn. + canDispatchSubWorkers: + !structuredSession && started.dispatch.depth < runtime.getNestedWorkerMaxDepth(), + taskId: task.id, + dispatchId: started.dispatch.id, + taskSpec: task.spec, + coordinatorHandle: params.from, + workerHandle: terminalHandle, + dispatchCapability: capability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) + }) + await (structuredSession + ? sendStructuredWorkerPreamble({ + host: structuredSession.host, + sessionId: structuredSession.identity.sessionId, + dispatchId: started.dispatch.id, + preamble + }) + : runtime.sendTerminalAgentPrompt(terminalHandle, preamble)) + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'accepted' + }) + const worker = db.markWorkerDispatchReady(started.dispatch.id, effects) + monitorWorkerSetup({ + runtime, + db, + runId: run.id, + dispatchId: started.dispatch.id, + setupReceipt, + effects + }) + return { + runId: run.id, + taskId: task.id, + dispatchId: started.dispatch.id, + state: worker.state, + stage: worker.stage, + setup: setupReceipt, + launch: launch.receipt, + timeoutMs: readinessTimeoutMs, + effects, + residualResources: [], + ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) + } + } catch (error) { + // A start that never reached ready leaves no settlement to release the hold later. + releaseStructuredWorkerSession(started.dispatch.id, runtime) + return failWorkerStartWithReceipt({ + db, + runId: run.id, + taskId: task.id, + dispatchId: started.dispatch.id, + failedStage, + error, + setup: setupReceipt, + launch: launch.receipt + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts new file mode 100644 index 00000000000..1262ee0666a --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-abandon.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createOrchestrationRpcHarness } from './orchestration-rpc-test-harness' +import type { OrchestrationRpcState } from './orchestration-rpc-test-harness' + +const released: string[] = [] + +vi.mock('./orchestration-structured-worker-session', () => ({ + releaseStructuredWorkerSession: (dispatchId: string) => released.push(dispatchId), + createStructuredWorkerSession: vi.fn(), + sendStructuredWorkerPreamble: vi.fn(), + structuredWorkerHoldId: (dispatchId: string) => `orchestration:dispatch:${dispatchId}` +})) + +const harness = createOrchestrationRpcHarness() + +describe('workerAbandon settles the structured hold', () => { + let state: OrchestrationRpcState + + beforeEach(() => { + released.length = 0 + state = harness.setup() + }) + + afterEach(() => { + harness.cleanup() + vi.restoreAllMocks() + }) + + async function startedDispatch(): Promise { + const task = state.db.createTask({ spec: 'do it' }) + const started = state.db.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + return started.dispatch.id + } + + it('releases the hold when the dispatch actually settles', async () => { + const dispatchId = await startedDispatch() + // Without this, the resume-capable hold outlives settlement: the provider child can never be + // evicted and host crash recovery keeps respawning an abandoned worker. + await harness.call('orchestration.workerAbandon', { dispatch: dispatchId }, state.ctx) + expect(released).toEqual([dispatchId]) + }) + + it('does not release twice when the dispatch was already settled', async () => { + const dispatchId = await startedDispatch() + await harness.call('orchestration.workerAbandon', { dispatch: dispatchId }, state.ctx) + await harness.call('orchestration.workerAbandon', { dispatch: dispatchId }, state.ctx) + expect(released).toEqual([dispatchId]) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts new file mode 100644 index 00000000000..5aa386e2358 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.test.ts @@ -0,0 +1,203 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { StructuredWorkerIdentity } from '../../structured-worker-identity' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) +vi.mock('./orchestration-structured-worker-session', () => ({ + releaseStructuredWorkerSession: vi.fn() +})) + +const { + captureStructuredWorkerArchive, + observeStructuredWorker, + readArchivedStructuredJournal, + readStructuredWorkerJournal, + stopStructuredWorker +} = await import('./orchestration-structured-worker-lifecycle') + +const IDENTITY: StructuredWorkerIdentity = { + handle: 'structworker_1', + sessionId: 'session-1', + agent: 'claude', + paneKey: 'structured-agent-session-session-1:11111111-1111-4111-a111-111111111111', + processIncarnation: 'structured:session-1', + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } +} + +const ITEMS: AgentJournalRenderItem[] = [ + { + itemId: 'i1', + observedAt: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hello' }] } + } as unknown as AgentJournalRenderItem +] + +function installHost(options: { + items?: AgentJournalRenderItem[] + hasSession?: boolean + claimStatus?: string + runtimeKind?: string + deathEvidence?: unknown + record?: unknown + close?: () => Promise +}) { + const record = + options.record === undefined + ? { + location: { executionHostId: 'local', wslDistro: null }, + lease: { + runtimeKind: options.runtimeKind ?? 'native', + claimStatus: options.claimStatus ?? 'live', + deathEvidence: options.deathEvidence ?? null, + runtimeFence: 3 + } + } + : options.record + let closed = false + hostRef.current = { + deps: { store: { getRecord: () => record } }, + hasSession: () => (closed ? false : (options.hasSession ?? true)), + setSessionTabVisibility: async () => {}, + close: + options.close ?? + (async () => { + closed = true + }), + history: () => ({ + ok: true, + page: { items: options.items ?? ITEMS, hasOlder: false } + }) + } +} + +describe('structured worker observation', () => { + beforeEach(() => { + hostRef.current = null + }) + + it('is unverifiable, never exited, when the host is not installed', () => { + // Not being able to look is not a death certificate. + expect(observeStructuredWorker(IDENTITY)).toEqual({ + status: 'unverifiable', + reason: expect.stringContaining('not installed') + }) + }) + + it('is live when the host holds the session under a live native lease', () => { + installHost({}) + expect(observeStructuredWorker(IDENTITY).status).toBe('live') + }) + + it('is exited only on a released lease with death evidence', () => { + installHost({ + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'x', observedAt: 1 } + }) + expect(observeStructuredWorker(IDENTITY).status).toBe('exited') + }) + + it('is unverifiable when the lease moved to a terminal owner', () => { + installHost({ runtimeKind: 'tui' }) + expect(observeStructuredWorker(IDENTITY).status).toBe('unverifiable') + }) +}) + +describe('structured worker stop', () => { + beforeEach(() => { + hostRef.current = null + }) + + it('settles only when the session is proven gone after the close', async () => { + installHost({}) + await expect(stopStructuredWorker(IDENTITY, 'd1')).resolves.toEqual({ stopped: true }) + }) + + it('retains when the close throws', async () => { + installHost({ + close: async () => { + throw new Error('close is queued for retry') + } + }) + const result = await stopStructuredWorker(IDENTITY, 'd1') + expect(result.stopped).toBe(false) + expect(result.reason).toContain('retry') + }) + + it('retains when the session is still attached after the close', async () => { + installHost({ hasSession: true, close: async () => {} }) + const result = await stopStructuredWorker(IDENTITY, 'd1') + expect(result.stopped).toBe(false) + }) + + it('retains when the host is not installed', async () => { + const result = await stopStructuredWorker(IDENTITY, 'd1') + expect(result.stopped).toBe(false) + }) +}) + +describe('structured worker output', () => { + beforeEach(() => { + hostRef.current = null + }) + + it('round-trips the journal through the archive and back out of a released read', () => { + installHost({}) + const live = readStructuredWorkerJournal({ + identity: IDENTITY, + dispatchId: 'd1', + workerState: 'ready', + agent: 'claude' + }) + expect(live.source).toBe('transcript') + const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + hostRef.current = null + const archived = readArchivedStructuredJournal({ + dispatchId: 'd1', + workerState: 'succeeded', + resourceId: 'res_1', + createdAt: '2026-09-05 00:00:00', + archive + }) + expect(archived.source).toBe('transcript') + expect(archived.archived).toBe(true) + expect(archived.transcript?.messages).toHaveLength(1) + expect(archived.transcript?.messages[0]?.blocks[0]).toMatchObject({ text: 'hello' }) + // The frozen source has its own identity, so a live cursor cannot be replayed against it. + expect(archived.sourceIdentity).not.toBe(live.sourceIdentity) + }) + + it('redacts dispatch capabilities from the archived journal', () => { + installHost({ + items: [ + { + itemId: 'i1', + observedAt: 1, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `token dcap_${'a'.repeat(30)} here` }] + } + } as unknown as AgentJournalRenderItem + ] + }) + const archive = captureStructuredWorkerArchive(IDENTITY, 'claude') + expect(JSON.stringify(archive)).not.toContain('dcap_aaa') + expect(JSON.stringify(archive)).toContain('[dispatch capability redacted]') + }) + + it('refuses to read a session the host no longer holds', () => { + expect(() => + readStructuredWorkerJournal({ + identity: IDENTITY, + dispatchId: 'd1', + workerState: 'ready', + agent: 'claude' + }) + ).toThrow(/not attached/) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts new file mode 100644 index 00000000000..22e0bf87866 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-lifecycle.ts @@ -0,0 +1,271 @@ +/** + * The lifecycle verbs for a worker that IS a structured agent session. + * + * Observation follows the SSH execution-boundary vocabulary — `live` / `unverifiable` / `exited` — + * because losing contact with a host generation is not a death certificate. In particular a + * runtime that has not installed the structured host cannot see a session's child at all, and that + * is `unverifiable`, never `exited`. + */ + +import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types' +import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types' +import type { OrchestrationWorkerReadTranscriptResult } from '../../../../shared/orchestration-worker-output' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + buildStructuredJournalArchive, + type WorkerStructuredJournalArchive +} from '../../orchestration/structured-worker-journal-archive' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import { + boundWorkerTranscriptMessages, + clampWorkerTranscriptLimit +} from '../../orchestration/worker-transcript-payload' +import { projectStructuredItemsToNativeChat } from '../../../../shared/structured-agent-session-projection' +import { + observeStructuredWorker, + resolveStructuredWorkerIdentity, + type StructuredWorkerObservation +} from '../../structured-worker-authority' +import type { StructuredWorkerIdentity } from '../../structured-worker-identity' +import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' + +export { observeStructuredWorker, type StructuredWorkerObservation } + +const JOURNAL_PAGE_LIMIT = 200 + +/** The structured worker behind a dispatch, or null when a PTY worker owns it. */ +export function resolveStructuredWorkerForDispatch( + db: OrchestrationDb, + dispatchId: string +): StructuredWorkerIdentity | null { + const handle = + db.getWorkerDispatch(dispatchId)?.agent_terminal_handle ?? + db.getDispatchContextById(dispatchId)?.assignee_handle + return handle ? resolveStructuredWorkerIdentity(handle, db) : null +} + +/** + * Stopping a structured worker. + * + * `host.close` returns void and keeps a failed close indexed for retry, so the only settlement + * evidence is the observation AFTER it: a session the host no longer holds and whose lease is no + * longer live is proven gone. Anything else is retained rather than settled. + */ +export async function stopStructuredWorker( + identity: StructuredWorkerIdentity, + dispatchId: string, + runtime?: Pick +): Promise<{ stopped: boolean; reason?: string }> { + const host = getStructuredAgentSessionHost() + if (!host) { + return { + stopped: false, + reason: 'The structured agent-session host is not installed; no session was closed.' + } + } + try { + await host.setSessionTabVisibility?.(identity.sessionId, false) + await host.close(identity.sessionId) + } catch (error) { + return { stopped: false, reason: error instanceof Error ? error.message : String(error) } + } + releaseStructuredWorkerSession(dispatchId, runtime) + const after = observeStructuredWorker(identity) + return after.status === 'live' + ? { stopped: false, reason: 'The structured session is still attached after close.' } + : { stopped: true } +} + +/** The structured half of `worker-read`, or null when a PTY worker owns the dispatch. */ +export function readStructuredWorkerOutput(args: { + db: OrchestrationDb + dispatchId: string + workerState: string + source?: 'auto' | 'transcript' | 'terminal' + cursor?: string | number + limit?: number +}): OrchestrationWorkerReadTranscriptResult | null { + const identity = resolveStructuredWorkerForDispatch(args.db, args.dispatchId) + if (!identity) { + return null + } + if (args.source === 'terminal') { + throw new OrchestrationError( + 'archive_unavailable', + `Worker Dispatch ${args.dispatchId} is a structured chat session; it has no terminal output.` + ) + } + return readStructuredWorkerJournal({ + identity, + dispatchId: args.dispatchId, + workerState: args.workerState, + agent: identity.agent ?? 'claude', + ...(args.cursor === undefined ? {} : { cursor: args.cursor }), + ...(args.limit === undefined ? {} : { limit: args.limit }) + }) +} + +/** Journal page in the shape `worker-read --source transcript` already serves. */ +export function readStructuredWorkerJournal(args: { + identity: StructuredWorkerIdentity + dispatchId: string + workerState: string + agent: AgentType + cursor?: string | number + limit?: number +}): OrchestrationWorkerReadTranscriptResult { + const host = getStructuredAgentSessionHost() + const page = host ? tryReadJournalPage(host, args.identity.sessionId) : null + if (!page) { + throw new OrchestrationError( + 'transcript_required', + `The structured session for Dispatch ${args.dispatchId} is not attached; its journal cannot be read.` + ) + } + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'structured-journal', + args.identity.processIncarnation, + args.identity.paneKey + ]) + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + if (cursor && (cursor.source !== 'transcript' || cursor.sourceIdentity !== sourceIdentity)) { + throw new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) + } + const bounded = boundWorkerTranscriptMessages(projectStructuredItemsToNativeChat(page.items)) + return pageMessages({ + messages: bounded.messages, + warnings: [ + ...bounded.warnings, + ...(page.hasOlder ? ['Older journal items were omitted from this page.'] : []) + ], + limited: bounded.limited || page.hasOlder, + dispatchId: args.dispatchId, + workerState: args.workerState, + agent: args.agent, + sourceIdentity, + start: cursor?.position ?? 0, + limit: args.limit, + archived: false + }) +} + +/** Freezes the journal before the session is closed, so a released worker is still readable. */ +export function captureStructuredWorkerArchive( + identity: StructuredWorkerIdentity, + agent: AgentType +): WorkerStructuredJournalArchive { + const host = getStructuredAgentSessionHost() + const page = host ? tryReadJournalPage(host, identity.sessionId) : null + if (!page) { + throw new OrchestrationError( + 'archive_failed', + 'Output could not be preserved for this structured worker; the session was retained.' + ) + } + return buildStructuredJournalArchive({ + agent, + processIncarnation: identity.processIncarnation, + items: page.items, + hasOlder: page.hasOlder + }) +} + +export function readArchivedStructuredJournal(args: { + dispatchId: string + workerState: string + resourceId: string + createdAt: string + archive: WorkerStructuredJournalArchive + cursor?: string | number + limit?: number +}): OrchestrationWorkerReadTranscriptResult { + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'released-structured-journal', + args.resourceId, + args.archive.processIncarnation, + args.createdAt + ]) + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + if (cursor && (cursor.source !== 'transcript' || cursor.sourceIdentity !== sourceIdentity)) { + throw new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) + } + return pageMessages({ + messages: args.archive.messages, + warnings: args.archive.warnings, + limited: args.archive.limited, + dispatchId: args.dispatchId, + workerState: args.workerState, + agent: args.archive.agent, + sourceIdentity, + start: cursor?.position ?? 0, + limit: args.limit, + archived: true + }) +} + +function pageMessages(input: { + messages: readonly NativeChatMessage[] + warnings: string[] + limited: boolean + dispatchId: string + workerState: string + agent: AgentType + sourceIdentity: string + start: number + limit: number | undefined + archived: boolean +}): OrchestrationWorkerReadTranscriptResult { + const start = Math.min(input.start, input.messages.length) + const end = Math.min(start + clampWorkerTranscriptLimit(input.limit), input.messages.length) + const nextCursor = encodeWorkerOutputCursor( + input.dispatchId, + 'transcript', + input.sourceIdentity, + end + ) + return { + dispatchId: input.dispatchId, + source: 'transcript', + sourceIdentity: input.sourceIdentity, + provider: input.agent, + transcript: { + messages: input.messages.slice(start, end), + nextCursor, + limited: input.limited || end < input.messages.length, + returnedMessageCount: end - start + }, + cursor: nextCursor, + status: { + worker: input.workerState, + terminal: input.archived ? 'exited' : 'running' + }, + fallbackReason: null, + warnings: input.warnings, + ...(input.archived ? { archived: true } : {}) + } +} + +function tryReadJournalPage( + host: NonNullable>, + sessionId: string +): { items: readonly AgentJournalRenderItem[]; hasOlder: boolean } | null { + try { + const result = host.history({ sessionId, direction: 'tail', limit: JOURNAL_PAGE_LIMIT }) + return { items: result.page.items, hasOlder: result.page.hasOlder } + } catch { + return null + } +} 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 new file mode 100644 index 00000000000..0c8a38488e9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hostRef: { current: unknown } = { current: null } +const createSpy = vi.fn() + +vi.mock('../../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) +vi.mock('./structured-agent-session-create', () => ({ + createStructuredAgentSessionForWorktree: (...args: unknown[]) => createSpy(...args) +})) + +const { createStructuredWorkerSession, releaseStructuredWorkerSession, structuredWorkerHoldId } = + await import('./orchestration-structured-worker-session') +const { structuredWorkerIdentities } = await import('../../structured-worker-identity') +const { structuredWorkerChildIdentityEnv } = + await import('../../structured-worker-child-identity-env') + +function installHost() { + const hold = vi.fn(async () => {}) + const release = vi.fn() + const dispose = vi.fn() + hostRef.current = { + setSessionTabVisibility: async () => {}, + close: async () => {}, + deps: { + store: { + getRecord: () => ({ + location: { executionHostId: 'local', wslDistro: null }, + lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } + }) + } + }, + hold, + release, + subscribe: () => dispose + } + return { hold, release, dispose } +} + +describe('structured worker session hold', () => { + beforeEach(() => { + structuredWorkerIdentities.clear() + createSpy.mockReset() + createSpy.mockImplementation(async (args: { envelope: { sessionId: string } }) => ({ + ok: true, + value: { sessionId: args.envelope.sessionId } + })) + }) + + it('takes a resume-capable hold at start and releases it only on settlement', async () => { + const { hold, release, dispose } = installHost() + const created = await createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'claude', + dispatchId: 'd1', + onJournalActivity: () => {} + }) + // Without the hold, the release clock evicts the provider child 15s after a user closes the + // worker's chat tab, killing an idle worker mid-dispatch. + expect(hold).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) + expect(release).not.toHaveBeenCalled() + + releaseStructuredWorkerSession('d1') + expect(release).toHaveBeenCalledWith(created.identity.sessionId, structuredWorkerHoldId('d1')) + expect(dispose).toHaveBeenCalledTimes(1) + expect(structuredWorkerIdentities.get(created.identity.handle)).toBeNull() + // A second settlement is a no-op rather than a second release of the same holder. + releaseStructuredWorkerSession('d1') + expect(release).toHaveBeenCalledTimes(1) + }) + + it('registers the identity BEFORE the session is created, so the child gets the handle', async () => { + installHost() + let envAtSpawn: Record | undefined + 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) + return { ok: true, value: { sessionId: args.envelope.sessionId } } + }) + const created = await createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'claude', + dispatchId: 'd_spawn', + onJournalActivity: () => {} + }) + expect(envAtSpawn?.ORCA_TERMINAL_HANDLE).toBe(created.identity.handle) + expect(envAtSpawn?.ORCA_CLI_COMMAND).toBe('orca') + expect(envAtSpawn?.ORCA_PANE_KEY).toBeUndefined() + releaseStructuredWorkerSession('d_spawn') + }) + + it('forgets the identity and discards the session when the start fails', async () => { + const { hold } = installHost() + hold.mockRejectedValueOnce(new Error('hold refused')) + const closed: string[] = [] + ;(hostRef.current as { close: (id: string) => Promise }).close = async (id) => { + closed.push(id) + } + await expect( + createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'claude', + dispatchId: 'd_fail', + onJournalActivity: () => {} + }) + ).rejects.toThrow('hold refused') + // Neither a live provider child nor a registry entry may outlive the failed start. + expect(closed).toHaveLength(1) + expect(structuredWorkerIdentities.getBySessionId(closed[0]!)).toBeNull() + }) + + it('registers a random handle bound to the created session', async () => { + installHost() + const created = await createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'codex', + dispatchId: 'd2', + onJournalActivity: () => {} + }) + expect(created.identity.handle.startsWith('structworker_')).toBe(true) + expect(created.identity.processIncarnation).toBe(`structured:${created.identity.sessionId}`) + expect(structuredWorkerIdentities.getBySessionId(created.identity.sessionId)?.agent).toBe( + 'codex' + ) + releaseStructuredWorkerSession('d2') + }) + + it('does not activate the worker session, so a dispatch cannot steal the surface', async () => { + installHost() + await createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'claude', + dispatchId: 'd3', + onJournalActivity: () => {} + }) + expect(createSpy.mock.calls[0]![0].activate).toBe(false) + releaseStructuredWorkerSession('d3') + }) + + it('refuses a session pinned to a non-local execution host', async () => { + installHost() + ;(hostRef.current as { deps: { store: { getRecord: () => unknown } } }).deps.store.getRecord = + () => ({ + location: { executionHostId: 'ssh-1', wslDistro: null }, + lease: { runtimeFence: 2, runtimeKind: 'native', claimStatus: 'live' } + }) + await expect( + createStructuredWorkerSession({ + runtime: { ensureStructuredAgentSessionHost: async () => {} } as never, + worktreeId: 'wt_1', + agent: 'claude', + dispatchId: 'd4', + onJournalActivity: () => {} + }) + ).rejects.toThrow(/local execution host/) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts new file mode 100644 index 00000000000..3992e4b262d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -0,0 +1,249 @@ +/** + * Starting, holding and retiring a worker that IS a structured agent session. + * + * Three things make this different from the PTY worker path, and all three live here: + * + * - The session is created directly as structured, so readiness is the attach returning ok. There + * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. + * - A structured session's provider child is evicted 15s after its last HOLDER leaves, and holds + * come only from bound surfaces. A dispatched worker parked on mail is exactly that state, so + * the dispatch takes its own resume-capable hold and keeps it until the worker settles. + * - The dispatch preamble is a turn, not keystrokes. + */ + +import { randomUUID } from 'node:crypto' +import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { getStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + mintAgentSessionOperationId, + structuredPointerPayloadFingerprint +} from '../../orchestration/structured-pointer-operation-id' +import { structuredPointerCallerKey } from '../../orchestration/structured-mailbox-pointer-host' +import { + mintStructuredWorkerHandle, + structuredWorkerHostScope, + structuredWorkerIdentities, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation, + type StructuredWorkerIdentity +} from '../../structured-worker-identity' +import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' + +type StructuredWorkerBinding = { + sessionId: string + handle: string + holderId: string + disposeSubscription: () => void +} + +const bindingsByDispatchId = new Map() + +export function structuredWorkerHoldId(dispatchId: string): string { + return `orchestration:dispatch:${dispatchId}` +} + +/** + * Drops the dispatch's hold, its redrive subscription and its parked mail; the release clock takes + * it from here. + * + * EVERY settlement has to reach this — stop, release AND abandon. A surviving hold does not just + * leak: it keeps the provider child un-evictable for the life of the app, and makes host crash + * recovery respawn a child for a worker that was settled long ago. + */ +export function releaseStructuredWorkerSession( + dispatchId: string, + runtime?: Pick +): void { + const binding = bindingsByDispatchId.get(dispatchId) + if (!binding) { + return + } + bindingsByDispatchId.delete(dispatchId) + binding.disposeSubscription() + structuredWorkerIdentities.forget(binding.handle) + runtime?.forgetStructuredSessionMail?.(binding.sessionId) + try { + getStructuredAgentSessionHost()?.release(binding.sessionId, binding.holderId) + } catch (error) { + console.warn('[orchestration] structured worker hold release failed', dispatchId, error) + } +} + +export async function createStructuredWorkerSession(args: { + runtime: OrcaRuntimeService + worktreeId: string + agent: 'claude' | 'codex' + dispatchId: string + /** Retried whenever the session's journal moves, which is the structured idle edge. */ + onJournalActivity: (sessionId: string) => void +}): Promise<{ identity: StructuredWorkerIdentity; host: StructuredAgentSessionHost }> { + const sessionId = randomUUID() + // Registered BEFORE the session is created, because `attach` is what spawns the provider child + // and the child's environment is read from this registry at spawn time. Registering afterwards + // ships a worker with no ORCA_TERMINAL_HANDLE, whose bare `orca orchestration check` then + // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. + // + // The scope is provisionally local; the record's own location is asserted local below, and a + // session that resolves anywhere else never reaches a hold. + const identity = structuredWorkerIdentities.register({ + handle: mintStructuredWorkerHandle(), + sessionId, + agent: args.agent, + paneKey: mintStructuredWorkerPaneKey(sessionId), + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: args.worktreeId, + hostScope: { kind: 'local', hostId: 'local' } + }) + let created: Awaited> | undefined + try { + created = await createStructuredAgentSessionForWorktree({ + runtime: args.runtime, + ensureHost: async () => { + await args.runtime.ensureStructuredAgentSessionHost() + return requireInstalledHost() + }, + caller: { callerKey: structuredPointerCallerKey(args.dispatchId) }, + envelope: { + sessionId, + clientOperationId: mintAgentSessionOperationId(Date.now()), + expectedRuntimeFence: null, + payloadFingerprint: '' + }, + worktree: `id:${args.worktreeId}`, + agent: args.agent, + // Dispatching a worker is background work; it must not pull the surface away from the user. + activate: false + }) + if (!created.ok) { + throw new OrchestrationError( + 'agent_unconfigured', + `The structured ${args.agent} session for this worker was refused: ${created.refusal.message}` + ) + } + const host = requireInstalledHost() + const record = host.deps.store.getRecord(sessionId) + if (!record || !structuredWorkerHostScope(record.location)) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A structured worker must run on the local execution host outside WSL.' + ) + } + const holderId = structuredWorkerHoldId(args.dispatchId) + await host.hold(sessionId, holderId) + const disposeSubscription = subscribeForRedrive(host, sessionId, args.onJournalActivity) + bindingsByDispatchId.set(args.dispatchId, { + sessionId, + handle: identity.handle, + holderId, + disposeSubscription + }) + return { identity, host } + } catch (error) { + // A start that fails after the session exists would otherwise strand a live provider child and + // a published background tab that no dispatch owns. + structuredWorkerIdentities.forget(identity.handle) + if (created?.ok) { + await discardCreatedSession(sessionId) + } + throw error + } +} + +/** Best-effort teardown of a session created by a worker start that then failed. */ +async function discardCreatedSession(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() + if (!host) { + return + } + try { + await host.setSessionTabVisibility?.(sessionId, false) + await host.close(sessionId) + } catch (error) { + console.warn( + '[orchestration] failed to discard a half-started structured worker', + sessionId, + error + ) + } +} + +/** Delivers the dispatch preamble as the worker's first turn. */ +export async function sendStructuredWorkerPreamble(args: { + host: StructuredAgentSessionHost + sessionId: string + dispatchId: string + preamble: string +}): Promise { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: args.preamble }] + } + const fence = args.host.deps.store.getRecord(args.sessionId)?.lease.runtimeFence + if (fence === undefined) { + throw new Error('The structured worker session has no durable record to dispatch into.') + } + const result = await args.host.send( + { callerKey: structuredPointerCallerKey(args.dispatchId) }, + { + envelope: { + sessionId: args.sessionId, + clientOperationId: mintAgentSessionOperationId(Date.now()), + expectedRuntimeFence: fence, + payloadFingerprint: structuredPointerPayloadFingerprint(args.sessionId, body) + }, + body, + retryUnknown: true + } + ) + if (!result.ok) { + throw new Error(`The dispatch preamble was refused: ${result.refusal.message}`) + } + if (result.value.submission.dispatchState === 'rejected') { + throw new Error( + `The dispatch preamble was rejected: ${result.value.submission.reason ?? 'no reason given'}` + ) + } +} + +function requireInstalledHost(): StructuredAgentSessionHost { + const host = getStructuredAgentSessionHost() + if (!host) { + throw new OrchestrationError( + 'agent_unconfigured', + 'Structured agent sessions are unavailable on this runtime.' + ) + } + return host +} + +/** + * Any journal movement is the redrive edge. + * + * A settled turn is TOMBSTONED rather than rewritten, so watching for a completed lifecycle row + * would miss the common case. Re-running the gate on every batch is cheap because it only does + * work when a pointer is actually parked on this session. + */ +function subscribeForRedrive( + host: StructuredAgentSessionHost, + sessionId: string, + onJournalActivity: (sessionId: string) => void +): () => void { + try { + return host.subscribe({ + id: `orchestration:redrive:${sessionId}`, + sessionId, + emit: (event) => { + if (event.type === 'batch' || event.type === 'reset') { + onJournalActivity(sessionId) + } + } + }) + } catch (error) { + console.warn('[orchestration] structured worker redrive subscription failed', sessionId, error) + return () => {} + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts index 1830944c98c..b8f33547627 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-archive-read.ts @@ -20,6 +20,8 @@ import { encodeWorkerOutputCursor } from '../../orchestration/worker-output-cursor' import { readWorkerTranscript } from '../../orchestration/worker-transcript-read' +import type { WorkerStructuredJournalArchive } from '../../orchestration/structured-worker-journal-archive' +import { readArchivedStructuredJournal } from './orchestration-structured-worker-lifecycle' const ARCHIVED_TERMINAL_PAGE_LINES = 2_000 @@ -42,6 +44,23 @@ export async function readArchivedWorkerOutput(args: { `Dispatch ${args.dispatchId} was released without a preserved output archive.` ) } + if (archive.kind === 'structured_journal') { + if (args.source === 'terminal') { + throw new OrchestrationError( + 'archive_unavailable', + `Dispatch ${args.dispatchId} preserved structured chat output only; terminal output was released.` + ) + } + return readArchivedStructuredJournal({ + dispatchId: args.dispatchId, + workerState: args.workerState, + resourceId: args.resource.id, + createdAt: archive.created_at, + archive: JSON.parse(archive.content) as WorkerStructuredJournalArchive, + ...(args.cursor === undefined ? {} : { cursor: args.cursor }), + ...(args.limit === undefined ? {} : { limit: args.limit }) + }) + } if (archive.kind === 'transcript_pin') { if (args.source === 'terminal') { throw new OrchestrationError( diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts index e21b899319b..db85c622b58 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-control.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -1,8 +1,5 @@ import { z } from 'zod' -import { - ORCHESTRATION_WORKER_READ_SOURCES, - type OrchestrationWorkerReadResult -} from '../../../../shared/orchestration-worker-output' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' import { contextOnlyAbandonWarning } from '../../orchestration/context-only-dispatch-release' import { OrchestrationError } from '../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../core' @@ -15,7 +12,9 @@ import { showContextOnlyWorker } from './orchestration-worker-observation' import { readArchivedWorkerOutput } from './orchestration-worker-archive-read' -import { readLegacyFederatedTerminal } from './orchestration-worker-legacy-federated-read' +import { readStructuredWorkerOutput } from './orchestration-structured-worker-lifecycle' +import { releaseStructuredWorkerSession } from './orchestration-structured-worker-session' +import { readFederatedWorkerOutput } from './orchestration-worker-legacy-federated-read' import { readExactWorkerOutput } from './orchestration-worker-output' import { exposeWorkerTerminalResource } from './orchestration-worker-release-completion' @@ -158,39 +157,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ const db = runtime.getOrchestrationDb() const federated = db.getFederatedDispatch(params.dispatch) if (federated) { - const server = resolvePinnedFederatedServer(runtime, federated) - try { - const remote = (await runtime.callOrchestrationWorkerServer( - server.environmentId, - 'orchestration.federationReadOutput', - { - dispatchId: params.dispatch, - cursor: params.cursor, - limit: params.limit, - source: params.source - }, - 15_000 - )) as { runtimeEpoch: string; output: OrchestrationWorkerReadResult } - return { - ...remote.output, - server: { environmentId: server.environmentId, name: server.name }, - remoteRuntimeEpoch: remote.runtimeEpoch - } - } catch (error) { - if (!(error instanceof OrchestrationError) || error.code !== 'method_not_found') { - throw error - } - return readLegacyFederatedTerminal({ - runtime, - server, - federated, - workerState: db.getWorkerDispatch(params.dispatch)?.state ?? 'unknown', - dispatchId: params.dispatch, - source: params.source, - cursor: params.cursor, - limit: params.limit - }) - } + return readFederatedWorkerOutput({ runtime, db, federated, params }) } const dispatch = db.getDispatchContextById(params.dispatch) const worker = db.getWorkerDispatch(params.dispatch) @@ -226,6 +193,17 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ `Worker Dispatch ${params.dispatch} no longer resolves to its exact process.` ) } + const structured = readStructuredWorkerOutput({ + db, + dispatchId: params.dispatch, + workerState: worker?.state ?? 'unsupervised', + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + if (structured) { + return structured + } const output = await readExactWorkerOutput({ runtime, dispatchId: params.dispatch, @@ -265,6 +243,10 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) if (abandoned.disposition === 'context_only') { if (!abandoned.alreadySettled) { + // Abandon settles the Dispatch, so it owes the same hold release stop and release do. + // A surviving hold pins the provider child for the life of the app and makes host crash + // recovery respawn a worker nobody is waiting on. + releaseStructuredWorkerSession(params.dispatch, runtime) runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') } return { @@ -279,6 +261,7 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ } const worker = abandoned.worker if (abandoned.disposition === 'abandoned') { + releaseStructuredWorkerSession(params.dispatch, runtime) runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') } return { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts index 87935f07173..ed97e1aa9f4 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-legacy-federated-read.ts @@ -1,4 +1,11 @@ -import type { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' +import type { + ORCHESTRATION_WORKER_READ_SOURCES, + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../../shared/orchestration-worker-output' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import type { FederatedDispatchRow } from '../../orchestration/types' import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' import { OrchestrationError } from '../../orchestration/orchestration-error' import { @@ -6,7 +13,7 @@ import { decodeWorkerOutputCursor, encodeWorkerOutputCursor } from '../../orchestration/worker-output-cursor' -import type { resolvePinnedFederatedServer } from './orchestration-worker-observation' +import { resolvePinnedFederatedServer } from './orchestration-worker-observation' // Pre-structured-output servers only expose raw terminal reads; keep that path fenced and // cursor-scoped so an old peer never silently degrades a transcript cursor. @@ -74,3 +81,51 @@ export async function readLegacyFederatedTerminal(args: { remoteRuntimeEpoch: remote.runtimeEpoch } } + +/** Current-protocol federated read, with the legacy terminal relay as the only fallback. */ +export async function readFederatedWorkerOutput(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + federated: FederatedDispatchRow + params: { + dispatch: string + cursor?: string | number + limit?: number + source?: OrchestrationWorkerReadSource + } +}) { + const { runtime, db, federated, params } = args + const server = resolvePinnedFederatedServer(runtime, federated) + try { + const remote = (await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationReadOutput', + { + dispatchId: params.dispatch, + cursor: params.cursor, + limit: params.limit, + source: params.source + }, + 15_000 + )) as { runtimeEpoch: string; output: OrchestrationWorkerReadResult } + return { + ...remote.output, + server: { environmentId: server.environmentId, name: server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch + } + } catch (error) { + if (!(error instanceof OrchestrationError) || error.code !== 'method_not_found') { + throw error + } + return readLegacyFederatedTerminal({ + runtime, + server, + federated, + workerState: db.getWorkerDispatch(params.dispatch)?.state ?? 'unknown', + dispatchId: params.dispatch, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-observation.ts b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts index b4e947893fc..fb0529d6fd9 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-observation.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts @@ -2,6 +2,10 @@ import type { RuntimeTerminalInteractiveWait } from '../../../../shared/runtime- import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + observeStructuredWorker, + resolveStructuredWorkerForDispatch +} from './orchestration-structured-worker-lifecycle' import type { DispatchContextRow, FederatedDispatchRow, @@ -27,6 +31,24 @@ export async function inspectWorkerTerminal( if (!terminalHandle) { return { terminal: null, exact: false, status: 'unattached' } } + const structured = resolveStructuredWorkerForDispatch(db, dispatchId) + if (structured) { + // Exactness is the recorded pane and lineage, which the runtime getters answer from the + // structured registry; there is no terminal to show. + const exact = db.isDispatchProcessCurrent({ + dispatchId, + paneKey: structured.paneKey, + processIncarnation: structured.processIncarnation + }) + const observation = observeStructuredWorker(structured) + return { + terminal: null, + exact, + status: exact ? observation.status : 'identity_changed', + ...(exact && observation.reason ? { reason: observation.reason } : {}), + agentWait: null + } + } const terminal = await runtime.showTerminal(terminalHandle).catch(() => null) if (!terminal) { return { terminal: null, exact: false, status: 'missing' } diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts index ecac17b8cbb..7b14e282dd8 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-completion.ts @@ -1,29 +1,32 @@ import type { OrchestrationDb } from '../../orchestration/db' -import type { - WorkerTerminalArchiveRow, - WorkerTerminalArchiveStatus, - WorkerTerminalResourceRow, - WorkerTerminalRetainedReason -} from '../../orchestration/worker-terminal-ownership' import { - captureWorkerOutputArchive, - type WorkerTerminalTailArchive -} from '../../orchestration/worker-output-archive' + archiveSummary, + retainedReason, + summarizeStoredArchive, + type WorkerReleaseReceipt +} from './orchestration-worker-release-receipts' +export { + archiveSummary, + exposeWorkerTerminalResource, + type WorkerReleaseReceipt +} from './orchestration-worker-release-receipts' +import type { + WorkerTerminalArchiveKind, + WorkerTerminalArchiveStatus, + WorkerTerminalResourceRow +} from '../../orchestration/worker-terminal-ownership' +import { captureWorkerOutputArchive } from '../../orchestration/worker-output-archive' import type { OrcaRuntimeService } from '../../orca-runtime' import { describeUnconfirmedAgentStop } from '../../../../shared/pty-liveness-verdict' import { inspectWorkerTerminal } from './orchestration-worker-observation' +import { + resolveStructuredWorkerForDispatch, + stopStructuredWorker +} from './orchestration-structured-worker-lifecycle' +import { isStructuredWorkerHandle } from '../../structured-worker-identity' +import { structuredWorkerTerminalLeaseIsCurrent } from './orchestration-worker-release-receipts' import { orchestrationTimestampToMs } from './orchestration-worker-output' -export type WorkerReleaseReceipt = { - dispatchId: string - state: 'released' | 'already_released' | 'retained' | 'release_pending' | 'release_unknown' - reason?: WorkerTerminalRetainedReason - processAction: 'closed_agent_terminal' | 'closed_exited_terminal' | 'none' - archive: { source: string | null; status: string | null } | null - recovery?: string - lastError?: string -} - type WorkerTerminalReleaseArgs = { runtime: OrcaRuntimeService db: OrchestrationDb @@ -37,48 +40,6 @@ const activeReleaseByRuntime = new WeakMap< Map> >() -export function exposeWorkerTerminalResource(resource: WorkerTerminalResourceRow): { - id: string - ownershipState: string - releaseState: string - retainedReason: string | null - terminalHandle: string - worktreeId: string | null - originDispatchId: string - ownerDispatchId: string - releaseRequestedAt: string | null - releaseCompletedAt: string | null - releaseError: string | null - archive: { source: string | null; status: string | null } -} { - return { - id: resource.id, - ownershipState: resource.ownership_state, - releaseState: resource.release_state, - retainedReason: resource.retained_reason, - terminalHandle: resource.terminal_handle, - worktreeId: resource.worktree_id, - originDispatchId: resource.origin_dispatch_id, - ownerDispatchId: resource.owner_dispatch_id, - releaseRequestedAt: resource.release_requested_at, - releaseCompletedAt: resource.release_completed_at, - releaseError: resource.release_error, - archive: { source: resource.archive_source, status: resource.archive_status } - } -} - -export function archiveSummary( - resource: WorkerTerminalResourceRow | null -): { source: string | null; status: string | null } | null { - if (!resource) { - return null - } - if (!resource.archive_source && !resource.archive_status) { - return null - } - return { source: resource.archive_source, status: resource.archive_status } -} - // Completes a durably requested release: re-prove exact identity, freeze output, close only the // exact agent terminal, settle. Shared between the RPC method and the startup reconciler. export function completeWorkerTerminalRelease( @@ -106,6 +67,23 @@ async function completeWorkerTerminalReleaseOnce( args: WorkerTerminalReleaseArgs ): Promise { const { runtime, db, dispatchId, resource } = args + if (isStructuredWorkerHandle(resource.terminal_handle)) { + // Observation and archive capture both read the structured host, and after a restart nothing + // has installed it yet — the startup recovery reconciler runs exactly this path. Installing it + // here is what lets the release see the session instead of reporting it unreadable. + // + // NOT yet handled, and deliberately follow-up: rebinding a restarted runtime to a structured + // worker's hold and redrive subscription. Until that exists, a worker that survives a restart + // keeps no hold, so its child is evictable and its parked mail waits for the next arrival + // rather than a settle edge. + await runtime.ensureStructuredAgentSessionHost().catch((error: unknown) => { + console.warn( + '[orchestration] structured host install failed before release', + dispatchId, + error + ) + }) + } const worker = db.getWorkerDispatch(dispatchId) if (!worker || worker.agent_terminal_handle !== resource.terminal_handle) { const retained = db.revertWorkerTerminalReleaseToRetained(resource.id, 'identity_unproven') @@ -169,16 +147,18 @@ async function completeWorkerTerminalReleaseOnce( const archive = db.getWorkerTerminalArchive(dispatchId) let archiveSource = resource.archive_source as 'transcript' | 'terminal' | null let archiveStatus: WorkerTerminalArchiveStatus | null = resource.archive_status - let capturedArchive: { kind: 'transcript_pin' | 'terminal_tail'; content: string } | undefined + let capturedArchive: { kind: WorkerTerminalArchiveKind; content: string } | undefined + const structured = resolveStructuredWorkerForDispatch(db, dispatchId) if (!archive) { const captured = await captureWorkerOutputArchive({ runtime, dispatchId, terminalHandle: resource.terminal_handle, - attachedAtMs: orchestrationTimestampToMs(worker.created_at) + attachedAtMs: orchestrationTimestampToMs(worker.created_at), + structuredWorker: structured }) capturedArchive = { kind: captured.kind, content: JSON.stringify(captured.content) } - archiveSource = captured.kind === 'transcript_pin' ? 'transcript' : 'terminal' + archiveSource = captured.kind === 'terminal_tail' ? 'terminal' : 'transcript' archiveStatus = captured.status } else { const stored = summarizeStoredArchive(archive) @@ -213,6 +193,31 @@ async function completeWorkerTerminalReleaseOnce( } try { + if (structured) { + const stop = await stopStructuredWorker(structured, dispatchId, runtime) + if (!stop.stopped) { + const unknown = db.markWorkerTerminalReleaseUnknown( + resource.id, + stop.reason ?? 'The structured session close was not proven.' + ) + return { + dispatchId, + state: 'release_unknown', + processAction: 'closed_agent_terminal', + archive: { source: archiveSource, status: archiveStatus }, + lastError: unknown.release_error ?? stop.reason, + recovery: `Inspect with: orca orchestration worker-show --dispatch ${dispatchId} --json — then repeat worker-release with the same --retry-request.` + } + } + const settled = db.settleWorkerTerminalRelease(resource.id) + runtime.notifyMessageArrived(`dispatch:${dispatchId}`, 'status') + return { + dispatchId, + state: 'released', + processAction: 'closed_agent_terminal', + archive: archiveSummary(settled) + } + } const close = await runtime.closeTerminal(resource.terminal_handle) if (!close.ptyKilled) { const reason = describeUnconfirmedAgentStop(close) @@ -268,6 +273,9 @@ function workerTerminalLeaseIsCurrent( resource: WorkerTerminalResourceRow ): boolean { const worker = db.getWorkerDispatch(dispatchId) + if (isStructuredWorkerHandle(resource.terminal_handle)) { + return structuredWorkerTerminalLeaseIsCurrent(db, dispatchId, worker, resource) + } const authority = runtime.getOrchestrationDispatchAuthority(resource.terminal_handle) return Boolean( worker?.agent_terminal_handle === resource.terminal_handle && @@ -281,25 +289,3 @@ function workerTerminalLeaseIsCurrent( !db.workerTerminalResourceHasIdentityConflict(resource.id) ) } - -function summarizeStoredArchive(archive: WorkerTerminalArchiveRow): { - source: 'transcript' | 'terminal' - status: Extract -} { - if (archive.kind === 'transcript_pin') { - return { source: 'transcript', status: 'captured' } - } - const content = JSON.parse(archive.content) as WorkerTerminalTailArchive - const empty = content.lines.every((line) => line.trim() === '') - return { source: 'terminal', status: empty ? 'empty' : 'captured' } -} - -function retainedReason(resource: WorkerTerminalResourceRow): WorkerTerminalRetainedReason { - if (resource.retained_reason) { - return resource.retained_reason as WorkerTerminalRetainedReason - } - if (resource.ownership_state === 'user_owned') { - return 'user_takeover' - } - return 'identity_unproven' -} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-release-receipts.ts b/src/main/runtime/rpc/methods/orchestration-worker-release-receipts.ts new file mode 100644 index 00000000000..b8d0fbcfc3e --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-release-receipts.ts @@ -0,0 +1,117 @@ +// Receipt shapes for worker terminal release, and the small readers that build them. +// +// Split from the release runner so the runner stays about the ordering of proofs. + +import type { + WorkerTerminalArchiveRow, + WorkerTerminalArchiveStatus, + WorkerTerminalResourceRow, + WorkerTerminalRetainedReason +} from '../../orchestration/worker-terminal-ownership' +import type { WorkerTerminalTailArchive } from '../../orchestration/worker-output-archive' +import type { OrchestrationDb } from '../../orchestration/db' +import type { WorkerDispatchRow } from '../../orchestration/types' +import { resolveStructuredWorkerIdentity } from '../../structured-worker-authority' + +export type WorkerReleaseReceipt = { + dispatchId: string + state: 'released' | 'already_released' | 'retained' | 'release_pending' | 'release_unknown' + reason?: WorkerTerminalRetainedReason + processAction: 'closed_agent_terminal' | 'closed_exited_terminal' | 'none' + archive: { source: string | null; status: string | null } | null + recovery?: string + lastError?: string +} + +export function exposeWorkerTerminalResource(resource: WorkerTerminalResourceRow): { + id: string + ownershipState: string + releaseState: string + retainedReason: string | null + terminalHandle: string + worktreeId: string | null + originDispatchId: string + ownerDispatchId: string + releaseRequestedAt: string | null + releaseCompletedAt: string | null + releaseError: string | null + archive: { source: string | null; status: string | null } +} { + return { + id: resource.id, + ownershipState: resource.ownership_state, + releaseState: resource.release_state, + retainedReason: resource.retained_reason, + terminalHandle: resource.terminal_handle, + worktreeId: resource.worktree_id, + originDispatchId: resource.origin_dispatch_id, + ownerDispatchId: resource.owner_dispatch_id, + releaseRequestedAt: resource.release_requested_at, + releaseCompletedAt: resource.release_completed_at, + releaseError: resource.release_error, + archive: { source: resource.archive_source, status: resource.archive_status } + } +} + +export function archiveSummary( + resource: WorkerTerminalResourceRow | null +): { source: string | null; status: string | null } | null { + if (!resource) { + return null + } + if (!resource.archive_source && !resource.archive_status) { + return null + } + return { source: resource.archive_source, status: resource.archive_status } +} + +export function summarizeStoredArchive(archive: WorkerTerminalArchiveRow): { + source: 'transcript' | 'terminal' + status: Extract +} { + if (archive.kind === 'transcript_pin') { + return { source: 'transcript', status: 'captured' } + } + if (archive.kind === 'structured_journal') { + const journal = JSON.parse(archive.content) as { messages: unknown[] } + return { source: 'transcript', status: journal.messages.length > 0 ? 'captured' : 'empty' } + } + const content = JSON.parse(archive.content) as WorkerTerminalTailArchive + const empty = content.lines.every((line) => line.trim() === '') + return { source: 'terminal', status: empty ? 'empty' : 'captured' } +} + +export function retainedReason(resource: WorkerTerminalResourceRow): WorkerTerminalRetainedReason { + if (resource.retained_reason) { + return resource.retained_reason as WorkerTerminalRetainedReason + } + if (resource.ownership_state === 'user_owned') { + return 'user_takeover' + } + return 'identity_unproven' +} + +export function structuredWorkerTerminalLeaseIsCurrent( + db: OrchestrationDb, + dispatchId: string, + worker: WorkerDispatchRow | undefined, + resource: WorkerTerminalResourceRow +): boolean { + // IDENTITY, not liveness. The durable row plus the session-lineage incarnation say whether + // this is still the same worker; whether its child is alive is what the observation reports, + // honestly, as live / unverifiable / exited. Asking the record for identity would make a + // restart — where the host may not be installed yet — read as a different worker and turn a + // durably requested release into a permanent `retained/identity_unproven`. + const identity = resolveStructuredWorkerIdentity(resource.terminal_handle, db) + return Boolean( + worker?.agent_terminal_handle === resource.terminal_handle && + identity && + resource.host_scope === JSON.stringify(identity.hostScope) && + db.isDispatchProcessCurrent({ + dispatchId, + paneKey: identity.paneKey, + processIncarnation: identity.processIncarnation + }) && + !db.workerTerminalResourceHasIdentityConflict(resource.id) + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts index cf081f9b36c..4b86d4cc95b 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts @@ -26,6 +26,8 @@ export const WorkerStartParams = z.object({ effort: OptionalWorkerLaunchPreference, retryOf: OptionalString, timeoutMs: OptionalFiniteNumber, + // Opt-in: start the worker as a native-born structured chat session rather than a PTY agent. + structured: z.boolean().optional(), devMode: z.boolean().optional() }) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts index 3eb46e19b0c..6dcd468cbb2 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts @@ -9,6 +9,10 @@ import { inspectWorkerTerminal, resolvePinnedFederatedServer } from './orchestration-worker-observation' +import { + resolveStructuredWorkerForDispatch, + stopStructuredWorker +} from './orchestration-structured-worker-lifecycle' const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) @@ -149,6 +153,26 @@ export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ 'none' ) } + const structured = resolveStructuredWorkerForDispatch(db, params.dispatch) + if (structured) { + const stop = await stopStructuredWorker(structured, params.dispatch, runtime) + if (!stop.stopped) { + // Close is retried by the host; only a proven exit may settle the dispatch. + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown(params.dispatch, stop.reason ?? 'The close was not proven.'), + 'closed_agent_terminal' + ) + } + const stopped = db.settleWorkerStop(params.dispatch) + runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') + return { + dispatchId: params.dispatch, + state: stopped.state, + alreadySettled: false, + processAction: 'closed_agent_terminal' + } + } try { const close = await runtime.closeTerminal(handle) if (!close.ptyKilled) { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts index 582d32058a8..7d27b794614 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -2,6 +2,8 @@ import type { AgentLaunchPreferences } from '../../../../shared/agent-session-ho import type { TuiAgent } from '../../../../shared/tui-agent' import type { OrcaRuntimeService } from '../../orca-runtime' import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { createStructuredWorkerSession } from './orchestration-structured-worker-session' export type WorkerEffect = { kind: 'worktree' | 'terminal' | 'setup' | 'dispatch_input' @@ -83,6 +85,43 @@ export async function createExistingWorktreeWorkerTerminal(args: { return { handle: terminal.handle, warning: terminal.warning } } +/** + * A worker that IS a structured chat session, in the same shape the terminal path returns. + * + * `requireWorkerAuthority` needs no branch: the runtime's pane-key and process-incarnation getters + * consult the structured registry, so the handle minted here answers exactly like a PTY handle. + */ +export async function createStructuredWorkerSessionForWorktree(args: { + runtime: OrcaRuntimeService + worktreeId: string + agent: TuiAgent + dispatchId: string + effects: WorkerEffect[] +}): Promise>> { + if (args.agent !== 'claude' && args.agent !== 'codex') { + throw new OrchestrationError( + 'agent_unconfigured', + `Structured workers support claude and codex; ${args.agent} has no structured session.` + ) + } + const created = await createStructuredWorkerSession({ + runtime: args.runtime, + worktreeId: args.worktreeId, + agent: args.agent, + dispatchId: args.dispatchId, + onJournalActivity: (sessionId) => + args.runtime.notifyStructuredSessionJournalActivity?.(sessionId) + }) + args.effects.push({ + kind: 'terminal', + role: 'agent', + action: 'created', + id: created.identity.handle, + surface: 'background' + }) + return created +} + export function applyWaitForSetupOutcome( receipt: WorkerSetupReceipt, effects: WorkerEffect[], diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts index 61271525939..ead13db334c 100644 --- a/src/main/runtime/rpc/methods/orchestration-workers.ts +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -1,26 +1,8 @@ -import type { TuiAgent } from '../../../../shared/tui-agent' -import { buildDispatchPreamble } from '../../orchestration/preamble' import { OrchestrationError } from '../../orchestration/orchestration-error' import { defineMethod, type RpcMethod } from '../core' import { startFederatedWorker } from './orchestration-federated-worker-start' -import { assertOrchestrationWorktreeCreationSupported } from './orchestration-folder-worktree-placement' +import { startLocalWorker } from './orchestration-local-worker-start' import { WorkerStartParams } from './orchestration-worker-start-schema' -import { - createExistingWorktreeWorkerTerminal, - createWorkerWorktree, - monitorWorkerSetup, - requireWorkerAuthority, - type WorkerEffect, - type WorkerSetupReceipt -} from './orchestration-worker-topology' -import { - persistGatedSetupSpawnFailure, - persistWorkerReadinessStage, - persistWorkerSetupWaitOutcome -} from './orchestration-worker-setup-gate' -import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' -import { prepareLocalWorkerStart } from './orchestration-worker-start-validation' -import { resolveDispatchCreator } from './orchestration-dispatch-creator' import { resolveOrchestrationCaller } from './orchestration-run-scope' import { isWorkerStartTimeoutWithinTimerLimit, @@ -64,6 +46,7 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ ) } + assertStructuredWorkerStartSupported(params) if (params.on) { return startFederatedWorker({ params, @@ -74,228 +57,50 @@ export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ orchestrationMutation }) } - - const requestedWorktree = params.worktree ?? 'current' - const createsWorktree = - requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' - const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) - - const coordinatorTerminal = await runtime.showTerminal(params.from) - const creationWorktree = createsWorktree - ? await runtime.showManagedWorktree(`id:${coordinatorTerminal.worktreeId}`) - : undefined - if (creationWorktree) { - await assertOrchestrationWorktreeCreationSupported({ - runtime, - repoSelector: params.repo ?? creationWorktree.repoId, - existingPlacement: 'current or an exact existing folder workspace' - }) - } - let resolvedWorktree = creationWorktree - ? undefined - : requestedWorktree === 'current' - ? await runtime.showManagedTerminalWorkspace(`id:${coordinatorTerminal.worktreeId}`) - : await runtime.showManagedTerminalWorkspace(requestedWorktree) - let explicitTerminal - if (params.terminal) { - explicitTerminal = await runtime.showTerminal(params.terminal) - if (explicitTerminal.worktreeId !== resolvedWorktree?.id) { - throw new OrchestrationError( - 'terminal_worktree_mismatch', - `Terminal ${params.terminal} does not belong to worktree ${resolvedWorktree?.id}.` - ) - } - if (!(await runtime.isTerminalRunningAgent(params.terminal))) { - throw new OrchestrationError( - 'agent_unconfigured', - `Terminal ${params.terminal} is not running a recognized agent.` - ) - } - } - - const startOptions = { - worktree: requestedWorktree, - resolvedWorktreeId: resolvedWorktree?.id ?? null, - name: params.name ?? null, - repo: params.repo ?? creationWorktree?.repoId ?? null, - baseBranch: params.baseBranch ?? null, - terminal: params.terminal ?? null, - agent: agent ?? null, - launch: launch.receipt, - timeoutMs: readinessTimeoutMs, - setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', - setupSource: createsWorktree - ? params.setup - ? 'explicit_request' - : 'orchestration_default' - : 'existing_worktree' - } - const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), - maxDepth: runtime.getNestedWorkerMaxDepth(), - taskId: task.id, - retryOf: params.retryOf, - startOptions, - runtimeEpoch: runtime.getRuntimeId(), - mutationReceipt: orchestrationMutation + return startLocalWorker({ + params, + runtime, + db, + run, + task, + readinessTimeoutMs, + orchestrationMutation }) - const effects: WorkerEffect[] = [] - if (resolvedWorktree) { - effects.push( - { kind: 'worktree', action: 'reused', id: resolvedWorktree.id }, - { kind: 'setup', action: 'not_applicable', state: 'not_applicable' } - ) - } - let terminalHandle = params.terminal - let terminalRevealWarning: string | undefined - let failedStage = 'terminal_create' - let setupReceipt: WorkerSetupReceipt = { - requested: 'not_applicable', - effective: 'not_applicable', - source: 'existing_worktree', - hookFound: false, - startupPolicy: 'start-immediately', - state: 'not_applicable' - } - try { - if (creationWorktree) { - failedStage = 'worktree_create' - const created = await createWorkerWorktree({ - runtime, - db, - dispatchId: started.dispatch.id, - requestedWorktree, - coordinatorWorktree: creationWorktree, - params, - agent: agent as TuiAgent, - launchPreferences: launch.preferences, - effects - }) - resolvedWorktree = created.worktree - terminalHandle = created.terminalHandle - setupReceipt = created.setupReceipt - } else if (!terminalHandle) { - db.recordWorkerStage({ - dispatchId: started.dispatch.id, - stage: 'terminal_creating', - worktreeId: resolvedWorktree!.id, - effects - }) - const terminal = await createExistingWorktreeWorkerTerminal({ - runtime, - worktreeId: resolvedWorktree!.id, - agent: agent as TuiAgent, - launchPreferences: launch.preferences, - taskId: task.id, - effects - }) - terminalHandle = terminal.handle - terminalRevealWarning = terminal.warning - } else { - effects.push({ - kind: 'terminal', - role: 'agent', - action: 'reused', - id: terminalHandle - }) - } - if (!resolvedWorktree || !terminalHandle) { - throw new Error('Worker topology did not resolve an agent terminal and worktree.') - } - const setupStage = { - db, - dispatchId: started.dispatch.id, - worktreeId: resolvedWorktree.id, - terminalHandle, - setup: setupReceipt, - effects - } - if (persistGatedSetupSpawnFailure(setupStage)) { - failedStage = 'setup_start' - throw new Error('Setup terminal failed to start before the gated agent launch.') - } - persistWorkerReadinessStage(setupStage) - - failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: readinessTimeoutMs - }) - persistWorkerSetupWaitOutcome({ ...setupStage, wait }) - if (!wait.satisfied) { - if (setupReceipt.state === 'failed') { - failedStage = 'setup_wait' - } - throw new Error( - wait.blockedReason - ? `Agent startup blocked: ${wait.blockedReason}` - : `Agent did not become ready (${wait.status}).` - ) - } - const terminalAuthority = requireWorkerAuthority(runtime, terminalHandle) - const capability = db.prepareStartingWorkerAuthority({ - dispatchId: started.dispatch.id, - handle: terminalHandle, - ...terminalAuthority, - worktreeId: resolvedWorktree.id, - effects, - setupState: setupReceipt.state, - terminalOwnership: params.terminal ? 'external' : 'created' - }) - - failedStage = 'dispatch_input' - const preamble = buildDispatchPreamble({ - canDispatchSubWorkers: started.dispatch.depth < runtime.getNestedWorkerMaxDepth(), - taskId: task.id, - dispatchId: started.dispatch.id, - taskSpec: task.spec, - coordinatorHandle: params.from, - workerHandle: terminalHandle, - dispatchCapability: capability, - devMode: params.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) - }) - await runtime.sendTerminalAgentPrompt(terminalHandle, preamble) - effects.push({ - kind: 'dispatch_input', - role: 'agent', - id: terminalHandle, - state: 'accepted' - }) - const worker = db.markWorkerDispatchReady(started.dispatch.id, effects) - monitorWorkerSetup({ - runtime, - db, - runId: run.id, - dispatchId: started.dispatch.id, - setupReceipt, - effects - }) - return { - runId: run.id, - taskId: task.id, - dispatchId: started.dispatch.id, - state: worker.state, - stage: worker.stage, - setup: setupReceipt, - launch: launch.receipt, - timeoutMs: readinessTimeoutMs, - effects, - residualResources: [], - ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) - } - } catch (error) { - return failWorkerStartWithReceipt({ - db, - runId: run.id, - taskId: task.id, - dispatchId: started.dispatch.id, - failedStage, - error, - setup: setupReceipt, - launch: launch.receipt - }) - } } }) ] + +/** + * Where `--structured` cannot apply. + * + * Refused rather than ignored: silently starting a terminal worker under a structured request + * would hand the coordinator a worker of a different kind than it asked for. + */ +function assertStructuredWorkerStartSupported(params: { + structured?: boolean + on?: string + terminal?: string + worktree?: string +}): void { + if (!params.structured) { + return + } + if (params.on) { + throw new OrchestrationError( + 'invalid_argument', + 'Structured workers run only on the local execution host; --structured cannot combine with --on.' + ) + } + if (params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses a running terminal agent and cannot combine with --structured.' + ) + } + if (params.worktree === 'new-child' || params.worktree === 'new-top-level') { + throw new OrchestrationError( + 'invalid_argument', + 'Structured workers attach to an existing worktree; create the worktree first, then pass it as --worktree.' + ) + } +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create.ts b/src/main/runtime/rpc/methods/structured-agent-session-create.ts new file mode 100644 index 00000000000..19d15b1dd13 --- /dev/null +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -0,0 +1,135 @@ +/** + * Creating a structured session for a worktree: resolve the create intent, attach it under the + * host-computed fingerprint, then publish its tab. + * + * Extracted from `agentSession.create` so orchestration can start a native-born structured worker + * on exactly the same path. `activate` is the only knob the two callers differ on: a chat the user + * asked for takes the surface, a background dispatch must not steal it (the terminal worker path's + * `surfaceOwner: false`). + * + * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before `attach` + * commits a session, so that span answers with a refusal, and nothing after it may be folded back + * in. Both callers run the same two halves, so orchestration gets that guarantee too. + */ + +import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionAttachResult, + AgentSessionMutationEnvelope, + AgentSessionMutationResult +} from '../../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { + resolveUncommittedStructuredCreate, + type StructuredCreateRefused +} from './structured-agent-session-precommit-refusal' + +export type PreparedStructuredAgentSessionCreate = { + host: StructuredAgentSessionHost + attachParams: AgentSessionAttachParams + /** Null when the caller supplied its own location; only a resolved worktree publishes a tab. */ + tab: { workspaceId: string; agent: 'claude' | 'codex' } | null +} + +/** The pre-commit half. Throws; the caller is expected to run it inside + * `resolveUncommittedStructuredCreate` so a failure reaches the client as a refusal. */ +export async function prepareStructuredAgentSessionCreateForWorktree(args: { + runtime: OrcaRuntimeService + /** Installs the host lazily; called at the same point the RPC handler always installed it. */ + ensureHost: () => Promise + envelope: AgentSessionMutationEnvelope + worktree: string + agent: 'claude' | 'codex' +}): Promise { + const resolved = await args.runtime.resolveStructuredAgentSessionCreateIntent({ + envelope: args.envelope, + worktree: args.worktree, + agent: args.agent + }) + const hostFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: args.envelope.sessionId, + fields: { + location: resolved.location, + provider: resolved.provider, + agent: resolved.agent, + accountHome: resolved.accountHome, + runtimeKind: resolved.runtimeKind, + expectedRuntimeFence: null + } + }) + const host = await args.ensureHost() + const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved + return { + host, + attachParams: { + ...resolvedAttach, + provider: resolved.provider as 'claude' | 'codex', + agent: resolved.agent as 'claude' | 'codex', + envelope: { ...args.envelope, payloadFingerprint: hostFingerprint } + }, + tab: { + workspaceId: resolved.location.workspaceId, + agent: resolved.agent as 'claude' | 'codex' + } + } +} + +/** The commit half. Past `attach`, a failure no longer proves the session does not exist. */ +export async function commitStructuredAgentSessionCreate(args: { + runtime: OrcaRuntimeService + caller: StructuredAgentSessionCaller + prepared: PreparedStructuredAgentSessionCreate + activate: boolean +}): Promise> { + const { prepared } = args + const result = await prepared.host.attach(args.caller, prepared.attachParams) + if (!result.ok || !prepared.tab) { + return result + } + try { + await args.runtime.publishStructuredAgentSessionTab({ + workspaceId: prepared.tab.workspaceId, + sessionId: result.value.sessionId, + agent: prepared.tab.agent, + activate: args.activate + }) + } catch (error) { + console.warn('[agent-session] create committed before tab publication failed', error) + return { + ok: false, + refusal: { + code: 'agent_session_operation_unknown', + message: 'The chat may have been created, but its tab could not be confirmed.' + } + } + } + return result +} + +export async function createStructuredAgentSessionForWorktree(args: { + runtime: OrcaRuntimeService + ensureHost: () => Promise + caller: StructuredAgentSessionCaller + envelope: AgentSessionMutationEnvelope + worktree: string + agent: 'claude' | 'codex' + activate: boolean +}): Promise> { + const prepared: PreparedStructuredAgentSessionCreate | StructuredCreateRefused = + await resolveUncommittedStructuredCreate(() => + prepareStructuredAgentSessionCreateForWorktree(args) + ) + if ('refusal' in prepared) { + return { ok: false, refusal: prepared.refusal } + } + return commitStructuredAgentSessionCreate({ + runtime: args.runtime, + caller: args.caller, + prepared, + activate: args.activate + }) +} diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b69ff6fd628..c6f6bd4c096 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -19,6 +19,10 @@ import { supportsStructuredSessions } from './structured-agent-session-gate' import type { AgentSessionAttachParams } from '../../../native-chat/agent-session-wire/structured-agent-session-attach' +import { + commitStructuredAgentSessionCreate, + prepareStructuredAgentSessionCreateForWorktree +} from './structured-agent-session-create' import { STRUCTURED_AGENT_SESSION_HOLD_METHODS } from './structured-agent-session-hold' import { resolveUncommittedStructuredCreate } from './structured-agent-session-precommit-refusal' import { @@ -107,35 +111,16 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ if (conflict) { return { refusal: conflict } } - const resolved = await ctx.runtime.resolveStructuredAgentSessionCreateIntent(params) - const hostFingerprint = computeAgentSessionPayloadFingerprint({ - method: 'agentSession.attach', - sessionId: params.envelope.sessionId, - fields: { - location: resolved.location, - provider: resolved.provider, - agent: resolved.agent, - accountHome: resolved.accountHome, - runtimeKind: resolved.runtimeKind, - expectedRuntimeFence: null - } + return prepareStructuredAgentSessionCreateForWorktree({ + runtime: ctx.runtime, + ensureHost: async () => { + await ensureHostInstalled(ctx) + return requireHost(ctx) + }, + envelope: params.envelope, + worktree: params.worktree, + agent: params.agent as 'claude' | 'codex' }) - await ensureHostInstalled(ctx) - const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved - const attachParams: AgentSessionAttachParams = { - ...resolvedAttach, - provider: resolved.provider as 'claude' | 'codex', - agent: resolved.agent as 'claude' | 'codex', - envelope: { ...params.envelope, payloadFingerprint: hostFingerprint } - } - return { - host: requireHost(ctx), - attachParams, - tab: { - workspaceId: resolved.location.workspaceId, - agent: resolved.agent as 'claude' | 'codex' - } - } } const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) return { host, attachParams, tab: null } @@ -143,27 +128,12 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [ if ('refusal' in prepared) { return { ok: false, refusal: prepared.refusal } } - const result = await prepared.host.attach(callerFor(ctx), prepared.attachParams) - if (result.ok && prepared.tab) { - try { - await ctx.runtime.publishStructuredAgentSessionTab({ - workspaceId: prepared.tab.workspaceId, - sessionId: result.value.sessionId, - agent: prepared.tab.agent, - activate: true - }) - } catch (error) { - console.warn('[agent-session] create committed before tab publication failed', error) - return { - ok: false, - refusal: { - code: 'agent_session_operation_unknown', - message: 'The chat may have been created, but its tab could not be confirmed.' - } - } - } - } - return result + return commitStructuredAgentSessionCreate({ + runtime: ctx.runtime, + caller: callerFor(ctx), + prepared, + activate: true + }) } }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts index bf7b4a5bd87..96b4455d7dc 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-query-methods.ts @@ -25,7 +25,10 @@ export const TERMINAL_QUERY_METHODS: RpcAnyMethod[] = [ name: 'terminal.resolveActive', params: TerminalResolveActive, handler: async (params, { runtime }) => ({ - handle: await runtime.resolveActiveTerminal(params.worktree) + handle: await runtime.resolveActiveTerminal( + params.worktree, + params.requireUnambiguous ? { requireUnambiguous: true } : {} + ) }) }), defineMethod({ diff --git a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts index afe0a3bb486..f887e55c99c 100644 --- a/src/main/runtime/rpc/methods/terminal/unary-schemas.ts +++ b/src/main/runtime/rpc/methods/terminal/unary-schemas.ts @@ -38,7 +38,9 @@ export const TerminalListParams = z.object({ }) export const TerminalResolveActive = z.object({ - worktree: OptionalString + worktree: OptionalString, + /** Refuse instead of guessing when several leaves could be the caller's own terminal. */ + requireUnambiguous: z.boolean().optional() }) export const TerminalResolvePane = z.object({ diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts new file mode 100644 index 00000000000..d3b69cc5f58 --- /dev/null +++ b/src/main/runtime/structured-worker-authority.ts @@ -0,0 +1,99 @@ +/** + * Resolves a structured worker handle to the same authority facts a live PTY supplies. + * + * The registry holds the handle→session mapping for this process; the durable worker-terminal + * resource row is what survives a restart, so a miss falls back to rehydrating from it. The + * durable agent-session record is the liveness half: a session handed to a TUI owner, released, or + * pinned to another execution host is no longer this runtime's structured worker. + */ + +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrchestrationDb } from './orchestration/db' +import { + isStructuredWorkerHandle, + structuredWorkerIdentities, + structuredWorkerRecordIsCurrent, + type StructuredWorkerIdentity +} from './structured-worker-identity' + +export type StructuredWorkerAuthority = { + identity: StructuredWorkerIdentity + record: AgentSessionRecord +} + +export function readStructuredAgentSessionRecord(sessionId: string): AgentSessionRecord | null { + try { + return getStructuredAgentSessionHost()?.deps.store.getRecord(sessionId) ?? null + } catch { + return null + } +} + +/** Registry entry for a handle, rehydrated from the durable row when this process restarted. */ +export function resolveStructuredWorkerIdentity( + handle: string, + db: OrchestrationDb | null | undefined +): StructuredWorkerIdentity | null { + if (!isStructuredWorkerHandle(handle)) { + return null + } + const known = structuredWorkerIdentities.get(handle) + if (known) { + return known + } + const row = db?.getWorkerTerminalResourceByHandle?.(handle) + return row ? structuredWorkerIdentities.rehydrate(row) : null +} + +/** Identity plus a record that still proves this runtime owns the session. */ +export function resolveStructuredWorkerAuthority( + handle: string, + db: OrchestrationDb | null | undefined +): StructuredWorkerAuthority | null { + const identity = resolveStructuredWorkerIdentity(handle, db) + if (!identity) { + return null + } + const record = readStructuredAgentSessionRecord(identity.sessionId) + return record && structuredWorkerRecordIsCurrent(record) ? { identity, record } : null +} + +export type StructuredWorkerObservation = { + status: 'live' | 'unverifiable' | 'exited' + reason?: string +} + +export function observeStructuredWorker( + identity: StructuredWorkerIdentity +): StructuredWorkerObservation { + const host = getStructuredAgentSessionHost() + if (!host) { + // Reading the persisted record store here would force-install the host, which is itself a side + // effect; not being able to look is not evidence the child is gone. + return { + status: 'unverifiable', + reason: 'The structured agent-session host is not installed in this runtime generation.' + } + } + const record = host.deps.store.getRecord(identity.sessionId) + if (!record) { + return { status: 'unverifiable', reason: 'No durable record backs this structured session.' } + } + if (record.lease.claimStatus === 'released' && record.lease.deathEvidence) { + return { status: 'exited' } + } + if (record.lease.runtimeKind !== 'native') { + return { + status: 'unverifiable', + reason: 'The session lease is held by a terminal owner, not this structured host.' + } + } + if (host.hasSession(identity.sessionId) && record.lease.claimStatus === 'live') { + return { status: 'live' } + } + return { + status: 'unverifiable', + reason: 'The session has no attached provider child in this runtime generation.' + } +} diff --git a/src/main/runtime/structured-worker-child-identity-env.ts b/src/main/runtime/structured-worker-child-identity-env.ts new file mode 100644 index 00000000000..eb98a955a6e --- /dev/null +++ b/src/main/runtime/structured-worker-child-identity-env.ts @@ -0,0 +1,35 @@ +/** + * The orchestration identity 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. + * + * 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. + * + * Empty for any session that is not a dispatched worker, so an ordinary chat session's child is + * unchanged. 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 { structuredWorkerIdentities } from './structured-worker-identity' + +export function structuredWorkerChildIdentityEnv(sessionId: string): Record { + const identity = structuredWorkerIdentities.getBySessionId(sessionId) + if (!identity) { + return {} + } + return { + ORCA_TERMINAL_HANDLE: identity.handle, + // Structured sessions only exist local and outside WSL, so the scoped `orca-ide` launcher + // never applies to one. + ORCA_CLI_COMMAND: 'orca' + } +} diff --git a/src/main/runtime/structured-worker-hook-attestation.test.ts b/src/main/runtime/structured-worker-hook-attestation.test.ts new file mode 100644 index 00000000000..2f3566c5249 --- /dev/null +++ b/src/main/runtime/structured-worker-hook-attestation.test.ts @@ -0,0 +1,127 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { OrcaRuntimeWithGetOrchestrationDispatchAuthority } = + await import('./orca-runtime-get-orchestration-dispatch-authority') +const { OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller } = + await import('./orca-runtime-verify-orchestration-compatibility-caller') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('./structured-worker-identity') + +const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + +const getAuthority = + OrcaRuntimeWithGetOrchestrationDispatchAuthority.prototype.getOrchestrationDispatchAuthority +// Both borrowed from the real prototype through their public surface: a stubbed copy of the +// method under test would pin nothing. +const verifyCaller = + OrcaRuntimeWithVerifyOrchestrationCompatibilityCaller.prototype + .verifyOrchestrationCompatibilityCaller + +function registerStructuredWorker(): string { + hostRef.current = { + deps: { + store: { + getRecord: () => ({ + location: { executionHostId: 'local', wslDistro: null }, + lease: { runtimeKind: 'native', claimStatus: 'live', runtimeFence: 1 } + }) + } + } + } + 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 +} + +function runtimeStub(overrides: Record = {}) { + return { + runtimeId: 'runtime-1', + getOrchestrationDbIfAvailable: () => null, + restoredOrchestrationAuthorityByPtyId: new Map(), + getOrchestrationDispatchAuthority: (handle: string) => + getAuthority.call(runtimeStub(overrides), handle), + orchestrationCompatibilityHostMatches: () => true, + attestAgentHookCompatibilityAuthorityFn: undefined, + // `freeze...` is protected and is reached only on the SUCCESS path, which every test here + // asserts is never taken. Leaving it off the stub means a regression that does reach it fails + // loudly instead of quietly returning a frozen authority. + ...overrides + } +} + +describe('structured worker hook attestation stays closed', () => { + beforeEach(() => { + structuredWorkerIdentities.clear() + hostRef.current = null + }) + + it('leaves both the launch token hash and the pty id empty', () => { + const handle = registerStructuredWorker() + const authority = getAuthority.call(runtimeStub(), handle) + expect(authority).not.toBeNull() + expect(authority!.launchTokenHash).toBeNull() + // Non-empty would make the restored-authority receipt lookup reachable. + expect(authority!.ptyId).toBe('') + }) + + it('refuses to attest a structured handle as a compatibility caller', () => { + const handle = registerStructuredWorker() + const stub = runtimeStub() + expect( + verifyCaller.call(stub, { + terminalHandle: handle, + paneKey: structuredWorkerIdentities.get(handle)!.paneKey, + launchToken: 'anything-the-caller-claims' + }) + ).toBeNull() + }) + + it('still refuses when a restored receipt exists under an empty pty id', () => { + const handle = registerStructuredWorker() + const identity = structuredWorkerIdentities.get(handle)! + // Fabricate the exact receipt the fallback would accept, keyed by the empty pty id. + const stub = runtimeStub({ + restoredOrchestrationAuthorityByPtyId: new Map([ + [ + '', + { + ptyId: '', + worktreeId: identity.worktreeId, + terminalHandle: handle, + paneKey: identity.paneKey, + processIncarnation: identity.processIncarnation, + hostScope: identity.hostScope + } + ] + ]), + orchestrationCompatibilityHostScopesEqual: () => true, + attestAgentHookCompatibilityAuthorityFn: undefined + }) + // Even then, attestation is required and there is no hook to provide it. + expect( + verifyCaller.call(stub, { + terminalHandle: handle, + paneKey: identity.paneKey, + launchToken: 'anything-the-caller-claims' + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-worker-identity.test.ts b/src/main/runtime/structured-worker-identity.test.ts new file mode 100644 index 00000000000..55c4dedaf59 --- /dev/null +++ b/src/main/runtime/structured-worker-identity.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it, beforeEach } from 'vitest' +import { isTerminalLeafId, parsePaneKey } from '../../shared/stable-pane-id' +import { structuredAgentSessionPaneKey } from '../../shared/structured-agent-session-projection' +import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' +import { + StructuredWorkerIdentityRegistry, + isStructuredWorkerHandle, + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + sessionIdFromStructuredWorkerIncarnation, + structuredWorkerHostScope, + structuredWorkerPaneKeyBelongsToSession, + structuredWorkerProcessIncarnation, + structuredWorkerRecordIsCurrent +} from './structured-worker-identity' +import type { AgentSessionRecord } from '../../shared/agent-session-record' + +const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' + +function record(overrides: { + runtimeKind?: 'native' | 'tui' + claimStatus?: AgentSessionRecord['lease']['claimStatus'] + executionHostId?: string + wslDistro?: string | null + runtimeFence?: number +}): AgentSessionRecord { + return { + schemaVersion: 2, + sessionId: SESSION_ID, + location: { + executionHostId: overrides.executionHostId ?? 'local', + wslDistro: overrides.wslDistro ?? null, + workspaceId: 'wt_1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + providerHandleChain: [], + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/me/.claude' }, + lease: { + sessionId: SESSION_ID, + runtimeKind: overrides.runtimeKind ?? 'native', + runtimeFence: overrides.runtimeFence ?? 1, + handoffStage: null, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + leaseDeadlineAt: 0, + lastRenewedAt: 0, + handoffOperationId: null, + journalCheckpoint: null, + claimKeyId: 'k', + claimStatus: overrides.claimStatus ?? 'live', + unreconciled: false, + deathEvidence: null + }, + createdAt: 0, + updatedAt: 0 + } as AgentSessionRecord +} + +describe('structured worker identity', () => { + it('mints a random bearer handle that is never derived from the session id', () => { + const first = mintStructuredWorkerHandle() + const second = mintStructuredWorkerHandle() + expect(first).not.toBe(second) + expect(isStructuredWorkerHandle(first)).toBe(true) + expect(first).not.toContain(SESSION_ID) + expect(first.startsWith('term_')).toBe(false) + }) + + it('mints an UNGUESSABLE pane key, because check accepts a caller-supplied one', () => { + // A derivable pane key would let anyone who learns a session id read that worker's mailbox: + // orchestration.check falls back to params.terminalPaneKey and matches assignee_pane_key. + const first = mintStructuredWorkerPaneKey(SESSION_ID) + const second = mintStructuredWorkerPaneKey(SESSION_ID) + expect(first).not.toBe(second) + // The TAB half legitimately names the session; it is the LEAF that must be unguessable, + // because both dispatch lookups key on the leaf (exact match, then leaf-suffix equivalence). + expect(parsePaneKey(first)!.leafId).not.toContain(SESSION_ID.slice(0, 8)) + // Specifically not the sha256-of-session-id helper the chat tab projection uses. + expect(first).not.toBe( + structuredAgentSessionPaneKey(`structured-agent-session-${SESSION_ID}`, SESSION_ID) + ) + }) + + it("accepts a persisted pane key for its own session and rejects another session's", () => { + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, SESSION_ID)).toBe(true) + expect(structuredWorkerPaneKeyBelongsToSession(paneKey, 'another-session-id')).toBe(false) + expect(structuredWorkerPaneKeyBelongsToSession('not-a-pane-key', SESSION_ID)).toBe(false) + expect(structuredWorkerPaneKeyBelongsToSession(null, SESSION_ID)).toBe(false) + }) + + it('derives a pane key whose leaf passes the terminal leaf check', () => { + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + const parsed = parsePaneKey(paneKey) + expect(parsed).not.toBeNull() + expect(isTerminalLeafId(parsed!.leafId)).toBe(true) + expect(parsed!.tabId).toBe(`structured-agent-session-${SESSION_ID}`) + }) + + it('round-trips the session id through the process incarnation', () => { + const incarnation = structuredWorkerProcessIncarnation(SESSION_ID) + expect(sessionIdFromStructuredWorkerIncarnation(incarnation)).toBe(SESSION_ID) + expect(sessionIdFromStructuredWorkerIncarnation('ptyid:3')).toBeNull() + }) + + it('claims local authority only for a local, non-WSL session', () => { + expect(structuredWorkerHostScope(record({}).location)).toEqual({ + kind: 'local', + hostId: 'local' + }) + expect(structuredWorkerHostScope(record({ wslDistro: 'Ubuntu' }).location)).toBeNull() + expect(structuredWorkerHostScope(record({ executionHostId: 'ssh-1' }).location)).toBeNull() + }) + + it('keeps a recovered session current across a fence bump', () => { + // The host bumps the fence on its own transparent crash recovery; fencing identity on it + // would wedge the SAME worker as identity_unproven forever. + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 1 }))).toBe(true) + expect(structuredWorkerRecordIsCurrent(record({ runtimeFence: 9 }))).toBe(true) + expect(structuredWorkerProcessIncarnation(SESSION_ID)).toBe( + structuredWorkerProcessIncarnation(SESSION_ID) + ) + }) + + it('refuses a session handed to a TUI owner or released', () => { + expect(structuredWorkerRecordIsCurrent(record({ runtimeKind: 'tui' }))).toBe(false) + expect(structuredWorkerRecordIsCurrent(record({ claimStatus: 'released' }))).toBe(false) + expect(structuredWorkerRecordIsCurrent(null)).toBe(false) + }) +}) + +describe('structured worker identity registry', () => { + let registry: StructuredWorkerIdentityRegistry + + beforeEach(() => { + registry = new StructuredWorkerIdentityRegistry() + }) + + it('rehydrates a durable row whose persisted pane key belongs to its session', () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + const identity = registry.rehydrate({ + terminal_handle: handle, + pane_key: paneKey, + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: 'wt_1', + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + expect(identity?.sessionId).toBe(SESSION_ID) + // The leaf is random, so the durable row is the ONLY place it survives a restart. + expect(identity?.paneKey).toBe(paneKey) + expect(registry.get(handle)?.handle).toBe(handle) + expect(registry.getBySessionId(SESSION_ID)?.handle).toBe(handle) + }) + + it('refuses a row whose pane key does not match its own session id', () => { + expect( + registry.rehydrate({ + terminal_handle: mintStructuredWorkerHandle(), + pane_key: mintStructuredWorkerPaneKey('some-other-session-id'), + process_incarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktree_id: 'wt_1', + host_scope: JSON.stringify({ kind: 'local', hostId: 'local' }) + }) + ).toBeNull() + }) + + it('forgets both indexes', () => { + const handle = mintStructuredWorkerHandle() + registry.register({ + handle, + sessionId: SESSION_ID, + agent: 'claude', + paneKey: mintStructuredWorkerPaneKey(SESSION_ID), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + registry.forget(handle) + expect(registry.get(handle)).toBeNull() + expect(registry.getBySessionId(SESSION_ID)).toBeNull() + }) +}) + +describe('structured workers stay outside the PTY-only fail-closed paths', () => { + it('never selects an exact provider session', () => { + // Fail-closed because a structured session emits no hook agent status. It stays closed only + // while ORCA_PANE_KEY is absent from the structured child's environment. + const paneKey = mintStructuredWorkerPaneKey(SESSION_ID) + expect( + selectExactWorkerProviderSession({ + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + connectionId: null, + launchToken: null, + observedAfter: 0, + statuses: [ + { + paneKey, + connectionId: null, + launchToken: null, + receivedAt: 10, + agentType: 'claude', + providerSession: { id: 'p1', transcriptPath: null } + } as never + ] + }) + ).not.toBeNull() + // With no hook status at all — the real structured case — it is null. + expect( + selectExactWorkerProviderSession({ + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + connectionId: null, + launchToken: null, + observedAfter: 0, + statuses: [] + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts new file mode 100644 index 00000000000..a82134f6dd6 --- /dev/null +++ b/src/main/runtime/structured-worker-identity.ts @@ -0,0 +1,198 @@ +/** + * Orchestration identity for a NATIVE-BORN structured agent session. + * + * Orchestration derives a worker's identity and its lifecycle authority from a live PTY. A + * structured session has none, so this registry is the second authority source: it maps a session + * id onto the same three facts the PTY path supplies — a bearer handle, a stable pane key, and a + * host scope — and nothing else about dispatch changes. + * + * The handle AND the pane key are both RANDOM on purpose. `orchestration.check` is identity-gated, + * not capability-gated: it falls back to a caller-supplied `terminalPaneKey` + * (`orchestration-check-methods.ts`) and dispatch lookup matches `assignee_pane_key` directly, so a + * derivable pane key alone would let anyone who learns a session id read and consume that worker's + * mailbox — and session ids are embedded in tab ids. PTY pane keys are safe only because their leaf + * is a random UUID; these match that. + */ + +import { randomUUID } from 'node:crypto' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../shared/agent-session-record' +import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' +import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +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. +const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' + +export type StructuredWorkerIdentity = { + handle: string + sessionId: string + /** Null when the entry was rehydrated from the durable row, which does not carry the provider. */ + agent: 'claude' | 'codex' | null + paneKey: string + processIncarnation: string + worktreeId: string + 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()}` +} + +/** + * A RANDOM leaf, minted once per worker and persisted with the rest of the identity. + * + * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A pane + * key is an identity credential on its own: `orchestration.check` is identity-gated, not + * capability-gated, and accepts a caller-supplied `terminalPaneKey` that `getActiveDispatchForIdentity` + * matches by leaf suffix. A derivable pane key would therefore let anyone who learns a session id — + * which the tab id embeds in plain text — read and consume that worker's mailbox with no token. + * PTY pane keys are safe only because their leaf UUID is random; this one has to be too. + * + * Restart stability comes from persisting the minted key, not from re-deriving it. + */ +export function mintStructuredWorkerPaneKey(sessionId: string): string { + return makePaneKey(structuredAgentSessionTabId(sessionId), randomUUID()) +} + +/** Integrity check for a persisted pane key: same session's tab, and a real terminal leaf. */ +export function structuredWorkerPaneKeyBelongsToSession( + paneKey: string | null | undefined, + sessionId: string +): boolean { + const parsed = paneKey ? parsePaneKey(paneKey) : null + return Boolean( + parsed && + parsed.tabId === structuredAgentSessionTabId(sessionId) && + isTerminalLeafId(parsed.leafId) + ) +} + +/** + * Process continuity for a structured worker. + * + * NOT the runtime fence: the fence is an owner-generation counter that the host bumps during its + * own transparent crash recovery, so fencing identity on it would make a recovered — but same — + * worker fail `verifyDispatchCapability` forever and wedge release as `identity_unproven`. The + * session id is minted once per dispatch and survives that recovery, so it is the lineage. + */ +export function structuredWorkerProcessIncarnation(sessionId: string): string { + return `${STRUCTURED_WORKER_INCARNATION_PREFIX}${sessionId}` +} + +export function sessionIdFromStructuredWorkerIncarnation( + processIncarnation: string | null | undefined +): string | null { + if (!processIncarnation?.startsWith(STRUCTURED_WORKER_INCARNATION_PREFIX)) { + return null + } + const sessionId = processIncarnation.slice(STRUCTURED_WORKER_INCARNATION_PREFIX.length) + return sessionId.length > 0 ? sessionId : null +} + +/** Structured sessions can only exist local and outside WSL; anything else is not our authority. */ +export function structuredWorkerHostScope( + location: AgentSessionExecutionLocation +): WorkerTerminalHostScope | null { + return location.executionHostId === LOCAL_EXECUTION_HOST_ID && !location.wslDistro + ? { kind: 'local', hostId: 'local' } + : null +} + +/** Whether the durable record still describes THIS worker under this host. */ +export function structuredWorkerRecordIsCurrent( + record: AgentSessionRecord | null | undefined +): boolean { + return Boolean( + record && + record.lease.runtimeKind === 'native' && + record.lease.claimStatus !== 'released' && + structuredWorkerHostScope(record.location) + ) +} + +export class StructuredWorkerIdentityRegistry { + private readonly byHandle = new Map() + private readonly bySessionId = new Map() + + register(identity: StructuredWorkerIdentity): StructuredWorkerIdentity { + this.byHandle.set(identity.handle, identity) + this.bySessionId.set(identity.sessionId, identity) + return identity + } + + get(handle: string): StructuredWorkerIdentity | null { + return this.byHandle.get(handle) ?? null + } + + getBySessionId(sessionId: string): StructuredWorkerIdentity | null { + return this.bySessionId.get(sessionId) ?? null + } + + forget(handle: string): void { + const identity = this.byHandle.get(handle) + if (!identity) { + return + } + this.byHandle.delete(handle) + if (this.bySessionId.get(identity.sessionId) === identity) { + this.bySessionId.delete(identity.sessionId) + } + } + + /** + * Rebuilds an entry from the durable worker-terminal resource row after a restart, which is the + * only place a structured worker's pane key and host scope outlive this process. A row whose + * pane key does not belong to its own recorded session is refused rather than trusted. + */ + rehydrate(row: { + terminal_handle: string + pane_key: string | null + process_incarnation: string | null + worktree_id: string | null + host_scope: string | null + }): StructuredWorkerIdentity | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(row.process_incarnation) + const hostScope = parseWorkerTerminalHostScope(row.host_scope) + if ( + !sessionId || + !hostScope || + !row.worktree_id || + !isStructuredWorkerHandle(row.terminal_handle) || + // The leaf is random, so the row IS the only source for it; verify only that it is a real + // leaf under this session's tab rather than trying to re-derive it. + !structuredWorkerPaneKeyBelongsToSession(row.pane_key, sessionId) + ) { + return null + } + return this.register({ + handle: row.terminal_handle, + sessionId, + // The row does not carry the provider; callers that need it read the durable record. + agent: null, + paneKey: row.pane_key as string, + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + worktreeId: row.worktree_id, + hostScope + }) + } + + clear(): void { + this.byHandle.clear() + this.bySessionId.clear() + } +} + +export const structuredWorkerIdentities = new StructuredWorkerIdentityRegistry() diff --git a/src/main/runtime/structured-worker-mail-routing.test.ts b/src/main/runtime/structured-worker-mail-routing.test.ts new file mode 100644 index 00000000000..63e7fff1957 --- /dev/null +++ b/src/main/runtime/structured-worker-mail-routing.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const hostRef: { current: unknown } = { current: null } + +vi.mock('../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const { OrcaRuntimeWithAdoptTerminalOrphansFromInventory } = + await import('./orca-runtime-adopt-terminal-orphans-from-inventory') +const { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} = await import('./structured-worker-identity') + +const SESSION_ID = 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d' +const prototype = OrcaRuntimeWithAdoptTerminalOrphansFromInventory.prototype +const getLivePaneKey = prototype.getLiveTerminalPaneKey +const resolveActiveTerminal = prototype.resolveActiveTerminal + +function installRecord(lease: { runtimeKind: string; claimStatus: string } | null): void { + hostRef.current = lease + ? { + deps: { + store: { + getRecord: () => ({ + location: { executionHostId: 'local', wslDistro: null }, + lease: { ...lease, runtimeFence: 1, deathEvidence: null } + }) + } + }, + hasSession: () => lease.claimStatus === 'live' + } + : null +} + +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 +} + +const paneKeyStub = { + getOrchestrationDbIfAvailable: () => null, + getLivePtyForHandle: () => null, + resolveLiveLeafForHandle: () => null, + ptysById: new Map(), + getPaneKeyForTerminalHandle: () => null +} + +describe('bare-handle direct mail to a structured session', () => { + beforeEach(() => { + structuredWorkerIdentities.clear() + hostRef.current = null + }) + + it('resolves a live pane key, so recipient routing does not answer terminal_not_found', () => { + // resolveBareOrchestrationRecipient reads this getter, not getTerminalPaneKey. + const handle = registerWorker() + installRecord({ runtimeKind: 'native', claimStatus: 'live' }) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBe( + structuredWorkerIdentities.get(handle)!.paneKey + ) + }) + + it('withholds the pane key when the session is not proven live', () => { + // The PTY branch is connected-gated so mail is never routed to a corpse; so is this one. + const handle = registerWorker() + installRecord({ runtimeKind: 'native', claimStatus: 'reserved' }) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() + }) + + it('withholds the pane key when the lease moved to a terminal owner', () => { + const handle = registerWorker() + installRecord({ runtimeKind: 'tui', claimStatus: 'live' }) + expect(getLivePaneKey.call(paneKeyStub, handle)).toBeNull() + }) +}) + +describe('implicit sender resolution refuses to guess', () => { + function senderStub(leafIds: readonly string[]) { + return { + graphStatus: 'ready', + assertGraphReady: () => {}, + resolveWorktreeSelector: async () => ({ id: 'wt_1' }), + tabs: new Map(), + leaves: new Map( + leafIds.map((leafId) => [leafId, { tabId: 'tab_1', leafId, worktreeId: 'wt_1' }]) + ), + issueHandle: (leaf: { leafId: string }) => `term_${leaf.leafId}` + } + } + + it('returns the only candidate leaf', async () => { + await expect( + resolveActiveTerminal.call(senderStub(['leaf_a']), 'id:wt_1', { requireUnambiguous: true }) + ).resolves.toBe('term_leaf_a') + }) + + it('refuses rather than picking the first of several', async () => { + // An arbitrary pick lets a bare `check` consume a sibling's mailbox and a bare `worker_done` + // settle a sibling's context-only dispatch, which has no capability token to reject on. + await expect( + resolveActiveTerminal.call(senderStub(['leaf_a', 'leaf_b']), 'id:wt_1', { + requireUnambiguous: true + }) + ).rejects.toThrow('no_active_terminal') + }) + + it('refuses the same arbitrary pick before the terminal graph is ready', async () => { + const preReady = { + graphStatus: 'starting', + resolveWorktreeSelector: async () => ({ id: 'wt_1' }), + getMobileSessionTabsForWorktree: () => ({ tabs: [] }), + listTerminals: async () => ({ terminals: [{ handle: 'term_a' }, { handle: 'term_b' }] }) + } + await expect( + resolveActiveTerminal.call(preReady, 'id:wt_1', { requireUnambiguous: true }) + ).rejects.toThrow('no_active_terminal') + await expect(resolveActiveTerminal.call(preReady, 'id:wt_1')).resolves.toBe('term_a') + }) + + it('still picks arbitrarily for callers that are not claiming an identity', async () => { + await expect( + resolveActiveTerminal.call(senderStub(['leaf_a', 'leaf_b']), 'id:wt_1') + ).resolves.toBe('term_leaf_a') + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx deleted file mode 100644 index a13f672feb4..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @vitest-environment happy-dom - -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' - -describe('NativeChatOrchestrationPausedNotice', () => { - afterEach(cleanup) - - it('stays hidden while dispatch state is loading or settled', () => { - const { rerender } = render() - - expect(screen.queryByRole('status')).toBeNull() - - rerender() - expect(screen.queryByRole('status')).toBeNull() - }) - - it.each(['pending', 'dispatched'] as const)( - 'persists recovery guidance for an active %s Dispatch', - (dispatchStatus) => { - render() - - const notice = screen.getByRole('status') - expect(notice.textContent).toContain('Orchestration paused') - expect(notice.textContent).toContain('Structured Chat blocks terminal prompts and sends') - expect(notice.textContent).toContain('Orchestration messages remain queued') - expect(notice.textContent).toContain( - 'switch to Terminal, then check the Orca inbox with orca orchestration check' - ) - } - ) -}) diff --git a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx b/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx deleted file mode 100644 index da3bfec5aaa..00000000000 --- a/src/renderer/src/components/native-chat/NativeChatOrchestrationPausedNotice.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { PauseCircle } from 'lucide-react' -import type { AgentStatusOrchestrationContext } from '../../../../shared/agent-status-types' -import { Badge } from '@/components/ui/badge' -import { translate } from '@/i18n/i18n' - -export function NativeChatOrchestrationPausedNotice({ - dispatchStatus -}: { - dispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] -}): React.JSX.Element | null { - if (dispatchStatus !== 'pending' && dispatchStatus !== 'dispatched') { - return null - } - - return ( -
-
- ) -} diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 6f934f66a6e..5fed92b6808 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -51,7 +51,6 @@ import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge' import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import type { NativeChatResolvedViewProps } from './native-chat-view-types' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' -import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' import { matchNativeChatSplitShortcut } from './native-chat-split-shortcut' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { formatShortcutLabel } from '@/hooks/useShortcutLabel' @@ -68,8 +67,7 @@ export function NativeChatResolvedView({ ownsTabWideLaunchDraft, onSwitchToTerminal, readTerminalScreen, - contextMenuActions, - orchestrationDispatchStatus + contextMenuActions }: NativeChatResolvedViewProps): React.JSX.Element { // Primitive owner selection (no useShallow): routes the pane's read/subscribe to // the remote runtime host for a runtime-owned pane; null keeps the local path. @@ -378,7 +376,6 @@ export function NativeChatResolvedView({ onContextMenuCapture={contextMenu.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > -
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx index 9ac354f8a73..0299d11950d 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.tsx @@ -16,7 +16,6 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click' import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context' import { useStructuredAgentSession } from './use-structured-agent-session' import { translate } from '@/i18n/i18n' -import { NativeChatOrchestrationPausedNotice } from './NativeChatOrchestrationPausedNotice' import { useNativeChatImageRuntimeContext } from './native-chat-image-runtime-context' import { useStructuredNativeChatPaneCommands } from './use-structured-native-chat-pane-commands' import type { NativeChatStructuredViewProps } from './native-chat-view-types' @@ -153,7 +152,6 @@ export function NativeChatStructuredSession( onContextMenuCapture={paneCommands.onContextMenuCapture} className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none" > -
{viewState.kind === 'loading' ? ( diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 84c397b8d1d..19ffc82c42f 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -24,8 +24,7 @@ function NativeChatBridgeView({ ownsTabWideLaunchDraft, onSwitchToTerminal, readTerminalScreen, - contextMenuActions, - orchestrationDispatchStatus + contextMenuActions }: Exclude): React.JSX.Element { const { entry: agentStatusEntry, paneKey } = useNativeChatStatusEntry( terminalTabId, @@ -52,7 +51,6 @@ function NativeChatBridgeView({ onSwitchToTerminal={onSwitchToTerminal} readTerminalScreen={readTerminalScreen} contextMenuActions={contextMenuActions} - orchestrationDispatchStatus={orchestrationDispatchStatus} /> )} diff --git a/src/renderer/src/components/native-chat/native-chat-view-types.ts b/src/renderer/src/components/native-chat/native-chat-view-types.ts index 920bede7028..1a519b5a1a2 100644 --- a/src/renderer/src/components/native-chat/native-chat-view-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-view-types.ts @@ -1,17 +1,10 @@ -import type { - AgentStatusOrchestrationContext, - AgentType -} from '../../../../shared/agent-status-types' +import type { AgentType } from '../../../../shared/agent-status-types' import type { TuiAgent } from '../../../../shared/tui-agent' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { NativeChatSession } from '../../../../shared/native-chat-types' import type { NativeChatContextMenuActions } from './use-native-chat-context-menu' -type NativeChatOrchestrationProps = { - orchestrationDispatchStatus?: AgentStatusOrchestrationContext['dispatchStatus'] -} - -export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { +export type NativeChatBridgeViewProps = { mode?: 'bridge' /** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */ terminalTabId: string @@ -34,7 +27,7 @@ export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & { contextMenuActions?: Omit } -export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { +export type NativeChatStructuredViewProps = { mode: 'structured' tabId: string groupId?: string @@ -45,7 +38,7 @@ export type NativeChatStructuredViewProps = NativeChatOrchestrationProps & { contextMenuActions?: Omit } -export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & { +export type NativeChatResolvedViewProps = { paneKey: string agent: NativeChatSession['agent'] sessionId: string | null diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index da2a3f02133..426221aa464 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -17,7 +17,6 @@ export function TerminalPaneNativeChatPortal({ chatPaneOwnsTabWideLaunchDraft, chatPanePtyId, chatPaneResolvedAgent, - chatPaneDispatchStatus, contextMenu, effectiveChatViewMode, expandedPaneId, @@ -77,7 +76,6 @@ export function TerminalPaneNativeChatPortal({ isVisible={isRendererVisible} target={structuredChatTarget} contextMenuActions={contextMenuActions} - orchestrationDispatchStatus={chatPaneDispatchStatus} /> ) : ( )}
, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 4f9919db5c4..aece439aceb 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17045,11 +17045,6 @@ "deny": "Deny" }, "launchPromptNotDelivered": "Not delivered — check the terminal", - "orchestrationPaused": { - "label": "Orchestration paused", - "message": "Structured Chat blocks terminal prompts and sends. Orchestration messages remain queued; switch to Terminal, then check the Orca inbox with", - "command": "orca orchestration check" - }, "structuredSessionCloseFailed": "Could not close this chat session", "structuredSessionLaunchFailed": "Could not open {{value0}} chat", "structuredSessionLaunchPending": "Starting {{value0}} chat…",