From 3e8fca371bba002e596e1bb75bbb4267386a9b27 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 9 Jul 2026 17:35:40 +0800 Subject: [PATCH] fix(agent-core-v2): cap detached process output --- .../src/agent/task/taskService.ts | 34 ++++++++++--------- .../agent-core-v2/test/task/manager.test.ts | 16 ++++----- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 336c8b1f9..3bc09fd33 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -96,9 +96,8 @@ interface ManagedTask { outputSizeBytes: number; retainedOutputBytes: number; /** - * True once a foreground command has crossed `MAX_TASK_OUTPUT_BYTES` and - * termination has been requested. One-shot guard so the ceiling fires exactly - * once. + * True once a command has crossed `MAX_TASK_OUTPUT_BYTES` and termination has + * been requested. One-shot guard so the ceiling fires exactly once. */ outputLimitTripped: boolean; status: AgentTaskStatus; @@ -126,16 +125,18 @@ interface ManagedTask { const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB /** - * Hard ceiling on the combined output a single foreground shell command may - * stream before it is force-terminated (SIGTERM → grace → SIGKILL). It guards - * the live-forward path from a runaway command (e.g. `b3sum --length `) - * whose output would otherwise grow without bound until Node aborts with an - * out-of-memory crash. Detached process tasks are exempt because their output - * is consumed through the bounded live ring plus `output.log`. + * Hard ceiling on the combined output a single shell command may stream before + * it is force-terminated (SIGTERM → grace → SIGKILL). It guards both the + * live-forward path and the on-disk `output.log` write chain from a runaway + * command (e.g. `b3sum --length `) whose output would otherwise grow + * without bound, filling the disk or retaining each pending-write chunk until + * Node aborts with an out-of-memory crash. Scoped to process tasks (foreground + * and background); subagent and user-question results are appended once and must + * always be persisted, so they are intentionally not capped here. */ const MAX_TASK_OUTPUT_BYTES = 16 * 1024 * 1024; // 16 MiB -/** Terminal `stopReason` recorded when a foreground command trips the output ceiling. */ +/** Terminal `stopReason` recorded when a command trips the output ceiling. */ function outputLimitReason(): string { const mib = Math.floor(MAX_TASK_OUTPUT_BYTES / (1024 * 1024)); return ( @@ -827,14 +828,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { entry.outputSizeBytes += chunkBytes; this.appendRetainedOutput(entry, chunk, chunkBytes); - // Foreground output ceiling: a single non-detached shell command must not - // grow the unbounded live-forward buffer until the process runs out of - // memory. Trip once, then request graceful termination through the shared - // stop path (SIGTERM → grace → SIGKILL). Detached tasks are exempt because - // their output is consumed through the bounded live ring plus output.log. + // Output ceiling: a single shell command must not grow the unbounded + // live-forward buffer or the on-disk write chain until the process runs out + // of memory or fills the disk. Trip once, then request graceful termination + // through the shared stop path (SIGTERM → grace → SIGKILL). Scoped to + // process tasks (foreground and background): subagent and user-question tasks + // append their bounded result in one shot and must always persist it, so they + // are intentionally not capped here. if ( !entry.outputLimitTripped && - !this.isDetached(entry) && entry.task?.kind === 'process' && entry.outputSizeBytes > MAX_TASK_OUTPUT_BYTES ) { diff --git a/packages/agent-core-v2/test/task/manager.test.ts b/packages/agent-core-v2/test/task/manager.test.ts index 95c03ed0b..a10589233 100644 --- a/packages/agent-core-v2/test/task/manager.test.ts +++ b/packages/agent-core-v2/test/task/manager.test.ts @@ -654,7 +654,7 @@ describe('AgentTaskService', () => { expect(forwardedChars).toBeLessThanOrEqual(LIMIT_BYTES); }); - it('does not terminate a detached process task that exceeds the foreground output limit', async () => { + it('also terminates a detached process task that exceeds the output limit', async () => { const { manager } = createAgentTaskService(); const chunks = Array.from({ length: 20 }, () => 'x'.repeat(MiB)); const { proc, killSpy } = streamingProcess(chunks); @@ -666,9 +666,9 @@ describe('AgentTaskService', () => { const info = await waitForTerminal(manager, taskId); - expect(info).toMatchObject({ status: 'completed' }); - expect(info?.stopReason).toBeUndefined(); - expect(killSpy).not.toHaveBeenCalledWith('SIGTERM'); + expect(info).toMatchObject({ status: 'killed' }); + expect(info?.stopReason ?? '').toMatch(/output limit/i); + expect(killSpy).toHaveBeenCalledWith('SIGTERM'); }); it('stops appending persisted foreground output once the output limit trips', async () => { @@ -697,7 +697,7 @@ describe('AgentTaskService', () => { } }); - it('persists detached process output beyond the foreground output limit', async () => { + it('stops appending persisted output once the output limit trips for a detached process task', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-limit-bg-')); try { const { manager } = createAgentTaskService({ sessionDir }); @@ -715,9 +715,9 @@ describe('AgentTaskService', () => { const info = await waitForTerminal(manager, taskId); const output = await manager.getOutputSnapshot(taskId, 1); - expect(info).toMatchObject({ status: 'completed' }); - expect(info?.stopReason).toBeUndefined(); - expect(output.outputSizeBytes).toBe(20 * MiB); + expect(info).toMatchObject({ status: 'killed' }); + expect(info?.stopReason ?? '').toMatch(/output limit/i); + expect(output.outputSizeBytes).toBeLessThanOrEqual(LIMIT_BYTES); } finally { await rm(sessionDir, { recursive: true, force: true }); }