Merge remote-tracking branch 'origin/main' into mobile-rearch

# Conflicts:
#	src/main/runtime/rpc/methods/native-chat.ts
This commit is contained in:
Jinwoo-H
2026-09-07 17:01:06 -04:00
325 changed files with 17645 additions and 1529 deletions
@@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
image-digest:
description: "Immutable relay image digest (sha256: plus 64 lowercase hex characters)"
description: 'Immutable relay image digest (sha256: plus 64 lowercase hex characters)'
required: true
type: string
regional-placement-mode:
+73 -2
View File
@@ -9,7 +9,9 @@ import { fileURLToPath } from 'node:url'
import { exportJWK, generateKeyPair, jwtVerify, SignJWT } from 'jose'
import {
buildHostProofMacInput,
HOST_CHALLENGE_PLAINTEXT_DOMAIN
HOST_CHALLENGE_PLAINTEXT_DOMAIN,
RELAY_HOST_CAPABILITIES_HEADER,
RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS
} from '@orca-cloud/relay-contract'
import nacl from 'tweetnacl'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -282,11 +284,17 @@ async function openHostControl(input?: {
previousGeneration?: number
keyPair?: nacl.BoxKeyPair
assignmentEpoch?: number
capabilities?: string
}): Promise<{ socket: WebSocket; ack: Record<string, unknown>; keyPair: nacl.BoxKeyPair }> {
const keyPair = input?.keyPair ?? nacl.box.keyPair()
const hostId = createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16)
const socket = new WebSocket(`${relayUrl.replace('http:', 'ws:')}/v1/host/control`, {
headers: { authorization: `Bearer ${await relayToken('orca-relay', hostId)}` },
headers: {
authorization: `Bearer ${await relayToken('orca-relay', hostId)}`,
...(input?.capabilities
? { [RELAY_HOST_CAPABILITIES_HEADER]: input.capabilities }
: {})
},
perMessageDeflate: false
})
await new Promise<void>((resolveOpen, reject) => {
@@ -653,6 +661,69 @@ describe('served relay URL', () => {
expect(result.reason).not.toContain('http')
})
it('restates a pending connection to the rebound control, detailed only when advertised', async () => {
// The one link the unit tests cannot reach: an upgrade that really carries
// x-orca-host-capabilities must reach acceptControl and change the ack. A
// typo in the header name here passes every other test in the suite.
const host = await openHostControl()
const hostId = createHash('sha256')
.update(host.keyPair.publicKey)
.digest('base64url')
.slice(0, 16)
const inviteResponse = nextMessage(host.socket)
host.socket.send(
JSON.stringify({
type: 'invite-create',
reqId: 'capability-invite',
relayDeviceId: 'capability-device'
})
)
const invite = await inviteResponse
const phone = new WebSocket(`${relayUrl.replace('http:', 'ws:')}/v1/connect/${hostId}`, {
headers: forwardedHeaders()
})
await new Promise<void>((resolveOpen, reject) => {
phone.once('open', resolveOpen)
phone.once('error', reject)
})
const connectionPromise = nextMessage(host.socket)
phone.send(
JSON.stringify({ type: 'relay-auth', v: 1, mode: 'connect', credential: invite.inviteToken })
)
// Never attached: the connection stays pending, which is what the ack restates.
const connection = await connectionPromise
expect(connection.type).toBe('conn-open')
const capable = await openHostControl({
keyPair: host.keyPair,
controlResumeSecret: String(host.ack.controlResumeSecret),
previousGeneration: 1,
capabilities: RELAY_HOST_CAPABILITY_PENDING_CONN_DETAILS
})
expect(capable.ack.pendingConns).toEqual([
{
connId: connection.connId,
connTicket: connection.connTicket,
kind: 'invite',
relayDeviceId: 'capability-device'
}
])
const legacy = await openHostControl({
keyPair: host.keyPair,
controlResumeSecret: String(capable.ack.controlResumeSecret),
previousGeneration: 1
})
// A shipped host parses these entries strictly, so an unannounced key would
// fail the whole ack and kill a control that was working.
expect(legacy.ack.pendingConns).toEqual([
{ connId: connection.connId, connTicket: connection.connTicket }
])
phone.close()
legacy.socket.close()
})
it('keeps a pending attach usable after a bad ticket and rejects ticket replay', async () => {
const host = await openHostControl()
const hostId = createHash('sha256')
@@ -1,7 +1,9 @@
import { pathToFileURL } from 'node:url'
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26)$/
// Every general cell that carries the rehome identity: the sixteen US cells and the
// three asia-east2 cells that drain mis-homed hosts back the other way.
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26|27|28|29)$/
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
export function parseRehomeTrustProbeArguments(argv, environment = process.env) {
@@ -111,3 +111,23 @@ test('fails when both trust-probe attempts return a transient 503', async () =>
)
assert.equal(calls, 2)
})
test('approves the asia-east2 rehome sources and still rejects unlisted cells', () => {
for (const cellId of ['production-gce-c27', 'production-gce-c28', 'production-gce-c29']) {
const parsed = parseRehomeTrustProbeArguments(
argv.map((value) => (value === 'production-gce-c7' ? cellId : value)),
environment
)
assert.equal(parsed.cellId, cellId)
}
for (const cellId of ['production-gce-c1', 'production-gce-c17', 'production-gce-c30']) {
assert.throws(
() =>
parseRehomeTrustProbeArguments(
argv.map((value) => (value === 'production-gce-c7' ? cellId : value)),
environment
),
/--cell-id is not approved/
)
}
})
+16 -51
View File
@@ -13414,9 +13414,7 @@
},
{
"file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
"assertions": [
"a refused pointer write drains a delivery parked behind its watermark"
]
"assertions": ["a refused pointer write drains a delivery parked behind its watermark"]
},
{
"file": "src/main/providers/settled-pty-writer-census.test.ts",
@@ -18638,27 +18636,13 @@
"protection": "partial",
"owner": "browser-runtime",
"layer": "electron-packaged",
"surfaces": [
"paired browser placement"
],
"platforms": [
"linux",
"macos",
"windows"
],
"providers": [
"paired-runtime"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"paired-runtime"
],
"surfaces": ["paired browser placement"],
"platforms": ["linux", "macos", "windows"],
"providers": ["paired-runtime"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["paired-runtime"],
"coverageNotes": "Published Linux 1.4.188 desktop against current source in both directions; scheduled weekly and manually runnable. No required PR check.",
"motivatingLinks": [
"https://github.com/stablyai/orca/actions/runs/34069063016"
],
"motivatingLinks": ["https://github.com/stablyai/orca/actions/runs/34069063016"],
"invariant": "A paired client and host without client-hosted browser capabilities retain server-hosted browser placement across supported version skew.",
"oracle": "Require both existing named browser placement scenarios to pass three times with one attempt, zero skips, zero failures, and no report errors.",
"commands": [
@@ -18682,9 +18666,7 @@
},
{
"file": "config/scripts/verify-packaged-browser-participation.test.mjs",
"assertions": [
"reject missing, substituted, skipped and retried scenarios"
]
"assertions": ["reject missing, substituted, skipped and retried scenarios"]
},
{
"file": "config/scripts/packaged-browser-lane-contract.test.mjs",
@@ -18739,26 +18721,13 @@
"protection": "partial",
"owner": "terminal-input",
"layer": "electron-native-ime-e2e",
"surfaces": [
"native Hangul composition",
"Wayland terminal input"
],
"platforms": [
"linux"
],
"providers": [
"local"
],
"coveredPlatforms": [
"linux"
],
"coveredProviders": [
"local"
],
"surfaces": ["native Hangul composition", "Wayland terminal input"],
"platforms": ["linux"],
"providers": ["local"],
"coveredPlatforms": ["linux"],
"coveredProviders": ["local"],
"coverageNotes": "Ubuntu 22.04 nested GNOME and IBus Hangul drive three complete native executions in GitHub Actions. GNOME owns IBus; daemon and CLI share its default config discovery path.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/19174"
],
"motivatingLinks": ["https://github.com/stablyai/orca/pull/19174"],
"invariant": "Typing d k 1 Return through native IBus Hangul delivers exactly 아1 followed by newline without missing, duplicate, or reordered characters.",
"oracle": "Three executions each assert three exact UTF-8 PTY lines. Verify the exact Playwright title, zero skips/retries, each individual native composition receipt, and the nested launch Wayland flag.",
"commands": [
@@ -18774,15 +18743,11 @@
"assertionRefs": [
{
"file": "tests/e2e/terminal-hangul-terminating-digit-native.spec.ts",
"assertions": [
"a digit typed right after a Hangul syllable reaches the pty"
]
"assertions": ["a digit typed right after a Hangul syllable reaches the pty"]
},
{
"file": "config/scripts/terminal-ime-e2e-workflow.test.mjs",
"assertions": [
"runs native Wayland independently with CJK fonts and retained evidence"
]
"assertions": ["runs native Wayland independently with CJK fonts and retained evidence"]
}
],
"evidenceRuns": [
@@ -38,6 +38,16 @@ const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([
new Set([
'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts'
])
],
[
// The rule wants one named handle cleared by name. Both startup effects arm a variable number
// of refresh timers, every one of them through addTimer into `timers`, which their cleanups
// clear -- a shape the rule reports whether the handles live in an array, a Set, or a nested
// helper. The finding predates this list; it surfaced when the effect body changed. This map
// keys on file, not line, so the entry covers both effects in it; nothing else in the file
// arms a timer, so widening it further is the only alternative, not a narrower option.
'react-doctor(effect-needs-cleanup)',
new Set(['mobile/src/session/use-mobile-session-startup.ts'])
]
])
@@ -173,6 +173,28 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
}
})
it('refuses a Windows rebuild when the process creation-time patch is missing', () => {
const projectDir = mkTempProject()
try {
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir)
writeFakeNodePtyConptyPayload(projectDir, 'x64')
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir, { creationTimePatchApplied: false })
const result = runRebuildScript(
projectDir,
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
['--platform=win32', '--arch=x64', '--force']
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('process creation-time patch')
} finally {
removeTreeSync(projectDir)
}
})
it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => {
const projectDir = mkTempProject()
@@ -374,13 +374,18 @@ export function writeFakeWindowsProcessTree(projectDir) {
export function writeFakeWindowsProcessTreeWithNodeAddonApi(
projectDir,
{ commandLinePatchApplied = true } = {}
{ commandLinePatchApplied = true, creationTimePatchApplied = true } = {}
) {
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api')
mkdirSync(nodeAddonApiDir, { recursive: true })
writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n')
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
join(processTreeDir, 'index.js'),
creationTimePatchApplied
? 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }\n'
: 'exports.ProcessDataFlag = { None: 0, Memory: 1, CommandLine: 2 }\n'
)
mkdirSync(join(processTreeDir, 'src'), { recursive: true })
writeFileSync(
join(processTreeDir, 'src', 'process_commandline.cc'),
@@ -388,6 +393,36 @@ export function writeFakeWindowsProcessTreeWithNodeAddonApi(
? '// kProcessCommandLineInformation = 60\n'
: unpatchedWindowsProcessTreeCommandLineSource()
)
writeFileSync(
join(processTreeDir, 'src', 'process.h'),
creationTimePatchApplied
? 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2, CREATIONTIME = 4 };\nULONGLONG creationTimeMs;\n'
: 'enum ProcessDataFlags { NONE = 0, MEMORY = 1, COMMANDLINE = 2 };\n'
)
writeFileSync(
join(processTreeDir, 'src', 'process.cc'),
creationTimePatchApplied
? 'GetProcessCreationTime(pinfo);\nGetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime);\n'
: 'GetProcessMemoryUsage(pinfo);\n'
)
writeFileSync(
join(processTreeDir, 'src', 'process_worker.cc'),
creationTimePatchApplied ? 'object.Set("creationTimeMs", process.creationTimeMs);\n' : '\n'
)
mkdirSync(join(processTreeDir, 'lib'), { recursive: true })
writeFileSync(
join(processTreeDir, 'lib', 'index.js'),
creationTimePatchApplied ? 'exports.ProcessDataFlag["CreationTime"] = 4;\n' : '\n'
)
writeFileSync(
join(processTreeDir, 'lib', 'index.ts'),
creationTimePatchApplied ? 'export enum ProcessDataFlag { CreationTime = 4 }\n' : '\n'
)
mkdirSync(join(processTreeDir, 'typings'), { recursive: true })
writeFileSync(
join(processTreeDir, 'typings', 'windows-process-tree.d.ts'),
creationTimePatchApplied ? 'creationTimeMs?: number\n' : '\n'
)
writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n')
writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n')
writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n')
@@ -33,6 +33,17 @@ export const WINDOWS_PROCESS_TREE_PATCH_PATH = join(
/** Only the patched reader defines this; the upstream one walks the PEB. */
const COMMAND_LINE_PATCH_MARKER = 'kProcessCommandLineInformation'
const CREATION_TIME_PATCH_MARKERS = [
['src/process.h', 'CREATIONTIME = 4'],
['src/process.h', 'ULONGLONG creationTimeMs'],
['src/process.cc', 'GetProcessCreationTime(pinfo)'],
['src/process.cc', 'GetProcessTimes(hProcess, &creationTime'],
['src/process_worker.cc', 'object.Set("creationTimeMs"'],
['lib/index.js', '["CreationTime"] = 4'],
['lib/index.ts', 'CreationTime = 4'],
['typings/windows-process-tree.d.ts', 'creationTimeMs?: number']
]
export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [
'napi.h',
'napi-inl.h',
@@ -83,6 +94,36 @@ export function inspectWindowsProcessTreeAddon(addonPath) {
return readFileSync(addonPath).includes(FLAGGED_IMPORT) ? 'unpatched' : 'clean'
}
export function assertWindowsProcessTreeCreationTimePatch(
packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR
) {
for (const [relativePath, expected] of CREATION_TIME_PATCH_MARKERS) {
const filePath = join(packageDir, relativePath)
if (!existsSync(filePath)) {
throw new Error(
`${filePath} is missing, so the process creation-time patch cannot be verified. ` +
'Run pnpm install.'
)
}
if (!readFileSync(filePath, 'utf8').includes(expected)) {
throw new Error(
`${relativePath} does not contain the process creation-time patch (${expected}). ` +
'Run pnpm install.'
)
}
}
}
export function assertWindowsProcessTreeRuntimeCreationTime(windowsProcessTree) {
if (windowsProcessTree?.ProcessDataFlag?.CreationTime !== 4) {
throw new Error(
'@vscode/windows-process-tree does not expose ProcessDataFlag.CreationTime, so native ' +
'Windows structured agent-session process ownership cannot be PID-reuse safe. Rebuild it ' +
'(pnpm run rebuild:electron) rather than using the published prebuild.'
)
}
}
/**
* Refuse to compile or load the upstream command-line reader.
*
@@ -159,6 +200,7 @@ export function ensureWindowsProcessTreeCommandLinePatch(
rmSync(windowsProcessTreeAddonPath(packageDir), { force: true })
repaired = true
}
assertWindowsProcessTreeCreationTimePatch(packageDir)
return repaired
}
@@ -12,12 +12,15 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertWindowsProcessTreeCreationTimePatch,
assertWindowsProcessTreeRuntimeCreationTime,
inspectWindowsProcessTreeAddon,
nodeGypRebuildInvocation,
stageWindowsProcessTreeNodeAddonApiHeaders,
WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS,
WINDOWS_PROCESS_TREE_PACKAGE_DIR
} from './windows-process-tree-gyp-rebuild.mjs'
import { writeFakeWindowsProcessTreeWithNodeAddonApi } from './rebuild-native-deps-test-fixtures.mjs'
describe('windows-process-tree node-gyp rebuild', () => {
it("resolves node-addon-api's gyp target from the rebuild cwd", () => {
@@ -97,3 +100,47 @@ describe('inspecting a compiled windows-process-tree addon', () => {
expect(inspectWindowsProcessTreeAddon(staged)).toBe('unpatched')
})
})
describe('windows-process-tree CreationTime patch assertion', () => {
let dir
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-creation-time-'))
})
afterEach(() => {
rmSync(dir, { recursive: true, force: true })
})
it('accepts a package whose source and JS surfaces expose process creation time', () => {
writeFakeWindowsProcessTreeWithNodeAddonApi(dir)
expect(() =>
assertWindowsProcessTreeCreationTimePatch(
join(dir, 'node_modules', '@vscode', 'windows-process-tree')
)
).not.toThrow()
})
it('rejects a package missing the process creation-time patch', () => {
writeFakeWindowsProcessTreeWithNodeAddonApi(dir, { creationTimePatchApplied: false })
expect(() =>
assertWindowsProcessTreeCreationTimePatch(
join(dir, 'node_modules', '@vscode', 'windows-process-tree')
)
).toThrow('process creation-time patch')
})
it('requires the runtime ProcessDataFlag.CreationTime enum', () => {
expect(() =>
assertWindowsProcessTreeRuntimeCreationTime({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }
})
).not.toThrow()
expect(() =>
assertWindowsProcessTreeRuntimeCreationTime({
ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2 }
})
).toThrow('ProcessDataFlag.CreationTime')
})
})
+1
View File
@@ -32,6 +32,7 @@
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-process-tree-kill.ts",
"../src/main/codex/codex-app-server-record-reader.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
+21 -2
View File
@@ -2,8 +2,27 @@
Orca selects a Relay region in the Electron main process before requesting a new assignment. The
director publishes an allowlisted region catalog containing only HTTPS cell subdomains of that
director; Orca takes three bounded `/health` latency samples per region and caches the stable choice
for 24 hours. A cached region changes only when the alternative is materially faster.
director. Orca discards one warm-up `/health` request per probe origin — a cold request pays TCP and
TLS setup that can exceed the round trip it measures — then takes three bounded samples and compares
regions by their minimum. A wide spread still rejects a region, but only a genuinely flapping one.
The stable choice is cached for 24 hours, and a cached region changes only when the alternative is
materially faster.
A region wins only against a measured competitor. If any region in the catalog is rejected or cannot
be measured, Orca sends no hint rather than selecting the sole survivor. Sending no hint is not
neutral placement: the director assigns `preferredRegion ?? RELAY_DEFAULT_REGION`, and the default
is `us-central1`. So an `asia-east2` user whose `us-central1` probe fails or flaps once is placed in
`us-central1` for that refresh. That trade is accepted because the relay database is
`us-central1`-only, and it is bounded: the withheld hint is cached for one hour, not the 24 hours a
chosen region gets, so the next hour re-measures. An origin that fails its warm-up probe is dropped
before the sampling rounds, so an unreachable region costs one probe timeout rather than four.
After a control socket registers, Orca probes the cell it actually landed on, once per cell URL per
process. The cache is deleted only when it names a region other than the best measured one and the
assigned cell is more than three times slower than that region — a far cell under a cache that still
names the best region means the director declined the hint, and re-measuring would return the same
answer. Self-heal skips an absent, expired, or no-hint cache, and never runs under
`ORCA_RELAY_REGION_OVERRIDE`.
The assignment request sends only `preferredRegion`. It does not send latency, IP address, country,
pairing data, or credentials. Catalog, probe, and cache failures fall back to an assignment without
+31 -31
View File
@@ -31,12 +31,12 @@ administrators can do about it.
Four independent evidence clusters, from six incidents:
| Cluster | Incidents | Evidence |
| ----------------- | --------- | -------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| Cluster | Incidents | Evidence |
| ----------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| **Update** | A, B, C | `orca-windows-setup.exe` → `old-uninstaller.exe`, `Uninstall Orca.exe` (electron-builder generates these; they are in no repo file) |
| **Spawn** | all six | `Orca.exe` → `orca-terminal-daemon.exe` → `powershell.exe` / `pwsh.exe` / `cmd.exe` / `reg.exe` → `claude.exe`, `gh.exe`, `codex.cmd` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
| **Process table** | D | "suspicious memory activity" — `OpenProcess` plus a PEB read against every process on a repeating cadence |
| **Computer use** | E, F | `runtime.ps1`, `computer-sidecar.js`, many `operation.json`, a burst of ~10 short-lived `powershell.exe` |
Incident E is the one to look at hardest: 5 alerts, 37 evidence items, ATT&CK
**Execution + Collection**, and a description reading _"Screenshots were taken
@@ -143,11 +143,11 @@ PEB fallback to reinstate it — a hooked `ntdll` answering
`STATUS_INVALID_INFO_CLASS` for one target would have flipped a process-wide,
one-way switch back to `PROCESS_VM_READ` on exactly the machines this exists for.
Because the property is the *absence* of an import, it is checkable on the
Because the property is the _absence_ of an import, it is checkable on the
artifact rather than the source: `inspectWindowsProcessTreeAddon()` answers
`clean` / `unpatched` / `missing`, and the rebuild, `ensure-native-runtime.mjs`,
the relay build and `loadWindowsProcessTree()` all key on it. That check is load-
bearing because the published tarball ships a *loadable* prebuilt built from
bearing because the published tarball ships a _loadable_ prebuilt built from
unpatched source, so "it required cleanly" is not evidence.
What to declare to administrators is now one
@@ -180,7 +180,7 @@ Three sites are named in the incident analysis:
`src/shared/setup-agent-sequencing.ts`,
`src/shared/windows-cmd-runner-delayed-launch.ts` and
`src/shared/windows-interactive-login-spawn.ts` each dropped
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script *files*,
`-ExecutionPolicy Bypass` as a measured no-op: the policy gates script _files_,
never `-EncodedCommand`. Where the bypass was load-bearing it moved in-payload as
a process-scope `Set-ExecutionPolicy` (`setup-agent-sequencing.ts`), which is the
pattern to copy rather than restoring the switch — the switch loses to a GPO
@@ -192,7 +192,7 @@ What remains is `-EncodedCommand` without the bypass: the PTY bootstraps
(`src/main/agent-hooks/windows-powershell-hook-launcher.ts` and its callers
`src/main/agent-hooks/runtime-home-hook-command.ts`,
`src/main/agent-hooks/installer-utils.ts`, and `src/main/claude/hook-settings.ts`
— that last one only as a *fallback* since #18875, see below),
— that last one only as a _fallback_ since #18875, see below),
`src/main/runtime/windows-default-route-interfaces.ts`,
`src/main/runtime/orchestration/setup-completion-signal.ts`,
`src/shared/hermes-startup-query.ts`, and the four ex-bypass sites above.
@@ -224,7 +224,7 @@ denies the analyser the payload it would otherwise clear.
The hook launcher is prior art worth knowing about. #16003 measured, on a
reporting Kaspersky host, that `-WindowStyle Hidden` paired with
`-EncodedCommand` was denied at `CreateProcess` with exit 126 regardless of
payload — `exit 0` was denied too. The fix was to stop *spelling* the flags:
payload — `exit 0` was denied too. The fix was to stop _spelling_ the flags:
`WINDOWS_POWERSHELL_HOOK_SWITCHES` is now just `-NoProfile`, and separately, in
#16576, the execution policy bypass moved in-payload as a process-scope
`Set-ExecutionPolicy` — a real command-line signal reduction, though #16003's
@@ -247,7 +247,7 @@ a quoted token, each `%` is broken with `"^%"`.
The escaping is not decorative. Measured on Windows 11 against a real `.cmd`
shim, `["a b", 'c"d', "e%F%g", "h&i", "j^k"]` came back as `["a b", 'c"d',
"e^%F^%g", "h"]` — the `&` truncated the argument *and* ran the remainder as a
"e^%F^%g", "h"]` — the `&` truncated the argument _and_ ran the remainder as a
command.
**How an EDR reads it:** caret escaping is the canonical obfuscation marker in
@@ -259,7 +259,7 @@ obfuscated-command-line detector is tuned on.
`Orca.exe` → the relocated daemon host (`orca-terminal-daemon.exe` in the builds
these incidents cover, `Orca.exe` since) → a shell → an agent CLI is what a
terminal multiplexer for coding agents *is*. `reg.exe` appears from
terminal multiplexer for coding agents _is_. `reg.exe` appears from
`src/main/win32-utils.ts`,
`src/main/agent-hooks/managed-hook-owner-identity.ts` and
`src/relay/pty-shell-utils.ts` (reading the OpenSSH `DefaultShell`).
@@ -267,7 +267,7 @@ terminal multiplexer for coding agents *is*. `reg.exe` appears from
Nothing here is avoidable in principle. What is controllable is depth and
breadth: every interpreter hop between Orca and the thing the user asked for adds
a scored edge, which is why the shipped doctrine of #15520 and #15595 is to
*shorten the interpreter chain* rather than to hide a window.
_shorten the interpreter chain_ rather than to hide a window.
#18875 is a worked example of that doctrine. The Claude Code lifecycle hook was
registered as `powershell.exe -NoProfile -EncodedCommand <...>` whose entire
@@ -295,7 +295,7 @@ That last clause is the standing assumption of this change, and it is worth
stating plainly because it is **not** measured. `||` parses in Git Bash, cmd.exe
and pwsh, but not in Windows PowerShell 5.1, so the direct shape is correct for
any host that is one of the first three. Claude Code itself is a Git Bash host on
native Windows. What no one here has verified is which host a *compat consumer*
native Windows. What no one here has verified is which host a _compat consumer_
uses: cursor-agent and Devin import `~/.claude/settings.json` and run `command`
through their own launcher (the managed `.cmd` carries a `DEVIN_PROJECT_DIR` skip
for exactly that). If one of them spawns hook strings through Windows PowerShell
@@ -318,12 +318,12 @@ then captures the screen through `Graphics.CopyFromScreen`.
That is four separate high-signal behaviours stacked in one process:
| Behaviour | How it is scored |
| ----------------------------------------------- | ---------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
| Behaviour | How it is scored |
| --------------------------------------------- | ------------------------------------------------------------- |
| `Graphics.CopyFromScreen` | **MITRE T1113**, screen capture — Collection tactic |
| `SendInput` synthetic keyboard/mouse | input synthesis against other applications |
| `Add-Type -TypeDefinition` on every operation | MSIL compiled at runtime; incident F's "suspicious MSIL code" |
| One `powershell.exe` per operation | a burst of short-lived interpreters under one parent |
The bottom two rows are the two the incident text named directly, and they are
also the two a persistent runtime host would remove: a long-lived helper compiles
@@ -381,16 +381,16 @@ changed. Check the code before relying on it.
The checklist. On Windows, do not reach for:
| Don't | Instead |
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
| Don't | Instead |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `-ExecutionPolicy Bypass` on the command line | Set the policy in-payload at process scope, as `windows-powershell-hook-launcher.ts` does, or do not run a `.ps1` at all |
| `-EncodedCommand` | A temp `.ps1` with an argument, or no PowerShell hop: prefer a native API or an existing Node path |
| `cmd.exe /c` carrying escaped free text | Spawn the real target directly. `cmd.exe` is only unavoidable for `.cmd`/`.bat`; keep free text out of the line where you can |
| Forking `powershell.exe` to read system state | The native reader — [`windows-process-enumeration.md`](./windows-process-enumeration.md) is the standing rule for the process table |
| A process per operation in a loop | One long-lived helper with a request channel. A burst of short-lived interpreters under one parent is itself the signal |
| `Add-Type -TypeDefinition` at runtime | A precompiled, signed assembly, or a native helper |
| Copying our own image under a different name | Copy it verbatim — [`windows-daemon-host-relocation.md`](./windows-daemon-host-relocation.md) (done for the daemon host) |
| Deriving a script runner from a UI preference | [`windows-setup-shell.md`](./windows-setup-shell.md) — the script declares its own interpreter |
Two framing rules that outlast the table:
@@ -407,7 +407,7 @@ Two framing rules that outlast the table:
This is the single most important operational point, and it is the one most
commonly got wrong. The six incidents are **MDE EDR behavioural alerts**.
Defender Antivirus path exclusions suppress *scan* detections; they do not
Defender Antivirus path exclusions suppress _scan_ detections; they do not
suppress EDR behavioural alerts the same way. Adding
`%LOCALAPPDATA%\Programs\orca\` to the AV exclusion list and expecting the
incidents to stop will not work.
+17 -17
View File
@@ -64,10 +64,10 @@ identity scan opens nothing.
So the module exposes two snapshots, and the row types differ so a cheap caller
cannot read what its flag set did not pay for:
| reader | row type | flags | per-process handles |
| ------------------------------------------ | ---------------------------- | --------------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
| reader | row type | flags | per-process handles |
| ------------------------------------------ | --------------------------- | ---------------------- | ------------------- |
| `readWindowsProcessIdentityTable[Fresh]()` | `WindowsProcessIdentityRow` | `None \| CreationTime` | none |
| `readWindowsProcessTable[Fresh]()` | `WindowsProcessRow` | `+ CommandLine` | one `OpenProcess` |
`Memory` is requested by neither. Nothing reads a working set off this table —
`windows-process-resource-collector.ts` runs its own sweep because it needs
@@ -103,7 +103,7 @@ only under concurrency.
Nothing else in this module prevents that. Each snapshot cache single-flights
only within itself (`inFlight` is a closure per reader), and the wedge set
latches only *after* a read misses its 3 s deadline, so through the healthy
latches only _after_ a read misses its 3 s deadline, so through the healthy
~12 ms of a scan neither excludes the other. Overlap is the normal state rather
than an edge case: other panes keep polling detailed at 750 ms while a teardown
takes identity snapshots, and `codex-structured-turn-processes.ts` issues fresh
@@ -166,15 +166,15 @@ through `toIdentityRow`, so an identity row carries no command line on any host.
### Which callers need which
| caller | reads | flag set |
| --------------------------------------------- | ------------------ | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
| caller | reads | flag set |
| ------------------------------------------ | --------------------------------- | -------- |
| `windows-agent-foreground-process.ts` | `command` (agent recognition) | detailed |
| `local-workspace-platform-port-scanner.ts` | `command` (port attribution) | detailed |
| `codex-structured-turn-processes.ts` | `command` (turn-process identity) | detailed |
| `structured-tui-process-identity.ts` | `command` (child match) | detailed |
| `windows-pty-root-identity.ts` | `pid` / `ppid` only | identity |
| `agent-session-process-identity-probe.ts` | `creationTimeMs` only | identity |
| `relay/windows-port-scan.ts` | `name` (port owner label) | detailed |
`windows-port-scan.ts` is the one mismatch in the table: it reads only `pid` and
`name`, which the identity set answers, but it calls the detailed reader. On a
@@ -344,7 +344,7 @@ on any other OS keeps using the scan.
## Why the package is patched
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries five changes.
`config/patches/@vscode__windows-process-tree@0.8.0.patch` carries six changes.
1. **Spectre mitigation.** The upstream `binding.gyp` requires Spectre-mitigated
libraries, which Orca's Windows build agents do not install. `node-pty` is
@@ -368,10 +368,10 @@ on any other OS keeps using the scan.
to Unix ms; a process that denies the handle is emitted with the field
absent, never zero, because callers must be able to tell "cannot identify"
from a timestamp.
5. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
6. **`supportedProcessDataFlags`.** `addon.cc` exports the flag bits the
compiled binary understands, and `lib/index.js` re-exports it.
Why a fifth hunk and not just the enum: unlike `node-pty`, this package
Why a separate hunk and not just the enum: unlike `node-pty`, this package
publishes a prebuilt `.node` at the same `build/Release/` path node-gyp
writes to. pnpm patches the source tree and leaves that prebuilt alone, so a
host can hold a patched `lib/index.js` — `ProcessDataFlag.CreationTime` and
+2 -3
View File
@@ -30,8 +30,7 @@ import { Callout } from '@/components/docs/prose'
[installer](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe)
</li>
<li>
**Linux:**
AppImage
**Linux:** AppImage
[x64](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) ·
[arm64](https://github.com/stablyai/orca/releases/latest/download/orca-linux-arm64.AppImage) ·
[.deb](https://github.com/stablyai/orca/releases) ·
@@ -131,7 +130,7 @@ On Linux the [Orca CLI](/docs/cli/reference) installs as **`orca-ide`**, not `or
- The `.deb` and `.rpm` put `orca-ide` on your `PATH` at install time, as `/usr/bin/orca-ide`.
- With the AppImage, register the CLI from [Settings → General → Orca CLI](/docs/settings). That installs `~/.local/bin/orca-ide`.
- Inside Orca's own terminals, bare `orca` works. Orca puts a shim on the `PATH` of the terminals it manages, so agents and scripts running there use the same command as on macOS and Windows.
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that *during* startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
- On a headless host, a packaged `orca serve` writes a bare `orca` into `~/.local/bin` as it starts, unless a file it does not own already holds that name. It writes that _during_ startup, so it is never what starts the server — the first launch is always [`orca-ide serve`](/docs/remote-servers).
Do not verify with `command -v orca`: on a GNOME desktop that succeeds and resolves to the screen reader. Use `orca-ide` in your own shell and `orca` inside Orca. If you want the short name everywhere and you do not use the screen reader, link it yourself:
+11 -3
View File
@@ -129,19 +129,23 @@ Install Orca and its bundled CLI on the server, then run:
<Callout title="On Linux, start it with orca-ide serve">
The Linux CLI is named `orca-ide`, because GNOME Orca's screen reader already owns
`/usr/bin/orca`. A packaged `orca serve` does write a bare `orca` into `~/.local/bin`, but only
while it is starting, so that shim can never be the command that starts the server. Read
`orca serve` as `orca-ide serve` throughout this page when the host is Linux. See
[Install → Linux](/docs/install#linux).
while it is starting, so that shim can never be the command that starts the server. Read `orca
serve` as `orca-ide serve` throughout this page when the host is Linux. See [Install →
Linux](/docs/install#linux).
</Callout>
```bash
orca serve --pairing-address <server-tailscale-ip-or-hostname>
# Linux
orca-ide serve --pairing-address <server-tailscale-ip-or-hostname>
```
For example:
```bash
orca serve --pairing-address 100.64.1.20
# Linux
orca-ide serve --pairing-address 100.64.1.20
```
The command:
@@ -157,6 +161,8 @@ Add `--port 6768` when a firewall, tunnel, or service definition requires a fixe
```bash
orca serve --port 6768 --pairing-address 100.64.1.20
# Linux
orca-ide serve --port 6768 --pairing-address 100.64.1.20
```
Use only one host mode at a time. If the Orca desktop app is already sharing that computer, do not start a second `orca serve` process for the same setup.
@@ -167,6 +173,8 @@ For the Orca mobile app, request a mobile-scoped QR code and link:
```bash
orca serve --pairing-address 100.64.1.20 --mobile-pairing
# Linux
orca-ide serve --pairing-address 100.64.1.20 --mobile-pairing
```
Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan the terminal QR code or paste the printed link.
+6 -6
View File
@@ -213,9 +213,9 @@ The commands, snapshot and ref rules, page affinity, and `browser_*` recoveries
This guide covers worktrees, terminals, and handoffs on its own. At a gate below, run `ORCA skills get orca-cli --reference references/<file>.md` and read only that document; `--references` lists the names. If the CLI rejects `--reference`, run `ORCA skills get orca-cli --full` once instead: it returns this guide plus every reference from the same CLI build, so read only the named one. If `--full` is rejected too, the CLI predates bundled references: use `ORCA <command> --help`, keep the rules above, and do not guess flags.
| Action gate | Reference |
|---|---|
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
| Action gate | Reference |
| --------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| Driving Orca's embedded browser: navigation, snapshots, refs, tabs, concurrent pages, or `browser_*` recoveries | `references/browser.md` |
| Creating, editing, running, or inspecting scheduled automations | `references/automations.md` |
| Publishing or revoking an artifact link, or publishing installed skills | `references/publishing.md` |
| Mobile emulator taps, gestures, typing, buttons, camera, or permissions | invoke the `orca-emulator` skill |
+17 -17
View File
@@ -52,23 +52,23 @@ Orca returns a clear message when the SDK is missing
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
| Goal | Command | Constraint |
| -------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| List devices + AVDs | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach <avd-name-or-serial> --json` | Given an AVD name, boots it first. Makes the device active for the worktree. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Swipe / gesture | `ORCA emulator gesture '<json>' --json` | adb approximates the path by its endpoints, first point to last. |
| Type text | `ORCA emulator type "user@example.com" --json` | US-ASCII, spaces handled, no newlines. |
| Hardware button | `ORCA emulator button back --json` | `home`, `back`, `recents`, `power`, `volume_up`, `volume_down`. |
| Rotate | `ORCA emulator rotate landscape_left --json` | Sets `user_rotation` and disables auto-rotate. |
| Install an APK | `ORCA emulator install ./app-debug.apk --reinstall --json` | `--reinstall` passes `-r`. |
| Launch an app | `ORCA emulator launch com.acme.app --activity .MainActivity --json` | Omit `--activity` to launch the default LAUNCHER activity. |
| Runtime permission | `ORCA emulator permissions grant com.acme.app android.permission.CAMERA --json` | Positional order is `<grant\|revoke> <package> <permission>`; `reset` takes no positionals and clears all runtime grants. |
| Accessibility tree | `ORCA emulator ax --json` | `uiautomator dump` parsed to a node tree. |
| Logcat (one-shot) | `ORCA emulator logcat --lines 200 --json` | Dumps recent lines, parsed to entries. |
| Raw adb shell | `ORCA emulator exec --command "getprop ro.build.version.sdk" --json` | Runs `adb -s <serial> shell <command>`. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the device down. |
## Targeting
+15 -15
View File
@@ -43,20 +43,20 @@ Orca reports a clear error when the host is missing macOS or the Xcode tools.
Use `--json` for agent-driven calls. Unqualified commands target the worktree's active
device.
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Goal | Command | Constraint |
| ------------------------ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| List available / running | `ORCA emulator list --json` | Orca-managed sessions plus raw serve-sim streams. Use its ids for `--device` / `--emulator`. |
| List devices everywhere | `ORCA emulator devices --json` | Every backend's devices with a platform column, booted and shutdown. |
| Attach / make active | `ORCA emulator attach "iPhone 16 Pro" --json` | Starts the helper if needed and makes the device active for the worktree. `--focus` switches the UI; it does not by default. |
| Single tap | `ORCA emulator tap <x> <y> --json` | Normalized 0..1 coordinates. |
| Multi-step gesture | `ORCA emulator gesture '<json>' --json` | Begin/move/end points. Use `tap` for a single tap. |
| Type text | `ORCA emulator type "text" --json` | US-ASCII only. |
| Hardware button | `ORCA emulator button home --json` | `home` and `side_button` are documented by the CLI spec; other names such as `swipe_home`, `app_switcher`, `lock`, and `siri` are forwarded to serve-sim unvalidated. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
| Rotate device | `ORCA emulator rotate landscape_left --json` | The orientation persists for subsequent gestures. |
| Accessibility tree | `ORCA emulator ax --json` | serve-sim node tree, capped at 500 nodes, frames normalized 0..1 with a top-left origin. Needs an active session. |
| Raw passthrough | `ORCA emulator exec --command "ca-debug blended on" --json` | serve-sim subcommand string, without a `serve-sim` prefix. |
| Stop the helper | `ORCA emulator kill --json` | Leaves the device booted. |
| Stop and power off | `ORCA emulator shutdown --json` | Stops the helper and shuts the simulator device down. |
## Targeting
@@ -65,8 +65,8 @@ commands target it. Pass a selector only to override that or reach a second devi
active session an unqualified command fails with `emulator_no_active`; attach or open the pane
and retry.
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`. `--emulator
<id>` is an alternative spelling: the bridge resolves both through the same lookup. These
- `--device "iPhone 16 Pro"` or `--device <udid>`, from `list` or `devices`.
`--emulator <id>` is an alternative spelling: the bridge resolves both through the same lookup. These
selectors apply to the action verbs; `list` and `devices` take only `--worktree`, and
`attach` names its device as a positional argument.
- `--worktree id:<fullWorktreeId>` or `--worktree active`. The full id is the exact
+7 -7
View File
@@ -367,10 +367,10 @@ rejects `--reference`, run `ORCA skills get orca-per-workspace-env --full` once
this guide plus every reference from the same CLI build, so read only the named one. If `--full` is
rejected too, keep these rules, use the command's `--help`, and do not guess flags.
| Action gate | Bundled reference |
| --- | --- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
| Action gate | Bundled reference |
| ----------------------------------------------------------------------------------------- | ------------------------------- |
| Writing the base-snapshot, auth, or create script for a snapshot-capable cloud provider | `references/provider-vercel.md` |
| The recipe connects over SSH instead of starting `orca serve`, including provisioned root | `references/ssh-host.md` |
| The environment is a local Docker container reached over SSH | `references/docker-ssh.md` |
| The user's desktop is Windows and you are scaffolding local-side scripts | `references/windows-scripts.md` |
| A doctor, provision, clone, login, or snapshot step failed | `references/failure-modes.md` |
File diff suppressed because one or more lines are too long
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { OrchestrationFleetWorker } from '../../../shared/orchestration-fleet-projection'
import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary'
import type {
NativeChatBlock,
NativeChatMessage,
NativeChatSubagentEntry
} from '../../../shared/native-chat-types'
import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output'
import { formatWorkerRead, formatWorkerStart } from './worker-output'
@@ -288,3 +294,207 @@ function workerReadResult(
type WorkerReadResultWithoutContext<T> = T extends unknown
? Omit<T, 'dispatchId' | 'status'>
: never
function transcriptRead(
blocks: NativeChatBlock[],
role: NativeChatMessage['role'] = 'assistant'
): OrchestrationWorkerReadResult {
const message: NativeChatMessage = {
id: 'm1',
role,
blocks,
timestamp: 1,
source: 'transcript'
}
return {
dispatchId: 'd1',
source: 'transcript',
sourceIdentity: 'pane:1',
provider: 'codex',
transcript: { messages: [message], nextCursor: '1', limited: false, returnedMessageCount: 1 },
cursor: '1',
status: { worker: 'running', terminal: 'running' },
fallbackReason: null,
warnings: []
}
}
const ROSTER: readonly NativeChatSubagentEntry[] = [
{ id: 'child-1', label: 'read', state: 'working' },
{ id: 'child-2', label: 'edit', state: 'failed' }
]
function occurrences(haystack: string, needle: string): number {
return haystack.split(needle).length - 1
}
describe('formatWorkerRead', () => {
// The replay case this row is durable for: SQLite-backed, re-sent on every
// reconnect, and read here by a client that draws no roster block, runs no
// reconciliation, and cannot re-check whether those children still exist. A
// sentence frozen mid-flight outlives the process that wrote it, so it must
// not keep asserting a liveness only that process could have observed —
// `docs/reference/ssh-execution-boundary.md` calls that loss of contact
// reported as a live state.
it('replays a mid-flight roster row without claiming a child is still working', () => {
const midFlight: readonly NativeChatSubagentEntry[] = [
{ id: 'child-1', label: 'read', state: 'working' },
{ id: 'child-2', label: 'search', state: 'working' },
{ id: 'child-3', label: 'edit', state: 'failed' }
]
const output = formatWorkerRead(
transcriptRead([
{ type: 'text', text: subagentGroupFallbackText(midFlight) },
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...midFlight] }
])
)
expect(output).toContain('[assistant] Kicked off 3 subagents (1 failed)')
expect(output).not.toMatch(/\bworking\b/)
})
// The body `codexSubagentGroupBody` actually writes: the plain-text twin, then
// the block it stands in for. The twin exists for clients that cannot draw the
// block, so a client printing the block must not print the twin beside it —
// the renderer drops the twin for the same reason, from the other side.
it('prints the roster sentence once for the two-block row the producer writes', () => {
const sentence = subagentGroupFallbackText(ROSTER)
const output = formatWorkerRead(
transcriptRead(
[
{ type: 'text', text: sentence },
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }
],
'system'
)
)
expect(output).toContain(`[system] ${sentence}`)
expect(occurrences(output, sentence)).toBe(1)
})
// Suppression is per twin, not per message. One twin beside two roster blocks
// silenced BOTH groups and printed one sentence, so the second roster vanished
// with no marker — the same silent drop the missing-twin case above avoids.
it('stands in for the second roster block when only one twin accompanies two', () => {
const other: readonly NativeChatSubagentEntry[] = [
{ id: 'child-3', label: 'plan', state: 'completed' }
]
const output = formatWorkerRead(
transcriptRead(
[
{ type: 'text', text: subagentGroupFallbackText(ROSTER) },
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] }
],
'system'
)
)
expect(occurrences(output, subagentGroupFallbackText(ROSTER))).toBe(1)
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(other)}`)
})
// Which group a lone twin belongs to is decided by its TEXT, not its position.
// Claiming positionally silenced whichever group came first, so a twin
// belonging to a LATER group erased the earlier group's roster and printed the
// later one's sentence twice — the same silent drop, one permutation over.
it('claims a lone twin for the group it names, not the first group in the message', () => {
const other: readonly NativeChatSubagentEntry[] = [
{ id: 'child-3', label: 'plan', state: 'completed' }
]
const second = subagentGroupFallbackText(other)
const output = formatWorkerRead(
transcriptRead(
[
{ type: 'text', text: second },
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] }
],
'system'
)
)
expect(occurrences(output, second)).toBe(1)
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`)
})
// The same claim, with the twin written after both blocks: nothing about the
// ORDER of a twin and its group is guaranteed by the block schema.
it('claims a trailing twin for the group it names', () => {
const other: readonly NativeChatSubagentEntry[] = [
{ id: 'child-3', label: 'plan', state: 'completed' }
]
const second = subagentGroupFallbackText(other)
const output = formatWorkerRead(
transcriptRead(
[
{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] },
{ type: 'subagent-group', groupId: 'thread:turn-2', agents: [...other] },
{ type: 'text', text: second }
],
'system'
)
)
expect(occurrences(output, second)).toBe(1)
expect(output).toContain(`[subagents] ${subagentGroupFallbackText(ROSTER)}`)
})
// A group with no twin beside it is a shape the block schema admits and no
// producer writes. Dropping it would lose the roster entirely, so the block
// itself carries the sentence when nothing else does.
it('stands in for a roster block that arrived without its twin', () => {
const output = formatWorkerRead(
transcriptRead([{ type: 'subagent-group', groupId: 'thread:turn-1', agents: [...ROSTER] }])
)
expect(output).toContain(`[assistant] [subagents] ${subagentGroupFallbackText(ROSTER)}`)
})
// A roster from a newer build holds a state this build does not know, which
// `summarizeSubagentGroup` reads as `unverifiable`. Recomputing the sentence
// to compare it against the frozen twin therefore produced a DIFFERENT string,
// and the CLI printed the roster twice: the twin's own wording plus a
// `[subagents]` line contradicting it.
it('prints the roster once when the twin names a state this build cannot reproduce', () => {
const frozenTwin = 'Ran 2 subagents (1 cancelled)'
const output = formatWorkerRead(
transcriptRead(
[
{ type: 'text', text: frozenTwin },
{
type: 'subagent-group',
groupId: 'thread:turn-1',
agents: [
{ id: 'child-1', label: 'read', state: 'completed' },
{ id: 'child-2', label: 'edit', state: 'cancelled' }
] as unknown as NativeChatSubagentEntry[]
}
],
'system'
)
)
expect(output).toContain(`[system] ${frozenTwin}`)
expect(output).not.toContain('[subagents]')
expect(output).not.toContain('unverifiable')
})
// The journal admits block types this build does not know, and `client.call`
// casts the RPC result rather than validating it — so a newer remote host's
// block reaches this formatter as-is. Reading fields off it threw a TypeError
// and took down the whole `worker read`.
it('degrades an unknown block type from a newer host instead of throwing', () => {
const output = formatWorkerRead(
transcriptRead([
{ type: 'text', text: 'before' },
{ type: 'plan-step', title: 'ship it' } as unknown as NativeChatBlock,
{ type: 'text', text: 'after' }
])
)
expect(output).toContain('[assistant] before\n[unsupported block]\nafter')
})
})
@@ -38,6 +38,7 @@ export type ClaudeStructuredSdkOptions = Pick<
| 'sessionId'
| 'resume'
| 'resumeSessionAt'
| 'resumeDropsTurn'
>
/**
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest'
import {
adapterFor,
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
import { ClaudeRewindAttempt } from './claude-structured-rewind'
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
const intent = { targetUuid: 'kept', previousLeafUuid: 'tip', dropsTurn: 'drop' }
const proofLaunch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
describe('Claude rewind acquisition', () => {
it('executes a cursor resume in place and proves the exact target before publication', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
try {
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: intent
})
expect(acquired.link.handle).toMatchObject({
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
leafUuid: 'kept'
})
expect(fake.connections[0]!.launch.options).toMatchObject({
resume: PROVIDER_SESSION_ID,
resumeSessionAt: 'kept',
resumeDropsTurn: 'drop'
})
expect(fake.connections[0]!.launch.options).not.toHaveProperty('forkSession')
expect(proof).toHaveBeenCalledWith(
expect.objectContaining({ previousLeafUuid: 'tip', intentionalRewindUuid: 'kept' })
)
await adapter.closeSession('session-1')
await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-next' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(
proof.mock.calls.filter(([input]) => input.intentionalRewindUuid !== undefined)
).toHaveLength(1)
} finally {
await adapter.closeAll()
}
})
it('recognizes the documented refusal and closes the failed child without retry', async () => {
const fake = fakeClaude()
const openConnection = fake.openConnection
fake.openConnection = async (launch, handlers) => {
const connection = await openConnection(launch, handlers)
const initialize = connection.initializationResult
connection.initializationResult = async (...args) => {
const result = await initialize(...args)
handlers?.onMessage?.({
type: 'result',
subtype: 'error_during_execution',
session_id: PROVIDER_SESSION_ID,
errors: ['Resume rejected by --resume-drops-turn: additional prompt observed']
})
return result
}
return connection
}
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(fake, { resumed: true }, [], [], undefined, proof)
await expect(
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn', rewind: intent })
).rejects.toMatchObject({ rewindReason: 'provider-refused' })
expect(fake.connections).toHaveLength(1)
expect(fake.connections[0]?.closed).toBe(true)
expect(proof).not.toHaveBeenCalled()
await adapter.closeAll()
})
it('consumes proof authorization even if its first read fails', async () => {
const proof = vi.fn(async () => {
throw new Error('torn transcript')
})
const attempt = new ClaudeRewindAttempt(intent)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBeInstanceOf(
AgentSessionRewindRefusal
)
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('never persists success for a mismatching leaf', async () => {
const onProved = vi.fn(async () => {})
const attempt = new ClaudeRewindAttempt(intent, onProved)
await expect(
attempt.prove(proofLaunch, { readTranscriptLeaf: async () => 'other' })
).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(onProved).not.toHaveBeenCalled()
})
it('preserves commit failure as unknown and consumes the override before persisting', async () => {
const diskError = new Error('record write failed')
const onProved = vi.fn(async () => {
throw diskError
})
const proof = vi.fn(async () => 'kept')
const attempt = new ClaudeRewindAttempt(intent, onProved)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBe(diskError)
expect(onProved).toHaveBeenCalledWith('kept')
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('checkpoints the proved target before late acquisition failure without persisting a stale cursor', async () => {
const fake = fakeClaude()
const launch = { resumed: true, resumeLeafUuid: 'tip' }
const persisted: unknown[] = []
const proof = vi.fn(async () => 'kept')
const adapter = adapterFor(fake, launch, [], persisted, undefined, proof)
const onProved = vi.fn(async (leafUuid: string) => {
launch.resumeLeafUuid = leafUuid
fake.connections[0]!.closed = true
})
try {
await expect(
adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: { ...intent, onProved }
})
).rejects.toThrow('exited while being acquired')
expect(onProved).toHaveBeenCalledWith('kept')
expect(persisted).toEqual([])
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 8,
spawnToken: 'retry'
})
expect(acquired.link.handle).toMatchObject({ leafUuid: 'kept' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(proof).toHaveBeenCalledTimes(1)
} finally {
await adapter.closeAll()
}
})
it('restores an interrupted unproved rewind only after exact ordinary branch proof', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const restored = vi.fn(async () => {})
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
const input = {
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewindRecovery: { leafUuid: 'tip', onProved: restored }
}
try {
await expect(adapter.acquire(input)).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(restored).not.toHaveBeenCalled()
proof.mockResolvedValue('tip')
await adapter.acquire({ ...input, fence: 8, spawnToken: 'retry' })
expect(restored).toHaveBeenCalledOnce()
expect(proof).toHaveBeenCalledWith(expect.objectContaining({ previousLeafUuid: 'tip' }))
for (const [request] of proof.mock.calls) {
expect(request).not.toHaveProperty('intentionalRewindUuid')
}
} finally {
await adapter.closeAll()
}
})
})
+118
View File
@@ -0,0 +1,118 @@
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export function claudeRewindRefusalFromMessage(
message: Record<string, unknown>
): AgentSessionRewindRefusal | null {
return message.type === 'result' &&
message.subtype === 'error_during_execution' &&
Array.isArray(message.errors) &&
message.errors.some(
(error) =>
typeof error === 'string' && error.startsWith('Resume rejected by --resume-drops-turn:')
)
? new AgentSessionRewindRefusal('provider-refused')
: null
}
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state'
type Intent = NonNullable<StructuredAgentSessionAcquireInput['rewind']>
/** The proof authorization exists only for this acquisition's first proof attempt. */
export class ClaudeRewindAttempt {
private refusal: AgentSessionRewindRefusal | null = null
constructor(
private intent: Intent | undefined,
private readonly onProved?: (leafUuid: string) => Promise<void>
) {}
observe(message: Record<string, unknown>): AgentSessionRewindRefusal | null {
if (!this.intent) {
return null
}
this.refusal ??= claudeRewindRefusalFromMessage(message)
return this.refusal
}
applyLaunch(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): void {
if (!this.intent) {
return
}
if (!launch.resumed || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('unsupported')
}
launch.options = {
...launch.options,
resume: launch.providerSessionId,
resumeSessionAt: this.intent.targetUuid,
...(this.intent.dropsTurn ? { resumeDropsTurn: this.intent.dropsTurn } : {})
}
launch.resumeLeafUuid = this.intent.targetUuid
}
async prove(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
const intent = this.intent
this.clear()
if (this.refusal) {
throw this.refusal
}
if (!intent) {
return null
}
let leaf: string | null
try {
leaf = await deps.readTranscriptLeaf!({
providerSessionId: launch.providerSessionId,
previousLeafUuid: intent.previousLeafUuid,
intentionalRewindUuid: intent.targetUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== intent.targetUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
} catch (error) {
throw error instanceof AgentSessionRewindRefusal
? error
: new AgentSessionRewindRefusal('proof-mismatch')
}
// Persistence failure is an unknown outcome, never evidence that the provider refused.
await this.onProved?.(leaf)
return leaf
}
clear(): void {
this.intent = undefined
}
}
/** An interrupted, unproved rewind restores its original cursor without ancestor authorization. */
export async function proveClaudeRewindRecovery(
recovery: StructuredAgentSessionAcquireInput['rewindRecovery'],
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
if (!recovery) {
return null
}
if (!launch.resumed || launch.resumeLeafUuid !== recovery.leafUuid || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
const leaf = await deps.readTranscriptLeaf({
providerSessionId: launch.providerSessionId,
previousLeafUuid: recovery.leafUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== recovery.leafUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
await recovery.onProved()
return leaf
}
@@ -1,3 +1,4 @@
import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionPreSpawnError
@@ -85,6 +86,7 @@ export async function acquireClaudeSession({
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
@@ -95,6 +97,11 @@ export async function acquireClaudeSession({
}
return
}
const refusal = rewind.observe(message)
if (refusal) {
initDeadline.reject(refusal)
return
}
if (init) {
initDeadline.resolve(init)
// Every turn opens with an init frame naming the model the CLI is actually
@@ -178,6 +185,7 @@ export async function acquireClaudeSession({
? error
: new AgentSessionPreSpawnError(error)
})
rewind.applyLaunch(launch, deps)
expectedProviderSessionId = launch.providerSessionId
observedLeafUuid = launch.resumeLeafUuid
acquisitions.assertCurrent(sessionId, attempt)
@@ -241,6 +249,9 @@ export async function acquireClaudeSession({
diagnostic: claudeAuthDiagnostic(init, settings)
})
)
observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid
observedLeafUuid =
(await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid
const process = await claudeProcessIdentity(
{ ...input, pid: connection.pid },
deps.readProcessStartTime
@@ -298,6 +309,7 @@ export async function acquireClaudeSession({
acquisitions.deleteIfCurrent(sessionId, attempt)
throw acquisitionError
} finally {
rewind.clear()
attempt.finish()
}
}
@@ -4,7 +4,6 @@ import type {
StructuredAgentSessionAcquireInput,
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
answerClaudePrompt,
cancelClaudeTurn,
@@ -58,6 +57,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
supportsLocation = supportsClaudeStructuredLocation
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = () =>
this.deps.readTranscriptLeaf ? { supported: true } : { supported: false, reason: 'unsupported' }
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireClaudeSession({
input,
@@ -67,7 +69,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
exits: this.exits,
callbacks: {
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
emit: (session, events, event) => this.emit(session, events, event),
emit: (session, _events, event) => this.emit(session, event),
handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error),
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
}
@@ -155,7 +157,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
acquisitionGeneration: exit.session.acquisitionGeneration
}
try {
this.emit(exit.session, exit.session.events, ended)
this.emit(exit.session, ended)
} finally {
settleClaudeExitedSession(exit.session)
}
@@ -187,11 +189,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
})
}
private emit(
session: ClaudeSession | null,
_events: StructuredAgentSessionEventSink | undefined,
event: ClaudeStructuredSessionEvent
): void {
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
const backgroundTasksChanged =
event.type === 'ended'
? (session?.backgroundTasks.clear() ?? false)
@@ -88,6 +88,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
readTranscriptLeaf?: (input: {
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
/** Account-scoped Claude config root that owns this provider session. */
claudeConfigDir: string
}) => Promise<string | null>
@@ -13,7 +13,7 @@ type TranscriptNode = {
export type ClaudeTranscriptBranchProof = {
leafUuid: string
relation: 'initial' | 'same' | 'descendant'
relation: 'initial' | 'same' | 'descendant' | 'intentional-rewind'
}
function nonEmptyString(value: unknown): string | null {
@@ -83,6 +83,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
contents: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): ClaudeTranscriptBranchProof {
const nodes = new Map<string, TranscriptNode>()
let leafUuid: string | null = null
@@ -156,6 +157,21 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
throw transcriptError('marker precedes its leaf record')
}
const previousLeafUuid = input.previousLeafUuid
if (input.intentionalRewindUuid !== undefined) {
if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
throw transcriptError('rewind target does not match the observed leaf')
}
proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
proveAppendOrder(nodes)
let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
if (ancestor === leafUuid) {
return { leafUuid, relation: 'intentional-rewind' }
}
ancestor = nodes.get(ancestor)?.parentUuid ?? null
}
throw transcriptError('rewind target is not an ancestor of the previous cursor')
}
if (!previousLeafUuid) {
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
// A branch proof is based on an append-only snapshot. A child that appears
@@ -210,11 +226,13 @@ export async function proveClaudeTranscriptBranch(input: {
transcriptPath: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): Promise<ClaudeTranscriptBranchProof> {
return proveClaudeTranscriptBranchFromJsonl({
contents: await readFile(input.transcriptPath, 'utf8'),
providerSessionId: input.providerSessionId,
previousLeafUuid: input.previousLeafUuid
previousLeafUuid: input.previousLeafUuid,
intentionalRewindUuid: input.intentionalRewindUuid
})
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof'
const row = (uuid: string, parentUuid: string | null, extra = {}) =>
JSON.stringify({ type: 'assistant', sessionId: 'provider', uuid, parentUuid, ...extra })
const marker = (leafUuid: string) =>
JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid })
const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')]
const prove = (rows: string[], leaf: string, intentionalRewindUuid?: string) =>
proveClaudeTranscriptBranchFromJsonl({
contents: `${[...rows, marker(leaf)].join('\n')}\n`,
providerSessionId: 'provider',
previousLeafUuid: 'old',
intentionalRewindUuid
})
describe('explicit Claude rewind ancestry', () => {
it('admits only the exact requested main-chain ancestor', () => {
expect(prove(graph, 'kept', 'kept')).toEqual({
leafUuid: 'kept',
relation: 'intentional-rewind'
})
expect(() => prove(graph, 'kept')).toThrow('sibling')
expect(() => prove(graph, 'kept', 'root')).toThrow('target')
expect(() => prove(graph, 'old', 'old')).toThrow('not an ancestor')
})
it('keeps sibling and sidechain rejection even with explicit intent', () => {
expect(() => prove([...graph, row('sibling', 'root')], 'sibling', 'sibling')).toThrow(
'not an ancestor'
)
expect(() =>
prove(
[row('root', null), row('kept', 'root', { isSidechain: true }), row('old', 'kept')],
'kept',
'kept'
)
).toThrow()
})
it('refuses missing, reordered, or cyclic ancestry', () => {
expect(() => prove(graph.slice(1), 'kept', 'kept')).toThrow('missing ancestor')
expect(() => prove([graph[1]!, graph[0]!, graph[2]!], 'kept', 'kept')).toThrow(
'parent row follows'
)
expect(() => prove([row('root', 'old'), ...graph.slice(1)], 'kept', 'kept')).toThrow('cycle')
})
})
+29 -4
View File
@@ -182,10 +182,10 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul
readStringClaim(authClaims, 'chatgpt_account_id') ??
readStringClaim(payload, 'chatgpt_account_id')
),
workspaceLabel: normalizeField(
readStringClaim(authClaims, 'workspace_name') ??
readStringClaim(profileClaims, 'workspace_name')
),
workspaceLabel:
normalizeField(readStringClaim(authClaims, 'workspace_name')) ??
normalizeField(readStringClaim(profileClaims, 'workspace_name')) ??
readPlanWorkspaceLabel(authClaims),
workspaceAccountId: normalizeField(
readStringClaim(authClaims, 'workspace_account_id') ??
tokenAccountId ??
@@ -194,6 +194,31 @@ export function readCodexAuthIdentity(contents: string): CodexAuthIdentity | nul
}
}
function readPlanWorkspaceLabel(authClaims: Record<string, unknown> | null): string | null {
// Codex tokens commonly omit workspace_name but identify the account's plan.
switch (normalizeField(readStringClaim(authClaims, 'chatgpt_plan_type'))?.toLowerCase()) {
case 'free':
return 'Personal (Free)'
case 'go':
return 'Personal (Go)'
case 'plus':
return 'Personal (Plus)'
case 'pro':
return 'Personal (Pro)'
case 'team':
return 'Team'
case 'business':
return 'Business'
case 'enterprise':
return 'Enterprise'
case 'edu':
return 'Education'
case undefined:
default:
return null
}
}
function readFreshnessFromAuthContents(contents: string): number | null {
const raw = parseJsonRecord(contents)
if (!raw) {
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import type { CodexManagedAccount } from '../../shared/managed-account-types'
import {
codexAuthMatchesManagedAccount,
codexAuthMatchesSystemDefaultIdentity,
readCodexAuthIdentity
} from './codex-auth-identity'
const email = 'same@example.com'
function auth(
accountId: string,
claims: Record<string, unknown>,
profileClaims: Record<string, unknown> = {}
): string {
const payload = Buffer.from(
JSON.stringify({
email,
'https://api.openai.com/auth': { chatgpt_account_id: accountId, ...claims },
'https://api.openai.com/profile': profileClaims
})
).toString('base64url')
return JSON.stringify({
tokens: { account_id: accountId, id_token: `header.${payload}.signature` }
})
}
describe('Codex personal and organization workspace identity', () => {
it.each([
['free', 'Personal (Free)'],
['go', 'Personal (Go)'],
['plus', 'Personal (Plus)'],
['pro', 'Personal (Pro)'],
['team', 'Team'],
['business', 'Business'],
['enterprise', 'Enterprise'],
['edu', 'Education']
])('uses the %s plan when the token omits the workspace name', (plan, label) => {
expect(readCodexAuthIdentity(auth('provider-1', { chatgpt_plan_type: plan }))).toEqual({
email,
providerAccountId: 'provider-1',
workspaceAccountId: 'provider-1',
workspaceLabel: label
})
})
it.each([undefined, null, '', 'future-plan', 42])(
'does not infer personal membership from an unknown plan %s',
(plan) => {
expect(
readCodexAuthIdentity(auth('provider-1', { chatgpt_plan_type: plan }))?.workspaceLabel
).toBeNull()
}
)
it('preserves an explicit organization name over the plan label', () => {
expect(
readCodexAuthIdentity(
auth('provider-1', { workspace_name: ' Acme ', chatgpt_plan_type: 'enterprise' })
)?.workspaceLabel
).toBe('Acme')
})
it('uses the profile workspace name when the auth workspace name is blank', () => {
expect(
readCodexAuthIdentity(
auth(
'provider-1',
{ workspace_name: ' ', chatgpt_plan_type: 'enterprise' },
{ workspace_name: 'Acme' }
)
)?.workspaceLabel
).toBe('Acme')
})
it('keeps same-email personal and enterprise credentials isolated in both directions', () => {
const personal = auth('personal-provider', { chatgpt_plan_type: 'plus' })
const enterprise = auth('enterprise-provider', { chatgpt_plan_type: 'enterprise' })
for (const [selectedAuth, otherAuth] of [
[personal, enterprise],
[enterprise, personal]
]) {
const identity = readCodexAuthIdentity(selectedAuth)!
const account: CodexManagedAccount = {
...identity,
id: 'orca-account',
email,
managedHomePath: 'managed-home',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
}
expect(codexAuthMatchesManagedAccount(selectedAuth, account, selectedAuth)).toBe(true)
expect(codexAuthMatchesManagedAccount(otherAuth, account, selectedAuth)).toBe(false)
expect(codexAuthMatchesSystemDefaultIdentity(otherAuth, selectedAuth)).toBe(false)
}
expect(readCodexAuthIdentity(personal)?.workspaceLabel).toBe('Personal (Plus)')
expect(readCodexAuthIdentity(enterprise)?.workspaceLabel).toBe('Enterprise')
})
it('does not treat a matching plan label as proof of account ownership', () => {
const first = auth('enterprise-a', { chatgpt_plan_type: 'enterprise' })
const second = auth('enterprise-b', { chatgpt_plan_type: 'enterprise' })
expect(codexAuthMatchesSystemDefaultIdentity(first, second)).toBe(false)
})
})
@@ -87,64 +87,67 @@ describe('CodexRuntimeHomeService', () => {
expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath)
})
it('gives two managed accounts distinct homes without racing one auth.json', async () => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two')
const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
shellStartupEnvProbeSupported: true,
codexManagedAccounts: [
{
id: 'account-1',
email: 'one@example.com',
managedHomePath: home1,
providerAccountId: 'acct-1',
workspaceLabel: null,
workspaceAccountId: 'acct-1',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: 'two@example.com',
managedHomePath: home2,
providerAccountId: 'acct-2',
workspaceLabel: null,
workspaceAccountId: 'acct-2',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// A pane for account-1 launches, then the user switches and a second pane
// for account-2 launches concurrently — each gets its OWN CODEX_HOME.
expect(service.prepareForCodexLaunch()).toBe(home1)
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
expect(service.prepareForCodexLaunch()).toBe(home2)
expect(
service.prepareForCodexLaunch(undefined, undefined, {
unavailableManagedHomePath: home1
it.each(['two@example.com', 'one@example.com'])(
'isolates account homes when the second email is %s',
async (secondEmail) => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one')
const account2Auth = createCodexAuthJson(secondEmail, 'acct-2', 'two')
const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth)
const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth)
const settings = createSettings({
shellStartupEnvProbeSupported: true,
codexManagedAccounts: [
{
id: 'account-1',
email: 'one@example.com',
managedHomePath: home1,
providerAccountId: 'acct-1',
workspaceLabel: null,
workspaceAccountId: 'acct-1',
createdAt: 1,
updatedAt: 1,
lastAuthenticatedAt: 1
},
{
id: 'account-2',
email: secondEmail,
managedHomePath: home2,
providerAccountId: 'acct-2',
workspaceLabel: null,
workspaceAccountId: 'acct-2',
createdAt: 2,
updatedAt: 2,
lastAuthenticatedAt: 2
}
],
activeCodexManagedAccountId: 'account-1',
activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} }
})
).toBe(home2)
expect(store.updateSettings).not.toHaveBeenCalled()
const store = createStore(settings)
const { CodexRuntimeHomeService } = await import('./runtime-home-service')
const service = new CodexRuntimeHomeService(store as never)
// Nothing is hot-swapped, so the still-running account-1 pane keeps seeing
// account-1's credentials — the single-auth.json race (GAP-5) is gone.
expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth)
expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
})
// A pane for account-1 launches, then the user switches and a second pane
// for account-2 launches concurrently — each gets its OWN CODEX_HOME.
expect(service.prepareForCodexLaunch()).toBe(home1)
settings.activeCodexManagedAccountId = 'account-2'
settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} }
expect(service.prepareForCodexLaunch()).toBe(home2)
expect(
service.prepareForCodexLaunch(undefined, undefined, {
unavailableManagedHomePath: home1
})
).toBe(home2)
expect(store.updateSettings).not.toHaveBeenCalled()
// Nothing is hot-swapped, so the still-running account-1 pane keeps seeing
// account-1's credentials — the single-auth.json race (GAP-5) is gone.
expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(account1Auth)
expect(readFileSync(join(home2, 'auth.json'), 'utf-8')).toBe(account2Auth)
expect(existsSync(getRuntimeCodexAuthPath())).toBe(false)
}
)
it('materializes resources and config into the per-account home on launch', async () => {
writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8')
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
@@ -29,6 +29,74 @@ vi.mock('node:os', async () => {
describe('CodexAccountService.addAccountFromHome', () => {
registerCodexAccountsTestHomes()
it('imports and switches personal and enterprise accounts sharing an email independently', async () => {
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
const sourceHomes = [
mkdtempSync(join(tmpdir(), 'orca-codex-personal-')),
mkdtempSync(join(tmpdir(), 'orca-codex-enterprise-'))
]
const email = 'same@example.com'
const credentials = ['plus', 'enterprise'].map((plan) => {
const parsed = JSON.parse(createCodexAuthJson(email, `provider-${plan}`, `refresh-${plan}`))
const payload = Buffer.from(
JSON.stringify({
email,
'https://api.openai.com/auth': {
chatgpt_account_id: `provider-${plan}`,
chatgpt_plan_type: plan
}
})
).toString('base64url')
parsed.tokens.id_token = `header.${payload}.signature`
return JSON.stringify(parsed)
})
try {
sourceHomes.forEach((home, index) => {
writeFileSync(join(home, 'auth.json'), credentials[index], 'utf-8')
})
const store = createStore(createSettings())
const runtimeHome = createRuntimeHome()
const { CodexAccountService } = await import('./service')
const service = new CodexAccountService(
store as never,
createRateLimits() as never,
runtimeHome as never
)
await service.addAccountFromHome(sourceHomes[0])
const result = await service.addAccountFromHome(sourceHomes[1])
const accounts = store.getSettings().codexManagedAccounts
expect(result.accounts).toHaveLength(2)
expect(new Set(accounts.map((account) => account.id)).size).toBe(2)
expect(new Set(accounts.map((account) => account.managedHomePath)).size).toBe(2)
expect(accounts.map((account) => account.email)).toEqual([email, email])
expect(accounts.map((account) => account.workspaceLabel)).toEqual([
'Personal (Plus)',
'Enterprise'
])
expect(accounts.map((account) => account.providerAccountId)).toEqual([
'provider-plus',
'provider-enterprise'
])
for (const account of accounts) {
const selected = await service.selectAccount(account.id)
expect(selected.activeAccountId).toBe(account.id)
expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe(account.id)
accounts.forEach((entry, index) => {
expect(readFileSync(join(entry.managedHomePath, 'auth.json'), 'utf-8')).toBe(
credentials[index]
)
})
}
expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(4)
} finally {
sourceHomes.forEach((home) => rmSync(home, { recursive: true, force: true }))
vi.doUnmock('../codex-cli/command')
}
})
it('registers a managed Codex account by importing an authenticated CODEX_HOME', async () => {
vi.doMock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' }))
const sourceHome = mkdtempSync(join(tmpdir(), 'orca-codex-source-'))
@@ -11,7 +11,8 @@ import {
runCodexHookTrustGrantSession,
type CodexHookTrustGrantRequest
} from './codex-app-server-client'
import { killCodexAppServerProcessTree, runCodexAppServerSession } from './codex-app-server-session'
import { killCodexAppServerProcessTree } from './codex-app-server-process-tree-kill'
import { runCodexAppServerSession } from './codex-app-server-session'
// Stub codex app-server speaking the same JSONL protocol: initialize →
// initialized → hooks/list → config/batchWrite → hooks/list. Scenario-driven
+8 -2
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process'
import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec'
import { spawnProcess } from '../../shared/child-process/run-process'
import { normalizeHookTrustKeyForLookup } from './config-toml-trust'
import { runCodexAppServerSession, type CodexAppServerInvocation } from './codex-app-server-session'
@@ -105,7 +106,12 @@ function collectHookListings(result: unknown): CodexHookListing[] {
*/
export async function runCodexHookTrustGrantSession(
request: CodexHookTrustGrantRequest,
spawnImpl: typeof spawn = spawn
spawnImpl: (
program: string,
args: string[],
options: Record<string, unknown>
) => ChildProcessHandle = (program, args, options) =>
spawnProcess({ program, args, ...options } as ProcessSpec)
): Promise<CodexHookTrustGrantSessionResult> {
return runCodexAppServerSession(
request.invocation,
@@ -0,0 +1,76 @@
import { spawnProcess } from '../../shared/child-process/run-process'
import type { ChildProcessHandle, ProcessSpec } from '../../shared/child-process/process-spec'
import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate'
/** Spawn seam for tests; production always goes through the hardened spawnProcess wrapper. */
export type CodexAppServerSpawn = (
program: string,
args: string[],
options: Record<string, unknown>
) => ChildProcessHandle
export const spawnCodexAppServerProcess: CodexAppServerSpawn = (program, args, options) =>
spawnProcess({ program, args, ...options } as ProcessSpec)
export function killCodexAppServerProcessTree(
child: Pick<ChildProcessHandle, 'pid' | 'kill'>,
options: { platform?: NodeJS.Platform; spawnImpl?: CodexAppServerSpawn } = {}
): void {
const platform = options.platform ?? process.platform
const spawnImpl = options.spawnImpl ?? spawnCodexAppServerProcess
if (platform === 'win32' && child.pid) {
if (
!admitProcessTreeKill({
pid: child.pid,
site: 'codex-app-server-session-deadline',
scope: 'win-taskkill-tree'
})
) {
// Refusal blocks the tree walk, not the termination: the root kill is
// handle-addressed, so it cannot reach the recycled pid we refused.
child.kill('SIGKILL')
return
}
try {
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
// leaves the app-server child alive after a timeout or failed shutdown.
const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
let fellBack = false
const killDirectChild = (): void => {
if (!fellBack) {
fellBack = true
child.kill('SIGKILL')
}
}
killer.on('error', killDirectChild)
killer.on('exit', (code) => {
if (code !== 0) {
killDirectChild()
}
})
killer.unref()
return
} catch {
// Fall through to the direct-child best effort when taskkill cannot start.
}
}
if (child.pid) {
try {
// npm/package-manager launchers insert a shim child on POSIX. Reap its
// direct descendants before signalling the wrapper itself.
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
stdio: 'ignore'
})
// A missing pkill surfaces as an async 'error' event, and an unhandled one
// takes down the main process.
descendants.on('error', () => undefined)
descendants.unref()
} catch {
// The direct kill below remains the fallback when pkill is unavailable.
}
}
child.kill('SIGKILL')
}
+7 -66
View File
@@ -1,9 +1,13 @@
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from 'node:child_process'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { waitForProcessExitUntil } from './codex-process-exit-deadline'
import { stderrIndicatesMissingAppServer } from './codex-app-server-capability-signal'
import { withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
import {
killCodexAppServerProcessTree,
spawnCodexAppServerProcess,
type CodexAppServerSpawn
} from './codex-app-server-process-tree-kill'
import { createCodexAppServerRecordReader } from './codex-app-server-record-reader'
import { admitProcessTreeKill } from '../../shared/child-process/process-tree-kill-gate'
// Why: `codex app-server` is Orca's sanctioned RPC surface into Codex-owned
// state (hook trust hashes, the sqlite thread index). This module owns the
@@ -68,69 +72,6 @@ export type CodexAppServerRpc = {
const JSON_RPC_METHOD_NOT_FOUND = -32601
const STDERR_TAIL_MAX_BYTES = 8192
export function killCodexAppServerProcessTree(
child: Pick<ChildProcess, 'pid' | 'kill'>,
options: { platform?: NodeJS.Platform; spawnImpl?: typeof spawn } = {}
): void {
const platform = options.platform ?? process.platform
const spawnImpl = options.spawnImpl ?? spawn
if (platform === 'win32' && child.pid) {
if (
!admitProcessTreeKill({
pid: child.pid,
site: 'codex-app-server-session-deadline',
scope: 'win-taskkill-tree'
})
) {
// Refusal blocks the tree walk, not the termination: the root kill is
// handle-addressed, so it cannot reach the recycled pid we refused.
child.kill('SIGKILL')
return
}
try {
// Why: npm-installed Codex runs behind cmd.exe; killing only that wrapper
// leaves the app-server child alive after a timeout or failed shutdown.
const killer = spawnImpl('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
let fellBack = false
const killDirectChild = (): void => {
if (!fellBack) {
fellBack = true
child.kill('SIGKILL')
}
}
killer.on('error', killDirectChild)
killer.on('exit', (code) => {
if (code !== 0) {
killDirectChild()
}
})
killer.unref()
return
} catch {
// Fall through to the direct-child best effort when taskkill cannot start.
}
}
if (child.pid) {
try {
// npm/package-manager launchers insert a shim child on POSIX. Reap its
// direct descendants before signalling the wrapper itself.
const descendants = spawnImpl('pkill', ['-KILL', '-P', String(child.pid)], {
stdio: 'ignore'
})
// A missing pkill surfaces as an async 'error' event, and an unhandled one
// takes down the main process.
descendants.on('error', () => undefined)
descendants.unref()
} catch {
// The direct kill below remains the fallback when pkill is unavailable.
}
}
child.kill('SIGKILL')
}
/** Codex answering "no such method" is the only response that proves the RPC
* surface is absent rather than temporarily failing. */
export function isCodexMethodNotFoundError(error: unknown): boolean {
@@ -152,7 +93,7 @@ export function isCodexMethodNotFoundError(error: unknown): boolean {
export async function runCodexAppServerSession<T>(
invocation: CodexAppServerInvocation,
body: (rpc: CodexAppServerRpc) => Promise<T>,
spawnImpl: typeof spawn = spawn
spawnImpl: CodexAppServerSpawn = spawnCodexAppServerProcess
): Promise<T> {
// Why: a default-home grant must run against the real ~/.codex, so strip an
// inherited CODEX_HOME (envToDelete) after applying the overlay, not before.
@@ -1,3 +1,5 @@
import { toolExecutionMetadata } from '../../shared/native-chat-tool-identity'
export const MAX_CODEX_ITEM_STREAM_STATES = 256
export const MAX_CODEX_ITEM_STREAM_PENDING_PATCHES = 128
export const MAX_CODEX_ITEM_STREAM_RETAINED_BYTES = 32 * 1024 * 1024
@@ -31,6 +33,6 @@ export function boundStreamItem(item: Record<string, unknown>): Record<string, u
...(typeof item.command === 'string' ? { command: item.command.slice(0, 4096) } : {}),
...(typeof item.cwd === 'string' ? { cwd: item.cwd.slice(0, 4096) } : {}),
...(typeof item.status === 'string' ? { status: item.status } : {}),
...(typeof item.exitCode === 'number' ? { exitCode: item.exitCode } : {})
...toolExecutionMetadata(item)
}
}
@@ -202,6 +202,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'shell',
input: { command: 'ls', cwd: '/tmp' },
exitCode: 0,
state: 'completed',
output: { head: 'a\nb\n', byteLength: 4, truncated: false, digest: expect.any(String) }
})
@@ -231,6 +232,7 @@ describe('codex item bodies', () => {
// `name` is the target's basename, which `path` already carries and no
// label ever reads, so it stays out of the bounded journal payload.
input: { command: "sed -n '1,200p' notes.txt", cwd: '/repo', path: '/repo/notes.txt' },
exitCode: 0,
state: 'completed'
})
// `read` is the one class that keeps `path`, so its row stays a tappable
@@ -275,6 +277,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'search',
input: { command: 'rg beta', cwd: '/repo' },
exitCode: 0,
state: 'completed'
})
})
@@ -294,6 +297,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'list',
input: { command: 'ls', cwd: '/repo' },
exitCode: 0,
state: 'completed'
})
// A stand-in `.` reaches mobile as a tappable "open file" link onto a
@@ -323,6 +327,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'shell',
input: { command: 'cat a.txt && ls src', cwd: '/repo' },
exitCode: 0,
state: 'completed'
})
})
@@ -345,6 +350,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'read',
input: { command: 'cat a.ts && cat b.ts', cwd: '/repo' },
exitCode: 0,
state: 'completed'
})
})
@@ -419,6 +425,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'read',
input: { command: 'cat', cwd: '/repo' },
exitCode: 0,
state: 'completed'
})
})
@@ -445,6 +452,7 @@ describe('codex item bodies', () => {
kind: 'tool-call',
name: 'shell',
input: { command: 'ls', cwd: '/tmp' },
exitCode: 0,
state: 'completed'
}
const base = {
@@ -606,6 +614,7 @@ describe('codex item bodies', () => {
// Server-qualified, and the arguments stay top level so the row label can
// read `query`/`command`/`file_path` out of them.
name: 'weather/get_forecast',
mcpIdentity: { server: 'weather', tool: 'get_forecast' },
input: { city: 'Oslo' },
state: 'completed',
output: { head: '12C', byteLength: 3, truncated: false, digest: expect.any(String) }
@@ -784,7 +793,7 @@ describe('codex item bodies', () => {
}
})
it('leaves subagent items on the generic row until a real renderer exists', () => {
it('drops the raw subagent item now the roster row renders it', () => {
expect(
codexJournalItem({
type: 'subAgentActivity',
@@ -793,10 +802,7 @@ describe('codex item bodies', () => {
agentThreadId: 'thread-child',
agentPath: '/root/list_directory'
})
).toMatchObject({
handled: false,
body: { kind: 'status', providerFrame: { kind: 'item:subAgentActivity' } }
})
).toMatchObject({ handled: true, body: null })
})
it('drops the sleep item, which codex itself renders as nothing', () => {
@@ -1,3 +1,4 @@
import { toolExecutionMetadata, toolWebSearchResults } from '../../shared/native-chat-tool-identity'
import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types'
import type { NativeChatBlock } from '../../shared/native-chat-types'
import {
@@ -97,6 +98,7 @@ function commandItem(item: CodexThreadItem): CodexJournalItem {
DEFAULT_JOURNAL_PAYLOAD_LIMITS
),
state: commandState(item),
...toolExecutionMetadata(item),
...(bounded === null ? {} : { output: bounded.bounded })
},
handled: true
@@ -159,6 +161,8 @@ function mcpToolArguments(value: unknown): unknown {
}
function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem {
const server = readString(item, 'server')
const tool = readString(item, 'tool')
const failure = readString(readRecord(item.error), 'message')
const text = failure ?? readTextContent(readRecord(item.result), 'content')
const bounded = text === null ? null : boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS)
@@ -166,6 +170,7 @@ function mcpToolCallItem(item: CodexThreadItem): CodexJournalItem {
body: {
kind: 'tool-call',
name: mcpToolCallName(item),
...(server && tool ? { mcpIdentity: { server, tool } } : {}),
input: boundToolInput(mcpToolArguments(item.arguments), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: failure === null ? commandState(item) : 'failed',
...(bounded === null ? {} : { output: bounded.bounded })
@@ -200,12 +205,14 @@ function webSearchInput(item: CodexThreadItem): Record<string, unknown> | null {
* completed item's own `query` is routinely still empty. The hits arrive on
* `results` and are the call's output. */
function webSearchItem(item: CodexThreadItem): CodexJournalItem {
const results = toolWebSearchResults(item.results)
const hits = Array.isArray(item.results) && item.results.length > 0 ? item.results : null
const bounded = hits && boundInlineText(JSON.stringify(hits), DEFAULT_JOURNAL_PAYLOAD_LIMITS)
return {
body: {
kind: 'tool-call',
name: 'web_search',
...(results.length > 0 ? { webSearchResults: results } : {}),
input: boundToolInput(webSearchInput(item), DEFAULT_JOURNAL_PAYLOAD_LIMITS),
state: item.action === null || item.action === undefined ? 'running' : 'completed',
...(bounded === null ? {} : { output: bounded.bounded })
@@ -7,3 +7,10 @@ export const MAX_CODEX_PENDING_PROMPTS = 128
export const MAX_CODEX_IDENTITY_ENTRIES = 512
export const MAX_CODEX_DETAIL_ENTRIES = 512
export const MAX_CODEX_DETAIL_BYTES = 64 * 1024
/** Spawn-group rows kept live per session, and children per row. Both bound an
* event-accumulated map that no provider snapshot ever prunes. */
export const MAX_CODEX_SUBAGENT_GROUPS = 32
export const MAX_CODEX_SUBAGENTS_PER_GROUP = 64
/** Threads whose latest token total is retained. Usage frames arrive for
* threads that are not yet (or never become) roster children. */
export const MAX_CODEX_TOKEN_USAGE_THREADS = 256
@@ -0,0 +1,43 @@
/**
* The translator's provider-frame arms.
*
* Each returns null for a frame it does not own, which is the translator's
* signal to keep looking. Split out so the translator reads as routing rather
* than as the shape checks each arm performs.
*/
import type { CodexJournalTranslationAdmission } from './codex-structured-journal-contracts'
import { settleCodexOversizedNotification } from './codex-structured-journal-settlement'
import {
readCodexJournalRecord,
readCodexJournalString
} from './codex-structured-journal-translation-values'
type OversizedInput = Parameters<typeof settleCodexOversizedNotification>[0]
/** A notification the transport refused to carry whole: settle whatever it
* opened rather than leaving the item mid-flight. */
export function settleCodexOversizedNotificationFrame(input: {
sessionId: string
threadId: string
kind: string
payload: unknown
sink: OversizedInput['sink']
streams: OversizedInput['streams']
activeItems: OversizedInput['activeItems']
}): CodexJournalTranslationAdmission | null {
if (input.kind !== 'frame:oversized-notification') {
return null
}
const method = readCodexJournalString(readCodexJournalRecord(input.payload), 'method')
return method
? settleCodexOversizedNotification({
sessionId: input.sessionId,
threadId: input.threadId,
method,
sink: input.sink,
streams: input.streams,
activeItems: input.activeItems
})
: null
}
@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { AgentSessionTurnActivity } from '../../shared/agent-session-wire'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { isSubagentGroupBlock } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
const SESSION_ID = 'session-1'
const THREAD_ID = 'thread-abc'
const TURN_ID = 'turn-1'
type Row = { key: string; body: AgentJournalItemBody }
function harness() {
const rows: Row[] = []
const activities: (AgentSessionTurnActivity | null)[] = []
const sink: StructuredAgentSessionEventSink = {
appendItem: (identity: AgentJournalItemIdentity, body) =>
rows.push({ key: agentJournalItemKey(identity), body }),
appendTombstone: () => {},
publish: () => {},
setActivity: (activity) => activities.push(activity)
}
const translator = createCodexJournalTranslator({
sink,
primaryThreadId: () => THREAD_ID,
schedule: (run: () => void) => {
run()
return () => {}
}
})
return { translator, rows, activities }
}
function notification(method: string, params: unknown): CodexStructuredSessionEvent {
return { type: 'notification', sessionId: SESSION_ID, threadId: THREAD_ID, method, params }
}
function subagentItem(kind: string, agentThreadId: string, agentPath: string): unknown {
return {
turnId: TURN_ID,
item: {
type: 'subAgentActivity',
id: `item-${agentThreadId}-${kind}`,
kind,
agentThreadId,
agentPath
}
}
}
/** Every activity item reaches the wire twice. */
function deliverActivity(
translator: ReturnType<typeof createCodexJournalTranslator>,
params: unknown
): void {
translator.handle(notification('item/started', params))
translator.handle(notification('item/completed', params))
}
function rosterAgents(rows: Row[]): { id: string; state: string; tokens?: number }[] {
const body = rows.findLast((row) => row.key.startsWith('orca:codex-subagents'))?.body
if (!body || body.kind !== 'message') {
return []
}
return body.blocks.find(isSubagentGroupBlock)?.agents ?? []
}
describe('codex journal translation — subagents', () => {
it('renders a spawn group as one roster row and no opcode-shaped duplicate', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/list_directory'))
deliverActivity(translator, subagentItem('interacted', 'child-1', '/root/list_directory'))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', label: 'list_directory', state: 'working' }
])
// Four wire deliveries (two items, each sent twice) collapse to ONE roster
// row, and none of the gray `codex · item:subAgentActivity` rows survive.
const providerFrameKinds = rows.flatMap((row) =>
row.body.kind === 'status' && row.body.providerFrame ? [row.body.providerFrame.kind] : []
)
expect(providerFrameKinds).toEqual([])
expect(rows.filter((row) => row.key.startsWith('orca:codex-subagents'))).toHaveLength(1)
})
// The roster claims the item, but claiming it must not take the turn tail with
// it: the activity table is reached only through the publish arm, so a bare
// return leaves the tail stuck on whatever the previous frame said.
it('still publishes the turn tail for an item the roster claims', () => {
const { translator, activities } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
activities.length = 0
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
expect(activities.at(-1)).toEqual({
turnId: TURN_ID,
text: 'Coordinating with another agent'
})
})
it('consumes thread/tokenUsage/updated instead of swallowing it as chrome', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
translator.handle(
notification('thread/tokenUsage/updated', {
threadId: 'child-1',
tokenUsage: { total: { totalTokens: 40661 } }
})
)
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', tokens: 40661 }])
})
// The QA scenario this row got wrong: three `spawn_agent` children were still
// running when a mid-turn correction ended their turn and opened a new one.
// They reported `completed` 57-87s later, so a turn boundary is a fact about
// the turn and never evidence that contact with a child was lost.
it('leaves children working when their turn ends and a newer turn opens', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read_readme'))
deliverActivity(translator, subagentItem('started', 'child-2', '/root/read_package'))
translator.handle(notification('turn/completed', { turn: { id: TURN_ID } }))
translator.handle(notification('turn/started', { turn: { id: 'turn-2' } }))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', state: 'working' },
{ id: 'child-2', state: 'working' }
])
// And the verdict a child reports after its turn ended still lands on the row.
deliverActivity(translator, subagentItem('completed', 'child-1', '/root/read_readme'))
expect(rosterAgents(rows)).toMatchObject([
{ id: 'child-1', state: 'completed' },
{ id: 'child-2', state: 'working' }
])
})
it('sweeps every group when the provider ends', () => {
const { translator, rows } = harness()
translator.handle(notification('turn/started', { turn: { id: TURN_ID } }))
deliverActivity(translator, subagentItem('started', 'child-1', '/root/read'))
translator.handle({
type: 'ended',
sessionId: SESSION_ID,
reason: 'provider exited',
cause: 'unexpected-exit',
fence: 1,
acquisitionGeneration: 'gen-1'
} as CodexStructuredSessionEvent)
expect(rosterAgents(rows)).toMatchObject([{ id: 'child-1', state: 'unverifiable' }])
})
})
@@ -1,4 +1,10 @@
import { createCodexProviderActivityReader } from '../native-chat/agent-session-wire/provider-frame-activity'
import {
CODEX_TOKEN_USAGE_METHOD,
readCodexNotificationThreadItem
} from './codex-subagent-activity'
import { CodexSubagentRoster } from './codex-subagent-roster'
import { readCodexThreadItem } from './codex-structured-item-translation'
import { CodexJournalGenericFrames } from './codex-structured-journal-generic-frames'
import { CodexJournalItems } from './codex-structured-journal-items'
import { CodexJournalPrompts } from './codex-structured-journal-prompts'
@@ -10,16 +16,12 @@ import {
} from './codex-structured-journal-contracts'
import {
settleCodexJournalSession,
settleCodexJournalTurn,
settleCodexOversizedNotification
settleCodexJournalTurn
} from './codex-structured-journal-settlement'
import { settleCodexOversizedNotificationFrame } from './codex-structured-journal-translation-frames'
import { restoreCodexJournalThread } from './codex-structured-journal-translation-restore'
import { CodexJournalActiveTurns } from './codex-structured-journal-translation-turn-state'
import { publishCodexTurnLifecycle } from './codex-structured-journal-translation-turns'
import {
readCodexJournalRecord,
readCodexJournalString
} from './codex-structured-journal-translation-values'
import { readCodexTurnId } from './codex-structured-thread-facts'
import type { CodexStructuredSessionEvent } from './codex-structured-session-adapter'
@@ -55,6 +57,11 @@ export function createCodexJournalTranslator(
const prompts = new CodexJournalPrompts(deps, (threadId, itemId) =>
items.detailFor(threadId, itemId)
)
const subagents = new CodexSubagentRoster({
sink: deps.sink,
primaryThreadId: () => deps.primaryThreadId?.() ?? null,
activeTurn: (threadId) => activeTurns.current(threadId)
})
const flushStreams = (): CodexJournalTranslationAdmission =>
items.streams.flush() ? CODEX_JOURNAL_ADMITTED : { accepted: false, reason: 'backpressure' }
let readActivity = createCodexProviderActivityReader()
@@ -118,6 +125,11 @@ export function createCodexJournalTranslator(
if (!admission.accepted) {
return admission
}
// No event will ever settle a child once the provider is gone.
const sweep = subagents.settleSession()
if (!sweep.accepted) {
return sweep
}
readActivity = createCodexProviderActivityReader()
deps.sink.setActivity?.(null)
items.activeItems.clear()
@@ -159,7 +171,30 @@ export function createCodexJournalTranslator(
if (event.method === 'turn/completed') {
return completeTurn(event)
}
if (event.method === CODEX_TOKEN_USAGE_METHOD) {
// Classified `status-chrome`, so the generic-frame path swallows it
// before the journal. The roster consumes it as a typed notification.
const admission = subagents.handleTokenUsage(event.params)
if (admission) {
return admission
}
}
if (event.method === 'item/started' || event.method === 'item/completed') {
const subagentItem = readCodexNotificationThreadItem(event.params, readCodexThreadItem)
// Null means the roster did not claim it; fall through to normal item
// handling. Returning here unconditionally swallows every other item.
const subagentAdmission = subagentItem
? subagents.handleItem({
threadId: event.threadId,
turnId: readCodexTurnId(event.params) ?? activeTurns.current(event.threadId),
item: subagentItem
})
: null
if (subagentAdmission) {
// Not a bare return: the roster claiming the item must not skip the
// turn-tail arm, which is the only publisher of its activity copy.
return publishActivity(event, subagentAdmission)
}
const translated = items.handle(event)
return publishActivity(
event,
@@ -186,30 +221,25 @@ export function createCodexJournalTranslator(
items.dispose()
prompts.dispose()
genericFrames.dispose()
subagents.dispose()
activeTurns.clear()
}
}
/** Settles the item a notification the transport refused to carry left
* mid-flight; null when the frame is not one. */
function settleOversizedNotification(event: {
sessionId: string
threadId: string
kind: string
payload: unknown
}): CodexJournalTranslationAdmission | null {
if (event.kind !== 'frame:oversized-notification') {
return null
}
const method = readCodexJournalString(readCodexJournalRecord(event.payload), 'method')
return method
? settleCodexOversizedNotification({
sessionId: event.sessionId,
threadId: event.threadId,
method,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
: null
return settleCodexOversizedNotificationFrame({
...event,
sink: deps.sink,
streams: items.streams,
activeItems: items.activeItems
})
}
function startTurn(event: {
@@ -255,6 +285,12 @@ export function createCodexJournalTranslator(
if (!turnId) {
return CODEX_JOURNAL_ADMITTED
}
// The roster is deliberately NOT swept here. `spawn_agent` children outlive
// the turn that spawned them and go on reporting into the same group, so a
// turn boundary is no evidence contact was lost — and `turn/completed` is
// the only turn-end notification Codex sends, so an abort cannot be told
// apart from a clean finish either. Only `settleSession` may write
// `unverifiable`.
const admission = settleCodexJournalTurn({
sink: deps.sink,
sessionId: event.sessionId,
@@ -44,7 +44,8 @@ function resolverFor(
store: { getRecord: () => value } as unknown as AgentSessionRecordStore,
resolveWorkspacePath,
resolveCommand: () => '/usr/local/bin/codex',
resolveRollout
resolveRollout,
isWindowsProcessStartTimeAvailable: () => true
})
}
@@ -68,7 +69,8 @@ describe('codex structured launch resolution', () => {
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
resolveWorkspacePath: async () => String.raw`C:\workspaces\orca`,
resolveCommand: () => command
resolveCommand: () => command,
isWindowsProcessStartTimeAvailable: () => true
})
await expect(resolveLaunch({ identity: IDENTITY })).resolves.toMatchObject({
@@ -78,6 +80,22 @@ describe('codex structured launch resolution', () => {
})
})
it('fails closed before resolving a Windows launch without creation-time proof', async () => {
await withPlatform('win32', async () => {
const resolveWorkspacePath = vi.fn(async () => String.raw`C:\workspaces\orca`)
const resolveLaunch = createCodexStructuredLaunchResolver({
store: { getRecord: () => record() } as unknown as AgentSessionRecordStore,
resolveWorkspacePath,
isWindowsProcessStartTimeAvailable: () => false
})
await expect(resolveLaunch({ identity: IDENTITY })).rejects.toThrow(
'Windows process creation-time proof'
)
expect(resolveWorkspacePath).not.toHaveBeenCalled()
})
})
it('resumes the last thread this session actually proved, not one a caller names', async () => {
const launch = await resolverFor(
record({
@@ -13,6 +13,7 @@ import { resolveCodexCommand } from '../codex-cli/command'
import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store'
import type { CodexStructuredLaunch } from './codex-structured-session-adapter'
import { resolvePinnedCodexRolloutProof } from './codex-tui-rollout-proof'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
export type CodexStructuredLaunchResolverDeps = {
store: AgentSessionRecordStore
@@ -24,6 +25,8 @@ export type CodexStructuredLaunchResolverDeps = {
/** Fresh shell/configured environment for this spawn; never written to the session record. */
resolveEnvironment?: () => Promise<NodeJS.ProcessEnv>
resolveRollout?: typeof resolvePinnedCodexRolloutProof
/** Test seam for the host capability; production uses the native process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
}
export function createCodexStructuredLaunchResolver(
@@ -46,6 +49,13 @@ export function createCodexStructuredLaunchResolver(
`codex structured sessions run on the local host, not ${location.executionHostId}`
)
}
// Refuse before resolving launch data; a PID alone cannot prove Windows ownership.
if (
process.platform === 'win32' &&
!(deps.isWindowsProcessStartTimeAvailable ?? isWindowsProcessStartTimeAvailable)()
) {
throw new Error('codex structured sessions require Windows process creation-time proof')
}
if (accountHome.variable !== 'CODEX_HOME') {
throw new Error(`codex sessions pin CODEX_HOME, not ${accountHome.variable}`)
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { supportsCodexStructuredLocation } from './codex-structured-location-support'
const LOCAL_WINDOWS_LOCATION: AgentSessionExecutionLocation = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
}
const WSL_WINDOWS_LOCATION: AgentSessionExecutionLocation = {
...LOCAL_WINDOWS_LOCATION,
wslDistro: 'Ubuntu'
}
function withPlatform<T>(platform: NodeJS.Platform, run: () => T): T {
const original = process.platform
Object.defineProperty(process, 'platform', { configurable: true, value: platform })
try {
return run()
} finally {
Object.defineProperty(process, 'platform', { configurable: true, value: original })
}
}
describe('Codex structured location support', () => {
it('uses the injected Windows identity capability for location admission', () => {
let proofAvailable = false
withPlatform('win32', () => {
expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe(
false
)
proofAvailable = true
expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => proofAvailable)).toBe(
true
)
})
})
it('rejects WSL locations while retaining native folder support on Windows', () => {
withPlatform('win32', () => {
expect(supportsCodexStructuredLocation(WSL_WINDOWS_LOCATION, () => true)).toBe(false)
expect(supportsCodexStructuredLocation(LOCAL_WINDOWS_LOCATION, () => true)).toBe(true)
})
})
})
@@ -2,10 +2,14 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
export function supportsCodexStructuredLocation(location: AgentSessionExecutionLocation): boolean {
export function supportsCodexStructuredLocation(
location: AgentSessionExecutionLocation,
// Injected by the adapter, which owns this dep for every other Codex gate too.
hasWindowsProcessStartTimeProof: () => boolean = isWindowsProcessStartTimeAvailable
): boolean {
return (
location.executionHostId === LOCAL_EXECUTION_HOST_ID &&
location.wslDistro === null &&
(process.platform !== 'win32' || isWindowsProcessStartTimeAvailable())
(process.platform !== 'win32' || hasWindowsProcessStartTimeProof())
)
}
@@ -0,0 +1,334 @@
import { describe, expect, it, vi } from 'vitest'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
import { openCodexThread } from './codex-structured-thread-open'
function fixture(reverted = true) {
const request = vi.fn(async (method: string): Promise<unknown> => {
if (method === 'thread/read') {
return { thread: { id: 'thread', historyMode: 'paginated', status: { type: 'idle' } } }
}
if (method === 'thread/revert') {
reverted = true
return {
thread: { id: 'thread', turns: [] },
turnsBackwardsCursor: 'turn-cursor',
itemsBackwardsCursor: 'item-cursor'
}
}
if (method === 'thread/turns/list') {
return { data: [...(reverted ? [] : [{ id: 'drop' }]), { id: 'kept' }], nextCursor: null }
}
return {
data: [
{
turnId: 'kept',
item: {
id: 'item-1',
type: 'userMessage',
content: [{ type: 'text', text: 'kept prompt' }]
}
}
],
nextCursor: null
}
})
const session = {
connection: { request },
threadId: 'thread',
fence: 2,
ended: false,
historyMode: 'paginated',
activeTurnIds: new Set()
} as unknown as CodexSession
return { request, session }
}
describe('Codex rewind', () => {
it('recovers verified history from fresh cursors without repeating revert', async () => {
const { session, request } = fixture()
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
ok: true,
items: [{ body: { kind: 'message', blocks: [{ type: 'text', text: 'kept prompt' }] } }]
})
expect(request.mock.calls.map(([method]) => method)).toEqual([
'thread/read',
'thread/turns/list',
'thread/items/list'
])
for (const method of ['thread/turns/list', 'thread/items/list']) {
expect(request).toHaveBeenCalledWith(
method,
expect.objectContaining({ cursor: null, sortDirection: 'desc' }),
expect.anything()
)
}
})
it('recognizes an unapplied rewind from the still-present target', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) =>
method === 'thread/turns/list'
? { data: [{ id: 'drop' }, { id: 'kept' }], nextCursor: null }
: original(method)
)
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'provider-refused'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it.each(['cycle', 'pages', 'entries', 'bytes'] as const)(
'bounds recovery by %s and never returns partial history',
async (limit) => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
let pages = 0
request.mockImplementation(async (method) => {
if (method !== 'thread/turns/list') {
return original(method)
}
pages++
if (limit === 'entries') {
return {
data: Array.from({ length: 1025 }, (_, i) => ({ id: String(i) })),
nextCursor: null
}
}
if (limit === 'bytes') {
return {
data: [],
padding: 'x'.repeat(AGENT_SESSION_HISTORY_MAX_PAGE_BYTES),
nextCursor: null
}
}
return { data: [], nextCursor: limit === 'cycle' ? 'repeated' : String(pages) }
})
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'history-limit'
)
expect(pages).toBeLessThanOrEqual(100)
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
}
)
it('keeps an interrupted recovery retryable with read-only requests', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/items/list') {
throw new Error('offline')
}
return original(method)
})
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'offline'
)
request.mockImplementation(original)
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
ok: true
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('refuses activity arriving during recovery hydration', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/items/list') {
session.activeTurnIds!.add('racing-turn')
}
return original(method)
})
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
})
it('uses native revert and reads both retained indexes despite empty response turns', async () => {
const { session, request } = fixture(false)
const onPrepared = vi.fn<NonNullable<Parameters<typeof rewindCodexSession>[1]['onPrepared']>>(
async (items) => {
expect(items).toMatchObject([{ identity: { turnId: 'kept' } }])
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
}
)
expect(
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onPrepared })
).toMatchObject({
ok: true,
items: [{ body: { kind: 'message' } }]
})
expect(onPrepared).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'thread/revert',
{ threadId: 'thread', beforeTurnId: 'drop' },
{ timeoutMs: undefined }
)
expect(request).toHaveBeenCalledWith(
'thread/turns/list',
expect.objectContaining({ cursor: 'turn-cursor', sortDirection: 'desc' }),
expect.anything()
)
expect(request).toHaveBeenCalledWith(
'thread/items/list',
expect.objectContaining({ cursor: 'item-cursor', sortDirection: 'desc' }),
expect.anything()
)
})
it('refuses a known legacy thread before making a request', async () => {
const { session, request } = fixture()
session.historyMode = 'legacy'
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'history-not-paginated'
})
expect(request).not.toHaveBeenCalled()
})
it('refuses history exceeding hydration capacity before mutating the provider', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
const turns = Array.from({ length: 600 }, (_, i) => String(i))
request.mockImplementation(async (method) => {
if (method === 'thread/turns/list') {
return { data: [{ id: 'drop' }, ...turns.map((id) => ({ id }))], nextCursor: null }
}
if (method === 'thread/items/list') {
return {
data: turns.map((turnId) => ({
turnId,
item: { id: turnId, type: 'userMessage', content: [{ type: 'text', text: 'x' }] }
})),
nextCursor: null
}
}
return original(method)
})
const onReverted = vi.fn()
expect(
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted })
).toEqual({ ok: false, reason: 'history-limit' })
expect(onReverted).not.toHaveBeenCalled()
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('refuses a missing target before mutation', async () => {
const { session, request } = fixture()
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'missing' })).toEqual({
ok: false,
reason: 'invalid-target'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('rechecks provider idleness after preflight hydration', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reads = 0
request.mockImplementation(async (method) => {
if (method === 'thread/read' && ++reads === 2) {
return { thread: { id: 'thread', status: { type: 'active' } } }
}
return original(method)
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('maps native legacy refusal without exposing provider text or falling back', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/read') {
return { thread: { id: 'thread', status: { type: 'idle' } } }
}
if (method !== 'thread/revert') {
return original(method)
}
throw new CodexAppServerRequestError(
'thread/revert',
-32600,
'thread/revert only supports paginated threads'
)
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'history-not-paginated'
})
expect(request.mock.calls.map(([method]) => method)).toEqual([
'thread/read',
'thread/turns/list',
'thread/items/list',
'thread/read',
'thread/revert'
])
})
it('refuses activity arriving during the preflight await', async () => {
const { session, request } = fixture()
request.mockImplementationOnce(async () => {
session.activeTurnIds!.add('racing-turn')
return { thread: { id: 'thread', status: { type: 'idle' } } }
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
expect(request).toHaveBeenCalledTimes(1)
})
it('treats hydration failure after revert as unknown and never retries revert', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reverted = false
request.mockImplementation(async (method) => {
if (method === 'thread/revert') {
reverted = true
}
if (method === 'thread/items/list' && reverted) {
throw new Error('offline')
}
return original(method)
})
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'offline'
)
expect(request.mock.calls.filter(([method]) => method === 'thread/revert')).toHaveLength(1)
})
it('captures history mode at both start and resume without changing defaults', async () => {
for (const resumeThreadId of [null, 'thread']) {
const request = vi.fn(async (_method: string, _params?: unknown) => ({
thread: { id: 'thread', historyMode: 'legacy' }
}))
expect(
await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10)
).toMatchObject({ historyMode: 'legacy' })
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode')
}
})
it('rejects post-revert history missing an item within a retained turn', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reverted = false
request.mockImplementation(async (method) => {
if (method === 'thread/revert') {
reverted = true
}
if (method === 'thread/items/list' && !reverted) {
return {
data: [2, 1].map((i) => ({
turnId: 'kept',
item: {
id: `item-${i}`,
type: 'userMessage',
content: [{ type: 'text', text: `prompt ${i}` }]
}
})),
nextCursor: null
}
}
return original(method)
})
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'proof-mismatch'
)
})
})
+309
View File
@@ -0,0 +1,309 @@
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { CODEX_RESTORE_MAX_OPERATIONS } from './codex-structured-journal-translation-restore'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
import { isCodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
const MAX_PAGES = 100
const MAX_ENTRIES = CODEX_RESTORE_MAX_OPERATIONS
class CodexRewindTargetRetainedError extends Error {}
class CodexRewindTargetMissingError extends Error {}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('agent_session_rewind:invalid-provider-response')
}
return value as Record<string, unknown>
}
function cursor(value: unknown): string | null {
if (value === null || (typeof value === 'string' && value.length > 0)) {
return value
}
throw new Error('agent_session_rewind:invalid-provider-cursor')
}
/** Read both indexes to completion before accepting the retained history. */
export async function verifyCodexRevertedHistory(
session: Pick<CodexSession, 'connection' | 'threadId'>,
reply: Record<string, unknown>,
beforeTurnId: string,
timeoutMs?: number,
targetPresence: 'absent' | 'present' = 'absent'
): Promise<{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]> {
let bytes = 0
let entries = 0
const turns = new Map<string, { id: string; items: unknown[] }>()
for (const [method, firstCursor] of [
['thread/turns/list', cursor(reply.turnsBackwardsCursor)],
['thread/items/list', cursor(reply.itemsBackwardsCursor)]
] as const) {
let next = firstCursor
const seen = new Set<string>()
for (let page = 0; ; page += 1) {
if (page >= MAX_PAGES || (next !== null && seen.has(next))) {
throw new Error('agent_session_rewind:history-limit')
}
if (next !== null) {
seen.add(next)
}
const result = record(
await session.connection.request(
method,
{
threadId: session.threadId,
cursor: next,
sortDirection: 'desc',
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
},
{ timeoutMs }
)
)
if (!Array.isArray(result.data)) {
throw new Error('agent_session_rewind:invalid-provider-page')
}
bytes += Buffer.byteLength(JSON.stringify(result), 'utf8')
entries += result.data.length
if (bytes > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES || entries > MAX_ENTRIES) {
throw new Error('agent_session_rewind:history-limit')
}
for (const raw of result.data) {
const item = record(raw)
const turnId = method === 'thread/turns/list' ? item.id : item.turnId
if (turnId === beforeTurnId && targetPresence === 'absent') {
throw new CodexRewindTargetRetainedError('agent_session_rewind:target-retained')
}
if (typeof turnId !== 'string' || !turnId) {
throw new Error('agent_session_rewind:invalid-retained-turn')
}
if (method === 'thread/turns/list') {
if (turns.has(turnId)) {
throw new Error('agent_session_rewind:duplicate-retained-turn')
}
turns.set(turnId, { id: turnId, items: [] })
} else {
const turn = turns.get(turnId)
if (!turn) {
throw new Error('agent_session_rewind:foreign-retained-item')
}
turn.items.push(record(item.item))
}
}
next = cursor(result.nextCursor)
if (next === null) {
break
}
}
}
if (targetPresence === 'present' && !turns.has(beforeTurnId)) {
throw new CodexRewindTargetMissingError('agent_session_rewind:target-missing')
}
const items = new Map<
string,
{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
>()
const translator = createCodexJournalTranslator({
sink: {
appendItem: (identity, body) => {
items.set(agentJournalItemKey(identity), { identity, body })
},
appendTombstone: (identity) => {
items.delete(agentJournalItemKey(identity))
},
publish: () => {}
},
primaryThreadId: () => session.threadId
})
try {
const chronological = [...turns.values()].toReversed()
const retained =
targetPresence === 'present'
? chronological.slice(
0,
chronological.findIndex((turn) => turn.id === beforeTurnId)
)
: chronological
const admission = translator.restoreThread(session.threadId, {
turns: retained.map((turn) => ({ ...turn, items: turn.items.toReversed() }))
})
if (!admission.accepted) {
throw new Error('agent_session_rewind:history-unreadable')
}
return [...items.values()]
} finally {
translator.dispose()
}
}
async function preflightCodexRewind(
session: CodexSession,
fence: number,
timeoutMs?: number
): Promise<
{ ok: true } | { ok: false; reason: 'invalid-target' | 'history-not-paginated' | 'busy' }
> {
if (session.fence !== fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
if (session.historyMode === 'legacy') {
return { ok: false, reason: 'history-not-paginated' }
}
if (session.activeTurnIds?.size || session.dispatchPending) {
return { ok: false, reason: 'busy' }
}
const metadata = record(
await session.connection.request(
'thread/read',
{ threadId: session.threadId, includeTurns: false },
{ timeoutMs }
)
)
const thread = record(metadata.thread)
if (thread.id !== session.threadId) {
return { ok: false, reason: 'invalid-target' }
}
if (thread.historyMode === 'legacy') {
session.historyMode = 'legacy'
return { ok: false, reason: 'history-not-paginated' }
}
if (
record(thread.status).type !== 'idle' ||
session.activeTurnIds?.size ||
session.dispatchPending
) {
return { ok: false, reason: 'busy' }
}
if (session.fence !== fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
return { ok: true }
}
export async function recoverCodexRewind(
session: CodexSession,
input: { fence: number; beforeTurnId: string },
timeoutMs?: number
): ReturnType<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>> {
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!admission.ok) {
return admission
}
try {
const items = await verifyCodexRevertedHistory(
session,
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
input.beforeTurnId,
timeoutMs
)
if (session.fence !== input.fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
if (session.activeTurnIds?.size || session.dispatchPending) {
return { ok: false, reason: 'busy' }
}
return { ok: true, items }
} catch (error) {
if (error instanceof CodexRewindTargetRetainedError) {
return { ok: false, reason: 'provider-refused' }
}
throw error
}
}
export async function rewindCodexSession(
session: CodexSession,
input: Omit<Parameters<NonNullable<StructuredAgentSessionAdapter['rewind']>>[0], 'sessionId'>,
timeoutMs?: number
): ReturnType<NonNullable<StructuredAgentSessionAdapter['rewind']>> {
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!admission.ok) {
return admission
}
let expectedItems: Set<string>
try {
const retained = await verifyCodexRevertedHistory(
session,
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
input.beforeTurnId,
timeoutMs,
'present'
)
expectedItems = new Set(retained.map(({ identity }) => agentJournalItemKey(identity)))
await input.onPrepared?.(retained)
} catch (error) {
return {
ok: false,
reason:
error instanceof CodexRewindTargetMissingError
? 'invalid-target'
: error instanceof Error && error.message === 'agent_session_rewind:history-limit'
? 'history-limit'
: 'provider-refused'
}
}
const current = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!current.ok) {
return current
}
let result: unknown
try {
result = await session.connection.request(
'thread/revert',
{
threadId: session.threadId,
beforeTurnId: input.beforeTurnId
},
{ timeoutMs }
)
} catch (error) {
if (isCodexAppServerRequestError(error)) {
if (error.message === 'thread/revert only supports paginated threads') {
session.historyMode = 'legacy'
return { ok: false, reason: 'history-not-paginated' }
}
if (error.code === -32601) {
return { ok: false, reason: 'unsupported' }
}
}
throw error
}
const reply = record(result)
if (record(reply.thread).id !== session.threadId) {
throw new Error('agent_session_rewind:foreign-thread')
}
await input.onReverted?.()
const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs)
if (
items.length !== expectedItems.size ||
items.some(({ identity }) => !expectedItems.has(agentJournalItemKey(identity)))
) {
throw new Error('agent_session_rewind:proof-mismatch')
}
return { ok: true, items }
}
export function observeCodexRewindActivity(
session: CodexSession,
method: string,
params: unknown
): void {
if ((readCodexThreadId(params) ?? session.threadId) !== session.threadId) {
return
}
const turnId = readCodexTurnId(params)
if (turnId && method === 'turn/started') {
session.activeTurnIds?.add(turnId)
}
if (turnId && method === 'turn/completed') {
session.activeTurnIds?.delete(turnId)
}
}
@@ -192,6 +192,8 @@ export async function acquireCodexStructuredSession(input: {
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
threadId: opened.threadId,
historyPath: opened.historyPath,
historyMode: opened.historyMode,
activeTurnIds: new Set(),
prompts: acquisition.prompts,
options: restoredCodexSessionOptions(acquireInput.options),
reportedOptions: reportedCodexThreadOptions(opened),
@@ -1,3 +1,4 @@
import * as codexRewind from './codex-structured-rewind'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
@@ -74,7 +75,8 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
})
}
supportsLocation = supportsCodexStructuredLocation
supportsLocation = (location: Parameters<typeof supportsCodexStructuredLocation>[0]): boolean =>
supportsCodexStructuredLocation(location, this.deps.isWindowsProcessStartTimeAvailable)
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireCodexStructuredSession({
@@ -118,6 +120,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
method: string,
params: unknown
): CodexJournalTranslationAdmission {
codexRewind.observeCodexRewindActivity(session, method, params)
if (this.turnCancellation.handleNotification(sessionId, session, method, params)) {
return { accepted: true }
}
@@ -176,8 +179,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
fence: number
}): Promise<AgentSessionDispatchOutcome> {
const session = this.session(input.sessionId)
await this.turnCancellation.captureBaseline(session)
return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
session.dispatchPending = true
try {
await this.turnCancellation.captureBaseline(session)
return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
} finally {
session.dispatchPending = false
}
}
async cancelTurn(input: {
@@ -190,6 +198,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false }
}
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
this.sessions.get(sessionId)?.historyMode === 'legacy'
? { supported: false, reason: 'history-not-paginated' }
: { supported: true }
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
codexRewind.rewindCodexSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
const session = this.session(input.sessionId)
return this.compactions.run(
@@ -41,6 +41,8 @@ export type CodexStructuredSessionAdapterDeps = {
resolveLaunch: (input: {
identity: AgentSessionJournalIdentity
}) => Promise<CodexStructuredLaunch>
/** Host capability seam; production uses the native Windows process table. */
isWindowsProcessStartTimeAvailable?: () => boolean
onEvent?: (event: CodexStructuredSessionEvent) => void
openConnection?: typeof openCodexAppServerConnection
readProcessStartTime?: (pid: number) => Promise<number | null>
@@ -63,6 +65,9 @@ export type CodexSession = {
acquisitionGeneration: string
threadId: string
historyPath: string | null
historyMode?: 'legacy' | 'paginated'
activeTurnIds?: Set<string>
dispatchPending?: boolean
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
@@ -16,6 +16,7 @@ export type CodexOpenedThread = {
thread?: Record<string, unknown>
/** Rollout file Codex named, when it named one. */
historyPath: string | null
historyMode?: 'legacy' | 'paginated'
model?: string
effort?: string
}
@@ -92,6 +93,9 @@ export async function openCodexThread(
threadId,
thread,
historyPath: readCodexThreadPath(opened),
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
? { historyMode: thread.historyMode }
: {}),
...(model ? { model } : {}),
...(effort ? { effort } : {})
}
+140
View File
@@ -0,0 +1,140 @@
// Reading Codex's subagent wire shapes.
//
// Established by a live probe against `codex app-server` 0.152.1, not inferred:
// * `subAgentActivity` items carry `{kind, agentThreadId, agentPath}`, and each
// one arrives TWICE — via `item/started` and again via `item/completed`.
// * `agentPath` is a tree path (`/root`, `/root/list_directory`); the trailing
// segment is a semantic task name and the only label available. There is no
// `thread/started` for a child, so nickname/role/depth do not exist.
// * `agentsStates` on `collabAgentToolCall` arrived empty (`{}`) throughout the
// probe, so nothing here reads it — state comes from `kind` alone.
// * `thread/tokenUsage/updated` reports a per-thread RUNNING TOTAL, so the
// latest frame replaces the previous one — it is never accumulated.
import type { NativeChatSubagentState } from '../../shared/native-chat-types'
import type { CodexThreadItem } from './codex-structured-item-translation'
export const CODEX_SUBAGENT_ITEM_TYPE = 'subAgentActivity'
export const CODEX_TOKEN_USAGE_METHOD = 'thread/tokenUsage/updated'
export type CodexSubagentActivity = {
kind: string
agentThreadId: string
agentPath: string | null
}
function nonEmptyString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
export function readCodexSubagentActivity(item: CodexThreadItem): CodexSubagentActivity | null {
if (item.type !== CODEX_SUBAGENT_ITEM_TYPE) {
return null
}
const agentThreadId = nonEmptyString(item.agentThreadId)
if (!agentThreadId) {
return null
}
return {
kind: nonEmptyString(item.kind) ?? '',
agentThreadId,
agentPath: nonEmptyString(item.agentPath)
}
}
/**
* The state a `kind` implies for the child it names.
*
* An unrecognized kind means "this child exists and reported something we
* cannot classify" — `working`, which the session sweep will later settle to
* `unverifiable` if nothing better ever arrives. Claiming a terminal state from
* an unknown kind would assert an outcome the wire never gave us.
*/
export function codexSubagentStateForKind(kind: string): NativeChatSubagentState {
if (kind === 'completed') {
return 'completed'
}
if (kind === 'interrupted') {
return 'stopped'
}
return 'working'
}
/** Path segments, empty ones dropped: `/root/list_directory` → 2 segments. */
export function codexSubagentPathSegments(agentPath: string | null): string[] {
return agentPath === null ? [] : agentPath.split('/').filter((part) => part.length > 0)
}
/** The one path segment that names the parent turn itself rather than a child.
* Compared after the same normalization the label uses, not against the raw
* string: `/root/` and `/root//` are the same node as `/root`, and a check that
* disagreed with `codexSubagentPathSegments` would let one path be both the
* turn and a child of it — a phantom row labelled `root` inflating the group.
* Only this segment is the root; `/morpheus` is single-segment too but IS a
* child. */
const CODEX_ROOT_AGENT_SEGMENT = 'root'
/**
* Whether an activity item describes the ROOT of the agent tree rather than a
* spawned child. Counting the root would make the parent turn report itself as
* its own subagent.
*
* A path-less item cannot be placed in the tree at all, so it is treated as a
* child: dropping it would lose a real spawn, while an extra row is visible and
* self-correcting.
*/
export function isCodexRootAgentActivity(activity: CodexSubagentActivity): boolean {
const segments = codexSubagentPathSegments(activity.agentPath)
return segments.length === 1 && segments[0] === CODEX_ROOT_AGENT_SEGMENT
}
/** Row label: the agent path's trailing segment, trimmed. A segment with nothing
* visible in it survives the empty-segment filter but would draw a nameless row,
* so it reads as no label and the caller's placeholder takes over. Trimmed
* because the caller keys its collision ordinals on this string: ` read ` and
* `read` render identically and must therefore collide. */
export function codexSubagentLabel(activity: CodexSubagentActivity): string | null {
const trailing = codexSubagentPathSegments(activity.agentPath).at(-1)?.trim()
return trailing !== undefined && trailing.length > 0 ? trailing : null
}
export type CodexThreadTokenTotal = { threadId: string; totalTokens: number }
/** `{threadId, tokenUsage: {total: {totalTokens}}}`. Older builds put the total
* on the envelope, so both shapes are accepted. */
export function readCodexThreadTokenTotal(params: unknown): CodexThreadTokenTotal | null {
const root = record(params)
if (!root) {
return null
}
const threadId = nonEmptyString(root.threadId) ?? nonEmptyString(record(root.thread)?.id)
if (!threadId) {
return null
}
const usage = record(root.tokenUsage)
const total = record(usage?.total)?.totalTokens ?? usage?.totalTokens ?? root.totalTokens
return typeof total === 'number' && Number.isFinite(total) && total >= 0
? { threadId, totalTokens: total }
: null
}
/** Pull the `subAgentActivity` item out of a raw notification payload.
*
* Lives beside the readers rather than in the translator: the translator's job
* is routing, and this is the shape check that decides whether a frame is one
* of ours at all. Returns null for anything that is not a thread item, which is
* the translator's signal to keep looking. */
export function readCodexNotificationThreadItem(
params: unknown,
read: (value: unknown) => CodexThreadItem | null
): CodexThreadItem | null {
const record =
typeof params === 'object' && params !== null ? (params as Record<string, unknown>) : {}
return read(record.item)
}
@@ -0,0 +1,769 @@
import { describe, expect, it } from 'vitest'
import { isAdmissibleAgentJournalItemBody } from '../../shared/agent-session-journal-schemas'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { MAX_SUBAGENT_FIELD_CHARS } from '../../shared/native-chat-subagent-summary'
import { isSubagentGroupBlock, type NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
CodexSubagentRoster,
codexSubagentGroupIdentity,
codexSubagentGroupId
} from './codex-subagent-roster'
import type { CodexThreadItem } from './codex-structured-item-translation'
import {
MAX_CODEX_SUBAGENT_GROUPS,
MAX_CODEX_SUBAGENTS_PER_GROUP,
MAX_CODEX_TOKEN_USAGE_THREADS
} from './codex-structured-journal-limits'
const THREAD = 'thread-parent'
const TURN = 'turn-1'
type Appended = { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
function createHarness(options: { threadId?: string | null } = {}): {
roster: CodexSubagentRoster
appended: Appended[]
agents: () => NativeChatSubagentEntry[]
latest: () => Appended | undefined
} {
const appended: Appended[] = []
let clock = 1_000
const sink: StructuredAgentSessionEventSink = {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => ({ accepted: true })
}
const roster = new CodexSubagentRoster({
sink,
primaryThreadId: () => (options.threadId === undefined ? THREAD : options.threadId),
activeTurn: () => TURN,
now: () => (clock += 1)
})
const agents = (): NativeChatSubagentEntry[] => {
const body = appended.at(-1)?.body
if (!body || body.kind !== 'message') {
return []
}
const block = body.blocks.find(isSubagentGroupBlock)
return block ? block.agents : []
}
return { roster, appended, agents, latest: () => appended.at(-1) }
}
function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined {
return appended.at(-1)?.identity
}
function activity(input: {
id?: string
kind: string
agentThreadId: string
agentPath: string | null
}): CodexThreadItem {
return {
type: 'subAgentActivity',
id: input.id ?? `item-${input.agentThreadId}-${input.kind}`,
kind: input.kind,
agentThreadId: input.agentThreadId,
agentPath: input.agentPath
}
}
function deliver(
roster: CodexSubagentRoster,
item: CodexThreadItem,
turnId: string | null = TURN
): void {
// Every activity item reaches the wire twice: item/started, then item/completed.
roster.handleItem({ threadId: THREAD, turnId, item })
roster.handleItem({ threadId: THREAD, turnId, item })
}
/**
* A sink that coalesces the way the real queue does: by `coalescingKey` ALONE,
* with no op-kind check, and only draining when released. A fake that ignores
* the key cannot see an append being spliced out by its own publish.
*/
function createCoalescingHarness(): {
roster: CodexSubagentRoster
appended: Appended[]
drain: () => void
} {
const appended: Appended[] = []
const queue: { key?: string; run: () => void }[] = []
let clock = 1_000
const submit = (key: string | undefined, run: () => void): void => {
const at = key === undefined ? -1 : queue.findIndex((queued) => queued.key === key)
if (at >= 0) {
queue.splice(at, 1)
}
queue.push(key === undefined ? { run } : { key, run })
}
const sink: StructuredAgentSessionEventSink = {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body, options) => {
submit(options?.coalescingKey, () => appended.push({ identity, body }))
return { accepted: true }
},
tryPublish: (options) => {
submit(options?.coalescingKey ?? 'publish', () => {})
return { accepted: true }
}
}
const roster = new CodexSubagentRoster({
sink,
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => (clock += 1)
})
return {
roster,
appended,
drain: () => {
while (queue.length > 0) {
queue.shift()?.run()
}
}
}
}
describe('CodexSubagentRoster', () => {
it('does not let its own publish evict the still-queued roster append', () => {
const { roster, appended, drain } = createCoalescingHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
drain()
// Sharing the append's coalescing key with the publish spliced the append
// out of the queue, and `lastSerialized` then suppressed every retry.
expect(appended).toHaveLength(1)
})
it('counts a /morpheus agent as a child — only /root is the turn itself', () => {
const { roster, agents } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-m', agentPath: '/morpheus' }))
expect(agents()).toMatchObject([{ id: 'child-m', label: 'morpheus', state: 'working' }])
})
// `codexSubagentPathSegments` already defines what a path means for the label,
// and the root check has to agree with it: a path that normalizes to the same
// node must classify the same way, or one string is both the turn itself and a
// child of it — a phantom row labelled `root` inflating the group by one.
it('reads a root path with a trailing or doubled separator as the turn itself', () => {
for (const agentPath of ['/root/', '/root//', '//root']) {
const { roster, appended } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath }))
expect(appended).toEqual([])
}
})
it('keeps a doubled separator inside a child path off the label', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root//read/' })
)
expect(agents()).toMatchObject([{ id: 'child-1', label: 'read' }])
})
// An all-whitespace trailing segment survives the empty-segment filter and
// would draw a row with no visible name at all.
it('falls back to the placeholder when the trailing segment has nothing to show', () => {
const { roster, agents } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/ ' }))
expect(agents()).toMatchObject([{ id: 'child-1', label: 'subagent' }])
})
// The collision ordinal keys on the label, so two segments that render
// identically must collide rather than both draw as `read`.
it('collides labels that differ only in surrounding whitespace', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/ read ' })
)
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
})
it('ignores the root node so a turn is not its own subagent', () => {
const { roster, appended } = createHarness()
deliver(roster, activity({ kind: 'started', agentThreadId: THREAD, agentPath: '/root' }))
expect(appended).toEqual([])
})
it('writes an admissible journal body carrying a plain-text fallback block', () => {
const { roster, latest } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/list_directory' })
)
const body = latest()?.body
expect(body?.kind).toBe('message')
expect(isAdmissibleAgentJournalItemBody(body)).toBe(true)
expect(body?.kind === 'message' ? body.blocks.map((block) => block.type) : []).toEqual([
'text',
'subagent-group'
])
expect(
body?.kind === 'message' && body.blocks[0]?.type === 'text' ? body.blocks[0].text : ''
).toBe('Kicked off 1 subagent')
})
it('keys the durable identity by the parent turn so a revision lands on one row', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
const expected = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, TURN))
expect(new Set(appended.map((entry) => JSON.stringify(entry.identity)))).toEqual(
new Set([JSON.stringify(expected)])
)
})
it('rule 1 — a duplicate delivery writes no second revision', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(appended).toHaveLength(1)
})
it('rule 2 — a first event of any kind creates the entry in the state it implies', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-late', agentPath: '/root/search' })
)
expect(agents()).toMatchObject([{ id: 'child-late', label: 'search', state: 'completed' }])
})
it('rule 3 — a terminal state latches against a late or duplicate start', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ state: 'completed' }])
})
it('rule 4 — the session sweep settles a lost child as unverifiable, not exited', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-2', agentPath: '/root/search' })
)
roster.settleSession()
expect(agents()).toMatchObject([
{ id: 'child-1', state: 'unverifiable' },
{ id: 'child-2', state: 'completed' }
])
})
it('lets a swept child still report what it actually did', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
expect(agents()[0]?.state).toBe('unverifiable')
// Contact can return — a reconnected provider replays the child's own
// verdict. Latching the sweep would report a child that finished as one we
// never saw finish.
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('completed')
})
it('refuses to put a swept child back to working', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
// A straggler progress tick after we gave up must not re-light the row.
deliver(
roster,
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('unverifiable')
})
it('keeps a real verdict when a later frame disagrees', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'interrupted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]?.state).toBe('completed')
})
it('rule 4 — the session sweep settles every group and never un-terminals one', () => {
const { roster, agents, appended } = createHarness()
deliver(
roster,
activity({ kind: 'interacted', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.settleSession()
const afterFirstSweep = appended.length
roster.settleSession()
expect(agents()).toMatchObject([{ state: 'unverifiable' }])
expect(appended).toHaveLength(afterFirstSweep)
})
it('rule 5 — the whole roster is persisted in the carrier, not just a count', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 40661 } } })
expect(agents()).toMatchObject([
{ id: 'child-1', label: 'read', state: 'working', tokens: 40661 }
])
})
it('rule 6 — the group id names the parent turn, or says there was none', () => {
const { roster, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/search' }),
null
)
expect(appended.map((entry) => entry.identity)).toEqual([
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:${TURN}` },
{ provider: 'orca', clientMessageId: `codex-subagents:${THREAD}:outside-turn` }
])
})
it('disambiguates two children that share a trailing path segment', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-2', agentPath: '/root/read' })
)
expect(agents().map((agent) => agent.label)).toEqual(['read', 'read 2'])
})
it('takes the latest token snapshot per child and never accumulates updates', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 100 } } })
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 250 } } })
expect(agents()).toMatchObject([{ tokens: 250 }])
})
it('retains a usage frame that arrives before the child is known', () => {
const { roster, agents } = createHarness()
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ tokens: 900 }])
})
it('never attributes the parent thread its own usage', () => {
const { roster, agents, appended } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
const beforeParentUsage = appended.length
roster.handleTokenUsage({ threadId: THREAD, tokenUsage: { total: { totalTokens: 26099 } } })
expect(appended).toHaveLength(beforeParentUsage)
expect(agents()).toHaveLength(1)
expect(agents()[0]).not.toHaveProperty('tokens')
})
// The row is durable and both readers clip these fields to the same cap, so
// writing more than that is bytes replayed on every reconnect and then thrown
// away. The marker is an ellipsis, not the tool-output truncation sentence:
// `id` is the roster key and the renderer's React key.
it('bounds the provider strings the roster row carries into the journal', () => {
const { roster, agents, latest } = createHarness()
const oversized = 'a'.repeat(20 * 1024)
deliver(
roster,
activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` })
)
const entry = agents()[0]
expect(entry?.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(entry?.label).toMatch(/…~0$/)
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(entry?.id).toMatch(/…~0$/)
expect(JSON.stringify(latest()?.body)).not.toContain('output truncated')
expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true)
})
// The clip cuts UTF-16 code units, so a boundary landing inside a surrogate
// pair left a LONE high surrogate in a durable row — malformed, and replaced
// with U+FFFD through any non-JSON UTF-8 hop.
it('never clips a provider string mid surrogate pair', () => {
const { roster, agents } = createHarness()
const astral = '😀'.repeat(400)
deliver(
roster,
activity({ kind: 'started', agentThreadId: astral, agentPath: `/root/${astral}` })
)
const entry = agents()[0]
expect(entry?.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(Buffer.from(entry?.id ?? '', 'utf8').toString('utf8')).toBe(entry?.id)
expect(Buffer.from(entry?.label ?? '', 'utf8').toString('utf8')).toBe(entry?.label)
})
// The clip removes exactly the tail that told two children apart: `id` is the
// renderer's React key, and `claimLabel` writes its repeat ordinal at the end.
// Two clipped children collapsing to one key drew two rows under one identity.
it('keeps clipped ids and labels distinct between children', () => {
const { roster, agents } = createHarness()
const prefix = 'p'.repeat(MAX_SUBAGENT_FIELD_CHARS)
const sharedPath = `/root/${'q'.repeat(640)}`
deliver(
roster,
activity({ kind: 'started', agentThreadId: `${prefix}AAAA`, agentPath: sharedPath })
)
deliver(
roster,
activity({ kind: 'started', agentThreadId: `${prefix}BBBB`, agentPath: sharedPath })
)
const entries = agents()
expect(entries).toHaveLength(2)
expect(new Set(entries.map((agent) => agent.id)).size).toBe(2)
expect(new Set(entries.map((agent) => agent.label)).size).toBe(2)
for (const agent of entries) {
expect(agent.id.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
expect(agent.label.length).toBeLessThanOrEqual(MAX_SUBAGENT_FIELD_CHARS)
}
})
it('caps the children one spawn group admits', () => {
const { roster, agents, appended } = createHarness()
for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' })
)
}
const atCap = appended.length
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' })
)
expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP)
expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap')
// Refusing the child must not burn a revision either.
expect(appended).toHaveLength(atCap)
})
// The eviction is the KNOWN LIMITATION the module documents: `groups` is never
// seeded from the journal, so the evicted group's next child rebuilds its
// durable row from that one child. Pinned so the boundary cannot move silently.
it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => {
const { roster, appended, agents } = createHarness()
for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }),
`turn-${index}`
)
}
const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0'))
const rowsFor = (identity: AgentJournalItemIdentity): Appended[] =>
appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity))
expect(rowsFor(evicted)).toHaveLength(1)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }),
'turn-0'
)
expect(latestIdentity(appended)).toEqual(evicted)
expect(agents().map((agent) => agent.id)).toEqual(['child-late'])
})
it('keeps a token count a later thread-map eviction would otherwise retract', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } })
expect(agents()).toMatchObject([{ tokens: 4242 }])
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }])
})
it('caps retained usage threads, so a frame evicted before its child is dropped', () => {
const { roster, agents } = createHarness()
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]).not.toHaveProperty('tokens')
})
it('declines a payload that is not a subagent item or a usage frame', () => {
const { roster } = createHarness()
expect(
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: { type: 'commandExecution', id: 'item-9' }
})
).toBeNull()
expect(roster.handleTokenUsage({ threadId: 'child-1' })).toBeNull()
})
// A refusal must never advance the duplicate-suppression state: an identical
// replay would short-circuit and the revision would never be retried. The
// append and the publish are the two ways to be refused, so both are covered.
it.each([{ refuse: 'append' as const }, { refuse: 'publish' as const }])(
'retries the same revision after the $refuse is refused',
({ refuse }) => {
let refusing = true
const appended: Appended[] = []
const published: number[] = []
const refusal = { accepted: false, reason: 'backpressure' } as const
const roster = new CodexSubagentRoster({
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
if (refusing && refuse === 'append') {
return refusal
}
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => {
if (refusing && refuse === 'publish') {
return refusal
}
published.push(1)
return { accepted: true }
}
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => 1_000
})
const item = activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual(refusal)
// The wire redelivers the very same item; nothing about the roster changed,
// so only a cleared suppression state can get the revision out.
refusing = false
expect(roster.handleItem({ threadId: THREAD, turnId: TURN, item })).toEqual({
accepted: true
})
// The retry re-appends when the publish was the half that failed; the real
// queue coalesces those two by the group key into one journal write. What
// must not happen is the revision never being published at all.
expect(published).toHaveLength(1)
const body = appended.at(-1)?.body
expect(
body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []
).toMatchObject([{ agents: [{ id: 'child-1', state: 'working' }] }])
}
)
// The sweep is the last event a group ever gets. A refusal there, left
// unretried, strands the settled roster's final revision — the exact "row
// stays stale forever" this row exists to prevent.
it('republishes the settled roster when the sweep publish was refused', () => {
let refusing = false
const appended: Appended[] = []
const published: number[] = []
const roster = new CodexSubagentRoster({
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: (identity, body) => {
appended.push({ identity, body })
return { accepted: true }
},
tryPublish: () => {
if (refusing) {
return { accepted: false, reason: 'backpressure' }
}
published.push(1)
return { accepted: true }
}
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN,
now: () => 1_000
})
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
})
const publishedBeforeSweep = published.length
refusing = true
expect(roster.settleSession()).toEqual({ accepted: false, reason: 'backpressure' })
// The retry sweep flips no state — every child already latched — so only a
// cleared suppression state can carry the unverifiable roster out.
refusing = false
expect(roster.settleSession()).toEqual({ accepted: true })
expect(published.length).toBe(publishedBeforeSweep + 1)
const body = appended.at(-1)?.body
expect(body?.kind === 'message' ? body.blocks.filter(isSubagentGroupBlock) : []).toMatchObject([
{ agents: [{ id: 'child-1', state: 'unverifiable' }] }
])
})
it('propagates sink backpressure instead of reporting the row as written', () => {
const roster = new CodexSubagentRoster({
sink: {
appendItem: () => {},
appendTombstone: () => {},
publish: () => {},
tryAppendItem: () => ({ accepted: false, reason: 'backpressure' }),
tryPublish: () => ({ accepted: true })
},
primaryThreadId: () => THREAD,
activeTurn: () => TURN
})
expect(
roster.handleItem({
threadId: THREAD,
turnId: TURN,
item: activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
})
).toEqual({ accepted: false, reason: 'backpressure' })
})
})
+347
View File
@@ -0,0 +1,347 @@
// The Codex subagent roster: one journal row per spawn group, revised in place.
//
// There is no snapshot to read. `agentsStates` arrived empty in the live probe
// and children get no `thread/started`, so the roster is
// accumulated purely from `subAgentActivity` items — each of which arrives TWICE
// (`item/started` and `item/completed`). Every transition here is therefore
// idempotent, and a terminal state latches: duplicate and out-of-order delivery
// must not resurrect a settled child.
//
// KNOWN LIMITATION: `groups` is process-local and is never seeded from the
// journal, while the row's identity is keyed on the group id alone. So once a
// group leaves the map its row stays, and the next activity item rebuilds that
// row from one child — rewriting N down to one. Two ways in: eviction past
// MAX_CODEX_SUBAGENT_GROUPS, which drops the oldest-inserted group in-process
// even while it is live, and skips the sweep so its children never latch
// `unverifiable`; and a restart on `threadId:outside-turn`, the one group id
// that outlives the process — `thread/resume` is verified to return the same
// thread, and a real turn id is assumed freshly minted per turn. Seeding from
// the journal is the fix.
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import {
canReplaceSubagentState,
isTerminalSubagentState,
MAX_SUBAGENT_FIELD_CHARS,
subagentGroupFallbackText
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
} from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
codexSubagentLabel,
codexSubagentStateForKind,
isCodexRootAgentActivity,
readCodexSubagentActivity,
readCodexThreadTokenTotal
} from './codex-subagent-activity'
import type { CodexThreadItem } from './codex-structured-item-translation'
import {
MAX_CODEX_SUBAGENT_GROUPS,
MAX_CODEX_SUBAGENTS_PER_GROUP,
MAX_CODEX_TOKEN_USAGE_THREADS
} from './codex-structured-journal-limits'
const ADMITTED: StructuredAgentSessionSinkAdmission = { accepted: true }
/** The turn a group belongs to when Codex reports activity outside any turn.
* Mirrors the generic-frame bucket name so the two read alike in the journal. */
const OUTSIDE_TURN = 'outside-turn'
const UNLABELLED_AGENT = 'subagent'
type RosterGroup = {
groupId: string
identity: AgentJournalItemIdentity
/** Insertion order is the display order; the map holds the state. */
entries: Map<string, NativeChatSubagentEntry>
/** Times each label has been claimed, so a repeat gets an ordinal suffix. */
labelCounts: Map<string, number>
/** Last body written, so an idempotent replay writes no new revision. */
lastSerialized: string | null
}
/** Group identity: the parent turn that spawned the children. `agentPath` is a
* tree rooted at the parent thread, so every child of one turn shares a row
* no matter which thread's stream carried its activity item. */
export function codexSubagentGroupId(threadId: string, turnId: string | null): string {
return `${threadId}:${turnId ?? OUTSIDE_TURN}`
}
/** Durable journal identity for the group's row — stable across revisions and
* across a restart, so replay finds the same row instead of appending a new one. */
export function codexSubagentGroupIdentity(groupId: string): AgentJournalItemIdentity {
return { provider: 'orca', clientMessageId: `codex-subagents:${groupId}` }
}
export type CodexSubagentRosterDeps = {
sink: StructuredAgentSessionEventSink
/** The thread that owns the agent tree; falls back to the event's thread. */
primaryThreadId: () => string | null
activeTurn: (threadId: string) => string | null
now?: () => number
}
export class CodexSubagentRoster {
private readonly groups = new Map<string, RosterGroup>()
/** Latest reported total per thread, kept regardless of roster membership: a
* usage frame can arrive before the child's first activity item, and filtering
* at receipt would lose it permanently. Children are selected at write time;
* the map itself is LRU-capped in `handleTokenUsage`. */
private readonly tokensByThread = new Map<string, number>()
private readonly now: () => number
constructor(private readonly deps: CodexSubagentRosterDeps) {
this.now = deps.now ?? (() => Date.now())
}
/** Consume a `subAgentActivity` item. Returns null when the item is not one. */
handleItem(input: {
threadId: string
turnId: string | null
item: CodexThreadItem
}): StructuredAgentSessionSinkAdmission | null {
const activity = readCodexSubagentActivity(input.item)
if (!activity) {
return null
}
// The root node is the parent turn itself, not a child it spawned.
if (isCodexRootAgentActivity(activity)) {
return ADMITTED
}
const group = this.groupFor(input.threadId, input.turnId)
const existing = group.entries.get(activity.agentThreadId)
const state = codexSubagentStateForKind(activity.kind)
if (!existing) {
// Rule: the first event for a child may be ANY kind. An `interacted` or
// `completed` with no prior `started` creates the entry in the state its
// kind implies rather than being dropped for lacking a roster row.
if (group.entries.size >= MAX_CODEX_SUBAGENTS_PER_GROUP) {
return ADMITTED
}
const now = this.now()
group.entries.set(activity.agentThreadId, {
id: activity.agentThreadId,
label: this.claimLabel(group, codexSubagentLabel(activity)),
state,
startedAt: now,
...(isTerminalSubagentState(state) ? { settledAt: now } : {})
})
} else if (canReplaceSubagentState(existing.state, state)) {
// A child's own verdict latches. Re-applying the same non-terminal state
// is a no-op, which is what makes the duplicate `item/started` +
// `item/completed` delivery idempotent. `unverifiable` does not latch: a
// child swept when contact was lost can still report what it actually did
// if contact returns.
group.entries.set(activity.agentThreadId, {
...existing,
state,
...(isTerminalSubagentState(state) ? { settledAt: this.now() } : {})
})
}
return this.write(group)
}
/** Consume `thread/tokenUsage/updated`. Returns null when the params are not one. */
handleTokenUsage(params: unknown): StructuredAgentSessionSinkAdmission | null {
const usage = readCodexThreadTokenTotal(params)
if (!usage) {
return null
}
// A running total: the newest frame REPLACES the previous one. Summing
// updates would multiply a single child's usage by its frame count.
// Re-insert so the eviction scan below sees recency: `set` on an existing
// key keeps its original position, which would age out an active thread.
this.tokensByThread.delete(usage.threadId)
this.tokensByThread.set(usage.threadId, usage.totalTokens)
while (this.tokensByThread.size > MAX_CODEX_TOKEN_USAGE_THREADS) {
const oldest = this.tokensByThread.keys().next().value
if (typeof oldest !== 'string') {
break
}
this.tokensByThread.delete(oldest)
}
for (const group of this.groups.values()) {
if (!group.entries.has(usage.threadId)) {
continue
}
const admission = this.write(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
}
/**
* The provider is gone, so any child still reported as working will never be
* settled by an event: it becomes `unverifiable` — contact was lost, which is
* NOT evidence the child exited.
*
* This is the ONLY sweep. A turn ending is not one: `spawn_agent` children
* routinely outlive their turn and keep reporting into the same group.
*/
settleSession(): StructuredAgentSessionSinkAdmission {
for (const group of this.groups.values()) {
const admission = this.sweep(group)
if (!admission.accepted) {
return admission
}
}
return ADMITTED
}
dispose(): void {
this.groups.clear()
this.tokensByThread.clear()
}
private sweep(group: RosterGroup | undefined): StructuredAgentSessionSinkAdmission {
if (!group) {
return ADMITTED
}
let changed = false
for (const [id, entry] of group.entries) {
if (isTerminalSubagentState(entry.state)) {
continue
}
group.entries.set(id, { ...entry, state: 'unverifiable', settledAt: this.now() })
changed = true
}
// A null `lastSerialized` means the previous write was refused part-way, so
// the settled roster's last revision is queued but never published. Nothing
// is guaranteed to write this group again, so retry here even when the sweep
// itself changed nothing.
return changed || group.lastSerialized === null ? this.write(group) : ADMITTED
}
private groupFor(threadId: string, turnId: string | null): RosterGroup {
const ownerThreadId = this.deps.primaryThreadId() ?? threadId
const ownerTurnId =
ownerThreadId === threadId ? turnId : (this.deps.activeTurn(ownerThreadId) ?? turnId)
const groupId = codexSubagentGroupId(ownerThreadId, ownerTurnId)
const existing = this.groups.get(groupId)
if (existing) {
return existing
}
const group: RosterGroup = {
groupId,
identity: codexSubagentGroupIdentity(groupId),
entries: new Map(),
labelCounts: new Map(),
lastSerialized: null
}
this.groups.set(groupId, group)
while (this.groups.size > MAX_CODEX_SUBAGENT_GROUPS) {
const oldest = this.groups.keys().next().value
if (typeof oldest !== 'string' || oldest === groupId) {
break
}
this.groups.delete(oldest)
}
return group
}
/** Two children can share a trailing path segment; the ordinal keeps their
* rows apart without inventing a name the provider never sent. */
private claimLabel(group: RosterGroup, label: string | null): string {
const base = label ?? UNLABELLED_AGENT
const seen = group.labelCounts.get(base) ?? 0
group.labelCounts.set(base, seen + 1)
return seen === 0 ? base : `${base} ${seen + 1}`
}
private write(group: RosterGroup): StructuredAgentSessionSinkAdmission {
const agents = [...group.entries].map(([id, entry]) => {
const tokens = this.tokensByThread.get(id)
if (typeof tokens !== 'number' || tokens === entry.tokens) {
return entry
}
// Persisted, not merely read: the thread map is LRU-capped, and reading it
// afresh each write would retract a count this row has already shown.
const merged = { ...entry, tokens }
group.entries.set(id, merged)
return merged
})
const body = codexSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
if (serialized === group.lastSerialized) {
// Nothing changed — a duplicate delivery must not burn a revision.
return ADMITTED
}
group.lastSerialized = serialized
// The append coalesces per group so a burst collapses to the latest roster.
// The publish must NOT reuse that key: the queue coalesces by key alone,
// with no op-kind check, so a publish carrying it would splice out the
// still-queued append and the row would never reach the journal.
const options = { coalescingKey: `codex-subagents:${group.groupId}` }
const admission = this.deps.sink.tryAppendItem
? this.deps.sink.tryAppendItem(group.identity, body, options)
: (this.deps.sink.appendItem(group.identity, body, options), ADMITTED)
if (!admission.accepted) {
group.lastSerialized = null
return admission
}
const published = this.deps.sink.tryPublish
? this.deps.sink.tryPublish()
: (this.deps.sink.publish(), ADMITTED)
if (!published.accepted) {
// Symmetric with the append refusal above: the suppression state may only
// advance once the revision is both queued AND published. Left set, an
// identical replay short-circuits and the last revision of a settled
// roster stays queued but never reaches the renderer.
group.lastSerialized = null
}
return published
}
}
/** The roster row: the structured block plus the plain sentence an older client
* renders in its place. A message whose only block is the new variant would
* reach such a client with nothing it can draw. */
export function codexSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
const bounded = agents.map((agent, index) => ({
...agent,
id: boundSubagentField(agent.id, index),
label: boundSubagentField(agent.label, index)
}))
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(bounded) },
{ type: 'subagent-group', groupId, agents: bounded }
]
}
}
/** `id` and `label` are provider strings, so they take the bound both readers of
* this row already clip them to. A plain length check, not the tool-output
* bound: that one digests the whole value before it checks the length, and this
* runs twice per child on every streamed token-usage frame.
*
* A clip is not identity-preserving, so a clipped value carries the child's
* index: two ids sharing a long prefix collapse to one React key, and
* `claimLabel` writes its ordinal at the very tail the clip removes. The index
* is reserved out of the bound, not appended to it, because both readers
* re-clip to the same cap and would cut a suffix that overflowed it. */
function boundSubagentField(value: string, index: number): string {
if (value.length <= MAX_SUBAGENT_FIELD_CHARS) {
return value
}
const suffix = `…~${index}`
const keep = MAX_SUBAGENT_FIELD_CHARS - suffix.length
// Slicing UTF-16 units can split a surrogate pair; a lone surrogate is
// malformed in a durable row and lossy through any non-JSON UTF-8 hop.
const last = value.charCodeAt(keep - 1)
const end = last >= 0xd800 && last <= 0xdbff ? keep - 1 : keep
return `${value.slice(0, end)}${suffix}`
}
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest'
import { codexItemBody, codexStreamingJournalItem } from './codex-structured-item-translation'
import { boundStreamItem } from './codex-structured-item-stream-bounds'
const command = {
type: 'commandExecution',
id: 'cmd',
command: 'missing-command',
status: 'completed'
}
describe('command row metadata', () => {
it.each([0, 127, -1])('preserves exit %s with provider duration', (exitCode) => {
expect(codexItemBody({ ...command, exitCode, durationMs: 400 })).toMatchObject({
kind: 'tool-call',
name: 'shell',
exitCode,
durationMs: 400,
state: exitCode === 0 ? 'completed' : 'failed'
})
})
it('omits unavailable or invalid values', () => {
for (const fields of [
{},
{ exitCode: null, durationMs: null },
{ exitCode: 1.5, durationMs: -1 }
]) {
const body = codexItemBody({ ...command, ...fields })
expect(body).not.toHaveProperty('exitCode')
expect(body).not.toHaveProperty('durationMs')
}
})
it('keeps snake-case duration through streamed and oversized command snapshots', () => {
const source = {
...command,
exitCode: 0,
duration_ms: 400,
aggregatedOutput: 'x'.repeat(70000)
}
expect(boundStreamItem(source)).toMatchObject({ exitCode: 0, durationMs: 400 })
expect(codexStreamingJournalItem(source, 'output').body).toMatchObject({
exitCode: 0,
durationMs: 400
})
})
it('keeps metadata on classified exec rows', () => {
expect(
codexItemBody({
...command,
exitCode: 0,
durationMs: 15,
commandActions: [{ type: 'read', command: 'cat a.ts', name: 'a.ts', path: 'a.ts' }]
})
).toMatchObject({ name: 'read', exitCode: 0, durationMs: 15 })
})
})
describe('web result annotations', () => {
it('adds safe result annotations and retains old-reader JSON output', () => {
const results = [{ title: 'Docs', url: 'https://example.com/' }, { url: 'javascript:alert(1)' }]
const body = codexItemBody({
type: 'webSearch',
id: 'web',
query: 'docs',
action: { type: 'search' },
results
})
expect(body).toMatchObject({
kind: 'tool-call',
name: 'web_search',
state: 'completed',
webSearchResults: [results[0]],
output: { head: JSON.stringify(results) }
})
})
it('leaves legacy and malformed results without an annotation', () => {
expect(
codexItemBody({ type: 'webSearch', id: 'web', query: 'docs', results: [{}] })
).not.toHaveProperty('webSearchResults')
})
})
it('annotates only confirmed MCP calls and retains the raw server/tool name', () => {
expect(
codexItemBody({ type: 'mcpToolCall', id: 'm', server: 'my_server', tool: 'ns.tool' })
).toMatchObject({
kind: 'tool-call',
name: 'my_server/ns.tool',
mcpIdentity: { server: 'my_server', tool: 'ns.tool' }
})
expect(codexItemBody(command)).not.toHaveProperty('mcpIdentity')
})
@@ -24,7 +24,9 @@ const AUDITED_GLOBAL_FETCH_LINES = new Map<string, number>([
['main/orca-profiles/profile-cloud-org-members-client.ts', 1],
['main/rate-limits/codex-fetcher.ts', 3],
['main/runtime/relay/relay-http-client.ts', 2],
['main/runtime/relay/relay-region-preference.ts', 3],
['main/runtime/relay/relay-region-catalog-fetch.ts', 1],
['main/runtime/relay/relay-region-preference.ts', 2],
['main/runtime/relay/relay-region-probe.ts', 1],
['main/source-control/hosted-review-api-request.ts', 1],
['main/speech/openai-transcription-client.ts', 1],
// Main HTTP port: one type declaration plus the Node fallback call. The fallback
+18 -1
View File
@@ -742,11 +742,28 @@ describe('registerMobileHandlers', () => {
})
it('reports the current relay broker status without exposing a toggle', () => {
registerMobileHandlers({} as never, { getRelayStatus: () => 'registered' })
registerMobileHandlers({} as never, { getRelayStatus: () => ({ status: 'registered' }) })
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'registered' })
})
it('reports the assigned relay cell alongside the status', () => {
registerMobileHandlers({} as never, {
getRelayStatus: () => ({ status: 'registered', cellUrl: 'https://c27.relay.example.com' })
})
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({
status: 'registered',
cellUrl: 'https://c27.relay.example.com'
})
})
it('falls back to offline with no cell when no relay status provider is wired', () => {
registerMobileHandlers({} as never, {})
expect(handlers.get('mobile:getRelayStatus')?.()).toEqual({ status: 'offline' })
})
it('consumes a pending auth-failure notification only from a window renderer', () => {
const consumePendingUnpairedDeviceAuthFailure = vi.fn(() => true)
registerMobileHandlers({} as never, { consumePendingUnpairedDeviceAuthFailure })
+6 -5
View File
@@ -13,7 +13,7 @@ import {
} from '../runtime/pairing-network-interfaces'
import { resolveAdvertisedPairingHostname } from '../runtime/pairing-endpoint'
import type { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc'
import type { RelayBrokerStatus } from '../runtime/relay/relay-session-broker'
import type { MobileRelayStatusDetail } from '../../shared/mobile-relay-status'
import { encodeMobilePairingQr, type MobilePairingQrResult } from '../runtime/mobile-pairing-qr'
import { getWindowsDefaultRouteInterfaceNames } from '../runtime/windows-default-route-interfaces'
import {
@@ -51,7 +51,7 @@ function toRuntimeAccessGrant(device: DeviceEntry): RuntimeAccessGrant {
export type MobileHandlerDependencies = {
firewallEnvironment?: WindowsMobileFirewallEnvironment
openWindowsNetworkSettings?: () => Promise<void>
getRelayStatus?: () => RelayBrokerStatus
getRelayStatus?: () => MobileRelayStatusDetail
consumePendingUnpairedDeviceAuthFailure?: (webContentsId: number) => boolean
encodePairingQr?: (pairingUrl: string) => Promise<MobilePairingQrResult>
getDefaultRouteInterfaceNames?: DefaultRouteInterfaceLookup
@@ -287,9 +287,10 @@ export function registerMobileHandlers(
return true
})
ipcMain.handle('mobile:getRelayStatus', () => ({
status: dependencies.getRelayStatus?.() ?? 'offline'
}))
ipcMain.handle(
'mobile:getRelayStatus',
(): MobileRelayStatusDetail => dependencies.getRelayStatus?.() ?? { status: 'offline' }
)
ipcMain.handle('mobile:consumePendingUnpairedDeviceAuthFailure', (event) => {
if (!isWindowRenderer(event)) {
@@ -1,4 +1,8 @@
import { mkdir } from 'node:fs/promises'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../../shared/agent-session-journal-types'
import type { AgentType } from '../../../shared/agent-status-types'
import {
findJournalFileFormatRemnant,
@@ -6,6 +10,7 @@ import {
} from './journal-file-format-remnant'
import type { JournalLoad } from './journal-open'
import { journalRepairDisclosure, type JournalRepairDisclosure } from './journal-repair-disclosure'
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
/** What any of this file's disclosures hands the store — a repair's, or the
* pre-SQLite notice's. Same shape, and neither is only a repair. */
@@ -36,9 +41,9 @@ export async function openJournalStoreState(input: {
adopt: (loaded: JournalLoad) => void
/** Republishes an anchor row for an epoch a repair emptied. */
publishRepairEpoch: () => void
appendDisclosure: (
identity: JournalRepairDisclosure['identity'],
body: JournalRepairDisclosure['body'],
appendItem: (
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
fence: number
) => Promise<unknown>
agent: AgentType
@@ -68,8 +73,9 @@ export async function openJournalStoreState(input: {
}
if (input.malformedRows() > 0 && !input.readOnly()) {
const disclosure = journalRepairDisclosure({ malformedRows: input.malformedRows() })
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
}
await settleStaleSubagentRosters(input, loaded)
// Founding the epoch and appending the row are two transactions, and a
// committed epoch sends every later open down this branch instead. Anything
// that interrupts between them — a quit during startup restore, a failed
@@ -92,7 +98,7 @@ export async function openJournalStoreState(input: {
async function discloseFileFormatRemnant(input: {
journalDir: string
agent: AgentType
appendDisclosure: (
appendItem: (
identity: JournalDisclosure['identity'],
body: JournalDisclosure['body'],
fence: number
@@ -108,5 +114,32 @@ async function discloseFileFormatRemnant(input: {
return
}
const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent })
await input.appendDisclosure(disclosure.identity, disclosure.body, input.highestFence())
await input.appendItem(disclosure.identity, disclosure.body, input.highestFence())
}
/**
* Retires a `working` subagent roster the previous host never got to settle.
*
* Skipped on a corrupt load: that journal is still owed a rebuild from provider
* history, and content written past the repair's free sequence retires the
* demand for it.
*/
async function settleStaleSubagentRosters(
input: {
appendItem: (
identity: AgentJournalItemIdentity,
body: AgentJournalItemBody,
fence: number
) => Promise<unknown>
highestFence: () => number
readOnly: () => boolean
},
loaded: JournalLoad
): Promise<void> {
if (input.readOnly() || loaded.corrupt) {
return
}
for (const revision of staleSubagentRosterRevisions(loaded.state.items.values())) {
await input.appendItem(revision.identity, revision.body, input.highestFence())
}
}
@@ -39,8 +39,7 @@ export function restoreJournalStore(
publishRepairEpoch: () =>
collaborators.epochController.start('unreconcilable_prefix', host.state().highestFence),
adopt: host.adopt,
appendDisclosure: (identity, body, fence) =>
host.journal().appendItem(identity, body, { fence }),
appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence }),
agent: host.identity.agent,
highestFence: () => host.state().highestFence,
malformedRows: host.malformedRows,
@@ -0,0 +1,202 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type {
AgentJournalRenderItem,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key'
import { isSubagentGroupBlock } from '../../../shared/native-chat-types'
import type { NativeChatSubagentEntry } from '../../../shared/native-chat-types'
import {
codexSubagentGroupBody,
codexSubagentGroupIdentity
} from '../../codex/codex-subagent-roster'
import type { openAgentSessionJournal } from './journal-store-factory'
import { createTrackedJournalOpener } from './journal-store-test-open'
import { staleSubagentRosterRevisions } from './journal-subagent-liveness'
const IDENTITY: AgentSessionJournalIdentity = {
sessionId: 'session-1',
workspaceId: 'ws-1',
hostId: 'host-1',
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'thread-1' }
}
const GROUP_ID = 'thread-1:turn-1'
let root: string
let clock = 1_000
function tick(): number {
clock += 1
return clock
}
const journals = createTrackedJournalOpener()
async function open(overrides: Partial<Parameters<typeof openAgentSessionJournal>[0]> = {}) {
return journals.open({
identity: IDENTITY,
journalDir: root,
now: tick,
mintEpoch: () => `epoch-${clock}`,
...overrides
})
}
/** The row as the producer writes it: the structured block plus its twin. */
function rosterRow(agents: NativeChatSubagentEntry[]) {
return {
identity: codexSubagentGroupIdentity(GROUP_ID),
body: codexSubagentGroupBody(GROUP_ID, agents)
}
}
function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem {
const row = rosterRow(agents)
return {
itemId: agentJournalItemKey(row.identity),
revision: 1,
body: row.body,
sequence: 2,
observedAt: 1
}
}
function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry[] {
return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : []
}
function twinOf(body: AgentJournalRenderItem['body']): string | undefined {
return body.kind === 'message'
? body.blocks.find((block) => block.type === 'text')?.text
: undefined
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-journal-subagents-'))
clock = 1_000
})
afterEach(async () => {
await journals.closeAll()
await rm(root, { recursive: true, force: true })
})
describe('staleSubagentRosterRevisions', () => {
it('settles a child the previous host left working, and moves the twin with it', () => {
const revisions = staleSubagentRosterRevisions([
renderItem([
{ id: 'a', label: 'read_readme', state: 'working', startedAt: 10 },
{ id: 'b', label: 'read_package', state: 'completed', startedAt: 10, settledAt: 20 }
])
])
expect(revisions).toHaveLength(1)
expect(rosterOf(revisions[0]!.body)).toMatchObject([
{ id: 'a', state: 'unverifiable' },
{ id: 'b', state: 'completed' }
])
// Mobile reads only this sentence, so it may not go on saying `Kicked off`.
expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)')
})
// The child stopped being observable at an unknown moment. A stamp taken now
// would report the time the app was down as how long the child ran.
it('records no terminal timestamp for a child whose run length is unknown', () => {
const revisions = staleSubagentRosterRevisions([
renderItem([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
])
expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt')
})
it('owes nothing for a roster whose children all settled', () => {
expect(
staleSubagentRosterRevisions([
renderItem([{ id: 'a', label: 'read', state: 'completed', settledAt: 20 }])
])
).toEqual([])
})
it('leaves rows that carry no roster alone', () => {
expect(
staleSubagentRosterRevisions([
{
itemId: 'orca:plain',
revision: 1,
body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] },
sequence: 2,
observedAt: 1
}
])
).toEqual([])
})
// Appending under a fresh identity would add a second row rather than revise
// the one on disk, so an unaddressable key is left exactly as it is.
it('skips a row whose key cannot be parsed back to its identity', () => {
expect(
staleSubagentRosterRevisions([
{ ...renderItem([{ id: 'a', label: 'r', state: 'working' }]), itemId: 'not-a-key' }
])
).toEqual([])
})
})
describe('journal reopen after the writing host is gone', () => {
it('settles a persisted working roster to unverifiable, while the live row still reads working', async () => {
const live = await open()
const row = rosterRow([
{ id: 'a', label: 'read_readme', state: 'working', startedAt: 10 },
{ id: 'b', label: 'read_package', state: 'working', startedAt: 10 }
])
await live.appendItem(row.identity, row.body, { fence: 0 })
// Still the writing host: it can see the children, so the row says so.
const beforeRestart = live.snapshot().items.at(-1)!
expect(rosterOf(beforeRestart.body)).toMatchObject([{ state: 'working' }, { state: 'working' }])
expect(twinOf(beforeRestart.body)).toBe('Kicked off 2 subagents')
// The host dies without ever settling them — no `ended`, so no session sweep.
await live.close()
const reopened = await open()
const afterRestart = reopened.snapshot().items.at(-1)!
expect(afterRestart.itemId).toBe(beforeRestart.itemId)
expect(rosterOf(afterRestart.body)).toMatchObject([
{ id: 'a', state: 'unverifiable' },
{ id: 'b', state: 'unverifiable' }
])
expect(twinOf(afterRestart.body)).toBe('Ran 2 subagents (2 unverifiable)')
})
it('revises the row in place rather than appending a second one', async () => {
const live = await open()
const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
await live.appendItem(row.identity, row.body, { fence: 0 })
const before = live.snapshot().items.length
await live.close()
const reopened = await open()
expect(reopened.snapshot().items).toHaveLength(before)
expect(reopened.snapshot().items.at(-1)?.revision).toBe(2)
})
it('writes nothing on a second reopen once every child is settled', async () => {
const live = await open()
const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }])
await live.appendItem(row.identity, row.body, { fence: 0 })
await live.close()
const once = await open()
const revision = once.snapshot().items.at(-1)?.revision
await once.close()
const twice = await open()
expect(twice.snapshot().items.at(-1)?.revision).toBe(revision)
})
})
@@ -0,0 +1,101 @@
// A roster row left claiming live children by a host that is gone.
//
// The writing host revises its `subagent-group` rows in place while it can see
// the children, and sweeps whatever is still `working` when the provider goes
// away. A host that DIED — crash, quit, force-restart — does neither: its last
// revision goes on saying `working`, and nothing replays those children, so no
// later event can ever settle them. Opening the journal is the one moment a new
// host can state the truth about the old one: contact was lost. That is
// `unverifiable`, never a synthesized exit — see
// `docs/reference/ssh-execution-boundary.md`.
//
// Reconciles JOURNAL ROWS, not roster state: nothing here seeds the producer's
// in-process group map, so the roster's known limitation is untouched.
import {
agentJournalItemKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity,
AgentJournalRenderItem
} from '../../../shared/agent-session-journal-types'
import {
isSubagentGroupFallbackText,
normalizeSubagentState,
subagentGroupFallbackText
} from '../../../shared/native-chat-subagent-summary'
import {
isSubagentGroupBlock,
type NativeChatBlock,
type NativeChatSubagentGroupBlock
} from '../../../shared/native-chat-types'
export type JournalSubagentLivenessRevision = {
identity: AgentJournalItemIdentity
body: AgentJournalItemBody
}
/** The revisions a reopened journal owes: one per row still claiming a live
* child. Empty — the common case — when nothing was left mid-flight. */
export function staleSubagentRosterRevisions(
items: Iterable<AgentJournalRenderItem>
): JournalSubagentLivenessRevision[] {
const revisions: JournalSubagentLivenessRevision[] = []
for (const item of items) {
const body = item.body
if (body.kind !== 'message' || !body.blocks.some(hasWorkingChild)) {
continue
}
// A key that will not parse cannot be re-addressed, and appending under a
// fresh identity would duplicate the row rather than revise it.
const identity = parseAgentJournalItemKey(item.itemId)
if (!identity || agentJournalItemKey(identity) !== item.itemId) {
continue
}
revisions.push({ identity, body: { ...body, blocks: settleBlocks(body.blocks) } })
}
return revisions
}
function hasWorkingChild(block: NativeChatBlock): boolean {
return (
isSubagentGroupBlock(block) &&
block.agents.some((agent) => normalizeSubagentState(agent.state) === 'working')
)
}
/** No `settledAt`: the child stopped being observable at an unknown moment, and
* stamping the reopen would report the time the app was down as how long it
* ran. Readers already draw an unverifiable child with no stamp as having no
* known run length. */
function settleBlocks(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
const settled = blocks.map((block) =>
hasWorkingChild(block) ? settleGroup(block as NativeChatSubagentGroupBlock) : block
)
const rosters = settled.filter(isSubagentGroupBlock)
const only = rosters.length === 1 ? rosters[0] : undefined
if (!only) {
return settled
}
// The plain-text twin is all a client without the block type ever shows, so it
// has to move with the block or the two would disagree about the same row.
const twin = subagentGroupFallbackText(only.agents)
return settled.map((block) =>
block.type === 'text' && isSubagentGroupFallbackText(block.text)
? { ...block, text: twin }
: block
)
}
function settleGroup(block: NativeChatSubagentGroupBlock): NativeChatSubagentGroupBlock {
return {
...block,
agents: block.agents.map((agent) =>
normalizeSubagentState(agent.state) === 'working'
? { ...agent, state: 'unverifiable' as const }
: agent
)
}
}
@@ -28,6 +28,16 @@ describe('provider frame activity', () => {
expect(codexProviderFrameActivity('item/reasoning/summaryPartAdded', {})).toBeNull()
})
it('names a fan-out from either Codex item type that reports one', () => {
for (const type of ['collabAgentToolCall', 'subAgentActivity']) {
expect(
codexProviderFrameActivity('item/started', {
item: { type, kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' }
})
).toBe('Coordinating with another agent')
}
})
it('uses Claude descriptions and safe semantic status without exposing tool labels', () => {
expect(
claudeProviderFrameActivity('message:system:task_started', {
@@ -6,6 +6,7 @@ import {
isDeltaShapedProviderFrameKind,
PROVIDER_FRAME_CLASSIFICATIONS
} from './provider-frame-disposition'
import { unhandledProviderFrameJournalItem } from './unhandled-provider-frame'
describe('provider frame classification catalog', () => {
it('classifies every pinned Codex app-server notification method', () => {
@@ -124,7 +125,7 @@ describe('provider frame classification catalog', () => {
)
})
it('keeps subagent items visible — the only evidence a spawned agent is working', () => {
it('suppresses subAgentActivity once the roster renders it, but never collabAgentToolCall', () => {
expect(
classifyProviderFrame('codex', 'item:subAgentActivity', {
id: 'a-1',
@@ -132,7 +133,9 @@ describe('provider frame classification catalog', () => {
agentThreadId: 'thread-child',
agentPath: '/root/list_directory'
})
).toBe('timeline-substantive')
// The spawn-group roster row renders this now, so a raw gray row beside it
// would duplicate it. Suppressing it was gated on that renderer existing.
).toBe('status-chrome')
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
id: 'c-1',
@@ -161,3 +164,45 @@ describe('provider frame classification catalog', () => {
}
})
})
describe('codex subagent item disposition', () => {
it('keeps subagent lifecycle out of the transcript now that it renders as a roster row', () => {
expect(
classifyProviderFrame('codex', 'item:subAgentActivity', {
type: 'subAgentActivity',
kind: 'started',
agentThreadId: 'child-1',
agentPath: '/root/read'
})
).toBe('status-chrome')
})
it('leaves collab tool calls substantive — they may be the only subagent signal', () => {
// A session that reports no `subAgentActivity` gets no roster row, so
// suppressing this too would render its fan-out blank.
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
type: 'collabAgentToolCall',
agentsStates: {}
})
).not.toBe('status-chrome')
})
it('journals no fallback row for subagent activity', () => {
expect(
unhandledProviderFrameJournalItem('codex', 'item:subAgentActivity', {
kind: 'completed',
agentThreadId: 'child-1'
})
).toBeNull()
})
it('still surfaces a subagent frame that reports a failure', () => {
expect(
classifyProviderFrame('codex', 'item:collabAgentToolCall', {
type: 'collabAgentToolCall',
status: 'failed'
})
).toBe('error-surface')
})
})
@@ -1,4 +1,5 @@
import type { CodexAppServerNotificationMethod } from '../../codex/codex-app-server-notification-schema'
import { CODEX_SUBAGENT_ITEM_TYPE } from '../../codex/codex-subagent-activity'
import type { ClaudeStreamJsonFrameKind } from './claude-stream-json-frame-schema'
export type ProviderFrameClassification =
@@ -198,10 +199,21 @@ const CODEX_ITEM_CLASSIFICATIONS: Record<string, ProviderFrameClassification> =
// The `thread/compacted` notification is already chrome; its item form is the
// same event and must not read as a mysterious opcode row.
contextCompaction: 'status-chrome',
// Subagent lifecycle renders as the spawn-group roster row, so its raw items
// must not print a gray `codex · item:<type>` row beside it. The live
// notification path intercepts them before this catalog is reached;
// `restoreThread` replays them straight through `items.handle`, which is where
// the classification earns its keep.
//
// `collabAgentToolCall` is deliberately NOT suppressed with it. Nothing
// guarantees a session reports subagent work as `subAgentActivity` at all; one
// that only ever emits the collab tool call gets no roster row, and suppressing
// that too would leave its fan-out showing nothing.
[CODEX_SUBAGENT_ITEM_TYPE]: 'status-chrome',
// `{id, durationMs}` and nothing else — Codex's own transcript renders it as
// nothing at all. Every other item type this build does not model carries text
// a user would want (review output, an image path, hook prompt text, subagent
// progress), so those keep their visible fallback row.
// a user would want (review output, an image path, hook prompt text), so those
// keep their visible fallback row.
sleep: 'status-chrome'
}
@@ -0,0 +1,81 @@
import { isDeepStrictEqual } from 'node:util'
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
AgentSessionPreSpawnError,
isAgentSessionPreSpawnError,
rethrowAfterAgentSessionAcquisitionCleanup
} from './structured-agent-session-adapter'
import { journalIdentityFor } from './structured-agent-session-attach'
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
/** A reservation with no process behind it is only a promise to spawn; the
* adapter makes it real and the store then grants the writer. */
export async function acquireOwner(
input: AttachFlowInput,
record: AgentSessionRecord
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
const { store, rewind, now } = input
const fence = record.lease.runtimeFence
const spawnToken = record.lease.reservedSpawnToken
if (!spawnToken) {
throw new Error('agent_session_ownership_unknown')
}
// Pre-spawn proof is single-use: this retry may create a child after the durable clear.
try {
try {
record = await input.store.setReservationProcesslessProof({
sessionId: record.sessionId,
fence,
spawnToken,
processlessAt: null,
now: input.now()
})
await input.onAcquiring?.()
} catch (error) {
throw new AgentSessionPreSpawnError(error)
}
const acquired = await input.adapter.acquire({
identity: journalIdentityFor(record, input.params),
...claudeRewindAcquisitionProofs({ store, record, rewind, now }),
fence,
// Retries must recover the original reservation, not mint a second child.
spawnToken,
...(record.options ? { options: record.options } : {}),
...(input.eventSink ? { events: input.eventSink } : {})
})
const options = await readNativeSessionOptions({
adapter: input.adapter,
sessionId: record.sessionId,
fence,
...(record.options ? { priorOptions: record.options } : {})
})
if (record.lease.ownerProcess === null) {
await input.store.commitProcessIdentity({
sessionId: record.sessionId,
fence,
process: acquired.process,
now: input.now()
})
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
throw new Error('agent_session_ownership_unknown')
}
const proved = await input.store.proveOwner({
sessionId: record.sessionId,
fence,
link: acquired.link,
now: input.now(),
...(options ? { options } : {})
})
return {
record: proved,
acquisitionGeneration: acquired.acquisitionGeneration ?? null
}
} catch (error) {
if (isAgentSessionPreSpawnError(error)) {
throw error
}
return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error)
}
}
@@ -46,6 +46,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
this.owner(input.sessionId).dispatch(input)
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
this.owners.get(sessionId)?.rewindSupport?.(sessionId) ?? {
supported: false,
reason: 'unsupported'
}
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
this.owner(input.sessionId).rewind?.(input) ??
Promise.resolve({ ok: false, reason: 'unsupported' })
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
this.owner(input.sessionId).recoverRewind?.(input) ??
Promise.resolve({ ok: false, reason: 'unsupported' })
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
const compact = this.owner(input.sessionId).compact
if (!compact) {
@@ -1,3 +1,7 @@
import type {
AgentSessionRewindReason,
AgentSessionRewindSupport
} from '../../../shared/agent-session-rewind'
// What the wire needs from a provider adapter.
//
// Phase 2 implements this over the Codex app-server and the Claude Agent SDK;
@@ -8,6 +12,7 @@
import type {
AgentJournalItemIdentity,
AgentJournalItemBody,
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
@@ -34,6 +39,12 @@ export class AgentSessionAcquisitionRefusal extends Error {
}
}
export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal {
constructor(readonly rewindReason: AgentSessionRewindReason) {
super(`agent_session_rewind:${rewindReason}`)
}
}
/**
* The provider's own root process was observed to exit, but its descendant tree
* could not be verified. The lease keys on the root's pid and start time, so its
@@ -97,6 +108,14 @@ export type StructuredAgentSessionLifecycleEvent = {
export type StructuredAgentSessionAcquireInput = {
identity: AgentSessionJournalIdentity
rewind?: {
targetUuid: string
previousLeafUuid: string
dropsTurn?: string
onProved?: (leafUuid: string) => Promise<void>
}
/** Recovery restores an unproved rewind's original cursor with ordinary branch proof. */
rewindRecovery?: { leafUuid: string; onProved: () => Promise<void> }
fence: number
spawnToken: string
options?: Readonly<Record<string, string>>
@@ -131,6 +150,27 @@ export type StructuredAgentSessionAdapter = {
body: AgentJournalMessageItem
fence: number
}): Promise<AgentSessionDispatchOutcome>
rewindSupport?(sessionId: string): AgentSessionRewindSupport
recoverRewind?(input: {
sessionId: string
fence: number
beforeTurnId: string
}): Promise<
| { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
| { ok: false; reason: AgentSessionRewindReason }
>
rewind?(input: {
sessionId: string
fence: number
beforeTurnId: string
onPrepared?: (
items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]
) => Promise<void>
onReverted?: () => Promise<void>
}): Promise<
| { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
| { ok: false; reason: AgentSessionRewindReason }
>
compact?(input: {
turnId: string
sessionId: string
@@ -0,0 +1,51 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
rethrowAfterAgentSessionAcquisitionCleanup
} from './structured-agent-session-adapter'
export async function settlePostAcquisitionAttachFailure(
input: AttachFlowInput,
record: AgentSessionRecord,
cause: unknown
): Promise<never> {
let cleanupError: unknown = cause
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
try {
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
} catch (error) {
cleanupError = error
exitProof =
error instanceof AgentSessionAcquisitionExitUnprovenError
? 'unproven'
: error instanceof AgentSessionAcquisitionRootExitObservedError
? 'root-exit-observed'
: 'exit-proven'
}
// A failed close must not prevent durable failure settlement.
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
try {
await input.store.settleFailedPostAcquisitionAttachment({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
spawnToken: record.lease.reservedSpawnToken ?? '',
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: {
status: 'failed',
code: 'agent_session_operation_invalid',
message: cause instanceof Error ? cause.message : String(cause)
},
exitProof,
now: input.now()
})
} catch (settlementError) {
throw new AggregateError(
[cleanupError, settlementError],
'agent session post-acquisition attachment failure settlement failed'
)
}
throw cleanupError
}
@@ -1,11 +1,16 @@
// The attach transition end to end: reserve the lease, make the reservation
// real, open the journal.
//
// Split out of the host so the sequence reads in one place. The host still owns
// the decisions that must not be client-supplied — the spawn token, the claim
// key, the owner probe — and passes them in.
import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure'
import { rewindRefusal } from './structured-rewind-refusal'
import {
AgentSessionRewindRefusal,
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionRefusal,
isAgentSessionPreSpawnError,
type StructuredAgentSessionAcquireInput,
type StructuredAgentSessionAdapter
} from './structured-agent-session-adapter'
// The host supplies owner authority; this flow reserves, proves, and publishes the session.
import { isDeepStrictEqual } from 'node:util'
import type {
AgentSessionAttachResult,
AgentSessionMutationResult
@@ -16,32 +21,24 @@ import {
admitAttachOrRefuse,
attachJournal,
classifyStoreFailure,
journalIdentityFor,
reserveRequestFor,
type AgentSessionAttachAuthority,
type AgentSessionAttachParams,
type AttachedJournal
} from './structured-agent-session-attach'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionRefusal,
AgentSessionPreSpawnError,
isAgentSessionPreSpawnError,
rethrowAfterAgentSessionAcquisitionCleanup
} from './structured-agent-session-adapter'
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
import { readAgentSessionHydrationPage } from './agent-session-history-page'
import { acquireOwner } from './structured-agent-session-acquisition'
import {
importAdoptedTranscript,
prepareAdoptedTranscript
} from './structured-agent-session-adopted-import'
export type AttachFlowInput = {
rewind?: StructuredAgentSessionAcquireInput['rewind']
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
@@ -49,22 +46,18 @@ export type AttachFlowInput = {
callerKey: string
params: AgentSessionAttachParams
now: () => number
/** Registers the opened journal and fans out to subscribers before the caller
* sees the result, so no client can send against a session the host has not
* finished publishing. */
/** Publishes the journal before clients can send against the new owner. */
onAttached: (
attached: AttachedJournal,
acquisitionGeneration: string | null
) => Promise<void> | void
/** Handed to the adapter so it can journal what the provider streams. The
* host owns it and binds it to the journal inside `onAttached`. */
/** Host-owned provider sink, bound to the journal inside `onAttached`. */
eventSink?: StructuredAgentSessionEventSink
/** Stops acquisition-window events targeting the superseded journal. */
onAcquiring?: () => Promise<void> | void
/** Settles writes already captured by the superseded journal before opening another. */
beforeJournalOpen?: () => Promise<void> | void
/** Removes any partial host publication after journal attachment fails, and
* closes the journal handle of the map entry it drops. Awaited: see eviction. */
/** Closes and removes partial publication after journal attachment fails. */
onAttachFailed?: () => Promise<void>
}
@@ -72,15 +65,27 @@ export async function performAttach(
input: AttachFlowInput
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
const { params, store } = input
const unsupported = (): AgentSessionMutationResult<AgentSessionAttachResult> => ({
ok: false,
refusal: {
code: 'structured_agent_session_unsupported',
message: 'This execution host cannot create the requested structured agent session.'
}
})
const sessionId = params.envelope.sessionId
const admitted = admitAttachOrRefuse(params)
if (!admitted.ok) {
return admitted
}
// Ensure/recovery bypass create-intent, so recheck before reserving or spawning.
if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) {
return unsupported()
}
let record: AgentSessionRecord
let acquisitionGeneration: string | null = null
let reservedRecord: AgentSessionRecord | null = null
let unsupportedReservationSettlementAttempted = false
let replayed = false
const preparedTranscript = store.getRecord(sessionId)
? { ok: true as const, items: null }
@@ -101,6 +106,21 @@ export async function performAttach(
)
record = reserved.record
replayed = reserved.disposition === 'replayed'
// Capability can change while the durable reservation is in flight. Recheck
// every reservation at its effect boundary so it cannot bypass the support
// gate, and release a pending reservation that support drift invalidated.
reservedRecord = record
if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) {
if (
record.lease.claimStatus === 'reserved' &&
record.lease.handoffStage === 'new-owner-proving' &&
record.lease.reservedSpawnToken
) {
unsupportedReservationSettlementAttempted = true
await settleUnsupportedReservation(input, record)
}
return unsupported()
}
if (
replayed &&
reserved.operationRow.outcome.status !== 'pending' &&
@@ -115,7 +135,6 @@ export async function performAttach(
return { ok: false, refusal: replay.refusal }
}
}
reservedRecord = record
if (!agentSessionLeaseAdmitsWriter(record.lease)) {
const acquired = await acquireOwner(input, record)
record = acquired.record
@@ -123,9 +142,8 @@ export async function performAttach(
}
} catch (error) {
const spawnToken = reservedRecord?.lease.reservedSpawnToken
if (reservedRecord && spawnToken) {
// A pre-spawn failure is its own processless proof; the settlement records the
// evidence and the failed operation in one durable transaction.
if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) {
// Settle processless proof and failed operation atomically.
const exitProof = isAgentSessionPreSpawnError(error)
? 'processless'
: error instanceof AgentSessionAcquisitionExitUnprovenError
@@ -169,6 +187,9 @@ export async function performAttach(
)
}
}
if (error instanceof AgentSessionRewindRefusal) {
return rewindRefusal(error.rewindReason)
}
if (error instanceof AgentSessionAcquisitionRefusal) {
return { ok: false, refusal: { code: error.code, message: error.message } }
}
@@ -217,115 +238,30 @@ export async function performAttach(
}
}
async function settlePostAcquisitionAttachFailure(
async function settleUnsupportedReservation(
input: AttachFlowInput,
record: AgentSessionRecord,
cause: unknown
): Promise<never> {
let cleanupError: unknown = cause
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
try {
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
} catch (error) {
cleanupError = error
exitProof =
error instanceof AgentSessionAcquisitionExitUnprovenError
? 'unproven'
: error instanceof AgentSessionAcquisitionRootExitObservedError
? 'root-exit-observed'
: 'exit-proven'
record: AgentSessionRecord
): Promise<void> {
const spawnToken = record.lease.reservedSpawnToken
if (!spawnToken) {
return
}
// Why: the close is awaited so the map entry is gone only once its handle is
// released, but a failed close must not also cost the store settlement below.
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
try {
await input.store.settleFailedPostAcquisitionAttachment({
await input.store.settleFailedAcquisition({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
spawnToken: record.lease.reservedSpawnToken ?? '',
spawnToken,
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: {
status: 'failed',
code: 'agent_session_operation_invalid',
message: cause instanceof Error ? cause.message : String(cause)
code: 'structured_agent_session_unsupported',
message: 'Structured session support changed before the provider could start.'
},
exitProof,
exitProof: 'processless',
now: input.now()
})
} catch (settlementError) {
throw new AggregateError(
[cleanupError, settlementError],
'agent session post-acquisition attachment failure settlement failed'
)
}
throw cleanupError
}
/** A reservation with no process behind it is only a promise to spawn; the
* adapter makes it real and the store then grants the writer. */
async function acquireOwner(
input: AttachFlowInput,
record: AgentSessionRecord
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
const fence = record.lease.runtimeFence
const spawnToken = record.lease.reservedSpawnToken
if (!spawnToken) {
throw new Error('agent_session_ownership_unknown')
}
// Pre-spawn proof is single-use: this retry may create a child after the durable clear.
try {
try {
record = await input.store.setReservationProcesslessProof({
sessionId: record.sessionId,
fence,
spawnToken,
processlessAt: null,
now: input.now()
})
await input.onAcquiring?.()
} catch (error) {
throw new AgentSessionPreSpawnError(error)
}
const acquired = await input.adapter.acquire({
identity: journalIdentityFor(record, input.params),
fence,
// Retries must recover the original reservation, not mint a second child.
spawnToken,
...(record.options ? { options: record.options } : {}),
...(input.eventSink ? { events: input.eventSink } : {})
})
const options = await readNativeSessionOptions({
adapter: input.adapter,
sessionId: record.sessionId,
fence,
...(record.options ? { priorOptions: record.options } : {})
})
if (record.lease.ownerProcess === null) {
await input.store.commitProcessIdentity({
sessionId: record.sessionId,
fence,
process: acquired.process,
now: input.now()
})
} else if (!isDeepStrictEqual(record.lease.ownerProcess, acquired.process)) {
throw new Error('agent_session_ownership_unknown')
}
const proved = await input.store.proveOwner({
sessionId: record.sessionId,
fence,
link: acquired.link,
now: input.now(),
...(options ? { options } : {})
})
return {
record: proved,
acquisitionGeneration: acquired.acquisitionGeneration ?? null
}
} catch (error) {
if (isAgentSessionPreSpawnError(error)) {
throw error
}
return rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, error)
throw new AggregateError([error], 'agent session unsupported reservation settlement failed')
}
}
@@ -1,3 +1,5 @@
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
import { recoverStructuredRewind } from './structured-rewind-recovery'
import { recoverInterruptedCompaction } from './structured-compaction-recovery'
// The host's attach, lifted out of the host class.
//
@@ -29,7 +31,8 @@ export function attachStructuredAgentSession(
context: StructuredAgentSessionAttachContext,
callerKey: string,
params: AgentSessionAttachParams,
admitRecoveryTicket?: () => boolean
admitRecoveryTicket?: () => boolean,
rewind?: StructuredAgentSessionAcquireInput['rewind']
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
const sessionId = params.envelope.sessionId
const attaching = context.serialize(sessionId, async () => {
@@ -61,6 +64,7 @@ export function attachStructuredAgentSession(
}
const eventSink = context.runtimeState.eventSinkFor(sessionId)
const attached = await performAttach({
rewind,
store: context.deps.store,
adapter: context.deps.adapter,
journalRoot: context.deps.journalRoot,
@@ -124,6 +128,16 @@ export function attachStructuredAgentSession(
hasProviderChild: true,
acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null
})
if (!rewind) {
await recoverStructuredRewind(
context.deps.store,
sessionId,
attached.journal,
fence,
context.deps.adapter,
context.now
)
}
await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence)
if (attached.recovery) {
context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence)
@@ -11,6 +11,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open'
import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import {
acquireNativeHandoffOwner,
createStructuredAgentSessionHostHandoff,
@@ -202,6 +203,191 @@ describe('native handoff acquisition', () => {
expect(order).toEqual(['append-entered', 'append-complete', 'unbind', 'acquire'])
})
it('refuses an unsupported adapter before unbinding the TUI owner', async () => {
const location: AgentSessionExecutionLocation = {
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId: 'workspace-unsupported',
workspaceKind: 'folder'
}
const operationId = `${now}-00000000000000000000000000000011`
const reserved = await store.reserveOwner({
sessionId: 'session-handoff-unsupported',
location,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') },
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'unsupported-spawn',
claimKeyId: 'key-1',
handoffOperationId: operationId,
probe: { outcome: 'reservation-unused' },
operation: { callerKey: 'test', operationId, fingerprint: 'unsupported' },
now
})
const journal = await journals.open({
identity: {
sessionId: 'session-handoff-unsupported',
workspaceId: location.workspaceId,
hostId: location.executionHostId,
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'unsupported-thread' }
},
journalDir: join(root, 'unsupported-journal')
})
const eventSink = createDeferredStructuredAgentSessionEventSink()
eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined })
const unbind = vi.spyOn(eventSink, 'unbind')
const acquire = vi.fn<NonNullable<StructuredAgentSessionHostDeps['adapter']['acquire']>>()
const adapter = {
supportsLocation: vi.fn(() => false),
acquire
}
const session = {
journal,
params: {
envelope: {
sessionId: 'session-handoff-unsupported',
clientOperationId: `${now}-00000000000000000000000000000012`,
expectedRuntimeFence: reserved.record.lease.runtimeFence,
payloadFingerprint: 'unsupported'
},
location,
provider: 'codex' as const,
agent: 'codex' as const,
accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') },
runtimeKind: 'native' as const,
providerHandle: { kind: 'codex' as const, threadId: 'unsupported-thread' }
},
fence: reserved.record.lease.runtimeFence,
hasProviderChild: false,
acquisitionGeneration: null
}
await expect(
acquireNativeHandoffOwner(
{
store,
adapter: adapter as never,
journalRoot: root,
claimKeyId: 'key-1'
},
{
session: () => session,
findSession: () => session,
eventSink: () => eventSink,
flush: async () => undefined,
serialize: async (_sessionId, task) => task(),
subscribers: {
publish: vi.fn(),
reset: vi.fn(),
handoff: vi.fn(),
snapshot: vi.fn()
} as never,
now: () => now
},
{
sessionId: 'session-handoff-unsupported',
fence: reserved.record.lease.runtimeFence,
spawnToken: 'unsupported-spawn'
}
)
).rejects.toThrow('structured_agent_session_unsupported')
expect(unbind).not.toHaveBeenCalled()
expect(acquire).not.toHaveBeenCalled()
})
it('rechecks adapter support immediately before handoff acquisition', async () => {
const sessionId = 'session-handoff-drift'
const location: AgentSessionExecutionLocation = {
executionHostId: LOCAL_EXECUTION_HOST_ID,
wslDistro: null,
workspaceId: 'workspace-drift',
workspaceKind: 'folder'
}
const operationId = `${now}-00000000000000000000000000000021`
const reserved = await store.reserveOwner({
sessionId,
location,
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') },
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'drift-spawn',
claimKeyId: 'key-1',
handoffOperationId: operationId,
probe: { outcome: 'reservation-unused' },
operation: { callerKey: 'test', operationId, fingerprint: 'drift' },
now
})
const journal = await journals.open({
identity: {
sessionId,
workspaceId: location.workspaceId,
hostId: location.executionHostId,
agent: 'codex',
providerHandle: { kind: 'codex', threadId: 'drift-thread' }
},
journalDir: join(root, 'drift-journal')
})
const eventSink = createDeferredStructuredAgentSessionEventSink()
eventSink.bind({ journal, fence: reserved.record.lease.runtimeFence, publish: () => undefined })
const unbind = vi.spyOn(eventSink, 'unbind')
const supportsLocation = vi.fn(() => true)
supportsLocation.mockReturnValueOnce(true).mockReturnValueOnce(false)
const acquire = vi.fn<NonNullable<StructuredAgentSessionHostDeps['adapter']['acquire']>>()
const adapter = { supportsLocation, acquire }
const session = {
journal,
params: {
envelope: {
sessionId,
clientOperationId: `${now}-00000000000000000000000000000022`,
expectedRuntimeFence: reserved.record.lease.runtimeFence,
payloadFingerprint: 'drift'
},
location,
provider: 'codex' as const,
agent: 'codex' as const,
accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') },
runtimeKind: 'native' as const,
providerHandle: { kind: 'codex' as const, threadId: 'drift-thread' }
},
fence: reserved.record.lease.runtimeFence,
hasProviderChild: false,
acquisitionGeneration: null
}
await expect(
acquireNativeHandoffOwner(
{
store,
adapter: adapter as never,
journalRoot: root,
claimKeyId: 'key-1'
},
{
session: () => session,
findSession: () => session,
eventSink: () => eventSink,
flush: async () => undefined,
serialize: async (_sessionId, task) => task(),
subscribers: {
publish: vi.fn(),
reset: vi.fn(),
handoff: vi.fn(),
snapshot: vi.fn()
} as never,
now: () => now
},
{ sessionId, fence: reserved.record.lease.runtimeFence, spawnToken: 'drift-spawn' }
)
).rejects.toThrow('structured_agent_session_unsupported')
expect(supportsLocation).toHaveBeenCalledTimes(2)
expect(unbind).toHaveBeenCalledOnce()
expect(acquire).not.toHaveBeenCalled()
})
})
describe('handoff status published for a session the host no longer holds', () => {
@@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
type HostHandoffAccess = {
@@ -195,12 +196,21 @@ export async function acquireNativeHandoffOwner(
if (!record) {
throw new Error('agent_session_identity_required')
}
// Native handoff bypasses attach admission; reject before unbinding TUI ownership.
if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) {
throw new Error('structured_agent_session_unsupported')
}
const eventSink = host.eventSink(input.sessionId)
const priorBarrier = await eventSink.drained()
if (!priorBarrier.ok) {
throw priorBarrier.error
}
eventSink.unbind()
// Recheck immediately before acquisition; capability probes may drift while
// the old TUI event sink is draining.
if (!adapterSupportsCreateIfDeclared(deps.adapter, record.location, record.provider)) {
throw new Error('structured_agent_session_unsupported')
}
const acquired = await deps.adapter.acquire({
identity: journalIdentityFor(record, session.params),
fence: input.fence,
@@ -1,3 +1,4 @@
import { rewindRefusal } from './structured-rewind-refusal'
// Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a
// prompt, change an option, read the options back.
//
@@ -75,6 +76,10 @@ export function sendStructuredAgentSessionTurn(
return mutate(context, caller, params.envelope, {
...plan,
run: (ctx) => {
const rewind = context.deps.store.getRecord(ctx.sessionId)?.rewind
if (rewind?.phase === 'prepared' || rewind?.phase === 'provider-succeeded') {
return Promise.resolve(rewindRefusal('outcome-unknown'))
}
const command = context.deps.store.getRecord(ctx.sessionId)?.conversationCommand
if (
command &&
@@ -153,6 +158,14 @@ export function readStructuredAgentSessionOptions(
const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence })
return {
...options,
rewind:
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' ||
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded'
? { supported: false, reason: 'outcome-unknown' }
: (context.deps.adapter.rewindSupport?.(sessionId) ?? {
supported: false,
reason: 'unsupported'
}),
conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear']
}
})
@@ -1,3 +1,5 @@
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
import { rewindStructuredAgentSession } from './structured-agent-session-rewind'
import { StructuredConversationCommandController } from './structured-conversation-command-controller'
// Structured agent-session host: where the lease, journal, and provider adapter meet.
// Mutations share one durable admission path and serialize per session.
@@ -211,13 +213,11 @@ export class StructuredAgentSessionHost {
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } {
return this.deps.store.getVisibleSessionTabIndex()
}
getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
this.deps.store.getVisibleSessionTabIndex()
setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
return this.deps.store.setSessionTabVisibility(sessionId, visible)
}
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
this.deps.store.setSessionTabVisibility(sessionId, visible)
reconcileRestartLeases = async (): Promise<void> => {
const refusal = await this.reconcileLeases('startup')
@@ -233,8 +233,7 @@ export class StructuredAgentSessionHost {
revealSession = (sessionId: string): Promise<StructuredAgentSessionReveal> =>
this.restore.revealSession(sessionId)
private serialize = <T>(sessionId: string, task: () => Promise<T>): Promise<T> =>
this.tasks.serialize(sessionId, task)
private serialize = this.tasks.serialize.bind(this.tasks)
private restoreRenewedHandoff(sessionId: string): Promise<void> {
return this.serialize(sessionId, async () => {
@@ -307,6 +306,9 @@ export class StructuredAgentSessionHost {
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) =>
rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params)
conversationCommand = (...args: Parameters<StructuredConversationCommandController['run']>) =>
this.conversationCommands.run(...args)
conversationReplacements = () => this.conversationCommands.replacements()
@@ -26,7 +26,11 @@ export async function runSettledAgentSessionMutation<TValue>(input: {
status: 'succeeded',
sessionId: input.envelope.sessionId
})
: { status: 'failed', code: outcome.refusal.code }
: {
status: 'failed',
code: outcome.refusal.code,
...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {})
}
)
return outcome
} catch (error) {
@@ -64,6 +64,151 @@ function attachParams(
}
describe('processless structured session reservation', () => {
it('refuses an adapter that declares no create support before reserving a lease', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-'))
const store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const reserveOwner = vi.spyOn(store, 'reserveOwner')
const acquire = vi.fn<StructuredAgentSessionAdapter['acquire']>()
const adapter = {
supportsCreate: vi.fn(() => false),
acquire,
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
} as unknown as StructuredAgentSessionAdapter
await expect(
performAttach({
store,
adapter,
journalRoot: root,
authority: {
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: OPERATION,
probe: { outcome: 'reservation-unused' }
},
callerKey: 'client-1',
params: attachParams(),
now: () => NOW,
onAttached: () => {}
})
).resolves.toMatchObject({
ok: false,
refusal: { code: 'structured_agent_session_unsupported' }
})
expect(reserveOwner).not.toHaveBeenCalled()
expect(acquire).not.toHaveBeenCalled()
})
it('refuses a replay when adapter support drifts after durable reservation', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-'))
const store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const supportsCreate = vi
.fn<NonNullable<StructuredAgentSessionAdapter['supportsCreate']>>()
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
const adapter = {
supportsCreate,
acquire: vi.fn(async ({ fence, spawnToken }) => ({
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken },
link: {
linkId: 'link-1',
handle: { provider: 'codex' as const, threadId: 'thread-1' },
origin: 'created' as const,
mintedAtFence: fence,
observedAt: NOW
}
}))
} as unknown as StructuredAgentSessionAdapter
const input = {
store,
adapter,
journalRoot: root,
authority: {
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: OPERATION,
probe: { outcome: 'reservation-unused' as const }
},
callerKey: 'client-1',
params: attachParams(),
now: () => NOW,
onAttached: () => {}
}
await expect(performAttach(input)).resolves.toMatchObject({ ok: true })
await expect(performAttach(input)).resolves.toMatchObject({
ok: false,
refusal: { code: 'structured_agent_session_unsupported' }
})
expect(supportsCreate).toHaveBeenCalledTimes(3)
expect(adapter.acquire).toHaveBeenCalledOnce()
})
it('releases a new reservation when support drifts before acquisition', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-'))
const store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const supportsCreate = vi
.fn<NonNullable<StructuredAgentSessionAdapter['supportsCreate']>>()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValueOnce(true)
.mockReturnValueOnce(true)
const acquire = vi.fn<StructuredAgentSessionAdapter['acquire']>()
const adapter = { supportsCreate, acquire } as unknown as StructuredAgentSessionAdapter
const input = {
store,
adapter,
journalRoot: root,
authority: {
spawnToken: 'spawn-drift',
claimKeyId: 'key-1',
handoffOperationId: OPERATION,
probe: { outcome: 'reservation-unused' as const }
},
callerKey: 'client-1',
params: attachParams(),
now: () => NOW,
onAttached: () => {}
}
await expect(performAttach(input)).resolves.toMatchObject({
ok: false,
refusal: { code: 'structured_agent_session_unsupported' }
})
expect(acquire).not.toHaveBeenCalled()
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
handoffStage: null,
reservedSpawnToken: null,
processlessAt: null,
runtimeFence: 2,
deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' }
})
expect(store.listOperationRows()[0]?.outcome).toMatchObject({
status: 'failed',
code: 'structured_agent_session_unsupported'
})
await expect(performAttach(input)).resolves.toMatchObject({
ok: false,
refusal: { code: 'structured_agent_session_unsupported' }
})
expect(acquire).not.toHaveBeenCalled()
})
it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-'))
const storeDir = join(root, 'store')
@@ -9,17 +9,35 @@ export function adapterSupportsCreate(
location: AgentSessionExecutionLocation,
agent: string
): boolean {
return (
adapter.supportsCreate?.(location, agent) ??
(agent === 'codex' && (adapter.supportsLocation?.(location) ?? false))
)
if (adapter.supportsCreate) {
return adapter.supportsCreate(location, agent)
}
if (agent !== 'codex') {
return false
}
// Older Codex adapters exposed only location support; absence still fails closed here.
return adapter.supportsLocation?.(location) ?? false
}
/** Honors declared gates while retaining legacy adapters whose acquire path is authoritative. */
export function adapterSupportsCreateIfDeclared(
adapter: StructuredAgentSessionAdapter,
location: AgentSessionExecutionLocation,
agent: string
): boolean {
if (!adapter.supportsCreate && !adapter.supportsLocation) {
return true
}
return adapterSupportsCreate(adapter, location, agent)
}
export function adapterSupportsRecord(
adapter: StructuredAgentSessionAdapter,
record: AgentSessionRecord
): boolean {
return adapter.supportsCreate
? adapter.supportsCreate(record.location, record.provider)
: record.provider === 'codex'
if (adapter.supportsCreate) {
return adapter.supportsCreate(record.location, record.provider)
}
// Old Codex records stay readable unless the adapter explicitly rejects their location.
return record.provider === 'codex' && (adapter.supportsLocation?.(record.location) ?? true)
}
@@ -1,3 +1,4 @@
import { rewindRefusal } from './structured-rewind-refusal'
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
import {
AGENT_SESSION_WIRE_REFUSAL_CODES,
@@ -19,6 +20,9 @@ export function resolveAgentSessionReplayOutcome<TValue>(input: {
}): AgentSessionReplayOutcomeDecision<TValue> {
const { operationId, outcome } = input
if (outcome.status === 'failed') {
if (outcome.rewindReason) {
return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal }
}
const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code)
? (outcome.code as AgentSessionWireRefusalCode)
: 'agent_session_operation_invalid'
@@ -0,0 +1,514 @@
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
agentJournalItemKey,
agentJournalSubmissionKey
} from '../../../shared/agent-session-journal-item-key'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { AgentSessionRewindRefusal } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type {
StructuredAgentSessionAdapter,
StructuredAgentSessionAcquireInput,
AgentSessionDispatchOutcome
} from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION,
HOST_TEST_THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const caller = { callerKey: 'desktop' }
let directory: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let sink: StructuredAgentSessionEventSink
let adapter: StructuredAgentSessionAdapter
let acquires: StructuredAgentSessionAcquireInput[]
const rewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['rewind']>>()
const recoverRewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>>()
let failClaude = false
beforeEach(async () => {
resetHostTestOperationIds()
rewind.mockReset().mockResolvedValue({ ok: true })
recoverRewind.mockReset().mockResolvedValue({
ok: true,
items: [
{
identity: { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'kept', ordinal: 0 },
body: hostTestMessage('verified history')
}
]
})
failClaude = false
acquires = []
directory = await mkdtemp(join(tmpdir(), 'orca-rewind-'))
store = await AgentSessionRecordStore.open({
directory: join(directory, 'store'),
hostId: 'local'
})
adapter = {
supportsCreate: (_location, agent) => agent === 'codex' || agent === 'claude',
supportsLocation: () => true,
acquire: async (input) => {
acquires.push(input)
if (input.rewind && failClaude) {
throw new AgentSessionRewindRefusal('provider-refused')
}
if (input.rewind) {
await input.rewind.onProved?.(input.rewind.targetUuid)
}
await input.rewindRecovery?.onProved()
sink = input.events!
const handle = input.identity.providerHandle
return {
process: {
hostId: 'local',
pid: 4000 + acquires.length,
processStartTimeMs: HOST_TEST_NOW,
spawnToken: input.spawnToken
},
acquisitionGeneration: `generation-${acquires.length}`,
link: {
linkId: `link-${acquires.length}`,
mintedAtFence: input.fence,
observedAt: HOST_TEST_NOW,
origin: acquires.length === 1 ? 'created' : 'resumed',
handle:
handle.kind === 'claude'
? {
provider: 'claude',
sessionId: handle.sessionId,
leafUuid: input.rewind?.targetUuid ?? 'tip'
}
: { provider: 'codex', threadId: HOST_TEST_THREAD }
}
}
},
dispatch: vi.fn(async (): Promise<AgentSessionDispatchOutcome> => ({
state: 'unknown',
reason: 'test'
})),
cancelTurn: async () => ({ cancelled: false }),
answerPrompt: async () => {},
setOption: async () => {},
rewindSupport: () => ({ supported: true }),
rewind,
recoverRewind,
releaseAcquisition: async () => true,
closeSession: async () => true
}
host = new StructuredAgentSessionHost({
store,
adapter,
journalRoot: directory,
claimKeyId: 'key',
now: () => HOST_TEST_NOW,
probeOwner: async () => ({ outcome: 'exit-observed' })
})
})
afterEach(async () => {
await host.flushAllStreamedEvents()
await rm(directory, { recursive: true, force: true })
})
async function seed(provider: 'codex' | 'claude' = 'codex', acceptedSubmissions = false) {
const params =
provider === 'codex'
? hostTestAttachParams(null)
: hostTestAttachParams(null, {
provider,
agent: provider,
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/claude' },
providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'tip' }
})
expect(await host.attach(caller, params)).toMatchObject({ ok: true })
const keys = ['kept', 'drop', 'tip'].map((uuid) =>
provider === 'codex'
? { provider, threadId: HOST_TEST_THREAD, turnId: uuid, ordinal: 0 }
: { provider, sessionId: 'claude-session', uuid }
)
let selectedItemId = agentJournalItemKey(keys[1]!)
for (const [i, identity] of keys.entries()) {
const body = {
...hostTestMessage(String(i)),
role: i === 2 ? ('assistant' as const) : ('user' as const)
}
if (acceptedSubmissions && i !== 2) {
const clientOperationId = hostTestOperationId()
vi.mocked(adapter.dispatch).mockResolvedValueOnce({
state: 'accepted',
providerIdentity: identity
})
expect(
await host.send(caller, {
body,
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId,
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: HOST_TEST_SESSION,
fields: { body }
})
}
})
).toMatchObject({ ok: true })
if (i === 1) {
selectedItemId = agentJournalSubmissionKey(clientOperationId)
}
} else {
sink.appendItem(identity, body)
}
}
await host.flushStreamedEvents(HOST_TEST_SESSION)
return selectedItemId
}
function params(
itemId: string,
expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch
) {
return {
itemId,
expectedEpoch,
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.rewind',
sessionId: HOST_TEST_SESSION,
fields: { itemId, expectedEpoch }
})
}
}
}
describe('host rewind', () => {
it.each(['codex', 'claude'] as const)(
'resolves accepted %s user submissions to provider targets',
async (provider) => {
const target = await seed(provider, true)
expect(target.startsWith('orca:')).toBe(true)
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
if (provider === 'codex') {
expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' }))
} else {
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'kept', dropsTurn: 'drop' })
}
}
)
it('retains the preceding accepted Claude prompt when rewinding its assistant response', async () => {
await seed('claude', true)
const target = agentJournalItemKey({
provider: 'claude',
sessionId: 'claude-session',
uuid: 'tip'
})
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'drop' })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
})
it('finishes a durable provider success on reattach without repeating the provider mutation', async () => {
const target = await seed()
const request = params(target)
const replace = vi
.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
.mockRejectedValueOnce(new Error('disk failed'))
await expect(host.rewind(caller, request)).rejects.toThrow('disk failed')
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded')
replace.mockRestore()
const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('retries complete hydration after native acknowledgement without committing partial history', async () => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockImplementation(async (input) => {
await input.onReverted?.()
throw new Error('history unavailable')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable')
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({
phase: 'prepared',
providerApplied: true
})
recoverRewind.mockRejectedValueOnce(new Error('history still unavailable'))
await expect(
host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).rejects.toThrow('history still unavailable')
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual(
hostTestMessage('verified history')
)
expect(recoverRewind).toHaveBeenCalledTimes(2)
expect(rewind).toHaveBeenCalledTimes(1)
})
it('fences stale owners and the second of two concurrent rewinds', async () => {
const target = await seed()
const stale = params(target)
stale.envelope.expectedRuntimeFence++
expect(await host.rewind(caller, stale)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale' }
})
let finish!: () => void
rewind.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => resolve({ ok: true })
})
)
const first = host.rewind(caller, params(target))
const second = host.rewind(caller, params(target))
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
finish()
expect(await first).toMatchObject({ ok: true })
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('replaces the epoch with the retained prefix and replays without another provider call', async () => {
const target = await seed()
const request = params(target)
const result = await host.rewind(caller, request)
expect(result).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch)
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('reacquires Claude at the retained cursor with the same session and a new lease fence', async () => {
const target = await seed('claude')
const before = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
const emit = vi.fn()
const unsubscribe = host.subscribe({ id: 'after-rewind', sessionId: HOST_TEST_SESSION, emit })
emit.mockClear()
sink.appendItem(
{ provider: 'claude', sessionId: 'claude-session', uuid: 'next' },
hostTestMessage('next')
)
sink.publish()
await host.flushStreamedEvents(HOST_TEST_SESSION)
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'batch' }))
unsubscribe()
expect(acquires[1]?.rewind).toMatchObject({
targetUuid: 'kept',
previousLeafUuid: 'tip',
dropsTurn: 'drop'
})
expect(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence).toBeGreaterThan(before)
expect(store.getRecord(HOST_TEST_SESSION)!.lease.ownerProcess?.pid).toBe(4002)
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
})
it('recovers a Claude refusal with one plain resume and preserves the journal', async () => {
const target = await seed('claude')
failClaude = true
const before = host.journalSnapshot(HOST_TEST_SESSION)
expect(await host.rewind(caller, params(target))).toMatchObject({
ok: false,
refusal: { rewindReason: 'provider-refused' }
})
expect(acquires).toHaveLength(3)
expect(acquires[2]?.rewind).toBeUndefined()
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)!.lease.claimStatus).toBe('live')
})
it('refuses a rewind racing an active turn before provider execution', async () => {
const target = await seed()
sink.appendItem(
{ provider: 'orca', clientMessageId: 'active' },
{ kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }
)
expect(await host.rewind(caller, params(target))).toMatchObject({
ok: false,
refusal: { rewindReason: 'busy' }
})
expect(rewind).not.toHaveBeenCalled()
})
it('refuses stale epochs and targets from another provider', async () => {
const target = await seed()
expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({
ok: false,
refusal: { rewindReason: 'stale-epoch' }
})
expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({
ok: false,
refusal: { rewindReason: 'invalid-target' }
})
expect(rewind).not.toHaveBeenCalled()
})
it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => {
const target = await seed()
const request = params(target)
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockRejectedValue(new Error('hydration failed'))
await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed')
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(await host.rewind(caller, request)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
const body = hostTestMessage('new prompt')
const envelope = {
...params(target).envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: HOST_TEST_SESSION,
fields: { body }
})
}
expect(await host.send(caller, { envelope, body })).toMatchObject({
ok: false,
refusal: { rewindReason: 'outcome-unknown' }
})
expect(adapter.dispatch).not.toHaveBeenCalled()
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('clears an unapplied prepared rewind after observing the target still present', async () => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockRejectedValueOnce(new Error('read failed before revert'))
await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed')
recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' })
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused')
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
})
it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => {
const target = await seed()
const items = ['older', 'kept'].map((turnId) => ({
identity: { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId, ordinal: 0 },
body: hostTestMessage(turnId)
}))
rewind.mockImplementationOnce(async (input) => {
await input.onPrepared?.(items)
await input.onReverted?.()
throw new Error('lost after revert')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert')
recoverRewind.mockResolvedValueOnce({ ok: true, items })
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
})
it.each(['turn', 'item'] as const)(
'never commits a recovered prefix that omits an expected retained %s',
async (missing) => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
const items = [0, 1].map((ordinal) => ({
identity: {
provider: 'codex' as const,
threadId: HOST_TEST_THREAD,
turnId: 'kept',
ordinal
},
body: hostTestMessage(String(ordinal))
}))
rewind.mockImplementationOnce(async (input) => {
await input.onPrepared?.(items)
throw new Error('reply lost')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost')
recoverRewind.mockResolvedValueOnce({
ok: true,
items: missing === 'turn' ? [] : items.slice(0, 1)
})
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
await expect(
host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).rejects.toThrow('proof-mismatch')
expect(replace).not.toHaveBeenCalled()
replace.mockRestore()
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
}
)
it('settles the existing epoch after a crash between journal commit and record completion', async () => {
const target = await seed()
const request = params(target)
const transition = store.transitionHandoff.bind(store)
const checkpoint = vi
.spyOn(store, 'transitionHandoff')
.mockImplementation((sessionId, update) =>
transition(sessionId, (record) => {
const next = update(record)
if (next.rewind?.phase === 'completed') {
throw new Error('completion write failed')
}
return next
})
)
await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed')
const committed = host.journalSnapshot(HOST_TEST_SESSION)
expect(committed.cursor.epoch).not.toBe(request.expectedEpoch)
checkpoint.mockRestore()
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed)
expect(replace).not.toHaveBeenCalled()
replace.mockRestore()
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
})
})
@@ -0,0 +1,252 @@
import {
agentJournalItemKey,
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionRewindParams,
AgentSessionRewindRecord,
AgentSessionRewindResult
} from '../../../shared/agent-session-rewind'
import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
import { conversationCommandBlocked } from './structured-conversation-command-admission'
import { rewindRefusal } from './structured-rewind-refusal'
import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery'
import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner'
export async function rewindStructuredAgentSession(
context: StructuredAgentSessionMutationContext,
attachContext: StructuredAgentSessionAttachContext,
caller: StructuredAgentSessionCaller,
params: AgentSessionRewindParams
): Promise<AgentSessionMutationResult<AgentSessionRewindResult>> {
const { sessionId, clientOperationId } = params.envelope
const store = context.deps.store
return context.serialize(sessionId, async () => {
const result = await admitAndRunAgentSessionMutation<AgentSessionRewindResult>({
store,
adapter: context.deps.adapter,
callerKey: caller.callerKey,
envelope: params.envelope,
journal: context.sessions.get(sessionId)?.journal,
publish: (journal) => context.publish(sessionId, journal),
now: context.now,
plan: {
method: 'agentSession.rewind',
fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch },
recoverUnknownFromDurableState: true,
settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }),
replay: (_ctx, outcome) => {
if (outcome.status === 'succeeded' && outcome.rewind) {
return outcome.rewind
}
const prior = store.getRecord(sessionId)?.rewind
return prior?.operationId === clientOperationId &&
prior.callerKey === caller.callerKey &&
prior.phase === 'completed' &&
prior.epoch
? { itemId: prior.itemId, epoch: prior.epoch }
: null
},
run: async (ctx) => {
await attachContext.runtimeState.flushEventSink(sessionId)
const record = store.getRecord(sessionId)!
const support = ctx.adapter.rewindSupport?.(sessionId)
if (!support?.supported) {
return rewindRefusal(support?.reason ?? 'unsupported')
}
if (
record.rewind?.phase === 'prepared' ||
record.rewind?.phase === 'provider-succeeded'
) {
return rewindRefusal('outcome-unknown')
}
if (conversationCommandBlocked(ctx, record)) {
return rewindRefusal('busy')
}
if (ctx.journal.isReadOnly) {
return rewindRefusal('unsupported')
}
const snapshot = ctx.journal.snapshot()
const providerKeys = new Map(
snapshot.submissions.flatMap((submission) =>
submission.dispatchState === 'accepted' && submission.providerItemId
? [
[
agentJournalSubmissionKey(submission.clientMessageId),
submission.providerItemId
] as const
]
: []
)
)
const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId
if (ctx.journal.cursor().epoch !== params.expectedEpoch) {
return rewindRefusal('stale-epoch')
}
const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId)
const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId))
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
if (!key || !head || key.provider !== head.provider) {
return rewindRefusal('invalid-target')
}
let boundary = selected
let claude: Parameters<typeof replaceClaudeRewindOwner>[3] | undefined
if (key.provider === 'codex' && head.provider === 'codex') {
if (key.threadId !== head.threadId) {
return rewindRefusal('invalid-target')
}
boundary = snapshot.items.findIndex((item) => {
const identity = parseAgentJournalItemKey(providerKey(item.itemId))
return (
(identity?.provider === 'codex' &&
identity.threadId === key.threadId &&
identity.turnId === key.turnId) ||
(item.body.kind === 'status' && item.body.turnLifecycle?.turnId === key.turnId)
)
})
} else if (key.provider === 'claude' && head.provider === 'claude') {
if (key.sessionId !== head.sessionId) {
return rewindRefusal('invalid-target')
}
const previous = snapshot.items
.slice(0, boundary)
.map((item) => parseAgentJournalItemKey(providerKey(item.itemId)))
.findLast(
(identity) =>
identity?.provider === 'claude' && identity.sessionId === key.sessionId
)
if (previous?.provider !== 'claude') {
return rewindRefusal('invalid-target')
}
const prompts = snapshot.items
.slice(boundary)
.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
const prompt =
prompts.length === 1
? parseAgentJournalItemKey(providerKey(prompts[0]!.itemId))
: null
claude = {
targetUuid: previous.uuid,
previousLeafUuid: head.leafUuid ?? '',
...(prompt?.provider === 'claude' ? { dropsTurn: prompt.uuid } : {})
}
} else {
return rewindRefusal('invalid-target')
}
const retained = snapshot.items
.slice(0, boundary)
.map(({ itemId, body, observedAt }) => ({
itemId: providerKey(itemId),
body,
observedAt
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
return rewindRefusal('history-limit')
}
let prepared: AgentSessionRewindRecord = {
operationId: clientOperationId,
callerKey: caller.callerKey,
itemId: params.itemId,
providerItemId: providerKey(params.itemId),
expectedEpoch: params.expectedEpoch,
phase: 'prepared',
retained
}
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
ctx.publish()
const provider = claude
? await replaceClaudeRewindOwner(attachContext, caller.callerKey, params, claude)
: await ctx.adapter.rewind!({
sessionId,
fence: ctx.fence,
beforeTurnId: key.provider === 'codex' ? key.turnId : '',
onPrepared: async (items) => {
const retained = items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: ctx.now()
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
prepared = { ...prepared, retained }
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
},
onReverted: async () => {
await persistRewindRecord(store, sessionId, ctx.fence, {
...prepared,
providerApplied: true
})
}
})
const fence = store.getRecord(sessionId)!.lease.runtimeFence
if (!provider.ok) {
const reason =
'reason' in provider
? provider.reason
: (provider.refusal.rewindReason ?? 'outcome-unknown')
if (reason !== 'outcome-unknown') {
await persistRewindRecord(store, sessionId, fence, {
...prepared,
phase: 'refused',
reason,
retained: []
})
const currentJournal = context.sessions.get(sessionId)?.journal
if (currentJournal) {
context.publish(sessionId, currentJournal)
}
}
return rewindRefusal(reason)
}
const confirmed = provider.items
? provider.items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: ctx.now()
}))
: prepared.retained
if (
Buffer.byteLength(JSON.stringify(confirmed), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
await persistRewindRecord(store, sessionId, fence, {
...prepared,
retained: confirmed,
phase: 'provider-succeeded',
hydrationVerified: true
})
const journal = context.sessions.get(sessionId)!.journal
await attachContext.runtimeState.flushEventSink(sessionId)
await recoverStructuredRewind(store, sessionId, journal, fence)
context.publish(sessionId, journal)
return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } }
}
}
})
return result.ok
? {
...result,
fence: store.getRecord(sessionId)!.lease.runtimeFence,
cursor: context.sessions.get(sessionId)!.journal.cursor()
}
: result
})
}
@@ -46,6 +46,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
a.workspaceId === b.workspaceId &&
a.agent === b.agent &&
a.status === b.status &&
a.rewindBlockedReason === b.rewindBlockedReason &&
// Settled activity changes ranking; streaming active turns must stay quiet.
(a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
a.latestPrompt === b.latestPrompt &&
@@ -126,6 +127,9 @@ export class StructuredAgentSessionStatusFeed {
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...projectStructuredAgentSessionStatusSummary(items),
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
? { rewindBlockedReason: 'outcome-unknown' as const }
: {}),
...(model ? { model } : {}),
...(providerSession ? { providerSession } : {}),
updatedAt: journal.lastActivityAt() || this.deps.now()
@@ -7,6 +7,9 @@ export function conversationCommandBlocked(
record: AgentSessionRecord
): string | null {
const items = ctx.journal.snapshot().items
if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') {
return 'agent_session_rewind:outcome-unknown'
}
if (
record.conversationCommand?.command === 'clear' &&
record.conversationCommand.phase === 'committed' &&
@@ -0,0 +1,77 @@
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import { createHash } from 'node:crypto'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
import { attachFingerprintFields } from './structured-agent-session-attach'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration'
import { rewindRefusal } from './structured-rewind-refusal'
/** Runs within the rewind's session queue; acquisition still uses the normal reservation CAS. */
export async function replaceClaudeRewindOwner(
context: StructuredAgentSessionAttachContext,
callerKey: string,
params: AgentSessionRewindParams,
rewind: NonNullable<StructuredAgentSessionAcquireInput['rewind']>
): Promise<{ ok: true; items?: never } | ReturnType<typeof rewindRefusal>> {
const sessionId = params.envelope.sessionId
const session = context.sessions.get(sessionId)!
if (!(await context.deps.adapter.closeSession?.(sessionId))) {
return rewindRefusal('outcome-unknown')
}
session.hasProviderChild = false
const head = agentSessionProviderHandleChainHead(
context.deps.store.getRecord(sessionId)!.providerHandleChain
)?.handle
if (head?.provider !== 'claude' || !head.leafUuid) {
return rewindRefusal('invalid-target')
}
rewind = { ...rewind, previousLeafUuid: head.leafUuid }
const attach = async (intent: typeof rewind | undefined, stage: string) => {
const current = context.deps.store.getRecord(sessionId)!
const operationId = `${params.envelope.clientOperationId.split('-')[0]}-${createHash('sha256')
.update(JSON.stringify([callerKey, params.envelope.clientOperationId, stage]))
.digest('hex')
.slice(0, 32)}`
const attachParams = {
...session.params,
envelope: {
sessionId,
clientOperationId: operationId,
expectedRuntimeFence: current.lease.runtimeFence,
payloadFingerprint: ''
}
}
attachParams.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId,
fields: attachFingerprintFields(attachParams)
})
return attachStructuredAgentSession(
{
...context,
serialize: (_id, run) => run()
},
callerKey,
attachParams,
undefined,
intent
)
}
const result = await attach(rewind, 'rewind')
if (result.ok) {
return { ok: true } as const
}
if (
result.refusal.rewindReason === 'provider-refused' ||
result.refusal.rewindReason === 'proof-mismatch'
) {
const recovered = await attach(undefined, 'resume')
if (!recovered.ok) {
return rewindRefusal('outcome-unknown')
}
return rewindRefusal(result.refusal.rewindReason)
}
return rewindRefusal(result.refusal.rewindReason ?? 'outcome-unknown')
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
function setup() {
let current = agentSessionRecordFixture()
current.providerHandleChain = current.providerHandleChain.map((link) => ({
...link,
handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'tip' }
}))
current.rewind = {
operationId: 'rewind-operation',
callerKey: 'desktop',
itemId: 'selected',
expectedEpoch: 'old-epoch',
phase: 'prepared',
retained: []
}
const store: Pick<AgentSessionRecordStore, 'transitionHandoff'> = {
transitionHandoff: async (_sessionId, transition) => {
current = transition(current)
return current
}
}
return {
store,
record: () => current,
setFence: () => {
current = { ...current, lease: { ...current.lease, runtimeFence: 8 } }
}
}
}
describe('Claude rewind durable proof checkpoints', () => {
it('atomically checkpoints the exact target and resumable head before owner publication', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
})
await expect(proofs.rewind!.onProved!('wrong')).rejects.toThrow('proof-mismatch')
expect(state.record().rewind?.phase).toBe('prepared')
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
await proofs.rewind!.onProved!('kept')
expect(state.record().rewind).toMatchObject({
phase: 'provider-succeeded',
hydrationVerified: true
})
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'kept' })
expect(
claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_001,
rewind: undefined
})
).toEqual({})
})
it('restores prepared recovery through ordinary proof without carrying rewind authorization', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: undefined
})
expect(proofs.rewind).toBeUndefined()
expect(proofs.rewindRecovery?.leafUuid).toBe('tip')
expect(state.record().rewind?.phase).toBe('prepared')
await proofs.rewindRecovery!.onProved()
expect(state.record().rewind).toMatchObject({ phase: 'refused', retained: [] })
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
})
it('refuses a proof checkpoint from a superseded acquisition', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
})
state.setFence()
await expect(proofs.rewind!.onProved!('kept')).rejects.toThrow('checkpoint_stale')
expect(state.record().rewind?.phase).toBe('prepared')
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
})
})
@@ -0,0 +1,69 @@
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity'
import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
/** Proof checkpoints survive failures later in acquisition, before an owner can be published. */
export function claudeRewindAcquisitionProofs(input: {
store: Pick<AgentSessionRecordStore, 'transitionHandoff'>
record: AgentSessionRecord
rewind: StructuredAgentSessionAcquireInput['rewind']
now: () => number
}): Pick<StructuredAgentSessionAcquireInput, 'rewind' | 'rewindRecovery'> {
const { record, store } = input
const pending = record.rewind
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
if (
record.provider !== 'claude' ||
pending?.phase !== 'prepared' ||
head?.provider !== 'claude'
) {
return input.rewind ? { rewind: input.rewind } : {}
}
const checkpoint = async (leafUuid?: string): Promise<void> => {
await store.transitionHandoff(record.sessionId, (current) => {
if (
current.lease.runtimeFence !== record.lease.runtimeFence ||
current.rewind?.operationId !== pending.operationId ||
current.rewind.callerKey !== pending.callerKey ||
current.rewind.phase !== 'prepared'
) {
throw new Error('agent_session_checkpoint_stale')
}
if (leafUuid === undefined) {
return {
...current,
rewind: { ...pending, phase: 'refused', reason: 'outcome-unknown', retained: [] }
}
}
if (leafUuid !== input.rewind?.targetUuid) {
throw new Error('agent_session_rewind:proof-mismatch')
}
const observedAt = input.now()
return {
...recordAgentSessionProviderHandle({
record: current,
fence: record.lease.runtimeFence,
link: claudeProviderHandleLink({
sessionId: head.sessionId,
leafUuid,
resumed: true,
fence: record.lease.runtimeFence,
observedAt
}),
now: observedAt
}),
rewind: { ...pending, phase: 'provider-succeeded', hydrationVerified: true }
}
})
}
if (input.rewind) {
return { rewind: { ...input.rewind, onProved: checkpoint } }
}
if (!head.leafUuid) {
throw new Error('agent_session_rewind:invalid-target')
}
return { rewindRecovery: { leafUuid: head.leafUuid, onProved: () => checkpoint() } }
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind'
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
describe('rewind recovery of newer durable records', () => {
it('keeps an unknown message role and block readable without discarding the row', () => {
expect(
restoreRewindJournalBody({
kind: 'message',
role: 'future-role',
blocks: [{ type: 'future-block' }]
})
).toEqual({
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: '{"type":"future-block"}' }]
})
})
it('preserves unknown state as evidence rather than inventing success or pending work', () => {
const body = {
kind: 'tool-call' as const,
name: 'future-tool',
input: { path: 'file' },
state: 'paused-by-provider'
}
expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) })
const status = {
kind: 'status' as const,
text: 'state',
turnLifecycle: { turnId: 'turn', state: 'future-state' }
}
expect(restoreRewindJournalBody(status)).toEqual({
kind: 'status',
text: JSON.stringify(status)
})
})
it('does not reject a saved recovery prefix over a newer refusal reason', () => {
expect(
AgentSessionRewindRecordSchema.safeParse({
operationId: 'operation',
callerKey: 'caller',
itemId: 'selected',
expectedEpoch: 'old',
phase: 'provider-succeeded',
reason: 'future-reason',
retained: []
}).success
).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas'
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
type StoredBody = AgentSessionRewindRecord['retained'][number]['body']
/** Unknown future values remain visible evidence, never invented turn or prompt state. */
export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody {
let normalized: unknown = body
const fallback = () => ({ kind: 'status', text: JSON.stringify(body) })
if (body.kind === 'message') {
normalized = {
...body,
role: NATIVE_CHAT_ROLES.find((role) => role === body.role) ?? 'system',
blocks: body.blocks.map((block) => {
if (
(block.type === 'text' && 'text' in block) ||
(block.type === 'tool-call' && 'name' in block && !('state' in block)) ||
(block.type === 'tool-result' && 'output' in block) ||
block.type === 'image-ref'
) {
return block
}
if (
block.type === 'tool-call' &&
'state' in block &&
(block.state === 'running' || block.state === 'completed' || block.state === 'failed')
) {
return block
}
return { type: 'text', text: JSON.stringify(block) }
})
}
} else if (
body.kind === 'tool-call' &&
body.state !== 'running' &&
body.state !== 'completed' &&
body.state !== 'failed'
) {
normalized = fallback()
} else if (
(body.kind === 'approval' || body.kind === 'question') &&
body.resolution.state !== 'pending' &&
body.resolution.state !== 'resolved' &&
body.resolution.state !== 'cancelled'
) {
normalized = fallback()
} else if (
body.kind === 'status' &&
body.turnLifecycle &&
body.turnLifecycle.state !== 'running' &&
body.turnLifecycle.state !== 'completed'
) {
normalized = fallback()
}
if (!isAdmissibleAgentJournalItemBody(normalized)) {
throw new Error('agent_session_rewind:invalid-retained-body')
}
return normalized
}
@@ -0,0 +1,132 @@
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
import { isDeepStrictEqual } from 'node:util'
import {
agentJournalItemKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
export function persistRewindRecord(
store: AgentSessionRecordStore,
sessionId: string,
fence: number,
rewind: AgentSessionRewindRecord
): Promise<unknown> {
return store.transitionHandoff(sessionId, (record) => {
if (record.lease.runtimeFence !== fence) {
throw new Error('agent_session_checkpoint_stale')
}
return { ...record, rewind }
})
}
/** Recovery observes provider state; it never repeats an ambiguous native mutation. */
export async function recoverStructuredRewind(
store: AgentSessionRecordStore,
sessionId: string,
journal: AgentSessionJournal,
fence: number,
adapter?: StructuredAgentSessionAdapter,
now: () => number = Date.now
): Promise<void> {
let rewind = store.getRecord(sessionId)?.rewind
if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') {
return
}
const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId)
if (target?.provider === 'codex' && !rewind.hydrationVerified) {
const recovered = await adapter?.recoverRewind?.({
sessionId,
fence,
beforeTurnId: target.turnId
})
if (!recovered?.ok) {
if (
recovered?.reason === 'provider-refused' &&
rewind.phase === 'prepared' &&
!rewind.providerApplied
) {
await persistRewindRecord(store, sessionId, fence, {
...rewind,
phase: 'refused',
reason: recovered.reason,
retained: []
})
return
}
throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`)
}
const expectedItems = new Set(rewind.retained.map((item) => item.itemId))
const observedItems = new Set<string>()
for (const { identity } of recovered.items) {
const itemId = agentJournalItemKey(identity)
if (
identity.provider !== 'codex' ||
identity.threadId !== target.threadId ||
!expectedItems.has(itemId)
) {
throw new Error('agent_session_rewind:proof-mismatch')
}
observedItems.add(itemId)
}
if (observedItems.size !== expectedItems.size) {
throw new Error('agent_session_rewind:proof-mismatch')
}
const retained = recovered.items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: now()
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
rewind = { ...rewind, retained, phase: 'provider-succeeded', hydrationVerified: true }
await persistRewindRecord(store, sessionId, fence, rewind)
}
if (rewind.phase !== 'provider-succeeded') {
return
}
const replacement = rewind.retained.map((item) => {
const identity = parseAgentJournalItemKey(item.itemId)
if (!identity) {
throw new Error('agent_session_rewind:invalid-retained-identity')
}
return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt }
})
// A crash after the journal transaction must settle its existing epoch, not replace it twice.
const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch
if (
alreadyReplaced &&
!isDeepStrictEqual(
journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })),
replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body }))
)
) {
throw new Error('agent_session_rewind:stale-epoch')
}
const cursor = alreadyReplaced
? journal.cursor()
: await journal.replaceEpochItems('handle_forked', fence, replacement)
await persistRewindRecord(store, sessionId, fence, {
...rewind,
phase: 'completed',
epoch: cursor.epoch,
retained: []
})
await store.recordOperationOutcome({
callerKey: rewind.callerKey,
operationId: rewind.operationId,
outcome: {
status: 'succeeded',
sessionId,
rewind: { itemId: rewind.itemId, epoch: cursor.epoch }
}
})
}
@@ -0,0 +1,24 @@
import {
AGENT_SESSION_REWIND_REASONS,
type AgentSessionRewindReason
} from '../../../shared/agent-session-rewind'
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
export function rewindRefusal(reason: AgentSessionRewindReason): {
ok: false
refusal: AgentSessionWireRefusal
} {
const knownReason =
AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown'
return {
ok: false,
refusal: {
code:
knownReason === 'outcome-unknown'
? 'agent_session_operation_unknown'
: 'agent_session_operation_invalid',
message: `agent_session_rewind:${knownReason}`,
rewindReason: knownReason
}
}
}
@@ -17,7 +17,7 @@ import {
admitSelfInitiatedTreeKill,
installMainProcessTreeKillGate
} from './own-chromium-tree-kill-guard'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill'
import { setProcessTreeKillGate } from '../shared/child-process/process-tree-kill-gate'
import { resetSelfInitiatedTreeKillLogForTest } from './crash-reporting/self-initiated-tree-kill-log'
import {
@@ -34,7 +34,7 @@ import { terminateNotebookProcessTree } from './ipc/notebook'
import { killLocalPrecheckProcessTree } from './automations/precheck-runner'
import { killRecipeProcess } from '../shared/ephemeral-vm-recipe-process'
import { killSpawnedCommandTree } from './git/command-runner/spawned-command-tree-kill'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-session'
import { killCodexAppServerProcessTree } from './codex/codex-app-server-process-tree-kill'
import { signalProcessTree } from '../shared/child-process/process-tree-termination'
import { killSourceControlAgentProcess } from './text-generation/source-control-local-process'
import { terminateCodexTurnProcesses } from './codex/codex-structured-turn-processes'
@@ -0,0 +1,42 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const { isWindowsProcessStartTimeAvailable, readWindowsProcessIdentityTableFresh } = vi.hoisted(
() => ({
isWindowsProcessStartTimeAvailable: vi.fn(() => true),
readWindowsProcessIdentityTableFresh: vi.fn()
})
)
vi.mock('../windows/windows-process-table', async (importOriginal) => ({
...(await importOriginal<object>()),
isWindowsProcessStartTimeAvailable,
readWindowsProcessIdentityTableFresh
}))
const { readProcessStartTimesMs } = await import('./agent-session-process-identity-probe')
const START_TIME = 1_700_000_000_000
afterEach(() => {
isWindowsProcessStartTimeAvailable.mockReset()
isWindowsProcessStartTimeAvailable.mockReturnValue(true)
readWindowsProcessIdentityTableFresh.mockReset()
})
describe('Windows owner identity batch probe', () => {
it('reads Windows start times for a batch from one process-table snapshot', async () => {
readWindowsProcessIdentityTableFresh.mockResolvedValue([
{ pid: 4242, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME },
{ pid: 4243, ppid: 1, name: 'codex.exe', creationTimeMs: START_TIME + 10 }
])
await expect(readProcessStartTimesMs([4242, 4243, 4242], 'win32')).resolves.toEqual(
new Map([
[4242, START_TIME],
[4243, START_TIME + 10]
])
)
expect(readWindowsProcessIdentityTableFresh).toHaveBeenCalledOnce()
})
})
@@ -131,6 +131,25 @@ async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null
}
}
async function readWindowsProcessStartTimesMs(
pids: readonly number[]
): Promise<Map<number, number | null>> {
const observed = new Map<number, number | null>(pids.map((pid) => [pid, null]))
if (pids.length === 0 || !isWindowsProcessStartTimeAvailable()) {
return observed
}
try {
const table = await readWindowsProcessIdentityTableFresh()
const startTimesByPid = new Map(table.map((row) => [row.pid, row.creationTimeMs ?? null]))
for (const pid of pids) {
observed.set(pid, startTimesByPid.get(pid) ?? null)
}
} catch {
// A missing process table is unknown, never evidence that every owner exited.
}
return observed
}
/**
* Process start time is the cross-platform PID-reuse guard when no provider hook can echo the
* spawn token back to the owner probe.
@@ -160,6 +179,9 @@ export async function readProcessStartTimesMs(
const table = await readDarwinProcessStartTimesMs(uniquePids)
return new Map(uniquePids.map((pid) => [pid, table.get(pid) ?? null]))
}
if (platform === 'win32') {
return readWindowsProcessStartTimesMs(uniquePids)
}
return new Map(
await Promise.all(
uniquePids.map(async (pid) => [pid, await readProcessStartTimeMs(pid, platform)] as const)
@@ -20,6 +20,7 @@ import {
browserUnavailableMessage
} from '../../shared/runtime-types'
import { runtimeTerminalDegradation } from './native-terminal-availability'
import { isWindowsProcessStartTimeAvailable } from '../windows/windows-process-table'
import type { RuntimeWorktreeLifecycleEvent } from './orca-runtime-core'
import { WORKTREE_CREATE_RESULT_TTL_MS } from './orca-runtime-core'
import type { RuntimePtyController } from './runtime-pty-controller-contract'
@@ -56,6 +57,10 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId {
const hasOffscreen = !hasRenderer && Boolean(this.offscreenBrowserBackend)
const hasHeadlessCommands = runtimeBrowserCommandsFactoryIsHeadless()
const canBrowse = hasRenderer || hasOffscreen
// This field reports current Windows process-identity proof. Structured RPC
// support itself stays advertised; agentSession.createSupport owns current eligibility.
const windowsProcessStartTimeAvailable =
process.platform === 'win32' && isWindowsProcessStartTimeAvailable()
const capabilities: RuntimeCapability[] = RUNTIME_CAPABILITIES.filter(
(capability) =>
(capability !== 'browser.screencast.v1' || canBrowse) &&
@@ -110,6 +115,7 @@ export class OrcaRuntimeWithGetStatus extends OrcaRuntimeWithGetRuntimeId {
capabilities,
...(degradations.length > 0 ? { degradations } : {}),
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_RESULT_TTL_MS },
...(windowsProcessStartTimeAvailable ? { windowsProcessStartTimeAvailable } : {}),
hostPlatform: process.platform,
terminalWindowsShell: this.store?.getSettings?.().terminalWindowsShell ?? null,
floatingWorkspaceEnabled: this.store?.getSettings?.().floatingTerminalEnabled !== false,
@@ -22,6 +22,8 @@ import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentS
import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends OrcaRuntimeWithStopStructuredSessionProcess {
protected async resolveRecoveredStructuredTuiTranscript(input: {
@@ -95,14 +97,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
protected async resolveStructuredAgentSessionLocation(worktreeSelector: string) {
const target = await this.resolveRuntimeFileTarget(worktreeSelector)
const repo = this.store?.getRepo(target.worktree.repoId)
// WSL routing describes *this* machine; no remote or runtime host may inherit it.
const wslDistro =
repo && target.executionHostId === LOCAL_EXECUTION_HOST_ID
const folderScope = parseWorkspaceKey(target.worktree.id)
const folderWorkspace = folderScope?.type === 'folder'
// WSL routing describes *this* machine; no remote or runtime host may inherit
// it. Both branches key on executionHostId: the target no longer carries a
// connectionId, which used to spell remote, unresolved and local alike.
const isLocalHost = target.executionHostId === LOCAL_EXECUTION_HOST_ID
const configuredWslDistro =
repo && isLocalHost
? (getLocalProjectWorktreeGitOptions(this.requireStore(), repo).wslDistro ?? null)
: null
const folderWorkspace = this.store
?.getFolderWorkspaces?.()
.some((workspace) => workspace.id === target.worktree.id)
// Folder workspaces have no repo Git options, so a WSL UNC path is the only
// durable signal that native Windows structured Codex cannot safely use it.
const wslDistro =
configuredWslDistro ??
(folderWorkspace && isLocalHost
? (parseWslUncPath(target.worktree.path)?.distro ?? null)
: null)
return {
executionHostId: target.executionHostId,
wslDistro,

Some files were not shown because too many files have changed in this diff Show More