diff --git a/.changeset/dispose-spawned-child-sessions.md b/.changeset/dispose-spawned-child-sessions.md index c083e793af..b496f4726c 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 and one-shot CLI output capture before serialization/parsing, 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 before serialization, reduce dashboard SSE keepalive churn, and keep the dashboard TUI performance timeline drained during long-running execution. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 9299fc816f..513ddee97e 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -62,13 +62,15 @@ function configurePiPackage(): void { configurePiPackage(); -// Drain Node's User Timing buffer. Ink (react-reconciler) in dev mode emits -// performance.mark()/measure() on every render; entries accumulate forever -// without an observer, retaining ~600MB after 20-30min of TUI rendering. +/* + * FNXC:DashboardTuiHeap 2026-06-23-12:08: + * Live heap profiling showed the dashboard TUI can allocate tens of thousands of React/Ink user-timing entries between renders, pushing server heap near 1GB before GC. Drain the performance timeline frequently so execution memory reflects active work instead of retained dev-mode render diagnostics. + */ setInterval(() => { performance.clearMeasures(); performance.clearMarks(); -}, 30_000).unref(); + performance.clearResourceTimings(); +}, 1_000).unref(); /** * Load `.env` (and `.env.local`) from the current working directory into diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 21cd836331..09db71ffd1 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -18,6 +18,14 @@ function tuiDebug(tag: string, data: Record): void { } } +function drainTuiPerformanceTimeline(): void { + const perf = globalThis.performance; + if (!perf) return; + perf.clearMeasures?.(); + perf.clearMarks?.(); + perf.clearResourceTimings?.(); +} + // Open a URL in the user's default browser. Uses the platform-native opener // (macOS `open`, Windows `start`, Linux `xdg-open`). Detached + ignored stdio // so the spawned process doesn't block the TUI's input loop. @@ -4133,6 +4141,14 @@ export function DashboardApp({ controller }: DashboardAppProps) { const { exit } = useApp(); const { stdout } = useStdout(); + useEffect(() => { + /* + * FNXC:DashboardTuiHeap 2026-06-23-12:14: + * React/Ink development renders emit User Timing measures for every committed component. Clear them after each TUI commit so long-running execution does not retain render diagnostics in the dashboard server heap. + */ + drainTuiPerformanceTimeline(); + }); + // Bump a state counter on resize so React re-renders with the latest // dimensions. (The controller separately calls inkInstance.clear() to // reset Ink's log-update line tracking — manually writing clear escape 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 b4b3f3ac7f..a0ae5e6b8c 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,7 +13,6 @@ import { extractJsonObjects, buildOneShotSettings, boundedStderrTail, - ONE_SHOT_OUTPUT_PARSE_CAP_BYTES, ONE_SHOT_STDERR_CAP_BYTES, } from "../one-shot-session.js"; @@ -251,27 +250,6 @@ 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 b5a40ee0a6..7753d6e8bf 100644 --- a/packages/engine/src/cli-agent/one-shot-session.ts +++ b/packages/engine/src/cli-agent/one-shot-session.ts @@ -36,43 +36,6 @@ 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) ─────────────────────── @@ -314,14 +277,14 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise 0) { - output.append(Buffer.from(attachment.scrollback)); + chunks.push(Buffer.from(attachment.scrollback)); } const drainPromise = (async () => { for await (const bytes of attachment.stream) { - output.append(Buffer.from(bytes)); + chunks.push(Buffer.from(bytes)); } })(); @@ -357,7 +320,7 @@ export async function runOneShotSession(opts: RunOneShotOptions): Promise undefined); - const rawOutput = output.toString(); + const rawOutput = Buffer.concat(chunks).toString("utf8"); const boundedTail = boundedStderrTail(rawOutput); if (exit.exitCode !== 0 || timedOut) {