mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 00:02:10 +00:00
Merge remote-tracking branch 'origin/main' into mobile-rearch
# Conflicts: # src/main/runtime/rpc/methods/native-chat.ts
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 } : {})
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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)
|
||||
|
||||
+186
@@ -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()
|
||||
|
||||
+5
-1
@@ -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) {
|
||||
|
||||
+145
@@ -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')
|
||||
|
||||
+25
-7
@@ -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
Reference in New Issue
Block a user