From 61d2a1a60902c1da56bc8622591f29e464170ceb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 11:48:38 -0700 Subject: [PATCH] fix(engine): bound one-shot CLI output capture --- .changeset/dispose-spawned-child-sessions.md | 2 +- .../__tests__/one-shot-session.test.ts | 22 +++++++++ .../engine/src/cli-agent/one-shot-session.ts | 45 +++++++++++++++++-- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/.changeset/dispose-spawned-child-sessions.md b/.changeset/dispose-spawned-child-sessions.md index 60db55d146..c083e793af 100644 --- a/.changeset/dispose-spawned-child-sessions.md +++ b/.changeset/dispose-spawned-child-sessions.md @@ -2,4 +2,4 @@ "@runfusion/fusion": patch --- -Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, bound structured tool-result log previews before serialization, and reduce dashboard SSE keepalive churn. +Dispose completed spawned child agent sessions so execution memory is released promptly after `fn_spawn_agent` children finish, keep artifact registry listing metadata-only so large inline artifacts are not loaded during agent execution, bound structured tool-result log previews and one-shot CLI output capture before serialization/parsing, and reduce dashboard SSE keepalive churn. diff --git a/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts b/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts index a0ae5e6b8c..b4b3f3ac7f 100644 --- a/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts +++ b/packages/engine/src/cli-agent/__tests__/one-shot-session.test.ts @@ -13,6 +13,7 @@ import { extractJsonObjects, buildOneShotSettings, boundedStderrTail, + ONE_SHOT_OUTPUT_PARSE_CAP_BYTES, ONE_SHOT_STDERR_CAP_BYTES, } from "../one-shot-session.js"; @@ -250,6 +251,27 @@ describe("one-shot session lifecycle", () => { } }); + it("retains only a bounded output tail while still parsing trailing JSON", async () => { + const h = newHarness(["codex"]); + const trailingJson = '{"text":"tail-ok"}'; + const result = await runWith( + h, + "codex", + "validator", + `${"x".repeat(ONE_SHOT_OUTPUT_PARSE_CAP_BYTES + 1024)}\n${trailingJson}`, + 0, + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(Buffer.byteLength(result.rawOutput)).toBeLessThanOrEqual( + ONE_SHOT_OUTPUT_PARSE_CAP_BYTES, + ); + expect(result.text).toBe("tail-ok"); + expect(result.rawOutput).toContain(trailingJson); + } + }); + it("unparseable output → typed unparseable failure (never silent success)", async () => { const h = newHarness(["droid"]); const result = await runWith(h, "droid", "validator", "not json at all", 0); diff --git a/packages/engine/src/cli-agent/one-shot-session.ts b/packages/engine/src/cli-agent/one-shot-session.ts index 7753d6e8bf..b5a40ee0a6 100644 --- a/packages/engine/src/cli-agent/one-shot-session.ts +++ b/packages/engine/src/cli-agent/one-shot-session.ts @@ -36,6 +36,43 @@ import type { CliSessionManager } from "./session-manager.js"; /** Maximum bytes of output retained for diagnostics on a failed one-shot. */ export const ONE_SHOT_STDERR_CAP_BYTES = 8 * 1024; +/* + * FNXC:CliAgentHeap 2026-06-23-11:46: + * One-shot sessions may run validators/tests that emit large terminal output. The terminal scrollback already gives users a bounded live view, so the result parser must retain only a bounded tail instead of buffering the full PTY stream in V8 heap until process exit. + */ +export const ONE_SHOT_OUTPUT_PARSE_CAP_BYTES = 2 * 1024 * 1024; + +class BoundedOutputCollector { + private chunks: Buffer[] = []; + private size = 0; + + append(chunk: Buffer): void { + if (chunk.byteLength === 0) return; + if (chunk.byteLength >= ONE_SHOT_OUTPUT_PARSE_CAP_BYTES) { + this.chunks = [chunk.subarray(chunk.byteLength - ONE_SHOT_OUTPUT_PARSE_CAP_BYTES)]; + this.size = ONE_SHOT_OUTPUT_PARSE_CAP_BYTES; + return; + } + + this.chunks.push(chunk); + this.size += chunk.byteLength; + while (this.size > ONE_SHOT_OUTPUT_PARSE_CAP_BYTES && this.chunks.length > 0) { + const overflow = this.size - ONE_SHOT_OUTPUT_PARSE_CAP_BYTES; + const head = this.chunks[0]; + if (head.byteLength <= overflow) { + this.chunks.shift(); + this.size -= head.byteLength; + } else { + this.chunks[0] = head.subarray(overflow); + this.size -= overflow; + } + } + } + + toString(): string { + return Buffer.concat(this.chunks, this.size).toString("utf8"); + } +} // ── One-shot launch (non-interactive command builder) ─────────────────────── @@ -277,14 +314,14 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise 0) { - chunks.push(Buffer.from(attachment.scrollback)); + output.append(Buffer.from(attachment.scrollback)); } const drainPromise = (async () => { for await (const bytes of attachment.stream) { - chunks.push(Buffer.from(bytes)); + output.append(Buffer.from(bytes)); } })(); @@ -320,7 +357,7 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise undefined); - const rawOutput = Buffer.concat(chunks).toString("utf8"); + const rawOutput = output.toString(); const boundedTail = boundedStderrTail(rawOutput); if (exit.exitCode !== 0 || timedOut) {