fix(engine): bound one-shot CLI output capture

This commit is contained in:
gsxdsm
2026-06-23 11:48:38 -07:00
parent fade815c85
commit 61d2a1a609
3 changed files with 64 additions and 5 deletions

View File

@@ -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.

View File

@@ -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);

View File

@@ -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<OneSho
// Attach to collect output (also exercises the read-only terminal stream).
const attachment = manager.attach(sessionId);
const chunks: Buffer[] = [];
const output = new BoundedOutputCollector();
// Replay scrollback captured at attach (usually empty for a fresh spawn).
if (attachment.scrollback.byteLength > 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<OneSho
attachment.detach();
await drainPromise.catch(() => undefined);
const rawOutput = Buffer.concat(chunks).toString("utf8");
const rawOutput = output.toString();
const boundedTail = boundedStderrTail(rawOutput);
if (exit.exitCode !== 0 || timedOut) {