From e08496fee88be2dfbf89cff64e9db8a85fb0c180 Mon Sep 17 00:00:00 2001 From: Wolf-L <50284298+Wolf-L@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:40:33 +0800 Subject: [PATCH] feat: support native persistent session queues --- .github/workflows/compiled-process-smoke.yml | 29 + .github/workflows/native-queue.yml | 43 ++ README.md | 1 + docs/native-queue-controls.md | 103 +++ package.json | 2 + scripts/CodexProcessTermination-smoke.mjs | 47 ++ scripts/native-queue.mjs | 106 +++ scripts/native-queue/probe.py | 395 ++++++++++ scripts/native-queue/queue_probe.py | 257 +++++++ scripts/native-queue/spawn-audit.cjs | 13 + src/CodexAcpClient.ts | 100 ++- src/CodexAcpServer.ts | 696 ++++++++++++++++-- src/CodexAppServerClient.ts | 21 + src/CodexJsonRpcConnection.ts | 99 ++- src/CodexProcessHelpers.ts | 88 +++ src/SessionIndexService.ts | 16 +- src/SessionQueue.ts | 200 +++++ .../session-queue-client-generation.test.ts | 157 ++++ .../session-queue-lifecycle.test.ts | 127 ++++ .../session-queue-maintainer.test.ts | 240 ++++++ .../session-queue-resume.test.ts | 241 ++++++ .../session-queue-review.test.ts | 254 +++++++ .../session-queue-server-final-review.test.ts | 82 +++ .../session-queue-subagents.test.ts | 207 ++++++ .../session-queue-upstream.test.ts | 194 +++++ .../CodexProcessTermination.failures.test.ts | 152 ++++ src/__tests__/CodexProcessTermination.test.ts | 176 +++++ src/__tests__/SessionQueue.test.ts | 202 +++++ .../fixtures/CodexProcessTermination.cjs | 48 ++ .../CodexProcessTermination.compiled.ts | 90 +++ .../CodexProcessTermination.entrypoint.cjs | 43 ++ src/index.ts | 57 +- src/subagents/CodexSubagentSubscriptions.ts | 104 ++- 33 files changed, 4446 insertions(+), 144 deletions(-) create mode 100644 .github/workflows/compiled-process-smoke.yml create mode 100644 .github/workflows/native-queue.yml create mode 100644 docs/native-queue-controls.md create mode 100644 scripts/CodexProcessTermination-smoke.mjs create mode 100644 scripts/native-queue.mjs create mode 100644 scripts/native-queue/probe.py create mode 100644 scripts/native-queue/queue_probe.py create mode 100644 scripts/native-queue/spawn-audit.cjs create mode 100644 src/CodexProcessHelpers.ts create mode 100644 src/SessionQueue.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-resume.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-review.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-subagents.test.ts create mode 100644 src/__tests__/CodexACPAgent/session-queue-upstream.test.ts create mode 100644 src/__tests__/CodexProcessTermination.failures.test.ts create mode 100644 src/__tests__/CodexProcessTermination.test.ts create mode 100644 src/__tests__/SessionQueue.test.ts create mode 100644 src/__tests__/fixtures/CodexProcessTermination.cjs create mode 100644 src/__tests__/fixtures/CodexProcessTermination.compiled.ts create mode 100644 src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs diff --git a/.github/workflows/compiled-process-smoke.yml b/.github/workflows/compiled-process-smoke.yml new file mode 100644 index 000000000..9e7598bb9 --- /dev/null +++ b/.github/workflows/compiled-process-smoke.yml @@ -0,0 +1,29 @@ +name: Compiled POSIX process smoke + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + compiled-process-smoke: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 + with: + bun-version: 1.3.11 + - run: npm ci + - name: Compile and exercise native override without Node on child PATH + timeout-minutes: 3 + run: npm run test:compiled-process diff --git a/.github/workflows/native-queue.yml b/.github/workflows/native-queue.yml new file mode 100644 index 000000000..f1b0459c4 --- /dev/null +++ b/.github/workflows/native-queue.yml @@ -0,0 +1,43 @@ +name: Native queue E2E + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: {} + +jobs: + native-queue: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: '24' + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install locked dependencies (including native optional package) + run: npm ci + - name: Fresh build and real native queue tests with loopback replies + timeout-minutes: 5 + run: npm run test:native-queue + - name: Preserve build hashes, RPC, model, spawn and failure evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: native-queue-${{ matrix.os }} + path: tmp/native-queue/ + include-hidden-files: true + if-no-files-found: warn + retention-days: 7 diff --git a/README.md b/README.md index 0c4ba6678..e12d3c4d3 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] ## Features +- Native persistent [message queue controls](docs/native-queue-controls.md), including edit, reorder, cancellation and explicit start. - ChatGPT, API key, and client-provided custom gateway authentication. - Model, reasoning effort, fast mode, approval, and sandbox mode configuration. - Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/air-extensions.md#recommended-config-values) capability. diff --git a/docs/native-queue-controls.md b/docs/native-queue-controls.md new file mode 100644 index 000000000..50101879a --- /dev/null +++ b/docs/native-queue-controls.md @@ -0,0 +1,103 @@ +# Native persistent queue controls / 原生持久队列 + +## English + +Inspect `initialize._meta.queue.actions`; only individually probed native methods +are advertised. The tested floor is native 0.160.1, not an asserted first release. +All requests use `_session/queue` and a `sessionId`: + +| action | Additional fields | Native result | +| --- | --- | --- | +| list | optional cursor, limit | data, nextCursor | +| add | input, clientUserMessageId | queuedSubmission | +| update | queuedSubmissionId, input | queuedSubmission | +| delete | queuedSubmissionId | deleted | +| reorder | queuedSubmissionIds | empty object | +| start | optional queuedSubmissionId | turn | + +Responses are `{status:"ok",result:...}` or `{status:"unsupported"}`. Native +failures stay errors. `input` uses native UserInput, including `text_elements`; +this method does not convert ACP prompt blocks. `update` preserves the original +client message ID. Reorder supplies every current pending ID exactly once; a +stale set is rejected by native storage. List again after a conflict. + +Queue persistence and scheduling use Codex's original `thread/queue/*` methods. +Adding to a loaded idle thread can start generation immediately. While a turn +runs, additions remain pending. Successful or failed completion advances the +queue. Consumption means accepted for execution, not successful completion. +Explicit start can select a non-head entry; subsequent entries retain their +order and drain automatically. It does not copy the entry into `session/prompt`. + +The adapter installs notifications and interactive delegates before add/start or +native resume. Autonomous turns use fresh event and permission contexts. +Ordinary ACP updates carry output/tools/usage; `_session/queue/turn` carries +`{sessionId,turn}` at start and completion, including native status/error. +`_session/queue/changed` carries `{sessionId}` as an invalidation, not a snapshot +or an acknowledgement that generation has stopped. These notification names +are also exposed in the capability. Hosts must register them before use. + +`session/cancel` interrupts the current owned turn. A native interrupted turn +preserves pending messages and suppresses automatic advancement; use explicit +start to continue. Cancel racing a completed turn is not an atomic pause of the +whole queue. Queue entries can still be edited/reordered/deleted during active +generation. Close keeps the closing fence while interrupting/unsubscribing. +The native unsubscribe operation is not itself a durable pause. + +Unloaded queues can be listed, edited, reordered and deleted without resuming. +Add without an ACP session requires native `notLoaded` status, because an +unsubscribed but still loaded thread can auto-dispatch. Load an idle session +before explicit start. Load/resume may automatically execute a non-interrupted +persisted queue. Ordinary prompts are refused while a pending/active queue is +observed; use the queue start/control operations or explicit steering. +External native writers remain outside the adapter's admission fence. + +Queue mutations are serialized against local lifecycle/settings changes and +bounded at 30 seconds. A timeout invalidates and terminates the provider using +the existing runtime recovery path. An ambiguous mutation failure fences later +queue writes until successful load/reconciliation. Never automatically retry an +add/start: admission and durable queue deletion are not one atomic operation. +No exactly-once execution, independent pause API or arbitrary native RPC tunnel +is promised. Subagent behavior remains subject to existing negotiated support. + +Native source references: `codex-rs/app-server/src/request_processors/thread_queue_processor.rs`, +`codex-rs/ext/queue/src/service.rs`, `codex-rs/state/src/runtime/queued_items.rs` +at `e9e6cf63492da108a3cabaf3cc7b23d0640ef4cf`. Desktop calls the same six queue +operations. Source semantics and executable verification are separate evidence. + +After an app-server crash, queue actions re-probe the replacement server. A session +that was loaded in the dead server requires an explicit `session/load` before queue +mutations; an uncertain write is never replayed. Lazy recovery of an ordinary prompt +refuses to resume a nonempty queue. Explicit load subscribes before native resume and +retains cancellation while history is buffered. Direct session-index rename/archive/ +unarchive requests share the queue reservation and cannot overlap a pending queue +write or cancellation. Existing serialized index writes retain their ordering. + +## 中文 + +客户端先读取 `initialize._meta.queue.actions`,仅调用逐项原生探测通过的动作。 +接口 `_session/queue` 统一带 `sessionId`,参数和返回见上表。新增/编辑采用原生 +UserInput(含 `text_elements`),不把 ACP prompt 格式直接混入。编辑保留原始 +clientUserMessageId;重排必须包含当前全部待发 ID 且各一次,集合陈旧则重读后处理。 + +原生负责持久化和派发,不另建调度器。空闲已加载会话的 add 可能立即生成;生成中 +新增则排队,正常结束或失败后继续下一条。start 可选择非队首消息,该条结束后按 +剩余顺序继续。不能通过重发同一文本模拟 start,否则可能重复执行。 + +ACP 在 resume/add/start 前注册事件和审批处理,自动轮次使用独立生命周期。 +输出/工具/用量沿用普通 session/update;`_session/queue/turn` 通知开始/结束及原生 +turn 状态,`_session/queue/changed` 提醒重读列表。前端应先注册这两个通知。 + +session/cancel 中断当前轮次;原生 interrupted 状态保留剩余消息、暂停自动推进, +显式 start 恢复。取消恰好遇到轮次完成不构成全队列原子暂停,unsubscribe 也不等于 +持久暂停。生成中允许编辑/重排/删除待发项。未加载队列可管理;无 ACP 本地会话时 +add 必须确认原生 notLoaded,避免未订阅但仍已加载的线程自动生成无人处理。 +load/resume 可能恢复非 interrupted 队列并自动执行。 + +app-server 崩溃后重新探测替代进程的能力。旧进程中已加载的会话需显式 +session/load 后再修改队列;普通 prompt 的自动恢复不会隐式派发非空持久队列。 +原生恢复前先订阅,历史缓冲期间仍可取消。sessionIndex 的直接重命名、归档、 +取消归档接口也与队列写入及取消互斥,同时保留原有索引写入串行顺序。 + +30 秒 mutation 超时沿用 provider 失效与终止流程;结果不明确时要求成功 load 对账。 +不自动重试 add/start、不承诺 exactly-once。外部原生客户端的并发写入不受 ACP +本地保护锁控制。此次仅扩展 ACP,Codeg 界面接入和全局安装不在本次改动中。 diff --git a/package.json b/package.json index 297f0a972..5fd2a6cd3 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "release:preflight": "bash scripts/release-preflight.sh", "test": "vitest run --no-file-parallelism --retry=2", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", + "test:native-queue": "node scripts/native-queue.mjs", + "test:compiled-process": "node scripts/CodexProcessTermination-smoke.mjs", "test:watch": "vitest", "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", "codex-test": "tsx .claude/skills/run-codex/scripts/run-codex-test.ts" diff --git a/scripts/CodexProcessTermination-smoke.mjs b/scripts/CodexProcessTermination-smoke.mjs new file mode 100644 index 000000000..0c9675297 --- /dev/null +++ b/scripts/CodexProcessTermination-smoke.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// Linux/macOS: node scripts/CodexProcessTermination-smoke.mjs +// Offline: append --compile-executable-path=/absolute/path/to/bun +// Containers must use --init so killed orphan group members are reaped. +import {spawnSync} from "node:child_process"; +import {mkdtempSync, mkdirSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; + +if (process.platform === "win32") throw new Error("Run this POSIX process-group smoke test on Linux/macOS (containers: --init)"); +const options = process.argv.slice(2); +if (options.length > 1 || options.some(value => !value.startsWith("--compile-executable-path="))) { + throw new Error("Usage: node scripts/CodexProcessTermination-smoke.mjs [--compile-executable-path=/absolute/path/to/bun]"); +} +const root = fileURLToPath(new URL("../", import.meta.url)); +const work = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-smoke-")); +const noNode = path.join(work, "empty-path"); +mkdirSync(noNode); +// Keep fixture runs isolated from the user's ACP configuration and log settings. +const env = {...process.env, APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: ""}; +function run(command, args, runEnv = env) { + const result = spawnSync(command, args, { + cwd: root, env: runEnv, stdio: "inherit", timeout: 60_000, + killSignal: "SIGKILL", windowsHide: true, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${path.basename(command)} failed: status=${result.status}, signal=${result.signal}`); +} +function compile(source, name) { + const output = path.join(work, name); + // Native host target avoids cross-runtime downloads; an explicit cached Bun + // executable may be supplied for fully offline reproduction. + run("bun", ["build", "--minify", "--compile", ...options, source, "--outfile", output]); + return output; +} +try { + const fixture = compile("src/__tests__/fixtures/CodexProcessTermination.cjs", "native-fixture"); + const transport = compile("src/__tests__/fixtures/CodexProcessTermination.compiled.ts", "transport-smoke"); + const acp = compile("src/index.ts", "codex-acp"); + run(transport, [fixture, "native"], {...env, PATH: noNode}); + run(process.execPath, ["src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs", acp, fixture], + {...env, PATH: noNode}); + console.log("PASS compiled POSIX smoke: no Node on child PATH; group termination, EOF, timeout and ACP entrypoint verified"); +} finally { + rmSync(work, {recursive: true, force: true}); +} diff --git a/scripts/native-queue.mjs b/scripts/native-queue.mjs new file mode 100644 index 000000000..3533aea9f --- /dev/null +++ b/scripts/native-queue.mjs @@ -0,0 +1,106 @@ +// No account, external provider, globally installed Codex, or saved fixture is used. +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +let output, wrongReply = false; +for (let i = 0; i < args.length; i++) { + if (args[i] === '--wrong-reply' && !wrongReply) wrongReply = true; + else if (args[i] === '--output' && !output && args[i + 1] && !args[i + 1].startsWith('--')) output = args[++i]; + else { + console.error('Usage: npm run test:native-queue -- [--wrong-reply] [--output ]'); + process.exit(2); + } +} +if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24+ is required'); +const parent = path.resolve(output ?? path.join(repo, 'tmp', 'native-queue')); +mkdirSync(parent, { recursive: true }); +const run = mkdtempSync(path.join(parent, 'run-')); +for (const name of ['home', 'workspace', 'tmp', 'home/appdata', 'home/localappdata', 'home/.config', 'home/.cache']) { + mkdirSync(path.join(run, name), { recursive: true }); +} +// A whitelist deliberately excludes credentials, proxies, NODE_OPTIONS, Git +// overrides, Codex configuration, and the invoking user's profile directories. +const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => + ['PATH', 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT'].includes(key.toUpperCase()))); +Object.assign(env, { + HOME: path.join(run, 'home'), USERPROFILE: path.join(run, 'home'), + CODEX_HOME: path.join(run, 'home'), APPDATA: path.join(run, 'home/appdata'), + LOCALAPPDATA: path.join(run, 'home/localappdata'), + XDG_CONFIG_HOME: path.join(run, 'home/.config'), XDG_CACHE_HOME: path.join(run, 'home/.cache'), + TMP: path.join(run, 'tmp'), TEMP: path.join(run, 'tmp'), TMPDIR: path.join(run, 'tmp'), + GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: path.join(run, 'home/.gitconfig'), + GIT_OPTIONAL_LOCKS: '0', +}); +writeFileSync(env.GIT_CONFIG_GLOBAL, ''); +const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex'); +function walk(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const file = path.join(dir, entry.name); + return entry.isDirectory() ? walk(file) : [file]; + }).sort(); +} +function sources() { + return Object.fromEntries([...walk(path.join(repo, 'src')), + ...walk(path.join(repo, 'scripts/native-queue')), + ...['package.json', 'package-lock.json', 'build.mjs', 'scripts/native-queue.mjs'].map((name) => path.join(repo, name))] + .map((file) => [path.relative(repo, file), hash(file)])); +} +function command(binary, argv) { + const result = spawnSync(binary, argv, { + cwd: repo, env, windowsHide: true, encoding: 'utf8', timeout: 120_000, + }); + if (result.error || result.status !== 0) { + throw new Error(`${binary} failed: ${result.error ?? result.stderr ?? result.status}`); + } + return result.stdout.trim(); +} +try { + const require = createRequire(path.join(repo, 'package.json')); + const codexManifest = require.resolve('@openai/codex/package.json'); + const installed = JSON.parse(readFileSync(codexManifest, 'utf8')); + const version = installed.version.match(/^(\d+)\.(\d+)\.(\d+)$/)?.slice(1).map(Number); + if (!version || !(version[0] > 0 || version[1] > 160 || (version[1] === 160 && version[2] >= 1))) { + throw new Error(`Native queue requires @openai/codex 0.160.1+, found ${installed.version}`); + } + const packageName = `@openai/codex-${process.platform}-${process.arch}`; + if (!installed.optionalDependencies?.[packageName]) throw new Error(`Unsupported native platform: ${packageName}`); + const nativeManifest = createRequire(codexManifest).resolve(`${packageName}/package.json`); + // Discover the actual installed layout; do not assume a vendor triple or bin/codex subdirectory. + const candidates = walk(path.join(path.dirname(nativeManifest), 'vendor')) + .filter((file) => path.basename(file) === (process.platform === 'win32' ? 'codex.exe' : 'codex')); + if (candidates.length !== 1) throw new Error(`Expected one installed native executable, found ${candidates.length}`); + const native = realpathSync(candidates[0]); + const nativeVersion = command(native, ['--version']); + if (nativeVersion !== `codex-cli ${installed.version}`) throw new Error(`Native/package version mismatch: ${nativeVersion}`); + const before = sources(); + writeFileSync(path.join(run, 'build.log'), command(process.execPath, [path.join(repo, 'build.mjs')])); + if (JSON.stringify(before) !== JSON.stringify(sources())) throw new Error('Sources changed during build; rerun after concurrent edits finish'); + const metadata = { + repo, run, wrongReply, node: process.execPath, nodeVersion: process.version, native, + nativePackage: packageName, codexVersion: installed.version, + nativeVersion, nativeSha256: hash(native), + distSha256: hash(path.join(repo, 'dist/index.js')), sources: before, + }; + const manifest = path.join(run, 'build.json'); + writeFileSync(manifest, JSON.stringify(metadata, null, 2) + '\n'); + const python = process.env.NATIVE_E2E_PYTHON || (process.platform === 'win32' ? 'python' : 'python3'); + console.log(`Native queue E2E artifacts: ${run}`); + const result = spawnSync(python, ['-B', path.join(repo, 'scripts/native-queue/probe.py'), manifest], { + // Python bounds each RPC/child shutdown and cleans up in finally. Killing + // just Python on a launcher timeout would orphan its owned ACP/native tree. + cwd: repo, env, windowsHide: true, stdio: 'inherit', + }); + if (result.error) throw result.error; + process.exitCode = result.status ?? 1; +} catch (error) { + writeFileSync(path.join(run, 'launcher-failure.txt'), String(error.stack ?? error)); + console.error(error); + console.error(`Native queue E2E artifacts: ${run}`); + process.exitCode = 1; +} diff --git a/scripts/native-queue/probe.py b/scripts/native-queue/probe.py new file mode 100644 index 000000000..cd62baf59 --- /dev/null +++ b/scripts/native-queue/probe.py @@ -0,0 +1,395 @@ +"""Real ACP/native processes over stdio, with a scripted loopback Responses server. + +Only the model is fake. Never read native rollout files, write ACP history, or +execute file tools in the test client. Invoke through ../native-queue.mjs so every +run is fresh and bound to a just-built artifact. Python 3 standard library only. +""" +import gzip +import hashlib +import http.server +import json +import os +from pathlib import Path +import queue +import shutil +import signal +import subprocess +import sys +import threading +import time +import traceback + + +TIMEOUT = 35 +FLAGS = getattr(subprocess, "CREATE_NO_WINDOW", 0) + + +def require(condition, message): + # Do not use assert: python -O must still fail a broken native test. + if not condition: + raise AssertionError(message) + + +def sha(file): + return hashlib.sha256(Path(file).read_bytes()).hexdigest() + + +def save(file, value): + Path(file).write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf8") + + +class Model: + def __init__(self, wrong_reply=False): + self.requests, self.title_requests, self.errors, self.blocked = [], [], [], [] + self.wrong_reply, self.injected = wrong_reply, False + self.pending = queue.Queue() + self.lock = threading.Lock() + self.gates = [] + owner = self + + class Deny(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def reject(self): + owner.blocked.append({"method": self.command, "target": self.path}) + self.send_error(502, "Native E2E denies external HTTP egress") + + do_CONNECT = do_GET = do_POST = reject + + class Responses(http.server.BaseHTTPRequestHandler): + def log_message(self, *_): + pass + + def do_POST(self): + try: + require(self.path == "/v1/responses", f"Unexpected model path: {self.path}") + raw = self.rfile.read(int(self.headers.get("Content-Length", "0"))) + if self.headers.get("Content-Encoding") == "gzip": + raw = gzip.decompress(raw) + body = json.loads(raw) + require("Authorization" not in self.headers, "Unexpected model credentials") + # The adapter also makes ephemeral title turns. Recognize the + # actual schema AND title instruction, never just a model name + # or a sentinel that could appear in discarded user history. + schema = body.get("text", {}).get("format", {}).get("schema", {}) + title = (schema.get("required") == ["title"] + and schema.get("properties") == {"title": {"type": "string"}} + and "Your task is to generate a very short title for a conversation" in json.dumps(body.get("input", []))) + with owner.lock: + target = owner.title_requests if title else owner.requests + target.append({"path": self.path, "body": body}) + number = len(owner.requests) + len(owner.title_requests) + if title: + require(len(owner.title_requests) <= 8, "Unexpected title request loop") + action = "title" + else: + try: + action = owner.pending.get_nowait() + except queue.Empty: + raise AssertionError("Unsolicited model request (no queue input was armed)") + if isinstance(action, dict): + # Independent gates let a queued successor stay blocked while + # the preceding ordinary prompt completes and cleans up. + users = [item for item in body.get("input", []) if item.get("role") == "user"] + latest = "".join(part.get("text", "") for part in users[-1].get("content", []) + if part.get("type") == "input_text") if users else "" + require(latest == action["input"], + f"Queue input/order mismatch: expected {action['input']!r}, received {latest!r}") + action["seen"].set() + require(action["release"].wait(TIMEOUT), "Queue gate was not released") + events = [{"type": "response.created", "response": {"id": f"resp-{number}"}}] + answer = '{"title":"Local native fixture"}' if title else action["answer"] + if not title and owner.wrong_reply and not owner.injected: + answer = "INJECTED_WRONG_REPLY" + owner.injected = True + if isinstance(action, dict): + events.extend([ + {"type": "response.output_item.added", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", "content": []}}, + {"type": "response.output_text.delta", "item_id": f"msg-{number}", + "output_index": 0, "content_index": 0, "delta": answer}, + ]) + events.append({"type": "response.output_item.done", "item": { + "type": "message", "role": "assistant", "id": f"msg-{number}", + "content": [{"type": "output_text", "text": answer}]}}) + events.append({"type": "response.completed", "response": { + "id": f"resp-{number}", "usage": {"input_tokens": 10, "output_tokens": 2, "total_tokens": 12}}}) + data = "".join("data: " + json.dumps(event) + "\n\n" for event in events).encode() + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + try: + self.wfile.write(data) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass # Expected after cancellation of the held native turn. + if isinstance(action, dict): + action["done"].set() + except Exception as error: + owner.errors.append(str(error)) + self.send_error(500, "Local fixture failed; see report.json") + + self.proxy = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Deny) + self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Responses) + for server in (self.proxy, self.server): + threading.Thread(target=server.serve_forever, daemon=True).start() + + def arm(self, *actions): + require(self.pending.empty(), "Previous prompt did not consume its expected model calls") + for action in actions: + self.pending.put(action) + + def check(self): + require(not self.errors, f"Local model errors: {self.errors}") + require(self.pending.empty(), "Missing expected model call") + + def close(self): + for gate in self.gates: + gate["release"].set() + for server in (self.proxy, self.server): + server.shutdown() + server.server_close() + + +class Rpc: + def __init__(self, probe): + self.probe = probe + self.seq, self.stash, self.trace = 0, {}, [] + self.incoming = queue.Queue() + self.closed = False + self.number = len(probe.clients) + probe.clients.append(self) + self.stderr = (probe.run / f"stderr-{self.number}.log").open("w", encoding="utf8") + self.p = subprocess.Popen( + [probe.meta["node"], str(probe.repo / "dist/index.js")], cwd=probe.work, env=probe.env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=self.stderr, + text=True, encoding="utf8", creationflags=FLAGS, start_new_session=os.name != "nt") + self.reader_thread = threading.Thread(target=self.reader, daemon=True) + self.reader_thread.start() + self.init = self.call("initialize", { + "protocolVersion": 1, "clientInfo": {"name": "isolated-native-queue", "version": "1"}, + "clientCapabilities": {}}) + + def reader(self): + try: + for line in self.p.stdout: + try: + message = json.loads(line) + except json.JSONDecodeError: + self.incoming.put(RuntimeError(f"Non-JSON ACP stdout: {line[:200]}")) + continue + self.trace.append({"direction": "in", "message": message}) + self.incoming.put(message) + finally: + self.incoming.put(RuntimeError("ACP stdout closed before requested response")) + + def write(self, message): + self.trace.append({"direction": "out", "message": message}) + self.p.stdin.write(json.dumps(message) + "\n") + self.p.stdin.flush() + + def send(self, method, params): + self.seq += 1 + self.write({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params}) + return self.seq + + def notify(self, method, params): + self.write({"jsonrpc": "2.0", "method": method, "params": params}) + + def response(self, ident): + if ident in self.stash: + return self.stash.pop(ident) + deadline = time.monotonic() + TIMEOUT + while True: + try: + message = self.incoming.get(timeout=max(0.01, deadline - time.monotonic())) + except queue.Empty: + raise TimeoutError(f"ACP response {ident} timed out; inspect stderr-{self.number}.log") + if isinstance(message, Exception): + raise message + if "id" in message and "method" in message: + # The queue fixture never requests client tools or approvals. + self.write({"jsonrpc": "2.0", "id": message["id"], "error": { + "code": -32601, "message": "Native E2E client tools disabled"}}) + self.probe.client_requests.append(message) + elif "id" in message: + if message["id"] == ident: + return message + self.stash[message["id"]] = message + require(time.monotonic() < deadline, f"ACP response {ident} timed out") + + def raw(self, method, params): + return self.response(self.send(method, params)) + + def call(self, method, params): + message = self.raw(method, params) + require("error" not in message, f"{method}: {message}") + return message["result"] + + def new(self): + return self.call("session/new", {"cwd": str(self.probe.work), "mcpServers": []})["sessionId"] + + def stop(self): + if self.closed: + return + self.closed = True + forced = False + try: + self.p.stdin.close() + try: + self.p.wait(timeout=8) + except subprocess.TimeoutExpired: + forced = True + if os.name == "nt": + subprocess.run(["taskkill", "/PID", str(self.p.pid), "/T", "/F"], + env=self.probe.env, capture_output=True, timeout=6, creationflags=FLAGS) + else: + os.killpg(self.p.pid, signal.SIGKILL) + self.p.wait(timeout=5) + self.reader_thread.join(timeout=2) + finally: + self.stderr.close() + self.probe.exits.append({"client": self.number, "pid": self.p.pid, + "exitCode": self.p.returncode, "forced": forced}) + require(not forced and self.p.returncode == 0, f"ACP did not shut down cleanly: {self.probe.exits[-1]}") + # Confirm the old provider exited before opening a replacement ACP + # process. A later final check alone could miss overlapping providers. + entries = [json.loads(line) for line in (self.probe.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + owned = {self.p.pid} + while True: + children = {entry.get("pid") for entry in entries + if entry["event"] == "spawn" and entry.get("parentPid") in owned} + expanded = owned | (children - {None}) + if expanded == owned: + break + owned = expanded + native = [entry for entry in entries if entry["event"] == "spawn" and entry.get("pid") in owned + and Path(entry["file"]).resolve() == Path(self.probe.meta["native"]).resolve()] + exited = {entry["pid"] for entry in entries if entry["event"] == "exit"} + require(len(native) == 1 and native[0]["pid"] in exited, "Owned native must exit before cold restart") + + +class Probe: + def __init__(self, manifest): + self.meta = json.loads(manifest.read_text(encoding="utf8")) + self.repo, self.run = Path(self.meta["repo"]), Path(self.meta["run"]) + self.home, self.work = self.run / "home", self.run / "workspace" + self.clients, self.exits, self.steps, self.client_requests = [], [], [], [] + self.model = Model(self.meta.get("wrongReply", False)) + self.env = dict(os.environ) # Launcher already replaced, not merged, the user's environment. + # Narrow runtime PATH to required programs and OS utilities. + git = shutil.which("git") + paths = [str(Path(self.meta["node"]).parent)] + if git: + paths.append(str(Path(git).parent)) + paths += [str(Path(os.environ["SYSTEMROOT"]) / "System32")] if os.name == "nt" else ["/usr/bin", "/bin"] + self.env["PATH"] = os.pathsep.join(dict.fromkeys(paths)) + proxy = f"http://127.0.0.1:{self.model.proxy.server_port}" + for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"): + self.env[key] = proxy + self.env.update(NO_PROXY="127.0.0.1,localhost,::1", no_proxy="127.0.0.1,localhost,::1") + provider = {"name": "Native E2E loopback", "base_url": f"http://127.0.0.1:{self.model.server.server_port}/v1", + "wire_api": "responses", "requires_openai_auth": False, + "request_max_retries": 0, "stream_max_retries": 0} + # A bundled model profile avoids discovery; all replies come from loopback. + config = {"model": "gpt-5.5", "model_provider": "mock", "model_providers": {"mock": provider}, + "approval_policy": "never", "sandbox_mode": "danger-full-access", + "features": {"shell_snapshot": False, "remote_models": False, "plugins": False, "code_mode": False}} + self.env.update(CODEX_PATH=self.meta["native"], MODEL_PROVIDER="mock", CODEX_CONFIG=json.dumps(config), + INITIAL_AGENT_MODE="agent-full-access", NO_BROWSER="1", + NODE_OPTIONS='--require=' + json.dumps(str(self.repo / "scripts/native-queue/spawn-audit.cjs")), + NATIVE_E2E_SPAWN_LOG=str(self.run / "spawns.jsonl")) + (self.home / "config.toml").write_text( + 'model = "gpt-5.5"\nmodel_provider = "mock"\napproval_policy = "never"\nsandbox_mode = "danger-full-access"\n' + '[features]\nshell_snapshot = false\nremote_models = false\nplugins = false\ncode_mode = false\n' + '[model_providers.mock]\n' + "\n".join(f"{key} = {json.dumps(value)}" for key, value in provider.items()) + "\n", + encoding="utf8") + self.rpc = None + + def step(self, name, **details): + self.steps.append({"name": name, "passed": True, **details}) + print("PASS " + name, flush=True) + + def start(self): + self.rpc = Rpc(self) + return self.rpc + + def restart(self): + self.rpc.stop() + self.verify_build() + return self.start() + + def verify_build(self): + require(sha(self.repo / "dist/index.js") == self.meta["distSha256"], "Build changed during probe; rerun") + require(sha(self.meta["native"]) == self.meta["nativeSha256"], "Native changed during probe; rerun") + current_names = {str(file.relative_to(self.repo)) for file in (self.repo / "src").rglob("*") if file.is_file()} + current_names.update(str(file.relative_to(self.repo)) + for file in (self.repo / "scripts/native-queue").rglob("*") if file.is_file()) + current_names.update(str(Path(name)) for name in + ["package.json", "package-lock.json", "build.mjs", "scripts/native-queue.mjs"]) + require(current_names == set(self.meta["sources"]), "Source file set changed during probe; rerun") + for name, expected in self.meta["sources"].items(): + require(sha(self.repo / name) == expected, f"Source changed during probe: {name}; rerun") + + def run_all(self): + report = {"success": False, "build": self.meta, "steps": self.steps} + try: + self.verify_build() + self.start() + from queue_probe import run_queue_probe + run_queue_probe(self) + self.rpc.stop() + self.verify_build() + self.model.check() + require(not self.client_requests, "Native unexpectedly requested host/client tools") + entries = [json.loads(line) for line in (self.run / "spawns.jsonl").read_text(encoding="utf8").splitlines()] + spawns = [entry for entry in entries if entry["event"] == "spawn"] + exits = {entry["pid"] for entry in entries if entry["event"] == "exit"} + native = [entry for entry in spawns if Path(entry["file"]).resolve() == Path(self.meta["native"]).resolve()] + require(len(native) == len(self.clients), "Each ACP process must launch the resolved real native binary once") + require(all(entry.get("pid") in exits for entry in native), "Native child did not exit before cold restart/cleanup") + self.step("native_identity_and_cold_process_shutdown", clients=len(self.clients), spawnCount=len(spawns)) + report["success"] = True + except Exception as error: + report["error"] = {"type": type(error).__name__, "message": str(error)} + (self.run / "failure.txt").write_text(traceback.format_exc(), encoding="utf8") + finally: + for gate in self.model.gates: + gate["release"].set() + for client in self.clients: + try: + client.stop() + except Exception as error: + report["success"] = False + report.setdefault("cleanupErrors", []).append(str(error)) + self.model.close() + try: + self.verify_build() + except Exception as error: + report["success"] = False + report["finalBuildError"] = str(error) + if self.model.errors or not self.model.pending.empty(): + report["success"] = False + report.setdefault("error", {"type": "ModelFixtureError", "message": "Late or missing model request; inspect providerErrors"}) + report.update(modelRequests=len(self.model.requests), titleRequests=len(self.model.title_requests), providerErrors=self.model.errors, + wrongReplyInjected=self.model.injected, + blockedExternalRequests=self.model.blocked, processExits=self.exits, + clientRequests=self.client_requests, + networkBoundary="Loopback provider and deny HTTP(S) proxy; not an OS egress firewall", + harnessSha256=sha(__file__)) + queue_harness = self.repo / "scripts/native-queue/queue_probe.py" + if queue_harness.exists(): + report["queueHarnessSha256"] = sha(queue_harness) + save(self.run / "rpc-traces.json", [client.trace for client in self.clients]) + save(self.run / "model-requests.json", self.model.requests) + save(self.run / "title-requests.json", self.model.title_requests) + save(self.run / "report.json", report) + print(json.dumps({"success": report["success"], "report": str(self.run / "report.json"), + "steps": len(self.steps), "error": report.get("error")}), flush=True) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + require(len(sys.argv) == 2, "Use npm run test:native-queue (a fresh build manifest is required)") + raise SystemExit(Probe(Path(sys.argv[1])).run_all()) diff --git a/scripts/native-queue/queue_probe.py b/scripts/native-queue/queue_probe.py new file mode 100644 index 000000000..e4152b2e5 --- /dev/null +++ b/scripts/native-queue/queue_probe.py @@ -0,0 +1,257 @@ +"""Native durable queue checks, invoked by the isolated queue-only probe. + +No native method replacement, synthetic ACP notifications, rollout edits, or +second scheduler. Scripted model responses only; all queue actions cross ACP. +""" +import threading +import time + + +def run_queue_probe(probe): + # probe.py runs as __main__; use its helpers without importing a second copy. + from __main__ import TIMEOUT, require, save + + model, c = probe.model, probe.rpc + actions = ["list", "add", "update", "delete", "reorder", "start"] + capability = c.init.get("_meta", {}).get("queue", {}) + require(capability.get("method") == "_session/queue", capability) + require(capability.get("version") == 1 and capability.get("actions") == actions, capability) + require(capability.get("changedNotification") == "_session/queue/changed" + and capability.get("turnNotification") == "_session/queue/turn", capability) + probe.step("queue_production_capabilities_six_actions", capability=capability) + sid = c.new() + evidence = {"sessionId": sid, "capability": capability} + + def q(action, **params): + response = c.call("_session/queue", {"sessionId": sid, "action": action, **params}) + require(response.get("status") == "ok", response) + return response["result"] + + def text(value): + return [{"type": "text", "text": value, "text_elements": []}] + + def add(value): + item = q("add", input=text(value), clientUserMessageId="client-" + value)["queuedSubmission"] + require(item.get("id") and item.get("clientUserMessageId") == "client-" + value, item) + return item + + def listed(session_id=None): + page = q("list", sessionId=session_id or sid) + require(page.get("nextCursor") is None, page) + return page["data"] + + def gate(value, held=False): + result = {"input": value, "answer": "ANSWER_" + value, + "seen": threading.Event(), "release": threading.Event(), "done": threading.Event()} + if not held: + result["release"].set() + model.gates.append(result) + return result + + def wait(predicate, label, timeout=TIMEOUT): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + require(not model.errors, model.errors) + require(c.p.poll() is None, "ACP exited while waiting for " + label) + if predicate(): + return + time.sleep(0.02) + raise AssertionError("Timed out waiting for " + label) + + def turns(start): + return [entry["message"]["params"]["turn"] for entry in c.trace[start:] + if entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"]["params"].get("sessionId") == sid] + + def completed(start, count): + wait(lambda: len([t for t in turns(start) if t.get("status") == "completed"]) >= count, + f"{count} completed queue turns") + events = turns(start) + ids = [t["id"] for t in events if t.get("status") == "completed"] + require(len(ids) == count and len(set(ids)) == count, events) + for ident in ids: + require([t["status"] for t in events if t["id"] == ident] == ["inProgress", "completed"], events) + return ids + + def live_answers(start, expected): + chunks = [entry["message"]["params"]["update"].get("content", {}).get("text", "") + for entry in c.trace[start:] if entry["direction"] == "in" + and entry["message"].get("method") == "session/update" + and entry["message"]["params"].get("sessionId") == sid + and entry["message"]["params"]["update"].get("sessionUpdate") == "agent_message_chunk"] + received = "".join(chunks) + require(all(received.count(value) == 1 for value in expected), {"expected": expected, "received": received}) + require([received.index(value) for value in expected] == sorted(received.index(value) for value in expected), received) + + def refused_unchanged(label, action, *, session_id=None, error_code=None, deleted_false=False, **params): + target = session_id or sid + sessions = list(dict.fromkeys([sid, target])) + before = {ident: listed(ident) for ident in sessions} + count, marker = len(model.requests), len(c.trace) + response = c.raw("_session/queue", {"sessionId": target, "action": action, **params}) + if deleted_false: + require("error" not in response and response.get("result", {}).get("status") == "ok" + and response["result"].get("result", {}).get("deleted") is False, {"case": label, "response": response}) + else: + error = response.get("error", {}) + require("result" not in response and error.get("code") in (-32600, -32602), + {"case": label, "response": response}) + if error_code is not None: + require(error["code"] == error_code, {"case": label, "response": response}) + after = {ident: listed(ident) for ident in sessions} + require(after == before, {"case": label, "before": before, "after": after}) + require(len(model.requests) == count and not model.errors, "Rejected operation generated: " + label) + require(not any(entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"].get("params", {}).get("sessionId") in sessions + for entry in c.trace[marker:]), "Rejected operation emitted a queue turn: " + label) + evidence.setdefault("rejectedOperations", []).append({"case": label, "response": response, "preserved": after}) + + # A brand-new session must own and stream queued work before any ordinary + # prompt installs prompt-local handlers. No session/load replay is involved. + early = gate("QUEUE_EARLY_IDLE") + model.arm(early) + marker = len(c.trace) + add(early["input"]) + evidence["earlyIdleTurns"] = completed(marker, 1) + live_answers(marker, [early["answer"]]) + require(listed() == [], "First idle add did not drain") + require(not any(entry["direction"] == "out" and entry["message"].get("method") == "session/prompt" + for entry in c.trace), "Early idle test ran after an ordinary prompt") + model.check() + probe.step("queue_first_idle_add_streams_before_any_ordinary_prompt", turnIds=evidence["earlyIdleTurns"]) + + # Ordinary prompt owns A; its completion must hand B to the durable owner + # before A's finally block can erase B's state. + initial = gate("QUEUE_INITIAL", held=True) + model.arm(initial) + marker = len(c.trace) + prompt = c.send("session/prompt", {"sessionId": sid, "prompt": text(initial["input"])}) + wait(initial["seen"].is_set, "gated normal prompt") + a, b, removed = add("QUEUE_A"), add("QUEUE_B"), add("QUEUE_DELETE") + changed = q("update", queuedSubmissionId=a["id"], input=text("QUEUE_A_EDITED"))["queuedSubmission"] + require(changed["id"] == a["id"] and changed["clientUserMessageId"] == a["clientUserMessageId"], changed) + require(changed["input"] == text("QUEUE_A_EDITED"), changed) + q("reorder", queuedSubmissionIds=[b["id"], a["id"], removed["id"]]) + require(q("delete", queuedSubmissionId=removed["id"]).get("deleted") is True, "Delete did not remove entry") + require([i["id"] for i in listed()] == [b["id"], a["id"]], "Native reorder/list disagreed") + # The ordinary turn is held, so rejected reorder calls cannot be confused + # with successful consumption. Compare entire records, including order. + refused_unchanged("reorder_missing_ids", "reorder", error_code=-32602) + refused_unchanged("reorder_non_array", "reorder", error_code=-32602, queuedSubmissionIds=b["id"]) + refused_unchanged("reorder_malformed_id", "reorder", error_code=-32602, queuedSubmissionIds=[b["id"], None]) + refused_unchanged("reorder_duplicate_ids", "reorder", queuedSubmissionIds=[b["id"], b["id"]]) + refused_unchanged("reorder_stale_deleted_id", "reorder", queuedSubmissionIds=[b["id"], removed["id"]]) + refused_unchanged("reorder_missing_current_id", "reorder", queuedSubmissionIds=[b["id"]]) + refused_unchanged("reorder_extra_stale_id", "reorder", queuedSubmissionIds=[b["id"], a["id"], removed["id"]]) + probe.step("queue_invalid_reorders_preserve_entries_and_order") + require(not any(t.get("status") == "inProgress" for t in turns(marker)), "Queued work started during gated prompt") + first, second = gate("QUEUE_B", held=True), gate("QUEUE_A_EDITED") + model.arm(first, second) + initial["release"].set() + response = c.response(prompt) + require(response.get("result", {}).get("stopReason") == "end_turn", response) + wait(first["seen"].is_set, "first auto queue turn") + first["release"].set() + evidence["autoTurns"] = completed(marker, 2) + live_answers(marker, [initial["answer"], first["answer"], second["answer"]]) + require(listed() == [], "Auto-dispatched queue did not drain") + require(any(e["direction"] == "in" and e["message"].get("method") == "_session/queue/changed" + and e["message"].get("params", {}).get("sessionId") == sid + for e in c.trace[marker:]), "Missing queue invalidation for this session") + model.check() + probe.step("queue_add_update_reorder_delete_then_auto_dispatch_live_output", turnIds=evidence["autoTurns"]) + + marker = len(c.trace) + blocked = gate("QUEUE_CANCEL", held=True) + model.arm(blocked) + add(blocked["input"]) + wait(blocked["seen"].is_set, "queue turn to cancel") + wait(lambda: any(t.get("status") == "inProgress" for t in turns(marker)), "cancel target native start") + pending_a, pending_b = add("QUEUE_REMAIN_A"), add("QUEUE_REMAIN_B") + c.notify("session/cancel", {"sessionId": sid}) + wait(lambda: any(t.get("status") == "interrupted" for t in turns(marker)), "native interrupted queue notification") + require([t["status"] for t in turns(marker)] == ["inProgress", "interrupted"], turns(marker)) + require(len({t["id"] for t in turns(marker)}) == 1, "Interrupted notification targeted another turn") + require([i["id"] for i in listed()] == [pending_a["id"], pending_b["id"]], "Cancel lost remaining queue") + count = len(model.requests) + blocked["release"].set() + wait(blocked["done"].is_set, "cancelled fixture response cleanup") + # Exercise an idle native start, not the active-turn guard. No model reply + # is armed: a fallback to the head entry must fail this fixture. + preserved = listed() + negative_marker = len(c.trace) + refused_unchanged("start_unknown_id", "start", queuedSubmissionId="missing-native-queue-item") + # A valid second session makes cross-session IDs meaningful. Deleting a + # foreign entry is a native no-op (deleted:false), not a JSON-RPC error. + other_sid = c.new() + require(other_sid != sid and listed(other_sid) == [], "Expected a distinct empty session") + refused_unchanged("update_wrong_session", "update", session_id=other_sid, + queuedSubmissionId=pending_a["id"], input=text("MUST_NOT_REPLACE_PENDING")) + refused_unchanged("delete_wrong_session", "delete", session_id=other_sid, + queuedSubmissionId=pending_a["id"], deleted_false=True) + refused_unchanged("reorder_wrong_session", "reorder", session_id=other_sid, + queuedSubmissionIds=[pending_b["id"], pending_a["id"]]) + refused_unchanged("start_wrong_session", "start", session_id=other_sid, + queuedSubmissionId=pending_a["id"]) + # Span the native external-queue watcher period. No queued model action is + # armed: neither cancel nor a rejected start may permit a delayed restart. + until = time.monotonic() + 10.5 + while time.monotonic() < until: + require(len(model.requests) == count and not model.errors, "Queue advanced after cancel") + require(c.p.poll() is None, "ACP exited during rejected-start observation") + time.sleep(0.05) + require(listed() == preserved and listed(other_sid) == [], "Rejected operations changed a queue after settling") + require(not any(entry["direction"] == "in" and entry["message"].get("method") == "_session/queue/turn" + and entry["message"].get("params", {}).get("sessionId") in (sid, other_sid) + for entry in c.trace[negative_marker:]), "Rejected start emitted a delayed queue turn") + model.check() + probe.step("queue_unknown_start_and_cross_session_ids_preserve_both_queues") + probe.step("queue_cancel_preserves_pending_and_suppresses_automatic_dispatch") + + marker = len(c.trace) + selected, remaining = gate("QUEUE_REMAIN_B"), gate("QUEUE_REMAIN_A") + model.arm(selected, remaining) + start = q("start", queuedSubmissionId=pending_b["id"]) + ids = completed(marker, 2) + require(start["turn"]["id"] == ids[0] and start["turn"]["status"] == "inProgress", start) + live_answers(marker, [selected["answer"], remaining["answer"]]) + require(listed() == [], "Explicit non-head start did not drain remaining queue") + model.check() + probe.step("queue_explicit_non_head_start_then_drain", turnIds=ids) + + # Stop the entire owned ACP/native tree: a fresh client has no local session + # and list/add/update must operate against persisted storage without resume. + # session/close only unsubscribes; it is NOT evidence of native unload. + # restart() waits for and verifies the old native process exit. Never replace + # this barrier with a fixed sleep or an immediate add after session/close. + closed_client = c + c.call("session/close", {"sessionId": sid}) + c = probe.restart() + require(closed_client.p.poll() == 0 and c is not closed_client, + "Cold queue writes require verified previous process termination") + evidence["coldWriteBarrier"] = {"oldClient": closed_client.number, + "newClient": c.number, "oldExitCode": closed_client.p.returncode} + count = len(model.requests) + require(listed() == [], "Expected empty cold queue before persisted add") + cold = add("QUEUE_PERSIST_ORIGINAL") + cold = q("update", queuedSubmissionId=cold["id"], input=text("QUEUE_PERSIST_EDITED"))["queuedSubmission"] + require(cold["clientUserMessageId"] == "client-QUEUE_PERSIST_ORIGINAL", cold) + require(cold["input"] == text("QUEUE_PERSIST_EDITED"), cold) + require(listed() == [cold] and len(model.requests) == count, "Closed-session operations resumed or changed persisted queue") + c = probe.restart() + require(listed() == [cold] and len(model.requests) == count, "Pending queue did not persist across native process restart") + probe.step("queue_closed_session_add_update_list_persist_across_restart", queuedSubmission=cold) + + marker = len(c.trace) + resumed = gate("QUEUE_PERSIST_EDITED") + model.arm(resumed) + # Public reload must install a queue owner and generate the persisted input. + result = c.call("session/load", {"sessionId": sid, "cwd": str(probe.work), "mcpServers": []}) + require(result.get("sessionId", sid) == sid, result) + ids = completed(marker, 1) + live_answers(marker, [resumed["answer"]]) + require(listed() == [], "Reload did not consume persisted native queue") + model.check() + probe.step("queue_reload_installs_owner_and_auto_dispatches_persisted_input", turnIds=ids) + evidence["reloadTurns"] = ids + save(probe.run / "queue-evidence.json", evidence) diff --git a/scripts/native-queue/spawn-audit.cjs b/scripts/native-queue/spawn-audit.cjs new file mode 100644 index 000000000..572248ea3 --- /dev/null +++ b/scripts/native-queue/spawn-audit.cjs @@ -0,0 +1,13 @@ +// Observe real native identity and exit barriers without changing spawned processes. +const cp = require('node:child_process'); +const { appendFileSync } = require('node:fs'); +const { syncBuiltinESMExports } = require('node:module'); +const spawn = cp.spawn; +const record = (entry) => appendFileSync(process.env.NATIVE_E2E_SPAWN_LOG, JSON.stringify(entry) + '\n'); +cp.spawn = function (file, args, options) { + const child = spawn.apply(this, arguments); + record({ event: 'spawn', file, args, parentPid: process.pid, pid: child.pid, windowsHide: options?.windowsHide, shell: options?.shell }); + child.once('exit', (code, signal) => record({ event: 'exit', pid: child.pid, code, signal })); + return child; +}; +syncBuiltinESMExports(); diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f8fc9380e..436b8f679 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,3 +1,4 @@ +import {probeSessionQueueSupport, type SessionQueueSupport} from "./SessionQueue"; import { type ApiKeyAuthRequest, CODEX_API_KEY_ENV_VAR, @@ -61,7 +62,7 @@ import type {AuthenticationStatusResponse} from "./AcpExtensions"; import {createCodexCollaborationMode} from "./CollaborationModeConfig"; import type {ModeKind} from "./app-server/ModeKind"; import {arePathBasenamesEqual, arePathsEqual, isAbsolutePathLike} from "./PathUtils"; -import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions"; +import {CodexSubagentSubscriptions, type Subscription} from "./subagents/CodexSubagentSubscriptions"; import {forkSession as runForkSession} from "./SessionFork"; import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata"; import { @@ -135,6 +136,21 @@ const SUPPORTED_GATEWAY_PROTOCOLS: Record = { * Converts ACP requests into corresponding app-server operations. */ export class CodexAcpClient { + private readonly promptEventResets = new Map void>(); + resumePromptEvents(sessionId: string): void { this.promptEventResets.get(sessionId)?.(); } + private readonly promptEventReleases = new Map void>(); + finishPromptEvents(sessionId: string): void { this.promptEventReleases.get(sessionId)?.(); } + private readonly queueSubscriptions = new Map>(); + private readonly sessionEventGenerations = new Map(); + + async subscribeToQueueEvents(...subscription: Parameters): Promise { + this.sessionEventGenerations.set(subscription[0], Symbol()); + this.subagents.clear(subscription[0]); + this.queueSubscriptions.set(subscription[0], subscription); + subscription[7] = true; + await this.subscribeToSessionEvents(...subscription); + } + private readonly codexClient: CodexAppServerClient; private readonly config: JsonObject; private readonly modelProvider: string | null; @@ -186,6 +202,8 @@ export class CodexAcpClient { name: `${packageJson.name}`, title: "Codex ACP", version: `${packageJson.version}` }; + queueSupport: SessionQueueSupport = {nativeVersion: null, actions: []}; + async initialize(request: acp.InitializeRequest): Promise { const response = await this.codexClient.initialize({ capabilities: { @@ -199,6 +217,9 @@ export class CodexAcpClient { } }); this.configPath = response?.codexHome ?? null; + // The running app-server version, not the ACP client or npm package version. + const version = response?.userAgent?.match(/^[^/]+\/(\d+\.\d+\.\d+)\s+\(/)?.[1]; + this.queueSupport = await probeSessionQueueSupport(version, action => this.codexClient.probeQueueAction(action)); this.isInitialized = true; } @@ -819,11 +840,18 @@ export class CodexAcpClient { } async closeSession(sessionId: string): Promise { + const generation = this.sessionEventGenerations.get(sessionId); try { await this.codexClient.threadUnsubscribe({threadId: sessionId}); } finally { - this.codexClient.clearThreadHandlers(sessionId); - this.subagents.clear(sessionId); + if (this.sessionEventGenerations.get(sessionId) === generation) { + this.sessionEventGenerations.delete(sessionId); + this.queueSubscriptions.delete(sessionId); + this.promptEventResets.delete(sessionId); + this.promptEventReleases.delete(sessionId); + this.codexClient.clearThreadHandlers(sessionId); + this.subagents.clear(sessionId); + } } } @@ -1101,10 +1129,53 @@ export class CodexAcpClient { supportsSubagents: boolean, observeInteraction: (result: ServerNotification) => void | Promise, waitForChildSession: (childThreadId: string) => Promise, + queueOwner = false, + captureChild?: (childThreadId: string) => Subscription, + observeNativeLifecycle?: (event: ServerNotification) => void, ) { + const generation = this.sessionEventGenerations.get(sessionId) ?? Symbol(); + this.sessionEventGenerations.set(sessionId, generation); + const isCurrent = () => this.sessionEventGenerations.get(sessionId) === generation; + // A prompt may hand off to its queue owner, but never to a later open + // of the same session. Resolve only within this subscription generation. + const queueSubscription = this.queueSubscriptions.get(sessionId); + let promptFinished = queueOwner; + if (!queueOwner) this.promptEventResets.set(sessionId, () => { promptFinished = false; }); + if (!queueOwner) this.promptEventReleases.set(sessionId, () => { promptFinished = true; }); + const current = () => promptFinished ? queueSubscription : undefined; const dispatch = (event: ServerNotification) => { - this.enqueueSessionNotification(sessionId, () => eventHandler(event)); + if (!isCurrent()) return; + (queueSubscription?.[9] ?? observeNativeLifecycle)?.(event); + this.enqueueSessionNotification(sessionId, async () => { + if (!isCurrent()) return; + const owner = event.method === "thread/queue/changed" ? queueSubscription : current(); + await (owner?.[1] ?? eventHandler)(event); + if (event.method === "turn/completed" && event.params.threadId === sessionId) promptFinished = true; + }); }; + const stable: Subscription = { + rootSessionId: sessionId, supportsSubagents, + dispatch: eventHandler, + enqueueInteraction: observeInteraction, + approvalHandler, elicitationHandler, + waitForRootNotifications: () => this.waitForSessionNotifications(sessionId), waitForChildSession, + }; + const deferredSubscription = (captured: Promise): Subscription => ({ + ...stable, + dispatch: event => this.enqueueSessionNotification(sessionId, async () => { const owner = await captured; if (isCurrent()) await owner.dispatch(event); }), + enqueueInteraction: event => this.enqueueSessionNotification(sessionId, async () => { const owner = await captured; if (isCurrent()) await owner.enqueueInteraction(event); }), + approvalHandler: { + handleCommandExecution: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handleCommandExecution(p) : {decision: "cancel"}; }, + handleFileChange: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handleFileChange(p) : {decision: "cancel"}; }, + handlePermissionsRequest: async p => { const owner = await captured; return isCurrent() ? owner.approvalHandler.handlePermissionsRequest(p) : {permissions: {}, scope: "turn", strictAutoReview: false}; }, + }, + elicitationHandler: { + handleElicitation: async p => { const owner = await captured; return isCurrent() ? owner.elicitationHandler.handleElicitation(p) : {action: "cancel", content: null, _meta: null}; }, + handleUserInput: async p => { const owner = await captured; return isCurrent() ? owner.elicitationHandler.handleUserInput(p) : {answers: {}}; }, + }, + waitForChildSession: async id => { const owner = await captured; return isCurrent() ? owner.waitForChildSession(id) : null; }, + captureChild: id => deferredSubscription(captured.then(owner => owner.captureChild?.(id) ?? owner)), + }); this.subagents.subscribe({ rootSessionId: sessionId, supportsSubagents, @@ -1113,12 +1184,25 @@ export class CodexAcpClient { // Child observation uses the same serialized, error-reporting queue // as ordinary session notifications; callers intentionally do not // await the callback registered with app-server. - this.enqueueSessionNotification(sessionId, () => observeInteraction(event)); + this.enqueueSessionNotification(sessionId, () => { if (isCurrent()) return (current()?.[5] ?? observeInteraction)(event); }); + }, + approvalHandler: { + handleCommandExecution: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handleCommandExecution(params) : {decision: "cancel"}, + handleFileChange: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handleFileChange(params) : {decision: "cancel"}, + handlePermissionsRequest: async params => isCurrent() ? (current()?.[2] ?? approvalHandler).handlePermissionsRequest(params) : {permissions: {}, scope: "turn", strictAutoReview: false}, + }, + elicitationHandler: { + handleElicitation: async params => isCurrent() ? (current()?.[3] ?? elicitationHandler).handleElicitation(params) : {action: "cancel", content: null, _meta: null}, + handleUserInput: async params => isCurrent() ? (current()?.[3] ?? elicitationHandler).handleUserInput(params) : {answers: {}}, }, - approvalHandler, - elicitationHandler, waitForRootNotifications: () => this.waitForSessionNotifications(sessionId), - waitForChildSession, + waitForChildSession: async id => isCurrent() ? (current()?.[6] ?? waitForChildSession)(id) : null, + captureChild: this.queueSubscriptions.has(sessionId) ? childThreadId => { + let resolve!: (value: Subscription) => void; + const captured = new Promise(r => { resolve = r; }); + this.enqueueSessionNotification(sessionId, () => { resolve(isCurrent() ? current()?.[8]?.(childThreadId) ?? captureChild?.(childThreadId) ?? stable : stable); }); + return deferredSubscription(captured); + } : undefined, }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index d3f90a8bb..02e6ce10b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1,3 +1,5 @@ +import {terminateCodexConnection} from "./CodexJsonRpcConnection"; +import {parseSessionQueueRequest, runSessionQueue, sessionQueueCapability, type SessionQueueRequest} from "./SessionQueue"; import * as acp from "@agentclientprotocol/sdk"; import {RequestError, type SessionId, type SessionModeState} from "@agentclientprotocol/sdk"; import {CodexEventHandler, type CompletedPlan} from "./CodexEventHandler"; @@ -17,7 +19,8 @@ import { type SessionMetadataWithThread, type UrlElicitationRequester } from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; +import {CodexAppServerClient, type ApprovalHandler, type ElicitationHandler} from "./CodexAppServerClient"; +import type {Subscription} from "./subagents/CodexSubagentSubscriptions"; import { isAccountReadAccountChangedError, isAccountReadAuthFailureError, @@ -333,6 +336,191 @@ export interface CodexProcessState { } export class CodexAcpServer { + private readonly queueCancelGenerations = new Map(); + private readonly queueCancellations = new Map(); + private readonly queueHistoryLoads = new Set(); + private readonly queueOwners = new Map(); + + /** Subscribe before resume/add: both can dispatch without a session/prompt request. */ + private async ownNativeQueue(sessionId: string, state?: SessionState): Promise { + const queueClient = this.codexAcpClient; + const existing = this.queueOwners.get(sessionId); + if (existing) { if (state) existing.install(state); return; } + let install!: (state: SessionState | null) => void; + let installedState = state; + const ready = new Promise(resolve => { install = resolve; }); + let stopped = false; + let cancelled = false; + let nativeTurnId: string | null = null; + const childTurns = new Set(); + const retained = new Set<{abort: AbortController; handler: CodexEventHandler}>(); + let idleHandler: CodexEventHandler | undefined; + const createHandler = (session: SessionState) => new CodexEventHandler(this.connection, session, + clientSupportsTypedSessionFailures(this.clientCapabilities), this.sessionFailureEpoch, + new CodexSubagentEventRouter(sessionId, clientSupportsSubagents(this.clientCapabilities), + new ACPSessionConnection(this.connection, sessionId), id => this.reportingConnection.reports.releaseOpen(id)), + value => this.handleAccountUpdated(value), false, + clientSupportsCompaction(this.clientCapabilities), clientSupportsNotices(this.clientCapabilities)); + let turn: {id: string; abort: AbortController; handler: CodexEventHandler; + approval: ApprovalHandler; elicitation: CodexElicitationHandler; + children: Map; finish(): void}>; + observe(event: ServerNotification): Promise} | undefined; + const owner = { + install: (value: SessionState) => { installedState = value; install(value); }, + stop: () => { + stopped = true; + nativeTurnId = null; + install(null); + turn?.abort.abort(); + for (const childOwner of retained) childOwner.abort.abort(); + void turn?.handler.dispose(); + void idleHandler?.dispose(); + turn = undefined; + }, + cancel: () => { cancelled = true; turn?.abort.abort(); for (const childOwner of retained) childOwner.abort.abort(); }, + allow: () => { cancelled = false; }, + active: () => turn !== undefined || retained.size > 0, + nativeTurnId: () => nativeTurnId, + }; + this.queueOwners.set(sessionId, owner); + const handler = async (event: ServerNotification) => { + const initial = await ready; + const session = installedState ?? initial; + if (!session || stopped) return; + if (event.method === "turn/started" && event.params.threadId === sessionId) { + if (turn?.id === event.params.turn.id) return; + childTurns.clear(); + const abort = new AbortController(); + const context = this.permissionLifecycleContext(session).beginPrompt(); + const renderer = new AcpToolCallRenderer(this.capabilities); + const elicitation = new CodexElicitationHandler(this.connection, context, this.clientCapabilities, abort.signal, renderer); + const events = createHandler(session); + turn = {id: event.params.turn.id, abort, handler: events, + children: new Map(), + approval: new CodexApprovalHandler(this.connection, context, abort.signal, renderer), elicitation, + observe: async value => { context.handleNotification(value); await elicitation.handleNotification(value); }}; + this.beginPromptTokenUsage(session); + if (cancelled || this.sessionIsClosing(sessionId)) { + abort.abort(); + void this.requestTurnInterrupt({threadId: sessionId, turnId: turn.id}, "Cancel").catch(error => { + this.queueRecoveryRequired.add(sessionId); + logger.error("Failed to interrupt a late queued turn", error); + }); + } + } + if (event.method === "thread/queue/changed") { + await this.connection.notify("_session/queue/changed", {sessionId}); + } + const current = turn; + if (!current) { + // Names, goals, account state and background tasks also change between turns. + idleHandler ??= createHandler(session); + await idleHandler.handleSessionScopedNotification(event); + return; + } + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id !== current.id) return; + if (event.method === "turn/started" && event.params.threadId !== sessionId) childTurns.add(event.params.turn.id); + if (event.method === "turn/completed" && event.params.threadId !== sessionId) childTurns.delete(event.params.turn.id); + await current.observe(event); + await current.handler.handleNotification(event); + if (event.method === "turn/started" && event.params.threadId === sessionId) { + await this.connection.notify("_session/queue/turn", {sessionId, turn: event.params.turn}); + } + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id === current.id) { + try { + await current.handler.flushPendingErrors(); + await current.handler.handleFailedTurn(event.params.turn); + await current.handler.flushPendingPlanUpdates(); + await this.connection.notify("_session/queue/turn", {sessionId, turn: event.params.turn}); + } finally { + if (turn === current) turn = undefined; + retained.add(current); + // Child terminal notifications must keep flowing; waiting inside + // this serialized callback would deadlock their completion. + void (async () => { + try { + if (event.params.turn.status === "completed") { + await current.handler.waitForNativeSubagents(current.abort.signal); + let onAbort!: () => void; + const cancelledChildren = new Promise(resolve => { onAbort = resolve; current.abort.signal.addEventListener("abort", onAbort, {once: true}); }); + try { + const deadline = Date.now() + 600_000; + while (!current.abort.signal.aborted && current.children.size > 0) { + const remaining = deadline - Date.now(); + if (remaining <= 0) break; + // Descendants can be registered while a child is running. + const changed = Promise.race([...current.children.values()].map(child => child.done)); + if (await settledWithin(Promise.race([changed, cancelledChildren]), remaining) === "pending") break; + } + } finally { current.abort.signal.removeEventListener("abort", onAbort); } + } + else await current.handler.finishOutstandingNativeSubagents(event.params.turn.status === "interrupted" ? "cancelled" : "failed"); + } finally { + current.abort.abort(); + await current.handler.dispose(); + retained.delete(current); + } + })().catch(error => logger.error("Queued child cleanup failed", error)); + } + } + }; + const owns = (p: {threadId: string; turnId?: string | null}) => turn && !turn.abort.signal.aborted + && (p.turnId == null || p.turnId === turn.id || childTurns.has(p.turnId)); + const captureChild = (childThreadId: string, captured = turn): Subscription => { + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + captured?.children.set(childThreadId, {done, finish}); + const finishChild = (event: ServerNotification) => { + if (event.method === "turn/completed" && event.params.threadId === childThreadId) { + captured?.children.delete(childThreadId); + finish(); + } + }; + return { + rootSessionId: sessionId, supportsSubagents: clientSupportsSubagents(this.clientCapabilities), + dispatch: async event => { if (captured) { await captured.observe(event); await captured.handler.handleNotification(event); finishChild(event); } }, + enqueueInteraction: async event => { await captured?.observe(event); finishChild(event); }, + approvalHandler: captured?.approval ?? { + handleCommandExecution: async () => ({decision: "cancel"}), + handleFileChange: async () => ({decision: "cancel"}), + handlePermissionsRequest: async () => ({permissions: {}, scope: "turn", strictAutoReview: false}), + }, + elicitationHandler: captured?.elicitation ?? { + handleElicitation: async () => ({action: "cancel", content: null, _meta: null}), + handleUserInput: async () => ({answers: {}}), + }, + waitForRootNotifications: () => queueClient.waitForSessionNotifications(sessionId), + waitForChildSession: async id => await captured?.handler.waitForNativeSubagentSession(id) ?? null, + captureChild: id => captureChild(id, captured), + }; + }; + await queueClient.subscribeToQueueEvents(sessionId, handler, { + handleCommandExecution: async p => owns(p) ? turn!.approval.handleCommandExecution(p) : {decision: "cancel"}, + handleFileChange: async p => owns(p) ? turn!.approval.handleFileChange(p) : {decision: "cancel"}, + handlePermissionsRequest: async p => owns(p) ? turn!.approval.handlePermissionsRequest(p) : {permissions: {}, scope: "turn", strictAutoReview: false}, + }, { + handleElicitation: async p => owns(p) ? turn!.elicitation.handleElicitation(p) : {action: "cancel", content: null, _meta: null}, + handleUserInput: async p => owns(p) ? turn!.elicitation.handleUserInput(p) : {answers: {}}, + }, clientSupportsSubagents(this.clientCapabilities), async event => { + if (event.method === "turn/started") childTurns.add(event.params.turn.id); + if (event.method === "turn/completed") childTurns.delete(event.params.turn.id); + await turn?.observe(event); + }, + async id => await turn?.handler.waitForNativeSubagentSession(id) ?? null, true, captureChild, + event => { + if (stopped) return; + if (event.method === "turn/started" && event.params.threadId === sessionId) nativeTurnId = event.params.turn.id; + if (event.method === "turn/completed" && event.params.threadId === sessionId && event.params.turn.id === nativeTurnId) nativeTurnId = null; + }); + if (state) install(state); + } private codexAcpClient: CodexAcpClient; private readonly connection: AcpClientConnection; private readonly mcpServerSignIn: McpServerSignIn; @@ -390,7 +578,19 @@ export class CodexAcpServer { }, beginSessionCloseFence: (sessionId) => this.beginSessionCloseFence(sessionId), endSessionCloseFence: (sessionId) => this.endSessionCloseFence(sessionId), + runSessionMutation: (sessionId, operation) => { + if (this.queueCancellations.has(sessionId)) throw RequestError.invalidRequest("Session cancellation is in progress"); + return this.sessionOperation(sessionId, async () => { + if (this.queueCancellations.has(sessionId)) throw RequestError.invalidRequest("Session cancellation is in progress"); + return operation(); + }); + }, }); + private providerCreationReservations = 0; + private providerInvalidated = false; + private readonly queueMutations = new Set(); + private readonly queueRecoveryRequired = new Set(); + private readonly sessionOperations = new Map(); constructor( connection: AcpClientConnection, @@ -511,6 +711,7 @@ export class CodexAcpServer { }, authMethods: getCodexAuthMethods(_params.clientCapabilities), _meta: { + ...(sessionQueueCapability(this.codexAcpClient.queueSupport) ? {queue: sessionQueueCapability(this.codexAcpClient.queueSupport)} : {}), steering: { supported: true, }, @@ -540,6 +741,128 @@ export class CodexAcpServer { }; } + private assertProviderAvailable(): void { + if (this.providerInvalidated) throw RequestError.invalidRequest("Native provider was invalidated after a timeout; reconnect the ACP adapter"); + } + + private async invalidateProviderAfterTimeout(): Promise { + // Poison admission BEFORE disposing the transport: late native completions must never + // install or publish a session, even if process termination itself fails. + this.providerInvalidated = true; + for (const session of this.sessions.values()) { + session.asyncTasks.clear(); + this.bumpSessionGeneration(session.sessionId); + this.mcpSessionStartup.close(session.sessionId); + } + for (const prompt of this.activePrompts.values()) prompt.requestClose(); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); + for (const pending of this.pendingTurnStarts.values()) pending.resolve(null); + this.sessions.clear(); + this.pendingTurnStarts.clear(); + this.steeringQueues.clear(); + if (this.codexProcessState !== null) await terminateCodexConnection(this.codexProcessState.connection); + } + + private async boundedQueueOperation(operation: () => Promise): Promise { + const pending = operation(); + // settledWithin observes the losing promise as well; it never becomes an unhandled rejection. + const result = await settledWithin(pending.then(value => ({value})), 30_000); + if (result === "pending") { + await this.invalidateProviderAfterTimeout(); + throw RequestError.internalError(undefined, "Native queue operation timed out; provider terminated, reconnect the ACP adapter"); + } + return result.value; + } + + private assertQueueOperationAllowed(sessionId: string): void { + this.assertProviderAvailable(); + if (this.queueMutations.has(sessionId)) throw RequestError.invalidRequest("Queue mutation is in progress"); + } + + private async sessionOperation(sessionId: string, operation: () => Promise): Promise { + // Keep provider waits at their upstream request boundaries. Close and local + // settings must be able to settle a prompt/resume that a provider update awaits. + this.assertQueueOperationAllowed(sessionId); + this.sessionOperations.set(sessionId, (this.sessionOperations.get(sessionId) ?? 0) + 1); + try { return await operation(); } + finally { + const remaining = (this.sessionOperations.get(sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(sessionId); + else this.sessionOperations.set(sessionId, remaining); + } + } + + private async assertNoPendingNativeQueue(sessionId: string): Promise { + if (!this.codexAcpClient.queueSupport.actions.includes("list")) return; + if (this.sessionIsClosing(sessionId)) throw RequestError.invalidRequest(`Session ${sessionId} is closing`); + const generation = this.getSessionGeneration(sessionId); + const client = this.codexAcpClient; + let page; + try { page = await client.appServerClient.queueNative().list({threadId: sessionId, limit: 1}); } + catch (error) { + // Preserve the existing public invalid-session diagnostic; this is a read validation + // error, never a reason to resume, mutate history or skip pending-queue checks. + if (error instanceof Error && error.message.startsWith("invalid thread id:")) { + throw RequestError.invalidParams(undefined, "invalid session id"); + } + throw error; + } + if (this.codexAcpClient !== client || this.getSessionGeneration(sessionId) !== generation || this.sessionIsClosing(sessionId)) { + throw RequestError.invalidRequest(`Session ${sessionId} changed while checking its native queue`); + } + if (page.data.length || page.nextCursor) throw RequestError.invalidRequest("Pending native queue must be started or cleared before sending a normal prompt"); + } + + async manageSessionQueue(value: unknown, signal?: AbortSignal): Promise { + const request = parseSessionQueueRequest(value); + const cancelGeneration = this.queueCancelGenerations.get(request.sessionId) ?? 0; + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + this.assertQueueOperationAllowed(request.sessionId); + if (this.queueRecoveryRequired.has(request.sessionId)) throw RequestError.invalidRequest("Load the session to reconcile its native state first"); + const state = this.sessions.get(request.sessionId); + const prompt = this.activePrompts.get(request.sessionId); + if (request.action === "add" && prompt && prompt.currentTurn === null) throw RequestError.invalidRequest("Prompt startup is pending"); + if (this.sessionIsClosing(request.sessionId) || this.sessionOperations.has(request.sessionId)) throw RequestError.invalidRequest("Session is busy"); + return this.sessionOperation(request.sessionId, async () => { + this.queueMutations.add(request.sessionId); + let dispatched = false; + try { + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + const capability = sessionQueueCapability(this.codexAcpClient.queueSupport); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + // A restart may auto-dispatch persisted work when the thread resumes. Only an + // explicit load may reconcile it; never resume or replay a queue write implicitly. + if (state && this.recovery !== null && !this.recovery.sessionIsLive(state)) { + throw RequestError.invalidRequest("Load the session after the app-server restart before managing its queue"); + } + if (signal?.aborted) throw RequestError.invalidRequest("Queue request cancelled before dispatch"); + if (request.action === "add" && !state) { + const native = await this.codexAcpClient.appServerClient.threadRead({threadId: request.sessionId}); + if (native.thread.status.type !== "notLoaded") throw RequestError.invalidRequest("Load the native session before adding to a live queue"); + } + if (state && !this.queueOwners.has(request.sessionId)) await this.ownNativeQueue(request.sessionId, state); + if (request.action === "start" && (!state || this.activePrompts.has(request.sessionId) || this.queueOwners.get(request.sessionId)?.active())) { + throw RequestError.invalidRequest("Load an idle session before starting its queue"); + } + if (signal?.aborted) throw RequestError.invalidRequest("Queue request cancelled before dispatch"); + if ((request.action === "add" || request.action === "start") + && (this.queueCancelGenerations.get(request.sessionId) ?? 0) === cancelGeneration) this.queueOwners.get(request.sessionId)?.allow(); + dispatched = true; + return await this.boundedQueueOperation(() => runSessionQueue(request, {sessionId: request.sessionId, support: this.codexAcpClient.queueSupport, native: this.codexAcpClient.appServerClient.queueNative()})); + } catch (error) { + // Transport/internal failures may follow a durable commit. Never retry writes. + if (dispatched && request.action !== "list" && !(typeof error === "object" && error !== null && "code" in error && + (error.code === -32600 || error.code === -32601 || error.code === -32602))) { + this.queueRecoveryRequired.add(request.sessionId); + } + throw error; + } + finally { this.queueMutations.delete(request.sessionId); } + }); + } + async listHooks(cwd: string): Promise { if (this.providerUpdate !== null) await this.waitForProviderUpdate(); const appServer = this.ensureAppServer(); @@ -555,6 +878,13 @@ export class CodexAcpServer { } async extMethod(method: string, params: Record): Promise> { + if ((method === GOAL_CONTROL_METHOD || method === LEGACY_GOAL_CONTROL_METHOD) && typeof params["sessionId"] === "string") { + return this.sessionOperation(params["sessionId"], () => this.extMethodDuringOperation(method, params)); + } + return this.extMethodDuringOperation(method, params); + } + + private async extMethodDuringOperation(method: string, params: Record): Promise> { const methodRequest = { method: method, params: params }; if (!isExtMethodRequest(methodRequest)) { return {}; @@ -776,11 +1106,15 @@ export class CodexAcpServer { } this.beginSessionCloseFence(sessionId); try { + this.queueOwners.get(sessionId)?.cancel(); // A dead app-server holds nothing to close. if (this.recovery === null || this.recovery.isReady()) { + const turnId = this.queueOwners.get(sessionId)?.nativeTurnId(); + if (turnId) await this.requestTurnInterrupt({threadId: sessionId, turnId}, "Close"); await this.runWithProcessCheck(() => this.codexAcpClient.closeSession(sessionId)); } } catch (err) { + if (this.queueOwners.get(sessionId)?.nativeTurnId()) await this.invalidateProviderAfterTimeout(); logger.error(`Failed to close stale session open for ${sessionId}`, err); } finally { this.endSessionCloseFence(sessionId); @@ -1130,8 +1464,10 @@ export class CodexAcpServer { } private installSessionState(sessionState: SessionState): void { + this.assertProviderAvailable(); this.sessions.get(sessionState.sessionId)?.asyncTasks.clear(); this.sessions.set(sessionState.sessionId, sessionState); + if (this.codexAcpClient.queueSupport.actions.includes("add") && !this.queueHistoryLoads.has(sessionState.sessionId)) void this.ownNativeQueue(sessionState.sessionId, sessionState); } private getAuthProviderForAuthenticateRequest(request: acp.AuthenticateRequest): string | null { @@ -1148,11 +1484,39 @@ export class CodexAcpServer { } private async loadSessionOnce(params: acp.LoadSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + if (this.queueOwners.get(params.sessionId)?.active() || this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Session has an active turn"); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + this.queueHistoryLoads.add(params.sessionId); + try { + const result = await this.loadSessionDuringOperation(params); + this.queueRecoveryRequired.delete(params.sessionId); + return result; + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + await this.closeSessionDuringOperation({sessionId: params.sessionId}).catch(closeError => { + logger.error("Failed to stop native work after load failure", closeError); + }); + } + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + throw error; + } finally { + this.queueHistoryLoads.delete(params.sessionId); + } + }); + } + + private async loadSessionDuringOperation(params: acp.LoadSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } const appServer = this.ensureAppServer(); if (appServer) await appServer; + if (this.codexAcpClient.queueSupport.actions.includes("add")) await this.ownNativeQueue(params.sessionId); + else if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); logger.log("Loading session...", {sessionId: params.sessionId}); // Captured before the load installs a fresh SessionState: a title // generation started by an earlier turn on this session belongs to the @@ -1184,6 +1548,7 @@ export class CodexAcpServer { // A load response means "the replay is complete"; a late rename echo // from a still-running title generation would arrive after it. await previousTitleGen?.waitForIdle(TITLE_GENERATION_SETTLE_TIMEOUT_MS); + this.queueOwners.get(sessionId)?.install(this.getSessionState(sessionId)); logger.log("Session loaded", { sessionId: sessionId, @@ -1204,11 +1569,34 @@ export class CodexAcpServer { } private async resumeSessionOnce(params: acp.ResumeSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + if (this.queueOwners.get(params.sessionId)?.active() || this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Session has an active turn"); + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest(`Session ${params.sessionId} is closing`); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + try { + return await this.resumeSessionDuringOperation(params); + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + await this.closeSessionDuringOperation({sessionId: params.sessionId}).catch(closeError => { + logger.error("Failed to stop native work after resume failure", closeError); + }); + } + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); + throw error; + } + }); + } + + private async resumeSessionDuringOperation(params: acp.ResumeSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } const appServer = this.ensureAppServer(); if (appServer) await appServer; + if (this.codexAcpClient.queueSupport.actions.includes("add")) await this.ownNativeQueue(params.sessionId); + else if (this.codexAcpClient.queueSupport.actions.includes("list")) await this.assertNoPendingNativeQueue(params.sessionId); logger.log("Resuming session...", {sessionId: params.sessionId}); const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); @@ -1225,6 +1613,13 @@ export class CodexAcpServer { } async forkSession(params: acp.ForkSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.forkSessionDuringOperation(params); + return result; + }); + } + + private async forkSessionDuringOperation(params: acp.ForkSessionRequest): Promise { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } @@ -1271,12 +1666,25 @@ export class CodexAcpServer { } async closeSession(params: acp.CloseSessionRequest): Promise { + if (this.providerInvalidated) return {}; + return this.sessionOperation(params.sessionId, async () => { + const result = await this.closeSessionDuringOperation(params); + return result; + }); + } + + private async closeSessionDuringOperation(params: acp.CloseSessionRequest): Promise { logger.log("Closing session...", {sessionId: params.sessionId}); const closeGeneration = this.bumpSessionGeneration(params.sessionId); const sessionState = this.sessions.get(params.sessionId); this.beginSessionCloseFence(params.sessionId); + this.queueOwners.get(params.sessionId)?.cancel(); try { + const queuedTurn = this.queueOwners.get(params.sessionId)?.nativeTurnId(); + if (queuedTurn && sessionState?.currentTurnId !== queuedTurn) { + await this.requestTurnInterrupt({threadId: params.sessionId, turnId: queuedTurn}, "Close"); + } if (sessionState) { await this.interruptSessionTurn(sessionState, "Close", true); sessionState.asyncTasks.clear(); @@ -1297,9 +1705,17 @@ export class CodexAcpServer { logger.log("Session closed without an app-server call: its app-server is gone", {sessionId: params.sessionId}); } logger.log("Session closed", {sessionId: params.sessionId}); + } catch (error) { + if (this.queueOwners.get(params.sessionId)?.nativeTurnId()) { + // Do not unsubscribe and forget a writer whose interruption failed. + await this.invalidateProviderAfterTimeout(); + } + throw error; } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { this.sessions.delete(params.sessionId); + this.queueOwners.get(params.sessionId)?.stop(); + this.queueOwners.delete(params.sessionId); this.mcpSessionStartup.close(params.sessionId); this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); @@ -1314,6 +1730,13 @@ export class CodexAcpServer { } async deleteSession(params: acp.DeleteSessionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.deleteSessionDuringOperation(params); + return result; + }); + } + + private async deleteSessionDuringOperation(params: acp.DeleteSessionRequest): Promise { const appServer = this.ensureAppServer(); if (appServer) await appServer; logger.log("Deleting session...", {sessionId: params.sessionId}); @@ -1357,32 +1780,37 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, ): Promise { - if (this.providerUpdate !== null) { - await this.waitForProviderUpdate(); - } - const appServer = this.ensureAppServer(); - if (appServer) await appServer; - logger.log("Starting new session..."); - const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); - - logger.log("New session created", { - sessionId: sessionId, - modelId: modelState.currentModelId, - availableModelCount: modelState.availableModels.length - }); + this.assertProviderAvailable(); + this.providerCreationReservations++; + try { + while (this.providerUpdate !== null) await this.waitForProviderUpdate(); + const appServer = this.ensureAppServer(); + if (appServer) await appServer; + this.assertProviderAvailable(); + logger.log("Starting new session..."); + const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); + + logger.log("New session created", { + sessionId: sessionId, + modelId: modelState.currentModelId, + availableModelCount: modelState.availableModels.length + }); - return { - sessionId: sessionId, - models: modelState, - modes: modeState, - ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), - }; + return { + sessionId: sessionId, + models: modelState, + modes: modeState, + ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), + }; + } finally { this.providerCreationReservations--; } } async authenticate( _params: acp.AuthenticateRequest, requestId?: acp.JsonRpcId, ): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Authenticate request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1423,6 +1851,8 @@ export class CodexAcpServer { } async logout(_params: acp.LogoutRequest): Promise { + this.assertProviderAvailable(); + if (this.providerCreationReservations) throw RequestError.invalidRequest("Provider history or runtime operation is in progress"); logger.log("Logout request received"); const appServer = this.ensureAppServer(); if (appServer) await appServer; @@ -1436,14 +1866,19 @@ export class CodexAcpServer { } async setProvider(params: acp.SetProviderRequest): Promise { - this.codexAcpClient.setProvider(params); + this.assertProviderAvailable(); + if (this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); await this.enqueueProviderUpdate((client) => client.setProvider(params)); return { }; } async disableProvider(params: acp.DisableProviderRequest): Promise { - this.codexAcpClient.disableProvider(params); + this.assertProviderAvailable(); + if (this.providerCreationReservations) throw RequestError.invalidRequest("Runtime control or session creation is in progress"); + if (this.providerCreationReservations || this.sessionOperations.size > 0) throw RequestError.invalidRequest("A session lifecycle or settings operation is in progress"); if (params.providerId !== OPENAI_PROVIDER_ID) { + this.codexAcpClient.disableProvider(params); return { }; } await this.enqueueProviderUpdate((client) => client.disableProvider(params)); @@ -1451,9 +1886,15 @@ export class CodexAcpServer { } private async enqueueProviderUpdate(apply: (client: CodexAcpClient) => void): Promise { + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("Cancel queued turns before replacing the provider"); + if (this.sessions.size === 0 && this.providerUpdate === null) { + apply(this.codexAcpClient); + return; + } const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve(); const update = previous.then(async () => { if (this.sessions.size === 0) { + apply(this.codexAcpClient); return; } @@ -1462,18 +1903,24 @@ export class CodexAcpServer { logger.log("Waiting for active prompts before provider restart", {count: activePrompts.length}); await Promise.all(activePrompts); } + for (const session of this.sessions.values()) await this.assertNoPendingNativeQueue(session.sessionId); + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("Cancel queued turns before replacing the provider"); logger.log("Restarting Codex app-server for provider update", {sessionCount: this.sessions.size}); for (const session of this.sessions.values()) { session.asyncTasks.prepareForAppServerReplacement(); } await this.finishAllAsyncTasks("stopped", "before the provider restart"); + if ([...this.queueOwners.values()].some(owner => owner.active())) throw RequestError.invalidRequest("A queued turn started before provider replacement"); + apply(this.codexAcpClient); const replacement = await this.restartCodexClient(); apply(replacement); if (this.initializeRequest === null) { throw new Error("Cannot restart Codex app-server before ACP initialization"); } await replacement.initialize(this.initializeRequest); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); this.codexAcpClient = replacement; this.sessionIndex.observe(replacement); this.availableCommands = this.createAvailableCommands(replacement); @@ -1489,6 +1936,12 @@ export class CodexAcpServer { break; } try { + if (replacement.queueSupport.actions.includes("add")) { + await this.ownNativeQueue(session.sessionId, session); + } else if (replacement.queueSupport.actions.includes("list")) { + const pending = await replacement.appServerClient.queueNative().list({threadId: session.sessionId, limit: 1}); + if (pending.data.length || pending.nextCursor) throw RequestError.invalidRequest("Native pending queue cannot be auto-resumed by ACP"); + } const request = { sessionId: session.sessionId, cwd: session.cwd, @@ -1578,6 +2031,7 @@ export class CodexAcpServer { * `const appServer = this.ensureAppServer(); if (appServer) await appServer;`. */ private ensureAppServer(): Promise | undefined { + this.assertProviderAvailable(); return this.recovery?.ensureRunning(); } @@ -1613,6 +2067,8 @@ export class CodexAcpServer { : null, install: (client) => this.installCodexClient(client), crashed: () => { + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); // Every session takes its tasks now, before any await, so a task that a restarted app-server starts // meanwhile is not failed with the old ones. for (const session of this.sessions.values()) { @@ -1622,6 +2078,14 @@ export class CodexAcpServer { } }, resumeSession: async (session, client, onSubscribed) => { + // Explicit load owns auto-dispatch and failed-load cleanup. A lazy recovery + // of an ordinary request must not start persisted work as a side effect. + if (client.queueSupport.actions.includes("list")) { + await this.assertNoPendingNativeQueue(session.sessionId); + } + if (client.queueSupport.actions.includes("add")) { + await this.ownNativeQueue(session.sessionId, session); + } const metadata = await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd, @@ -1644,6 +2108,9 @@ export class CodexAcpServer { /** Makes `client`, of a restarted app-server, the client of the agent and of every session. */ private installCodexClient(client: CodexAcpClient): void { + this.assertProviderAvailable(); + for (const owner of this.queueOwners.values()) owner.stop(); + this.queueOwners.clear(); // A `providers/set` can have changed the routing of the old client while the restart ran. client.adoptRoutingFrom(this.codexAcpClient); this.codexAcpClient = client; @@ -1844,6 +2311,7 @@ export class CodexAcpServer { async setSessionMode( _params: acp.SetSessionModeRequest, ): Promise { + this.assertQueueOperationAllowed(_params.sessionId); logger.log("Set session mode requested", { sessionId: _params.sessionId, modeId: _params.modeId @@ -1856,6 +2324,13 @@ export class CodexAcpServer { } async setSessionConfigOption(params: acp.SetSessionConfigOptionRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.setSessionConfigOptionDuringOperation(params); + return result; + }); + } + + private async setSessionConfigOptionDuringOperation(params: acp.SetSessionConfigOptionRequest): Promise { logger.log("Set session config option requested", { sessionId: params.sessionId, configId: params.configId, @@ -1965,6 +2440,13 @@ export class CodexAcpServer { } async unstable_setSessionModel(params: LegacySetSessionModelRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.unstable_setSessionModelDuringOperation(params); + return result; + }); + } + + private async unstable_setSessionModelDuringOperation(params: LegacySetSessionModelRequest): Promise { logger.log("Set session model requested", { sessionId: params.sessionId, modelId: params.modelId @@ -2027,6 +2509,14 @@ export class CodexAcpServer { * {@link performSteeringRequest}. */ async executeOrQueueSteeringRequest(params: SessionSteerRequest): Promise { + return this.sessionOperation(params.sessionId, async () => { + const result = await this.executeOrQueueSteeringRequestDuringOperation(params); + return result; + }); + } + + private async executeOrQueueSteeringRequestDuringOperation(params: SessionSteerRequest): Promise { + if (this.queueRecoveryRequired.has(params.sessionId)) throw RequestError.invalidRequest("Previous queue outcome is uncertain; load the session before continuing"); const queue = this.getSteeringQueue(params.sessionId); try { return await queue.enqueue(params); @@ -2884,45 +3374,6 @@ export class CodexAcpServer { return updates; } - private createReasoningUpdates(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent[] { - const parts = item.summary.length > 0 ? item.summary : item.content; - const messageId = item.id; - return parts.map((text) => createAgentTextThoughtChunk(text, messageId)); - } - - private createReviewModeUpdate( - item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" }, - entered: boolean - ): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: `${entered ? "Entered" : "Exited"} review mode: ${item.review}`, - }, - }; - } - - private createPlanHistoryUpdate( - item: ThreadItem & { type: "plan" } - ): UpdateSessionEvent { - if (this.capabilities.planUpdates) { - return { - sessionUpdate: "plan_update", - plan: { - type: "markdown", - planId: item.id, - content: item.text, - }, - }; - } - return createAgentTextMessageChunk( - item.text, - item.id, - createMessagePhaseMeta("final_answer", this.capabilities.airClient), - ); - } - private userInputToContentBlocks(input: UserInput): acp.ContentBlock[] { switch (input.type) { case "text": @@ -2952,6 +3403,7 @@ export class CodexAcpServer { } } + private formatUriAsLink(name: string | null, uri: string): string { if (name && name.length > 0) { return `[@${name}](${uri})`; @@ -2964,6 +3416,45 @@ export class CodexAcpServer { return uri; } + private createReasoningUpdates(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent[] { + const parts = item.summary.length > 0 ? item.summary : item.content; + const messageId = item.id; + return parts.map((text) => createAgentTextThoughtChunk(text, messageId)); + } + + private createReviewModeUpdate( + item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" }, + entered: boolean + ): UpdateSessionEvent { + return { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `${entered ? "Entered" : "Exited"} review mode: ${item.review}`, + }, + }; + } + + private createPlanHistoryUpdate( + item: ThreadItem & { type: "plan" } + ): UpdateSessionEvent { + if (this.capabilities.planUpdates) { + return { + sessionUpdate: "plan_update", + plan: { + type: "markdown", + planId: item.id, + content: item.text, + }, + }; + } + return createAgentTextMessageChunk( + item.text, + item.id, + createMessagePhaseMeta("final_answer", this.capabilities.airClient), + ); + } + getSessionState(sessionId: string): SessionState { const sessionState = this.sessions.get(sessionId); if (!sessionState) { @@ -3151,14 +3642,19 @@ export class CodexAcpServer { }); return; } catch (err) { - const retryDelay = requestName === "Cancel" + const retryDelay = (requestName === "Cancel" || this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) && isNoActiveTurnError(err) && attempt < NO_ACTIVE_TURN_RETRY_DELAYS_MS.length - && this.activePrompts.has(turn.threadId) + && (this.activePrompts.has(turn.threadId) + || this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) ? NO_ACTIVE_TURN_RETRY_DELAYS_MS[attempt]! : null; if (retryDelay === null) { logger.error(`${requestName} - turnInterrupt failed`, err); + if (this.queueOwners.get(turn.threadId)?.nativeTurnId() === turn.turnId) { + this.queueRecoveryRequired.add(turn.threadId); + throw RequestError.internalError(undefined, "Queued turn interruption unconfirmed; reload required"); + } return; } // The cancel raced the turn's registration in Codex: the prompt @@ -3178,6 +3674,8 @@ export class CodexAcpServer { private turnAppServerIsLive(threadId: string): boolean { if (this.recovery === null) return true; + // A cold resume can start queued work before its new SessionState is installed. + if (this.queueOwners.get(threadId)?.nativeTurnId()) return this.recovery.isReady(); const sessionState = this.sessions.get(threadId); return sessionState === undefined ? this.recovery.isReady() : this.recovery.sessionIsLive(sessionState); } @@ -3256,6 +3754,14 @@ export class CodexAcpServer { if (this.providerUpdate !== null) { await this.waitForProviderUpdate(); } + this.assertQueueOperationAllowed(params.sessionId); + if (this.queueRecoveryRequired.has(params.sessionId)) { + throw RequestError.invalidRequest("Previous queue outcome is uncertain; load the session before continuing"); + } + if (this.sessionIsClosing(params.sessionId)) throw RequestError.invalidRequest("Session is closing"); + if (this.activePrompts.has(params.sessionId)) throw RequestError.invalidRequest("Prompt is already running"); + if (this.queueHistoryLoads.has(params.sessionId)) throw RequestError.invalidRequest("Session history is loading"); + if (this.queueOwners.get(params.sessionId)?.active()) throw RequestError.invalidRequest("A queued turn is running"); logger.log("Prompt received", { sessionId: params.sessionId, prompt: params.prompt, @@ -3275,6 +3781,7 @@ export class CodexAcpServer { // A prompt that starts no model work, such as /status or a cancellation before the turn, used no tokens. sessionState.promptTokenUsage = null; const activePrompt = this.trackActivePrompt(params.sessionId); + let completedPromptUsage: TokenCount | null | undefined; let pendingTurnStart: PendingTurnStart | null = null; const ensurePendingTurnStart = (): PendingTurnStart => { if (pendingTurnStart === null) { @@ -3300,7 +3807,7 @@ export class CodexAcpServer { ? activePrompt.signal : AbortSignal.any([activePrompt.signal, appServerLost.signal]); const clearRecoveredSessionFailure = async (handler: CodexEventHandler): Promise => { - await handler.completeSuccessfulTurn(sessionState.currentTurnId); + await handler.completeSuccessfulTurn(activePrompt.currentTurn?.turnId ?? null); const current = sessionState.sessionFailure; if (recoverableSessionFailure !== undefined && current !== undefined @@ -3316,6 +3823,7 @@ export class CodexAcpServer { return this.cancelledPromptResponse(sessionState); }; + let subscribedPrompt = false; try { const sessionReady = this.recovery?.ensureSessionReady(sessionState); if (sessionReady) { @@ -3341,12 +3849,22 @@ export class CodexAcpServer { promptGeneration = this.recovery.generation; removeAppServerLossListener = this.recovery.onGenerationLost(promptGeneration, () => appServerLost.abort()); } + if (this.codexAcpClient.queueSupport.actions.includes("add")) { + await this.assertNoPendingNativeQueue(params.sessionId); + const native = await this.codexAcpClient.appServerClient.threadRead({threadId: params.sessionId}); + if (native.thread.status.type === "active") throw RequestError.invalidRequest("Native queued work is starting or running"); + await this.codexAcpClient.waitForSessionNotifications(params.sessionId); + if (this.queueOwners.get(params.sessionId)?.active()) throw RequestError.invalidRequest("A queued turn is running"); + } const promptEventHandler = new CodexEventHandler( this.connection, sessionState, clientSupportsTypedSessionFailures(this.clientCapabilities), this.sessionFailureEpoch, - sessionState.subagents, + this.codexAcpClient.queueSupport.actions.includes("add") + ? new CodexSubagentEventRouter(params.sessionId, clientSupportsSubagents(this.clientCapabilities), + new ACPSessionConnection(this.connection, params.sessionId), id => this.reportingConnection.reports.releaseOpen(id)) + : sessionState.subagents, (accountUpdated) => this.handleAccountUpdated(accountUpdated), agentFileChangeReportRequest !== null, clientSupportsCompaction(this.clientCapabilities), @@ -3384,6 +3902,7 @@ export class CodexAcpServer { const completesActiveTurn = event.method === "turn/completed" && event.params.threadId === sessionState.sessionId && event.params.turn.id === sessionState.currentTurnId; + if (completesActiveTurn) completedPromptUsage = sessionState.promptTokenUsage?.usage() ?? null; await promptEventHandler.handleNotification(event); if (completesActiveTurn) { // The prompt may remain open for plan approval after its turn has ended. Switch at @@ -3396,6 +3915,7 @@ export class CodexAcpServer { clientSupportsSubagents(this.clientCapabilities), observeInteraction, childThreadId => promptEventHandler.waitForNativeSubagentSession(childThreadId)); + subscribedPrompt = true; if (activePrompt.signal.aborted) { return cancelledPromptResponse(); @@ -3589,6 +4109,7 @@ export class CodexAcpServer { if ( completedPlan !== null && sessionState.collaborationMode === PLAN_COLLABORATION_MODE + && !this.queueOwners.get(params.sessionId)?.active() && !this.promptShouldStop(params.sessionId, activePrompt) ) { const approved = await this.requestPlanImplementationPermission( @@ -3600,7 +4121,10 @@ export class CodexAcpServer { if (this.promptShouldStop(params.sessionId, activePrompt)) { return cancelledPromptResponse(); } - if (approved && !this.promptShouldStop(params.sessionId, activePrompt)) { + if (approved && !this.promptShouldStop(params.sessionId, activePrompt) + && !this.queueOwners.get(params.sessionId)?.active()) { + await this.assertNoPendingNativeQueue(params.sessionId); + promptClient.resumePromptEvents(params.sessionId); await this.applyCollaborationModeChange(sessionState, DEFAULT_COLLABORATION_MODE); const session = new ACPSessionConnection(this.connection, sessionState.sessionId); await session.update({ @@ -3722,8 +4246,8 @@ export class CodexAcpServer { return { stopReason: "end_turn", - usage: this.buildPromptUsage(sessionState), - _meta: this.buildQuotaMeta(sessionState), + usage: this.buildPromptUsage(sessionState, completedPromptUsage), + _meta: this.buildQuotaMeta(sessionState, completedPromptUsage), }; } catch (caught) { const err = this.recovery?.mapError(caught, promptClient) ?? caught; @@ -3774,9 +4298,10 @@ export class CodexAcpServer { // The app-server subscription is session-scoped and outlives this prompt. Flip routing before // awaiting disposal so queued late notifications cannot enter prompt-local buffers. promptNotificationsActive = false; + if (subscribedPrompt) promptClient.finishPromptEvents(params.sessionId); try { await promptClient.waitForSessionNotifications(params.sessionId); - await eventHandler?.finishOutstandingNativeSubagents( + if (!this.queueOwners.get(params.sessionId)?.active()) await eventHandler?.finishOutstandingNativeSubagents( promptWasCancelled || activePrompt.signal.aborted || this.sessionIsClosing(params.sessionId) ? "cancelled" : "failed", @@ -3808,7 +4333,7 @@ export class CodexAcpServer { await eventHandler?.dispose(); disposePromptRequestCancellation(); removeAppServerLossListener(); - sessionState.currentTurnId = null; + if (!this.queueOwners.get(params.sessionId)?.active()) sessionState.currentTurnId = null; const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId); if (registeredPendingTurnStart !== undefined) { this.pendingTurnStarts.delete(params.sessionId); @@ -3922,8 +4447,7 @@ export class CodexAcpServer { } /** `_meta.quota`: the usage of the whole prompt, the same counts as {@link buildPromptUsage}. */ - private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { - const promptTokenUsage = sessionState.promptTokenUsage?.usage() ?? null; + private buildQuotaMeta(sessionState: SessionState, promptTokenUsage = sessionState.promptTokenUsage?.usage() ?? null): { quota: QuotaMeta } { // Remove the "[reasoning-level]" suffix from currentModelId if present const modelName = sessionState.currentModelId.replace(/\[.*?]$/, ''); @@ -3949,8 +4473,7 @@ export class CodexAcpServer { sessionState.promptTokenUsage = new PromptTokenUsage(startTotal); } - private buildPromptUsage(sessionState: SessionState): acp.Usage | null { - const usage = sessionState.promptTokenUsage?.usage() ?? null; + private buildPromptUsage(sessionState: SessionState, usage = sessionState.promptTokenUsage?.usage() ?? null): acp.Usage | null { return usage === null ? null : toPromptUsage(usage); } @@ -3960,6 +4483,7 @@ export class CodexAcpServer { * `client` is the client that the operation uses, by default the installed one at the call. */ private async runWithProcessCheck(operation: () => Promise, client = this.codexAcpClient): Promise { + this.assertProviderAvailable(); try { return await operation(); } catch (err) { @@ -3992,8 +4516,32 @@ export class CodexAcpServer { } async cancel(params: acp.CancelNotification): Promise { + // Cancel must enter even while a queue write is pending, but index writes + // must not race its native interrupt/ACK reconciliation. + this.sessionOperations.set(params.sessionId, (this.sessionOperations.get(params.sessionId) ?? 0) + 1); + this.queueCancellations.set(params.sessionId, (this.queueCancellations.get(params.sessionId) ?? 0) + 1); + try { + await this.cancelDuringOperation(params); + } finally { + const cancelling = (this.queueCancellations.get(params.sessionId) ?? 1) - 1; + if (cancelling === 0) this.queueCancellations.delete(params.sessionId); + else this.queueCancellations.set(params.sessionId, cancelling); + const remaining = (this.sessionOperations.get(params.sessionId) ?? 1) - 1; + if (remaining === 0) this.sessionOperations.delete(params.sessionId); + else this.sessionOperations.set(params.sessionId, remaining); + } + } + + private async cancelDuringOperation(params: acp.CancelNotification): Promise { this.activePrompts.get(params.sessionId)?.requestClientCancel(); + this.queueCancelGenerations.set(params.sessionId, (this.queueCancelGenerations.get(params.sessionId) ?? 0) + 1); + this.queueOwners.get(params.sessionId)?.cancel(); const sessionState = this.sessions.get(params.sessionId); + const rawTurnId = this.queueOwners.get(params.sessionId)?.nativeTurnId(); + if (rawTurnId && rawTurnId !== sessionState?.currentTurnId) { + await this.requestTurnInterrupt({threadId: params.sessionId, turnId: rawTurnId}, "Cancel"); + return; + } if (!sessionState) { logger.log("Cancel request rejected: session not found", {sessionId: params.sessionId}); return; diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9a1f95c8d..f8873c335 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,3 +1,4 @@ +import type {SessionQueueAction, SessionQueueNative} from "./SessionQueue"; import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; import {AppServerConnectionLostError, ConnectionLoss} from "./app-server-recovery/ConnectionLoss"; import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; @@ -742,6 +743,26 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/delete", params: params }); } + /** Invalid parameters only: proves method registration without a valid mutation target. */ + async probeQueueAction(action: SessionQueueAction): Promise { + try { await this.connection.sendRequest(`thread/queue/${action}`, {}); return false; } + catch (error) { + return typeof error === "object" && error !== null && "code" in error && error.code === -32600 + && "message" in error && error.message === "Invalid request: missing field `threadId`"; + } + } + + queueNative(): SessionQueueNative { + return { + list: params => this.connection.sendRequest("thread/queue/list", params), + add: params => this.connection.sendRequest("thread/queue/add", params), + update: params => this.connection.sendRequest("thread/queue/update", params), + delete: params => this.connection.sendRequest("thread/queue/delete", params), + reorder: params => this.connection.sendRequest("thread/queue/reorder", params), + start: params => this.connection.sendRequest("thread/queue/start", params), + }; + } + async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise { return await this.sendRequest({ method: "thread/unsubscribe", params: params }); } diff --git a/src/CodexJsonRpcConnection.ts b/src/CodexJsonRpcConnection.ts index 94fb23bb0..cb9f920bf 100644 --- a/src/CodexJsonRpcConnection.ts +++ b/src/CodexJsonRpcConnection.ts @@ -4,12 +4,22 @@ import type {MessageConnection} from "vscode-jsonrpc/node"; import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process"; import {createRequire} from "node:module"; import crossSpawn from "cross-spawn"; +import type {Socket} from "node:net"; +import {ownedProcessHelperArgs} from "./CodexProcessHelpers"; import {createJSONRPCReader, createJSONRPCWriter} from "./StdUtils"; import {logger} from "./Logger"; const require = createRequire(import.meta.url); +const terminationTimeoutMs = 5_000; +interface OwnedProcess { + groupId?: number; + control?: Socket; + termination?: Promise; +} +const ownedProcesses = new WeakMap(); + export interface CodexConnection { readonly connection: MessageConnection readonly process: ChildProcessWithoutNullStreams; @@ -40,15 +50,29 @@ export function startCodexConnection( const spawnEnv = env ?? process.env; let codex: ChildProcessWithoutNullStreams; - if (codexPath) { + if (process.platform !== "win32") { + const command = codexPath ?? process.execPath; + const args = codexPath ? appServerStartupArgs + : [require.resolve("@openai/codex/bin/codex.js"), ...appServerStartupArgs]; + codex = spawn(process.execPath, ownedProcessHelperArgs("supervisor", [command, ...args]), { + env: spawnEnv, + windowsHide: true, + detached: true, + stdio: ["pipe", "pipe", "pipe", "pipe"], + }) as ChildProcessWithoutNullStreams; + const control = codex.stdio[3] as Socket; + // A broken control pipe is checked by termination via group disappearance. + control.on("error", () => {}); + control.unref(); // This extra lifeline must not keep the adapter event loop alive. + ownedProcesses.set(codex, {...(codex.pid === undefined ? {} : {groupId: codex.pid}), control}); + } else if (codexPath) { // cross-spawn runs `.cmd` shims through cmd.exe with correct escaping for the TOML hook override. - codex = process.platform === "win32" - ? crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv}) as ChildProcessWithoutNullStreams - : spawn(codexPath, appServerStartupArgs, {env: spawnEnv}); + codex = crossSpawn(codexPath, appServerStartupArgs, {env: spawnEnv, windowsHide: true}) as ChildProcessWithoutNullStreams; } else { const bundledCodexPath = require.resolve("@openai/codex/bin/codex.js"); - codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv}); + codex = spawn(process.execPath, [bundledCodexPath, ...appServerStartupArgs], {env: spawnEnv, windowsHide: true}); } + if (!ownedProcesses.has(codex)) ownedProcesses.set(codex, {}); attachLogs(codex); // A write to a process that is dying fails with EPIPE. Without a listener that error would end the adapter. @@ -101,3 +125,68 @@ export function attachLogs(proc: ChildProcessWithoutNullStreams) { logger.log(`[EXIT] code: ${code?.toString()}`); }); } + +/** Stops an unresponsive owned provider before allowing the client to reconnect. */ +export function terminateCodexConnection(native: CodexConnection): Promise { + const child = native.process; + let owned = ownedProcesses.get(child); + if (!owned) { + // Do not infer group ownership from a caller-supplied PID. + owned = {}; + ownedProcesses.set(child, owned); + owned.termination = Promise.reject(new Error("Cannot terminate an unowned provider tree")); + } + // Cache failures as well as successes: retrying taskkill against an old PID is unsafe. + return owned.termination ??= (async () => { + native.connection.dispose(); + if (child.pid === undefined) return; // spawn failed; no OS process was created + if (owned.groupId !== undefined) { + owned.control!.end("stop"); + await waitForTermination(() => hasExited(child) && !groupExists(owned.groupId!)); + } else { + // A reaped Windows launcher no longer proves ownership of its PID/tree. + if (hasExited(child)) throw new Error("Provider exited before tree termination; tree state is unknown"); + await terminateWindowsTree(child.pid); + await waitForTermination(() => hasExited(child)); + } + })(); +} + +function hasExited(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode !== null || child.signalCode !== null; +} + +function groupExists(groupId: number): boolean { + try { + process.kill(-groupId, 0); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +async function waitForTermination(stopped: () => boolean): Promise { + const deadline = Date.now() + terminationTimeoutMs; + while (!stopped()) { + if (Date.now() >= deadline) throw new Error("Provider tree termination timed out; tree state is unknown"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +function terminateWindowsTree(pid: number): Promise { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], {windowsHide: true, stdio: "ignore"}); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + killer.kill(); + reject(new Error("Provider tree termination timed out; tree state is unknown")); + }, terminationTimeoutMs); + killer.once("error", error => {clearTimeout(timer); reject(error);}); + killer.once("exit", code => { + clearTimeout(timer); + // Launcher exit alone cannot turn a failed tree kill into success. + if (code === 0) resolve(); + else reject(new Error("Provider tree termination failed; tree state is unknown")); + }); + }); +} diff --git a/src/CodexProcessHelpers.ts b/src/CodexProcessHelpers.ts new file mode 100644 index 000000000..2f9673a77 --- /dev/null +++ b/src/CodexProcessHelpers.ts @@ -0,0 +1,88 @@ +import {createRequire} from "node:module"; + +const prefix = "--internal-owned-process-"; +type HelperRole = "supervisor" | "guardian"; + +// Keep this function self-contained: Node/Bun script runtimes execute this fixed +// built-in function with -e; compiled Bun dispatches to the same function below. +// No source code is accepted from arguments or the environment. +function helperMain(require: NodeRequire, role: string, args: string[], compiled: boolean): void { + const {spawn} = require("node:child_process") as typeof import("node:child_process"); + const {fstatSync, closeSync, read} = require("node:fs") as typeof import("node:fs"); + const checks = {pipe(fd: number) { + const stat = fstatSync(fd); + if (!stat.isSocket() && !stat.isFIFO()) throw new Error("Missing private process-helper pipe"); + }}; + if (process.platform === "win32") throw new Error("POSIX process helper required"); + checks.pipe(3); + if (role === "guardian") { + checks.pipe(0); + const group = Number(args[0]); + if (args.length !== 1 || !Number.isSafeInteger(group) || group <= 1 || process.ppid !== group) { + throw new Error("Invalid process-helper parent"); + } + // Our live membership pins this group ID even after the leader exits. + for (const fd of [0, 3]) { + // One byte, EOF, or an error all mean stop. Raw reads also work for + // Bun's inherited pipes, which need not be supported by net.Socket. + read(fd, Buffer.alloc(1), 0, 1, null, () => process.kill(-group, "SIGKILL")); + } + process.stdout.write("ready"); + return; + } + if (role !== "supervisor" || !args[0]) throw new Error("Invalid process-helper invocation"); + // detached spawn must have established a group named after this live PID. + // Refuse to install a guardian when invoked in a caller's shared group. + process.kill(-process.pid, 0); + let provider: import("node:child_process").ChildProcess | undefined; + let pendingSignal: NodeJS.Signals | undefined; + for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { + process.on(signal, () => { + if (provider) provider.kill(signal); + else pendingSignal = signal; + }); + } + const guardianArgs = compiled + ? ["--internal-owned-process-guardian", String(process.pid)] + : ["-e", `(${helperMain.toString()})(require, "guardian", process.argv.slice(1), false)`, String(process.pid)]; + const guardian = spawn(process.execPath, guardianArgs, {stdio: [3, "pipe", "inherit", "pipe"], windowsHide: true}); + closeSync(3); + guardian.on("error", error => {console.error(error); process.exit(1);}); + guardian.on("exit", () => process.exit(1)); + guardian.stdout!.once("data", () => { + provider = spawn(args[0]!, args.slice(1), {stdio: "inherit", windowsHide: true}); + provider.on("error", error => {console.error(error); process.exit(1);}); + provider.on("exit", (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else process.exit(code ?? 1); + }); + if (pendingSignal) provider.kill(pendingSignal); + }); +} + +function isCompiledBun(): boolean { + // Bun.main is inside the embedded filesystem only for compiled executables. + const main = (globalThis as typeof globalThis & {Bun?: {main: string}}).Bun?.main; + return main?.startsWith("/$bunfs/") === true || main?.startsWith("B:/~BUN/") === true; +} + +export function ownedProcessHelperArgs(role: HelperRole, args: string[]): string[] { + return isCompiledBun() ? [`${prefix}${role}`, ...args] + : ["-e", `(${helperMain.toString()})(require, ${JSON.stringify(role)}, process.argv.slice(1), false)`, ...args]; +} + +/** Must run before importing the ACP application or reading its configuration. */ +export async function runInternalProcessHelper(): Promise { + const flag = process.argv[2]; + if (!flag?.startsWith(prefix)) return; + try { + helperMain(createRequire(import.meta.url), flag.slice(prefix.length), process.argv.slice(3), true); + // Never fall through into the ACP application while the helper is alive. + await new Promise(() => {}); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/src/SessionIndexService.ts b/src/SessionIndexService.ts index 2b27db1c8..40ed867e6 100644 --- a/src/SessionIndexService.ts +++ b/src/SessionIndexService.ts @@ -57,6 +57,8 @@ export interface SessionIndexHost { closeSession(sessionId: string): Promise; beginSessionCloseFence(sessionId: string): void; endSessionCloseFence(sessionId: string): void; + /** Reserves the session against native queue writes and cancellation in flight. */ + runSessionMutation?(sessionId: string, operation: () => Promise): Promise; } type SessionPage = {sessions: acp.SessionInfo[], nextCursor: string | null}; @@ -189,9 +191,12 @@ export class SessionIndexService { /** `_session/rename`. */ async rename({sessionId, title}: SessionRenameRequest): Promise> { this.require(SESSION_RENAME_METHOD); - await this.host.ensureAppServer(); - await this.titles.rename(sessionId, title); - return {}; + const rename = async () => { + await this.host.ensureAppServer(); + await this.titles.rename(sessionId, title); + return {}; + }; + return this.host.runSessionMutation ? this.host.runSessionMutation(sessionId, rename) : rename(); } /** @@ -202,6 +207,11 @@ export class SessionIndexService { */ async setArchived({sessionId}: SessionArchiveRequest, archived: boolean): Promise> { this.require(archived ? SESSION_ARCHIVE_METHOD : SESSION_UNARCHIVE_METHOD); + const mutate = () => this.setArchivedDuringOperation(sessionId, archived); + return this.host.runSessionMutation ? this.host.runSessionMutation(sessionId, mutate) : mutate(); + } + + private async setArchivedDuringOperation(sessionId: string, archived: boolean): Promise> { logger.log(archived ? "Archiving session..." : "Unarchiving session...", {sessionId}); await this.host.ensureAppServer(); // Read before the fence, which counts as open. diff --git a/src/SessionQueue.ts b/src/SessionQueue.ts new file mode 100644 index 000000000..722ef530f --- /dev/null +++ b/src/SessionQueue.ts @@ -0,0 +1,200 @@ +import {RequestError} from "@agentclientprotocol/sdk"; +import {z} from "zod"; +import type {Turn} from "./app-server/v2/Turn"; + +export const SESSION_QUEUE_METHOD = "_session/queue"; +export const SESSION_QUEUE_ACTIONS = ["list", "add", "update", "delete", "reorder", "start"] as const; +export type SessionQueueAction = typeof SESSION_QUEUE_ACTIONS[number]; + +// A conservative tested floor, NOT a claim about the first native release. +// Source: codex-rs/app-server-protocol/src/protocol/v2/thread.rs:926-1027; +// experimental registrations: protocol/common.rs:630-664. Not generated types. +export const SESSION_QUEUE_MIN_NATIVE_VERSION = "0.160.1"; + +const identifier = z.string().refine(value => value.trim().length > 0 && !/[\u0000-\u001f\u007f]/u.test(value)); +const byteOffset = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER); +const textElement = z.strictObject({ + byteRange: z.strictObject({start: byteOffset, end: byteOffset}), + placeholder: z.string().nullable().optional(), +}); +const detail = z.enum(["auto", "low", "high", "original"]).nullable().optional(); + +// Preserve native UserInput, including snake_case text_elements and omitted +// serde-default fields. No ACP text/image conversion, defaults, or dropped keys. +// Source: protocol/v2/turn.rs:382-474. New variants require an explicit review. +const nativeInput = z.union([ + z.strictObject({type: z.literal("text"), text: z.string(), text_elements: z.array(textElement).optional()}), + z.strictObject({type: z.literal("image"), url: z.string(), detail}), + z.strictObject({type: z.literal("image"), fileId: z.string(), detail}), + z.strictObject({type: z.literal("localImage"), path: z.string(), detail}), + z.strictObject({type: z.literal("audio"), url: z.string()}), + z.strictObject({type: z.literal("localAudio"), path: z.string()}), + z.strictObject({type: z.literal("skill"), name: z.string(), path: z.string()}), + z.strictObject({type: z.literal("mention"), name: z.string(), path: z.string()}), +]); +export type NativeQueueInput = z.infer; + +const session = {sessionId: identifier}; +const requestSchema = z.discriminatedUnion("action", [ + z.strictObject({ + ...session, action: z.literal("list"), cursor: z.string().nullable().optional(), + limit: z.number().int().min(0).max(0xffff_ffff).nullable().optional(), + }), + z.strictObject({ + ...session, action: z.literal("add"), input: z.array(nativeInput), clientUserMessageId: identifier, + }), + z.strictObject({ + ...session, action: z.literal("update"), queuedSubmissionId: identifier, input: z.array(nativeInput), + }), + z.strictObject({...session, action: z.literal("delete"), queuedSubmissionId: identifier}), + z.strictObject({...session, action: z.literal("reorder"), queuedSubmissionIds: z.array(identifier)}), + z.strictObject({...session, action: z.literal("start"), queuedSubmissionId: identifier.nullable().optional()}), +]); +export type SessionQueueRequest = z.infer; + +export function parseSessionQueueRequest(value: unknown): SessionQueueRequest { + const parsed = requestSchema.safeParse(value); + if (!parsed.success) { + // Do not echo prompt content, attachment URLs, or unknown injected fields. + throw RequestError.invalidParams(undefined, "Invalid session queue request"); + } + return parsed.data; +} + +export type NativeQueuedSubmission = { + id: string; + input: NativeQueueInput[]; + clientUserMessageId: string; + [key: string]: unknown; +}; +export type NativeQueueListResponse = {data: NativeQueuedSubmission[]; nextCursor: string | null; [key: string]: unknown}; +export type NativeQueueWriteResponse = {queuedSubmission: NativeQueuedSubmission; [key: string]: unknown}; +export type NativeQueueDeleteResponse = {deleted: boolean; [key: string]: unknown}; +export type NativeQueueReorderResponse = Record; +export type NativeQueueStartResponse = {turn: Turn; [key: string]: unknown}; + +type NativeParams = + Omit, "sessionId" | "action"> & {threadId: string}; + +/** No caller-selectable RPC method or free-form native params escape hatch. */ +export interface SessionQueueNative { + list(params: NativeParams<"list">): Promise; + add(params: NativeParams<"add">): Promise; + update(params: NativeParams<"update">): Promise; + delete(params: NativeParams<"delete">): Promise; + reorder(params: NativeParams<"reorder">): Promise; + /** This starts real generation. The owning server must attach turn handlers first. */ + start(params: NativeParams<"start">): Promise; +} + +export type SessionQueueSupport = { + readonly nativeVersion: string | null; + readonly actions: readonly SessionQueueAction[]; +}; + +function eligibleNativeVersion(version: string | null | undefined): boolean { + // Only an actual normalized stable native version. An npm package version, + // missing version, or unverified prerelease is not support evidence. + const match = version?.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u); + if (!match) return false; + const [major, minor, patch] = match.slice(1).map(Number); + if (![major, minor, patch].every(value => Number.isSafeInteger(value))) return false; + return major! > 0 || minor! > 160 || (minor === 160 && patch! >= 1); +} + +/** + * The integrator supplies per-action POSITIVE evidence from the running native + * process (e.g. its actual experimental schema, or a side-effect-free parameter + * validation probe). A list-only probe proves only list. Never call a valid + * add/update/delete/reorder/start to probe, and never treat an arbitrary -32602, + * auth/store/network error, or package version as proof of method support. + * Cache only for that native process generation; re-probe on restart/override. + */ +export async function probeSessionQueueSupport( + nativeVersion: string | null | undefined, + probeAction: (action: SessionQueueAction) => Promise, +): Promise { + const actions: SessionQueueAction[] = []; + if (eligibleNativeVersion(nativeVersion)) { + for (const action of SESSION_QUEUE_ACTIONS) { + try { + if (await probeAction(action) === true) actions.push(action); + } catch { + // Absence of affirmative evidence leaves this action unadvertised. + } + } + } + return Object.freeze({nativeVersion: nativeVersion ?? null, actions: Object.freeze(actions)}); +} + +export function sessionQueueCapability(support: SessionQueueSupport) { + if (!eligibleNativeVersion(support.nativeVersion)) return undefined; + const actions = SESSION_QUEUE_ACTIONS.filter(action => support.actions.includes(action)); + if (actions.length === 0) return undefined; + return {version: 1 as const, method: SESSION_QUEUE_METHOD, actions, + ...(actions.includes("add") ? {changedNotification: "_session/queue/changed", turnNotification: "_session/queue/turn"} : {}), + }; +} + +export type SessionQueueResponse = + | {status: "unsupported"} + | {status: "ok"; result: NativeQueueListResponse | NativeQueueWriteResponse | + NativeQueueDeleteResponse | NativeQueueReorderResponse | NativeQueueStartResponse}; + +/** + * Must run INSIDE the server's lifecycle reservation for this exact session and + * native process: serialize with prompt, rewind, close, delete, provider change, + * and queue operations until the native promise settles. A timeout/transport + * loss does not imply no mutation; the caller must reconcile, never auto-retry. + * This module has no transport, process launching, locks, or event ownership. + */ +export async function runSessionQueue( + value: unknown, + dependencies: {sessionId: string; support: SessionQueueSupport; native: SessionQueueNative}, +): Promise { + const request = parseSessionQueueRequest(value); + if (request.sessionId !== dependencies.sessionId) { + throw RequestError.invalidParams(undefined, "Queue request does not target the bound session"); + } + const capability = sessionQueueCapability(dependencies.support); + if (!capability?.actions.includes(request.action)) return {status: "unsupported"}; + const threadId = dependencies.sessionId; + const native = dependencies.native; + try { + switch (request.action) { + case "list": + return {status: "ok", result: await native.list({ + threadId, + ...("cursor" in request ? {cursor: request.cursor} : {}), + ...("limit" in request ? {limit: request.limit} : {}), + })}; + case "add": + return {status: "ok", result: await native.add({threadId, input: request.input, clientUserMessageId: request.clientUserMessageId})}; + case "update": + return {status: "ok", result: await native.update({threadId, queuedSubmissionId: request.queuedSubmissionId, input: request.input})}; + case "delete": + return {status: "ok", result: await native.delete({threadId, queuedSubmissionId: request.queuedSubmissionId})}; + case "reorder": + return {status: "ok", result: await native.reorder({threadId, queuedSubmissionIds: request.queuedSubmissionIds})}; + case "start": + return {status: "ok", result: await native.start({ + threadId, + ...("queuedSubmissionId" in request ? {queuedSubmissionId: request.queuedSubmissionId} : {}), + })}; + } + } catch (error) { + // Native -32600 is its definitive pre-mutation invalid-request response. + // Translate to ACP's error type so its router preserves the validation + // failure rather than wrapping vscode-jsonrpc ResponseError as -32603. + if (typeof error === "object" && error !== null && "code" in error + && (error.code === -32600 || error.code === -32602)) { + throw RequestError.invalidParams(undefined, "Native queue rejected this request; refresh the queue and retry with current IDs"); + } + // A definitive JSON-RPC method-not-found is safe to report as unsupported. + // No other error is converted to success/unsupported and nothing retries. + if (typeof error === "object" && error !== null && "code" in error && error.code === -32601) { + return {status: "unsupported"}; + } + throw error; + } +} diff --git a/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts b/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts new file mode 100644 index 000000000..2b6b7c316 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-client-generation.test.ts @@ -0,0 +1,157 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture} from "../acp-test-utils"; +import {CodexSubagentSubscriptions, type Subscription} from "../../subagents/CodexSubagentSubscriptions"; +import type {ApprovalHandler, ElicitationHandler, CodexAppServerClient} from "../../CodexAppServerClient"; +import type {ServerNotification} from "../../app-server"; +import type {Turn} from "../../app-server/v2"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function owner(): Subscription { + return { + rootSessionId: "root", supportsSubagents: true, + dispatch: vi.fn(), enqueueInteraction: vi.fn(), + approvalHandler: { + handleCommandExecution: vi.fn().mockResolvedValue({decision: "accept"}), + handleFileChange: vi.fn().mockResolvedValue({decision: "accept"}), + handlePermissionsRequest: vi.fn().mockResolvedValue({permissions: {network: {enabled: true}}, scope: "turn", strictAutoReview: false}), + }, + elicitationHandler: { + handleElicitation: vi.fn().mockResolvedValue({action: "accept", content: null, _meta: null}), + handleUserInput: vi.fn().mockResolvedValue({answers: {answer: {answers: ["yes"]}}}), + }, + waitForRootNotifications: vi.fn().mockResolvedValue(undefined), + waitForChildSession: vi.fn().mockImplementation(async id => id), + }; +} +function setup() { + const events = new Map void>(); + const approvals = new Map(); + const elicitations = new Map(); + const subscriptions = new CodexSubagentSubscriptions({ + onServerNotification: (id: string, callback: (event: ServerNotification) => void) => events.set(id, callback), + onApprovalRequest: (id: string, handler: ApprovalHandler) => approvals.set(id, handler), + onElicitationRequest: (id: string, handler: ElicitationHandler) => elicitations.set(id, handler), + clearThreadHandlers: (id: string) => { events.delete(id); approvals.delete(id); elicitations.delete(id); }, + } as unknown as CodexAppServerClient); + const subscription = owner(); + subscription.captureChild = () => subscription; + subscriptions.subscribe(subscription); + const emit = (threadId: string, method: string, params: object) => + events.get(threadId)?.({method, params: {threadId, ...params}} as ServerNotification); + const spawn = (tool = "spawnAgent") => emit("root", "item/completed", { + turnId: "parent", item: {type: "collabAgentToolCall", tool, receiverThreadIds: ["child"]}, + }); + const reuse = () => { + emit("child", "turn/completed", {turn: turn("old", "completed")}); + spawn("sendInput"); + emit("child", "turn/started", {turn: turn("new")}); + }; + spawn(); + emit("child", "turn/started", {turn: turn("old")}); + return {events, approvals, elicitations, subscriptions, subscription, emit, reuse}; +} +const interactions = [ + ["handleCommandExecution", {decision: "cancel"}], + ["handleFileChange", {decision: "cancel"}], + ["handlePermissionsRequest", {permissions: {}, scope: "turn", strictAutoReview: false}], + ["handleElicitation", {action: "cancel", content: null, _meta: null}], + ["handleUserInput", {answers: {}}], +] as const; +function request(h: ReturnType, method: typeof interactions[number][0], turnId: string | null) { + const handlers = {...h.approvals.get("child")!, ...h.elicitations.get("child")!}; + // This unit boundary only consumes thread/turn IDs; downstream handlers are mocks. + return (handlers[method] as (params: {threadId: string; turnId: string | null}) => Promise)({threadId: "child", turnId}); +} + +describe("queue subscription generation boundaries", () => { + it.each(interactions)("rejects stale child turn IDs for %s and preserves the new turn", async (method, rejected) => { + const h = setup(); + h.reuse(); + expect(await request(h, method, "old")).toEqual(rejected); + expect(await request(h, method, "new")).not.toEqual(rejected); + }); + + it.each(interactions)("rejects %s captured before a child generation replacement", async (method, rejected) => { + const h = setup(); + const gate = deferred(); + vi.mocked(h.subscription.waitForRootNotifications).mockReturnValueOnce(gate.promise); + const pending = request(h, method, "old"); + h.reuse(); + gate.resolve(); + expect(await pending).toEqual(rejected); + }); + + it("rechecks generation after waiting for child materialization", async () => { + const h = setup(); + const entered = deferred(); + const gate = deferred(); + vi.mocked(h.subscription.waitForChildSession).mockImplementationOnce(() => { entered.resolve(); return gate.promise; }); + const pending = request(h, "handleCommandExecution", "old"); + await entered.promise; + h.reuse(); + gate.resolve("child"); + expect(await pending).toEqual({decision: "cancel"}); + }); + + it("does not let old starts or completions reopen or end a reused child", async () => { + const h = setup(); + const oldCallback = h.events.get("child")!; + h.reuse(); + h.emit("child", "turn/started", {turn: turn("old")}); + h.emit("child", "turn/completed", {turn: turn("old", "completed")}); + oldCallback({method: "turn/completed", params: {threadId: "child", turn: turn("old", "completed")}}); + expect(await request(h, "handleCommandExecution", "new")).toEqual({decision: "accept"}); + h.emit("child", "turn/completed", {turn: turn("new", "completed")}); + h.emit("child", "turn/started", {turn: turn("new")}); + expect(await request(h, "handleCommandExecution", "new")).toEqual({decision: "cancel"}); + }); + + it("rejects uncorrelated elicitation when its child turn changes during the barrier", async () => { + const h = setup(); + const gate = deferred(); + vi.mocked(h.subscription.waitForRootNotifications).mockReturnValueOnce(gate.promise); + const pending = request(h, "handleElicitation", null); + h.reuse(); + gate.resolve(); + expect(await pending).toMatchObject({action: "cancel"}); + }); + + it("observes root lifecycle synchronously during a prompt and fences retired callbacks", async () => { + const f = createCodexMockTestFixture(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + const registrations = vi.spyOn(native, "onServerNotification"); + const old = owner(); + const gate = deferred(); + const observed = vi.fn(); + vi.mocked(old.dispatch).mockImplementation(() => gate.promise); + const attach = (subscription: Subscription, watch: typeof observed) => client.subscribeToQueueEvents( + "root", subscription.dispatch, subscription.approvalHandler, subscription.elicitationHandler, + true, subscription.enqueueInteraction, subscription.waitForChildSession, true, undefined, watch, + ); + await attach(old, observed); + await client.subscribeToSessionEvents("root", old.dispatch, old.approvalHandler, old.elicitationHandler, + true, old.enqueueInteraction, old.waitForChildSession); + const callback = registrations.mock.calls[0]![1]; + const started: ServerNotification = {method: "turn/started", params: {threadId: "root", turn: turn("first")}}; + callback(started); + expect(observed).toHaveBeenCalledExactlyOnceWith(started); + const fresh = owner(); + const freshObserved = vi.fn(); + await attach(fresh, freshObserved); + callback(started); + expect(observed).toHaveBeenCalledTimes(1); + expect(freshObserved).not.toHaveBeenCalled(); + gate.resolve(); + await client.waitForSessionNotifications("root"); + expect(fresh.dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts b/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts new file mode 100644 index 000000000..b69bdc2bb --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-lifecycle.test.ts @@ -0,0 +1,127 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {SessionState} from "../../CodexAcpServer"; +import type {Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { let resolve!: (value: T) => void; const promise = new Promise(r => {resolve = r;}); return {promise, resolve}; } +async function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(), client = f.getCodexAcpClient(), native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}), + update: vi.fn().mockResolvedValue({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}), + delete: vi.fn().mockResolvedValue({deleted: true}), reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {status: {type: "idle"}}} as never); + const request = (action: string, extra = {}) => agent.manageSessionQueue({sessionId, action, ...extra}); + await request("list"); + const emit = async (method: string, params: object) => { + f.sendServerNotification({method, params: {threadId: sessionId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + return {f, agent, client, native, state, queue, request, emit}; +} + +describe("native queue ownership", () => { + it("keeps session-scoped updates observable before and after autonomous turns", async () => { + const {state, emit} = await setup(); + await emit("thread/name/updated", {threadName: "before queue"}); + expect(state.sessionTitle).toBe("before queue"); + await emit("turn/started", {turn: turn("queued")}); + await emit("turn/completed", {turn: turn("queued", "completed")}); + await emit("thread/name/updated", {threadName: "after queue"}); + expect(state.sessionTitle).toBe("after queue"); + }); + it("attaches before idle add, publishes output and terminal events after the RPC returned", async () => { + const {f, state, queue, request, emit, agent} = await setup(); + queue.add.mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("queued")}); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + await request("add", {input: [{type: "text", text: "hello"}], clientUserMessageId: "client"}); + expect(state.currentTurnId).toBe("queued"); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toMatchObject({data: expect.stringContaining("queued turn")}); + await emit("item/agentMessage/delta", {turnId: "queued", itemId: "answer", delta: "queue answer"}); + await emit("turn/completed", {turn: turn("queued", "completed")}); + expect(state.currentTurnId).toBeNull(); + const events = f.getAcpConnectionEvents([]); + expect(JSON.stringify(events)).toContain("queue answer"); + expect(events.filter(e => e.args[0] === "_session/queue/turn").map(e => (e.args[1] as any).turn.status)).toEqual(["inProgress", "completed"]); + }); + + it("hands automatic successors off without old prompt cleanup clearing their turn", async () => { + const {f, agent, native, client, state, emit, request} = await setup(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementation(async () => { + await emit("turn/started", {turn: turn("normal")}); return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockReturnValue(completed.promise); + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}); + await vi.waitFor(() => expect(state.currentTurnId).toBe("normal")); + await request("add", {input: [], clientUserMessageId: "client"}); + await emit("turn/completed", {turn: turn("normal", "completed")}); + await emit("turn/started", {turn: turn("successor")}); + completed.resolve({threadId: sessionId, turn: turn("normal", "completed")}); + await prompt; + expect(state.currentTurnId).toBe("successor"); + await emit("item/agentMessage/delta", {turnId: "successor", itemId: "answer", delta: "still alive"}); + expect(JSON.stringify(f.getAcpConnectionEvents([]))).toContain("still alive"); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + await agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "successor"}); + await emit("turn/completed", {turn: turn("successor", "interrupted")}); + }); + + it("routes queue approval through a fresh non-aborted context", async () => { + const {f, emit} = await setup(); + await emit("turn/started", {turn: turn("q1")}); + await emit("turn/completed", {turn: turn("q1", "interrupted")}); + await emit("turn/started", {turn: turn("q2")}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const response = await f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId: "q2", itemId: "cmd", approvalId: null, + command: "echo queue", cwd: "/test/cwd", reason: null, availableDecisions: ["accept", "decline"], + }); + expect(response).toEqual({decision: "accept"}); + await emit("turn/completed", {turn: turn("q2", "completed")}); + }); + + it("serializes edits and does not retry uncertain mutations", async () => { + const {agent, request, queue} = await setup(); + const gate = deferred(); queue.update.mockReturnValueOnce(gate.promise); + const updating = request("update", {queuedSubmissionId: "q", input: []}); + await vi.waitFor(() => expect(queue.update).toHaveBeenCalledOnce()); + await expect(request("reorder", {queuedSubmissionIds: ["q"]})).rejects.toThrow(); + await expect(agent.closeSession({sessionId})).rejects.toThrow(); + gate.resolve({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}); await updating; + queue.add.mockRejectedValueOnce(new Error("connection lost after commit")); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow("connection lost"); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow(); + expect(queue.add).toHaveBeenCalledOnce(); + }); + + it("manages persistent entries while unloaded without resuming or executing them", async () => { + const {agent, client, native, request, queue} = await setup(); + await agent.closeSession({sessionId}); + vi.mocked(native.threadRead).mockResolvedValue({thread: {status: {type: "notLoaded"}}} as never); + const resume = vi.spyOn(client, "resumeSession"); + await request("add", {input: [], clientUserMessageId: "client"}); + await request("update", {queuedSubmissionId: "q", input: []}); + await request("reorder", {queuedSubmissionIds: ["q"]}); + await request("delete", {queuedSubmissionId: "q"}); + expect(queue.add).toHaveBeenCalledOnce(); expect(resume).not.toHaveBeenCalled(); + await expect(request("start")).rejects.toThrow(); + expect(queue.start).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts b/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts new file mode 100644 index 000000000..c2eb1167f --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-maintainer.test.ts @@ -0,0 +1,240 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; +import type {AcpClientConnection} from "../../ACPSessionConnection"; +import type {ThreadItem, ThreadReadResponse, Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +async function setup(attach = true) { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + const connection = f.getAcpConnection(); + // Model the real ACP cancellation boundary, absent from the shared mock. + (agent as unknown as {connection: AcpClientConnection}).connection = new Proxy(connection, { + get(target, property) { + if (property !== "request") return Reflect.get(target, property); + return (...args: Parameters) => { + if (args[2]?.cancellationSignal?.aborted) return Promise.reject(new Error("Permission context aborted")); + return Reflect.get(target, property)(...args); + }; + }, + }); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + const boundaries = agent as unknown as { + sessions: Map; + installSessionState(state: SessionState): void; + }; + boundaries.sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {id: sessionId, status: {type: "idle"}}} as ThreadReadResponse); + const send = (method: string, params: object = {}, threadId = sessionId) => + f.sendServerNotification({method, params: {threadId, ...params}}); + const emit = async (method: string, params: object = {}, threadId = sessionId) => { + send(method, params, threadId); + await client.waitForSessionNotifications(sessionId); + }; + if (attach) await agent.manageSessionQueue({sessionId, action: "list"}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const spawn = async (parent: string, parentTurn: string, child: string) => { + await emit("item/completed", {turnId: parentTurn, completedAtMs: 0, item: { + type: "collabAgentToolCall", id: `spawn-${child}`, tool: "spawnAgent", status: "completed", + senderThreadId: parent, receiverThreadIds: [child], prompt: "child task", + model: null, reasoningEffort: null, agentsStates: {[child]: {status: "running", message: null}}, + } satisfies ThreadItem}, parent); + await emit("turn/started", {turn: turn(`turn-${child}`)}, child); + }; + const approval = (child: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: child, turnId: `turn-${child}`, itemId: `cmd-${child}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: "echo test", cwd: "/test/cwd", availableDecisions: ["accept", "decline"], + }); + return {f, agent, client, native, state, boundaries, queue, send, emit, spawn, approval}; +} + +describe("independent maintainer queue regressions", () => { + it("retries cancellation while an autonomous turn is not registered as interruptible yet", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("queued")}); + const interrupt = vi.spyOn(h.client, "turnInterrupt") + .mockRejectedValueOnce(new Error("no active turn to interrupt")) + .mockResolvedValue(); + try { + await h.agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledTimes(2); + } finally { + await h.emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("preserves cancellation while the first queue subscription is being installed", async () => { + const h = await setup(false); + const entered = deferred(); + const release = deferred(); + const subscribe = h.client.subscribeToQueueEvents.bind(h.client); + vi.spyOn(h.client, "subscribeToQueueEvents").mockImplementationOnce(async (...args) => { + await subscribe(...args); + entered.resolve(); + await release.promise; + }); + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + const add = h.agent.manageSessionQueue({sessionId, action: "add", input: [], clientUserMessageId: "client"}); + await entered.promise; + await h.agent.cancel({sessionId}); + release.resolve(); + await add; + try { + await h.emit("turn/started", {turn: turn("queued")}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "queued"}); + } finally { + await h.emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("does not replay buffered notifications from the old subscription into a replacement owner", async () => { + const h = await setup(); + const entered = deferred(); + const release = deferred(); + const connection = h.f.getAcpConnection(); + const notify = connection.notify.bind(connection); + let block = true; + const original = (h.agent as unknown as {connection: AcpClientConnection}).connection; + (h.agent as unknown as {connection: AcpClientConnection}).connection = new Proxy(original, { + get(target, property) { + if (property !== "notify") return Reflect.get(target, property); + return async (...args: Parameters) => { + if (block) { block = false; entered.resolve(); await release.promise; } + return notify(...args); + }; + }, + }); + h.send("thread/queue/changed"); + await entered.promise; + h.send("turn/started", {turn: turn("old-subscription-turn")}); + const fresh = createTestSessionState(); + const metadata: Awaited> = [ + sessionId, {currentModelId: fresh.currentModelId, availableModels: []}, fresh.agentMode.toSessionModeState(false), + ]; + vi.spyOn(h.agent, "getOrCreateSession").mockImplementationOnce(async () => { + h.boundaries.installSessionState(fresh); + return metadata; + }); + try { + await h.agent.closeSession({sessionId}); + await h.agent.resumeSession({sessionId, cwd: "/test/cwd", mcpServers: []}); + } finally { + release.resolve(); + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(fresh.currentTurnId).toBeNull(); + } finally { + await h.emit("turn/completed", {turn: turn("old-subscription-turn", "interrupted")}); + } + }); + + it("keeps hidden grandchildren alive when they are discovered after root completion", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("queued")}); + await h.spawn(sessionId, "queued", "child"); + await h.emit("turn/completed", {turn: turn("queued", "completed")}); + await checkpoint(); + await h.spawn("child", "turn-child", "grandchild"); + expect(await h.approval("grandchild")).toEqual({decision: "accept"}); + await h.emit("turn/completed", {turn: turn("turn-child", "completed")}, "child"); + await checkpoint(); + try { + expect(await h.approval("grandchild")).toEqual({decision: "accept"}); + } finally { + await h.emit("turn/completed", {turn: turn("turn-grandchild", "completed")}, "grandchild"); + } + }); + + it("interrupts early autonomous execution when a cold load fails during history replay", async () => { + const h = await setup(false); + h.boundaries.sessions.clear(); + type HistoryBoundary = { + getOrCreateSessionWithHistory: (...args: unknown[]) => Promise; + streamThreadHistory: (...args: unknown[]) => Promise; + }; + const history = h.agent as unknown as HistoryBoundary; + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + vi.spyOn(history, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + h.send("turn/started", {turn: turn("early")}); + h.boundaries.installSessionState(h.state); + return {sessionId, modelState: {currentModelId: h.state.currentModelId, availableModels: []}, + modeState: h.state.agentMode.toSessionModeState(false), thread: {id: sessionId}, + history: (async function* () {})()}; + }); + vi.spyOn(history, "streamThreadHistory").mockRejectedValueOnce(new Error("history read failed")); + await expect(h.agent.loadSession({sessionId, cwd: "/test/cwd", mcpServers: []})).rejects.toThrow("history read failed"); + await h.client.waitForSessionNotifications(sessionId); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "early"}); + }); + + it("rejects a late old-turn approval after a child thread is reused", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("first")}); + await h.spawn(sessionId, "first", "reused"); + await h.emit("turn/completed", {turn: turn("turn-reused", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("first", "completed")}); + await checkpoint(); + await h.emit("turn/started", {turn: turn("second")}); + await h.emit("item/completed", {turnId: "second", completedAtMs: 0, item: { + type: "collabAgentToolCall", id: "reuse", tool: "sendInput", status: "completed", + senderThreadId: sessionId, receiverThreadIds: ["reused"], prompt: "follow-up", + model: null, reasoningEffort: null, agentsStates: {reused: {status: "running", message: null}}, + } satisfies ThreadItem}); + await h.emit("turn/started", {turn: turn("turn-reused-new")}, "reused"); + try { + // approval() deliberately sends the previous turn-reused id. + expect(await h.approval("reused")).toEqual({decision: "cancel"}); + } finally { + await h.emit("turn/completed", {turn: turn("turn-reused-new", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("second", "completed")}); + } + }); + + it("does not dispatch an add cancelled while unloaded-thread preflight is pending", async () => { + const h = await setup(false); + h.boundaries.sessions.clear(); + const entered = deferred(); + const release = deferred(); + vi.mocked(h.native.threadRead).mockImplementationOnce(async () => { + entered.resolve(); + await release.promise; + return {thread: {id: sessionId, status: {type: "notLoaded"}}} as ThreadReadResponse; + }); + const abort = new AbortController(); + const add = h.agent.manageSessionQueue({sessionId, action: "add", input: [], clientUserMessageId: "client"}, abort.signal); + const result = add.then(value => ({value}), error => ({error})); + await entered.promise; + abort.abort(); + release.resolve(); + expect(await result).toHaveProperty("error"); + expect(h.queue.add).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-resume.test.ts b/src/__tests__/CodexACPAgent/session-queue-resume.test.ts new file mode 100644 index 000000000..d294cc9ee --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-resume.test.ts @@ -0,0 +1,241 @@ +import {describe, expect, it, vi} from "vitest"; +import type {LoadSessionRequest} from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {CodexAcpServer, SessionState} from "../../CodexAcpServer"; +import {ACPSessionConnection} from "../../ACPSessionConnection"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import {TitleGenerator} from "../../TitleGenerator"; +import type {Thread, ThreadItem, Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const params = {sessionId, cwd: "/test/cwd", mcpServers: []}; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} + +type Metadata = Awaited>; +type HistoryMetadata = { + sessionId: string; + modelState: Metadata[1]; + modeState: Metadata[2]; + thread: Thread; + history: AsyncIterable; +}; +// Only metadata/history boundaries are replaced. Installation and queue routing stay real. +type OpenBoundaries = { + sessions: Map; + installSessionState(state: SessionState): void; + getOrCreateSessionWithHistory(request: LoadSessionRequest): Promise; + streamThreadHistory(sessionId: string, thread: Thread, history: AsyncIterable): Promise; +}; + +function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const boundaries = agent as unknown as OpenBoundaries; + const state = createTestSessionState(); + const metadata: Metadata = [sessionId, + {currentModelId: state.currentModelId, availableModels: []}, + state.agentMode.toSessionModeState(false), + ]; + const historyMetadata: HistoryMetadata = { + sessionId, modelState: metadata[1], modeState: metadata[2], + // The history boundary below is stubbed; no production code consumes this thread. + thread: {id: sessionId} as Thread, + history: (async function* () {})(), + }; + const send = (method: string, values: object = {}) => + f.sendServerNotification({method, params: {threadId: sessionId, ...values}}); + const emit = async (method: string, values: object = {}) => { + send(method, values); + await client.waitForSessionNotifications(sessionId); + }; + const events = () => f.getAcpConnectionEvents([]); + const text = () => events().filter(event => event.method === "sessionUpdate" + && event.args[0].update.sessionUpdate === "agent_message_chunk") + .map(event => event.args[0].update.content.text); + const approval = (id: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId: id, itemId: `cmd-${id}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${id}`, cwd: params.cwd, availableDecisions: ["accept", "decline"], + }); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const early = (id: string) => { + send("turn/started", {turn: turn(id)}); + send("item/agentMessage/delta", {turnId: id, itemId: `answer-${id}`, delta: `live ${id}`}); + let settled = false; + const response = approval(id).then(value => { settled = true; return value; }); + return {response, settled: () => settled}; + }; + const stubResume = (install: () => Promise) => vi.spyOn(agent, "getOrCreateSession") + .mockImplementation(async () => { await install(); return metadata; }); + const stubLoad = (install: () => Promise) => vi.spyOn(boundaries, "getOrCreateSessionWithHistory") + .mockImplementation(async () => { await install(); return historyMetadata; }); + return {f, agent, client, native, boundaries, state, send, emit, events, text, approval, early, stubResume, stubLoad}; +} + +// Advance runnable promise continuations, without wall-clock sleeps or awaiting the blocked queue. +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +describe("native queue resume and history buffering", () => { + it("buffers native start, output and approval before resume installs SessionState", async () => { + const h = setup(); + const entered = deferred(); + const install = deferred(); + h.stubResume(async () => { + entered.resolve(); + await install.promise; + h.boundaries.installSessionState(h.state); + }); + const resumed = h.agent.resumeSession(params); + await entered.promise; + const pending = h.early("resume"); + try { + await checkpoint(); + expect(h.boundaries.sessions.has(sessionId)).toBe(false); + expect(pending.settled()).toBe(false); + expect(h.events()).toEqual([]); + } finally { + install.resolve(); + await resumed; + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "accept"}); + expect(h.state.currentTurnId).toBe("resume"); + expect(h.text()).toEqual(["live resume"]); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("resume", "completed")}); + } + }); + + it.each([false, true])("keeps live events behind history replay and previous title settlement (reload=%s)", async reload => { + const h = setup(); + const entered = deferred(); + const install = deferred(); + const replayEntered = deferred(); + const replay = deferred(); + const titleEntered = deferred(); + const title = deferred(); + if (reload) { + const previous = createTestSessionState(); + previous.titleGen = new TitleGenerator(h.native, sessionId, params.cwd, () => "unknown"); + vi.spyOn(previous.titleGen, "waitForIdle").mockImplementation(async () => { + titleEntered.resolve(); + await title.promise; + }); + // A reload must replace the already-ready owner from the preceding open. + h.boundaries.installSessionState(previous); + await checkpoint(); + } + h.stubLoad(async () => { + entered.resolve(); + await install.promise; + h.boundaries.installSessionState(h.state); + }); + vi.spyOn(h.boundaries, "streamThreadHistory").mockImplementation(async () => { + replayEntered.resolve(); + await replay.promise; + await new ACPSessionConnection(h.f.getAcpConnection(), sessionId).update({ + sessionUpdate: "agent_message_chunk", content: {type: "text", text: "history replay"}, + }); + }); + const loaded = h.agent.loadSession(params); + await entered.promise; + const pending = h.early("load"); + try { + await checkpoint(); + expect(h.boundaries.sessions.get(sessionId)).not.toBe(h.state); + expect(h.events()).toEqual([]); + expect(pending.settled()).toBe(false); + install.resolve(); + await replayEntered.promise; + await checkpoint(); + expect(h.boundaries.sessions.get(sessionId)).toBe(h.state); + expect(h.events()).toEqual([]); + expect(pending.settled()).toBe(false); + replay.resolve(); + if (reload) { + await titleEntered.promise; + await checkpoint(); + expect(h.text()).toEqual(["history replay"]); + expect(pending.settled()).toBe(false); + } + } finally { + install.resolve(); + replay.resolve(); + title.resolve(); + await loaded; + } + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "accept"}); + expect(h.text()).toEqual(["history replay", "live load"]); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("load", "completed")}); + } + }); + + it.each(["resume metadata", "load metadata", "load history"] as const)( + "releases buffered waits after failed %s and replaces failed routing on retry", + async stage => { + const h = setup(); + const entered = deferred(); + const fail = deferred(); + const failure = new Error(`failed ${stage}`); + const reject = async () => { entered.resolve(); await fail.promise; throw failure; }; + if (stage === "resume metadata") h.stubResume(reject); + else if (stage === "load metadata") h.stubLoad(reject); + else { + h.stubLoad(async () => { h.boundaries.installSessionState(h.state); }); + vi.spyOn(h.boundaries, "streamThreadHistory").mockImplementation(reject); + } + const opening = stage === "resume metadata" ? h.agent.resumeSession(params) : h.agent.loadSession(params); + // Observe rejection immediately so a cleanup failure cannot become an unhandled rejection. + const result = opening.then(() => null, error => error); + await entered.promise; + const pending = h.early("failed"); + try { + await checkpoint(); + expect(pending.settled()).toBe(false); + expect(h.events()).toEqual([]); + } finally { + fail.resolve(); + } + expect(await result).toBe(failure); + await h.client.waitForSessionNotifications(sessionId); + expect(await pending.response).toEqual({decision: "cancel"}); + expect(h.boundaries.sessions.has(sessionId)).toBe(false); + await h.emit("turn/started", {turn: turn("orphan")}); + await h.emit("item/agentMessage/delta", {turnId: "orphan", itemId: "orphan", delta: "orphan output"}); + expect(await h.approval("orphan")).toEqual({decision: "cancel"}); + expect(h.events()).toEqual([]); + + const retryState = createTestSessionState(); + h.stubResume(async () => { h.boundaries.installSessionState(retryState); }); + await h.agent.resumeSession(params); + const retry = h.early("retry"); + try { + await h.client.waitForSessionNotifications(sessionId); + expect(await retry.response).toEqual({decision: "accept"}); + expect(h.text()).toEqual(["live retry"]); + expect(retryState.currentTurnId).toBe("retry"); + expect(h.events().filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("retry", "completed")}); + } + }, + ); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-review.test.ts b/src/__tests__/CodexACPAgent/session-queue-review.test.ts new file mode 100644 index 000000000..8d65b38d6 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-review.test.ts @@ -0,0 +1,254 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import type {SessionState} from "../../CodexAcpServer"; +import {OPENAI_PROVIDER_ID} from "../../CodexAcpClient"; +import type {ThreadReadResponse, Turn, TurnCompletedNotification} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} + +async function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + // Prompt admission only consumes the status from this metadata read. + vi.spyOn(native, "threadRead").mockResolvedValue({ + thread: {id: sessionId, status: {type: "idle"}}, + } as ThreadReadResponse); + const request = (action: string, extra: Record = {}) => + agent.manageSessionQueue({sessionId, action, ...extra}); + await request("list"); + const emit = async (method: string, params: object = {}) => { + f.sendServerNotification({method, params: {threadId: sessionId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + const notifications = (method: string) => f.getAcpConnectionEvents([]) + .filter(event => event.method === "notify" && event.args[0] === method) + .map(event => event.args[1]); + const approval = (turnId: string) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: sessionId, turnId, itemId: `cmd-${turnId}`, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${turnId}`, cwd: "/test/cwd", reason: null, + availableDecisions: ["accept", "decline"], + }); + return {f, agent, client, native, state, queue, request, emit, notifications, approval}; +} + +describe("native queue review regressions", () => { + it("invalidates the provider on an unanswered durable queue write and refuses a late retry", async () => { + const {agent, queue, request} = await setup(); + vi.useFakeTimers(); + try { + const pending = deferred>>(); + queue.add.mockReturnValueOnce(pending.promise); + const result = request("add", {input: [], clientUserMessageId: "client"}); + const failed = expect(result).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(30_000); + await failed; + pending.resolve({queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}); + await Promise.resolve(); + await expect(request("add", {input: [], clientUserMessageId: "client"})).rejects.toThrow(); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toThrow(); + expect(queue.add).toHaveBeenCalledOnce(); + await expect(agent.closeSession({sessionId})).resolves.toEqual({}); + } finally { vi.useRealTimers(); } + }); + it.each(["set", "disable"] as const)("rejects provider %s before changing configuration during a queued turn", async action => { + const {agent, client, emit} = await setup(); + client.setProvider({ + providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://original.example/v1", + }); + const before = agent.listProviders({}); + await emit("turn/started", {turn: turn("queued")}); + try { + const changing = action === "set" + ? agent.setProvider({ + providerId: OPENAI_PROVIDER_ID, apiType: "openai", baseUrl: "https://replacement.example/v1", + }) + : agent.disableProvider({providerId: OPENAI_PROVIDER_ID}); + await expect(changing).rejects.toMatchObject({code: -32600}); + expect(agent.listProviders({})).toEqual(before); + } finally { + await emit("turn/completed", {turn: turn("queued", "completed")}); + } + }); + + it.each(["local command", "failed native start"] as const)( + "owns queued work after a prompt exits through %s without turn/completed", + async exit => { + const {f, agent, native, request, emit, notifications, client, state} = await setup(); + const start = vi.spyOn(native, "turnStart"); + if (exit === "local command") { + // Missing rename arguments only produce a local usage message. + await expect(agent.prompt({sessionId, prompt: [{type: "text", text: "/rename"}]})) + .resolves.toMatchObject({stopReason: "end_turn"}); + expect(start).not.toHaveBeenCalled(); + } else { + start.mockRejectedValueOnce(new Error("native start rejected")); + await expect(agent.prompt({sessionId, prompt: [{type: "text", text: "hello"}]})) + .rejects.toThrow("native start rejected"); + expect(start).toHaveBeenCalledOnce(); + } + + await request("add", {input: [], clientUserMessageId: "client"}); + await emit("turn/started", {turn: turn("queued")}); + await emit("item/agentMessage/delta", {turnId: "queued", itemId: "answer", delta: "queued output"}); + try { + expect(notifications("_session/queue/turn")).toEqual([{sessionId, turn: turn("queued")}]); + expect(state.currentTurnId).toBe("queued"); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate" + && event.args[0].update.content?.text === "queued output")).toHaveLength(1); + await expect(agent.prompt({sessionId, prompt: []})).rejects.toMatchObject({ + data: expect.stringContaining("queued turn"), + }); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + await agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "queued"}); + } finally { + await emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + expect(notifications("_session/queue/turn")).toEqual([ + {sessionId, turn: turn("queued")}, + {sessionId, turn: turn("queued", "interrupted")}, + ]); + }, + ); + + it.each(["add", "start"] as const)("remembers cancel while queue %s is awaiting turn/started", async action => { + const {agent, client, queue, request, emit, state} = await setup(); + const dispatched = deferred(); + const release = deferred(); + const interrupt = vi.spyOn(client, "turnInterrupt").mockResolvedValue(); + if (action === "add") { + queue.add.mockImplementationOnce(async () => { + dispatched.resolve(); + await release.promise; + await emit("turn/started", {turn: turn("queued")}); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + } else { + queue.start.mockImplementationOnce(async () => { + dispatched.resolve(); + await release.promise; + await emit("turn/started", {turn: turn("queued")}); + return {turn: turn("queued")}; + }); + } + const pending = request(action, action === "add" ? {input: [], clientUserMessageId: "client"} : {}); + await dispatched.promise; + try { + expect(state.currentTurnId).toBeNull(); + await agent.cancel({sessionId}); + expect(interrupt).not.toHaveBeenCalled(); + } finally { + release.resolve(); + await pending; + } + try { + expect(interrupt).toHaveBeenCalledExactlyOnceWith({threadId: sessionId, turnId: "queued"}); + } finally { + await emit("turn/completed", {turn: turn("queued", "interrupted")}); + } + }); + + it("rejects an approval delayed across completion and successor start without rejecting the successor", async () => { + const {f, client, emit, approval} = await setup(); + await emit("turn/started", {turn: turn("old")}); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const waiting = deferred(); + const release = deferred(); + const drain = client.waitForSessionNotifications.bind(client); + // Pause only the interactive request's notification barrier. Normal event delivery remains live. + const barrier = vi.spyOn(client, "waitForSessionNotifications").mockImplementationOnce(async id => { + waiting.resolve(); + await release.promise; + await drain(id); + }); + const stale = approval("old"); + await waiting.promise; + try { + await emit("turn/completed", {turn: turn("old", "completed")}); + await emit("turn/started", {turn: turn("new")}); + } finally { + release.resolve(); + barrier.mockRestore(); + } + try { + expect(await stale).toEqual({decision: "cancel"}); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toEqual([]); + expect(await approval("new")).toEqual({decision: "accept"}); + expect(f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toHaveLength(1); + } finally { + await emit("turn/completed", {turn: turn("new", "completed")}); + } + }); + + it("publishes each queue invalidation exactly once during an ordinary active prompt", async () => { + const {agent, native, request, queue, emit, notifications} = await setup(); + const awaitingCompletion = deferred(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("normal")}); + return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockImplementationOnce(() => { + awaitingCompletion.resolve(); + return completed.promise; + }); + const prompt = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}); + await Promise.race([ + awaitingCompletion.promise, + prompt.then(() => { throw new Error("Prompt completed before the native turn was awaited"); }), + ]); + queue.add.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + queue.update.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {queuedSubmission: {id: "q", clientUserMessageId: "client", input: []}}; + }); + queue.reorder.mockImplementationOnce(async () => { + await emit("thread/queue/changed"); + return {}; + }); + try { + await request("add", {input: [], clientUserMessageId: "client"}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}]); + await request("update", {queuedSubmissionId: "q", input: []}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}, {sessionId}]); + await request("reorder", {queuedSubmissionIds: ["q"]}); + expect(notifications("_session/queue/changed")).toEqual([{sessionId}, {sessionId}, {sessionId}]); + expect(notifications("_session/queue/turn")).toEqual([]); + } finally { + const terminal = {threadId: sessionId, turn: turn("normal", "completed")}; + await emit("turn/completed", terminal); + completed.resolve(terminal); + await prompt; + } + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts b/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts new file mode 100644 index 000000000..e143abc47 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-server-final-review.test.ts @@ -0,0 +1,82 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import type {SessionState} from "../../CodexAcpServer"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import type {Turn} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn: Turn = {id: "early", status: "inProgress", items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null}; +const request = {sessionId, cwd: "/test/cwd", mcpServers: []}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +function setup() { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const boundary = agent as unknown as { + installSessionState(state: SessionState): void; + getOrCreateSessionWithHistory: (...args: unknown[]) => Promise; + streamThreadHistory: (...args: unknown[]) => Promise; + beginSessionOpen(id: string): number; + cleanupStaleSessionOpen(id: string, generation: number): Promise; + }; + const state = createTestSessionState(); + const start = () => f.sendServerNotification({method: "turn/started", params: {threadId: sessionId, turn}}); + const complete = () => f.sendServerNotification({method: "turn/completed", params: {threadId: sessionId, turn: {...turn, status: "interrupted"}}}); + return {f, agent, client, boundary, state, start, complete}; +} + +describe("maintainer final server failure paths", () => { + it("interrupts a raw native turn immediately while cold history replay is blocked", async () => { + const h = setup(); + const replayEntered = deferred(); + const release = deferred(); + vi.spyOn(h.boundary, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + h.start(); + h.boundary.installSessionState(h.state); + return {sessionId, modelState: {currentModelId: h.state.currentModelId, availableModels: []}, + modeState: h.state.agentMode.toSessionModeState(false), thread: {id: sessionId}, + history: (async function* () {})()}; + }); + vi.spyOn(h.boundary, "streamThreadHistory").mockImplementationOnce(async () => { + replayEntered.resolve(); + await release.promise; + }); + const interrupt = vi.spyOn(h.client, "turnInterrupt").mockResolvedValue(); + const load = h.agent.loadSession(request); + await replayEntered.promise; + try { + await h.agent.cancel({sessionId}); + expect(interrupt).toHaveBeenCalledWith({threadId: sessionId, turnId: "early"}); + } finally { + release.resolve(); + await load; + await h.client.waitForSessionNotifications(sessionId); + h.complete(); + await h.client.waitForSessionNotifications(sessionId); + } + }); + + it("interrupts before metadata-failure helper releases the native subscription", async () => { + const h = setup(); + const calls: string[] = []; + vi.spyOn(h.client, "turnInterrupt").mockImplementation(async () => { calls.push("interrupt"); }); + const close = h.client.closeSession.bind(h.client); + vi.spyOn(h.client, "closeSession").mockImplementation(async id => { calls.push("unsubscribe"); await close(id); }); + vi.spyOn(h.boundary, "getOrCreateSessionWithHistory").mockImplementationOnce(async () => { + const generation = h.boundary.beginSessionOpen(sessionId); + h.start(); + await h.boundary.cleanupStaleSessionOpen(sessionId, generation); + throw new Error("metadata failed after subscription"); + }); + await expect(h.agent.loadSession(request)).rejects.toThrow("metadata failed after subscription"); + await h.client.waitForSessionNotifications(sessionId); + expect(calls[0]).toBe("interrupt"); + expect(calls).toContain("unsubscribe"); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts b/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts new file mode 100644 index 000000000..f9259096b --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-subagents.test.ts @@ -0,0 +1,207 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import {ACPSessionConnection, type AcpClientConnection} from "../../ACPSessionConnection"; +import type {SessionState} from "../../CodexAcpServer"; +import {SESSION_QUEUE_ACTIONS, type SessionQueueNative} from "../../SessionQueue"; +import {CodexSubagentEventRouter} from "../../subagents/CodexSubagentEventRouter"; +import type {ThreadItem, ThreadReadResponse, Turn, TurnCompletedNotification} from "../../app-server/v2"; + +const sessionId = "session-id"; +const turn = (id: string, status: Turn["status"] = "inProgress"): Turn => ({ + id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null, +}); +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(r => { resolve = r; }); + return {promise, resolve}; +} +const checkpoint = () => new Promise(resolve => setImmediate(resolve)); + +async function setup(supportsSubagents = true) { + const f = createCodexMockTestFixture(); + const agent = f.getCodexAcpAgent(); + const client = f.getCodexAcpClient(); + const native = f.getCodexAppServerClient(); + // The shared fixture ignores request cancellation. Match the real ACP boundary + // so a retired child's aborted context cannot accidentally accept a permission. + const connection = f.getAcpConnection(); + const abortAware = new Proxy(connection, { + get(target, property) { + if (property !== "request") return Reflect.get(target, property); + return (...args: Parameters) => { + if (args[2]?.cancellationSignal?.aborted) return Promise.reject(new Error("Permission context aborted")); + return Reflect.get(target, property)(...args); + }; + }, + }); + (agent as unknown as {connection: AcpClientConnection}).connection = abortAware; + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {_meta: {jetbrains: {air: { + version: 1, capabilities: supportsSubagents ? ["nativeSubagentSessions"] : [], + }}}}, + }); + client.queueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; + const state = createTestSessionState(); + state.subagents = new CodexSubagentEventRouter( + sessionId, supportsSubagents, new ACPSessionConnection(f.getAcpConnection(), sessionId), + id => state.toolCallReports.releaseOpen(id), + ); + (agent as unknown as {sessions: Map}).sessions.set(sessionId, state); + const submission = {id: "q", clientUserMessageId: "client", input: []}; + const queue = { + list: vi.fn().mockResolvedValue({data: [], nextCursor: null}), + add: vi.fn().mockResolvedValue({queuedSubmission: submission}), + update: vi.fn().mockResolvedValue({queuedSubmission: submission}), + delete: vi.fn().mockResolvedValue({deleted: true}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn: turn("queued")}), + } satisfies SessionQueueNative; + vi.spyOn(native, "queueNative").mockReturnValue(queue); + vi.spyOn(native, "threadRead").mockResolvedValue({thread: {id: sessionId, status: {type: "idle"}}} as ThreadReadResponse); + await agent.manageSessionQueue({sessionId, action: "list"}); + f.clearAcpConnectionDump(); + f.setPermissionResponse({outcome: {outcome: "selected", optionId: "allow_once"}}); + const emit = async (method: string, params: object, threadId = sessionId) => { + f.sendServerNotification({method, params: {threadId, ...params}}); + await client.waitForSessionNotifications(sessionId); + }; + const spawn = async (parentTurn: string, childId: string) => { + const item: ThreadItem = { + type: "collabAgentToolCall", id: `spawn-${childId}`, tool: "spawnAgent", status: "completed", + senderThreadId: sessionId, receiverThreadIds: [childId], prompt: "Child task", + model: null, reasoningEffort: null, agentsStates: {[childId]: {status: "running", message: null}}, + }; + await emit("item/completed", {turnId: parentTurn, completedAtMs: 0, item}); + await emit("item/started", { + turnId: parentTurn, startedAtMs: 0, + item: {type: "subAgentActivity", id: `activity-${childId}`, kind: "started", + agentThreadId: childId, agentPath: `/root/${childId}`} satisfies ThreadItem, + }); + await emit("turn/started", {turn: turn(`turn-${childId}`)}, childId); + }; + const approval = (childId: string, itemId: string, turnId = `turn-${childId}`) => f.sendServerRequest("item/commandExecution/requestApproval", { + threadId: childId, turnId, itemId, approvalId: null, + kind: "command", startedAtMs: 0, environmentId: null, + command: `echo ${itemId}`, cwd: "/test/cwd", availableDecisions: ["accept", "decline"], + }); + const updates = () => f.getAcpConnectionEvents([]).filter(event => event.method === "sessionUpdate").map(event => event.args[0]); + const childTerminals = (id: string) => updates().filter(event => event.update.sessionUpdate === "subagent_state_update" + && event.update.subagentSessionId === id).map(event => event.update.state); + const startPrompt = async () => { + const entered = deferred(); + const completed = deferred(); + vi.spyOn(native, "turnStart").mockImplementationOnce(async () => { + await emit("turn/started", {turn: turn("normal")}); + return {turn: turn("normal")}; + }); + vi.spyOn(native, "awaitTurnCompleted").mockImplementationOnce(() => { + entered.resolve(); + return completed.promise; + }); + let settled = false; + const result = agent.prompt({sessionId, prompt: [{type: "text", text: "normal"}]}).then( + response => { settled = true; return {response}; }, + error => { settled = true; return {error}; }, + ); + await Promise.race([entered.promise, result.then(() => { throw new Error("Prompt ended before native completion wait"); })]); + return {completed, result, settled: () => settled}; + }; + return {f, agent, client, state, emit, spawn, approval, updates, childTerminals, startPrompt}; +} + +describe("native queue child ownership", () => { + it("routes a reused child thread to the queued turn that sends its new input", async () => { + const h = await setup(); + await h.emit("turn/started", {turn: turn("first")}); + await h.spawn("first", "reused"); + await h.emit("turn/completed", {turn: turn("turn-reused", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("first", "completed")}); + await checkpoint(); + await h.emit("turn/started", {turn: turn("second")}); + await h.emit("item/completed", {turnId: "second", completedAtMs: 0, item: { + type: "collabAgentToolCall", id: "reuse", tool: "sendInput", status: "completed", + senderThreadId: sessionId, receiverThreadIds: ["reused"], prompt: "Follow-up", + model: null, reasoningEffort: null, agentsStates: {reused: {status: "running", message: null}}, + } satisfies ThreadItem}); + await h.emit("item/started", {turnId: "second", startedAtMs: 0, item: { + type: "subAgentActivity", id: "reuse-activity", kind: "started", + agentThreadId: "reused", agentPath: "/root/reused", + } satisfies ThreadItem}); + await h.emit("turn/started", {turn: turn("turn-reused-2")}, "reused"); + try { + expect(await h.approval("reused", "new-input", "turn-reused-2")).toEqual({decision: "accept"}); + await h.emit("item/agentMessage/delta", { + turnId: "turn-reused-2", itemId: "reused-answer", delta: "follow-up output", + }, "reused"); + expect(h.updates().filter(event => event.update.content?.text === "follow-up output")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("turn-reused-2", "completed")}, "reused"); + await h.emit("turn/completed", {turn: turn("second", "interrupted")}); + } + }); + + it.each([ + {root: "queued", visible: true}, {root: "queued", visible: false}, + {root: "normal", visible: true}, {root: "normal", visible: false}, + ] as const)("keeps $root child approvals alive after root completion and queue handoff (visible=$visible)", async ({root, visible}) => { + const h = await setup(visible); + const prompt = root === "normal" ? await h.startPrompt() : undefined; + if (root === "queued") await h.emit("turn/started", {turn: turn(root)}); + await h.spawn(root, "child-a"); + const terminal = {threadId: sessionId, turn: turn(root, "completed")}; + await h.emit("turn/completed", terminal); + let successorStarted = false; + try { + expect(await h.approval("child-a", "after-parent")).toEqual({decision: "accept"}); + await h.emit("turn/started", {turn: turn("successor")}); + successorStarted = true; + expect(await h.approval("child-a", "after-successor")).toEqual({decision: "accept"}); + const requests = h.f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission"); + expect(requests.map(event => event.args[0].sessionId)).toEqual([ + visible ? "child-a" : sessionId, visible ? "child-a" : sessionId, + ]); + expect(h.childTerminals("child-a")).toEqual([]); + await h.emit("turn/completed", {turn: turn("turn-child-a", "completed")}, "child-a"); + expect(await h.approval("child-a", "stale-child")).toEqual({decision: "cancel"}); + expect(h.f.getAcpConnectionEvents([]).filter(event => event.method === "requestPermission")).toHaveLength(2); + if (visible) expect(h.childTerminals("child-a")).toEqual(["completed"]); + } finally { + await h.emit("turn/completed", {turn: turn("turn-child-a", "completed")}, "child-a"); + if (successorStarted) await h.emit("turn/completed", {turn: turn("successor", "completed")}); + prompt?.completed.resolve(terminal); + await prompt?.result; + } + }); + + it.each(["completed", "failed"] as const)("does not let a %s ordinary prompt wait on or finalize successor children", async status => { + const h = await setup(); + const prompt = await h.startPrompt(); + const terminal = {threadId: sessionId, turn: turn("normal", status)}; + await h.emit("turn/completed", terminal); + await h.emit("turn/started", {turn: turn("successor")}); + await h.spawn("successor", "child-b"); + expect(h.updates().filter(event => event.update.sessionUpdate === "subagent_spawned" + && event.update.subagentSessionId === "child-b")).toHaveLength(1); + prompt.completed.resolve(terminal); + try { + await checkpoint(); + // B's child stays open until its own native terminal event, even if A failed. + expect(h.childTerminals("child-b")).toEqual([]); + expect(prompt.settled()).toBe(true); + expect(h.state.currentTurnId).toBe("successor"); + expect(await h.approval("child-b", "survived-cleanup")).toEqual({decision: "accept"}); + await h.emit("item/agentMessage/delta", { + turnId: "turn-child-b", itemId: "answer-b", delta: "child survived", + }, "child-b"); + expect(h.updates().filter(event => event.sessionId === "child-b" + && event.update.content?.text === "child survived")).toHaveLength(1); + } finally { + await h.emit("turn/completed", {turn: turn("turn-child-b", "completed")}, "child-b"); + await h.emit("turn/completed", {turn: turn("successor", "completed")}); + await prompt.result; + } + expect(h.childTerminals("child-b")).toEqual(["completed"]); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts b/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts new file mode 100644 index 000000000..675c77598 --- /dev/null +++ b/src/__tests__/CodexACPAgent/session-queue-upstream.test.ts @@ -0,0 +1,194 @@ +import {describe, expect, it, vi} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; +import {SESSION_QUEUE_ACTIONS} from "../../SessionQueue"; +import {createRecoveryFixture, defaultAnswer, initialize, requestsOf, type RecoveryFixture} from "../app-server-recovery/recovery-fixture"; + +const sessionId = "thread-new"; +const add = {sessionId, action: "add", input: [{type: "text", text: "queued"}], clientUserMessageId: "client-1"}; +const queued = {queuedSubmission: {id: "q-1", input: add.input, clientUserMessageId: add.clientUserMessageId}}; +const turn = (id = "queued-turn", status = "inProgress") => ({id, status, items: [], itemsView: "notLoaded", error: null, + startedAt: null, completedAt: null, durationMs: null}); +const capability = {_meta: {jetbrains: {air: {version: 1, capabilities: ["sessionIndex"]}}}}; +const writes = (f: RecoveryFixture, action: string) => f.servers.flatMap(s => requestsOf(s, `thread/queue/${action}`)) + .filter(p => (p as {threadId?: string}).threadId); + +async function setup(open = true) { + const f = createRecoveryFixture(); + f.answers.set("initialize", () => ({userAgent: "codex/0.160.1 (fixture)", codexHome: "/codex-home"})); + for (const action of SESSION_QUEUE_ACTIONS) { + f.answers.set(`thread/queue/${action}`, params => { + if (!(params as {threadId?: string}).threadId) return new ResponseError(-32600, "Invalid request: missing field `threadId`"); + if (action === "list") return {data: [], nextCursor: null}; + if (action === "start") return {turn: turn()}; + if (action === "delete") return {deleted: true}; + return action === "reorder" ? {} : queued; + }); + } + f.answers.set("thread/read", params => ({thread: { + ...(defaultAnswer("thread/resume", params) as {thread: object}).thread, + path: "/codex-home/sessions/fixture.jsonl", status: {type: open ? "idle" : "notLoaded"}, + }})); + await initialize(f, true, capability); + if (open) await f.agent.newSession({cwd: "/work", mcpServers: []}); + return f; +} + +const indexMutation = (f: RecoveryFixture, action: string) => action === "rename" + ? f.agent.sessionIndex.rename({sessionId, title: "Renamed"}) + : f.agent.sessionIndex.setArchived({sessionId}, action === "archive"); + +describe("native queue with upstream recovery and session index", () => { + it("restarts and reprobes before reading a cold queue without resuming it", async () => { + const f = await setup(false); + await f.kill(); + await expect(f.agent.manageSessionQueue({sessionId, action: "list"})).resolves.toMatchObject({status: "ok"}); + expect(f.servers).toHaveLength(2); + expect(writes(f, "list")).toHaveLength(1); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + for (const action of SESSION_QUEUE_ACTIONS) expect(requestsOf(f.current(), `thread/queue/${action}`)).toContainEqual({}); + }); + + it("fails closed for a stale loaded session, then accepts one write after explicit load", async () => { + const f = await setup(); + await f.kill(); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Load the session")}); + expect(writes(f, "add")).toEqual([]); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + await f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + await expect(f.agent.manageSessionQueue(add)).resolves.toMatchObject({status: "ok"}); + expect(writes(f, "add")).toHaveLength(1); + }); + + it("never replays a write whose ACK was lost, even when another request restarts the server", async () => { + const f = await setup(); + f.answers.set("thread/queue/add", p => (p as {threadId?: string}).threadId + ? undefined : new ResponseError(-32600, "Invalid request: missing field `threadId`")); + const pending = f.agent.manageSessionQueue(add); + const rejected = expect(pending).rejects.toBeDefined(); + await vi.waitFor(() => expect(writes(f, "add")).toHaveLength(1)); + await f.kill(); + await rejected; + await f.agent.listSessions({}); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("reconcile")}); + expect(writes(f, "add")).toHaveLength(1); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + }); + + it("rechecks support on the replacement rather than using the dead server's capability", async () => { + const f = await setup(false); + await f.kill(); + f.answers.set("initialize", () => ({userAgent: "codex/0.159.0 (fixture)", codexHome: "/codex-home"})); + await expect(f.agent.manageSessionQueue(add)).resolves.toEqual({status: "unsupported"}); + expect(writes(f, "add")).toEqual([]); + }); + + it("requires explicit load instead of auto-dispatching persisted work during lazy prompt recovery", async () => { + const f = await setup(); + await f.kill(); + f.answers.set("thread/queue/list", params => (params as {threadId?: string}).threadId + ? {data: [queued.queuedSubmission], nextCursor: null} + : new ResponseError(-32600, "Invalid request: missing field `threadId`")); + await expect(f.agent.prompt({sessionId, prompt: [{type: "text", text: "ordinary"}]})).rejects.toBeDefined(); + expect(requestsOf(f.current(), "thread/resume")).toEqual([]); + expect(requestsOf(f.current(), "turn/start")).toEqual([]); + expect(writes(f, "add")).toEqual([]); + }); + + it("owns a resumed queue before metadata completes and interrupts it during cold load", async () => { + const f = await setup(); + await f.kill(); + f.answers.set("thread/resume", (params, server) => { + server.rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn()}}); + return defaultAnswer("thread/resume", params); + }); + f.answers.set("model/list", () => undefined); + const loading = f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + loading.catch(() => undefined); + await vi.waitFor(() => expect(f.servers.length === 2 && f.current().rpc.hasPending("model/list")).toBe(true)); + await f.agent.cancel({sessionId}); + expect(requestsOf(f.current(), "turn/interrupt")).toEqual([{threadId: sessionId, turnId: "queued-turn"}]); + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("queued-turn", "interrupted")}}); + f.current().rpc.resolve("model/list", defaultAnswer("model/list", {})); + await loading; + }); + + it("discards the crashed owner's events and publishes the resumed owner's output", async () => { + const f = await setup(); + const previous = f.current(); + previous.rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn("old")}}); + await f.kill(); + await f.agent.loadSession({sessionId, cwd: "/work", mcpServers: []}); + previous.rpc.notify({method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: "old", itemId: "old", delta: "stale output"}}); + f.current().rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn("new")}}); + f.current().rpc.notify({method: "item/agentMessage/delta", params: {threadId: sessionId, turnId: "new", itemId: "new", delta: "fresh output"}}); + await vi.waitFor(() => expect(f.updates().map(x => x.update)).toContainEqual(expect.objectContaining({content: {type: "text", text: "fresh output"}}))); + expect(f.updates().map(x => x.update)).not.toContainEqual(expect.objectContaining({content: {type: "text", text: "stale output"}})); + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("new", "completed")}}); + }); + + it("unblocks a queue request waiting for a provider replacement that crashed without replaying it", async () => { + const f = await setup(); + f.answers.set("thread/resume", () => undefined); + const update = f.agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "http://127.0.0.1:1/v1"}); + const rejectedUpdate = expect(update).rejects.toBeDefined(); + await vi.waitFor(() => expect(f.servers.length === 2 && f.current().rpc.hasPending("thread/resume")).toBe(true)); + const queue = f.agent.manageSessionQueue(add); + const rejectedQueue = expect(queue).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Load the session")}); + await f.kill(); + await rejectedUpdate; + await rejectedQueue; + expect(writes(f, "add")).toEqual([]); + expect(f.servers).toHaveLength(3); + }); + + it("lets close settle a prompt that a provider update is waiting for", async () => { + const f = await setup(); + const prompt = f.agent.prompt({sessionId, prompt: [{type: "text", text: "ordinary"}]}); + prompt.catch(() => undefined); + await vi.waitFor(() => expect(f.agent.getSessionState(sessionId).currentTurnId).not.toBeNull()); + const update = f.agent.setProvider({providerId: "openai", apiType: "openai", baseUrl: "http://127.0.0.1:1/v1"}); + update.catch(() => undefined); + await f.agent.closeSession({sessionId}); + await expect(prompt).resolves.toMatchObject({stopReason: "cancelled"}); + // The queue check fails replacement closed when close wins the race; + // it must not keep close waiting on the update that awaits this prompt. + await expect(update).rejects.toMatchObject({code: -32600, data: `Session ${sessionId} is closing`}); + expect(requestsOf(f.current(), "turn/interrupt")).toHaveLength(1); + }); + + it.each(["archive", "unarchive", "rename"])("blocks direct index %s during a queue write", async action => { + const f = await setup(false); + f.answers.set("thread/queue/add", () => undefined); + const pending = f.agent.manageSessionQueue(add); + await vi.waitFor(() => expect(writes(f, "add")).toHaveLength(1)); + await expect(indexMutation(f, action)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("Queue mutation")}); + expect(requestsOf(f.current(), "thread/archive")).toEqual([]); + expect(requestsOf(f.current(), "thread/unarchive")).toEqual([]); + expect(requestsOf(f.current(), "thread/name/set")).toEqual([]); + f.current().rpc.resolve("thread/queue/add", queued); + await pending; + }); + + it.each(["archive", "unarchive", "rename"])("blocks direct index %s until cancellation settles", async action => { + const f = await setup(); + f.current().rpc.notify({method: "turn/started", params: {threadId: sessionId, turn: turn()}}); + f.answers.set("turn/interrupt", () => undefined); + const cancel = f.agent.cancel({sessionId}); + await vi.waitFor(() => expect(f.current().rpc.hasPending("turn/interrupt")).toBe(true)); + await expect(indexMutation(f, action)).rejects.toMatchObject({code: -32600, data: expect.stringContaining("cancellation")}); + f.current().rpc.resolve("turn/interrupt", {}); + await cancel; + f.current().rpc.notify({method: "turn/completed", params: {threadId: sessionId, turn: turn("queued-turn", "interrupted")}}); + }); + + it("keeps a cold archive reserved through its native ACK", async () => { + const f = await setup(false); + f.answers.set("thread/archive", () => undefined); + const archive = f.agent.sessionIndex.setArchived({sessionId}, true); + await vi.waitFor(() => expect(f.current().rpc.hasPending("thread/archive")).toBe(true)); + await expect(f.agent.manageSessionQueue(add)).rejects.toMatchObject({code: -32600, data: "Session is busy"}); + expect(writes(f, "add")).toEqual([]); + f.current().rpc.resolve("thread/archive", {}); + await archive; + }); +}); diff --git a/src/__tests__/CodexProcessTermination.failures.test.ts b/src/__tests__/CodexProcessTermination.failures.test.ts new file mode 100644 index 000000000..0196076d4 --- /dev/null +++ b/src/__tests__/CodexProcessTermination.failures.test.ts @@ -0,0 +1,152 @@ +import {EventEmitter} from "node:events"; +import {PassThrough} from "node:stream"; +import type {ChildProcessWithoutNullStreams} from "node:child_process"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection} from "../CodexJsonRpcConnection"; + +const mocks = vi.hoisted(() => ({spawn: vi.fn(), spawnSync: vi.fn(), dispose: vi.fn()})); +vi.mock("node:child_process", () => ({spawn: mocks.spawn, spawnSync: mocks.spawnSync})); +vi.mock("cross-spawn", () => ({default: mocks.spawn})); +vi.mock("vscode-jsonrpc/node", () => ({ + createMessageConnection: () => ({listen() {}, onNotification() {}, dispose: mocks.dispose}), +})); +vi.mock("../StdUtils", () => ({createJSONRPCReader() { return {}; }, createJSONRPCWriter() {}})); +vi.mock("../Logger", () => ({logger: {log: vi.fn()}})); + +function child(pid: number | undefined = 987654) { + return Object.assign(new EventEmitter(), { + pid: pid as number | undefined, exitCode: null as number | null, signalCode: null as NodeJS.Signals | null, + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), + stdio: [null, null, null, Object.assign(new PassThrough(), {unref: vi.fn()})], kill: vi.fn(), + }); +} +const platform = Object.getOwnPropertyDescriptor(process, "platform")!; +function usePlatform(value: string) { + Object.defineProperty(process, "platform", {...platform, value}); +} +afterEach(() => { + Object.defineProperty(process, "platform", platform); + vi.restoreAllMocks(); + vi.useRealTimers(); + mocks.spawn.mockReset(); + mocks.dispose.mockReset(); +}); + +describe("CodexProcessTermination failure boundaries", () => { + it.each(["linux", "win32"])("does not signal or wait for a nonexistent PID on %s", async platform => { + usePlatform(platform); + const provider = child(); + provider.pid = undefined; + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + await terminateCodexConnection(native); + await terminateCodexConnection(native); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it("rejects unowned processes without signalling a PID or shared group", async () => { + const provider = child(); + const kill = vi.spyOn(process, "kill"); + await expect(terminateCodexConnection({ + process: provider as unknown as ChildProcessWithoutNullStreams, + connection: {dispose: mocks.dispose} as any, + })).rejects.toThrow("unowned"); + expect(kill).not.toHaveBeenCalled(); + expect(provider.kill).not.toHaveBeenCalled(); + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it.each(["SIGTERM", "SIGKILL"] as const)("recognizes already signalled Windows exit (%s) without reusing its PID", async signal => { + usePlatform("win32"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const native = startCodexConnection("synthetic-provider"); + provider.signalCode = signal; + await expect(terminateCodexConnection(native)).rejects.toThrow("tree state is unknown"); + expect(mocks.spawn).toHaveBeenCalledTimes(1); + }); + + it("does not treat launcher exit as successful taskkill and never retries the old PID", async () => { + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("tree termination failed"); + provider.signalCode = "SIGKILL"; + killer.emit("exit", 1); + await failure; + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn.mock.calls[1]).toEqual([ + "taskkill", ["/PID", "987654", "/T", "/F"], {windowsHide: true, stdio: "ignore"}, + ]); + }); + + it("rejects a taskkill timeout and caches the failure", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + const provider = child(); + const killer = child(987655); + mocks.spawn.mockReturnValueOnce(provider).mockReturnValueOnce(killer); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(killer.kill).toHaveBeenCalledTimes(1); + expect(terminateCodexConnection(native)).toBe(termination); + expect(mocks.spawn).toHaveBeenCalledTimes(2); + }); + + it("does not report stopped when taskkill succeeded but no child exit was observed", async () => { + vi.useFakeTimers(); + usePlatform("win32"); + mocks.spawn.mockReturnValueOnce(child()).mockReturnValueOnce(child(987655)); + const native = startCodexConnection("synthetic-provider"); + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + mocks.spawn.mock.results[1]!.value.emit("exit", 0); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + }); + + it.each([undefined, "synthetic-provider"])("owns an isolated POSIX group for launcher %s and only probes its PGID", async command => { + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockImplementation(() => { + throw Object.assign(new Error("gone"), {code: "ESRCH"}); + }); + const native = startCodexConnection(command); + expect(mocks.spawn.mock.calls[0]![2]).toMatchObject({detached: true, stdio: ["pipe", "pipe", "pipe", "pipe"]}); + expect((provider.stdio[3] as PassThrough & {unref: ReturnType}).unref).toHaveBeenCalledOnce(); + const args = mocks.spawn.mock.calls[0]![1] as string[]; + if (command === undefined) expect(args.some(arg => /codex[/\\]bin[/\\]codex.js$/.test(arg))).toBe(true); + else expect(args).toContain(command); + provider.signalCode = "SIGKILL"; + await terminateCodexConnection(native); + expect(kill).toHaveBeenCalledExactlyOnceWith(-987654, 0); + expect(provider.kill).not.toHaveBeenCalled(); + }); + + it("rejects when a POSIX group remains after its leader exits; no destructive PID retry", async () => { + vi.useFakeTimers(); + usePlatform("linux"); + const provider = child(); + mocks.spawn.mockReturnValue(provider); + const kill = vi.spyOn(process, "kill").mockReturnValue(true); + const native = startCodexConnection("synthetic-provider"); + provider.exitCode = 0; + const termination = terminateCodexConnection(native); + const failure = expect(termination).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(5_000); + await failure; + expect(kill.mock.calls.every(([pid, signal]) => pid === -987654 && signal === 0)).toBe(true); + expect(terminateCodexConnection(native)).toBe(termination); + expect(provider.listenerCount("exit")).toBe(2); // only the permanent logging/disposal listeners + }); +}); diff --git a/src/__tests__/CodexProcessTermination.test.ts b/src/__tests__/CodexProcessTermination.test.ts new file mode 100644 index 000000000..8b818bfa5 --- /dev/null +++ b/src/__tests__/CodexProcessTermination.test.ts @@ -0,0 +1,176 @@ +import {mkdtempSync, existsSync, readFileSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {fileURLToPath} from "node:url"; +import {once} from "node:events"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {startCodexConnection, terminateCodexConnection, type CodexConnection} from "../CodexJsonRpcConnection"; + +vi.mock("../Logger", () => ({logger: {log: vi.fn(), error: vi.fn()}})); +// Exercise the default launcher branch without launching the installed Codex. +vi.mock("node:module", async importOriginal => { + const original = await importOriginal(); + return {...original, createRequire: (...args: Parameters) => { + const require = original.createRequire(...args); + const resolve = require.resolve; + require.resolve = Object.assign((id: string, options?: Parameters[1]) => + id === "@openai/codex/bin/codex.js" + ? fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)) + : resolve(id, options), {paths: resolve.paths}); + return require; + }}; +}); + +const fixture = fileURLToPath(new URL("./fixtures/CodexProcessTermination.cjs", import.meta.url)); +const fixtures: {directory: string; native: CodexConnection}[] = []; +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function until(check: () => boolean, timeout = 10_000): Promise { + const deadline = Date.now() + timeout; + while (!check()) { + if (Date.now() > deadline) throw new Error("Fixture condition timed out"); + await delay(20); + } +} + +function alive(pid: number): boolean { + try { + process.kill(pid, 0); + // Zombies have exited, though kill(0) still sees them until the OS reaps them. + if (process.platform === "linux") { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + return stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3) !== "Z"; + } + return true; + } catch (error) { + if (["ESRCH", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return false; + throw error; + } +} + +async function startFixture(defaultLauncher = false) { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(defaultLauncher ? undefined : process.execPath, + {...process.env, APP_SERVER_LOGS: ""}, defaultLauncher ? ["parent", directory] : [fixture, "parent", directory]); + fixtures.push({directory, native}); + await until(() => existsSync(path.join(directory, "child.pid")) && existsSync(path.join(directory, "heartbeat"))); + const parentPid = Number(readFileSync(path.join(directory, "parent.pid"), "utf8")); + const childPid = Number(readFileSync(path.join(directory, "child.pid"), "utf8")); + return {directory, native, parentPid, childPid}; +} + +afterEach(async () => { + for (const {directory, native} of fixtures.splice(0)) { + // Cleanup through a fixture-specific file, never by a potentially stale PID. + writeFileSync(path.join(directory, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + await delay(150); + rmSync(directory, {recursive: true, force: true}); + } +}); + +describe("CodexProcessTermination real owned processes", () => { + for (const mode of ["eof", "signal"]) { + it.skipIf(mode === "signal" && process.platform === "win32")(`preserves provider stdio and exit status through ${mode}`, async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(process.execPath, {...process.env, APP_SERVER_LOGS: ""}, + [fixture, "stdio", directory]); + fixtures.push({directory, native}); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + if (mode === "eof") native.process.stdin.end(); + else native.process.kill("SIGTERM"); + await closed; + expect(native.process.exitCode).toBe(mode === "eof" ? 17 : 29); + expect(stderr).toBe(mode === "eof" ? "fixture EOF\n" : "fixture SIGTERM\n"); + if (process.platform !== "win32") await terminateCodexConnection(native); + }, 15_000); + } + + it.skipIf(process.platform !== "win32")("does not mistake a failed Windows shim launch for a verified stopped tree", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider.exe")); + fixtures.push({directory, native}); + const [error] = await once(native.process, "error"); + expect(error.code).toBe("ENOENT"); + await until(() => native.process.exitCode !== null || native.process.signalCode !== null); + const termination = terminateCodexConnection(native); + await expect(termination).rejects.toThrow("tree state is unknown"); + expect(terminateCodexConnection(native)).toBe(termination); + }); + + it.skipIf(process.platform === "win32")("reports a provider spawn failure on stderr and exits without holding pipes open", async () => { + const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-")); + const native = startCodexConnection(path.join(directory, "missing-provider")); + fixtures.push({directory, native}); + let stderr = ""; + native.process.stderr.on("data", data => {stderr += data;}); + await once(native.process, "close"); + expect(native.process.exitCode).toBe(1); + expect(stderr).toContain("ENOENT"); + await terminateCodexConnection(native); + }, 15_000); + + it("stops the default Node launcher and its native-shaped child", async () => { + const target = await startFixture(true); + await terminateCodexConnection(target.native); + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + }, 20_000); + + it("stops both an ignoring parent and child, leaves another tree alive, and shares repeated closes", async () => { + const target = await startFixture(); + const unrelated = await startFixture(); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + if (process.platform !== "win32") { + process.kill(target.parentPid, "SIGTERM"); + process.kill(target.childPid, "SIGTERM"); + await delay(100); + expect(alive(target.parentPid)).toBe(true); + expect(alive(target.childPid)).toBe(true); + } + const first = terminateCodexConnection(target.native); + expect(terminateCodexConnection(target.native)).toBe(first); + await first; + await until(() => !alive(target.parentPid) && !alive(target.childPid)); + expect(alive(unrelated.parentPid)).toBe(true); + expect(alive(unrelated.childPid)).toBe(true); + await expect(terminateCodexConnection(target.native)).resolves.toBeUndefined(); + await terminateCodexConnection(unrelated.native); + }, 25_000); + + it.each(["exit-parent", "signal-parent"])("handles %s before close without killing a stale launcher PID", async mode => { + const target = await startFixture(); + writeFileSync(path.join(target.directory, mode), "stop"); + await until(() => target.native.process.exitCode !== null || target.native.process.signalCode !== null); + if (process.platform === "win32") { + // taskkill cannot safely reconstruct a tree once its launcher has exited. + await expect(terminateCodexConnection(target.native)).rejects.toThrow("tree state is unknown"); + } else { + if (mode === "signal-parent") { + expect(target.native.process.exitCode).toBeNull(); + expect(target.native.process.signalCode).toBe("SIGKILL"); + } else { + expect(target.native.process.exitCode).toBe(23); + } + await terminateCodexConnection(target.native); + await until(() => !alive(target.childPid)); + } + }, 20_000); + + it.skipIf(process.platform === "win32")("cleans descendants even when the supervisor is killed first", async () => { + const target = await startFixture(); + target.native.process.kill("SIGKILL"); + await until(() => target.native.process.signalCode !== null); + await terminateCodexConnection(target.native); + expect(alive(target.parentPid)).toBe(false); + expect(alive(target.childPid)).toBe(false); + }, 20_000); +}); diff --git a/src/__tests__/SessionQueue.test.ts b/src/__tests__/SessionQueue.test.ts new file mode 100644 index 000000000..4cece6d56 --- /dev/null +++ b/src/__tests__/SessionQueue.test.ts @@ -0,0 +1,202 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../app-server/v2/Turn"; +import { + SESSION_QUEUE_ACTIONS, SESSION_QUEUE_METHOD, parseSessionQueueRequest, + probeSessionQueueSupport, runSessionQueue, sessionQueueCapability, + type NativeQueueInput, type SessionQueueNative, type SessionQueueSupport, +} from "../SessionQueue"; + +const input: NativeQueueInput[] = [ + {type: "text", text: "正文", text_elements: [{byteRange: {start: 0, end: 6}, placeholder: null}]}, + {type: "text", text: " keep whitespace "}, + {type: "image", fileId: "file-id", detail: "original"}, + {type: "image", url: "data:image/png;base64,AA==", detail: null}, + {type: "localImage", path: "C:\\a.png"}, + {type: "audio", url: "data:audio/wav;base64,AA=="}, + {type: "localAudio", path: "C:\\a.wav"}, + {type: "skill", name: "skill", path: "C:\\SKILL.md"}, + {type: "mention", name: "file", path: "C:\\file.txt"}, +]; +const support: SessionQueueSupport = {nativeVersion: "0.160.1", actions: SESSION_QUEUE_ACTIONS}; +const queued = {id: "native-q", input, clientUserMessageId: "client-id", futureNativeField: {keep: true}}; +const turn: Turn = {id: "native-turn", items: [], itemsView: "notLoaded", status: "inProgress", error: null, startedAt: null, completedAt: null, durationMs: null}; + +function fixture() { + const native = { + list: vi.fn().mockResolvedValue({data: [queued], nextCursor: "opaque-next"}), + add: vi.fn().mockResolvedValue({queuedSubmission: queued}), + update: vi.fn().mockResolvedValue({queuedSubmission: queued}), + delete: vi.fn().mockResolvedValue({deleted: false}), + reorder: vi.fn().mockResolvedValue({}), + start: vi.fn().mockResolvedValue({turn}), + }; + const dependencies = {sessionId: "session", support, native}; + return {native, dependencies, run: (request: unknown) => runSessionQueue(request, dependencies)}; +} + +describe("session queue capability", () => { + it.each(["0.154.0", "0.159.3", "0.160.0", "0.160.1-preview.1", "codex 0.160.1", "2.1.1-preview", "", null, undefined])( + "does not probe or advertise unknown/old/unverified native %s", async version => { + const probe = vi.fn(async () => true); + const result = await probeSessionQueueSupport(version, probe); + expect(probe).not.toHaveBeenCalled(); + expect(result.actions).toEqual([]); + expect(sessionQueueCapability(result)).toBeUndefined(); + }, + ); + it("advertises only each action positively confirmed on the running native", async () => { + const result = await probeSessionQueueSupport("0.160.1", async action => { + if (action === "start") throw new Error("probe transport failed"); + return action === "list"; + }); + expect(sessionQueueCapability(result)).toEqual({version: 1, method: SESSION_QUEUE_METHOD, actions: ["list"]}); + expect(Object.isFrozen(result.actions)).toBe(true); + }); + it.each(["0.160.1", "0.161.0", "1.0.0"])("still requires positive evidence for %s", async version => { + const result = await probeSessionQueueSupport(version, async () => false); + expect(sessionQueueCapability(result)).toBeUndefined(); + }); + it("rejects an old native support record even if its actions were supplied", async () => { + const f = fixture(); + f.dependencies.support = {nativeVersion: "0.154.0", actions: SESSION_QUEUE_ACTIONS}; + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); +}); + +describe("session queue native boundary", () => { + it("keeps native multimodal input, omission, IDs, and unknown RESULT fields", async () => { + const f = fixture(); + const result = {queuedSubmission: queued, nativeExtra: {revision: "native-owned"}}; + f.native.add.mockResolvedValue(result); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})) + .resolves.toEqual({status: "ok", result}); + expect(f.native.add).toHaveBeenCalledExactlyOnceWith({threadId: "session", input, clientUserMessageId: "client-id"}); + expect(f.native.add.mock.calls[0]![0].input[1]).not.toHaveProperty("text_elements"); + expect(result.queuedSubmission.id).toBe("native-q"); + }); + it("preserves list pagination, nulls, and the native result object", async () => { + const f = fixture(); + const result = {data: [queued], nextCursor: "opaque-next", total: 200}; + f.native.list.mockResolvedValue(result); + const response = await f.run({sessionId: "session", action: "list", cursor: "opaque-in", limit: 0}); + expect(response.status === "ok" && response.result).toBe(result); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: "opaque-in", limit: 0}); + await f.run({sessionId: "session", action: "list", cursor: null, limit: null}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session", cursor: null, limit: null}); + await f.run({sessionId: "session", action: "list"}); + expect(f.native.list).toHaveBeenLastCalledWith({threadId: "session"}); + }); + it("updates only the named native submission without replacing its client ID", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "update", queuedSubmissionId: "q", input})) + .resolves.toEqual({status: "ok", result: {queuedSubmission: queued}}); + expect(f.native.update).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "q", input}); + }); + it("keeps delete=false and does not invent successful removal", async () => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "delete", queuedSubmissionId: "gone"})) + .resolves.toEqual({status: "ok", result: {deleted: false}}); + expect(f.native.delete).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionId: "gone"}); + }); + it("delegates native ordering and membership validation without local reordering", async () => { + const f = fixture(); + const ids = ["third", "first"]; + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ids})) + .resolves.toEqual({status: "ok", result: {}}); + expect(f.native.reorder).toHaveBeenCalledExactlyOnceWith({threadId: "session", queuedSubmissionIds: ids}); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([{}, {queuedSubmissionId: null}, {queuedSubmissionId: "q"}])("only fake-starts once with native selector %j", async selector => { + const f = fixture(); + await expect(f.run({sessionId: "session", action: "start", ...selector})) + .resolves.toEqual({status: "ok", result: {turn}}); + expect(f.native.start).toHaveBeenCalledExactlyOnceWith({threadId: "session", ...selector}); + expect(f.native.delete).not.toHaveBeenCalled(); + }); + it.each(SESSION_QUEUE_ACTIONS)("never accesses another session for %s", async action => { + const f = fixture(); + const requests = { + list: {}, add: {input, clientUserMessageId: "id"}, update: {input, queuedSubmissionId: "id"}, + delete: {queuedSubmissionId: "id"}, reorder: {queuedSubmissionIds: []}, start: {}, + }; + await expect(f.run({sessionId: "other", action, ...requests[action]})).rejects.toThrow("bound session"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it("rejects unproven start even when list was positively probed", async () => { + const f = fixture(); + f.dependencies.support = await probeSessionQueueSupport("0.160.1", async action => action === "list"); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).not.toHaveBeenCalled(); + }); + it("reports a definitive method-not-found without retry or fallback", async () => { + const f = fixture(); + f.native.start.mockRejectedValue({code: -32601, message: "Method not found"}); + await expect(f.run({sessionId: "session", action: "start"})).resolves.toEqual({status: "unsupported"}); + expect(f.native.start).toHaveBeenCalledTimes(1); + }); + it.each([ + {code: -32603, message: "storage failed"}, + new Error("response lost after native commit"), + ])("propagates failures without replaying an uncertain mutation", async error => { + const f = fixture(); + f.native.add.mockRejectedValue(error); + await expect(f.run({sessionId: "session", action: "add", input, clientUserMessageId: "client-id"})).rejects.toBe(error); + expect(f.native.add).toHaveBeenCalledTimes(1); + expect(f.native.list).not.toHaveBeenCalled(); + }); + it.each([-32600, -32602])("keeps a definitive native validation failure as ACP invalid params (%s)", async code => { + const f = fixture(); + f.native.reorder.mockRejectedValue({code, message: "queue reorder must include every queued submission exactly once"}); + await expect(f.run({sessionId: "session", action: "reorder", queuedSubmissionIds: ["stale"]})) + .rejects.toMatchObject({code: -32602, name: "RequestError"}); + expect(f.native.reorder).toHaveBeenCalledOnce(); + }); +}); + +describe("session queue input validation", () => { + it.each([ + null, [], "list", {}, + {sessionId: "session", action: "thread/delete"}, + {sessionId: "session", action: "list", threadId: "other"}, + {sessionId: "session", action: "list", method: "thread/delete"}, + {sessionId: "session", action: "list", params: {threadId: "other"}}, + {sessionId: "session", action: "list", limit: -1}, + {sessionId: "session", action: "list", limit: 1.5}, + {sessionId: "session", action: "list", limit: 0x1_0000_0000}, + {sessionId: "session", action: "list", cursor: 1}, + {sessionId: " ", action: "list"}, + {sessionId: "session\n", action: "list"}, + {sessionId: "session", action: "add", input}, + {sessionId: "session", action: "add", input, clientUserMessageId: "id", revision: 1}, + {sessionId: "session", action: "start", input}, + {sessionId: "session", action: "start", queuedSubmissionId: ""}, + {sessionId: "session", action: "reorder", queuedSubmissionIds: [null]}, + {sessionId: "session", action: "update", input, queuedSubmissionId: "q", clientUserMessageId: "replacement"}, + ])("rejects malformed/extra fields before any native call: %j", async request => { + const f = fixture(); + await expect(f.run(request)).rejects.toThrow("Invalid session queue request"); + for (const callback of Object.values(f.native)) expect(callback).not.toHaveBeenCalled(); + }); + it.each([ + {type: "text", text: 42}, + {type: "text", text: "x", text_elements: [{byteRange: {start: -1, end: 1}}]}, + {type: "text", text: "x", text_elements: [{byteRange: {start: 0, end: 1}, placeholder: 7}]}, + {type: "image", url: "data:x", fileId: "ambiguous"}, + {type: "image", url: "data:x", detail: "unlimited"}, + {type: "image", fileId: "x", threadId: "other"}, + {type: "toolOutput", text: "inject"}, + {type: "localImage", path: 42}, + ])("rejects unknown/malformed native input: %j", block => { + expect(() => parseSessionQueueRequest({sessionId: "session", action: "add", input: [block], clientUserMessageId: "id"})) + .toThrow("Invalid session queue request"); + }); + it("does not echo rejected prompt/URL contents in errors", () => { + try { + parseSessionQueueRequest({sessionId: "session", action: "add", input: [{type: "unknown", secret: "sensitive"}], clientUserMessageId: "id"}); + expect.fail("should reject"); + } catch (error) { + expect(String(error)).not.toContain("sensitive"); + } + }); +}); diff --git a/src/__tests__/fixtures/CodexProcessTermination.cjs b/src/__tests__/fixtures/CodexProcessTermination.cjs new file mode 100644 index 000000000..15d619082 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.cjs @@ -0,0 +1,48 @@ +// Only synthetic processes: no Codex configuration, credentials, or model traffic. +const {spawn} = require("node:child_process"); +const {writeFileSync, existsSync} = require("node:fs"); + +let [role, directory, mode] = process.argv.slice(2); +if (role === "app-server") { + role = "stdio"; + directory = process.env.CODEX_PROCESS_TERMINATION_FIXTURE_DIR; +} +const write = (name, value) => writeFileSync(`${directory}/${name}`, String(value)); +if (role === "stdio") { + process.stdin.resume(); + process.stdin.on("data", data => process.stdout.write(data)); + process.stdin.on("end", () => { + process.stderr.write("fixture EOF\n", () => process.exit(17)); + }); + process.on("SIGTERM", () => { + process.stderr.write("fixture SIGTERM\n", () => process.exit(29)); + }); + write("ready", process.pid); + setTimeout(() => process.exit(99), 30_000).unref(); +} else if (role === "child") { + process.on("SIGTERM", () => {}); + write("child.pid", process.pid); + // A safety lease bounds the lifetime even if a test crashes before cleanup. + const lease = setTimeout(() => process.exit(0), 30_000); + const heartbeat = setInterval(() => { + if (existsSync(`${directory}/cleanup`)) { + clearInterval(heartbeat); + clearTimeout(lease); + process.exit(0); + } + write("heartbeat", Date.now()); + }, 25); +} else { + process.on("SIGTERM", () => {}); + const childArgs = typeof Bun !== "undefined" && Bun.main.startsWith("/$bunfs/") + ? ["child", directory, mode] : [__filename, "child", directory, mode]; + const child = spawn(process.execPath, childArgs, {stdio: "inherit"}); + child.on("error", () => process.exit(1)); + write("parent.pid", process.pid); + setTimeout(() => process.exit(0), 30_000); + setInterval(() => { + if (existsSync(`${directory}/exit-parent`)) process.exit(23); + if (existsSync(`${directory}/signal-parent`)) process.kill(process.pid, "SIGKILL"); + if (existsSync(`${directory}/cleanup`)) process.exit(0); + }, 25); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.compiled.ts b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts new file mode 100644 index 000000000..ba63ede7e --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.compiled.ts @@ -0,0 +1,90 @@ +// Compile this entrypoint with `bun build --compile` and run with: +// +// Keep build output outside dist. Linux tests need an init that reaps orphans. +import assert from "node:assert/strict"; +import {mkdtempSync, readFileSync, existsSync, writeFileSync, rmSync, mkdirSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {once} from "node:events"; +import {runInternalProcessHelper} from "../../CodexProcessHelpers"; +await runInternalProcessHelper(); +const {startCodexConnection, terminateCodexConnection} = await import("../../CodexJsonRpcConnection"); + +const [node, fixture] = process.argv.slice(2); +assert(node && fixture, "Pass Node and fixture paths"); +const isBun = Boolean(process.versions["bun"]); +if (isBun) assert.notEqual(process.execPath, node); +const directory = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-compiled-")); +const env = {...process.env, PATH: directory, APP_SERVER_LOGS: ""}; +const providerArgs = (role: string, dir: string) => fixture === "native" ? [role, dir] : [fixture, role, dir]; +const until = async (check: () => boolean) => { + const deadline = Date.now() + 10_000; + while (!check()) { + assert(Date.now() < deadline, "Fixture wait timed out"); + await new Promise(resolve => setTimeout(resolve, 20)); + } +}; +try { + for (const mode of ["kill", "parent-first"]) { + const caseDir = path.join(directory, mode); + mkdirSync(caseDir); + const native = startCodexConnection(node, env, providerArgs("parent", caseDir)); + native.process.stderr.on("data", data => process.stderr.write(data)); + try { + await until(() => existsSync(path.join(caseDir, "heartbeat"))); + const pids = ["parent.pid", "child.pid"].map(name => Number(readFileSync(path.join(caseDir, name), "utf8"))); + if (mode === "parent-first") { + const exited = once(native.process, "exit"); + writeFileSync(path.join(caseDir, "exit-parent"), "exit"); + await exited; + assert.equal(native.process.exitCode, 23); + } + await terminateCodexConnection(native); + for (const pid of pids) assert.throws(() => process.kill(pid, 0), {code: "ESRCH"}); + await terminateCodexConnection(native); + console.log(`PASS compiled ${mode}: parent and child gone`); + } finally { + writeFileSync(path.join(caseDir, "cleanup"), "stop"); + await terminateCodexConnection(native).catch(() => {}); + } + } + const native = startCodexConnection(node, env, providerArgs("stdio", directory)); + let stdout = ""; + let stderr = ""; + native.process.stdout.on("data", data => {stdout += data;}); + native.process.stderr.on("data", data => {stderr += data;}); + await until(() => existsSync(path.join(directory, "ready"))); + const line = '{"method":"fixture/echo","params":{"text":"你好"}}\n'; + native.process.stdin.write(line); + await until(() => stdout === line); + const closed = once(native.process, "close"); + native.process.stdin.end(); + await closed; + assert.equal(native.process.exitCode, 17); + assert.equal(stderr, "fixture EOF\n"); + await terminateCodexConnection(native); + console.log("PASS compiled EOF: exact stdout/stderr and exit code 17"); + + const stalledDir = path.join(directory, "stalled"); + mkdirSync(stalledDir); + const stalled = startCodexConnection(node, env, providerArgs("parent", stalledDir)); + await until(() => existsSync(path.join(stalledDir, "heartbeat"))); + const group = stalled.process.pid!; + process.kill(-group, "SIGSTOP"); // Hold this live owned group so it cannot service the control pipe. + try { + await assert.rejects(terminateCodexConnection(stalled), /timed out; tree state is unknown/); + assert.equal(stalled.process.exitCode, null); + assert.equal(stalled.process.signalCode, null); + } finally { + process.kill(-group, "SIGCONT"); // The stopped group still pins its identity. + } + await until(() => { + try {process.kill(-group, 0); return false;} + catch (error) {if ((error as NodeJS.ErrnoException).code === "ESRCH") return true; throw error;} + }); + console.log("PASS compiled timeout: stopped group rejected after deadline, then reaped after resume"); + + +} finally { + rmSync(directory, {recursive: true, force: true}); +} diff --git a/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs new file mode 100644 index 000000000..a2904a998 --- /dev/null +++ b/src/__tests__/fixtures/CodexProcessTermination.entrypoint.cjs @@ -0,0 +1,43 @@ +// Run with node . Both children use +// a PATH without Node; this harness itself does not supply an interpreter to them. +const {spawn, spawnSync} = require("node:child_process"); +const {mkdtempSync, existsSync, rmSync} = require("node:fs"); +const {tmpdir} = require("node:os"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +const {once} = require("node:events"); + +(async () => { + const [acp, fixture] = process.argv.slice(2); + const dir = mkdtempSync(path.join(tmpdir(), "CodexProcessTermination-entry-")); + try { + const env = {...process.env, PATH: dir, CODEX_PATH: fixture, + APP_SERVER_LOGS: "", CODEX_CONFIG: "{}", DEFAULT_AUTH_REQUEST: "", + CODEX_PROCESS_TERMINATION_FIXTURE_DIR: dir}; + const provider = spawn(acp, [], {env, stdio: "pipe"}); + let stderr = ""; + provider.stderr.on("data", data => {stderr += data;}); + const exited = once(provider, "exit"); + const deadline = Date.now() + 10_000; + while (!existsSync(path.join(dir, "ready"))) { + assert(Date.now() < deadline, `ACP did not launch native fixture: ${stderr}`); + await new Promise(resolve => setTimeout(resolve, 20)); + } + provider.stdin.end(); + const [code] = await exited; + assert.equal(code, 0, stderr); + console.log("PASS compiled ACP entrypoint: CODEX_PATH native launch and EOF, no Node on PATH"); + for (const role of ["supervisor", "guardian", "unknown"]) { + const result = spawnSync(acp, [`--internal-owned-process-${role}`], { + env: {...env, CODEX_CONFIG: "invalid JSON", APP_SERVER_LOGS: path.join(dir, "forbidden-logs")}, + encoding: "utf8", timeout: 5000, + }); + assert.equal(result.status, 1); + assert(!existsSync(path.join(dir, "forbidden-logs"))); + assert(!result.stderr.includes("JSON")); + } + console.log("PASS compiled ACP helper rejection: no private fd, no config/log initialization"); + } finally { + rmSync(dir, {recursive: true, force: true}); + } +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/src/index.ts b/src/index.ts index 8fb26044b..dc375c1f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,26 +1,34 @@ #!/usr/bin/env node - -import * as acp from "@agentclientprotocol/sdk"; -import {z} from "zod"; -import {startCodexConnection} from "./CodexJsonRpcConnection"; -import {CodexAcpServer, type CodexProcessState} from "./CodexAcpServer"; -import {createJsonStream} from "./StdUtils"; -import {isCodexAuthRequest} from "./CodexAuthMethod"; -import {CodexAcpClient} from "./CodexAcpClient"; -import {CodexAppServerClient} from "./CodexAppServerClient"; -import packageJson from "../package.json"; -import {logger} from "./Logger"; -import {CodexAppServerSupervisor} from "./app-server-recovery/CodexAppServerSupervisor"; -import {runLoginCommand} from "./login"; -import {runCodexCli} from "./CodexCli"; -import {prepareCodexHookConfig} from "./CodexHookConfig"; -import {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} from "./CodexHookTrust"; -import { +import {runInternalProcessHelper} from "./CodexProcessHelpers"; +import type * as AcpTypes from "@agentclientprotocol/sdk"; +import type {CodexProcessState, CodexAcpServer as CodexAcpServerType} from "./CodexAcpServer"; + +await runInternalProcessHelper(); + +const {SESSION_QUEUE_METHOD, parseSessionQueueRequest} = await import("./SessionQueue"); + +const acp = await import("@agentclientprotocol/sdk"); +const {z} = await import("zod"); +const {startCodexConnection} = await import("./CodexJsonRpcConnection"); +const {CodexAcpServer} = await import("./CodexAcpServer"); +const {createJsonStream} = await import("./StdUtils"); +const {isCodexAuthRequest} = await import("./CodexAuthMethod"); +const {CodexAcpClient} = await import("./CodexAcpClient"); +const {CodexAppServerClient} = await import("./CodexAppServerClient"); +const {default: packageJson} = await import("../package.json"); +const {logger} = await import("./Logger"); +const {runLoginCommand} = await import("./login"); +const {runCodexCli} = await import("./CodexCli"); +const {prepareCodexHookConfig} = await import("./CodexHookConfig"); +const {CODEX_HOOKS_LIST_METHOD, CODEX_HOOKS_TRUST_METHOD} = await import("./CodexHookTrust"); +const { GOAL_CONTROL_METHOD, LEGACY_SET_SESSION_MODEL_METHOD, SESSION_STEERING_METHOD, -} from "./AcpExtensions"; -import {ASYNC_TASK_STOP_METHOD} from "./async-tasks/AsyncTaskExtension"; -import { +} = await import("./AcpExtensions"); +const {ASYNC_TASK_STOP_METHOD} = await import("./async-tasks/AsyncTaskExtension"); + +const {CodexAppServerSupervisor} = await import("./app-server-recovery/CodexAppServerSupervisor"); +const { SESSION_ARCHIVE_METHOD, SESSION_LIST_SUBSCRIBE_METHOD, SESSION_LIST_UNSUBSCRIBE_METHOD, @@ -29,7 +37,7 @@ import { sessionArchiveParamsParser, sessionListSubscriptionParamsParser, sessionRenameParamsParser, -} from "./SessionIndex"; +} = await import("./SessionIndex"); const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -133,7 +141,7 @@ function startAcpServer() { const acpJsonStream = createJsonStream(process.stdin, process.stdout); - function createAgent(connection: acp.AgentContext): CodexAcpServer { + function createAgent(connection: AcpTypes.AgentContext): CodexAcpServerType { const appServerClient = new CodexAppServerClient(codexProcessState.connection.connection); const codexClient = new CodexAcpClient(appServerClient, hookConfig.sessionConfig, modelProvider); return new CodexAcpServer( @@ -146,8 +154,8 @@ function startAcpServer() { ); } - let codexAcpServer: CodexAcpServer | null = null; - const getAgent = (): CodexAcpServer => { + let codexAcpServer: CodexAcpServerType | null = null; + const getAgent = (): CodexAcpServerType => { if (!codexAcpServer) { throw acp.RequestError.internalError("ACP agent is not connected"); } @@ -187,6 +195,7 @@ function startAcpServer() { .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) .onRequest(SESSION_STEERING_METHOD, sessionSteerParamsParser, (ctx) => getAgent().extMethod(SESSION_STEERING_METHOD, ctx.params)) .onRequest(ASYNC_TASK_STOP_METHOD, asyncTaskStopParamsParser, (ctx) => getAgent().extMethod(ASYNC_TASK_STOP_METHOD, ctx.params)) + .onRequest(SESSION_QUEUE_METHOD, z.unknown().transform(value => parseSessionQueueRequest(value)), (ctx) => getAgent().manageSessionQueue(ctx.params, ctx.signal)) .onRequest(CODEX_HOOKS_LIST_METHOD, hooksListParamsParser, (ctx) => getAgent().listHooks(ctx.params.cwd)) .onRequest(CODEX_HOOKS_TRUST_METHOD, hooksTrustParamsParser, (ctx) => getAgent().trustHooks(ctx.params.cwd, ctx.params.hooks)) .onRequest(GOAL_CONTROL_METHOD, goalControlParamsParser, (ctx) => getAgent().extMethod(GOAL_CONTROL_METHOD, ctx.params)) diff --git a/src/subagents/CodexSubagentSubscriptions.ts b/src/subagents/CodexSubagentSubscriptions.ts index 27998e086..9491f456f 100644 --- a/src/subagents/CodexSubagentSubscriptions.ts +++ b/src/subagents/CodexSubagentSubscriptions.ts @@ -6,20 +6,28 @@ import type { import type {ServerNotification} from "../app-server"; import {isRootAgentPath} from "./CodexAgentPath"; -type Subscription = { +export type Subscription = { rootSessionId: string; supportsSubagents: boolean; - dispatch(event: ServerNotification): void; - enqueueInteraction(event: ServerNotification): void; + dispatch(event: ServerNotification): void | Promise; + enqueueInteraction(event: ServerNotification): void | Promise; approvalHandler: ApprovalHandler; elicitationHandler: ElicitationHandler; waitForRootNotifications(): Promise; waitForChildSession(childThreadId: string): Promise; + captureChild?: ((childThreadId: string) => Subscription) | undefined; }; type SessionSubscription = { current: Subscription; - children: Set; + children: Map; +}; + +type ChildSubscription = { + turnId: string | null; + ended: boolean; + retiredTurnIds: Set; + reused: boolean; }; /** Discovers child threads and keeps their output/interaction boundary negotiated. */ @@ -35,9 +43,10 @@ export class CodexSubagentSubscriptions { return; } - const session = {current: subscription, children: new Set()}; + const session: SessionSubscription = {current: subscription, children: new Map()}; this.sessions.set(subscription.rootSessionId, session); this.client.onServerNotification(subscription.rootSessionId, (event) => { + if (this.sessions.get(subscription.rootSessionId) !== session) return; // Register synchronously: app-server may emit child output directly // after the spawning collaboration item. this.discover(session, event); @@ -47,66 +56,103 @@ export class CodexSubagentSubscriptions { } clear(rootSessionId: string): void { - for (const childSessionId of this.sessions.get(rootSessionId)?.children ?? []) { + for (const childSessionId of this.sessions.get(rootSessionId)?.children.keys() ?? []) { this.client.clearThreadHandlers(childSessionId); } this.sessions.delete(rootSessionId); } - private discover(session: SessionSubscription, event: ServerNotification): void { + private discover(session: SessionSubscription, event: ServerNotification, parent?: Subscription): void { if (event.method !== "item/started" && event.method !== "item/completed") { return; } const item = event.params.item; - const childSessionIds = item.type === "collabAgentToolCall" && item.tool === "spawnAgent" + const resumesChild = item.type === "collabAgentToolCall" + && ["resumeAgent", "sendInput", "followupTask"].includes(item.tool); + const childSessionIds = item.type === "collabAgentToolCall" && (item.tool === "spawnAgent" || resumesChild) ? item.receiverThreadIds : item.type === "subAgentActivity" && item.kind !== "interrupted" && !isRootAgentPath(item.agentPath) ? [item.agentThreadId] : []; for (const childSessionId of childSessionIds) { if (childSessionId.trim() === "") continue; + const previous = session.children.get(childSessionId); if (childSessionId === session.current.rootSessionId || childSessionId === event.params.threadId - || session.children.has(childSessionId)) { + || (previous && !(resumesChild && previous.ended))) { continue; } - session.children.add(childSessionId); + const child: ChildSubscription = { + turnId: null, ended: false, reused: previous !== undefined, + retiredTurnIds: new Set(previous?.retiredTurnIds), + }; + if (previous?.turnId) child.retiredTurnIds.add(previous.turnId); + session.children.set(childSessionId, child); + // A child keeps the turn's event/permission context even after the root + // starts another queued turn. Do not resolve it from session.current later. + const owner = parent?.captureChild?.(childSessionId) ?? parent ?? session.current.captureChild?.(childSessionId); this.client.onServerNotification(childSessionId, (childEvent) => { + if (this.sessions.get(session.current.rootSessionId) !== session + || session.children.get(childSessionId) !== child) return; const eventThreadId = (childEvent.params as {threadId?: unknown}).threadId; if (eventThreadId !== childSessionId) return; - this.discover(session, childEvent); - if (session.current.supportsSubagents) session.current.dispatch(childEvent); - else session.current.enqueueInteraction(this.rootAttributed(childEvent, session.current.rootSessionId)); + const turnId = childEvent.method === "turn/started" || childEvent.method === "turn/completed" + ? childEvent.params.turn.id : (childEvent.params as {turnId?: string}).turnId; + if (turnId && child.retiredTurnIds.has(turnId)) return; + if (childEvent.method === "turn/started") { + if (child.turnId !== turnId && child.turnId !== null) child.retiredTurnIds.add(child.turnId); + child.turnId = childEvent.params.turn.id; + child.ended = false; + } else if (turnId && child.turnId !== null && turnId !== child.turnId) return; + if (childEvent.method === "turn/completed") { + child.turnId = childEvent.params.turn.id; + child.ended = true; + child.retiredTurnIds.add(childEvent.params.turn.id); + } + this.discover(session, childEvent, owner); + const current = owner ?? session.current; + if (current.supportsSubagents) current.dispatch(childEvent); + else current.enqueueInteraction(owner ? childEvent : this.rootAttributed(childEvent, current.rootSessionId)); }); // Hidden children keep only root-attributed permission requests. - this.registerInteractiveHandlers(session, childSessionId); + this.registerInteractiveHandlers(session, childSessionId, owner, child); } } - private registerInteractiveHandlers(session: SessionSubscription, targetSessionId: string): void { + private registerInteractiveHandlers(session: SessionSubscription, targetSessionId: string, owner?: Subscription, child?: ChildSubscription): void { + const resolveSession = async (current: Subscription, turnId?: string | null): Promise => { + // Capture even uncorrelated elicitations before waiting: they must not + // cross a child turn or subscription replacement while materializing. + const requestedTurn = child?.turnId; + const isCurrent = () => this.sessions.get(current.rootSessionId) === session + && (!child || (session.children.get(targetSessionId) === child && !child.ended + && (turnId == null ? child.turnId === requestedTurn : child.turnId === turnId + || (child.turnId === null && !child.reused && !child.retiredTurnIds.has(turnId))))); + await current.waitForRootNotifications(); + if (!isCurrent()) return null; + const sessionId = await this.interactionSessionId(current, targetSessionId); + return isCurrent() ? sessionId : null; + }; this.client.onApprovalRequest(targetSessionId, { handleCommandExecution: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {decision: "cancel"}; return await current.approvalHandler.handleCommandExecution( {...params, threadId: sessionId}, ); }, handleFileChange: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {decision: "cancel"}; return await current.approvalHandler.handleFileChange( {...params, threadId: sessionId}, ); }, handlePermissionsRequest: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {permissions: {}, scope: "turn", strictAutoReview: false}; return await current.approvalHandler.handlePermissionsRequest( {...params, threadId: sessionId}, @@ -115,18 +161,16 @@ export class CodexSubagentSubscriptions { }); this.client.onElicitationRequest(targetSessionId, { handleElicitation: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {action: "cancel", content: null, _meta: null}; return await current.elicitationHandler.handleElicitation( {...params, threadId: sessionId}, ); }, handleUserInput: async (params) => { - const current = session.current; - await current.waitForRootNotifications(); - const sessionId = await this.interactionSessionId(current, targetSessionId); + const current = owner ?? session.current; + const sessionId = await resolveSession(current, params.turnId); if (sessionId === null) return {answers: {}}; return await current.elicitationHandler.handleUserInput( {...params, threadId: sessionId},