diff --git a/docs/harnesses/antigravity/antigravity-question-interaction-postmortem.md b/docs/harnesses/antigravity/antigravity-question-interaction-postmortem.md index 0d7ae63a1..15a436c69 100644 --- a/docs/harnesses/antigravity/antigravity-question-interaction-postmortem.md +++ b/docs/harnesses/antigravity/antigravity-question-interaction-postmortem.md @@ -204,10 +204,10 @@ if (isAntigravityQuestionTool(rawToolName)) { ... } ### 8.2 当前实现 -- `packages/adapters/antigravity/src/question-bridge.ts`:每个 Turn 创建独立的本地桥,仅监听 `127.0.0.1`,使用内存中的随机鉴权值,验证原生会话身份并拒绝重复或重叠请求。 +- `packages/adapters/antigravity/src/question-bridge.ts`:本地桥仅监听 `127.0.0.1`,使用内存中的随机鉴权值,验证原生会话身份并拒绝重复或重叠请求。成功的 Turn 会保留驱动与桥供下一轮复用,此时桥在 `rebind` 中换绑新的 Turn 身份并自增代际;携带旧代际的在途请求一律拒绝,不会被归到新 Turn。 - `question-hook-client.ts`:内嵌的轻量 Hook 客户端,随发布 Bundle 打包;运行时写入 Hook 目录,不需要额外安装依赖。 - Hook 目录通过 `--add-dir` 加入每次 CLI 运行。真实 `/hooks` 查询已验证附加目录可以提供 Hooks,不修改项目或全局 `hooks.json`。 -- 有 Thread ID 时,每个 Thread 固定使用 `<数据目录>/antigravity-history/hooks/`,跨 Turn 及恢复会话保留,每轮只重写其中的 Hook 文件。下一轮先等上一轮的清理(CLI 退出、桥关闭)完成再准备。清理在 CLI 收到结束信号后最多再等 5 秒进程关闭,避免占着输出不退出的子进程卡住后续轮次和关闭会话;保留的目录不会被删除。同一原生会话的后续 Turn 仍可能引用此前加入的目录:每轮删除再换新目录时,曾出现终端命令以已删除的 Hook 目录为工作目录而失败。目录中不保存鉴权值;没有 Thread ID 时退回每轮私有临时目录,Turn 结束后删除;这个目录不与其他 Turn 共用,所以下一轮无需等待上一轮清理。 +- 有 Thread ID 时,每个 Thread 固定使用 `<数据目录>/antigravity-history/hooks/`,跨 Turn 及恢复会话保留,每轮只重写其中的 Hook 文件。成功的 Turn 不关闭 CLI,下一轮直接写入同一 stdin;只有失败、取消、关闭会话或切换模型/档位/权限时才清理驱动,且下一轮会先等这次清理完成再准备。清理在 CLI 收到结束信号后最多再等 5 秒进程关闭,避免占着输出不退出的子进程卡住后续轮次和关闭会话;保留的目录不会被删除。同一原生会话的后续 Turn 仍可能引用此前加入的目录:每轮删除再换新目录时,曾出现终端命令以已删除的 Hook 目录为工作目录而失败。目录中不保存鉴权值;没有 Thread ID 时退回每轮私有临时目录,Turn 结束后删除;这个目录不与其他 Turn 共用,所以下一轮无需等待上一轮清理。 - Adapter 通过已有 `HostQuestionInteraction`、`interaction.respond`、`interaction.closed` 和 `CodexTurnProjector` 完成双向交互,Host 与 Renderer 不新增 Antigravity 专用请求分支。 - 桥接条目命名为 `codexhost.ask_question`,保存问题与响应,明确区分桥接动作和原生工具结果。 - 当前支持一批单选或文本问题,单选允许自由填写其他答案。现有 Desktop 投影没有多选字段,因此多选请求明确拒绝,不静默伪装成单选。 diff --git a/docs/proposals/external-harness-idle-unload-proposal.md b/docs/proposals/external-harness-idle-unload-proposal.md index 53e665065..d812f4dfe 100644 --- a/docs/proposals/external-harness-idle-unload-proposal.md +++ b/docs/proposals/external-harness-idle-unload-proposal.md @@ -141,7 +141,7 @@ | Cursor | 打开会话时启动 CLI 进程并驻留 | 关 stdin,超时 SIGKILL | | Grok / Hermes / Kiro | 每会话一个 ACP 进程 | 关 stdin,超时 SIGTERM 或 SIGKILL | | OpenCode | 每会话独占一个 server 进程 | 结束该 server 进程 | -| Antigravity | 无,每个 Turn 临时启动进程 | 结束进行中 Turn 的进程,flush 历史 | +| Antigravity | 成功 Turn 之间保留一个 agy 驱动(含 Language Server 与 Hook HTTP server),切换模型/档位/权限、失败、取消或关闭时释放 | 结束进行中 Turn 的进程并释放空闲驱动,flush 历史 | | DeepSeek Harness | 同一 Adapter 下共用一个 `dsh web` 进程 | 不结束共用进程 | ### 5.2 Host 恢复入口 diff --git a/packages/adapters/antigravity/src/antigravity-adapter.ts b/packages/adapters/antigravity/src/antigravity-adapter.ts index 1c3549074..c38d27682 100644 --- a/packages/adapters/antigravity/src/antigravity-adapter.ts +++ b/packages/adapters/antigravity/src/antigravity-adapter.ts @@ -92,7 +92,7 @@ import { type AntigravityCodeAction, } from "./code-action-diff.js"; import { fetchAntigravityQuota, type AntigravityQuotaSnapshot } from "./quota.js"; -import { AntigravityQuestionBridge } from "./question-bridge.js"; +import { AntigravityQuestionBridge, type BridgeOptions } from "./question-bridge.js"; import { AntigravitySubagents } from "./subagents.js"; import { nativeSubagentIdSchema, readSubagentTranscript } from "./subagent-transcript.js"; import { createAntigravityUsageStatistics } from "./usage-statistics.js"; @@ -132,10 +132,19 @@ interface ActiveTurn { /** Model passed to this CLI invocation, independent of later selections. */ model: HarnessModelRef | undefined; process: ChildProcessByStdio; - exited: Promise; questions: AntigravityQuestionBridge; subagents: AntigravitySubagents; logPath: string; + /** The native driver this Turn runs on; a successful Turn hands it to the next one. */ + transport: AntigravityTransport; + /** Raw step lines this Turn emitted, keyed by index, to recognise a replay later. */ + stepLines: Map; + /** + * Previous-Turn step lines this Turn has not yet matched as a replay, keyed by index. + * Entries are consumed as they match, so a new Turn whose own first step happens to be + * byte-identical to the previous Turn's still gets delivered once the replay is spent. + */ + replayedSteps: Map; agentItem: HostAgentMessageItem | null; agentText: string; tools: Map; @@ -168,6 +177,30 @@ interface ActiveTurn { httpsPort: number | null; } +/** + * One agy stream-json driver. A successful Turn keeps it (and its Question bridge) + * alive for the next Turn of the same model/effort/permission selection, so + * continued conversations skip CLI, Language Server, and customization startup. + * Any other outcome disposes it. + */ +interface AntigravityTransport { + process: ChildProcessByStdio; + exited: Promise; + questions: AntigravityQuestionBridge; + logPath: string; + /** Model, effort, and permission mode this process was started with. */ + key: string; + nativePermissionMode: string | null; + httpsPort: number | null; + codeActionCursor: number; + /** Highest `num_turns` whose result this process already reported. */ + lastResultTurn: number; + /** Fingerprint of the last successful result, to ignore a replayed one. */ + lastResultJSON: string | null; + /** Raw step lines the most recent successful Turn emitted on this process. */ + lastTurnStepLines: Map; +} + const antigravityHarnessId = harnessIdSchema.parse("antigravity"); const DEFAULT_INSPECT_TIMEOUT_MS = 20_000; const DEFAULT_PRINT_TIMEOUT = "30m"; @@ -549,6 +582,10 @@ class AntigravitySession implements HarnessSession { readonly #history: AntigravityHistory; readonly #subagentObservers = new Set(); #active: ActiveTurn | null = null; + /** Set from the moment a turn.start is accepted until it finishes starting. */ + #starting = false; + /** The driver a successful Turn kept for this Session's next Turn. */ + #transport: AntigravityTransport | null = null; #preparingQuestions: Promise | null = null; #questionCleanup: Promise = Promise.resolve(); #closed = false; @@ -621,11 +658,21 @@ class AntigravitySession implements HarnessSession { get isActive(): boolean { return ( this.#active !== null || + this.#starting || this.#preparingQuestions !== null || [...this.#subagentObservers].some((observer) => observer.running) ); } + /** + * Releases the driver an idle Session kept for its next Turn, if there is one. fork and + * rollback copy the Conversation's database, which a live agy keeps writing, so the copy + * is only taken once no process can touch it. + */ + async releaseIdleDriver(): Promise { + return this.#disposeTransport(this.#transport); + } + subagentState(id: string): HostSubagentState | undefined { for (const observer of [...this.#subagentObservers].reverse()) { const state = observer.state(id); @@ -699,59 +746,101 @@ class AntigravitySession implements HarnessSession { }; } + // Claim the Session synchronously, before any await, so two concurrent starts cannot + // both pass the busy check and overwrite #active. Every exit path releases the claim. + this.#starting = true; + try { + return await this.#startTurn(command, text); + } finally { + this.#starting = false; + } + } + + async #startTurn( + command: TurnStartCommand, + text: string, + ): Promise> { let questions: AntigravityQuestionBridge; const hookDirectory = antigravityHookDirectory(this.#environment); - // The Thread's Hook directory outlives each Turn, while the previous Turn's agy may still be - // exiting and its bridge closing; the next bridge rewrites the directory after that cleanup, - // which is itself bounded. A private per-Turn directory is shared with no one. - const previousTurn = hookDirectory ? this.#questionCleanup : Promise.resolve(); - this.#preparingQuestions = previousTurn.then(() => - AntigravityQuestionBridge.create({ - ...(hookDirectory ? { directory: hookDirectory } : {}), - turnId: command.turnId, - nativeSessionId: () => - this.#active?.command === command && !this.#active.cancellationRequested - ? this.#nativeRef?.nativeSessionId - : undefined, - schedule: (action) => { - if (this.#active?.command === command) this.#enqueue(this.#active, action); - else action(); - }, - emit: (output) => { - const active = this.#active; - if (!active || active.command !== command) return; - if (output.kind === "event" && output.event.type === "item.started" && active.agentItem) { - this.#completeItem(active, active.agentItem, { status: "succeeded" }); - active.agentItem = null; - active.agentText = ""; - } - if (output.kind === "event" && output.event.type === "item.completed") { - active.completedItems.push(output.event.snapshot); - } - this.#channel.emit(output); - }, - }), - ); + // A successful Turn keeps its driver, bridge, and log alive for the next Turn, so a + // continued conversation skips CLI, Language Server, and customization startup. A + // different model/effort/permission selection, or a dead or unwritable process, starts + // a fresh driver. The previous Turn's cleanup is awaited first so a retained bridge is + // never rebound while its old driver is still being torn down. + const transportKey = JSON.stringify([ + this.#model?.id ?? null, + this.#thinkingOptionId ?? null, + this.#permissionMode, + ]); + await this.#questionCleanup.catch(() => undefined); + if (this.#closed) { + return { ok: false, error: invalidState("Antigravity Session is closed") }; + } + let reusable = this.#transport; + if ( + reusable && + (reusable.key !== transportKey || + reusable.process.exitCode !== null || + reusable.process.signalCode !== null || + !reusable.process.stdin.writable) + ) { + await this.#disposeTransport(reusable); + reusable = null; + } + const questionOptions: BridgeOptions = { + ...(hookDirectory ? { directory: hookDirectory } : {}), + turnId: command.turnId, + nativeSessionId: () => + this.#active?.command === command && !this.#active.cancellationRequested + ? this.#nativeRef?.nativeSessionId + : undefined, + schedule: (action) => { + if (this.#active?.command === command) this.#enqueue(this.#active, action); + else action(); + }, + emit: (output) => { + const active = this.#active; + if (!active || active.command !== command) return; + if (output.kind === "event" && output.event.type === "item.started" && active.agentItem) { + this.#completeItem(active, active.agentItem, { status: "succeeded" }); + active.agentItem = null; + active.agentText = ""; + } + if (output.kind === "event" && output.event.type === "item.completed") { + active.completedItems.push(output.event.snapshot); + } + this.#channel.emit(output); + }, + }; + this.#preparingQuestions = reusable + ? Promise.resolve().then(() => reusable.questions.rebind(questionOptions)) + : AntigravityQuestionBridge.create(questionOptions); try { questions = await this.#preparingQuestions; } catch (error) { + // A bridge that was disposed or is still busy cannot serve this driver again, so a + // retry would fail identically: release the driver and report a permanent failure. + const permanent = reusable !== null && /disposed|busy/u.test(errorMessage(error)); + if (permanent) await this.#disposeTransport(reusable); return { ok: false, error: { - code: "nativeFailure", + code: permanent ? "invalidState" : "nativeFailure", message: `Question bridge setup failed: ${errorMessage(error)}`, - retryable: true, + retryable: !permanent, }, }; } finally { this.#preparingQuestions = null; } if (this.#closed) { - await questions.dispose(); + await this.#disposeTransport(reusable); + if (!reusable) await questions.dispose(); return { ok: false, error: invalidState("Antigravity Session is closed") }; } const environment = { ...this.#environment, ...questions.environment }; - const logPath = path.join(os.tmpdir(), `codexhost-antigravity-${randomUUID()}.log`); + const logPath = + reusable?.logPath ?? path.join(os.tmpdir(), `codexhost-antigravity-${randomUUID()}.log`); const arguments_ = [ "--input-format", "stream-json", @@ -769,13 +858,15 @@ class AntigravitySession implements HarnessSession { const invocation = commandInvocation(this.#executable, arguments_, environment); let child: ChildProcessByStdio; try { - child = spawn(invocation.command, invocation.arguments, { - cwd: this.#cwd, - env: environment, - windowsHide: true, - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - stdio: ["pipe", "pipe", "pipe"], - }); + child = + reusable?.process ?? + spawn(invocation.command, invocation.arguments, { + cwd: this.#cwd, + env: environment, + windowsHide: true, + windowsVerbatimArguments: invocation.windowsVerbatimArguments, + stdio: ["pipe", "pipe", "pipe"], + }); } catch (error) { await questions.dispose(); return { @@ -783,11 +874,23 @@ class AntigravitySession implements HarnessSession { error: { code: "nativeFailure", message: errorMessage(error), retryable: true }, }; } + const transport: AntigravityTransport = reusable ?? { + process: child, + exited: new Promise((resolve) => child.once("close", () => resolve())), + questions, + logPath, + key: transportKey, + nativePermissionMode: null, + httpsPort: null, + codeActionCursor: 0, + lastResultTurn: -1, + lastResultJSON: null, + lastTurnStepLines: new Map(), + }; const active: ActiveTurn = { command, model: this.#model, process: child, - exited: new Promise((resolve) => child.once("close", () => resolve())), questions, subagents: new AntigravitySubagents({ turnId: command.turnId, @@ -814,6 +917,9 @@ class AntigravitySession implements HarnessSession { schedule: (work) => this.#enqueue(active, work), }), logPath, + transport, + stepLines: new Map(), + replayedSteps: reusable ? new Map(reusable.lastTurnStepLines) : new Map(), agentItem: null, agentText: "", tools: new Map(), @@ -826,51 +932,103 @@ class AntigravitySession implements HarnessSession { latestUsage: null, contextUsagePromise: null, queue: Promise.resolve(), - codeActionCursor: 0, + codeActionCursor: reusable?.codeActionCursor ?? 0, fileChanges: new Map(), pendingSteps: new Map(), httpsPort: null, }; this.#active = active; + this.#transport = transport; this.#subagentObservers.add(active.subagents); - child.stdin.on("error", () => undefined); - child.stderr.setEncoding("utf8").on("data", (chunk: string) => { - active.stderr = (active.stderr + chunk).slice(-8_000); - }); - const lines = readline.createInterface({ input: child.stdout }); - lines.on("line", (line) => { - const event = parseAntigravityStreamLine(line); - if (event) this.#enqueue(active, () => this.#handleEvent(active, event)); - else if (line.trim()) active.stderr = (active.stderr + `\n${line}`).slice(-8_000); - }); - child.once("error", (error) => { - this.#enqueue(active, () => { - if (this.#active !== active) return; - this.#completeTurn(active, { - status: "failed", - error: { code: "nativeFailure", message: error.message, retryable: true }, - }); + // A reused process already has its stream listeners and its stdin error handler; + // attaching a second set would deliver every event twice. + if (!reusable) { + child.stdin.on("error", () => undefined); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { + const turn = this.#active; + if (turn?.process === child) turn.stderr = (turn.stderr + chunk).slice(-8_000); }); - }); - child.once("close", (code) => { - this.#enqueue(active, () => { - void unlink(active.logPath).catch(() => undefined); - if (active.subagents.running) void active.subagents.cancel(); - else active.subagents.stop(); - if (this.#active !== active || active.receivedResult) return; - if (active.cancellationRequested) { - this.#completeTurn(active, { status: "cancelled", reason: "Cancelled by user" }); - } else { - this.#completeTurn(active, { + const lines = readline.createInterface({ input: child.stdout }); + lines.on("line", (line) => { + const turn = this.#active; + if (!turn || turn.process !== child) return; + const event = parseAntigravityStreamLine(line); + if (!event) { + if (line.trim()) turn.stderr = (turn.stderr + `\n${line}`).slice(-8_000); + return; + } + // agy replays the previous Turn's result and old steps when it starts the next + // stdin message, so only genuinely new Turn output reaches the Host. A replay is + // filtered whatever its status: a native ERROR that the Host accepted as a + // success (a replied Turn repeating an earlier Conversation error) still keeps + // this driver, so its replay would otherwise fail the next Turn before its own + // result arrived. + if (event.event === "result" && Number.isInteger(event.result.num_turns)) { + const fingerprint = JSON.stringify(event.result); + if ( + event.result.num_turns <= transport.lastResultTurn && + fingerprint === transport.lastResultJSON + ) { + return; + } + transport.lastResultTurn = Math.max(transport.lastResultTurn, event.result.num_turns); + transport.lastResultJSON = fingerprint; + } + if (event.event === "step_update") { + const index = event.step_update.step_index; + if (Number.isInteger(index)) { + // A replayed step is byte-identical to the same index of the previous Turn, + // and each replay is consumed once. Matching on content rather than index + // order is what makes this correct whether agy continues numbering across + // stdin messages or restarts at 1; consuming the match is what keeps a new + // Turn's own step from being dropped when it repeats the previous line. + if (turn.replayedSteps.get(index) === line) { + turn.replayedSteps.delete(index); + return; + } + turn.stepLines.set(index, line); + } + } + this.#enqueue(turn, () => this.#handleEvent(turn, event)); + }); + child.once("error", (error) => { + const turn = this.#active; + if (turn?.process !== child) return; + this.#enqueue(turn, () => { + if (this.#active !== turn) return; + this.#completeTurn(turn, { status: "failed", - error: normalizedProcessError( - active.stderr, - `Antigravity CLI exited before a result event (code ${String(code)})`, - ), + error: { code: "nativeFailure", message: error.message, retryable: true }, }); + }); + }); + child.once("close", (code) => { + if (this.#transport === transport) this.#transport = null; + const turn = this.#active; + if (!turn || turn.process !== child) { + // An idle driver exited between Turns. Nothing else will release its bridge and + // log, so do it here rather than leaking an HTTP server and a temp file. + void this.#disposeTransport(transport); + return; } + this.#enqueue(turn, () => { + if (turn.subagents.running) void turn.subagents.cancel(); + else turn.subagents.stop(); + if (this.#active !== turn || turn.receivedResult) return; + if (turn.cancellationRequested) { + this.#completeTurn(turn, { status: "cancelled", reason: "Cancelled by user" }); + } else { + this.#completeTurn(turn, { + status: "failed", + error: normalizedProcessError( + turn.stderr, + `Antigravity CLI exited before a result event (code ${String(code)})`, + ), + }); + } + }); }); - }); + } try { const turnPrompt = formatAntigravityTurnPrompt(text, this.#cwd); if (child.stdin.writable) { @@ -905,6 +1063,9 @@ class AntigravitySession implements HarnessSession { await active.subagents.cancel(); active.process.kill(); this.#completeTurn(active, { status: "cancelled", reason: "Session closed" }); + } else if (this.#transport) { + // A retained driver from a successful Turn belongs to the Session; close releases it. + await this.#disposeTransport(this.#transport); } await Promise.all([...this.#subagentObservers].map((observer) => observer.cancel())); await this.#questionCleanup; @@ -1276,17 +1437,31 @@ class AntigravitySession implements HarnessSession { #completeTurn(active: ActiveTurn, outcome: TurnOutcome, nativeTurnRef?: NativeTurnRef): void { if (this.#active !== active) return; active.questions.stop(); + const transport = active.transport; + // A successful Turn keeps its driver for the next Turn of this Session; the bridge is + // rebound there. Anything else (failure, cancellation, close) releases the driver. + const keepTransport = + outcome.status === "succeeded" && + !this.#closed && + this.#transport === transport && + active.process.exitCode === null && + active.process.signalCode === null && + active.process.stdin.writable; + if (keepTransport) { + // The port belongs to the process; the cursor belongs to the Conversation's + // trajectory, so a turn that resolved edits advances it for the next Turn. + transport.httpsPort = active.httpsPort ?? transport.httpsPort; + transport.codeActionCursor = active.codeActionCursor; + transport.nativePermissionMode = active.nativePermissionMode; + transport.lastTurnStepLines = active.stepLines; + } this.#questionCleanup = this.#questionCleanup .then(async () => { - if (outcome.status !== "succeeded") await active.subagents.cancel(); + // Cancel first: a failed Turn's Subagents may stay observable as running, and + // `settled` would then never resolve, stranding the driver. + if (!keepTransport && outcome.status !== "succeeded") await active.subagents.cancel(); await active.subagents.settled; - if (active.process.stdin.writable) active.process.stdin.end(); - const timer = setTimeout(() => active.process.kill(), NATIVE_EXIT_GRACE_MS); - // A child that keeps agy's output open can delay `close` indefinitely, and later Turns' - // cleanup and close() queue behind this one. - await settledWithin(active.exited, NATIVE_EXIT_GRACE_MS + this.#turnCleanupWaitMs); - clearTimeout(timer); - await active.questions.dispose(); + if (!keepTransport) await this.#disposeTransport(transport); this.#subagentObservers.delete(active.subagents); }) .catch(() => undefined); @@ -1335,6 +1510,47 @@ class AntigravitySession implements HarnessSession { }); } + /** + * Releases a native driver: stop its bridge, end stdin, then kill if it lingers. + * Resolves true only once the process has actually exited, so a caller that is about to + * read the Conversation database knows no process can still be writing it. + */ + async #disposeTransport(transport: AntigravityTransport | null): Promise { + if (!transport) return true; + // A driver that has not confirmed exit stays reachable, so a caller that retries keeps + // waiting on it instead of being told a released process is gone. + const confirmed = () => + transport.process.exitCode !== null || transport.process.signalCode !== null; + if (this.#transport === transport && confirmed()) this.#transport = null; + transport.questions.stop(); + if (transport.process.stdin.writable) transport.process.stdin.end(); + const timer = setTimeout(() => transport.process.kill(), NATIVE_EXIT_GRACE_MS); + // The escalation is scheduled after the bounded wait, not merely alongside it: a Turn + // cleanup wait shorter than the escalation would otherwise cancel the SIGKILL and leave + // an unresponsive agy with no owner. + try { + // A process that keeps agy's output open can delay `close` indefinitely, and later + // Turns' cleanup and close() queue behind this one. + await settledWithin(transport.exited, NATIVE_EXIT_GRACE_MS + this.#turnCleanupWaitMs); + } finally { + clearTimeout(timer); + if (transport.process.exitCode === null && transport.process.signalCode === null) { + // Still alive after the bounded wait: escalate, and keep the timer past this + // function so a stuck agy is not left running with no owner. + setTimeout(() => transport.process.kill("SIGKILL"), NATIVE_EXIT_GRACE_MS).unref(); + } + await transport.questions.dispose(); + await unlink(transport.logPath).catch(() => undefined); + } + if (confirmed()) { + if (this.#transport === transport) this.#transport = null; + return true; + } + // Still alive: keep it as the Session's driver so a retry escalates again rather than + // reporting success for a process that may still be writing the database. + return false; + } + #completeItem(active: ActiveTurn, item: HostItem, outcome: HostItemOutcome): void { const snapshot = { item, outcome } satisfies HostItemSnapshot; active.completedItems.push(snapshot); @@ -1742,6 +1958,17 @@ export class AntigravityAdapter implements HarnessAdapter { if (input.kind === "fork") { const sourceSession = this.#findSession(input.sourceRef.nativeSessionId); + // The source's database is copied below, so no process may still be writing it. + if (sourceSession && !sourceSession.isActive && !(await sourceSession.releaseIdleDriver())) { + return { + ok: false, + error: { + code: "sessionBusy", + message: "Antigravity Session driver could not be stopped before forking", + retryable: true, + }, + }; + } return forkAntigravitySession({ harnessId: this.harnessId, input, @@ -1782,6 +2009,17 @@ export class AntigravityAdapter implements HarnessAdapter { if (input.kind === "rollbackLastTurn") { const sourceSession = this.#findSession(input.sourceRef.nativeSessionId); + // The source's database is copied below, so no process may still be writing it. + if (sourceSession && !sourceSession.isActive && !(await sourceSession.releaseIdleDriver())) { + return { + ok: false, + error: { + code: "sessionBusy", + message: "Antigravity Session driver could not be stopped before rolling back", + retryable: true, + }, + }; + } return rollbackAntigravityLastTurn({ harnessId: this.harnessId, input, diff --git a/packages/adapters/antigravity/src/question-bridge.ts b/packages/adapters/antigravity/src/question-bridge.ts index c995d795f..f3ac8ffb0 100644 --- a/packages/adapters/antigravity/src/question-bridge.ts +++ b/packages/adapters/antigravity/src/question-bridge.ts @@ -49,7 +49,8 @@ const responseSchema = z cancelled: z.boolean().optional(), }) .strict(); -interface BridgeOptions { +/** Internal to the Antigravity Session; not part of the public Harness contract. */ +export interface BridgeOptions { /** * A directory the caller keeps across Turns. The bridge writes its Hook files there and leaves * the directory on dispose; without one it uses a private temporary directory it removes. @@ -89,6 +90,12 @@ export class AntigravityQuestionBridge { readonly #pending = new Map(); readonly #seenSteps = new Set(); #stopped = false; + /** + * Bumped on every rebind. A request received before the bump is stale: it belongs to the + * Turn that was driving when the hook process made it, and must not be attributed to the + * Turn now owning the bridge. + */ + #generation = 0; #disposal: Promise | null = null; private constructor(directory: string, options: BridgeOptions) { @@ -158,6 +165,9 @@ export class AntigravityQuestionBridge { } #receive(request: IncomingMessage, response: ServerResponse): void { + // The hook process is spawned per Turn by agy; capturing the generation here means a + // request that straddles a rebind cannot be answered by the next Turn. + const generation = this.#generation; const auth = Buffer.from(request.headers.authorization ?? ""); const expected = Buffer.from(`Bearer ${this.#token}`); if ( @@ -198,13 +208,14 @@ export class AntigravityQuestionBridge { return; } const payload = parsed.data; - this.#options.schedule(() => this.#ask(payload, response)); + this.#options.schedule(() => this.#ask(payload, response, generation)); }); } - #ask(payload: z.infer, response: ServerResponse): void { + #ask(payload: z.infer, response: ServerResponse, generation: number): void { if ( this.#stopped || + generation !== this.#generation || response.destroyed || payload.conversationId !== this.#options.nativeSessionId() ) { @@ -374,6 +385,28 @@ export class AntigravityQuestionBridge { }); } + /** + * Hands the bridge to the next Turn of a reused native driver. Ownership, the Turn + * identity used in emitted Items, and the duplicate-question guard all move to the new + * Turn, so a question from an earlier Turn can no longer be attributed or answered. + */ + rebind(options: BridgeOptions): AntigravityQuestionBridge { + if (this.#disposal) throw new Error("Cannot reuse a disposed Question bridge"); + if (this.#pending.size) throw new Error("Cannot reuse a busy Question bridge"); + if (options.directory !== this.#options.directory) { + throw new Error("A reused Question bridge keeps its directory"); + } + this.#options.turnId = options.turnId; + this.#options.nativeSessionId = options.nativeSessionId; + this.#options.schedule = options.schedule; + this.#options.emit = options.emit; + if (options.timeoutMs !== undefined) this.#options.timeoutMs = options.timeoutMs; + this.#stopped = false; + this.#seenSteps.clear(); + this.#generation += 1; + return this; + } + stop(): void { if (this.#stopped) return; this.#stopped = true; diff --git a/packages/adapters/antigravity/test/adversarial-fork-rollback.test.ts b/packages/adapters/antigravity/test/adversarial-fork-rollback.test.ts index 3db2202ff..1305498fb 100644 --- a/packages/adapters/antigravity/test/adversarial-fork-rollback.test.ts +++ b/packages/adapters/antigravity/test/adversarial-fork-rollback.test.ts @@ -60,7 +60,9 @@ if (process.argv.includes("models") || process.argv.some((arg) => arg.includes(" const runsDir = ${JSON.stringify(runsDir)}; fs.mkdirSync(runsDir, { recursive: true }); -const count = fs.readdirSync(runsDir).length; +// The script position is shared by every process, so a forked or resumed session picks +// up where the previous process left off rather than replaying an earlier Turn. +let count = fs.readdirSync(runsDir).length; fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), JSON.stringify(process.argv) + "\\n"); const convIdx = process.argv.indexOf("--conversation"); @@ -71,6 +73,7 @@ const delay = ${delayMs}; function emit() { const rawLines = turnResponses[Math.min(count, turnResponses.length - 1)] || []; + count += 1; for (const rawLine of rawLines) { try { const parsed = JSON.parse(rawLine); @@ -90,17 +93,23 @@ function emit() { } } +let buffer = ""; +function emitNext() { + if (delay > 0) setTimeout(emit, delay); + else emit(); +} process.stdin.resume(); -process.stdin.on("data", () => {}); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(index + 1); + emitNext(); + } +}); process.stdin.on("end", () => { process.exit(0); }); - -if (delay > 0) { - setTimeout(emit, delay); -} else { - emit(); -} `; const jsPath = path.join(directory, "agy.cjs"); diff --git a/packages/adapters/antigravity/test/adversarial-stress.test.ts b/packages/adapters/antigravity/test/adversarial-stress.test.ts index 5ef7ea8c7..0865f9867 100644 --- a/packages/adapters/antigravity/test/adversarial-stress.test.ts +++ b/packages/adapters/antigravity/test/adversarial-stress.test.ts @@ -352,6 +352,8 @@ describe("Antigravity Adversarial & Stress Testing", () => { const runsDir = path.join(directory, "runs"); const jsPath = path.join(directory, "agy.cjs"); + // Like the real CLI, one process serves every Turn: each stdin line drives the + // next scripted response, so a reused driver never replays an earlier Turn. const scriptContent = ` const fs = require('fs'); const path = require('path'); @@ -363,16 +365,25 @@ if (process.argv.includes("models") || process.argv.some((arg) => arg.includes(" } const runsDir = ${JSON.stringify(runsDir)}; fs.mkdirSync(runsDir, { recursive: true }); -// Only a Turn consumes a scripted response. Quota probes also request -// stream-json output, but unlike Turns they do not use stream-json input. -const isTurn = process.argv.includes("--input-format"); -const counter = isTurn ? fs.readdirSync(runsDir).length : -1; -if (isTurn) fs.writeFileSync(path.join(runsDir, "run-" + counter + ".txt"), ""); const allResponses = JSON.parse(fs.readFileSync(${JSON.stringify(runsFile)}, 'utf8')); -const lines = isTurn ? allResponses[counter] || allResponses[0] || [] : []; -for (const line of lines) { - process.stdout.write(line + "\\n"); +let turn = 0; +let buffer = ""; +function emit() { + const lines = allResponses[turn] || allResponses[allResponses.length - 1] || []; + turn += 1; + for (const line of lines) process.stdout.write(line + "\\n"); } +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(index + 1); + fs.writeFileSync(path.join(runsDir, "run-" + turn + ".txt"), ""); + emit(); + } +}); +process.stdin.on("end", () => process.exit(0)); `; await writeFile(jsPath, scriptContent); if (process.platform === "win32") { diff --git a/packages/adapters/antigravity/test/antigravity-adapter.test.ts b/packages/adapters/antigravity/test/antigravity-adapter.test.ts index fd67c1503..5236e5701 100644 --- a/packages/adapters/antigravity/test/antigravity-adapter.test.ts +++ b/packages/adapters/antigravity/test/antigravity-adapter.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import type { HarnessOutput, HarnessSession, HostEvent } from "@codexhost/harness-adapter"; +import type { NativeSessionRef } from "@codexhost/shared-contracts"; import { accountCreditsSnapshotSchema, harnessModelRefSchema, @@ -666,6 +667,15 @@ if (${keepAlive}) { return { command, cwd, cleanup }; } + function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } + async function nextEvent(iterator: AsyncIterator): Promise { const result = await iterator.next(); if (result.done) throw new Error("Output stream ended unexpectedly"); @@ -1427,21 +1437,23 @@ if (process.argv.includes("models")) { // The quota probe runs as --print=/usage; Turns carry --print-timeout instead. if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); const addDirs = process.argv.filter((_, index, all) => all[index - 1] === "--add-dir"); -let input = ""; +let buffer = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { - if (input.includes("\\n")) return; - input += chunk; - const newline = input.indexOf("\\n"); - if (newline === -1) return; - const prompt = JSON.parse(input.slice(0, newline)).message.content; - fs.appendFileSync(${JSON.stringify(runs)}, JSON.stringify({ addDirs, prompt }) + "\\n"); - const turn = fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length; - for (const event of [ - { event: "init", conversation_id: "conv-hooks", init: { permission_mode: "dangerously-skip-permissions" } }, - { event: "result", result: { conversation_id: "conv-hooks", status: "SUCCESS", num_turns: turn, response: "ok" } }, - ]) { - process.stdout.write(JSON.stringify(event) + "\\n"); + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + const prompt = JSON.parse(line).message.content; + fs.appendFileSync(${JSON.stringify(runs)}, JSON.stringify({ addDirs, prompt }) + "\\n"); + const turn = fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length; + for (const event of [ + { event: "init", conversation_id: "conv-hooks", init: { permission_mode: "dangerously-skip-permissions" } }, + { event: "result", result: { conversation_id: "conv-hooks", status: "SUCCESS", num_turns: turn, response: "ok" } }, + ]) { + process.stdout.write(JSON.stringify(event) + "\\n"); + } } }); process.stdin.on("end", () => process.exit(0)); @@ -1553,24 +1565,32 @@ if (process.argv.includes("models")) { process.exit(0); } if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); -fs.appendFileSync(${JSON.stringify(runs)}, "run\\n"); -const turn = fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length; -if (turn === 1) { - // Detached so it outlives this process on Windows too, where a child otherwise ends with it. - const holder = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { - detached: true, - stdio: ["ignore", "inherit", "inherit"], - windowsHide: true, - }); - fs.writeFileSync(${JSON.stringify(holderPidFile)}, String(holder.pid)); -} -for (const event of [ - { event: "init", conversation_id: "conv-linger", init: { permission_mode: "dangerously-skip-permissions" } }, - { event: "result", result: { conversation_id: "conv-linger", status: "SUCCESS", num_turns: turn, response: "ok" } }, -]) { - process.stdout.write(JSON.stringify(event) + "\\n"); -} +let buffer = ""; process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + fs.appendFileSync(${JSON.stringify(runs)}, "run\\n"); + const turn = fs.readFileSync(${JSON.stringify(runs)}, "utf8").trim().split("\\n").length; + if (turn === 1) { + // Detached so it outlives this process on Windows too, where a child otherwise ends with it. + const holder = spawn(process.execPath, ["-e", "setTimeout(() => {}, 60000)"], { + detached: true, + stdio: ["ignore", "inherit", "inherit"], + windowsHide: true, + }); + fs.writeFileSync(${JSON.stringify(holderPidFile)}, String(holder.pid)); + } + for (const event of [ + { event: "init", conversation_id: "conv-linger", init: { permission_mode: "dangerously-skip-permissions" } }, + { event: "result", result: { conversation_id: "conv-linger", status: "SUCCESS", num_turns: turn, response: "ok" } }, + ]) { + process.stdout.write(JSON.stringify(event) + "\\n"); + } + } +}); process.stdin.on("end", () => process.exit(0)); `; const jsPath = path.join(directory, "agy.cjs"); @@ -1642,6 +1662,568 @@ process.stdin.on("end", () => process.exit(0)); 20_000, ); + it("reuses one native driver across successful Turns and releases it on model select", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-reuse-")); + const cwd = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-reuse-cwd-")); + const pids = path.join(directory, "pids.jsonl"); + const cleanup = async (): Promise => { + for (const target of [directory, cwd]) { + await rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }; + // One process answers every Turn it is given, and records its own PID per Turn. + const scriptContent = ` +const fs = require("node:fs"); +if (process.argv.includes("models")) { + process.stdout.write("gemini-3.7-flash-high\\tGemini 3.7 Flash High\\ngemini-3.1-pro-high\\tGemini 3.1 Pro High\\n"); + process.exit(0); +} +if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); +// The Conversation keeps counting across processes, as agy reports num_turns for the +// Conversation rather than for the process. +let turn = fs.existsSync(${JSON.stringify(pids)}) + ? fs.readFileSync(${JSON.stringify(pids)}, "utf8").trim().split("\\n").length + : 0; +let buffer = ""; +function answer() { + turn += 1; + fs.appendFileSync(${JSON.stringify(pids)}, JSON.stringify({ pid: process.pid, turn }) + "\\n"); + for (const event of [ + { event: "init", conversation_id: "conv-reuse", init: { permission_mode: "dangerously-skip-permissions" } }, + { event: "result", result: { conversation_id: "conv-reuse", status: "SUCCESS", num_turns: turn, response: "reply " + turn } }, + ]) { + process.stdout.write(JSON.stringify(event) + "\\n"); + } +} +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + answer(); + } +}); +process.stdin.on("end", () => process.exit(0)); +`; + const jsPath = path.join(directory, "agy.cjs"); + await writeFile(jsPath, scriptContent); + let command: string; + if (process.platform === "win32") { + command = path.join(directory, "agy.cmd"); + await writeFile(command, `@node "${jsPath}" %*\r\n`); + } else { + command = path.join(directory, "agy"); + await writeFile(command, `#!/usr/bin/env node\n${scriptContent}`); + await chmod(command, 0o755); + } + + const adapter = new AntigravityAdapter({ command }); + try { + const opened = await adapter.open({ kind: "create", cwd }); + if (!opened.ok) throw new Error(opened.error.message); + const session = opened.value; + const iterator = session.outputs[Symbol.asyncIterator](); + const runTurn = async (turnId: string): Promise => { + const started = await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse(turnId), + input: [{ type: "text", text: turnId }], + }); + expect(started.ok).toBe(true); + while ((await nextEvent(iterator)).type !== "turn.completed") { + // Remaining events are not under test. + } + }; + + await runTurn("turn-reuse-1"); + await runTurn("turn-reuse-2"); + await runTurn("turn-reuse-3"); + const observed = (await readFile(pids, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { pid: number; turn: number }); + expect(observed).toHaveLength(3); + expect(new Set(observed.map(({ pid }) => pid)).size).toBe(1); + + // A different model needs a different invocation, so the next Turn starts a new driver. + await session.execute({ + type: "model.select", + model: harnessModelRefSchema.parse({ id: "gemini-3.1-pro-high" }), + }); + await runTurn("turn-reuse-4"); + const afterSelect = (await readFile(pids, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as { pid: number; turn: number }); + expect(afterSelect).toHaveLength(4); + expect(new Set(afterSelect.map(({ pid }) => pid)).size).toBe(2); + + const snapshot = await session.readSnapshot(); + expect(snapshot.ok).toBe(true); + if (snapshot.ok) expect(snapshot.value.turns).toHaveLength(4); + await session.close(); + } finally { + await adapter.close(); + await cleanup(); + } + }); + + it("refuses a second turn.start that starts while the first is still preparing", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-race-")); + const cwd = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-race-cwd-")); + const cleanups = async (): Promise => { + for (const target of [directory, cwd]) { + await rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }; + const scriptContent = ` +if (process.argv.includes("models")) { + process.stdout.write("gemini-3.7-flash-high\\tGemini 3.7 Flash High\\n"); + process.exit(0); +} +if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + process.stdout.write(JSON.stringify({ event: "init", conversation_id: "conv-race", init: { permission_mode: "dangerously-skip-permissions" } }) + "\\n"); + process.stdout.write(JSON.stringify({ event: "result", result: { conversation_id: "conv-race", status: "SUCCESS", num_turns: 1, response: "ok" } }) + "\\n"); + } +}); +process.stdin.on("end", () => process.exit(0)); +`; + const jsPath = path.join(directory, "agy.cjs"); + await writeFile(jsPath, scriptContent); + let command: string; + if (process.platform === "win32") { + command = path.join(directory, "agy.cmd"); + await writeFile(command, `@node "${jsPath}" %*\r\n`); + } else { + command = path.join(directory, "agy"); + await writeFile(command, `#!/usr/bin/env node\n${scriptContent}`); + await chmod(command, 0o755); + } + + const adapter = new AntigravityAdapter({ command }); + try { + const opened = await adapter.open({ kind: "create", cwd }); + if (!opened.ok) throw new Error(opened.error.message); + const session = opened.value; + // Both starts are issued before either can await anything. + const [first, second] = await Promise.all([ + session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("turn-race-1"), + input: [{ type: "text", text: "first" }], + }), + session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("turn-race-2"), + input: [{ type: "text", text: "second" }], + }), + ]); + expect(first.ok).toBe(true); + expect(second).toMatchObject({ ok: false, error: { code: "sessionBusy" } }); + await session.close(); + } finally { + await adapter.close(); + await cleanups(); + } + }); + + it("releases an idle driver that exits between turns", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-idle-")); + const cwd = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-idle-cwd-")); + const cleanup = async (): Promise => { + for (const target of [directory, cwd]) { + await rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }; + const scriptContent = ` +const fs = require("node:fs"); +if (process.argv.includes("models")) { + process.stdout.write("gemini-3.7-flash-high\\tGemini 3.7 Flash High\\n"); + process.exit(0); +} +if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); +// The log file is the driver's; its removal by the parent proves the release ran. +for (const arg of process.argv) { + if (arg.endsWith(".log")) fs.writeFileSync(arg, "started\\n"); +} +let served = false; +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + served = true; + process.stdout.write(JSON.stringify({ event: "init", conversation_id: "conv-idle", init: { permission_mode: "dangerously-skip-permissions" } }) + "\\n"); + process.stdout.write(JSON.stringify({ event: "result", result: { conversation_id: "conv-idle", status: "SUCCESS", num_turns: 1, response: "ok" } }) + "\\n"); + // Exit on its own once the Turn is answered, like a native driver that idles out. + setTimeout(() => process.exit(0), 50); + } +}); +process.stdin.on("end", () => process.exit(served ? 0 : 1)); +`; + const jsPath = path.join(directory, "agy.cjs"); + await writeFile(jsPath, scriptContent); + let command: string; + if (process.platform === "win32") { + command = path.join(directory, "agy.cmd"); + await writeFile(command, `@node "${jsPath}" %*\r\n`); + } else { + command = path.join(directory, "agy"); + await writeFile(command, `#!/usr/bin/env node\n${scriptContent}`); + await chmod(command, 0o755); + } + + const adapter = new AntigravityAdapter({ command }); + try { + const opened = await adapter.open({ kind: "create", cwd }); + if (!opened.ok) throw new Error(opened.error.message); + const session = opened.value; + const iterator = session.outputs[Symbol.asyncIterator](); + await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("turn-idle-1"), + input: [{ type: "text", text: "one" }], + }); + while ((await nextEvent(iterator)).type !== "turn.completed") { + // Remaining events are not under test. + } + // The driver exits on its own shortly after answering; the next Turn must start a + // fresh one rather than write into the dead process. + await new Promise((resolve) => setTimeout(resolve, 400)); + const second = await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("turn-idle-2"), + input: [{ type: "text", text: "two" }], + }); + expect(second.ok).toBe(true); + while ((await nextEvent(iterator)).type !== "turn.completed") { + // The replacement driver answers the second Turn. + } + await session.close(); + } finally { + await adapter.close(); + await cleanup(); + } + }); + + // POSIX only: simulating a driver that survives termination needs a signal it can + // ignore. On Windows the .cmd wrapper runs agy as a grandchild, so killing the wrapper + // leaves the grandchild holding the pipe and the fixture cannot model this at all — + // that platform gap belongs to the adapter, not to this test. + it.skipIf(process.platform === "win32")( + "refuses to copy the database while an idle driver has not confirmed exit", + async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-exit-")); + const cwd = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-exit-cwd-")); + // Best effort: this fixture deliberately leaves a driver that had to be SIGKILLed, and + // Windows can keep its working directory locked a little longer than the process. The + // assertions above already ran; failing the test on temp-directory cleanup would only + // report the OS timing, and the runner's temp directory is disposable. + const cleanup = async (): Promise => { + for (const target of [directory, cwd]) { + await rm(target, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 250, + }).catch(() => undefined); + } + }; + // The driver ignores stdin ending and SIGTERM, so its exit cannot be confirmed within + // the cleanup bound. It does exit on its own after a few seconds, which bounds the + // test and lets the temp cwd be removed; the pid is recorded so cleanup can wait. + const pidFile = path.join(directory, "driver.pid"); + const scriptContent = ` +const fs = require("node:fs"); +if (process.argv.includes("models")) { + process.stdout.write("gemini-3.7-flash-high\\tGemini 3.7 Flash High\\n"); + process.exit(0); +} +if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); +fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); +// Ignored where signals are distinct; on Windows kill() terminates regardless. +process.on("SIGTERM", () => undefined); +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + process.stdout.write(JSON.stringify({ event: "init", conversation_id: "conv-exit", init: { permission_mode: "dangerously-skip-permissions" } }) + "\\n"); + process.stdout.write(JSON.stringify({ event: "result", result: { conversation_id: "conv-exit", status: "SUCCESS", num_turns: 1, response: "ok" } }) + "\\n"); + } +}); +process.stdin.on("end", () => undefined); +setInterval(() => undefined, 1000); +`; + const jsPath = path.join(directory, "agy.cjs"); + await writeFile(jsPath, scriptContent); + let command: string; + if (process.platform === "win32") { + command = path.join(directory, "agy.cmd"); + await writeFile(command, `@node "${jsPath}" %*\r\n`); + } else { + command = path.join(directory, "agy"); + await writeFile(command, `#!/usr/bin/env node\n${scriptContent}`); + await chmod(command, 0o755); + } + + const adapter = new AntigravityAdapter({ command, turnCleanupWaitMs: 0 }); + try { + const opened = await adapter.open({ kind: "create", cwd }); + if (!opened.ok) throw new Error(opened.error.message); + const session = opened.value; + const iterator = session.outputs[Symbol.asyncIterator](); + await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse("turn-exit-1"), + input: [{ type: "text", text: "one" }], + }); + while ((await nextEvent(iterator)).type !== "turn.completed") { + // Remaining events are not under test. + } + + // The driver is retained and refuses to exit, so fork must not copy its database. + const nativeRef = (session as unknown as { nativeRef?: NativeSessionRef }).nativeRef; + if (!nativeRef) throw new Error("The Turn did not bind a native session"); + // A driver whose exit cannot be confirmed must keep reporting that, not report + // success on the second ask while the process is still writing the database. + const session2 = session as unknown as { releaseIdleDriver(): Promise }; + const pid = Number(await readFile(pidFile, "utf8").catch(() => "0")); + const alive = () => Number.isInteger(pid) && pid > 0 && isProcessAlive(pid); + + // The invariant, on every platform: release reports success only when the process is + // actually gone. On POSIX the fixture ignores SIGTERM, so the first attempt cannot + // confirm exit and the driver must stay reachable; on Windows kill() terminates it, + // so the first attempt may legitimately succeed. + const first = await session2.releaseIdleDriver(); + if (first) expect(alive()).toBe(false); + else expect(alive()).toBe(true); + + if (!first) { + // Unconfirmed exit must keep reporting the truth, not report release on a retry + // just because the first attempt already asked. + const retried = await session2.releaseIdleDriver(); + if (retried) expect(alive()).toBe(false); + else expect(alive()).toBe(true); + } + + // Two consecutive false results are the invariant under test. The fork below may run + // after the escalation finally kills the driver, so its failure code is not asserted. + const fork = await adapter.open({ + kind: "fork", + cwd, + sourceRef: nativeRef, + checkpoint: nativeRef as never, + }); + expect(fork.ok).toBe(false); + } finally { + await adapter.close(); + // The escalation SIGKILLs the driver; give it a moment to disappear before removing + // its cwd, which Windows keeps locked while the process exists. Bounded and best + // effort: the assertions above already ran, and the runner's temp dir is disposable. + const pid = Number(await readFile(pidFile, "utf8").catch(() => "0")); + if (Number.isInteger(pid) && pid > 0) { + for (let attempt = 0; attempt < 80; attempt += 1) { + try { + process.kill(pid, 0); + } catch { + break; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } + await cleanup(); + } + }, + 20_000, + ); + + it("drops only the replayed steps of the previous turn, whatever their numbering", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-replay-")); + const cwd = await mkdtemp(path.join(os.tmpdir(), "codexhost-agy-replay-cwd-")); + const cleanup = async (): Promise => { + for (const target of [directory, cwd]) { + await rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }); + } + }; + // Turn 1 emits steps 1..2. Turn 2 replays those same lines and then emits its own + // steps starting again at 1, the numbering an agy that restarts per stdin message + // would use; a reused driver must keep exactly the new ones. + const step = (index: number, tool: string) => + JSON.stringify({ + event: "step_update", + step_update: { + conversation_id: "conv-replay", + step_index: index, + state: "DONE", + step_type: "tool", + tool_name: tool, + tool_info: { parameters: { Note: tool } }, + }, + }); + const turn1 = [ + JSON.stringify({ + event: "init", + conversation_id: "conv-replay", + init: { permission_mode: "dangerously-skip-permissions" }, + }), + step(1, "first_tool"), + step(2, "second_tool"), + JSON.stringify({ + event: "result", + result: { + conversation_id: "conv-replay", + status: "SUCCESS", + num_turns: 1, + response: "one", + }, + }), + ]; + const turn2 = [ + JSON.stringify({ + event: "init", + conversation_id: "conv-replay", + init: { permission_mode: "dangerously-skip-permissions" }, + }), + step(1, "first_tool"), + step(2, "second_tool"), + step(1, "third_tool"), + step(2, "fourth_tool"), + JSON.stringify({ + event: "result", + result: { + conversation_id: "conv-replay", + status: "SUCCESS", + num_turns: 2, + response: "two", + }, + }), + ]; + const turn3 = [ + JSON.stringify({ + event: "init", + conversation_id: "conv-replay", + init: { permission_mode: "dangerously-skip-permissions" }, + }), + step(1, "third_tool"), + step(2, "fourth_tool"), + step(1, "first_tool"), + step(2, "fifth_tool"), + JSON.stringify({ + event: "result", + result: { + conversation_id: "conv-replay", + status: "SUCCESS", + num_turns: 3, + response: "three", + }, + }), + ]; + // Turn 4 replays first_tool/fifth_tool and then emits first_tool again as its own + // first step. Matching a replay must consume it, or this identical new step is lost. + const turn4 = [ + JSON.stringify({ + event: "init", + conversation_id: "conv-replay", + init: { permission_mode: "dangerously-skip-permissions" }, + }), + step(1, "first_tool"), + step(2, "fifth_tool"), + step(1, "first_tool"), + step(2, "sixth_tool"), + JSON.stringify({ + event: "result", + result: { + conversation_id: "conv-replay", + status: "SUCCESS", + num_turns: 4, + response: "four", + }, + }), + ]; + const scriptContent = ` +const turns = ${JSON.stringify([turn1, turn2, turn3, turn4])}; +if (process.argv.includes("models")) { + process.stdout.write("gemini-3.7-flash-high\\tGemini 3.7 Flash High\\n"); + process.exit(0); +} +if (process.argv.some((value) => value.startsWith("--print="))) process.exit(0); +let index = 0; +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + const lines = turns[index] ?? []; + index += 1; + for (const line of lines) process.stdout.write(line + "\\n"); + } +}); +process.stdin.on("end", () => process.exit(0)); +`; + const jsPath = path.join(directory, "agy.cjs"); + await writeFile(jsPath, scriptContent); + let command: string; + if (process.platform === "win32") { + command = path.join(directory, "agy.cmd"); + await writeFile(command, `@node "${jsPath}" %*\r\n`); + } else { + command = path.join(directory, "agy"); + await writeFile(command, `#!/usr/bin/env node\n${scriptContent}`); + await chmod(command, 0o755); + } + + const adapter = new AntigravityAdapter({ command }); + try { + const opened = await adapter.open({ kind: "create", cwd }); + if (!opened.ok) throw new Error(opened.error.message); + const session = opened.value; + const iterator = session.outputs[Symbol.asyncIterator](); + const toolNames = async (turnId: string, text: string): Promise => { + await session.execute({ + type: "turn.start", + turnId: hostTurnIdSchema.parse(turnId), + input: [{ type: "text", text }], + }); + const names: string[] = []; + while (true) { + const event = await nextEvent(iterator); + if (event.type === "item.started" && event.item.type === "toolExecution") { + names.push(event.item.toolName); + } + if (event.type === "turn.completed") return names; + } + }; + + expect(await toolNames("turn-replay-1", "one")).toEqual(["first_tool", "second_tool"]); + // The replayed pair is dropped; the Turn's own two steps survive despite reusing + // indices 1 and 2. + expect(await toolNames("turn-replay-2", "two")).toEqual(["third_tool", "fourth_tool"]); + // A third Turn replays third/fourth and then repeats first_tool verbatim as its own + // first step: each replay is consumed once, so this one must still be delivered. + expect(await toolNames("turn-replay-3", "three")).toEqual(["first_tool", "fifth_tool"]); + expect(await toolNames("turn-replay-4", "four")).toEqual(["first_tool", "sixth_tool"]); + await session.close(); + } finally { + await adapter.close(); + await cleanup(); + } + }); + it("projects file steps as Tool Executions when the applied patch cannot be read", async () => { const streamLines = [ JSON.stringify({ @@ -1784,11 +2366,28 @@ if (process.argv.includes("models")) { if (!process.argv.includes("--input-format")) process.exit(0); const runsDir = ${JSON.stringify(runsDir)}; fs.mkdirSync(runsDir, { recursive: true }); -const count = fs.readdirSync(runsDir).length; -fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), ""); -const result = ${JSON.stringify(results)}[count]; -process.stdout.write(JSON.stringify({ event: "init", conversation_id: "conv-sticky", init: { permission_mode: "default" } }) + "\\n"); -process.stdout.write(JSON.stringify({ event: "result", result: { conversation_id: "conv-sticky", ...result } }) + "\\n"); +const results = ${JSON.stringify(results)}; +// One process serves every Turn, as the real CLI does: each stdin line advances the script. +let count = fs.readdirSync(runsDir).length; +let buffer = ""; +function answer() { + fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), ""); + const result = results[count]; + count += 1; + if (!result) return; + process.stdout.write(JSON.stringify({ event: "init", conversation_id: "conv-sticky", init: { permission_mode: "default" } }) + "\\n"); + process.stdout.write(JSON.stringify({ event: "result", result: { conversation_id: "conv-sticky", ...result } }) + "\\n"); +} +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(newline + 1); + answer(); + } +}); +process.stdin.on("end", () => process.exit(0)); `; const jsPath = path.join(directory, "agy.cjs"); await writeFile(jsPath, scriptContent); diff --git a/packages/adapters/antigravity/test/fork.test.ts b/packages/adapters/antigravity/test/fork.test.ts index 09987a1fe..c54aa62e2 100644 --- a/packages/adapters/antigravity/test/fork.test.ts +++ b/packages/adapters/antigravity/test/fork.test.ts @@ -81,15 +81,26 @@ if (process.argv.includes("models") || process.argv.some((arg) => arg.includes(" const runsDir = ${JSON.stringify(runsDir)}; fs.mkdirSync(runsDir, { recursive: true }); -const count = fs.readdirSync(runsDir).length; -fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), JSON.stringify(process.argv) + "\\n"); - const turnStreamLines = ${JSON.stringify(turnStreamLines)}; -const lines = turnStreamLines[Math.min(count, turnStreamLines.length - 1)] || []; -for (const line of lines) { - process.stdout.write(line + "\\n"); +// One process serves every Turn, as the real CLI does: each stdin line advances the script. +let count = fs.readdirSync(runsDir).length; +function emit() { + fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), JSON.stringify(process.argv) + "\\n"); + const lines = turnStreamLines[Math.min(count, turnStreamLines.length - 1)] || []; + count += 1; + for (const line of lines) process.stdout.write(line + "\\n"); } -setTimeout(() => { process.exit(0); }, 50); +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(index + 1); + emit(); + } +}); +process.stdin.on("end", () => process.exit(0)); `; const jsPath = path.join(directory, "agy.cjs"); await writeFile(jsPath, scriptContent); diff --git a/packages/adapters/antigravity/test/question-bridge.test.ts b/packages/adapters/antigravity/test/question-bridge.test.ts index 4d8a00d0e..658e2eb59 100644 --- a/packages/adapters/antigravity/test/question-bridge.test.ts +++ b/packages/adapters/antigravity/test/question-bridge.test.ts @@ -84,6 +84,65 @@ async function question(outputs: HarnessOutput[]): Promise { + it("refuses to reuse a bridge that still owns a pending question", async () => { + const { bridge, outputs } = await fixture(); + try { + const done = runHook(bridge); + await question(outputs); + expect(() => + bridge.rebind({ + turnId: hostTurnIdSchema.parse("turn-next"), + nativeSessionId: () => input.conversationId, + schedule: (action) => action(), + emit: () => undefined, + }), + ).toThrow("busy"); + bridge.stop(); + await done; + } finally { + await bridge.dispose(); + } + }); + + it("rejects a request that arrives before a rebind instead of attributing it to the next Turn", async () => { + const { bridge, outputs } = await fixture(); + try { + // Hold the scheduled work so the request is in flight across the rebind. + let release: (() => void) | undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const pending: (() => void)[] = []; + bridge.rebind({ + turnId: hostTurnIdSchema.parse("turn-before"), + nativeSessionId: () => input.conversationId, + schedule: (action) => pending.push(action), + emit: (output) => outputs.push(output), + }); + const done = runHook(bridge); + await vi.waitFor(() => expect(pending).toHaveLength(1)); + + bridge.stop(); + bridge.rebind({ + turnId: hostTurnIdSchema.parse("turn-after"), + nativeSessionId: () => input.conversationId, + schedule: (action) => action(), + emit: (output) => outputs.push(output), + }); + release?.(); + await held; + // Run the stale scheduled work only after the bridge belongs to the next Turn. + for (const action of pending) action(); + + const reply = JSON.parse((await done).stdout) as { decision: string; reason: string }; + expect(reply.decision).toBe("deny"); + expect(reply.reason).toContain("does not belong to the active codexhost Turn"); + expect(outputs.some(({ kind }) => kind === "interaction")).toBe(false); + } finally { + await bridge.dispose(); + } + }); + it("rejects answers past the announced deadline even before the timer callback runs", async () => { const { bridge, outputs } = await fixture(); try { diff --git a/packages/adapters/antigravity/test/rollback.test.ts b/packages/adapters/antigravity/test/rollback.test.ts index 75012bb07..db6e59d0d 100644 --- a/packages/adapters/antigravity/test/rollback.test.ts +++ b/packages/adapters/antigravity/test/rollback.test.ts @@ -44,15 +44,26 @@ if (process.argv.includes("models") || process.argv.some((arg) => arg.includes(" const runsDir = ${JSON.stringify(runsDir)}; fs.mkdirSync(runsDir, { recursive: true }); -const count = fs.readdirSync(runsDir).length; -fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), JSON.stringify(process.argv) + "\\n"); - const turnStreamLines = ${JSON.stringify(turnStreamLines)}; -const lines = turnStreamLines[Math.min(count, turnStreamLines.length - 1)] || []; -for (const line of lines) { - process.stdout.write(line + "\\n"); +// One process serves every Turn, as the real CLI does: each stdin line advances the script. +let count = fs.readdirSync(runsDir).length; +function emit() { + fs.writeFileSync(path.join(runsDir, "run-" + count + ".txt"), JSON.stringify(process.argv) + "\\n"); + const lines = turnStreamLines[Math.min(count, turnStreamLines.length - 1)] || []; + count += 1; + for (const line of lines) process.stdout.write(line + "\\n"); } -setTimeout(() => { process.exit(0); }, 50); +let buffer = ""; +process.stdin.resume(); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let index; + while ((index = buffer.indexOf("\\n")) >= 0) { + buffer = buffer.slice(index + 1); + emit(); + } +}); +process.stdin.on("end", () => process.exit(0)); `; const jsPath = path.join(directory, "agy.cjs"); await writeFile(jsPath, scriptContent);