fix(engine): bound one-shot CLI output capture
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user