fix(agent-core-v2): cap detached process output

This commit is contained in:
_Kerman 2026-07-09 17:35:40 +08:00
parent 2e2ca0362f
commit 3e8fca371b
2 changed files with 26 additions and 24 deletions

View file

@ -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 <huge>`)
* 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 <huge>`) 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
) {

View file

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