From 22781ae083c665108baa90164b93633e1067554e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 16:56:47 +0800 Subject: [PATCH 01/21] refactor: generalize background tasks --- .../components/dialogs/task-output-viewer.ts | 4 +- .../tui/components/dialogs/tasks-browser.ts | 34 +- .../src/tui/components/messages/tool-call.ts | 6 +- .../tui/controllers/session-event-handler.ts | 12 +- .../src/tui/controllers/session-replay.ts | 10 +- .../src/tui/controllers/streaming-ui.ts | 2 +- .../src/tui/controllers/tasks-browser.ts | 1 + .../src/tui/utils/background-task-status.ts | 21 +- .../kimi-code/src/tui/utils/message-replay.ts | 7 +- .../test/tui/background-task-status.test.ts | 32 +- .../kimi-code/test/tui/message-replay.test.ts | 13 + .../test/tui/task-output-viewer.test.ts | 3 +- apps/kimi-code/test/tui/tasks-browser.test.ts | 3 +- docs/en/reference/tools.md | 4 +- docs/zh/reference/tools.md | 4 +- .../agent-core/src/agent/background/index.ts | 27 +- packages/agent-core/src/agent/tool/index.ts | 3 +- packages/agent-core/src/index.ts | 2 + .../src/tools/background/agent-task.ts | 102 ++++ .../agent-core/src/tools/background/format.ts | 14 + .../agent-core/src/tools/background/index.ts | 4 + .../src/tools/background/manager.ts | 505 ++++++------------ .../src/tools/background/persist.ts | 13 +- .../src/tools/background/process-task.ts | 85 +++ .../src/tools/background/task-list.ts | 21 +- .../src/tools/background/task-output.md | 4 +- .../src/tools/background/task-output.ts | 93 ++-- .../agent-core/src/tools/background/task.ts | 59 ++ .../src/tools/builtin/collaboration/agent.ts | 39 +- .../tools/background/agent-timeout.test.ts | 25 +- .../test/tools/background/lifecycle.test.ts | 15 +- .../test/tools/background/manager.test.ts | 63 +-- .../test/tools/background/task-tools.test.ts | 68 ++- packages/node-sdk/src/types.ts | 2 + 34 files changed, 717 insertions(+), 583 deletions(-) create mode 100644 packages/agent-core/src/tools/background/agent-task.ts create mode 100644 packages/agent-core/src/tools/background/format.ts create mode 100644 packages/agent-core/src/tools/background/process-task.ts create mode 100644 packages/agent-core/src/tools/background/task.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index 0f1fa04b..77bb6e94 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -39,6 +39,7 @@ const STATUS_LABEL: Record = { awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', + timed_out: 'timed out', killed: 'killed', lost: 'lost', }; @@ -52,6 +53,7 @@ function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string case 'completed': return colors.textMuted; case 'failed': + case 'timed_out': case 'killed': case 'lost': return colors.error; @@ -191,7 +193,7 @@ export class TaskOutputViewer extends Container implements Focusable { const segments: string[] = []; if (info !== undefined) { segments.push(chalk.hex(statusColor(colors, info.status))(STATUS_LABEL[info.status])); - if (info.exitCode !== null && info.exitCode !== undefined) { + if (info.kind === 'process' && info.exitCode !== null) { segments.push(chalk.hex(colors.textMuted)(`exit ${String(info.exitCode)}`)); } if (info.description && info.description.length > 0) { diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index 8634e045..d94ac8eb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -57,6 +57,7 @@ const STATUS_LABEL: Record = { awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', + timed_out: 'timed out', killed: 'killed', lost: 'lost', }; @@ -82,6 +83,7 @@ function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string case 'completed': return colors.textMuted; case 'failed': + case 'timed_out': case 'killed': case 'lost': return colors.error; @@ -90,7 +92,11 @@ function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string function isTerminal(status: BackgroundTaskStatus): boolean { return ( - status === 'completed' || status === 'failed' || status === 'killed' || status === 'lost' + status === 'completed' || + status === 'failed' || + status === 'timed_out' || + status === 'killed' || + status === 'lost' ); } @@ -161,6 +167,7 @@ function countByStatus(tasks: readonly BackgroundTaskInfo[]): StatusCounts { counts.completed += 1; break; case 'failed': + case 'timed_out': case 'killed': case 'lost': counts.terminalFailed += 1; @@ -467,7 +474,7 @@ export class TasksBrowserApp extends Container implements Focusable { const pointer = selected ? '> ' : ' '; const pointerStyled = chalk.hex(selected ? colors.primary : colors.textDim)(pointer); - const idColor = selected ? colors.primary : task.taskId.startsWith('agent-') + const idColor = selected ? colors.primary : task.kind === 'agent' ? colors.success : colors.accent; const idText = selected @@ -484,7 +491,9 @@ export class TasksBrowserApp extends Container implements Focusable { if (descBudget < 4) return fitExactly(prefix, innerWidth); const description = - singleLine(task.description) || singleLine(task.command) || '(no description)'; + singleLine(task.description) || + (task.kind === 'process' ? singleLine(task.command) : '') || + '(no description)'; const desc = truncateToWidth(description, descBudget, ELLIPSIS); return fitExactly(`${prefix} ${chalk.hex(colors.text)(desc)}`, innerWidth); } @@ -536,9 +545,15 @@ export class TasksBrowserApp extends Container implements Focusable { `${label('Status:')}${chalk.hex(statusColor(colors, task.status))(STATUS_LABEL[task.status])}`, `${label('Description:')}${value(singleLine(task.description) || '—')}`, ]; - if (task.command && task.command !== task.description) { + if (task.kind === 'process' && task.command && task.command !== task.description) { lines.push(`${label('Command:')}${value(singleLine(task.command))}`); } + if (task.kind === 'agent' && task.agentId !== undefined) { + lines.push(`${label('Agent ID:')}${value(task.agentId)}`); + } + if (task.kind === 'agent' && task.subagentType !== undefined) { + lines.push(`${label('Agent type:')}${value(task.subagentType)}`); + } const timing = task.status === 'running' || task.status === 'awaiting_approval' ? `running ${formatRelativeTime(task.startedAt)}` @@ -546,15 +561,14 @@ export class TasksBrowserApp extends Container implements Focusable { ? `finished ${formatRelativeTime(task.endedAt)}` : ''; if (timing.length > 0) lines.push(`${label('Time:')}${chalk.hex(colors.textMuted)(timing)}`); - if (task.pid > 0) lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); - if (task.exitCode !== null && task.exitCode !== undefined) { + if (task.kind === 'process' && task.pid > 0) { + lines.push(`${label('Pid:')}${chalk.hex(colors.textMuted)(String(task.pid))}`); + } + if (task.kind === 'process' && task.exitCode !== null) { lines.push(`${label('Exit code:')}${chalk.hex(colors.textMuted)(String(task.exitCode))}`); } if (task.stopReason !== undefined && task.stopReason.length > 0) { - lines.push(`${label('Stop reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); - } - if (task.timedOut === true) { - lines.push(`${label('Timed out:')}${chalk.hex(colors.warning)('yes')}`); + lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); } if (task.approvalReason !== undefined && task.approvalReason.length > 0) { lines.push( diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index 04992223..0fd4a53f 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -97,13 +97,15 @@ export interface ToolCallReadSnapshot { } function backgroundFailureMessage( - status: 'completed' | 'failed' | 'killed' | 'lost' | undefined, + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost' | undefined, ): string | undefined { switch (status) { case 'lost': return 'Background agent lost (session restarted before completion)'; case 'killed': return 'Background agent killed'; + case 'timed_out': + return 'Background agent timed out'; case 'failed': return 'Background agent failed'; case 'completed': @@ -991,7 +993,7 @@ export class ToolCallComponent extends Container { * reclassifies a previously-running task as `lost`). */ setBackgroundTaskTerminalStatus( - status: 'completed' | 'failed' | 'killed' | 'lost', + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost', options: { errorText?: string | undefined } = {}, ): void { const phase: 'done' | 'failed' = status === 'completed' ? 'done' : 'failed'; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 27b13a13..3df4644f 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -828,7 +828,7 @@ export class SessionEventHandler { if (description === undefined) return undefined; let match: string | undefined; for (const info of this.backgroundTasks.values()) { - if (!info.taskId.startsWith('agent-')) continue; + if (info.kind !== 'agent') continue; if (info.description !== description) continue; if (match !== undefined) return undefined; match = info.taskId; @@ -889,11 +889,12 @@ export class SessionEventHandler { const isTerminal = info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost'; if (event.type === 'background.task.started') { - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { this.syncBackgroundTaskBadge(); this.host.tasksBrowserController.repaint(); return; @@ -905,7 +906,7 @@ export class SessionEventHandler { } if (event.type === 'background.task.terminated' && isTerminal) { - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { // The Agent tool's spawn-success ToolResult is not an error, so the // parent toolCall card would otherwise render `✓ Completed` for any // terminated bg agent — including `lost` / `failed` / `killed`. @@ -917,7 +918,7 @@ export class SessionEventHandler { }); } if (!this.backgroundTaskTranscriptedTerminal.has(info.taskId)) { - if (info.taskId.startsWith('bash-')) { + if (info.kind === 'process') { this.appendBackgroundTaskEntry(info); } this.backgroundTaskTranscriptedTerminal.add(info.taskId); @@ -955,12 +956,13 @@ export class SessionEventHandler { if ( info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost' ) { continue; } - if (info.taskId.startsWith('agent-')) { + if (info.kind === 'agent') { agentTasks += 1; } else { bashTasks += 1; diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 27b76ceb..712b9432 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -116,12 +116,13 @@ export class SessionReplayRenderer { */ private applyTerminalBackgroundAgentStatuses(agent: ResumedAgentState): void { for (const info of agent.background) { - if (!info.taskId.startsWith('agent-')) continue; + if (info.kind !== 'agent') continue; if (!isTerminalBackgroundTask(info)) continue; const status = info.status; if ( status !== 'completed' && status !== 'failed' && + status !== 'timed_out' && status !== 'killed' && status !== 'lost' ) { @@ -496,7 +497,7 @@ export class SessionReplayRenderer { ): void { const { sessionEventHandler } = this.host; const task = sessionEventHandler.backgroundTasks.get(origin.taskId); - if (task !== undefined && task.taskId.startsWith('bash-')) { + if (task !== undefined && task.kind === 'process') { const status = formatBackgroundTaskTranscript({ ...task, status: origin.status }); this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', status.headline, 'plain'), @@ -526,6 +527,11 @@ export class SessionReplayRenderer { ...status, headline: status.headline.replace(' failed in background', ' stopped'), }; + } else if (origin.status === 'timed_out') { + status = { + ...status, + headline: status.headline.replace(' failed in background', ' timed out'), + }; } this.host.appendTranscriptEntry({ ...replayEntry(context, 'status', status.headline, 'plain'), diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index cb0a3321..095fe2b6 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -211,7 +211,7 @@ export class StreamingUIController { applyBackgroundTaskTerminalStatus(args: { agentId?: string | undefined; description: string; - status: 'completed' | 'failed' | 'killed' | 'lost'; + status: 'completed' | 'failed' | 'timed_out' | 'killed' | 'lost'; /** * Real failure message to surface on the card. Pass the `subagent.failed` * event's `error` for live crashes — it is far more useful than the diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 56f1cecc..1d00f622 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -188,6 +188,7 @@ export class TasksBrowserController { (t) => t.status !== 'completed' && t.status !== 'failed' && + t.status !== 'timed_out' && t.status !== 'killed' && t.status !== 'lost', ); diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts index def7a39c..4ff76bd5 100644 --- a/apps/kimi-code/src/tui/utils/background-task-status.ts +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -2,8 +2,8 @@ * Format a `BackgroundTaskInfo` snapshot into the transcript card data * consumed by `BackgroundAgentStatusComponent`. * - * Background tasks have six statuses (running / awaiting_approval / - * completed / failed / killed / lost) but the transcript card only + * Background tasks have several statuses (running / awaiting_approval / + * completed / failed / timed_out / killed / lost) but the transcript card only * renders three visual phases (started / completed / failed). The * mapping packs the extra nuance — exit code, kill reason, lost-reason * — into the dim detail line so the user still sees it. @@ -33,18 +33,19 @@ function phaseFromStatus(status: BackgroundTaskStatus): BackgroundAgentStatusPha case 'completed': return 'completed'; case 'failed': + case 'timed_out': case 'killed': case 'lost': return 'failed'; } } -function subjectFor(taskId: string): string { - return taskId.startsWith('agent-') ? 'agent task' : 'bash task'; +function subjectFor(info: BackgroundTaskInfo): string { + return info.kind === 'agent' ? 'agent task' : 'bash task'; } function headlineFor(info: BackgroundTaskInfo): string { - const subject = subjectFor(info.taskId); + const subject = subjectFor(info); switch (info.status) { case 'running': return `${subject} started in background`; @@ -54,6 +55,8 @@ function headlineFor(info: BackgroundTaskInfo): string { return `${subject} completed in background`; case 'failed': return `${subject} failed in background`; + case 'timed_out': + return `${subject} timed out`; case 'killed': return `${subject} stopped`; case 'lost': @@ -67,7 +70,7 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { if (description !== undefined) parts.push(description); if (info.status === 'completed' || info.status === 'failed') { - if (info.exitCode !== null && info.exitCode !== undefined) { + if (info.kind === 'process' && info.exitCode !== null) { parts.push(`exit ${info.exitCode}`); } } @@ -75,6 +78,11 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { const reason = truncate(info.stopReason); parts.push(reason !== undefined ? `stopped — ${reason}` : 'stopped'); } + if (info.status === 'failed') { + const reason = truncate(info.stopReason); + if (reason !== undefined) parts.push(reason); + } + if (info.status === 'timed_out') parts.push('timed out'); if (info.status === 'awaiting_approval') { const reason = truncate(info.approvalReason); if (reason !== undefined) parts.push(`awaiting: ${reason}`); @@ -82,7 +90,6 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { if (info.status === 'lost') { parts.push('session restarted before completion'); } - if (info.timedOut === true) parts.push('timed out'); return parts.length > 0 ? parts.join(' · ') : undefined; } diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 8b83186d..9dc00ff5 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -62,6 +62,7 @@ export function isTerminalBackgroundTask(info: BackgroundTaskInfo): boolean { return ( info.status === 'completed' || info.status === 'failed' || + info.status === 'timed_out' || info.status === 'killed' || info.status === 'lost' ); @@ -75,7 +76,7 @@ export function countActiveBackgroundTasks(tasks: ReadonlyMap(); for (const info of background) { - if (!info.taskId.startsWith('agent-')) continue; + if (info.kind !== 'agent') continue; if (isTerminalBackgroundTask(info)) continue; backgroundAgentMetadata.set(info.taskId, { - agentId: info.taskId, + agentId: info.agentId ?? info.taskId, parentToolCallId: info.taskId, description: info.description, }); diff --git a/apps/kimi-code/test/tui/background-task-status.test.ts b/apps/kimi-code/test/tui/background-task-status.test.ts index e8033d45..8ed114ab 100644 --- a/apps/kimi-code/test/tui/background-task-status.test.ts +++ b/apps/kimi-code/test/tui/background-task-status.test.ts @@ -4,17 +4,34 @@ import { describe, expect, it } from 'vitest'; import { formatBackgroundTaskTranscript } from '@/tui/utils/background-task-status'; function task(overrides: Partial = {}): BackgroundTaskInfo { - return { - taskId: 'bash-abcd1234', - command: 'npm run dev', + const taskId = overrides.taskId ?? 'bash-abcd1234'; + const kind = overrides.kind ?? (taskId.startsWith('agent-') ? 'agent' : 'process'); + const base = { + taskId, + kind, description: 'dev server', status: 'running', - pid: 1234, - exitCode: null, startedAt: Date.now() - 1000, endedAt: null, ...overrides, }; + if (kind === 'agent') { + return { + ...base, + kind: 'agent', + agentId: 'agent-child', + subagentType: 'coder', + ...overrides, + } as BackgroundTaskInfo; + } + return { + ...base, + kind: 'process', + command: 'npm run dev', + pid: 1234, + exitCode: null, + ...overrides, + } as BackgroundTaskInfo; } describe('formatBackgroundTaskTranscript', () => { @@ -75,13 +92,12 @@ describe('formatBackgroundTaskTranscript', () => { expect(data.detail).toContain('needs network'); }); - it('surfaces timedOut for agent deadlines', () => { + it('surfaces timeout stop reason for agent deadlines', () => { const data = formatBackgroundTaskTranscript( task({ taskId: 'agent-aaaaaaaa', status: 'failed', - exitCode: 1, - timedOut: true, + stopReason: 'Timed out', endedAt: Date.now(), }), ); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index a63a9790..6ba52821 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -203,8 +203,21 @@ function backgroundTask( description: string, status: BackgroundTaskInfo['status'] = 'running', ): BackgroundTaskInfo { + if (taskId.startsWith('agent-')) { + return { + taskId, + kind: 'agent', + agentId: taskId, + subagentType: 'coder', + description, + status, + startedAt: 1, + endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + }; + } return { taskId, + kind: 'process', command: `[agent] ${description}`, description, status, diff --git a/apps/kimi-code/test/tui/task-output-viewer.test.ts b/apps/kimi-code/test/tui/task-output-viewer.test.ts index 2cb998b4..8c487936 100644 --- a/apps/kimi-code/test/tui/task-output-viewer.test.ts +++ b/apps/kimi-code/test/tui/task-output-viewer.test.ts @@ -39,6 +39,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { function info(overrides: Partial = {}): BackgroundTaskInfo { return { taskId: 'bash-aaaaaaaa', + kind: 'process', command: 'npm run dev', description: 'dev server', status: 'running', @@ -47,7 +48,7 @@ function info(overrides: Partial = {}): BackgroundTaskInfo { startedAt: Date.now() - 60_000, endedAt: null, ...overrides, - }; + } as BackgroundTaskInfo; } function makeViewer(opts: { diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index fd973c8f..8513e2bd 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -44,6 +44,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { function task(overrides: Partial = {}): BackgroundTaskInfo { return { taskId: 'bash-abcd1234', + kind: 'process', command: 'npm run dev', description: 'dev server', status: 'running', @@ -52,7 +53,7 @@ function task(overrides: Partial = {}): BackgroundTaskInfo { startedAt: Date.now() - 60_000, endedAt: null, ...overrides, - }; + } as BackgroundTaskInfo; } function makeProps(overrides: Partial = {}): TasksBrowserProps { diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index fc5cc759..7515a285 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -97,9 +97,9 @@ Background task tools manage background tasks started via `Bash` or `Agent`. Whe | `TaskOutput` | Auto-approved | View the output of a background task | | `TaskStop` | Requires approval | Stop a running background task | -**`TaskList`** returns a list of background tasks; each record includes the task ID, status, command, description, and PID. Optional parameters: `active_only` (default true, lists only running tasks) and `limit` (maximum number of entries to return, default 20, range 1–100). Tasks that have reached a terminal state also include `exit_code`; tasks explicitly terminated by `TaskStop` additionally include `reason`. +**`TaskList`** returns a list of background tasks by serializing each task's info record as snake_case fields, omitting null and undefined fields. Common fields include `task_id`, task `kind`, status, description, and timestamps. Process tasks additionally include `command` and `pid`, and include `exit_code` when an exit code is known; background subagent tasks additionally include `agent_id` and `subagent_type` when available. Optional parameters: `active_only` (default true, lists only running tasks) and `limit` (maximum number of entries to return, default 20, range 1–100). Tasks explicitly terminated by `TaskStop` additionally include `stop_reason`. -**`TaskOutput`** returns the status and output of a specified task by `task_id`. The inline preview includes at most the most recent 32 KB of content; the full log is saved on disk, and the tool also returns `output_path` with a prompt to paginate it via `Read` (around 300 lines per page is recommended). Optional `block` (default false) and `timeout` (seconds to wait, default 30, range 0–3600) parameters can be used to wait for the task to finish before returning. In the response, `retrieval_status` is one of `success` / `timeout` / `not_ready`; tasks aborted by an external deadline timeout additionally include `timed_out: true` and `terminal_reason: timed_out`, and tasks explicitly terminated by `TaskStop` additionally include `stop_reason` and `terminal_reason: stopped`. +**`TaskOutput`** returns the status and output of a specified task by `task_id`. For process tasks, output is command stdout/stderr; for background subagent tasks, output is the subagent's final summary once it completes. The inline preview includes at most the most recent 32 KB of content; the full log is saved on disk, and the tool also returns `output_path` with a prompt to paginate it via `Read` (around 300 lines per page is recommended). Optional `block` (default false) and `timeout` (seconds to wait, default 30, range 0–3600) parameters can be used to wait for the task to finish before returning. In the response, `retrieval_status` is one of `success` / `timeout` / `not_ready`; tasks aborted by an external deadline timeout return `status: timed_out` and `terminal_reason: timed_out`, and tasks explicitly terminated by `TaskStop` additionally include `stop_reason` and `terminal_reason: stopped`. **`TaskStop`** accepts `task_id` and an optional `reason` (reason for stopping, default `Stopped by TaskStop`). It is safe to call on a task that is already in a terminal state — it returns the current status without error. diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index 4d14b657..faa58bb4 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -97,9 +97,9 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 工 | `TaskOutput` | 自动放行 | 查看后台任务的输出 | | `TaskStop` | 需审批 | 停止正在运行的后台任务 | -**`TaskList`** 返回后台任务列表,每条记录包含任务 ID、状态、命令、描述和 PID。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(最多返回条数,默认 20,取值范围 1–100)。已进入终止状态的任务还会附带 `exit_code`,被 `TaskStop` 显式终止的任务会附带 `reason`。 +**`TaskList`** 返回后台任务列表,输出来自每个任务 info 记录的直接序列化,字段名转为 snake_case,并省略 null 与 undefined 字段。通用字段包含 `task_id`、任务 `kind`、状态、描述和时间戳。进程任务还会包含 `command` 和 `pid`,并在已知退出码时包含 `exit_code`;后台子 Agent 任务在可用时还会包含 `agent_id` 和 `subagent_type`。可选参数 `active_only`(默认 true,仅列出运行中的任务)和 `limit`(最多返回条数,默认 20,取值范围 1–100)。被 `TaskStop` 显式终止的任务会附带 `stop_reason`。 -**`TaskOutput`** 根据 `task_id` 返回指定任务的状态与输出。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取(建议每页约 300 行)。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。返回结构中 `retrieval_status` 取 `success` / `timeout` / `not_ready`;任务因超时被外部 deadline 中止时会附带 `timed_out: true` 与 `terminal_reason: timed_out`,被 `TaskStop` 显式终止时会附带 `stop_reason` 与 `terminal_reason: stopped`。 +**`TaskOutput`** 根据 `task_id` 返回指定任务的状态与输出。对进程任务,输出是命令的 stdout/stderr;对后台子 Agent 任务,输出是子 Agent 完成后的最终摘要。内联预览最多包含最近 32 KB 的内容;完整日志保存在磁盘上,工具会一并返回 `output_path` 并提示通过 `Read` 分页读取(建议每页约 300 行)。可选 `block`(默认 false)和 `timeout`(等待秒数,默认 30,取值范围 0–3600)参数可用于等待任务完成后再返回。返回结构中 `retrieval_status` 取 `success` / `timeout` / `not_ready`;任务因超时被外部 deadline 中止时返回 `status: timed_out` 与 `terminal_reason: timed_out`,被 `TaskStop` 显式终止时会附带 `stop_reason` 与 `terminal_reason: stopped`。 **`TaskStop`** 接受 `task_id` 和可选的 `reason`(停止原因,默认 `Stopped by TaskStop`)。对已处于终止状态的任务也能安全调用,会直接返回当前状态而不报错。 diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 190e3b72..98faf600 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -52,7 +52,7 @@ export class BackgroundManager extends BackgroundProcessManager { case 'started': this.agent.emitEvent({ type: 'background.task.started', info }); this.agent.telemetry.track('background_task_created', { - kind: info.taskId.startsWith('agent-') ? 'agent' : 'bash', + kind: info.kind === 'agent' ? 'agent' : 'bash', }); return; case 'updated': @@ -64,13 +64,13 @@ export class BackgroundManager extends BackgroundProcessManager { const duration_s = info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; const properties: Record = { - kind: info.taskId.startsWith('agent-') ? 'agent' : 'bash', + kind: info.kind === 'agent' ? 'agent' : 'bash', success, duration_s, }; if (!success) { properties['reason'] = - info.timedOut === true + info.status === 'timed_out' ? 'timeout' : info.status === 'killed' ? 'killed' @@ -132,7 +132,7 @@ export class BackgroundManager extends BackgroundProcessManager { const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) .preview; if (this.hasDeliveredNotification(origin)) return; - const isAgentTask = info.taskId.startsWith('agent-'); + const isAgentTask = info.kind === 'agent'; const label = isAgentTask ? 'agent' : 'task'; const notification: BackgroundTaskNotification = { id: notificationId, @@ -140,10 +140,10 @@ export class BackgroundManager extends BackgroundProcessManager { type: `task.${info.status}`, source_kind: 'background_task', source_id: info.taskId, - agent_id: isAgentTask ? info.agentId : undefined, + agent_id: info.kind === 'agent' ? info.agentId : undefined, title: `Background ${label} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', - body: buildBackgroundTaskNotificationBody(info, isAgentTask), + body: buildBackgroundTaskNotificationBody(info), tail_output: tailOutput, }; const content = [ @@ -210,16 +210,17 @@ function notificationKey(origin: BackgroundTaskOrigin): string { * sessions that pre-date `agent_id` persistence keep the original * single-sentence body. */ -function buildBackgroundTaskNotificationBody( - info: BackgroundTaskInfo, - isAgentTask: boolean, -): string { +function buildBackgroundTaskNotificationBody(info: BackgroundTaskInfo): string { const baseLine = - info.status === 'killed' && info.stopReason - ? `${info.description} was killed: ${info.stopReason}.` + info.status === 'timed_out' + ? `${info.description} timed out.` + : info.stopReason + ? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${ + info.stopReason + }.` : `${info.description} ${info.status}.`; - if (!isAgentTask) return baseLine; + if (info.kind !== 'agent') return baseLine; if (info.status === 'completed') return baseLine; const agentId = info.agentId; if (agentId === undefined || agentId === info.taskId) return baseLine; diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index 550cfeba..f95de837 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -386,10 +386,9 @@ export class ToolManager { this.agent.subagentHost && new b.AgentTool( this.agent.subagentHost, - background, + allowBackground ? background : undefined, DEFAULT_AGENT_PROFILES['agent']?.subagents, { - allowBackground, log: this.agent.log, }, ), diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index ab5c3504..a44241e2 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -36,9 +36,11 @@ export type { } from './agent/context'; export type { BackgroundLifecycleEvent, + AgentBackgroundTaskInfo, BackgroundTaskInfo, BackgroundTaskKind, BackgroundTaskStatus, + ProcessBackgroundTaskInfo, } from './tools/background/manager'; export type { ToolServices } from './tools/support/services'; export { SingleModelProvider } from './session/provider-manager'; diff --git a/packages/agent-core/src/tools/background/agent-task.ts b/packages/agent-core/src/tools/background/agent-task.ts new file mode 100644 index 00000000..efad2927 --- /dev/null +++ b/packages/agent-core/src/tools/background/agent-task.ts @@ -0,0 +1,102 @@ +import { isAbortError } from '../../loop/errors'; +import { + type BackgroundTask, + type BackgroundTaskInfoBase, + type BackgroundTaskSink, +} from './task'; + +export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'agent'; + /** Subagent identifier accepted by Agent(resume=...). */ + readonly agentId?: string; + /** Subagent profile name. */ + readonly subagentType?: string; +} + +export interface AgentBackgroundTaskOptions { + readonly timeoutMs?: number; + readonly abort?: () => void; + readonly agentId?: string; + readonly subagentType?: string; +} + +export class AgentBackgroundTask implements BackgroundTask { + readonly kind = 'agent' as const; + readonly idPrefix: string = 'agent'; + readonly timeoutMs?: number; + readonly agentId?: string; + readonly subagentType?: string; + private readonly abort?: () => void; + + constructor( + private readonly completion: Promise<{ result: string }>, + readonly description: string, + options: AgentBackgroundTaskOptions = {}, + ) { + this.timeoutMs = options.timeoutMs; + this.abort = options.abort; + this.agentId = options.agentId; + this.subagentType = options.subagentType; + } + + async start(sink: BackgroundTaskSink): Promise { + const requestAbort = (): void => { + this.abort?.(); + }; + if (sink.signal.aborted) { + requestAbort(); + } else { + sink.signal.addEventListener('abort', requestAbort, { once: true }); + } + + const deadlineTimeout = Symbol('background-agent-deadline'); + const raceInputs: Array | Promise> = [ + this.completion, + ]; + const timeoutMs = this.timeoutMs; + let deadlineTimer: ReturnType | undefined; + + if (timeoutMs !== undefined && timeoutMs > 0) { + raceInputs.push( + new Promise((resolve) => { + deadlineTimer = setTimeout(() => { + resolve(deadlineTimeout); + }, timeoutMs); + }), + ); + } + + try { + const outcome = await Promise.race(raceInputs); + if (outcome === deadlineTimeout) { + this.abort?.(); + await sink.settle({ status: 'timed_out' }); + return; + } + sink.appendOutput(outcome.result); + await sink.settle({ status: 'completed' }); + } catch (error: unknown) { + if (sink.signal.aborted && isAbortError(error)) { + await sink.settle({ status: 'killed' }); + return; + } + if (error instanceof Error && error.name === 'RunCancelled') { + await sink.settle({ status: 'killed' }); + return; + } + await sink.settle({ status: 'failed' }); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + sink.signal.removeEventListener('abort', requestAbort); + } + } + + toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo { + return { + ...base, + kind: 'agent', + agentId: this.agentId, + subagentType: this.subagentType, + }; + } +} diff --git a/packages/agent-core/src/tools/background/format.ts b/packages/agent-core/src/tools/background/format.ts new file mode 100644 index 00000000..dcb6b5c5 --- /dev/null +++ b/packages/agent-core/src/tools/background/format.ts @@ -0,0 +1,14 @@ +function formatValue(value: unknown): string { + return typeof value === 'string' ? value : String(value); +} + +function fieldName(key: string): string { + return key.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); +} + +export function formatPlainObject(record: object): string { + return Object.entries(record) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key, value]) => `${fieldName(key)}: ${formatValue(value)}`) + .join('\n'); +} diff --git a/packages/agent-core/src/tools/background/index.ts b/packages/agent-core/src/tools/background/index.ts index 1f924b35..d305cef3 100644 --- a/packages/agent-core/src/tools/background/index.ts +++ b/packages/agent-core/src/tools/background/index.ts @@ -4,12 +4,16 @@ export { BackgroundProcessManager, generateTaskId } from './manager'; export type { + AgentBackgroundTaskInfo, BackgroundTaskInfo, BackgroundTaskKind, BackgroundTaskOutputSnapshot, BackgroundTaskStatus, + ProcessBackgroundTaskInfo, ReconcileResult, } from './manager'; +export { AgentBackgroundTask } from './agent-task'; +export { ProcessBackgroundTask } from './process-task'; export { VALID_TASK_ID } from './persist'; export { TaskListTool, TaskListInputSchema } from './task-list'; export type { TaskListInput } from './task-list'; diff --git a/packages/agent-core/src/tools/background/manager.ts b/packages/agent-core/src/tools/background/manager.ts index c87cbe66..e19d813c 100644 --- a/packages/agent-core/src/tools/background/manager.ts +++ b/packages/agent-core/src/tools/background/manager.ts @@ -1,8 +1,7 @@ /** - * BackgroundProcessManager — manages background shell processes. + * BackgroundProcessManager — manages background tasks. * - * Tracks background bash tasks spawned by `BashTool` when - * `run_in_background=true`. + * Tracks background bash tasks and background subagent tasks. * * Each task gets a unique ID, captures stdout+stderr to a ring buffer, * and supports status query / output retrieval / stop operations. @@ -16,7 +15,6 @@ import { randomBytes } from 'node:crypto'; import type { KaosProcess } from '@moonshot-ai/kaos'; -import { isAbortError } from '../../loop/errors'; import { appendTaskOutput, listTasks, @@ -30,6 +28,15 @@ import { writeTask, type PersistedTask, } from './persist'; +import { ProcessBackgroundTask } from './process-task'; +import { + TERMINAL_BACKGROUND_TASK_STATUSES, + type BackgroundTask, + type BackgroundTaskInfo, + type BackgroundTaskInfoBase, + type BackgroundTaskSink, + type BackgroundTaskStatus, +} from './task'; // ── Types ──────────────────────────────────────────────────────────── @@ -46,74 +53,30 @@ import { * preserved because `awaiting_approval` in BPM does not leak permission * vocabulary into the loop. */ -export type BackgroundTaskStatus = - | 'running' - | 'awaiting_approval' - | 'completed' - | 'failed' - | 'killed' - | 'lost'; - /** Terminal states tasks never leave once reached. */ -const TERMINAL_STATUSES: ReadonlySet = new Set([ - 'completed', - 'failed', - 'killed', - 'lost', -]); +const TERMINAL_STATUSES = TERMINAL_BACKGROUND_TASK_STATUSES; export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { return TERMINAL_STATUSES.has(status); } -/** Task kinds with distinct id prefixes. */ -export type BackgroundTaskKind = 'bash' | 'agent'; +export type { AgentBackgroundTaskInfo } from './agent-task'; +export type { ProcessBackgroundTaskInfo } from './process-task'; +export type { + BackgroundTaskInfo, + BackgroundTaskKind, BackgroundTaskStatus +} from './task'; /** Lifecycle phases observed by `onLifecycle` subscribers. */ export type BackgroundLifecycleEvent = 'started' | 'updated' | 'terminated'; -export interface BackgroundTaskInfo { - readonly taskId: string; - readonly command: string; - readonly description: string; - readonly status: BackgroundTaskStatus; - readonly pid: number; - readonly exitCode: number | null; - readonly startedAt: number; - readonly endedAt: number | null; - /** Populated only while `status === 'awaiting_approval'`. */ - readonly approvalReason?: string | undefined; - /** True when an agent task was aborted by its deadline. */ - readonly timedOut?: boolean | undefined; - /** Reason recorded when a task is explicitly stopped. */ - readonly stopReason?: string | undefined; - /** - * Deadline (ms) supplied to `registerAgentTask`. Surfaced so shutdown - * wait-caps and UI can read the originally-requested timeout without - * round-tripping the call site. `undefined` means no deadline. - */ - readonly timeoutMs?: number | undefined; - /** Identifier of the spawned subagent (agent tasks only). */ - readonly agentId?: string | undefined; - /** Profile name of the spawned subagent (agent tasks only). */ - readonly subagentType?: string | undefined; - /** - * Human-readable reason recorded when a non-terminal task is reclassified - * via reconcile (e.g. a stale heartbeat → lost). - */ - readonly failureReason?: string | undefined; -} - -interface ManagedProcess { +interface ManagedTask { readonly taskId: string; - readonly command: string; - readonly description: string; - readonly proc: KaosProcess; + readonly task: BackgroundTask; readonly outputChunks: string[]; /** Total UTF-8 bytes observed, including chunks dropped from the live ring buffer. */ outputSizeBytes: number; status: BackgroundTaskStatus; - exitCode: number | null; readonly startedAt: number; endedAt: number | null; /** Listeners awaiting task completion. */ @@ -122,20 +85,14 @@ interface ManagedProcess { terminalFired: boolean; /** Reason carried while awaiting approval. */ approvalReason?: string | undefined; - /** Set when a deadline fires before natural completion. */ - timedOut?: boolean | undefined; - /** Reason recorded when a task is explicitly stopped. */ + /** Reason recorded when a task is explicitly stopped or aborted. */ stopReason?: string | undefined; /** Deadline supplied at registration; surfaced via task info. */ timeoutMs?: number | undefined; - /** Subagent identifier (agent tasks only). */ - agentId?: string | undefined; - /** Subagent profile name (agent tasks only). */ - subagentType?: string | undefined; /** Non-terminal-reclassification reason (e.g. stale heartbeat). */ failureReason?: string | undefined; - /** True after stop() has requested cancellation but before terminal status is chosen. */ - stopRequested: boolean; + /** Cancellation signal owned by the manager and observed by the concrete task. */ + readonly abortController: AbortController; /** Session dir captured at registration for output.log writes. */ readonly outputSessionDir?: string | undefined; lifecyclePromise: Promise; @@ -168,7 +125,7 @@ const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; * over an 8-char suffix yields ~36^8 ≈ 2.8e12 distinct ids which is * more than enough uniqueness for per-session task ids. */ -export function generateTaskId(kind: BackgroundTaskKind): string { +export function generateTaskId(kind: string): string { const bytes = randomBytes(8); let suffix = ''; for (let i = 0; i < 8; i++) { @@ -220,7 +177,7 @@ function emptyOutputSnapshot(): BackgroundTaskOutputSnapshot { // ── Manager ────────────────────────────────────────────────────────── export class BackgroundProcessManager { - private readonly processes = new Map(); + private readonly tasks = new Map(); private reservedTaskSlots = 0; /** * Ghosts: tasks loaded from disk during reconcile that have no live @@ -233,7 +190,7 @@ export class BackgroundProcessManager { /** * Registered terminal-state callbacks. Fired once per task when the - * task reaches a terminal state (completed / failed / killed). + * task reaches a terminal state (completed / failed / timed_out / killed). */ private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = []; @@ -302,7 +259,7 @@ export class BackgroundProcessManager { * exit cannot yield duplicate notifications. This is the manager-side * half of the dedupe pact with `NotificationManager.dedupe_key`. */ - private fireTerminalCallbacks(entry: ManagedProcess): void { + private fireTerminalCallbacks(entry: ManagedTask): void { if (entry.terminalFired) return; entry.terminalFired = true; const info = this.toInfo(entry); @@ -331,11 +288,21 @@ export class BackgroundProcessManager { this.fireLifecycle('terminated', info); } - private resolveWaiters(entry: ManagedProcess): void { + private resolveWaiters(entry: ManagedTask): void { const waiters = entry.waiters.splice(0); for (const resolve of waiters) resolve(); } + private createTaskSink(entry: ManagedTask): BackgroundTaskSink { + return { + signal: entry.abortController.signal, + appendOutput: (chunk) => { + this.appendOutput(entry, chunk); + }, + settle: (settlement) => this.settleTask(entry, settlement), + }; + } + assertCanRegister(): void { const maxRunningTasks = this.options.maxRunningTasks; if (maxRunningTasks === undefined) return; @@ -362,7 +329,7 @@ export class BackgroundProcessManager { private activeTaskCount(): number { let count = 0; - for (const entry of this.processes.values()) { + for (const entry of this.tasks.values()) { if (!TERMINAL_STATUSES.has(entry.status)) count++; } return count; @@ -374,9 +341,9 @@ export class BackgroundProcessManager { * Returns the assigned task ID. * * `opts.kind` picks the id prefix. Defaults to `'bash'` because bash - * subprocess registration is the only caller on the process path - * today; agent tasks go through `registerAgentTask` which forces - * `'agent'`. + * subprocess registration is the only caller on the process path today. + * Agent tasks are constructed by their caller and registered through + * `registerTask`. */ register( proc: KaosProcess, @@ -384,7 +351,7 @@ export class BackgroundProcessManager { description: string, opts: | { - kind?: BackgroundTaskKind; + kind?: string; /** * Optional shell metadata. Carried so the `/task` UI and the * background persist snapshot can surface which dialect a @@ -400,55 +367,58 @@ export class BackgroundProcessManager { } | undefined = undefined, ): string { - if (opts?.reservation) { - opts.reservation.release(); + return this.registerTask( + new ProcessBackgroundTask(proc, command, description, { idPrefix: opts?.kind }), + opts?.reservation, + ); + } + + registerTask( + task: BackgroundTask, + reservation?: BackgroundTaskReservation, + ): string { + if (reservation) { + reservation.release(); } else { this.assertCanRegister(); } - const kind = opts?.kind; - const taskId = generateTaskId(kind ?? 'bash'); - const entry: ManagedProcess = { + const taskId = generateTaskId(task.idPrefix); + const entry: ManagedTask = { taskId, - command, - description, - proc, + task, outputChunks: [], outputSizeBytes: 0, status: 'running', - exitCode: null, startedAt: Date.now(), endedAt: null, waiters: [], terminalFired: false, - stopRequested: false, + abortController: new AbortController(), + timeoutMs: task.timeoutMs, outputSessionDir: this.sessionDir, lifecyclePromise: Promise.resolve(), persistWriteQueue: Promise.resolve(), outputWriteQueue: Promise.resolve(), }; - this.processes.set(taskId, entry); + this.tasks.set(taskId, entry); - // Capture stdout + stderr into the ring buffer. - for (const stream of [proc.stdout, proc.stderr]) { - stream.setEncoding('utf8'); - stream.on('data', (chunk: string) => { - this.appendOutput(entry, chunk); + const sink = this.createTaskSink(entry); + try { + entry.lifecyclePromise = Promise.resolve(task.start(sink)).catch(async () => { + await this.settleTask(entry, { + status: entry.abortController.signal.aborted ? 'killed' : 'failed', + }); }); + } catch { + entry.lifecyclePromise = this.settleTask(entry, { + status: entry.abortController.signal.aborted ? 'killed' : 'failed', + }).then(() => {}); } // Initial persistence (snapshot at start). void this.persistLive(entry); this.fireLifecycle('started', this.toInfo(entry)); - // Monitor lifecycle via wait() — no EventEmitter dependency. - entry.lifecyclePromise = proc - .wait() - .then((exitCode) => this.settleProcessExit(entry, exitCode)) - .catch(async (_err: unknown) => { - // When `proc.wait()` rejects (launch failed / stream error), - // still drive the task through the same terminal finalizer. - await this.finalizeTerminal(entry, entry.stopRequested ? 'killed' : 'failed', null); - }); void entry.lifecyclePromise; return taskId; @@ -456,7 +426,7 @@ export class BackgroundProcessManager { /** Get info about a specific task. Falls back to reconcile ghosts. */ getTask(taskId: string): BackgroundTaskInfo | undefined { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (entry !== undefined) { return this.toInfo(entry); } @@ -487,7 +457,7 @@ export class BackgroundProcessManager { */ list(activeOnly = true, limit?: number): BackgroundTaskInfo[] { const result: BackgroundTaskInfo[] = []; - for (const entry of this.processes.values()) { + for (const entry of this.tasks.values()) { // An awaiting_approval task is non-terminal and therefore counts // as active in listings (UI needs to show it alongside plain // running tasks). @@ -514,7 +484,7 @@ export class BackgroundProcessManager { * observe the complete log. No-op for unknown/ghost tasks. */ async flushOutput(taskId: string): Promise { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (entry === undefined) return; await entry.outputWriteQueue; } @@ -593,7 +563,7 @@ export class BackgroundProcessManager { }; } - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (entry === undefined) return emptyOutputSnapshot(); const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); @@ -610,7 +580,7 @@ export class BackgroundProcessManager { /** Get the combined output of a task (tail of the ring buffer). */ getOutput(taskId: string, tail?: number): string { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (!entry) return ''; const full = entry.outputChunks.join(''); if (tail !== undefined && tail < full.length) { @@ -620,7 +590,7 @@ export class BackgroundProcessManager { } async readOutput(taskId: string, tail?: number): Promise { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); const outputSessionDir = this.outputSessionDirFor(taskId); if (outputSessionDir !== undefined) { await entry?.outputWriteQueue; @@ -644,7 +614,7 @@ export class BackgroundProcessManager { /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (!entry) return undefined; // Normalize at this shared boundary: every public stop path (the TaskStop // tool, SDK/RPC) funnels through here, so a blank or whitespace-only @@ -661,17 +631,11 @@ export class BackgroundProcessManager { } entry.approvalReason = undefined; - entry.stopRequested = true; entry.stopReason = stopReason; - - try { - await entry.proc.kill('SIGTERM'); - } catch { - /* process already gone */ - } + entry.abortController.abort(stopReason); // Wait up to 5s for the lifecycle path to settle, then SIGKILL. - // Waiting on lifecyclePromise, rather than proc.wait() directly, lets a + // Waiting on lifecyclePromise, rather than the task directly, lets a // natural completion win the race instead of being overwritten here. let graceTimer: ReturnType | undefined; const graceful = await Promise.race([ @@ -692,9 +656,9 @@ export class BackgroundProcessManager { return this.toInfo(entry); } - if (!graceful && entry.proc.exitCode === null) { + if (!graceful) { try { - await entry.proc.kill('SIGKILL'); + await entry.task.forceStop?.(); } catch { /* ignore */ } @@ -705,15 +669,15 @@ export class BackgroundProcessManager { return this.toInfo(entry); } - // Agent tasks whose completion promise never settles (no timeoutMs, - // or a truly hung coroutine) need an explicit terminal finalize here. - await this.finalizeTerminal(entry, 'killed', null, { stopReason }); + // Tasks whose lifecycle promise never settles need an explicit terminal + // finalize here after their stop/force-stop hooks have had a chance. + await this.settleTask(entry, { status: 'killed', stopReason }); return this.toInfo(entry); } async stopAll(reason?: string): Promise { - const taskIds = Array.from(this.processes.values()) + const taskIds = Array.from(this.tasks.values()) .filter((entry) => !TERMINAL_STATUSES.has(entry.status)) .map((entry) => entry.taskId); const results = await Promise.all(taskIds.map((taskId) => this.stop(taskId, reason))); @@ -725,7 +689,7 @@ export class BackgroundProcessManager { * Returns immediately if already terminal. Times out after `timeoutMs`. */ async wait(taskId: string, timeoutMs = 30_000): Promise { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (!entry) return undefined; if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; @@ -758,147 +722,6 @@ export class BackgroundProcessManager { return this.toInfo(entry); } - /** - * Register a Promise-based agent task (no KaosProcess). Used by - * AgentTool for background subagent dispatch. Agent tasks appear in - * `list()` / `getTask()` but have pid=0 and empty output. - * - * `opts.timeoutMs` wraps the completion in an external deadline. On - * deadline fire, the task is marked `failed` with `timedOut=true` - * (distinct from a caller-driven `stop()` which uses `killed`, and - * distinct from an internal `TimeoutError` rejection which is a - * generic `failed` with `timedOut` left unset). - */ - registerAgentTask( - completion: Promise<{ result: string }>, - description: string, - opts: { - timeoutMs?: number; - abort?: () => void; - reservation?: BackgroundTaskReservation; - /** Subagent identifier; surfaced on task info. */ - agentId?: string; - /** Subagent profile name; surfaced on task info. */ - subagentType?: string; - } = {}, - ): string { - if (opts.reservation) { - opts.reservation.release(); - } else { - this.assertCanRegister(); - } - const taskId = generateTaskId('agent'); - const entry: ManagedProcess = { - taskId, - command: `[agent] ${description}`, - description, - timeoutMs: opts.timeoutMs, - // Fall back to defaults that satisfy callers reading these fields - // without forcing every call site to supply them. The dedicated - // dispatch path in AgentTool passes the real handle.agentId / - // handle.profileName. - agentId: opts.agentId ?? taskId, - subagentType: opts.subagentType ?? 'agent', - // Dummy KaosProcess — agent tasks are Promise-based, not process-based - proc: { - stdin: { write: () => false, end: () => {} } as never, - stdout: { setEncoding: () => {}, on: () => {} } as never, - stderr: { setEncoding: () => {}, on: () => {} } as never, - pid: 0, - exitCode: null, - wait: () => completion.then(() => 0), - kill: async () => { - opts.abort?.(); - }, - } as unknown as KaosProcess, - outputChunks: [], - outputSizeBytes: 0, - status: 'running', - exitCode: null, - startedAt: Date.now(), - endedAt: null, - waiters: [], - terminalFired: false, - stopRequested: false, - outputSessionDir: this.sessionDir, - lifecyclePromise: Promise.resolve(), - persistWriteQueue: Promise.resolve(), - outputWriteQueue: Promise.resolve(), - }; - this.processes.set(taskId, entry); - void this.persistLive(entry); - this.fireLifecycle('started', this.toInfo(entry)); - - // Deadline symbol distinguishes "external timeout fired" from "the - // agent promise itself rejected with TimeoutError" (which must - // remain a generic failure, not a deadline timeout). - const deadlineTimeout = Symbol('deadline-timeout'); - let deadlineTimer: ReturnType | undefined; - - const raceInputs: Array> = [completion]; - if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) { - raceInputs.push( - new Promise((resolve) => { - deadlineTimer = setTimeout(() => { - resolve(deadlineTimeout); - }, opts.timeoutMs); - }), - ); - } - - const settleLifecycle = Promise.race(raceInputs) - .then(async (outcome) => { - if (outcome === deadlineTimeout) { - // External deadline fired before the agent resolved. - if (TERMINAL_STATUSES.has(entry.status)) return; - opts.abort?.(); - await this.finalizeTerminal(entry, 'failed', 1, { timedOut: true }); - return; - } - // `completion` resolved before deadline. - const r = outcome as { result: string }; - if (TERMINAL_STATUSES.has(entry.status)) return; - this.appendOutput(entry, r.result); - await this.finalizeTerminal(entry, 'completed', 0); - }) - .catch(async (error: unknown) => { - // Caller-driven stop() that ran to completion through our own - // abort callback: the rejection is an AbortError-shaped object. - // Treat as `killed` so user-initiated cancellation is recorded - // as a cancellation, not a failure. The shape check is - // load-bearing — if a non-AbortError rejection arrives while - // `stopRequested` is set, it means a real failure (e.g. a - // model error) won the race against the in-flight stop, and we - // must record that failure rather than hide it behind the - // user's cancellation. - if (entry.stopRequested && isAbortError(error)) { - await this.finalizeTerminal(entry, 'killed', null); - return; - } - // Runner-initiated cancellation: the background agent runner - // raises `RunCancelled` to signal "abort this run" (e.g. on a - // Ctrl+C path with no BPM-side stop()). Map to `killed` - // because cancellation is not a failure. - if (error instanceof Error && error.name === 'RunCancelled') { - await this.finalizeTerminal(entry, 'killed', null); - return; - } - // Internal rejection (including TimeoutError, model errors, - // and stopRequested cases where a non-abort failure won the - // race): generic failure. `timedOut` stays unset so consumers - // can distinguish this from a true external deadline. - await this.finalizeTerminal(entry, 'failed', 1); - }) - .finally(() => { - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); - }); - - entry.lifecyclePromise = settleLifecycle; - void entry.lifecyclePromise; - - return taskId; - } - // ── awaiting_approval state transitions ──────────────────────────── /** @@ -909,7 +732,7 @@ export class BackgroundProcessManager { * the ApprovalRuntime callback path is race-safe. */ markAwaitingApproval(taskId: string, reason: string): void { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (!entry) return; if (TERMINAL_STATUSES.has(entry.status)) return; entry.status = 'awaiting_approval'; @@ -925,7 +748,7 @@ export class BackgroundProcessManager { * state. */ clearAwaitingApproval(taskId: string): void { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (!entry) return; if (entry.status !== 'awaiting_approval') return; entry.status = 'running'; @@ -946,7 +769,7 @@ export class BackgroundProcessManager { * manager's perspective. */ async waitForTerminal(taskId: string): Promise { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (entry === undefined) return this.ghosts.get(taskId); if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; @@ -961,7 +784,7 @@ export class BackgroundProcessManager { /** Reset internal state (for testing). */ _reset(): void { - this.processes.clear(); + this.tasks.clear(); this.ghosts.clear(); this.sessionDir = undefined; } @@ -991,7 +814,7 @@ export class BackgroundProcessManager { const persisted = await listTasks(this.sessionDir); for (const t of persisted) { // Skip ids that already exist as live processes — live wins. - if (this.processes.has(t.task_id)) continue; + if (this.tasks.has(t.task_id)) continue; this.ghosts.set(t.task_id, persistedToInfo(t)); } } @@ -1049,39 +872,21 @@ export class BackgroundProcessManager { } /** - * Persist the current state of a live ManagedProcess. Called from + * Persist the current state of a live ManagedTask. Called from * `register()` and the lifecycle finally block. No-op unless attached. */ - private persistLive(entry: ManagedProcess): Promise { + private persistLive(entry: ManagedTask): Promise { if (this.sessionDir === undefined) return Promise.resolve(); const sessionDir = this.sessionDir; - const isAgentTask = entry.taskId.startsWith('agent-'); - const task: PersistedTask = { - task_id: entry.taskId, - command: entry.command, - description: entry.description, - pid: entry.proc.pid, - started_at: entry.startedAt, - ended_at: entry.endedAt, - exit_code: entry.exitCode, - status: entry.status, - approval_reason: entry.approvalReason, - timed_out: entry.timedOut, - stop_reason: entry.stopReason, - // Only persist subagent identifiers for agent tasks. The base-class - // fallback `agentId ?? taskId` (registerAgentTask) makes them equal - // for tasks registered without an explicit id — skip those too so the - // disk record stays honest about whether we know a real agent_id. - agent_id: isAgentTask && entry.agentId !== entry.taskId ? entry.agentId : undefined, - subagent_type: isAgentTask ? entry.subagentType : undefined, - }; + const info = this.toInfo(entry); + const task: PersistedTask = infoToPersisted(info); entry.persistWriteQueue = entry.persistWriteQueue .then(() => writeTask(sessionDir, task)) .catch(() => {}); return entry.persistWriteQueue; } - private appendOutput(entry: ManagedProcess, chunk: string): void { + private appendOutput(entry: ManagedTask, chunk: string): void { entry.outputSizeBytes += Buffer.byteLength(chunk, 'utf-8'); entry.outputChunks.push(chunk); // Enforce output cap: drop oldest chunks when over budget. @@ -1100,117 +905,119 @@ export class BackgroundProcessManager { } private outputSessionDirFor(taskId: string): string | undefined { - const entry = this.processes.get(taskId); + const entry = this.tasks.get(taskId); if (entry !== undefined) return entry.outputSessionDir; if (this.ghosts.has(taskId)) return this.sessionDir; return undefined; } - private async settleProcessExit(entry: ManagedProcess, exitCode: number): Promise { + private async settleTask( + entry: ManagedTask, + settlement: { + readonly status: 'completed' | 'failed' | 'timed_out' | 'killed'; + readonly stopReason?: string; + }, + ): Promise { if (TERMINAL_STATUSES.has(entry.status)) { - if (entry.status === 'killed' && entry.exitCode === null) { - entry.exitCode = exitCode; - entry.endedAt = Date.now(); + if (entry.status === 'killed' && settlement.status === 'killed') { + entry.endedAt = Math.max(Date.now(), (entry.endedAt ?? 0) + 1); await this.persistLive(entry); this.fireTerminalCallbacks(entry); this.resolveWaiters(entry); } - return; + return false; } - const status = entry.stopRequested ? 'killed' : exitCode === 0 ? 'completed' : 'failed'; - await this.finalizeTerminal(entry, status, exitCode); + entry.status = settlement.status; + entry.endedAt = Date.now(); + entry.stopReason = + settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); + // A task that ended while still in awaiting_approval (e.g. crashed + // mid-prompt, deadline fired, or got killed) must not leak the + // stale approvalReason onto the terminal record. The awaiting → + // running path (clearAwaitingApproval) already clears it; mirror + // that here for the awaiting → terminal path. + entry.approvalReason = undefined; + await this.persistLive(entry); + this.fireTerminalCallbacks(entry); + this.resolveWaiters(entry); + return true; } private observedExitCompletions(): Promise[] { const completions: Promise[] = []; - for (const entry of this.processes.values()) { - if (!TERMINAL_STATUSES.has(entry.status) && entry.proc.exitCode !== null) { + for (const entry of this.tasks.values()) { + if (!TERMINAL_STATUSES.has(entry.status) && entry.task.hasObservedTerminal?.() === true) { completions.push(entry.lifecyclePromise); } } return completions; } - private toInfo(entry: ManagedProcess): BackgroundTaskInfo { - return { + private toInfo(entry: ManagedTask): BackgroundTaskInfo { + const base: BackgroundTaskInfoBase = { taskId: entry.taskId, - command: entry.command, - description: entry.description, + kind: entry.task.kind, + description: entry.task.description, status: entry.status, - pid: entry.proc.pid, - exitCode: entry.exitCode, startedAt: entry.startedAt, endedAt: entry.endedAt, approvalReason: entry.approvalReason, - timedOut: entry.timedOut, stopReason: entry.stopReason, timeoutMs: entry.timeoutMs, - agentId: entry.agentId, - subagentType: entry.subagentType, failureReason: entry.failureReason, }; + return entry.task.toInfo(base); } - private async finalizeTerminal( - entry: ManagedProcess, - status: BackgroundTaskStatus, - exitCode: number | null, - options: { readonly timedOut?: boolean; readonly stopReason?: string } = {}, - ): Promise { - if (TERMINAL_STATUSES.has(entry.status)) return false; - entry.status = status; - entry.exitCode = exitCode; - entry.endedAt = Date.now(); - entry.timedOut = options.timedOut; - entry.stopReason = status === 'killed' ? (options.stopReason ?? entry.stopReason) : undefined; - // A task that ended while still in awaiting_approval (e.g. crashed - // mid-prompt, deadline fired, or got killed) must not leak the - // stale approvalReason onto the terminal record. The awaiting → - // running path (clearAwaitingApproval) already clears it; mirror - // that here for the awaiting → terminal path. - entry.approvalReason = undefined; - entry.stopRequested = false; - await this.persistLive(entry); - this.fireTerminalCallbacks(entry); - this.resolveWaiters(entry); - return true; - } } // ── persistence shape <-> in-memory shape ────────────────────────────── function persistedToInfo(t: PersistedTask): BackgroundTaskInfo { - return { + const status = t.timed_out === true ? 'timed_out' : t.status; + const base: BackgroundTaskInfoBase = { taskId: t.task_id, - command: t.command, + kind: t.kind ?? (t.task_id.startsWith('agent-') ? 'agent' : 'process'), description: t.description, - status: t.status, - pid: t.pid, - exitCode: t.exit_code, + status, startedAt: t.started_at, endedAt: t.ended_at, approvalReason: t.approval_reason, - timedOut: t.timed_out, stopReason: t.stop_reason, - agentId: t.agent_id, - subagentType: t.subagent_type, + }; + if (base.kind === 'agent') { + return { + ...base, + kind: 'agent', + agentId: t.agent_id, + subagentType: t.subagent_type, + }; + } + return { + ...base, + kind: 'process', + command: t.command, + pid: t.pid, + exitCode: t.exit_code, }; } function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { + const command = info.kind === 'process' ? info.command : `[agent] ${info.description}`; + const pid = info.kind === 'process' ? info.pid : 0; return { task_id: info.taskId, - command: info.command, + kind: info.kind, + command, description: info.description, - pid: info.pid, + pid, started_at: info.startedAt, ended_at: info.endedAt, - exit_code: info.exitCode, + exit_code: info.kind === 'process' ? info.exitCode : null, status: info.status, approval_reason: info.approvalReason, - timed_out: info.timedOut, stop_reason: info.stopReason, - agent_id: info.agentId === info.taskId ? undefined : info.agentId, - subagent_type: info.subagentType, + agent_id: info.kind === 'agent' ? info.agentId : undefined, + subagent_type: info.kind === 'agent' ? info.subagentType : undefined, }; } diff --git a/packages/agent-core/src/tools/background/persist.ts b/packages/agent-core/src/tools/background/persist.ts index aaa73b6f..1288c26a 100644 --- a/packages/agent-core/src/tools/background/persist.ts +++ b/packages/agent-core/src/tools/background/persist.ts @@ -18,7 +18,7 @@ import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; -import type { BackgroundTaskStatus } from './manager'; +import type { BackgroundTaskKind, BackgroundTaskStatus } from './task'; /** * Task id format: `{bash|agent}-{8 chars of [0-9a-z]}`. @@ -32,6 +32,7 @@ export const VALID_TASK_ID: RegExp = /^(bash|agent)-[0-9a-z]{8}$/; /** On-disk task representation (snake_case, Python-friendly). */ export interface PersistedTask { readonly task_id: string; + readonly kind?: BackgroundTaskKind; readonly command: string; readonly description: string; readonly pid: number; @@ -45,13 +46,12 @@ export interface PersistedTask { */ readonly approval_reason?: string | undefined; /** - * True when an agent task was forcibly terminated by its external - * deadline (`registerAgentTask(..., { timeoutMs })`). An internal - * `TimeoutError` raised by the agent promise itself is a generic - * failure and does NOT set this flag. + * Legacy timeout marker from older persisted task files. New task info + * records timeout as `status: "timed_out"`; this field is retained only + * so old session state can be read and normalized. */ readonly timed_out?: boolean | undefined; - /** Reason recorded when a task is explicitly stopped. */ + /** Reason recorded when a task is explicitly stopped or aborted. */ readonly stop_reason?: string | undefined; /** * Shell origin metadata (name / path / cwd) captured when @@ -250,6 +250,7 @@ function isValidPersistedTask(obj: unknown): obj is PersistedTask { const o = obj as Record; return ( typeof o['task_id'] === 'string' && + (o['kind'] === undefined || o['kind'] === 'process' || o['kind'] === 'agent') && typeof o['command'] === 'string' && typeof o['description'] === 'string' && typeof o['pid'] === 'number' && diff --git a/packages/agent-core/src/tools/background/process-task.ts b/packages/agent-core/src/tools/background/process-task.ts new file mode 100644 index 00000000..2809943d --- /dev/null +++ b/packages/agent-core/src/tools/background/process-task.ts @@ -0,0 +1,85 @@ +import type { KaosProcess } from '@moonshot-ai/kaos'; + +import type { + BackgroundTask, + BackgroundTaskInfoBase, + BackgroundTaskSink, +} from './task'; + +export interface ProcessBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; +} + +export interface ProcessBackgroundTaskOptions { + readonly idPrefix?: string; +} + +export class ProcessBackgroundTask implements BackgroundTask { + readonly kind = 'process' as const; + readonly idPrefix: string; + private exitCode: number | null = null; + + constructor( + readonly proc: KaosProcess, + readonly command: string, + readonly description: string, + options: ProcessBackgroundTaskOptions = {}, + ) { + this.idPrefix = options.idPrefix ?? 'bash'; + } + + async start(sink: BackgroundTaskSink): Promise { + for (const stream of [this.proc.stdout, this.proc.stderr]) { + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + sink.appendOutput(chunk); + }); + } + + const requestStop = (): void => { + void this.proc.kill('SIGTERM').catch(() => {}); + }; + if (sink.signal.aborted) { + requestStop(); + } else { + sink.signal.addEventListener('abort', requestStop, { once: true }); + } + + try { + const exitCode = await this.proc.wait(); + this.exitCode = exitCode; + await sink.settle({ + status: sink.signal.aborted ? 'killed' : exitCode === 0 ? 'completed' : 'failed', + }); + } catch { + this.exitCode = this.proc.exitCode; + await sink.settle({ + status: sink.signal.aborted ? 'killed' : 'failed', + }); + } finally { + sink.signal.removeEventListener('abort', requestStop); + } + } + + async forceStop(): Promise { + if (this.proc.exitCode !== null) return; + await this.proc.kill('SIGKILL'); + } + + hasObservedTerminal(): boolean { + return this.proc.exitCode !== null; + } + + toInfo(base: BackgroundTaskInfoBase): ProcessBackgroundTaskInfo { + return { + ...base, + kind: 'process', + command: this.command, + pid: this.proc.pid, + exitCode: this.exitCode, + }; + } +} diff --git a/packages/agent-core/src/tools/background/task-list.ts b/packages/agent-core/src/tools/background/task-list.ts index 6c56c842..5072b0fb 100644 --- a/packages/agent-core/src/tools/background/task-list.ts +++ b/packages/agent-core/src/tools/background/task-list.ts @@ -8,8 +8,8 @@ import type { BuiltinTool } from '../../agent/tool'; import type { ToolExecution } from '../../loop/types'; import { toInputJsonSchema } from '../support/input-schema'; import { matchesGlobRuleSubject } from '../support/rule-match'; +import { formatPlainObject } from './format'; import type { BackgroundProcessManager, BackgroundTaskInfo } from './manager'; -import { isBackgroundTaskTerminal } from './manager'; import TASK_LIST_DESCRIPTION from './task-list.md'; // ── Input schema ───────────────────────────────────────────────────── @@ -34,30 +34,13 @@ export type TaskListInput = z.Infer; // ── Implementation ─────────────────────────────────────────────────── -function formatTask(t: BackgroundTaskInfo): string { - const lines = [ - `task_id: ${t.taskId}`, - `status: ${t.status}`, - `command: ${t.command}`, - `description: ${t.description}`, - `pid: ${String(t.pid ?? 'N/A')}`, - ]; - // Terminal tasks carry an outcome the AI needs to act on: the process - // exit code, and — when the task was ended via TaskStop — the stop reason. - if (isBackgroundTaskTerminal(t.status)) { - if (t.exitCode !== null) lines.push(`exit_code: ${String(t.exitCode)}`); - if (t.stopReason !== undefined) lines.push(`reason: ${t.stopReason}`); - } - return lines.join('\n'); -} - function formatTaskList(tasks: BackgroundTaskInfo[], activeOnly: boolean): string { // `active_only=false` mixes in terminal/lost tasks, so the count is no // longer purely "active" — use a neutral label to avoid mislabeling them. const label = activeOnly ? 'active_background_tasks' : 'background_tasks'; const header = `${label}: ${String(tasks.length)}`; if (tasks.length === 0) return `${header}\nNo background tasks found.`; - return `${header}\n${tasks.map(formatTask).join('\n---\n')}`; + return `${header}\n${tasks.map((task) => formatPlainObject(task)).join('\n---\n')}`; } export class TaskListTool implements BuiltinTool { diff --git a/packages/agent-core/src/tools/background/task-output.md b/packages/agent-core/src/tools/background/task-output.md index 395f2537..1b62c778 100644 --- a/packages/agent-core/src/tools/background/task-output.md +++ b/packages/agent-core/src/tools/background/task-output.md @@ -1,12 +1,12 @@ Retrieve output from a running or completed background task. -Use this after `Bash(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. +Use this after `Bash(run_in_background=true)` or `Agent(run_in_background=true)` when you need to inspect progress or explicitly wait for completion. Guidelines: - Prefer relying on automatic completion notifications. Use this tool only when you need task output before the automatic notification arrives. - By default this tool is non-blocking and returns a current status/output snapshot. - Use block=true only when you intentionally want to wait for completion or timeout. - This tool returns structured task metadata, a fixed-size output preview, and an output_path for the full log. -- For a terminal task, the metadata also explains why it ended: `timed_out` when an agent task was aborted by its deadline, and `stop_reason` when the task was explicitly stopped. `terminal_reason` is a categorical label for the same event — its value is `timed_out` or `stopped` — and is emitted alongside the matching `timed_out` / `stop_reason` field. A task that ended on its own emits none of these three fields. +- For a terminal task, the metadata also explains why it ended: `status: timed_out` when a task was aborted by its deadline, and `stop_reason` when the task was explicitly stopped. `terminal_reason` is a categorical label for the same event — its value is `timed_out` or `stopped` — and is emitted alongside the matching status / `stop_reason` field. A task that ended on its own emits neither `stop_reason` nor `terminal_reason`. - The full, never-truncated log is always available at output_path; use the `Read` tool with that path to page through it, whether or not the preview was truncated. - This tool works with the generic background task system and should remain the primary read path for future task types, not just bash. diff --git a/packages/agent-core/src/tools/background/task-output.ts b/packages/agent-core/src/tools/background/task-output.ts index fe912e7b..2ef1ec98 100644 --- a/packages/agent-core/src/tools/background/task-output.ts +++ b/packages/agent-core/src/tools/background/task-output.ts @@ -8,8 +8,8 @@ * has been truncated to a tail. * * For terminal tasks the output also surfaces why the task ended: - * `timed_out` when an agent task was aborted by its deadline, and - * `stop_reason` when the task was explicitly stopped via `TaskStop`. + * `stop_reason` records the concrete reason; `terminal_reason` classifies + * timeout vs. explicit stop for callers that need stable labels. */ import { z } from 'zod'; @@ -19,10 +19,13 @@ import type { ExecutableToolResult, ToolExecution } from '../../loop/types'; import { isBackgroundTaskTerminal, type BackgroundProcessManager, + type BackgroundTaskInfo, + type BackgroundTaskOutputSnapshot, type BackgroundTaskStatus, } from './manager'; import { toInputJsonSchema } from '../support/input-schema'; import { matchesGlobRuleSubject } from '../support/rule-match'; +import { formatPlainObject } from './format'; import TASK_OUTPUT_DESCRIPTION from './task-output.md'; /** @@ -66,6 +69,30 @@ function retrievalStatus( return block ? 'timeout' : 'not_ready'; } +function terminalReason(info: BackgroundTaskInfo): 'timed_out' | 'stopped' | undefined { + if (info.status === 'timed_out') return 'timed_out'; + if (info.stopReason !== undefined) return 'stopped'; + return undefined; +} + +function fullOutputHint(output: BackgroundTaskOutputSnapshot): string | undefined { + if (!output.fullOutputAvailable || output.outputPath === undefined) return undefined; + if (output.truncated) { + return ( + `Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown above. ` + + 'Use the Read tool with the output_path to page through the full log ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); + } + return ( + 'The preview above is the complete output. Use the Read tool with the output_path ' + + 'if you need to re-read the full log later ' + + `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + + 'lines per page).' + ); +} + export class TaskOutputTool implements BuiltinTool { readonly name = 'TaskOutput' as const; readonly description: string = TASK_OUTPUT_DESCRIPTION; @@ -105,56 +132,20 @@ export class TaskOutputTool implements BuiltinTool { const output = await this.manager.getOutputSnapshot(args.task_id, OUTPUT_PREVIEW_BYTES); const lines = [ - `retrieval_status: ${retrievalStatus(current.status, args.block)}`, - `task_id: ${current.taskId}`, - `status: ${current.status}`, - `description: ${current.description}`, - `command: ${current.command}`, + formatPlainObject({ + retrievalStatus: retrievalStatus(current.status, args.block), + ...current, + outputPath: output.outputPath, + terminalReason: terminalReason(current), + outputSizeBytes: output.outputSizeBytes, + outputPreviewBytes: output.previewBytes, + outputTruncated: output.truncated, + fullOutputAvailable: output.fullOutputAvailable, + fullOutputTool: + output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, + fullOutputHint: fullOutputHint(output), + }), ]; - if (output.outputPath !== undefined) { - lines.push(`output_path: ${output.outputPath}`); - } - if (current.exitCode !== null) { - lines.push(`exit_code: ${String(current.exitCode)}`); - } - // Surface why a terminal task ended. `terminal_reason` is a categorical - // label; `timed_out` / `stop_reason` carry the concrete detail. - // - timed_out: an agent task aborted by its external deadline. - // - stopped: the task was explicitly cancelled via `TaskStop` - // (`stop_reason` is the reason text supplied there). - // A task that ended on its own (completed / failed / lost) emits none - // of these so the absence is itself meaningful. - if (current.timedOut === true) { - lines.push('timed_out: true', 'terminal_reason: timed_out'); - } else if (current.stopReason !== undefined) { - lines.push(`stop_reason: ${current.stopReason}`, 'terminal_reason: stopped'); - } - lines.push( - `output_size_bytes: ${String(output.outputSizeBytes)}`, - `output_preview_bytes: ${String(output.previewBytes)}`, - `output_truncated: ${String(output.truncated)}`, - ); - // The full, never-truncated log is readable from disk via the `Read` - // tool whenever it was persisted. Surface that guidance unconditionally - // — even when the preview already shows everything — so the model knows - // it can page the complete output. The hint text adapts to whether the - // preview was truncated. When no full log was persisted, say so instead. - if (output.fullOutputAvailable && output.outputPath !== undefined) { - lines.push('full_output_available: true', 'full_output_tool: Read'); - lines.push( - output.truncated - ? `full_output_hint: Only the last ${String(OUTPUT_PREVIEW_BYTES)} bytes are shown ` + - 'above. Use the Read tool with the output_path to page through the full log ' + - `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + - 'lines per page).' - : 'full_output_hint: The preview above is the complete output. Use the Read tool ' + - 'with the output_path if you need to re-read the full log later ' + - `(parameters: path, line_offset, n_lines; read about ${String(PAGING_HINT_LINES)} ` + - 'lines per page).', - ); - } else { - lines.push('full_output_available: false'); - } // When the preview omits the head of the log, emit an explicit // banner just before the `[output]` marker so the model knows it is diff --git a/packages/agent-core/src/tools/background/task.ts b/packages/agent-core/src/tools/background/task.ts new file mode 100644 index 00000000..b9a3a96c --- /dev/null +++ b/packages/agent-core/src/tools/background/task.ts @@ -0,0 +1,59 @@ +import type { AgentBackgroundTaskInfo } from './agent-task'; +import type { ProcessBackgroundTaskInfo } from './process-task'; + +export type BackgroundTaskStatus = + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'timed_out' + | 'killed' + | 'lost'; + +export const TERMINAL_BACKGROUND_TASK_STATUSES: ReadonlySet = + new Set(['completed', 'failed', 'timed_out', 'killed', 'lost']); + +export type BackgroundTaskInfo = ProcessBackgroundTaskInfo | AgentBackgroundTaskInfo; +export type BackgroundTaskKind = BackgroundTaskInfo['kind']; +export type BackgroundTaskSettlementStatus = 'completed' | 'failed' | 'timed_out' | 'killed'; + +export interface BackgroundTaskSettlement { + readonly status: BackgroundTaskSettlementStatus; + /** Reason recorded when a task is explicitly stopped or aborted. */ + readonly stopReason?: string; +} + +export interface BackgroundTaskInfoBase { + readonly taskId: string; + readonly kind: string; + readonly description: string; + readonly status: BackgroundTaskStatus; + readonly startedAt: number; + readonly endedAt: number | null; + /** Populated only while `status === 'awaiting_approval'`. */ + readonly approvalReason?: string; + /** Reason recorded when a task is explicitly stopped or aborted. */ + readonly stopReason?: string; + /** Deadline supplied at registration; surfaced via task info. */ + readonly timeoutMs?: number; + /** Human-readable reason recorded when a task is reclassified. */ + readonly failureReason?: string; +} + +export interface BackgroundTaskSink { + readonly signal: AbortSignal; + appendOutput(chunk: string): void; + settle(settlement: BackgroundTaskSettlement): Promise; +} + +export interface BackgroundTask { + readonly idPrefix: string; + readonly kind: string; + readonly description: string; + readonly timeoutMs?: number; + + start(sink: BackgroundTaskSink): void | Promise; + forceStop?(): Promise; + hasObservedTerminal?(): boolean; + toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo; +} diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index a726217c..061f982d 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -30,6 +30,7 @@ import { isUserCancellation, type DeadlineAbortSignal, } from '../../../utils/abort'; +import { AgentBackgroundTask } from '../../background/agent-task'; import type { BackgroundProcessManager } from '../../background/manager'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; @@ -115,22 +116,18 @@ export class AgentTool implements BuiltinTool { readonly name: string = 'Agent'; readonly description: string; readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); - private readonly allowBackground: boolean; - constructor( private readonly subagentHost: SessionSubagentHost, private readonly backgroundManager?: BackgroundProcessManager | undefined, subagents?: ResolvedAgentProfile['subagents'] | undefined, options?: { - allowBackground?: boolean; log?: Logger; }, ) { - this.allowBackground = options?.allowBackground ?? this.backgroundManager !== undefined; const log = options?.log; const typeLines = buildSubagentDescriptions(subagents); const baseDescription = `${AGENT_DESCRIPTION_BASE}\n\n${ - this.allowBackground ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION + this.backgroundManager !== undefined ? AGENT_BACKGROUND_DESCRIPTION : AGENT_BACKGROUND_DISABLED_DESCRIPTION }`; this.description = typeLines ? `${baseDescription}\n\nAvailable agent types (pass via subagent_type):\n${typeLines}` @@ -187,18 +184,15 @@ export class AgentTool implements BuiltinTool { } let reservation: ReturnType | undefined; - let backgroundManager: BackgroundProcessManager | undefined; if (runInBackground) { - const configuredBackgroundManager = this.backgroundManager; - if (!this.allowBackground || configuredBackgroundManager === undefined) { + if (this.backgroundManager === undefined) { return { output: BACKGROUND_AGENT_UNAVAILABLE, isError: true, }; } try { - reservation = configuredBackgroundManager.reserveSlot(); - backgroundManager = configuredBackgroundManager; + reservation = this.backgroundManager.reserveSlot(); } catch (error) { return { output: error instanceof Error ? error.message : String(error), @@ -244,24 +238,19 @@ export class AgentTool implements BuiltinTool { } if (runInBackground) { - if (backgroundManager === undefined) { - reservation?.release(); - return { - output: BACKGROUND_AGENT_UNAVAILABLE, - isError: true, - }; - } let taskId: string; try { - taskId = backgroundManager.registerAgentTask(handle.completion, args.description, { - timeoutMs: timeoutMs ?? this.subagentHost.backgroundTaskTimeoutMs, + taskId = this.backgroundManager!.registerTask( + new AgentBackgroundTask(handle.completion, args.description, { + timeoutMs: timeoutMs ?? this.subagentHost.backgroundTaskTimeoutMs, + agentId: handle.agentId, + subagentType: handle.profileName, + abort: () => { + backgroundController?.abort(); + }, + }), reservation, - agentId: handle.agentId, - subagentType: handle.profileName, - abort: () => { - backgroundController?.abort(); - }, - }); + ); } catch (error) { reservation?.release(); backgroundController?.abort(); diff --git a/packages/agent-core/test/tools/background/agent-timeout.test.ts b/packages/agent-core/test/tools/background/agent-timeout.test.ts index 9455bf4b..02fbc4b1 100644 --- a/packages/agent-core/test/tools/background/agent-timeout.test.ts +++ b/packages/agent-core/test/tools/background/agent-timeout.test.ts @@ -2,16 +2,17 @@ * `registerAgentTask` `timeoutMs` option. * * Semantics: - * - external deadline fires → status=`failed`, `timedOut=true` + * - external deadline fires → status=`failed`, `stopReason="Timed out"` * - no `timeoutMs` → the task runs to completion without a wrapper * - internal `TimeoutError` rejection (e.g. aiohttp sock_read) is a - * generic `failed` with `timedOut` left unset — the flag must + * generic `failed` with no stop reason — the timeout reason must * only be set for the caller-driven deadline */ import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackgroundProcessManager } from '../../../src/tools/background/manager'; +import { BACKGROUND_TASK_TIMEOUT_STOP_REASON } from '../../../src/tools/background/task'; describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { const manager = new BackgroundProcessManager(); @@ -21,7 +22,7 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { vi.useRealTimers(); }); - it('external deadline marks task failed with timedOut=true', async () => { + it('external deadline marks task failed with a timeout stop reason', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); // A never-resolving completion — only the deadline will fire. const hangForever = new Promise<{ result: string }>(() => {}); @@ -34,7 +35,7 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { const info = await terminalPromise; expect(info?.status).toBe('failed'); - expect(info?.timedOut).toBe(true); + expect(info?.stopReason).toBe(BACKGROUND_TASK_TIMEOUT_STOP_REASON); }); it('omitting timeoutMs lets the task run to completion (no wrapper)', async () => { @@ -47,10 +48,10 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { resolveFn({ result: 'finished' }); const info = await manager.waitForTerminal(taskId); expect(info?.status).toBe('completed'); - expect(info?.timedOut).toBeUndefined(); + expect(info?.stopReason).toBeUndefined(); }); - it('internal TimeoutError rejection = generic failure, timedOut unset', async () => { + it('internal TimeoutError rejection = generic failure, stop reason unset', async () => { // Even with a deadline set, an internal TimeoutError that fires // BEFORE the deadline must land as a plain `failed` (not as a // deadline-driven timeout). @@ -63,8 +64,8 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { const info = await manager.waitForTerminal(taskId); expect(info?.status).toBe('failed'); - // Deadline never fired → timedOut must NOT be set. - expect(info?.timedOut).toBeUndefined(); + // Deadline never fired → timeout stop reason must NOT be set. + expect(info?.stopReason).toBeUndefined(); }); // Explicit per-task timeoutMs must be surfaced on the task info so @@ -121,17 +122,17 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { // with a short race so the test does not hang on the never- // settling completion promise; the racing branch winning is the // expected outcome. - const raced = await Promise.race<{ status: string; timedOut?: boolean } | undefined>([ + const raced = await Promise.race<{ status: string; stopReason?: string } | undefined>([ manager.waitForTerminal(taskId).then((info) => - info === undefined ? undefined : { status: info.status, timedOut: info.timedOut }, + info === undefined ? undefined : { status: info.status, stopReason: info.stopReason }, ), - new Promise<{ status: string; timedOut?: boolean }>((res) => { + new Promise<{ status: string; stopReason?: string }>((res) => { setTimeout(() => { res({ status: 'running' }); }, 100); }), ]); expect(raced?.status).toBe('running'); - expect(raced?.timedOut).toBeUndefined(); + expect(raced?.stopReason).toBeUndefined(); }); }); diff --git a/packages/agent-core/test/tools/background/lifecycle.test.ts b/packages/agent-core/test/tools/background/lifecycle.test.ts index 1f8a8a6f..345e956a 100644 --- a/packages/agent-core/test/tools/background/lifecycle.test.ts +++ b/packages/agent-core/test/tools/background/lifecycle.test.ts @@ -156,8 +156,11 @@ describe('BackgroundProcessManager — onLifecycle', () => { const terminated = records.filter((r) => r.event === 'terminated'); expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('completed'); - expect(terminated[0]!.info.exitCode).toBe(0); + expect(terminated[0]!.info).toMatchObject({ + kind: 'process', + status: 'completed', + exitCode: 0, + }); }); it("fires 'terminated' on non-zero exit (failed)", async () => { @@ -169,8 +172,11 @@ describe('BackgroundProcessManager — onLifecycle', () => { const terminated = records.filter((r) => r.event === 'terminated'); expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('failed'); - expect(terminated[0]!.info.exitCode).toBe(2); + expect(terminated[0]!.info).toMatchObject({ + kind: 'process', + status: 'failed', + exitCode: 2, + }); }); it("fires 'terminated' exactly once for the same task (idempotent)", async () => { @@ -226,7 +232,6 @@ describe('BackgroundProcessManager — onLifecycle', () => { exit_code: null, status: 'running', approval_reason: undefined, - timed_out: undefined, stop_reason: undefined, }; writeFileSync(join(tasksDir, 'bash-deadbeef.json'), JSON.stringify(ghost)); diff --git a/packages/agent-core/test/tools/background/manager.test.ts b/packages/agent-core/test/tools/background/manager.test.ts index 9e3210d5..a74d9a2b 100644 --- a/packages/agent-core/test/tools/background/manager.test.ts +++ b/packages/agent-core/test/tools/background/manager.test.ts @@ -104,12 +104,12 @@ function manuallyResolvedProcess(): { } function waiterCount(manager: BackgroundProcessManager, taskId: string): number { - const processes = ( + const tasks = ( manager as unknown as { - processes: Map void> }>; + tasks: Map void> }>; } - ).processes; - return processes.get(taskId)?.waiters.length ?? 0; + ).tasks; + return tasks.get(taskId)?.waiters.length ?? 0; } function processExitingAfterSigkill( @@ -186,6 +186,8 @@ describe('BackgroundProcessManager', () => { expect(taskId).toMatch(/^bash-[0-9a-z]{8}$/); const info = manager.getTask(taskId); expect(info).toBeDefined(); + expect(info!.kind).toBe('process'); + if (info!.kind !== 'process') throw new Error('expected process task'); expect(info!.command).toBe('echo hello'); expect(info!.description).toBe('test echo'); expect(info!.pid).toBe(proc.pid); @@ -223,12 +225,10 @@ describe('BackgroundProcessManager', () => { expect(taskId).toMatch(/^agent-[0-9a-z]{8}$/); const info = manager.getTask(taskId); expect(info).toBeDefined(); + expect(info!.kind).toBe('agent'); expect(info!.status).toBe('running'); - // Agent tasks use pid=0 (dummy KaosProcess). - expect(info!.pid).toBe(0); - // Spec marker: command includes the `[agent]` tag so LLM renderers - // can distinguish bash vs agent entries when scrolling tasks. - expect(info!.command).toContain('[agent]'); + expect('pid' in info!).toBe(false); + expect('command' in info!).toBe(false); }); it('getTask on an unknown id does not touch disk or create state', () => { @@ -293,8 +293,7 @@ describe('BackgroundProcessManager', () => { }); const info = manager.getTask(taskId); - expect(info!.status).toBe('completed'); - expect(info!.exitCode).toBe(0); + expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); }); it('task status transitions to failed on non-zero exit', async () => { @@ -306,8 +305,7 @@ describe('BackgroundProcessManager', () => { }); const info = manager.getTask(taskId); - expect(info!.status).toBe('failed'); - expect(info!.exitCode).toBe(42); + expect(info).toMatchObject({ kind: 'process', status: 'failed', exitCode: 42 }); }); it('does not finalize task status from a visible process exit code before wait settles', () => { @@ -317,8 +315,7 @@ describe('BackgroundProcessManager', () => { markExited(); const info = manager.getTask(taskId); - expect(info!.status).toBe('running'); - expect(info!.exitCode).toBeNull(); + expect(info).toMatchObject({ kind: 'process', status: 'running', exitCode: null }); expect(info!.endedAt).toBeNull(); }); @@ -329,8 +326,7 @@ describe('BackgroundProcessManager', () => { markExited(); const info = await manager.wait(taskId, 1); - expect(info!.status).toBe('running'); - expect(info!.exitCode).toBeNull(); + expect(info).toMatchObject({ kind: 'process', status: 'running', exitCode: null }); }); it('stop kills a running task via KaosProcess.kill()', async () => { @@ -388,8 +384,7 @@ describe('BackgroundProcessManager', () => { await reader.loadFromDisk(); const persisted = reader.getTask(taskId); - expect(persisted?.status).toBe('killed'); - expect(persisted?.exitCode).toBe(0); + expect(persisted).toMatchObject({ kind: 'process', status: 'killed', exitCode: 0 }); expect(persisted?.stopReason).toBe('user requested'); } finally { await rm(sessionDir, { recursive: true, force: true }); @@ -494,7 +489,7 @@ describe('BackgroundProcessManager', () => { await vi.advanceTimersByTimeAsync(25); const info = local.getTask(taskId); - expect(info!.exitCode).toBe(137); + expect(info).toMatchObject({ kind: 'process', exitCode: 137 }); expect(info!.endedAt).toBeGreaterThan(stopEndedAt!); expect(terminated).toEqual(['killed']); } finally { @@ -564,6 +559,8 @@ describe('BackgroundProcessManager — registration semantics', () => { expect(taskId.startsWith('bash-')).toBe(true); const info = manager.getTask(taskId); expect(info).toBeDefined(); + expect(info!.kind).toBe('process'); + if (info!.kind !== 'process') throw new Error('expected process task'); // Py: 'starting' state visible. TS: starting status is collapsed // into 'running' here — the assertion lives at the py level. expect((info!.status as string) === 'starting' || info!.status === 'running').toBe(true); @@ -581,8 +578,7 @@ describe('BackgroundProcessManager — registration semantics', () => { setTimeout(r, 20); }); const info = manager.getTask(taskId); - expect(info!.status).toBe('completed'); - expect(info!.exitCode).toBe(0); + expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); }); // Worker launch raises → manager re-raises, AND persists a `failed` @@ -610,21 +606,17 @@ describe('BackgroundProcessManager — registration semantics', () => { // Agent task registration places kind_payload-style info on the task // info (agent_id / subagent_type carried through), status visible. it('agent task registration exposes agent metadata on the task info', () => { - const taskId = manager.registerAgentTask(new Promise(() => {}), 'investigate bug'); + const taskId = manager.registerAgentTask(new Promise(() => {}), 'investigate bug', { + agentId: 'agent-child', + subagentType: 'coder', + }); expect(taskId.startsWith('agent-')).toBe(true); const info = manager.getTask(taskId); expect(info).toBeDefined(); - // Py: `kind_payload.agent_id / subagent_type`. TS exposes neither - // today — assertion lives at the py spec level. - const extended = info as unknown as { - readonly agentId?: string; - readonly subagentType?: string; - readonly kindPayload?: { agent_id?: string; subagent_type?: string }; - }; - const agentId = extended.agentId ?? extended.kindPayload?.agent_id; - const subagentType = extended.subagentType ?? extended.kindPayload?.subagent_type; - expect(agentId).toBeDefined(); - expect(subagentType).toBeDefined(); + expect(info!.kind).toBe('agent'); + if (info!.kind !== 'agent') throw new Error('expected agent task'); + expect(info!.agentId).toBe('agent-child'); + expect(info!.subagentType).toBe('coder'); }); // Lookup for an unknown task id must return undefined AND must NOT @@ -708,8 +700,7 @@ describe('BackgroundProcessManager — registration semantics', () => { }; const taskId = manager.register(proc, 'node -e ', 'real worker smoke'); const info = await manager.wait(taskId, 10_000); - expect(info!.status).toBe('completed'); - expect(info!.exitCode).toBe(0); + expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); expect(manager.getOutput(taskId)).toContain('bg-ok'); }, 15_000); diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index 30b07993..7d648bc1 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { BackgroundProcessManager } from '../../../src/tools/background/manager'; import { writeTask } from '../../../src/tools/background/persist'; +import { BACKGROUND_TASK_TIMEOUT_STOP_REASON } from '../../../src/tools/background/task'; import { TaskListTool } from '../../../src/tools/background/task-list'; import { TaskOutputTool } from '../../../src/tools/background/task-output'; import { TaskStopTool } from '../../../src/tools/background/task-stop'; @@ -154,6 +155,8 @@ describe('TaskListTool', () => { // Synchronous check — the task is running immediately after register. const tasks = manager.list(true); expect(tasks.length).toBe(1); + expect(tasks[0]!.kind).toBe('process'); + if (tasks[0]!.kind !== 'process') throw new Error('expected process task'); expect(tasks[0]!.command).toBe('sleep 60'); }); @@ -229,8 +232,7 @@ describe('TaskListTool', () => { await manager.stop(taskId, 'superseded by newer task'); const result = await executeTool(tool, context('c_stop_reason', { active_only: false })); const content = toolContentString(result); - expect(content).toContain('reason: superseded by newer task'); - expect(content).not.toContain('stop_reason:'); + expect(content).toContain('stop_reason: superseded by newer task'); }); it('omits exit_code and reason for non-terminal tasks', async () => { @@ -359,6 +361,30 @@ describe('TaskOutputTool', () => { expect(content).not.toContain('output_path:'); }); + it('returns agent metadata and final summary without process fields', async () => { + const taskId = manager.registerAgentTask( + Promise.resolve({ result: 'SUBAGENT-FINAL-SUMMARY\n' }), + 'agent output test', + { + agentId: 'agent-child', + subagentType: 'coder', + }, + ); + await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); + + const result = await executeTool(tool, context('c_agent_output', { task_id: taskId })); + + expect(result.isError).toBe(false); + const content = toolContentString(result); + expect(content).toContain('kind: agent'); + expect(content).toContain('agent_id: agent-child'); + expect(content).toContain('actual_subagent_type: coder'); + expect(content).toContain('[output]\nSUBAGENT-FINAL-SUMMARY'); + expect(content).not.toMatch(/^pid:/m); + expect(content).not.toMatch(/^command:/m); + expect(content).not.toMatch(/^exit_code:/m); + }); + it('reads persisted output for a task loaded after restart', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-output-')); try { @@ -621,28 +647,32 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { }); describe('TaskOutputTool — terminal metadata fields', () => { - it('exposes timed_out and terminal_reason for an agent task aborted by its deadline', async () => { + it('exposes stop_reason and terminal_reason for an agent task aborted by its deadline', async () => { const manager = new BackgroundProcessManager(); try { - // An agent task whose completion never resolves, with a 0ms deadline: - // the external deadline fires and finalizes the task with timedOut=true. - const taskId = manager.registerAgentTask(new Promise<{ result: string }>(() => {}), 'slow agent', { - timeoutMs: 1, - }); + // An agent task whose completion never resolves: the external deadline + // fires and finalizes the task with a timeout stop reason. + const taskId = manager.registerAgentTask( + new Promise<{ result: string }>(() => {}), + 'slow agent', + { + timeoutMs: 1, + }, + ); await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'failed', - timedOut: true, + stopReason: BACKGROUND_TASK_TIMEOUT_STOP_REASON, }); const result = await executeTool( new TaskOutputTool(manager), - context('c_timed_out', { task_id: taskId }), + context('c_timeout', { task_id: taskId }), ); expect(result.isError).toBe(false); const content = toolContentString(result); - expect(content).toContain('timed_out: true'); + expect(content).toContain(`stop_reason: ${BACKGROUND_TASK_TIMEOUT_STOP_REASON}`); expect(content).toContain('terminal_reason: timed_out'); - expect(content).not.toContain('stop_reason:'); + expect(content).not.toContain('timed_out:'); } finally { manager._reset(); } @@ -672,7 +702,7 @@ describe('TaskOutputTool — terminal metadata fields', () => { } }); - it('omits timed_out / stop_reason / terminal_reason for a normally completed task', async () => { + it('omits stop_reason / terminal_reason for a normally completed task', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-meta-')); const manager = new BackgroundProcessManager(); manager.attachSessionDir(sessionDir); @@ -988,8 +1018,8 @@ describe('TaskOutputTool — py envelope contract', () => { } }); - // For a task that timed out (failed terminal state with timedOut=true), - // the envelope surfaces: status:failed + timed_out:true + + // For a task that timed out (failed terminal state with timeout stop reason), + // the envelope surfaces: status:failed + stop_reason:Timed out + // terminal_reason:timed_out. The Python contract also includes // `interrupted: true` and a standalone `reason:` line; TS deliberately // omits both — `interrupted` is not modeled, and the categorical @@ -997,13 +1027,13 @@ describe('TaskOutputTool — py envelope contract', () => { // (PR#243 by-design exclusion). The TS contract assertions below // suffice; the dropped assertions are documented for traceability. it('a timed-out task surfaces the full timeout contract', async () => { - // Build a manager state where status=failed and timedOut=true. + // Build a manager state where status=failed and stopReason=Timed out. const taskId = manager.registerAgentTask(new Promise(() => {}), 'will time out', { timeoutMs: 50, }); const info = await manager.waitForTerminal(taskId); expect(info?.status).toBe('failed'); - expect(info?.timedOut).toBe(true); + expect(info?.stopReason).toBe(BACKGROUND_TASK_TIMEOUT_STOP_REASON); const result = await executeTool( tool, @@ -1012,8 +1042,9 @@ describe('TaskOutputTool — py envelope contract', () => { expect(result.isError).toBe(false); const text = toolContentString(result); expect(text).toContain('status: failed'); - expect(text).toContain('timed_out: true'); + expect(text).toContain(`stop_reason: ${BACKGROUND_TASK_TIMEOUT_STOP_REASON}`); expect(text).toContain('terminal_reason: timed_out'); + expect(text).not.toContain('timed_out:'); }); // Oversized output (>32KB): the envelope truncates to a preview @@ -1107,6 +1138,7 @@ describe('background tool descriptions', () => { const tool = new TaskOutputTool(manager); const desc = tool.description; expect(desc).toMatch(/background/i); + expect(desc).toContain('Agent(run_in_background=true)'); expect(desc).toMatch(/block/); expect(desc).toMatch(/output_path/); // TS uses `Read` rather than Python's `ReadFile` for the full-log diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index d9948e96..0296ad1f 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -17,6 +17,7 @@ export type Unsubscribe = () => void; export type { AgentReplayRecord, + AgentBackgroundTaskInfo, BackgroundConfig, BackgroundTaskInfo, BackgroundTaskKind, @@ -37,6 +38,7 @@ export type { PluginMcpServerInfo, PluginSource, PluginSummary, + ProcessBackgroundTaskInfo, PromptOrigin, ProviderConfig, ProviderType, From f869daba299a2a7a710a85b53a7a75160ca0374c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 17:22:29 +0800 Subject: [PATCH 02/21] refactor: consolidate background manager --- .changeset/background-agent-runtime.md | 6 + .../{tools => agent}/background/agent-task.ts | 16 +- .../agent-core/src/agent/background/index.ts | 1099 ++++++++++++++++- .../{tools => agent}/background/persist.ts | 2 +- .../background/process-task.ts | 0 .../src/{tools => agent}/background/task.ts | 0 .../agent-core/src/agent/context/types.ts | 2 +- packages/agent-core/src/index.ts | 2 +- packages/agent-core/src/rpc/core-api.ts | 2 +- packages/agent-core/src/rpc/events.ts | 2 +- packages/agent-core/src/rpc/resumed.ts | 2 +- .../agent-core/src/tools/background/format.ts | 2 +- .../agent-core/src/tools/background/index.ts | 13 - .../src/tools/background/manager.ts | 1023 --------------- .../src/tools/background/task-list.ts | 4 +- .../src/tools/background/task-output.ts | 10 +- .../src/tools/background/task-stop.ts | 7 +- .../src/tools/builtin/collaboration/agent.ts | 7 +- .../agent-core/src/tools/builtin/index.ts | 1 - .../src/tools/builtin/shell/bash.ts | 10 +- .../test/agent/background-manager.test.ts | 34 +- .../agent/bg-idle-notification-repro.test.ts | 27 +- packages/agent-core/test/agent/resume.test.ts | 2 +- packages/agent-core/test/tools/agent.test.ts | 20 +- .../tools/background/agent-timeout.test.ts | 35 +- .../tools/background/heartbeat-stale.test.ts | 8 +- .../test/tools/background/ids.test.ts | 2 +- .../test/tools/background/lifecycle.test.ts | 15 +- .../test/tools/background/manager.test.ts | 50 +- .../tools/background/output-access.test.ts | 16 +- .../test/tools/background/persist.test.ts | 2 +- .../test/tools/background/reconcile.test.ts | 34 +- .../background/state-transitions.test.ts | 10 +- .../test/tools/background/task-tools.test.ts | 109 +- packages/agent-core/test/tools/bash.test.ts | 28 +- .../test/tools/builtin-current.test.ts | 4 +- 36 files changed, 1277 insertions(+), 1329 deletions(-) create mode 100644 .changeset/background-agent-runtime.md rename packages/agent-core/src/{tools => agent}/background/agent-task.ts (83%) rename packages/agent-core/src/{tools => agent}/background/persist.ts (99%) rename packages/agent-core/src/{tools => agent}/background/process-task.ts (100%) rename packages/agent-core/src/{tools => agent}/background/task.ts (100%) delete mode 100644 packages/agent-core/src/tools/background/manager.ts diff --git a/.changeset/background-agent-runtime.md b/.changeset/background-agent-runtime.md new file mode 100644 index 00000000..adee41b0 --- /dev/null +++ b/.changeset/background-agent-runtime.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core": patch +"@moonshot-ai/kimi-code": patch +--- + +Consolidate background task management under the agent background runtime. diff --git a/packages/agent-core/src/tools/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts similarity index 83% rename from packages/agent-core/src/tools/background/agent-task.ts rename to packages/agent-core/src/agent/background/agent-task.ts index efad2927..3a0f41fb 100644 --- a/packages/agent-core/src/tools/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -1,3 +1,5 @@ +import { sleep } from '@antfu/utils'; + import { isAbortError } from '../../loop/errors'; import { type BackgroundTask, @@ -49,21 +51,14 @@ export class AgentBackgroundTask implements BackgroundTask { sink.signal.addEventListener('abort', requestAbort, { once: true }); } - const deadlineTimeout = Symbol('background-agent-deadline'); - const raceInputs: Array | Promise> = [ + const deadlineTimeout: unique symbol = Symbol('background-agent-deadline'); + const raceInputs: Array> = [ this.completion, ]; const timeoutMs = this.timeoutMs; - let deadlineTimer: ReturnType | undefined; if (timeoutMs !== undefined && timeoutMs > 0) { - raceInputs.push( - new Promise((resolve) => { - deadlineTimer = setTimeout(() => { - resolve(deadlineTimeout); - }, timeoutMs); - }), - ); + raceInputs.push(sleep(timeoutMs).then(() => deadlineTimeout)); } try { @@ -86,7 +81,6 @@ export class AgentBackgroundTask implements BackgroundTask { } await sink.settle({ status: 'failed' }); } finally { - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); sink.signal.removeEventListener('abort', requestAbort); } } diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 98faf600..3727271e 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -1,15 +1,187 @@ +/** + * BackgroundManager — manages background tasks for an agent. + * + * Tracks background bash tasks and background subagent tasks. + * + * Each task gets a unique ID, captures stdout+stderr to a ring buffer, + * and supports status query / output retrieval / stop operations. + * + * Accepts `KaosProcess` (not `ChildProcess`) so there is no unsafe cast + * at the BashTool call site. Lifecycle detection uses `wait()` instead + * of EventEmitter `on('exit')`. + */ + +import { randomBytes } from 'node:crypto'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; import type { TelemetryPropertyValue } from '../../telemetry'; -import { - BackgroundProcessManager, - type BackgroundTaskInfo, - isBackgroundTaskTerminal, - type ReconcileResult, -} from '../../tools/builtin'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; +import { + appendTaskOutput, + listTasks, + readTaskOutput, + readTaskOutputBytes, + removeTask, + taskOutputExists, + taskOutputExistsSync, + taskOutputFile, + taskOutputSizeBytes, + writeTask, + type PersistedTask, +} from './persist'; +import { ProcessBackgroundTask } from './process-task'; +import { + TERMINAL_BACKGROUND_TASK_STATUSES, + type BackgroundTask, + type BackgroundTaskInfo, + type BackgroundTaskInfoBase, + type BackgroundTaskSink, + type BackgroundTaskStatus, +} from './task'; + +// ── Types ──────────────────────────────────────────────────────────── + +/** + * `'lost'` is a reconcile-only terminal state. Tasks loaded from disk + * that were marked `running` at startup but have no live KaosProcess + * (the previous CLI process died) are reclassified as lost. + * + * `'awaiting_approval'` is a non-terminal state entered when a background + * agent task is paused waiting for tool-call approval from the root + * agent. The BPM state machine is the single source of truth for "is + * this task actively running vs. gated on approval" — UI reads from BPM + * instead of reverse-querying the ApprovalRuntime. The loop boundary is + * preserved because `awaiting_approval` in BPM does not leak permission + * vocabulary into the loop. + */ +/** Terminal states tasks never leave once reached. */ +const TERMINAL_STATUSES = TERMINAL_BACKGROUND_TASK_STATUSES; + +export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { + return TERMINAL_STATUSES.has(status); +} + +export { AgentBackgroundTask } from './agent-task'; +export type { AgentBackgroundTaskInfo } from './agent-task'; +export { ProcessBackgroundTask } from './process-task'; +export type { ProcessBackgroundTaskInfo } from './process-task'; +export { VALID_TASK_ID } from './persist'; +export type { + BackgroundTaskInfo, + BackgroundTaskKind, + BackgroundTaskStatus, +} from './task'; + +/** Lifecycle phases observed by `onLifecycle` subscribers. */ +export type BackgroundLifecycleEvent = 'started' | 'updated' | 'terminated'; + +interface ManagedTask { + readonly taskId: string; + readonly task: BackgroundTask; + readonly outputChunks: string[]; + /** Total UTF-8 bytes observed, including chunks dropped from the live ring buffer. */ + outputSizeBytes: number; + status: BackgroundTaskStatus; + readonly startedAt: number; + endedAt: number | null; + /** Listeners awaiting task completion. */ + readonly waiters: Array<() => void>; + /** True once `fireTerminalCallbacks` has already run. */ + terminalFired: boolean; + /** Reason carried while awaiting approval. */ + approvalReason?: string | undefined; + /** Reason recorded when a task is explicitly stopped or aborted. */ + stopReason?: string | undefined; + /** Deadline supplied at registration; surfaced via task info. */ + timeoutMs?: number | undefined; + /** Non-terminal-reclassification reason (e.g. stale heartbeat). */ + failureReason?: string | undefined; + /** Cancellation signal owned by the manager and observed by the concrete task. */ + readonly abortController: AbortController; + /** Session dir captured at registration for output.log writes. */ + readonly outputSessionDir?: string | undefined; + lifecyclePromise: Promise; + persistWriteQueue: Promise; + outputWriteQueue: Promise; +} + +/** + * Maximum bytes of combined output kept in the in-memory ring buffer per + * task. When exceeded, the oldest chunks are dropped. + * + * The ring buffer is a lightweight tail intended for the `/tasks` UI and + * terminal notifications only — it deliberately discards old output to + * cap memory. It is NOT the authoritative full output: the complete, + * never-truncated log lives on disk at `/tasks//output.log`. + * Callers that need the full output (e.g. `TaskOutput`) must read the + * disk log via `getOutputSizeBytes` / `readOutputBytesFromDisk`. + */ +const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB + +const SIGTERM_GRACE_MS = 5_000; +const EXIT_SETTLE_GRACE_MS = 10; + +const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; + +/** + * Generate `{prefix}-{8 base36 chars}`. + * + * `randomBytes(8) % 36` has a modest modulo bias (256 % 36 = 4) but + * over an 8-char suffix yields ~36^8 ≈ 2.8e12 distinct ids which is + * more than enough uniqueness for per-session task ids. + */ +export function generateTaskId(kind: string): string { + const bytes = randomBytes(8); + let suffix = ''; + for (let i = 0; i < 8; i++) { + suffix += _ALPHABET[bytes[i]! % 36]; + } + return `${kind}-${suffix}`; +} + +/** + * Terminal-state info for tasks reconciled as lost on resume. They + * have no live KaosProcess and no captured output (the buffer died + * with the previous process), so list/get returns this minimal record. + */ +export interface ReconcileResult { + /** Task IDs that were marked `lost` because their process is gone. */ + readonly lost: readonly string[]; + /** Snapshot of each lost task's persisted info for terminal notifications. */ + readonly lostInfo: readonly BackgroundTaskInfo[]; +} + +export interface BackgroundManagerOptions { + readonly maxRunningTasks?: number; + readonly sessionDir?: string; +} + +export interface BackgroundTaskReservation { + release(): void; +} + +export interface BackgroundTaskOutputSnapshot { + readonly outputPath?: string; + readonly outputSizeBytes: number; + readonly previewBytes: number; + readonly truncated: boolean; + readonly fullOutputAvailable: boolean; + readonly preview: string; +} + +function emptyOutputSnapshot(): BackgroundTaskOutputSnapshot { + return { + outputSizeBytes: 0, + previewBytes: 0, + truncated: false, + fullOutputAvailable: false, + preview: '', + }; +} type BackgroundTaskNotification = Record & { readonly id: string; @@ -17,11 +189,7 @@ type BackgroundTaskNotification = Record & { readonly type: string; readonly source_kind: 'background_task'; readonly source_id: string; - /** Subagent id for agent-* tasks. Surfaced as a structured attribute so - * the LLM can pass it verbatim to `Agent(resume=...)` without confusing - * it with `source_id` (the BackgroundManager ledger id). Omitted for - * bash background tasks and for restored tasks whose previous session - * pre-dates agent_id persistence. */ + /** Subagent id accepted by Agent(resume=...). Omitted for process tasks. */ readonly agent_id?: string | undefined; readonly title: string; readonly severity: 'info' | 'warning'; @@ -37,29 +205,67 @@ interface BackgroundTaskNotificationContext { const NOTIFICATION_TAIL_BYTES = 3_000; -export class BackgroundManager extends BackgroundProcessManager { +// ── Manager ────────────────────────────────────────────────────────── + +export class BackgroundManager { + private readonly tasks = new Map(); + private reservedTaskSlots = 0; + public readonly agent?: Agent; + private readonly maxRunningTasks?: number; + /** + * Ghosts: tasks loaded from disk during reconcile that have no live + * KaosProcess. They appear in `list()` / `getTask()` with status + * `lost` so users see what was running before the crash/restart. + */ + private readonly ghosts = new Map(); + /** When set, register/lifecycle changes persist to disk. */ + private sessionDir: string | undefined; + + /** + * Registered terminal-state callbacks. Fired once per task when the + * task reaches a terminal state (completed / failed / timed_out / killed). + */ + private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = + []; + + /** + * Registered lifecycle callbacks. Fired for every observable + * transition (started / updated / terminated). Errors thrown by + * callbacks are silently swallowed so the BPM main flow never breaks + * because of a buggy subscriber. + */ + private readonly lifecycleCallbacks: Array< + (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void + > = []; private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); - constructor(public readonly agent: Agent) { - super({ - maxRunningTasks: agent.kimiConfig?.background?.maxRunningTasks, - sessionDir: agent.homedir, - }); + constructor(agentOrOptions: Agent | BackgroundManagerOptions = {}) { + if (isAgent(agentOrOptions)) { + this.agent = agentOrOptions; + this.maxRunningTasks = agentOrOptions.kimiConfig?.background?.maxRunningTasks; + this.sessionDir = agentOrOptions.homedir; + } else { + this.maxRunningTasks = agentOrOptions.maxRunningTasks; + this.sessionDir = agentOrOptions.sessionDir; + } + + const agent = this.agent; + if (agent === undefined) return; this.onLifecycle((event, info) => { switch (event) { case 'started': - this.agent.emitEvent({ type: 'background.task.started', info }); - this.agent.telemetry.track('background_task_created', { + agent.emitEvent({ type: 'background.task.started', info }); + agent.telemetry.track('background_task_created', { kind: info.kind === 'agent' ? 'agent' : 'bash', }); return; case 'updated': - this.agent.emitEvent({ type: 'background.task.updated', info }); + agent.emitEvent({ type: 'background.task.updated', info }); return; case 'terminated': { - this.agent.emitEvent({ type: 'background.task.terminated', info }); + agent.emitEvent({ type: 'background.task.terminated', info }); const success = info.status === 'completed'; const duration_s = info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; @@ -76,21 +282,711 @@ export class BackgroundManager extends BackgroundProcessManager { ? 'killed' : 'error'; } - this.agent.telemetry.track('background_task_completed', properties); + agent.telemetry.track('background_task_completed', properties); return; } } }); } - override async reconcile(): Promise { - const result = await super.reconcile(); + /** + * Register a callback that fires when any task reaches a terminal + * state. The callback receives the task's `BackgroundTaskInfo` + * snapshot. Multiple callbacks may be registered; they are invoked in + * registration order. Errors thrown by callbacks are silently swallowed. + */ + onTerminal(callback: (info: BackgroundTaskInfo) => void | Promise): void { + this.terminalCallbacks.push(callback); + } + + /** + * Register a callback that fires on every lifecycle transition: + * - 'started': task just registered (either bash or agent) + * - 'updated': awaiting_approval entered / cleared + * - 'terminated': task reached a terminal state (also triggers + * onTerminal); fires exactly once per task. + * + * Synchronous callback. Errors are swallowed so the BPM lifecycle + * machinery (status updates, persistence, waiters) cannot be blocked + * by a buggy subscriber. Use it for fan-out to RPC events; do not put + * heavy work in it (defer to microtask if needed). + */ + onLifecycle(callback: (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void): void { + this.lifecycleCallbacks.push(callback); + } + + /** Fan out a lifecycle event to subscribers. */ + private fireLifecycle(event: BackgroundLifecycleEvent, info: BackgroundTaskInfo): void { + for (const cb of this.lifecycleCallbacks) { + try { + cb(event, info); + } catch { + /* swallow callback errors */ + } + } + } + + /** + * Fire all registered terminal callbacks for a task. Idempotent: the + * second invocation for the same task is a no-op so `reconcile()` / + * a lagging `wait()` resolver / a race between `stop()` and natural + * exit cannot yield duplicate notifications. This is the manager-side + * half of the dedupe pact with `NotificationManager.dedupe_key`. + */ + private fireTerminalCallbacks(entry: ManagedTask): void { + if (entry.terminalFired) return; + entry.terminalFired = true; + const info = this.toInfo(entry); + try { + void this.notifyBackgroundTask(info).catch(() => {}); + } catch { + /* swallow */ + } + this.fireTerminalSubscribers(info); + } + + private fireTerminalSubscribers(info: BackgroundTaskInfo): void { + for (const cb of this.terminalCallbacks) { + try { + const result = cb(info); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + } catch { + /* swallow callback errors */ + } + } + this.fireLifecycle('terminated', info); + } + + private resolveWaiters(entry: ManagedTask): void { + const waiters = entry.waiters.splice(0); + for (const resolve of waiters) resolve(); + } + + private createTaskSink(entry: ManagedTask): BackgroundTaskSink { + return { + signal: entry.abortController.signal, + appendOutput: (chunk) => { + this.appendOutput(entry, chunk); + }, + settle: (settlement) => this.settleTask(entry, settlement), + }; + } + + assertCanRegister(): void { + const maxRunningTasks = this.maxRunningTasks; + if (maxRunningTasks === undefined) return; + if (this.activeTaskCount() + this.reservedTaskSlots < maxRunningTasks) return; + throw new Error('Too many background tasks are already running.'); + } + + reserveSlot(): BackgroundTaskReservation { + const maxRunningTasks = this.maxRunningTasks; + if (maxRunningTasks === undefined) { + return { release: () => {} }; + } + this.assertCanRegister(); + this.reservedTaskSlots++; + let released = false; + return { + release: () => { + if (released) return; + released = true; + this.reservedTaskSlots--; + }, + }; + } + + private activeTaskCount(): number { + let count = 0; + for (const entry of this.tasks.values()) { + if (!TERMINAL_STATUSES.has(entry.status)) count++; + } + return count; + } + + /** + * Register a KaosProcess as a background task. + * Starts capturing stdout/stderr and monitors lifecycle via `wait()`. + * Returns the assigned task ID. + * + * `opts.kind` picks the id prefix. Defaults to `'bash'` because bash + * subprocess registration is the only caller on the process path today. + * Agent tasks are constructed by their caller and registered through + * `registerTask`. + */ + register( + proc: KaosProcess, + command: string, + description: string, + opts: + | { + kind?: string; + /** + * Optional shell metadata. Carried so the `/task` UI and the + * background persist snapshot can surface which dialect a + * task was launched under. Legacy callers omitting this + * field keep the implicit 'bash' default. + */ + shellInfo?: { + shellName: string; + shellPath: string; + cwd: string; + }; + reservation?: BackgroundTaskReservation; + } + | undefined = undefined, + ): string { + return this.registerTask( + new ProcessBackgroundTask(proc, command, description, { idPrefix: opts?.kind }), + opts?.reservation, + ); + } + + registerTask( + task: BackgroundTask, + reservation?: BackgroundTaskReservation, + ): string { + if (reservation) { + reservation.release(); + } else { + this.assertCanRegister(); + } + const taskId = generateTaskId(task.idPrefix); + const entry: ManagedTask = { + taskId, + task, + outputChunks: [], + outputSizeBytes: 0, + status: 'running', + startedAt: Date.now(), + endedAt: null, + waiters: [], + terminalFired: false, + abortController: new AbortController(), + timeoutMs: task.timeoutMs, + outputSessionDir: this.sessionDir, + lifecyclePromise: Promise.resolve(), + persistWriteQueue: Promise.resolve(), + outputWriteQueue: Promise.resolve(), + }; + this.tasks.set(taskId, entry); + + const sink = this.createTaskSink(entry); + try { + entry.lifecyclePromise = Promise.resolve(task.start(sink)).catch(async () => { + await this.settleTask(entry, { + status: entry.abortController.signal.aborted ? 'killed' : 'failed', + }); + }); + } catch { + entry.lifecyclePromise = this.settleTask(entry, { + status: entry.abortController.signal.aborted ? 'killed' : 'failed', + }).then(() => {}); + } + + // Initial persistence (snapshot at start). + void this.persistLive(entry); + this.fireLifecycle('started', this.toInfo(entry)); + + void entry.lifecyclePromise; + + return taskId; + } + + /** Get info about a specific task. Falls back to reconcile ghosts. */ + getTask(taskId: string): BackgroundTaskInfo | undefined { + const entry = this.tasks.get(taskId); + if (entry !== undefined) { + return this.toInfo(entry); + } + return this.ghosts.get(taskId); + } + + /** + * Give just-ended processes a short grace period to settle their `wait()` + * promise, then return with whatever lifecycle state has been finalized. + */ + async settlePendingExits(): Promise { + const pendingCompletions = this.observedExitCompletions(); + if (pendingCompletions.length === 0) return; + await Promise.race([ + Promise.allSettled(pendingCompletions).then(() => {}), + new Promise((resolve) => { + setTimeout(resolve, EXIT_SETTLE_GRACE_MS); + }), + ]); + } + + /** + * List tasks, optionally filtering to active-only. + * + * When `activeOnly=false`, includes reconcile ghosts (lost tasks + * from a prior CLI process) so the user sees what survived the + * restart. Active-only mode never shows ghosts (they're terminal). + */ + list(activeOnly = true, limit?: number): BackgroundTaskInfo[] { + const result: BackgroundTaskInfo[] = []; + for (const entry of this.tasks.values()) { + // An awaiting_approval task is non-terminal and therefore counts + // as active in listings (UI needs to show it alongside plain + // running tasks). + if (activeOnly && TERMINAL_STATUSES.has(entry.status)) continue; + result.push(this.toInfo(entry)); + if (limit !== undefined && result.length >= limit) return result; + } + if (!activeOnly) { + for (const ghost of this.ghosts.values()) { + result.push(ghost); + if (limit !== undefined && result.length >= limit) return result; + } + } + return result; + } + + /** + * Await all pending `output.log` appends for a task to settle. + * + * Output chunks are persisted to disk on an async queue, so a task can + * reach a terminal state before its final chunks have landed on disk. + * Callers that read the on-disk log (`getOutputSizeBytes` / + * `readOutputBytesFromDisk`) should `await flushOutput()` first so they + * observe the complete log. No-op for unknown/ghost tasks. + */ + async flushOutput(taskId: string): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return; + await entry.outputWriteQueue; + } + + /** + * Total byte size of a task's full output as stored on disk. + * + * Reads `/tasks//output.log`, which is the complete, + * never-truncated log — unlike the in-memory ring buffer it never drops + * old chunks. Returns 0 when the manager is detached, the task is + * unknown, or the task has produced no output yet. + */ + async getOutputSizeBytes(taskId: string): Promise { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return 0; + return taskOutputSizeBytes(outputSessionDir, taskId); + } + + /** + * Read a byte range of a task's full output from the on-disk log. + * + * Reads up to `maxBytes` bytes starting at `offset` of `output.log`, + * straight from disk so it never loses the head of a large task the way + * the in-memory ring buffer would. Callers derive `offset` and `maxBytes` + * from a single `getOutputSizeBytes` snapshot, so the bytes returned stay + * consistent with the size used for metadata even when a still-running + * task keeps growing its log. Returns an empty string when the manager + * is detached, the task is unknown, or the log is absent. + */ + async readOutputBytesFromDisk( + taskId: string, + offset: number, + maxBytes: number, + ): Promise { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return ''; + return readTaskOutputBytes(outputSessionDir, taskId, offset, maxBytes); + } + + /** + * Return the output snapshot used by TaskOutput. + * + * Persisted logs are preferred when the task was registered with an + * output session directory and `output.log` has actually been created, + * because they are the complete, never-truncated source. Detached managers, + * tasks registered before a session dir was attached, and silent tasks with + * no persisted log fall back to the live ring buffer. + */ + async getOutputSnapshot( + taskId: string, + maxPreviewBytes: number, + ): Promise { + if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); + + await this.flushOutput(taskId); + + const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir !== undefined && (await taskOutputExists(outputSessionDir, taskId))) { + const outputSizeBytes = await taskOutputSizeBytes(outputSessionDir, taskId); + const previewOffset = Math.max(0, outputSizeBytes - previewLimit); + const previewBytes = outputSizeBytes - previewOffset; + const preview = await readTaskOutputBytes( + outputSessionDir, + taskId, + previewOffset, + previewBytes, + ); + return { + outputPath: taskOutputFile(outputSessionDir, taskId), + outputSizeBytes, + previewBytes, + truncated: previewOffset > 0, + fullOutputAvailable: true, + preview, + }; + } + + const entry = this.tasks.get(taskId); + if (entry === undefined) return emptyOutputSnapshot(); + + const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); + const previewBytes = Math.min(previewLimit, available.byteLength, entry.outputSizeBytes); + const previewOffset = available.byteLength - previewBytes; + return { + outputSizeBytes: entry.outputSizeBytes, + previewBytes, + truncated: entry.outputSizeBytes > previewBytes, + fullOutputAvailable: false, + preview: available.subarray(previewOffset).toString('utf-8'), + }; + } + + /** Get the combined output of a task (tail of the ring buffer). */ + getOutput(taskId: string, tail?: number): string { + const entry = this.tasks.get(taskId); + if (!entry) return ''; + const full = entry.outputChunks.join(''); + if (tail !== undefined && tail < full.length) { + return full.slice(-tail); + } + return full; + } + + async readOutput(taskId: string, tail?: number): Promise { + const entry = this.tasks.get(taskId); + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir !== undefined) { + await entry?.outputWriteQueue; + const persisted = await readTaskOutput(outputSessionDir, taskId); + if (persisted.length > 0) { + if (tail !== undefined && tail < persisted.length) { + return persisted.slice(-tail); + } + return persisted; + } + } + return this.getOutput(taskId, tail); + } + + getOutputPath(taskId: string): string | undefined { + const outputSessionDir = this.outputSessionDirFor(taskId); + if (outputSessionDir === undefined) return undefined; + if (!taskOutputExistsSync(outputSessionDir, taskId)) return undefined; + return taskOutputFile(outputSessionDir, taskId); + } + + /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ + async stop(taskId: string, reason?: string): Promise { + this.agent?.records.logRecord({ + type: 'background.stop', + taskId, + }); + const entry = this.tasks.get(taskId); + if (!entry) return undefined; + // Normalize at this shared boundary: every public stop path (the TaskStop + // tool, SDK/RPC) funnels through here, so a blank or whitespace-only + // reason must never be recorded as an empty stopReason. + const trimmedReason = reason?.trim(); + const stopReason = + trimmedReason === undefined || trimmedReason.length === 0 ? undefined : trimmedReason; + // Terminal tasks short-circuit. awaiting_approval tasks can still + // be stopped (the approval gate is lifted when we transition to + // 'killed'). + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + entry.approvalReason = undefined; + entry.stopReason = stopReason; + entry.abortController.abort(stopReason); + + // Wait up to 5s for the lifecycle path to settle, then SIGKILL. + // Waiting on lifecyclePromise, rather than the task directly, lets a + // natural completion win the race instead of being overwritten here. + let graceTimer: ReturnType | undefined; + const graceful = await Promise.race([ + entry.lifecyclePromise.then( + () => true, + () => true, + ), + new Promise((resolve) => { + graceTimer = setTimeout(() => { + resolve(false); + }, SIGTERM_GRACE_MS); + }), + ]); + if (graceTimer !== undefined) clearTimeout(graceTimer); + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + if (!graceful) { + try { + await entry.task.forceStop?.(); + } catch { + /* ignore */ + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + // Tasks whose lifecycle promise never settles need an explicit terminal + // finalize here after their stop/force-stop hooks have had a chance. + await this.settleTask(entry, { status: 'killed', stopReason }); + + return this.toInfo(entry); + } + + async stopAll(reason?: string): Promise { + const taskIds = Array.from(this.tasks.values()) + .filter((entry) => !TERMINAL_STATUSES.has(entry.status)) + .map((entry) => entry.taskId); + const results = await Promise.all(taskIds.map((taskId) => this.stop(taskId, reason))); + return results.filter((info): info is BackgroundTaskInfo => info !== undefined); + } + + /** + * Wait for a task to reach a terminal state. + * Returns immediately if already terminal. Times out after `timeoutMs`. + */ + async wait(taskId: string, timeoutMs = 30_000): Promise { + const entry = this.tasks.get(taskId); + if (!entry) return undefined; + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + let terminalWaiter: (() => void) | undefined; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + new Promise((resolve) => { + terminalWaiter = resolve; + entry.waiters.push(resolve); + }), + new Promise((resolve) => { + timeout = setTimeout(resolve, timeoutMs); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (terminalWaiter !== undefined) { + const index = entry.waiters.indexOf(terminalWaiter); + if (index !== -1) entry.waiters.splice(index, 1); + } + } + + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + } + return this.toInfo(entry); + } + + // ── awaiting_approval state transitions ──────────────────────────── + + /** + * Mark a running task as paused pending approval. The approval reason + * (tool call description) is retained until the task either returns + * to `'running'` via `clearAwaitingApproval()` or reaches a terminal + * state. Calls on terminal or unknown tasks are silently ignored so + * the ApprovalRuntime callback path is race-safe. + */ + markAwaitingApproval(taskId: string, reason: string): void { + const entry = this.tasks.get(taskId); + if (!entry) return; + if (TERMINAL_STATUSES.has(entry.status)) return; + entry.status = 'awaiting_approval'; + entry.approvalReason = reason; + void this.persistLive(entry); + this.fireLifecycle('updated', this.toInfo(entry)); + } + + /** + * Drop the approval gate and return to `'running'`. Clears the stored + * reason so stale text cannot leak into a future `awaiting_approval` + * cycle. No-op unless the task is currently in the awaiting_approval + * state. + */ + clearAwaitingApproval(taskId: string): void { + const entry = this.tasks.get(taskId); + if (!entry) return; + if (entry.status !== 'awaiting_approval') return; + entry.status = 'running'; + entry.approvalReason = undefined; + void this.persistLive(entry); + this.fireLifecycle('updated', this.toInfo(entry)); + } + + // ── completion event (await lifecycle end) ──────────────────────── + + /** + * Resolve when the task reaches a terminal state. If the task is + * already terminal, resolves synchronously on the next microtask. + * Intended for integration code that wants to `await` a specific + * task's exit without installing a full `onTerminal` subscriber. + * Returns `undefined` for unknown ids (matching `getTask`). Ghost + * (reconciled-lost) entries are considered terminal from the + * manager's perspective. + */ + async waitForTerminal(taskId: string): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined) return this.ghosts.get(taskId); + if (TERMINAL_STATUSES.has(entry.status)) { + await entry.persistWriteQueue; + return this.toInfo(entry); + } + await new Promise((resolve) => { + entry.waiters.push(resolve); + }); + await entry.persistWriteQueue; + return this.toInfo(entry); + } + + /** Reset internal state (for testing). */ + _reset(): void { + this.tasks.clear(); + this.ghosts.clear(); + this.sessionDir = undefined; + this.scheduledNotificationKeys.clear(); + this.deliveredNotificationKeys.clear(); + } + + // ── persistence + reconcile ──────────────────────────────────────── + + /** + * Attach the manager to a session directory for persistence. Tasks + * created via `register()` after this call are written to + * `/tasks/.json` and updated on lifecycle change. + * Tasks created before attach are NOT retroactively persisted. + */ + attachSessionDir(sessionDir: string): void { + this.sessionDir = sessionDir; + } + + /** + * Load persisted task records into the ghost map. Does NOT reconcile + * (call `reconcile()` after `loadFromDisk()`). Idempotent; subsequent + * calls overwrite the ghost map. + * + * Requires `attachSessionDir()` first; no-op otherwise. + */ + async loadFromDisk(): Promise { + if (this.sessionDir === undefined) return; + this.ghosts.clear(); + const persisted = await listTasks(this.sessionDir); + for (const t of persisted) { + // Skip ids that already exist as live processes — live wins. + if (this.tasks.has(t.task_id)) continue; + this.ghosts.set(t.task_id, persistedToInfo(t)); + } + } + + /** + * Reconcile loaded ghost tasks. Any ghost with status `running` is + * reclassified as `lost` (its previous CLI process died without + * writing a terminal state). Updates the on-disk record and returns + * the lost task ids so the caller can emit user-facing notifications. + */ + protected async markLoadedTasksLost(): Promise { + const lost: string[] = []; + const lostInfo: BackgroundTaskInfo[] = []; + for (const [id, info] of this.ghosts) { + // Any non-terminal ghost is lost. Includes `awaiting_approval` + // (the approval context died with the previous process so it + // cannot be resumed). + if (TERMINAL_STATUSES.has(info.status)) continue; + const updated: BackgroundTaskInfo = { + ...info, + status: 'lost', + endedAt: info.endedAt ?? Date.now(), + approvalReason: undefined, + failureReason: 'Background worker heartbeat expired', + }; + this.ghosts.set(id, updated); + if (this.sessionDir !== undefined) { + await writeTask(this.sessionDir, infoToPersisted(updated)); + } + lost.push(id); + lostInfo.push(updated); + } + return { lost, lostInfo }; + } + + async reconcile(): Promise { + const result = await this.markLoadedTasksLost(); + // Fire onTerminal for newly-lost ghosts so NotificationManager + // receives a `task.lost` notification. Dedupe on the consumer side + // is by `dedupe_key`; a second reconcile() on the same ghost is a + // no-op because the status flips to `lost` above and we guard on + // TERMINAL_STATUSES on the next pass. + for (const info of result.lostInfo) { + this.fireTerminalSubscribers(info); + } await this.restoreBackgroundTaskNotifications(); return result; } - protected override onLiveTaskTerminal(info: BackgroundTaskInfo): void | Promise { - return this.notifyBackgroundTask(info); + /** Drop a persisted task from disk and ghost map. */ + async forgetTask(taskId: string): Promise { + this.ghosts.delete(taskId); + if (this.sessionDir !== undefined) { + await removeTask(this.sessionDir, taskId); + } + } + + /** + * Persist the current state of a live ManagedTask. Called from + * `register()` and the lifecycle finally block. No-op unless attached. + */ + private persistLive(entry: ManagedTask): Promise { + if (this.sessionDir === undefined) return Promise.resolve(); + const sessionDir = this.sessionDir; + const info = this.toInfo(entry); + const task: PersistedTask = infoToPersisted(info); + entry.persistWriteQueue = entry.persistWriteQueue + .then(() => writeTask(sessionDir, task)) + .catch(() => {}); + return entry.persistWriteQueue; + } + + private appendOutput(entry: ManagedTask, chunk: string): void { + entry.outputSizeBytes += Buffer.byteLength(chunk, 'utf-8'); + entry.outputChunks.push(chunk); + // Enforce output cap: drop oldest chunks when over budget. + let total = entry.outputChunks.reduce((s, c) => s + c.length, 0); + while (total > MAX_OUTPUT_BYTES && entry.outputChunks.length > 1) { + const removed = entry.outputChunks.shift(); + if (removed === undefined) break; + total -= removed.length; + } + + const outputSessionDir = entry.outputSessionDir; + if (outputSessionDir === undefined) return; + entry.outputWriteQueue = entry.outputWriteQueue + .then(() => appendTaskOutput(outputSessionDir, entry.taskId, chunk)) + .catch(() => {}); + } + + private outputSessionDirFor(taskId: string): string | undefined { + const entry = this.tasks.get(taskId); + if (entry !== undefined) return entry.outputSessionDir; + if (this.ghosts.has(taskId)) return this.sessionDir; + return undefined; } private async restoreBackgroundTaskNotifications(): Promise { @@ -101,16 +997,20 @@ export class BackgroundManager extends BackgroundProcessManager { } private async notifyBackgroundTask(info: BackgroundTaskInfo): Promise { + const agent = this.agent; + if (agent === undefined) return; const context = await this.buildBackgroundTaskNotificationContext(info); if (context === undefined) return; - this.agent.turn.steer(context.content, context.origin); + agent.turn.steer(context.content, context.origin); this.fireNotificationHook(context.notification); } private async restoreBackgroundTaskNotification(info: BackgroundTaskInfo): Promise { + const agent = this.agent; + if (agent === undefined) return; const context = await this.buildBackgroundTaskNotificationContext(info); if (context === undefined) return; - this.agent.context.appendUserMessage(context.content, context.origin); + agent.context.appendUserMessage(context.content, context.origin); this.fireNotificationHook(context.notification); } @@ -156,7 +1056,7 @@ export class BackgroundManager extends BackgroundProcessManager { } private fireNotificationHook(notification: BackgroundTaskNotification): void { - void this.agent.hooks?.fireAndForgetTrigger('Notification', { + void this.agent?.hooks?.fireAndForgetTrigger('Notification', { matcherValue: notification.type, inputData: { sink: 'context', @@ -178,47 +1078,134 @@ export class BackgroundManager extends BackgroundProcessManager { return this.deliveredNotificationKeys.has(notificationKey(origin)); } - override stop(taskId: string, reason?: string) { - this.agent.records.logRecord({ - type: 'background.stop', - taskId, - }); - return super.stop(taskId, reason); + private async settleTask( + entry: ManagedTask, + settlement: { + readonly status: 'completed' | 'failed' | 'timed_out' | 'killed'; + readonly stopReason?: string; + }, + ): Promise { + if (TERMINAL_STATUSES.has(entry.status)) { + if (entry.status === 'killed' && settlement.status === 'killed') { + entry.endedAt = Math.max(Date.now(), (entry.endedAt ?? 0) + 1); + await this.persistLive(entry); + this.fireTerminalCallbacks(entry); + this.resolveWaiters(entry); + } + return false; + } + entry.status = settlement.status; + entry.endedAt = Date.now(); + entry.stopReason = + settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); + // A task that ended while still in awaiting_approval (e.g. crashed + // mid-prompt, deadline fired, or got killed) must not leak the + // stale approvalReason onto the terminal record. The awaiting → + // running path (clearAwaitingApproval) already clears it; mirror + // that here for the awaiting → terminal path. + entry.approvalReason = undefined; + await this.persistLive(entry); + this.fireTerminalCallbacks(entry); + this.resolveWaiters(entry); + return true; } - override _reset(): void { - super._reset(); - this.scheduledNotificationKeys.clear(); - this.deliveredNotificationKeys.clear(); + private observedExitCompletions(): Promise[] { + const completions: Promise[] = []; + for (const entry of this.tasks.values()) { + if (!TERMINAL_STATUSES.has(entry.status) && entry.task.hasObservedTerminal?.() === true) { + completions.push(entry.lifecyclePromise); + } + } + return completions; + } + + private toInfo(entry: ManagedTask): BackgroundTaskInfo { + const base: BackgroundTaskInfoBase = { + taskId: entry.taskId, + kind: entry.task.kind, + description: entry.task.description, + status: entry.status, + startedAt: entry.startedAt, + endedAt: entry.endedAt, + approvalReason: entry.approvalReason, + stopReason: entry.stopReason, + timeoutMs: entry.timeoutMs, + failureReason: entry.failureReason, + }; + return entry.task.toInfo(base); } + +} + +// ── persistence shape <-> in-memory shape ────────────────────────────── + +function persistedToInfo(t: PersistedTask): BackgroundTaskInfo { + const status = t.timed_out === true ? 'timed_out' : t.status; + const base: BackgroundTaskInfoBase = { + taskId: t.task_id, + kind: t.kind ?? (t.task_id.startsWith('agent-') ? 'agent' : 'process'), + description: t.description, + status, + startedAt: t.started_at, + endedAt: t.ended_at, + approvalReason: t.approval_reason, + stopReason: t.stop_reason, + }; + if (base.kind === 'agent') { + return { + ...base, + kind: 'agent', + agentId: t.agent_id, + subagentType: t.subagent_type, + }; + } + return { + ...base, + kind: 'process', + command: t.command, + pid: t.pid, + exitCode: t.exit_code, + }; +} + +function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { + const command = info.kind === 'process' ? info.command : `[agent] ${info.description}`; + const pid = info.kind === 'process' ? info.pid : 0; + return { + task_id: info.taskId, + kind: info.kind, + command, + description: info.description, + pid, + started_at: info.startedAt, + ended_at: info.endedAt, + exit_code: info.kind === 'process' ? info.exitCode : null, + status: info.status, + approval_reason: info.approvalReason, + stop_reason: info.stopReason, + agent_id: info.kind === 'agent' ? info.agentId : undefined, + subagent_type: info.kind === 'agent' ? info.subagentType : undefined, + }; +} + +function isAgent(value: Agent | BackgroundManagerOptions): value is Agent { + return typeof value === 'object' && value !== null && 'emitEvent' in value && 'turn' in value; } function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } -/** - * Build the human/LLM-readable body that lands in the `` - * XML. For agent-* tasks that ended non-successfully and whose subagent id - * we still know, append a paragraph telling the LLM exactly how to resume - * — which id to pass, how to distinguish it from the look-alike `source_id`, - * and what state the resumed subagent will and will not have. The intent is - * to make recovery a one-shot decision instead of a memory lookup against - * the original spawn-success ToolResult. - * - * Bash tasks, successful agent tasks, and restored agent tasks from - * sessions that pre-date `agent_id` persistence keep the original - * single-sentence body. - */ function buildBackgroundTaskNotificationBody(info: BackgroundTaskInfo): string { const baseLine = info.status === 'timed_out' ? `${info.description} timed out.` : info.stopReason - ? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${ - info.stopReason - }.` - : `${info.description} ${info.status}.`; + ? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${ + info.stopReason + }.` + : `${info.description} ${info.status}.`; if (info.kind !== 'agent') return baseLine; if (info.status === 'completed') return baseLine; diff --git a/packages/agent-core/src/tools/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts similarity index 99% rename from packages/agent-core/src/tools/background/persist.ts rename to packages/agent-core/src/agent/background/persist.ts index 1288c26a..564ee3c2 100644 --- a/packages/agent-core/src/tools/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -55,7 +55,7 @@ export interface PersistedTask { readonly stop_reason?: string | undefined; /** * Shell origin metadata (name / path / cwd) captured when - * `BackgroundProcessManager.register` attached a `shellInfo` option. + * `BackgroundManager.register` attached a `shellInfo` option. * Persisted so restart can reconstruct the spawn environment. */ readonly shell_info?: diff --git a/packages/agent-core/src/tools/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts similarity index 100% rename from packages/agent-core/src/tools/background/process-task.ts rename to packages/agent-core/src/agent/background/process-task.ts diff --git a/packages/agent-core/src/tools/background/task.ts b/packages/agent-core/src/agent/background/task.ts similarity index 100% rename from packages/agent-core/src/tools/background/task.ts rename to packages/agent-core/src/agent/background/task.ts diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index ad57895c..5bfbf523 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -1,7 +1,7 @@ import type { ContentPart, Message } from '@moonshot-ai/kosong'; import type { SkillSource } from '../../skill'; -import type { BackgroundTaskStatus } from '../../tools/background'; +import type { BackgroundTaskStatus } from '../background'; export interface UserPromptOrigin { readonly kind: 'user'; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index a44241e2..37cf9dd9 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -41,7 +41,7 @@ export type { BackgroundTaskKind, BackgroundTaskStatus, ProcessBackgroundTaskInfo, -} from './tools/background/manager'; +} from './agent/background'; export type { ToolServices } from './tools/support/services'; export { SingleModelProvider } from './session/provider-manager'; export type { diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 504e9a30..648c07d0 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -1,5 +1,6 @@ import type { AgentConfigData } from '#/agent/config'; import type { AgentContextData } from '#/agent/context'; +import type { BackgroundTaskInfo } from '#/agent/background'; import type { PermissionData, PermissionMode } from '#/agent/permission'; import type { PlanData } from '#/agent/plan'; import type { ToolInfo } from '#/agent/tool'; @@ -7,7 +8,6 @@ import type { KimiConfig, KimiConfigPatch } from '#/config'; import type { ExperimentalFlagMap } from '#/flags'; import type { ResumeSessionResult } from '#/rpc/resumed'; import type { SessionMeta } from '#/session'; -import type { BackgroundTaskInfo } from '#/tools/builtin'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { PluginInfo, PluginSummary, ReloadSummary } from '#/plugin'; diff --git a/packages/agent-core/src/rpc/events.ts b/packages/agent-core/src/rpc/events.ts index 5d8f99e3..62db189e 100644 --- a/packages/agent-core/src/rpc/events.ts +++ b/packages/agent-core/src/rpc/events.ts @@ -4,7 +4,7 @@ import type { CronJobOrigin, PromptOrigin } from '../agent/context'; import type { KimiErrorPayload } from '../errors'; import type { PermissionMode } from '../agent/permission'; import type { SkillSource } from '../skill'; -import type { BackgroundTaskInfo } from '../tools/background/manager'; +import type { BackgroundTaskInfo } from '../agent/background'; import type { ToolInputDisplay } from '../tools/display'; export type { ToolInputDisplay } from '../tools/display'; diff --git a/packages/agent-core/src/rpc/resumed.ts b/packages/agent-core/src/rpc/resumed.ts index 211f2571..1259627f 100644 --- a/packages/agent-core/src/rpc/resumed.ts +++ b/packages/agent-core/src/rpc/resumed.ts @@ -1,4 +1,5 @@ import type { AgentType } from '#/agent'; +import type { BackgroundTaskInfo } from '#/agent/background'; import type { AgentConfigData, AgentConfigUpdateData } from '#/agent/config'; import type { AgentContextData, ContextMessage } from '#/agent/context'; import type { @@ -11,7 +12,6 @@ import type { ToolInfo } from '#/agent/tool'; import type { SessionSummary } from '#/rpc/core-api'; import type { UsageStatus } from '#/rpc/events'; import type { SessionMeta } from '#/session'; -import type { BackgroundTaskInfo } from '#/tools/builtin'; export type AgentReplayRecord = | { type: 'message'; message: ContextMessage } diff --git a/packages/agent-core/src/tools/background/format.ts b/packages/agent-core/src/tools/background/format.ts index dcb6b5c5..0f14bc12 100644 --- a/packages/agent-core/src/tools/background/format.ts +++ b/packages/agent-core/src/tools/background/format.ts @@ -3,7 +3,7 @@ function formatValue(value: unknown): string { } function fieldName(key: string): string { - return key.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); + return key.replaceAll(/[A-Z]/g, (match) => `_${match.toLowerCase()}`); } export function formatPlainObject(record: object): string { diff --git a/packages/agent-core/src/tools/background/index.ts b/packages/agent-core/src/tools/background/index.ts index d305cef3..8e4e28ed 100644 --- a/packages/agent-core/src/tools/background/index.ts +++ b/packages/agent-core/src/tools/background/index.ts @@ -2,19 +2,6 @@ * Background task management tools barrel. */ -export { BackgroundProcessManager, generateTaskId } from './manager'; -export type { - AgentBackgroundTaskInfo, - BackgroundTaskInfo, - BackgroundTaskKind, - BackgroundTaskOutputSnapshot, - BackgroundTaskStatus, - ProcessBackgroundTaskInfo, - ReconcileResult, -} from './manager'; -export { AgentBackgroundTask } from './agent-task'; -export { ProcessBackgroundTask } from './process-task'; -export { VALID_TASK_ID } from './persist'; export { TaskListTool, TaskListInputSchema } from './task-list'; export type { TaskListInput } from './task-list'; export { TaskOutputTool, TaskOutputInputSchema } from './task-output'; diff --git a/packages/agent-core/src/tools/background/manager.ts b/packages/agent-core/src/tools/background/manager.ts deleted file mode 100644 index e19d813c..00000000 --- a/packages/agent-core/src/tools/background/manager.ts +++ /dev/null @@ -1,1023 +0,0 @@ -/** - * BackgroundProcessManager — manages background tasks. - * - * Tracks background bash tasks and background subagent tasks. - * - * Each task gets a unique ID, captures stdout+stderr to a ring buffer, - * and supports status query / output retrieval / stop operations. - * - * Accepts `KaosProcess` (not `ChildProcess`) so there is no unsafe cast - * at the BashTool call site. Lifecycle detection uses `wait()` instead - * of EventEmitter `on('exit')`. - */ - -import { randomBytes } from 'node:crypto'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; - -import { - appendTaskOutput, - listTasks, - readTaskOutput, - readTaskOutputBytes, - removeTask, - taskOutputExists, - taskOutputExistsSync, - taskOutputFile, - taskOutputSizeBytes, - writeTask, - type PersistedTask, -} from './persist'; -import { ProcessBackgroundTask } from './process-task'; -import { - TERMINAL_BACKGROUND_TASK_STATUSES, - type BackgroundTask, - type BackgroundTaskInfo, - type BackgroundTaskInfoBase, - type BackgroundTaskSink, - type BackgroundTaskStatus, -} from './task'; - -// ── Types ──────────────────────────────────────────────────────────── - -/** - * `'lost'` is a reconcile-only terminal state. Tasks loaded from disk - * that were marked `running` at startup but have no live KaosProcess - * (the previous CLI process died) are reclassified as lost. - * - * `'awaiting_approval'` is a non-terminal state entered when a background - * agent task is paused waiting for tool-call approval from the root - * agent. The BPM state machine is the single source of truth for "is - * this task actively running vs. gated on approval" — UI reads from BPM - * instead of reverse-querying the ApprovalRuntime. The loop boundary is - * preserved because `awaiting_approval` in BPM does not leak permission - * vocabulary into the loop. - */ -/** Terminal states tasks never leave once reached. */ -const TERMINAL_STATUSES = TERMINAL_BACKGROUND_TASK_STATUSES; - -export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { - return TERMINAL_STATUSES.has(status); -} - -export type { AgentBackgroundTaskInfo } from './agent-task'; -export type { ProcessBackgroundTaskInfo } from './process-task'; -export type { - BackgroundTaskInfo, - BackgroundTaskKind, BackgroundTaskStatus -} from './task'; - -/** Lifecycle phases observed by `onLifecycle` subscribers. */ -export type BackgroundLifecycleEvent = 'started' | 'updated' | 'terminated'; - -interface ManagedTask { - readonly taskId: string; - readonly task: BackgroundTask; - readonly outputChunks: string[]; - /** Total UTF-8 bytes observed, including chunks dropped from the live ring buffer. */ - outputSizeBytes: number; - status: BackgroundTaskStatus; - readonly startedAt: number; - endedAt: number | null; - /** Listeners awaiting task completion. */ - readonly waiters: Array<() => void>; - /** True once `fireTerminalCallbacks` has already run. */ - terminalFired: boolean; - /** Reason carried while awaiting approval. */ - approvalReason?: string | undefined; - /** Reason recorded when a task is explicitly stopped or aborted. */ - stopReason?: string | undefined; - /** Deadline supplied at registration; surfaced via task info. */ - timeoutMs?: number | undefined; - /** Non-terminal-reclassification reason (e.g. stale heartbeat). */ - failureReason?: string | undefined; - /** Cancellation signal owned by the manager and observed by the concrete task. */ - readonly abortController: AbortController; - /** Session dir captured at registration for output.log writes. */ - readonly outputSessionDir?: string | undefined; - lifecyclePromise: Promise; - persistWriteQueue: Promise; - outputWriteQueue: Promise; -} - -/** - * Maximum bytes of combined output kept in the in-memory ring buffer per - * task. When exceeded, the oldest chunks are dropped. - * - * The ring buffer is a lightweight tail intended for the `/tasks` UI and - * terminal notifications only — it deliberately discards old output to - * cap memory. It is NOT the authoritative full output: the complete, - * never-truncated log lives on disk at `/tasks//output.log`. - * Callers that need the full output (e.g. `TaskOutput`) must read the - * disk log via `getOutputSizeBytes` / `readOutputBytesFromDisk`. - */ -const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB - -const SIGTERM_GRACE_MS = 5_000; -const EXIT_SETTLE_GRACE_MS = 10; - -const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; - -/** - * Generate `{prefix}-{8 base36 chars}`. - * - * `randomBytes(8) % 36` has a modest modulo bias (256 % 36 = 4) but - * over an 8-char suffix yields ~36^8 ≈ 2.8e12 distinct ids which is - * more than enough uniqueness for per-session task ids. - */ -export function generateTaskId(kind: string): string { - const bytes = randomBytes(8); - let suffix = ''; - for (let i = 0; i < 8; i++) { - suffix += _ALPHABET[bytes[i]! % 36]; - } - return `${kind}-${suffix}`; -} - -/** - * Terminal-state info for tasks reconciled as lost on resume. They - * have no live KaosProcess and no captured output (the buffer died - * with the previous process), so list/get returns this minimal record. - */ -export interface ReconcileResult { - /** Task IDs that were marked `lost` because their process is gone. */ - readonly lost: readonly string[]; - /** Snapshot of each lost task's persisted info for terminal notifications. */ - readonly lostInfo: readonly BackgroundTaskInfo[]; -} - -export interface BackgroundProcessManagerOptions { - readonly maxRunningTasks?: number; - readonly sessionDir?: string; -} - -export interface BackgroundTaskReservation { - release(): void; -} - -export interface BackgroundTaskOutputSnapshot { - readonly outputPath?: string; - readonly outputSizeBytes: number; - readonly previewBytes: number; - readonly truncated: boolean; - readonly fullOutputAvailable: boolean; - readonly preview: string; -} - -function emptyOutputSnapshot(): BackgroundTaskOutputSnapshot { - return { - outputSizeBytes: 0, - previewBytes: 0, - truncated: false, - fullOutputAvailable: false, - preview: '', - }; -} - -// ── Manager ────────────────────────────────────────────────────────── - -export class BackgroundProcessManager { - private readonly tasks = new Map(); - private reservedTaskSlots = 0; - /** - * Ghosts: tasks loaded from disk during reconcile that have no live - * KaosProcess. They appear in `list()` / `getTask()` with status - * `lost` so users see what was running before the crash/restart. - */ - private readonly ghosts = new Map(); - /** When set, register/lifecycle changes persist to disk. */ - private sessionDir: string | undefined; - - /** - * Registered terminal-state callbacks. Fired once per task when the - * task reaches a terminal state (completed / failed / timed_out / killed). - */ - private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = - []; - - /** - * Registered lifecycle callbacks. Fired for every observable - * transition (started / updated / terminated). Errors thrown by - * callbacks are silently swallowed so the BPM main flow never breaks - * because of a buggy subscriber. - */ - private readonly lifecycleCallbacks: Array< - (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void - > = []; - - constructor(private readonly options: BackgroundProcessManagerOptions = {}) { - this.sessionDir = options.sessionDir; - } - - /** - * Register a callback that fires when any task reaches a terminal - * state. The callback receives the task's `BackgroundTaskInfo` - * snapshot. Multiple callbacks may be registered; they are invoked in - * registration order. Errors thrown by callbacks are silently swallowed. - */ - onTerminal(callback: (info: BackgroundTaskInfo) => void | Promise): void { - this.terminalCallbacks.push(callback); - } - - /** - * Register a callback that fires on every lifecycle transition: - * - 'started': task just registered (either bash or agent) - * - 'updated': awaiting_approval entered / cleared - * - 'terminated': task reached a terminal state (also triggers - * onTerminal); fires exactly once per task. - * - * Synchronous callback. Errors are swallowed so the BPM lifecycle - * machinery (status updates, persistence, waiters) cannot be blocked - * by a buggy subscriber. Use it for fan-out to RPC events; do not put - * heavy work in it (defer to microtask if needed). - */ - onLifecycle(callback: (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void): void { - this.lifecycleCallbacks.push(callback); - } - - /** Fan out a lifecycle event to subscribers. */ - private fireLifecycle(event: BackgroundLifecycleEvent, info: BackgroundTaskInfo): void { - for (const cb of this.lifecycleCallbacks) { - try { - cb(event, info); - } catch { - /* swallow callback errors */ - } - } - } - - /** - * Subclasses can react to live task completion here. Restored disk - * tasks reconciled as lost do not call this hook. - */ - protected onLiveTaskTerminal(_info: BackgroundTaskInfo): void | Promise {} - - /** - * Fire all registered terminal callbacks for a task. Idempotent: the - * second invocation for the same task is a no-op so `reconcile()` / - * a lagging `wait()` resolver / a race between `stop()` and natural - * exit cannot yield duplicate notifications. This is the manager-side - * half of the dedupe pact with `NotificationManager.dedupe_key`. - */ - private fireTerminalCallbacks(entry: ManagedTask): void { - if (entry.terminalFired) return; - entry.terminalFired = true; - const info = this.toInfo(entry); - try { - const result = this.onLiveTaskTerminal(info); - if (result && typeof result.catch === 'function') { - result.catch(() => {}); - } - } catch { - /* swallow */ - } - this.fireTerminalSubscribers(info); - } - - private fireTerminalSubscribers(info: BackgroundTaskInfo): void { - for (const cb of this.terminalCallbacks) { - try { - const result = cb(info); - if (result && typeof result.catch === 'function') { - result.catch(() => {}); - } - } catch { - /* swallow callback errors */ - } - } - this.fireLifecycle('terminated', info); - } - - private resolveWaiters(entry: ManagedTask): void { - const waiters = entry.waiters.splice(0); - for (const resolve of waiters) resolve(); - } - - private createTaskSink(entry: ManagedTask): BackgroundTaskSink { - return { - signal: entry.abortController.signal, - appendOutput: (chunk) => { - this.appendOutput(entry, chunk); - }, - settle: (settlement) => this.settleTask(entry, settlement), - }; - } - - assertCanRegister(): void { - const maxRunningTasks = this.options.maxRunningTasks; - if (maxRunningTasks === undefined) return; - if (this.activeTaskCount() + this.reservedTaskSlots < maxRunningTasks) return; - throw new Error('Too many background tasks are already running.'); - } - - reserveSlot(): BackgroundTaskReservation { - const maxRunningTasks = this.options.maxRunningTasks; - if (maxRunningTasks === undefined) { - return { release: () => {} }; - } - this.assertCanRegister(); - this.reservedTaskSlots++; - let released = false; - return { - release: () => { - if (released) return; - released = true; - this.reservedTaskSlots--; - }, - }; - } - - private activeTaskCount(): number { - let count = 0; - for (const entry of this.tasks.values()) { - if (!TERMINAL_STATUSES.has(entry.status)) count++; - } - return count; - } - - /** - * Register a KaosProcess as a background task. - * Starts capturing stdout/stderr and monitors lifecycle via `wait()`. - * Returns the assigned task ID. - * - * `opts.kind` picks the id prefix. Defaults to `'bash'` because bash - * subprocess registration is the only caller on the process path today. - * Agent tasks are constructed by their caller and registered through - * `registerTask`. - */ - register( - proc: KaosProcess, - command: string, - description: string, - opts: - | { - kind?: string; - /** - * Optional shell metadata. Carried so the `/task` UI and the - * background persist snapshot can surface which dialect a - * task was launched under. Legacy callers omitting this - * field keep the implicit 'bash' default. - */ - shellInfo?: { - shellName: string; - shellPath: string; - cwd: string; - }; - reservation?: BackgroundTaskReservation; - } - | undefined = undefined, - ): string { - return this.registerTask( - new ProcessBackgroundTask(proc, command, description, { idPrefix: opts?.kind }), - opts?.reservation, - ); - } - - registerTask( - task: BackgroundTask, - reservation?: BackgroundTaskReservation, - ): string { - if (reservation) { - reservation.release(); - } else { - this.assertCanRegister(); - } - const taskId = generateTaskId(task.idPrefix); - const entry: ManagedTask = { - taskId, - task, - outputChunks: [], - outputSizeBytes: 0, - status: 'running', - startedAt: Date.now(), - endedAt: null, - waiters: [], - terminalFired: false, - abortController: new AbortController(), - timeoutMs: task.timeoutMs, - outputSessionDir: this.sessionDir, - lifecyclePromise: Promise.resolve(), - persistWriteQueue: Promise.resolve(), - outputWriteQueue: Promise.resolve(), - }; - this.tasks.set(taskId, entry); - - const sink = this.createTaskSink(entry); - try { - entry.lifecyclePromise = Promise.resolve(task.start(sink)).catch(async () => { - await this.settleTask(entry, { - status: entry.abortController.signal.aborted ? 'killed' : 'failed', - }); - }); - } catch { - entry.lifecyclePromise = this.settleTask(entry, { - status: entry.abortController.signal.aborted ? 'killed' : 'failed', - }).then(() => {}); - } - - // Initial persistence (snapshot at start). - void this.persistLive(entry); - this.fireLifecycle('started', this.toInfo(entry)); - - void entry.lifecyclePromise; - - return taskId; - } - - /** Get info about a specific task. Falls back to reconcile ghosts. */ - getTask(taskId: string): BackgroundTaskInfo | undefined { - const entry = this.tasks.get(taskId); - if (entry !== undefined) { - return this.toInfo(entry); - } - return this.ghosts.get(taskId); - } - - /** - * Give just-ended processes a short grace period to settle their `wait()` - * promise, then return with whatever lifecycle state has been finalized. - */ - async settlePendingExits(): Promise { - const pendingCompletions = this.observedExitCompletions(); - if (pendingCompletions.length === 0) return; - await Promise.race([ - Promise.allSettled(pendingCompletions).then(() => {}), - new Promise((resolve) => { - setTimeout(resolve, EXIT_SETTLE_GRACE_MS); - }), - ]); - } - - /** - * List tasks, optionally filtering to active-only. - * - * When `activeOnly=false`, includes reconcile ghosts (lost tasks - * from a prior CLI process) so the user sees what survived the - * restart. Active-only mode never shows ghosts (they're terminal). - */ - list(activeOnly = true, limit?: number): BackgroundTaskInfo[] { - const result: BackgroundTaskInfo[] = []; - for (const entry of this.tasks.values()) { - // An awaiting_approval task is non-terminal and therefore counts - // as active in listings (UI needs to show it alongside plain - // running tasks). - if (activeOnly && TERMINAL_STATUSES.has(entry.status)) continue; - result.push(this.toInfo(entry)); - if (limit !== undefined && result.length >= limit) return result; - } - if (!activeOnly) { - for (const ghost of this.ghosts.values()) { - result.push(ghost); - if (limit !== undefined && result.length >= limit) return result; - } - } - return result; - } - - /** - * Await all pending `output.log` appends for a task to settle. - * - * Output chunks are persisted to disk on an async queue, so a task can - * reach a terminal state before its final chunks have landed on disk. - * Callers that read the on-disk log (`getOutputSizeBytes` / - * `readOutputBytesFromDisk`) should `await flushOutput()` first so they - * observe the complete log. No-op for unknown/ghost tasks. - */ - async flushOutput(taskId: string): Promise { - const entry = this.tasks.get(taskId); - if (entry === undefined) return; - await entry.outputWriteQueue; - } - - /** - * Total byte size of a task's full output as stored on disk. - * - * Reads `/tasks//output.log`, which is the complete, - * never-truncated log — unlike the in-memory ring buffer it never drops - * old chunks. Returns 0 when the manager is detached, the task is - * unknown, or the task has produced no output yet. - */ - async getOutputSizeBytes(taskId: string): Promise { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return 0; - return taskOutputSizeBytes(outputSessionDir, taskId); - } - - /** - * Read a byte range of a task's full output from the on-disk log. - * - * Reads up to `maxBytes` bytes starting at `offset` of `output.log`, - * straight from disk so it never loses the head of a large task the way - * the in-memory ring buffer would. Callers derive `offset` and `maxBytes` - * from a single `getOutputSizeBytes` snapshot, so the bytes returned stay - * consistent with the size used for metadata even when a still-running - * task keeps growing its log. Returns an empty string when the manager - * is detached, the task is unknown, or the log is absent. - */ - async readOutputBytesFromDisk( - taskId: string, - offset: number, - maxBytes: number, - ): Promise { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return ''; - return readTaskOutputBytes(outputSessionDir, taskId, offset, maxBytes); - } - - /** - * Return the output snapshot used by TaskOutput. - * - * Persisted logs are preferred when the task was registered with an - * output session directory and `output.log` has actually been created, - * because they are the complete, never-truncated source. Detached managers, - * tasks registered before a session dir was attached, and silent tasks with - * no persisted log fall back to the live ring buffer. - */ - async getOutputSnapshot( - taskId: string, - maxPreviewBytes: number, - ): Promise { - if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); - - await this.flushOutput(taskId); - - const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir !== undefined && (await taskOutputExists(outputSessionDir, taskId))) { - const outputSizeBytes = await taskOutputSizeBytes(outputSessionDir, taskId); - const previewOffset = Math.max(0, outputSizeBytes - previewLimit); - const previewBytes = outputSizeBytes - previewOffset; - const preview = await readTaskOutputBytes( - outputSessionDir, - taskId, - previewOffset, - previewBytes, - ); - return { - outputPath: taskOutputFile(outputSessionDir, taskId), - outputSizeBytes, - previewBytes, - truncated: previewOffset > 0, - fullOutputAvailable: true, - preview, - }; - } - - const entry = this.tasks.get(taskId); - if (entry === undefined) return emptyOutputSnapshot(); - - const available = Buffer.from(entry.outputChunks.join(''), 'utf-8'); - const previewBytes = Math.min(previewLimit, available.byteLength, entry.outputSizeBytes); - const previewOffset = available.byteLength - previewBytes; - return { - outputSizeBytes: entry.outputSizeBytes, - previewBytes, - truncated: entry.outputSizeBytes > previewBytes, - fullOutputAvailable: false, - preview: available.subarray(previewOffset).toString('utf-8'), - }; - } - - /** Get the combined output of a task (tail of the ring buffer). */ - getOutput(taskId: string, tail?: number): string { - const entry = this.tasks.get(taskId); - if (!entry) return ''; - const full = entry.outputChunks.join(''); - if (tail !== undefined && tail < full.length) { - return full.slice(-tail); - } - return full; - } - - async readOutput(taskId: string, tail?: number): Promise { - const entry = this.tasks.get(taskId); - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir !== undefined) { - await entry?.outputWriteQueue; - const persisted = await readTaskOutput(outputSessionDir, taskId); - if (persisted.length > 0) { - if (tail !== undefined && tail < persisted.length) { - return persisted.slice(-tail); - } - return persisted; - } - } - return this.getOutput(taskId, tail); - } - - getOutputPath(taskId: string): string | undefined { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return undefined; - if (!taskOutputExistsSync(outputSessionDir, taskId)) return undefined; - return taskOutputFile(outputSessionDir, taskId); - } - - /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ - async stop(taskId: string, reason?: string): Promise { - const entry = this.tasks.get(taskId); - if (!entry) return undefined; - // Normalize at this shared boundary: every public stop path (the TaskStop - // tool, SDK/RPC) funnels through here, so a blank or whitespace-only - // reason must never be recorded as an empty stopReason. - const trimmedReason = reason?.trim(); - const stopReason = - trimmedReason === undefined || trimmedReason.length === 0 ? undefined : trimmedReason; - // Terminal tasks short-circuit. awaiting_approval tasks can still - // be stopped (the approval gate is lifted when we transition to - // 'killed'). - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - entry.approvalReason = undefined; - entry.stopReason = stopReason; - entry.abortController.abort(stopReason); - - // Wait up to 5s for the lifecycle path to settle, then SIGKILL. - // Waiting on lifecyclePromise, rather than the task directly, lets a - // natural completion win the race instead of being overwritten here. - let graceTimer: ReturnType | undefined; - const graceful = await Promise.race([ - entry.lifecyclePromise.then( - () => true, - () => true, - ), - new Promise((resolve) => { - graceTimer = setTimeout(() => { - resolve(false); - }, SIGTERM_GRACE_MS); - }), - ]); - if (graceTimer !== undefined) clearTimeout(graceTimer); - - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - if (!graceful) { - try { - await entry.task.forceStop?.(); - } catch { - /* ignore */ - } - } - - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - // Tasks whose lifecycle promise never settles need an explicit terminal - // finalize here after their stop/force-stop hooks have had a chance. - await this.settleTask(entry, { status: 'killed', stopReason }); - - return this.toInfo(entry); - } - - async stopAll(reason?: string): Promise { - const taskIds = Array.from(this.tasks.values()) - .filter((entry) => !TERMINAL_STATUSES.has(entry.status)) - .map((entry) => entry.taskId); - const results = await Promise.all(taskIds.map((taskId) => this.stop(taskId, reason))); - return results.filter((info): info is BackgroundTaskInfo => info !== undefined); - } - - /** - * Wait for a task to reach a terminal state. - * Returns immediately if already terminal. Times out after `timeoutMs`. - */ - async wait(taskId: string, timeoutMs = 30_000): Promise { - const entry = this.tasks.get(taskId); - if (!entry) return undefined; - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - let terminalWaiter: (() => void) | undefined; - let timeout: ReturnType | undefined; - try { - await Promise.race([ - new Promise((resolve) => { - terminalWaiter = resolve; - entry.waiters.push(resolve); - }), - new Promise((resolve) => { - timeout = setTimeout(resolve, timeoutMs); - }), - ]); - } finally { - if (timeout !== undefined) clearTimeout(timeout); - if (terminalWaiter !== undefined) { - const index = entry.waiters.indexOf(terminalWaiter); - if (index !== -1) entry.waiters.splice(index, 1); - } - } - - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - } - return this.toInfo(entry); - } - - // ── awaiting_approval state transitions ──────────────────────────── - - /** - * Mark a running task as paused pending approval. The approval reason - * (tool call description) is retained until the task either returns - * to `'running'` via `clearAwaitingApproval()` or reaches a terminal - * state. Calls on terminal or unknown tasks are silently ignored so - * the ApprovalRuntime callback path is race-safe. - */ - markAwaitingApproval(taskId: string, reason: string): void { - const entry = this.tasks.get(taskId); - if (!entry) return; - if (TERMINAL_STATUSES.has(entry.status)) return; - entry.status = 'awaiting_approval'; - entry.approvalReason = reason; - void this.persistLive(entry); - this.fireLifecycle('updated', this.toInfo(entry)); - } - - /** - * Drop the approval gate and return to `'running'`. Clears the stored - * reason so stale text cannot leak into a future `awaiting_approval` - * cycle. No-op unless the task is currently in the awaiting_approval - * state. - */ - clearAwaitingApproval(taskId: string): void { - const entry = this.tasks.get(taskId); - if (!entry) return; - if (entry.status !== 'awaiting_approval') return; - entry.status = 'running'; - entry.approvalReason = undefined; - void this.persistLive(entry); - this.fireLifecycle('updated', this.toInfo(entry)); - } - - // ── completion event (await lifecycle end) ──────────────────────── - - /** - * Resolve when the task reaches a terminal state. If the task is - * already terminal, resolves synchronously on the next microtask. - * Intended for integration code that wants to `await` a specific - * task's exit without installing a full `onTerminal` subscriber. - * Returns `undefined` for unknown ids (matching `getTask`). Ghost - * (reconciled-lost) entries are considered terminal from the - * manager's perspective. - */ - async waitForTerminal(taskId: string): Promise { - const entry = this.tasks.get(taskId); - if (entry === undefined) return this.ghosts.get(taskId); - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - await new Promise((resolve) => { - entry.waiters.push(resolve); - }); - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - /** Reset internal state (for testing). */ - _reset(): void { - this.tasks.clear(); - this.ghosts.clear(); - this.sessionDir = undefined; - } - - // ── persistence + reconcile ──────────────────────────────────────── - - /** - * Attach the manager to a session directory for persistence. Tasks - * created via `register()` after this call are written to - * `/tasks/.json` and updated on lifecycle change. - * Tasks created before attach are NOT retroactively persisted. - */ - attachSessionDir(sessionDir: string): void { - this.sessionDir = sessionDir; - } - - /** - * Load persisted task records into the ghost map. Does NOT reconcile - * (call `reconcile()` after `loadFromDisk()`). Idempotent; subsequent - * calls overwrite the ghost map. - * - * Requires `attachSessionDir()` first; no-op otherwise. - */ - async loadFromDisk(): Promise { - if (this.sessionDir === undefined) return; - this.ghosts.clear(); - const persisted = await listTasks(this.sessionDir); - for (const t of persisted) { - // Skip ids that already exist as live processes — live wins. - if (this.tasks.has(t.task_id)) continue; - this.ghosts.set(t.task_id, persistedToInfo(t)); - } - } - - /** - * Reconcile loaded ghost tasks. Any ghost with status `running` is - * reclassified as `lost` (its previous CLI process died without - * writing a terminal state). Updates the on-disk record and returns - * the lost task ids so the caller can emit user-facing notifications. - */ - protected async markLoadedTasksLost(): Promise { - const lost: string[] = []; - const lostInfo: BackgroundTaskInfo[] = []; - for (const [id, info] of this.ghosts) { - // Any non-terminal ghost is lost. Includes `awaiting_approval` - // (the approval context died with the previous process so it - // cannot be resumed). - if (TERMINAL_STATUSES.has(info.status)) continue; - const updated: BackgroundTaskInfo = { - ...info, - status: 'lost', - endedAt: info.endedAt ?? Date.now(), - approvalReason: undefined, - failureReason: 'Background worker heartbeat expired', - }; - this.ghosts.set(id, updated); - if (this.sessionDir !== undefined) { - await writeTask(this.sessionDir, infoToPersisted(updated)); - } - lost.push(id); - lostInfo.push(updated); - } - return { lost, lostInfo }; - } - - async reconcile(): Promise { - const result = await this.markLoadedTasksLost(); - // Fire onTerminal for newly-lost ghosts so NotificationManager - // receives a `task.lost` notification. Dedupe on the consumer side - // is by `dedupe_key`; a second reconcile() on the same ghost is a - // no-op because the status flips to `lost` above and we guard on - // TERMINAL_STATUSES on the next pass. - for (const info of result.lostInfo) { - this.fireTerminalSubscribers(info); - } - return result; - } - - /** Drop a persisted task from disk and ghost map. */ - async forgetTask(taskId: string): Promise { - this.ghosts.delete(taskId); - if (this.sessionDir !== undefined) { - await removeTask(this.sessionDir, taskId); - } - } - - /** - * Persist the current state of a live ManagedTask. Called from - * `register()` and the lifecycle finally block. No-op unless attached. - */ - private persistLive(entry: ManagedTask): Promise { - if (this.sessionDir === undefined) return Promise.resolve(); - const sessionDir = this.sessionDir; - const info = this.toInfo(entry); - const task: PersistedTask = infoToPersisted(info); - entry.persistWriteQueue = entry.persistWriteQueue - .then(() => writeTask(sessionDir, task)) - .catch(() => {}); - return entry.persistWriteQueue; - } - - private appendOutput(entry: ManagedTask, chunk: string): void { - entry.outputSizeBytes += Buffer.byteLength(chunk, 'utf-8'); - entry.outputChunks.push(chunk); - // Enforce output cap: drop oldest chunks when over budget. - let total = entry.outputChunks.reduce((s, c) => s + c.length, 0); - while (total > MAX_OUTPUT_BYTES && entry.outputChunks.length > 1) { - const removed = entry.outputChunks.shift(); - if (removed === undefined) break; - total -= removed.length; - } - - const outputSessionDir = entry.outputSessionDir; - if (outputSessionDir === undefined) return; - entry.outputWriteQueue = entry.outputWriteQueue - .then(() => appendTaskOutput(outputSessionDir, entry.taskId, chunk)) - .catch(() => {}); - } - - private outputSessionDirFor(taskId: string): string | undefined { - const entry = this.tasks.get(taskId); - if (entry !== undefined) return entry.outputSessionDir; - if (this.ghosts.has(taskId)) return this.sessionDir; - return undefined; - } - - private async settleTask( - entry: ManagedTask, - settlement: { - readonly status: 'completed' | 'failed' | 'timed_out' | 'killed'; - readonly stopReason?: string; - }, - ): Promise { - if (TERMINAL_STATUSES.has(entry.status)) { - if (entry.status === 'killed' && settlement.status === 'killed') { - entry.endedAt = Math.max(Date.now(), (entry.endedAt ?? 0) + 1); - await this.persistLive(entry); - this.fireTerminalCallbacks(entry); - this.resolveWaiters(entry); - } - return false; - } - entry.status = settlement.status; - entry.endedAt = Date.now(); - entry.stopReason = - settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); - // A task that ended while still in awaiting_approval (e.g. crashed - // mid-prompt, deadline fired, or got killed) must not leak the - // stale approvalReason onto the terminal record. The awaiting → - // running path (clearAwaitingApproval) already clears it; mirror - // that here for the awaiting → terminal path. - entry.approvalReason = undefined; - await this.persistLive(entry); - this.fireTerminalCallbacks(entry); - this.resolveWaiters(entry); - return true; - } - - private observedExitCompletions(): Promise[] { - const completions: Promise[] = []; - for (const entry of this.tasks.values()) { - if (!TERMINAL_STATUSES.has(entry.status) && entry.task.hasObservedTerminal?.() === true) { - completions.push(entry.lifecyclePromise); - } - } - return completions; - } - - private toInfo(entry: ManagedTask): BackgroundTaskInfo { - const base: BackgroundTaskInfoBase = { - taskId: entry.taskId, - kind: entry.task.kind, - description: entry.task.description, - status: entry.status, - startedAt: entry.startedAt, - endedAt: entry.endedAt, - approvalReason: entry.approvalReason, - stopReason: entry.stopReason, - timeoutMs: entry.timeoutMs, - failureReason: entry.failureReason, - }; - return entry.task.toInfo(base); - } - -} - -// ── persistence shape <-> in-memory shape ────────────────────────────── - -function persistedToInfo(t: PersistedTask): BackgroundTaskInfo { - const status = t.timed_out === true ? 'timed_out' : t.status; - const base: BackgroundTaskInfoBase = { - taskId: t.task_id, - kind: t.kind ?? (t.task_id.startsWith('agent-') ? 'agent' : 'process'), - description: t.description, - status, - startedAt: t.started_at, - endedAt: t.ended_at, - approvalReason: t.approval_reason, - stopReason: t.stop_reason, - }; - if (base.kind === 'agent') { - return { - ...base, - kind: 'agent', - agentId: t.agent_id, - subagentType: t.subagent_type, - }; - } - return { - ...base, - kind: 'process', - command: t.command, - pid: t.pid, - exitCode: t.exit_code, - }; -} - -function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { - const command = info.kind === 'process' ? info.command : `[agent] ${info.description}`; - const pid = info.kind === 'process' ? info.pid : 0; - return { - task_id: info.taskId, - kind: info.kind, - command, - description: info.description, - pid, - started_at: info.startedAt, - ended_at: info.endedAt, - exit_code: info.kind === 'process' ? info.exitCode : null, - status: info.status, - approval_reason: info.approvalReason, - stop_reason: info.stopReason, - agent_id: info.kind === 'agent' ? info.agentId : undefined, - subagent_type: info.kind === 'agent' ? info.subagentType : undefined, - }; -} diff --git a/packages/agent-core/src/tools/background/task-list.ts b/packages/agent-core/src/tools/background/task-list.ts index 5072b0fb..35bf8064 100644 --- a/packages/agent-core/src/tools/background/task-list.ts +++ b/packages/agent-core/src/tools/background/task-list.ts @@ -4,12 +4,12 @@ import { z } from 'zod'; +import type { BackgroundManager, BackgroundTaskInfo } from '../../agent/background'; import type { BuiltinTool } from '../../agent/tool'; import type { ToolExecution } from '../../loop/types'; import { toInputJsonSchema } from '../support/input-schema'; import { matchesGlobRuleSubject } from '../support/rule-match'; import { formatPlainObject } from './format'; -import type { BackgroundProcessManager, BackgroundTaskInfo } from './manager'; import TASK_LIST_DESCRIPTION from './task-list.md'; // ── Input schema ───────────────────────────────────────────────────── @@ -48,7 +48,7 @@ export class TaskListTool implements BuiltinTool { readonly description = TASK_LIST_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(TaskListInputSchema); - constructor(private readonly manager: BackgroundProcessManager) {} + constructor(private readonly manager: BackgroundManager) {} resolveExecution(args: TaskListInput): ToolExecution { const listScope = (args.active_only ?? true) ? 'active' : 'all'; diff --git a/packages/agent-core/src/tools/background/task-output.ts b/packages/agent-core/src/tools/background/task-output.ts index 2ef1ec98..3c2834f4 100644 --- a/packages/agent-core/src/tools/background/task-output.ts +++ b/packages/agent-core/src/tools/background/task-output.ts @@ -15,14 +15,14 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../agent/tool'; -import type { ExecutableToolResult, ToolExecution } from '../../loop/types'; import { + type BackgroundManager, isBackgroundTaskTerminal, - type BackgroundProcessManager, type BackgroundTaskInfo, type BackgroundTaskOutputSnapshot, type BackgroundTaskStatus, -} from './manager'; +} from '../../agent/background'; +import type { ExecutableToolResult, ToolExecution } from '../../loop/types'; import { toInputJsonSchema } from '../support/input-schema'; import { matchesGlobRuleSubject } from '../support/rule-match'; import { formatPlainObject } from './format'; @@ -98,7 +98,7 @@ export class TaskOutputTool implements BuiltinTool { readonly description: string = TASK_OUTPUT_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(TaskOutputInputSchema); - constructor(private readonly manager: BackgroundProcessManager) {} + constructor(private readonly manager: BackgroundManager) {} resolveExecution(args: TaskOutputInput): ToolExecution { return { @@ -145,12 +145,12 @@ export class TaskOutputTool implements BuiltinTool { output.fullOutputAvailable && output.outputPath !== undefined ? 'Read' : undefined, fullOutputHint: fullOutputHint(output), }), + '', ]; // When the preview omits the head of the log, emit an explicit // banner just before the `[output]` marker so the model knows it is // looking at a tail, not the full output. - lines.push(''); if (output.truncated) { lines.push( output.fullOutputAvailable && output.outputPath !== undefined diff --git a/packages/agent-core/src/tools/background/task-stop.ts b/packages/agent-core/src/tools/background/task-stop.ts index c115da7b..e3827312 100644 --- a/packages/agent-core/src/tools/background/task-stop.ts +++ b/packages/agent-core/src/tools/background/task-stop.ts @@ -5,10 +5,13 @@ import { z } from 'zod'; import type { BuiltinTool } from '../../agent/tool'; +import { + isBackgroundTaskTerminal, + type BackgroundManager, +} from '../../agent/background'; import type { ToolExecution } from '../../loop/types'; import { toInputJsonSchema } from '../support/input-schema'; import { matchesGlobRuleSubject } from '../support/rule-match'; -import { isBackgroundTaskTerminal, type BackgroundProcessManager } from './manager'; import TASK_STOP_DESCRIPTION from './task-stop.md'; // ── Input schema ───────────────────────────────────────────────────── @@ -31,7 +34,7 @@ export class TaskStopTool implements BuiltinTool { readonly description = TASK_STOP_DESCRIPTION; readonly parameters: Record = toInputJsonSchema(TaskStopInputSchema); - constructor(private readonly manager: BackgroundProcessManager) {} + constructor(private readonly manager: BackgroundManager) {} resolveExecution(args: TaskStopInput): ToolExecution { return { diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 061f982d..859b4299 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -30,8 +30,7 @@ import { isUserCancellation, type DeadlineAbortSignal, } from '../../../utils/abort'; -import { AgentBackgroundTask } from '../../background/agent-task'; -import type { BackgroundProcessManager } from '../../background/manager'; +import { AgentBackgroundTask, type BackgroundManager } from '../../../agent/background'; import { toInputJsonSchema } from '../../support/input-schema'; import { matchesGlobRuleSubject } from '../../support/rule-match'; import AGENT_BACKGROUND_DISABLED_DESCRIPTION from './agent-background-disabled.md'; @@ -118,7 +117,7 @@ export class AgentTool implements BuiltinTool { readonly parameters: Record = toInputJsonSchema(AgentToolInputSchema); constructor( private readonly subagentHost: SessionSubagentHost, - private readonly backgroundManager?: BackgroundProcessManager | undefined, + private readonly backgroundManager?: BackgroundManager | undefined, subagents?: ResolvedAgentProfile['subagents'] | undefined, options?: { log?: Logger; @@ -183,7 +182,7 @@ export class AgentTool implements BuiltinTool { }; } - let reservation: ReturnType | undefined; + let reservation: ReturnType | undefined; if (runInBackground) { if (this.backgroundManager === undefined) { return { diff --git a/packages/agent-core/src/tools/builtin/index.ts b/packages/agent-core/src/tools/builtin/index.ts index ebbe0dc7..2a50b8f2 100644 --- a/packages/agent-core/src/tools/builtin/index.ts +++ b/packages/agent-core/src/tools/builtin/index.ts @@ -1,4 +1,3 @@ -export * from '../background/manager'; export * from '../background/task-list'; export * from '../background/task-output'; export * from '../background/task-stop'; diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 71867a9d..9405e241 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -8,7 +8,7 @@ * - `Kaos` — shell execution abstraction (exec / execWithEnv) * - `cwd` — default working directory for commands * - `Environment` — cross-platform probe (shellName / shellPath) - * - `BackgroundProcessManager?` — optional: required iff run_in_background=true + * - `BackgroundManager?` — optional: required iff run_in_background=true * * Execution goes through Kaos, never directly via node:child_process. * @@ -29,10 +29,10 @@ import { StringDecoder } from 'node:string_decoder'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { z } from 'zod'; +import type { BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; -import type { BackgroundProcessManager } from '../../background/manager'; import { toInputJsonSchema } from '../../support/input-schema'; import { literalRulePattern, matchesGlobRuleSubject } from '../../support/rule-match'; import { ToolResultBuilder } from '../../support/result-builder'; @@ -155,7 +155,7 @@ export class BashTool implements BuiltinTool { constructor( private readonly kaos: Kaos, private readonly cwd: string, - private readonly backgroundManager?: BackgroundProcessManager, + private readonly backgroundManager?: BackgroundManager, options?: { allowBackground?: boolean | undefined; }, @@ -354,7 +354,7 @@ export class BashTool implements BuiltinTool { if (!this.backgroundManager) { return { isError: true, - output: 'Background execution is not available (no BackgroundProcessManager configured).', + output: 'Background execution is not available (no BackgroundManager configured).', }; } const backgroundManager = this.backgroundManager; @@ -366,7 +366,7 @@ export class BashTool implements BuiltinTool { }; } - let reservation: ReturnType; + let reservation: ReturnType; try { reservation = backgroundManager.reserveSlot(); } catch (error) { diff --git a/packages/agent-core/test/agent/background-manager.test.ts b/packages/agent-core/test/agent/background-manager.test.ts index e255d0c4..eb5f314f 100644 --- a/packages/agent-core/test/agent/background-manager.test.ts +++ b/packages/agent-core/test/agent/background-manager.test.ts @@ -15,9 +15,9 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundManager } from '../../src/agent/background'; +import { AgentBackgroundTask, BackgroundManager } from '../../src/agent/background'; import type { AgentEvent } from '../../src/rpc/events'; -import { appendTaskOutput, writeTask } from '../../src/tools/background/persist'; +import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; interface FakeAgent { emitEvent: (event: AgentEvent) => void; @@ -116,7 +116,7 @@ describe('BackgroundManager — RPC event emission', () => { }); it('emits background.task.started on registerAgentTask()', () => { - const taskId = agent.background.registerAgentTask(new Promise(() => {}), 'agent task'); + const taskId = agent.background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'agent task')); const started = agent.emittedEvents.filter((e) => e.type === 'background.task.started'); expect(started.length).toBe(1); @@ -184,9 +184,9 @@ describe('BackgroundManager — RPC event emission', () => { }); it('tracks timed-out agent tasks with reason=timeout', async () => { - const taskId = agent.background.registerAgentTask(new Promise(() => {}), 'slow agent', { + const taskId = agent.background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'slow agent', { timeoutMs: 1, - }); + })); agent.telemetry.track.mockClear(); await agent.background.waitForTerminal(taskId); @@ -244,10 +244,10 @@ describe('BackgroundManager — RPC event emission', () => { }); it('steers completed agent task notifications into the turn flow', async () => { - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'final subagent summary' }), 'agent task', - ); + )); await agent.background.waitForTerminal(taskId); await vi.waitFor(() => { @@ -314,10 +314,10 @@ describe('BackgroundManager — RPC event emission', () => { it('queues background agent notifications without waiting for an active turn', async () => { agent.turn.hasActiveTurn = true; - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'active turn summary' }), 'agent task', - ); + )); await agent.background.waitForTerminal(taskId); await vi.waitFor(() => { @@ -525,10 +525,10 @@ describe('BackgroundManager — RPC event emission', () => { const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); agent = makeAgent({ hooks: { fireAndForgetTrigger } }); - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'final agent output' }), 'inspect repository', - ); + )); await agent.background.wait(taskId); await vi.waitFor(() => { @@ -555,10 +555,10 @@ describe('BackgroundManager — RPC event emission', () => { }); agent = makeAgent({ hooks: { fireAndForgetTrigger } }); - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'final agent output' }), 'inspect repository', - ); + )); await agent.background.wait(taskId); await vi.waitFor(() => { @@ -637,11 +637,11 @@ describe('BackgroundManager — RPC event emission', () => { // Promise.reject (non-AbortError) routes through the registerAgentTask // `.catch` branch and lands at status `failed`, which is the same // agent-* failure branch reconcile uses for `lost` tasks. - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.reject(new Error('subagent crashed')), 'inspect repository', { agentId: 'agent-7' }, - ); + )); await agent.background.waitForTerminal(taskId); await vi.waitFor(() => { @@ -655,11 +655,11 @@ describe('BackgroundManager — RPC event emission', () => { }); it('completed agent task body does NOT add resume instructions', async () => { - const taskId = agent.background.registerAgentTask( + const taskId = agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'all good' }), 'inspect repository', { agentId: 'agent-8' }, - ); + )); await agent.background.wait(taskId); await vi.waitFor(() => { diff --git a/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts b/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts index c9ba13d0..81f3dbe6 100644 --- a/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts +++ b/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts @@ -22,8 +22,9 @@ import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; -import { appendTaskOutput, writeTask } from '../../src/tools/background/persist'; +import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; import { testAgent } from './harness/agent'; +import { AgentBackgroundTask } from '../../src/agent/background'; describe('background notification → main agent (real Agent instance)', () => { it('IDLE: completed bg agent auto-starts a new turn with XML', async () => { @@ -36,10 +37,10 @@ describe('background notification → main agent (real Agent instance)', () => { // The expected auto-launched turn will call generate once, then end. ctx.mockNextResponse({ type: 'text', text: 'ack from main agent' }); - const taskId = ctx.agent.background.registerAgentTask( + const taskId = ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'background agent finished its job' }), 'idle-state repro', - ); + )); await ctx.agent.background.waitForTerminal(taskId); @@ -93,10 +94,10 @@ describe('background notification → main agent (real Agent instance)', () => { // Right after kicking off, register a background task that // completes immediately. The notification should be steer()d // while activeTurn is still set, landing in the steerBuffer. - const taskId = ctx.agent.background.registerAgentTask( + const taskId = ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'busy-state bg result' }), 'busy-state repro', - ); + )); // Wait for the first turn to end. await promptPromise; @@ -132,18 +133,18 @@ describe('background notification → main agent (real Agent instance)', () => { ctx.mockNextResponse({ type: 'text', text: 'ack group' }); const taskIds = [ - ctx.agent.background.registerAgentTask( + ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'bg #1 result' }), 'group-1', - ), - ctx.agent.background.registerAgentTask( + )), + ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'bg #2 result' }), 'group-2', - ), - ctx.agent.background.registerAgentTask( + )), + ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'bg #3 result' }), 'group-3', - ), + )), ]; for (const id of taskIds) { @@ -205,10 +206,10 @@ describe('background notification → main agent (real Agent instance)', () => { // completion — this is the IDLE path, NOT the racy one. We // queue an LLM response so the auto-launched turn can run. ctx.mockNextResponse({ type: 'text', text: 'auto ack from bg notification' }); - const taskId = ctx.agent.background.registerAgentTask( + const taskId = ctx.agent.background.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'post-turn bg result' }), 'race-after-turn', - ); + )); await ctx.agent.background.waitForTerminal(taskId); diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index dafbc864..0d906621 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -9,7 +9,7 @@ import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, } from '../../src/agent/records'; -import { appendTaskOutput, writeTask } from '../../src/tools/background/persist'; +import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index db814a77..eb89c493 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -4,7 +4,7 @@ import { ToolAccesses } from '../../src/loop'; import type { Logger, LogPayload } from '../../src/logging'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; -import { BackgroundProcessManager } from '../../src/tools/background/manager'; +import { AgentBackgroundTask, BackgroundManager } from '../../src/agent/background'; import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent'; import { userCancellationReason } from '../../src/utils/abort'; import { executeTool } from './fixtures/execute-tool'; @@ -109,7 +109,7 @@ describe('AgentTool', () => { it('explains background timeout fallback in the background-enabled description without claiming a 15min default', () => { const host = mockSubagentHost({ spawn: vi.fn() }); - const tool = new AgentTool(host, new BackgroundProcessManager()); + const tool = new AgentTool(host, new BackgroundManager()); // #5: the background-enabled variant describes the real timeout fallback — // an omitted timeout falls back to the operator-configured background @@ -315,7 +315,7 @@ describe('AgentTool', () => { it('does not consume a background task slot when validation fails before launch', async () => { const completion = new Promise<{ result: string }>(() => {}); - const background = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const background = new BackgroundManager({ maxRunningTasks: 1 }); const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', @@ -425,7 +425,7 @@ describe('AgentTool', () => { completion, }), }); - const background = new BackgroundProcessManager(); + const background = new BackgroundManager(); const tool = new AgentTool(host, background); const result = await executeTool(tool, @@ -456,7 +456,7 @@ describe('AgentTool', () => { completion: new Promise<{ result: string }>(() => {}), }), }); - const background = new BackgroundProcessManager(); + const background = new BackgroundManager(); const tool = new AgentTool(host, background); const result = await executeTool(tool, @@ -517,8 +517,8 @@ describe('AgentTool', () => { }); it('does not spawn background subagents when the task limit is reached', async () => { - const background = new BackgroundProcessManager({ maxRunningTasks: 1 }); - background.registerAgentTask(new Promise(() => {}), 'existing agent'); + const background = new BackgroundManager({ maxRunningTasks: 1 }); + background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'existing agent')); const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', @@ -545,7 +545,7 @@ describe('AgentTool', () => { }); it('reserves a task slot before spawning concurrent background subagents', async () => { - const background = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const background = new BackgroundManager({ maxRunningTasks: 1 }); const host = mockSubagentHost({ spawn: vi .fn() @@ -635,8 +635,8 @@ describe('AgentTool', () => { completion: new Promise<{ result: string }>(() => {}), }), }); - const background = new BackgroundProcessManager(); - vi.spyOn(background, 'registerAgentTask').mockImplementation(() => { + const background = new BackgroundManager(); + vi.spyOn(background, 'registerTask').mockImplementation(() => { throw error; }); const tool = new AgentTool(host, background, undefined, { log: logger }); diff --git a/packages/agent-core/test/tools/background/agent-timeout.test.ts b/packages/agent-core/test/tools/background/agent-timeout.test.ts index 02fbc4b1..e4532251 100644 --- a/packages/agent-core/test/tools/background/agent-timeout.test.ts +++ b/packages/agent-core/test/tools/background/agent-timeout.test.ts @@ -1,8 +1,8 @@ /** - * `registerAgentTask` `timeoutMs` option. + * AgentBackgroundTask `timeoutMs` option. * * Semantics: - * - external deadline fires → status=`failed`, `stopReason="Timed out"` + * - external deadline fires → status=`timed_out` * - no `timeoutMs` → the task runs to completion without a wrapper * - internal `TimeoutError` rejection (e.g. aiohttp sock_read) is a * generic `failed` with no stop reason — the timeout reason must @@ -11,22 +11,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; -import { BACKGROUND_TASK_TIMEOUT_STOP_REASON } from '../../../src/tools/background/task'; +import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; -describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { - const manager = new BackgroundProcessManager(); +describe('AgentBackgroundTask — timeoutMs', () => { + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); vi.useRealTimers(); }); - it('external deadline marks task failed with a timeout stop reason', async () => { + it('external deadline marks task timed_out', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); // A never-resolving completion — only the deadline will fire. const hangForever = new Promise<{ result: string }>(() => {}); - const taskId = manager.registerAgentTask(hangForever, 'hang', { timeoutMs: 2_000 }); + const taskId = manager.registerTask(new AgentBackgroundTask(hangForever, 'hang', { timeoutMs: 2_000 })); // Advance past the deadline; awaitTerminal resolves once the race // finishes and the `.finally` block runs. @@ -34,8 +33,8 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { await vi.advanceTimersByTimeAsync(2_100); const info = await terminalPromise; - expect(info?.status).toBe('failed'); - expect(info?.stopReason).toBe(BACKGROUND_TASK_TIMEOUT_STOP_REASON); + expect(info?.status).toBe('timed_out'); + expect(info?.stopReason).toBeUndefined(); }); it('omitting timeoutMs lets the task run to completion (no wrapper)', async () => { @@ -43,7 +42,7 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { const completion = new Promise<{ result: string }>((res) => { resolveFn = res; }); - const taskId = manager.registerAgentTask(completion, 'no deadline'); + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'no deadline')); resolveFn({ result: 'finished' }); const info = await manager.waitForTerminal(taskId); @@ -58,9 +57,9 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { const internalErr = new Error('aiohttp sock_read timeout'); internalErr.name = 'TimeoutError'; const rejecting = Promise.reject(internalErr); - const taskId = manager.registerAgentTask(rejecting, 'internal timeout', { + const taskId = manager.registerTask(new AgentBackgroundTask(rejecting, 'internal timeout', { timeoutMs: 900_000, - }); + })); const info = await manager.waitForTerminal(taskId); expect(info?.status).toBe('failed'); @@ -78,9 +77,9 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { // promise's `.finally(clearTimeout)` would not run under real time. it('explicit timeoutMs is persisted on the task info', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); - const taskId = manager.registerAgentTask(new Promise(() => {}), 'persist timeout', { + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'persist timeout', { timeoutMs: 1_800_000, - }); + })); const info = manager.getTask(taskId); expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBe(1_800_000); }); @@ -98,7 +97,7 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { // guard: if someone later adds a hard-coded default in // registerAgentTask, the assertion below catches it. it('omitted timeoutMs leaves the task info field undefined', () => { - const taskId = manager.registerAgentTask(new Promise(() => {}), 'default timeout'); + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'default timeout')); const info = manager.getTask(taskId); expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBeUndefined(); }); @@ -111,9 +110,9 @@ describe('BackgroundProcessManager.registerAgentTask — timeoutMs', () => { // zero so a caller writing `0` does not lose its task to an // immediate kill. it('timeoutMs=0 is preserved on the task info and does not arm a deadline', async () => { - const taskId = manager.registerAgentTask(new Promise(() => {}), 'zero timeout', { + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'zero timeout', { timeoutMs: 0, - }); + })); // The literal zero is preserved on the task info. const initial = manager.getTask(taskId); expect((initial as unknown as { timeoutMs?: number }).timeoutMs).toBe(0); diff --git a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts b/packages/agent-core/test/tools/background/heartbeat-stale.test.ts index d2a444d7..562eda61 100644 --- a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts +++ b/packages/agent-core/test/tools/background/heartbeat-stale.test.ts @@ -18,8 +18,8 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; -import { writeTask } from '../../../src/tools/background/persist'; +import { BackgroundManager } from '../../../src/agent/background'; +import { writeTask } from '../../../src/agent/background/persist'; let sessionDir: string; @@ -50,7 +50,7 @@ describe('BPM reconcile — stale heartbeat ghost detection', () => { status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); const fired: Array<{ taskId: string; status: string }> = []; mgr.onTerminal((info) => { fired.push({ taskId: info.taskId, status: info.status }); @@ -74,7 +74,7 @@ describe('BPM reconcile — stale heartbeat ghost detection', () => { status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); const fired: string[] = []; mgr.onTerminal((info) => { fired.push(info.taskId); diff --git a/packages/agent-core/test/tools/background/ids.test.ts b/packages/agent-core/test/tools/background/ids.test.ts index cf00a981..e010aa92 100644 --- a/packages/agent-core/test/tools/background/ids.test.ts +++ b/packages/agent-core/test/tools/background/ids.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest'; -import { generateTaskId, VALID_TASK_ID } from '../../../src/tools/background/index'; +import { generateTaskId, VALID_TASK_ID } from '../../../src/agent/background'; describe('background task id format', () => { it('generated ids pass VALID_TASK_ID for every kind', () => { diff --git a/packages/agent-core/test/tools/background/lifecycle.test.ts b/packages/agent-core/test/tools/background/lifecycle.test.ts index 345e956a..f69a857d 100644 --- a/packages/agent-core/test/tools/background/lifecycle.test.ts +++ b/packages/agent-core/test/tools/background/lifecycle.test.ts @@ -1,5 +1,5 @@ /** - * BackgroundProcessManager — onLifecycle hook. + * BackgroundManager — onLifecycle hook. * * Covers the three lifecycle events emitted to subscribers: * - 'started' on register / registerAgentTask @@ -19,10 +19,7 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { - BackgroundProcessManager, - type BackgroundTaskInfo, -} from '../../../src/tools/background/manager'; +import { AgentBackgroundTask, BackgroundManager, type BackgroundTaskInfo } from '../../../src/agent/background'; type LifecycleEvent = 'started' | 'updated' | 'terminated'; @@ -80,11 +77,11 @@ function pendingProcess(): KaosProcess { }; } -describe('BackgroundProcessManager — onLifecycle', () => { - let manager: BackgroundProcessManager; +describe('BackgroundManager — onLifecycle', () => { + let manager: BackgroundManager; beforeEach(() => { - manager = new BackgroundProcessManager(); + manager = new BackgroundManager(); }); afterEach(() => { @@ -107,7 +104,7 @@ describe('BackgroundProcessManager — onLifecycle', () => { const { records, callback } = makeRecorder(); manager.onLifecycle(callback); - const taskId = manager.registerAgentTask(new Promise(() => {}), 'an agent'); + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'an agent')); expect(records.length).toBe(1); expect(records[0]!.event).toBe('started'); diff --git a/packages/agent-core/test/tools/background/manager.test.ts b/packages/agent-core/test/tools/background/manager.test.ts index a74d9a2b..a10f7070 100644 --- a/packages/agent-core/test/tools/background/manager.test.ts +++ b/packages/agent-core/test/tools/background/manager.test.ts @@ -1,5 +1,5 @@ /** - * Covers: BackgroundProcessManager. + * Covers: BackgroundManager. * * Uses KaosProcess fakes — the manager accepts KaosProcess directly, * with no ChildProcess dependency. @@ -14,7 +14,7 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; +import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; /** * Creates a KaosProcess that completes immediately with the given exit code. @@ -103,7 +103,7 @@ function manuallyResolvedProcess(): { }; } -function waiterCount(manager: BackgroundProcessManager, taskId: string): number { +function waiterCount(manager: BackgroundManager, taskId: string): number { const tasks = ( manager as unknown as { tasks: Map void> }>; @@ -172,8 +172,8 @@ function processWithVisibleExitCodeBeforeWait(exitCode = 143): { }; } -describe('BackgroundProcessManager', () => { - const manager = new BackgroundProcessManager(); +describe('BackgroundManager', () => { + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); @@ -221,7 +221,7 @@ describe('BackgroundProcessManager', () => { it('registerAgentTask registers as running with agent- id prefix', () => { // Promise that never resolves — we only inspect the initial register // snapshot here. - const taskId = manager.registerAgentTask(new Promise(() => {}), 'agent task'); + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'agent task')); expect(taskId).toMatch(/^agent-[0-9a-z]{8}$/); const info = manager.getTask(taskId); expect(info).toBeDefined(); @@ -249,7 +249,7 @@ describe('BackgroundProcessManager', () => { }); it('rejects new bash tasks when maxRunningTasks is reached', () => { - const limited = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const limited = new BackgroundManager({ maxRunningTasks: 1 }); const { proc: first } = pendingProcess(); const { proc: second } = pendingProcess(); @@ -261,12 +261,12 @@ describe('BackgroundProcessManager', () => { }); it('rejects new agent tasks when maxRunningTasks is reached', () => { - const limited = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const limited = new BackgroundManager({ maxRunningTasks: 1 }); - limited.registerAgentTask(new Promise(() => {}), 'first agent'); + limited.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'first agent')); expect(() => { - limited.registerAgentTask(new Promise(() => {}), 'second agent'); + limited.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'second agent')); }).toThrow('Too many background tasks are already running.'); }); @@ -370,7 +370,7 @@ describe('BackgroundProcessManager', () => { it('persists graceful process shutdown as killed when stop requested', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-race-')); try { - const writer = new BackgroundProcessManager(); + const writer = new BackgroundManager(); writer.attachSessionDir(sessionDir); const { proc, resolve } = manuallyResolvedProcess(); const taskId = writer.register(proc, 'sleep 60', 'persisted process race test'); @@ -379,7 +379,7 @@ describe('BackgroundProcessManager', () => { resolve(0); await stopPromise; - const reader = new BackgroundProcessManager(); + const reader = new BackgroundManager(); reader.attachSessionDir(sessionDir); await reader.loadFromDisk(); @@ -397,7 +397,7 @@ describe('BackgroundProcessManager', () => { resolveCompletion = resolve; }); const abort = vi.fn(); - const taskId = manager.registerAgentTask(completion, 'agent race test', { abort }); + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent race test', { abort })); const stopPromise = manager.stop(taskId, 'user requested'); resolveCompletion({ result: 'finished naturally' }); @@ -415,7 +415,7 @@ describe('BackgroundProcessManager', () => { rejectCompletion = reject; }); const abort = vi.fn(); - const taskId = manager.registerAgentTask(completion, 'agent failure race test', { abort }); + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent failure race test', { abort })); const stopPromise = manager.stop(taskId, 'user requested'); rejectCompletion(new Error('model failed')); @@ -436,7 +436,7 @@ describe('BackgroundProcessManager', () => { const abort = vi.fn(() => { rejectCompletion(abortError); }); - const taskId = manager.registerAgentTask(completion, 'agent abort test', { abort }); + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent abort test', { abort })); const result = await manager.stop(taskId, 'user requested'); @@ -448,9 +448,9 @@ describe('BackgroundProcessManager', () => { it('stop finalizes a never-settling agent task after the grace window', async () => { vi.useFakeTimers(); try { - const local = new BackgroundProcessManager(); + const local = new BackgroundManager(); const abort = vi.fn(); - const taskId = local.registerAgentTask(new Promise(() => {}), 'hung agent task', { abort }); + const taskId = local.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'hung agent task', { abort })); const terminalPromise = local.waitForTerminal(taskId); const stopPromise = local.stop(taskId, 'user requested'); @@ -470,7 +470,7 @@ describe('BackgroundProcessManager', () => { it('updates endedAt when a killed task finally exits after SIGKILL', async () => { vi.useFakeTimers(); try { - const local = new BackgroundProcessManager(); + const local = new BackgroundManager(); const terminated: string[] = []; local.onLifecycle((event, info) => { if (event === 'terminated') terminated.push(info.status); @@ -542,8 +542,8 @@ describe('BackgroundProcessManager', () => { // ── py-aligned coverage for bash + agent registration semantics ──────── -describe('BackgroundProcessManager — registration semantics', () => { - const manager = new BackgroundProcessManager(); +describe('BackgroundManager — registration semantics', () => { + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); @@ -606,10 +606,10 @@ describe('BackgroundProcessManager — registration semantics', () => { // Agent task registration places kind_payload-style info on the task // info (agent_id / subagent_type carried through), status visible. it('agent task registration exposes agent metadata on the task info', () => { - const taskId = manager.registerAgentTask(new Promise(() => {}), 'investigate bug', { + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'investigate bug', { agentId: 'agent-child', subagentType: 'coder', - }); + })); expect(taskId.startsWith('agent-')).toBe(true); const info = manager.getTask(taskId); expect(info).toBeDefined(); @@ -626,7 +626,7 @@ describe('BackgroundProcessManager — registration semantics', () => { m.mkdtemp(join(tmpdir(), 'kimi-bg-mgr-missing-')), ); try { - const m2 = new BackgroundProcessManager(); + const m2 = new BackgroundManager(); m2.attachSessionDir(sessionDir); expect(m2.getTask('bash-bogusss0')).toBeUndefined(); const { readdir } = await import('node:fs/promises'); @@ -718,13 +718,13 @@ describe('BackgroundProcessManager — registration semantics', () => { const completion = new Promise<{ result: string }>((_res, rej) => { rejectCompletion = rej; }); - const taskId = manager.registerAgentTask(completion, 'killable', { + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'killable', { abort: () => { const abortError = new Error('cancelled'); abortError.name = 'AbortError'; rejectCompletion(abortError); }, - }); + })); const stopped = await manager.stop(taskId, 'test kill'); expect(stopped?.status).toBe('killed'); expect(stopped?.stopReason).toBe('test kill'); diff --git a/packages/agent-core/test/tools/background/output-access.test.ts b/packages/agent-core/test/tools/background/output-access.test.ts index 3d91e91f..9de7e0c5 100644 --- a/packages/agent-core/test/tools/background/output-access.test.ts +++ b/packages/agent-core/test/tools/background/output-access.test.ts @@ -1,5 +1,5 @@ /** - * BackgroundProcessManager — output retrieval surface. + * BackgroundManager — output retrieval surface. * * Covers the two methods consumed by the `/tasks` UI: * - `readOutput(taskId, tail?)` reads the persisted @@ -18,8 +18,8 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; -import { appendTaskOutput } from '../../../src/tools/background/persist'; +import { BackgroundManager } from '../../../src/agent/background'; +import { appendTaskOutput } from '../../../src/agent/background/persist'; function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { return { @@ -34,7 +34,7 @@ function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { } async function waitForLiveOutput( - manager: BackgroundProcessManager, + manager: BackgroundManager, taskId: string, expected: string, ): Promise { @@ -45,13 +45,13 @@ async function waitForLiveOutput( throw new Error(`Timed out waiting for live output: ${expected}`); } -describe('BackgroundProcessManager — readOutput / getOutputPath', () => { +describe('BackgroundManager — readOutput / getOutputPath', () => { let sessionDir: string; - let manager: BackgroundProcessManager; + let manager: BackgroundManager; beforeEach(() => { sessionDir = mkdtempSync(join(tmpdir(), 'bpm-output-')); - manager = new BackgroundProcessManager(); + manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); }); @@ -115,7 +115,7 @@ describe('BackgroundProcessManager — readOutput / getOutputPath', () => { expect((await manager.readOutput(taskId)).length).toBeGreaterThan(0); // Stage 2: simulate a fresh restart — new manager, same sessionDir. - const fresh = new BackgroundProcessManager(); + const fresh = new BackgroundManager(); fresh.attachSessionDir(sessionDir); await fresh.loadFromDisk(); await fresh.reconcile(); diff --git a/packages/agent-core/test/tools/background/persist.test.ts b/packages/agent-core/test/tools/background/persist.test.ts index 172adcdf..953a805e 100644 --- a/packages/agent-core/test/tools/background/persist.test.ts +++ b/packages/agent-core/test/tools/background/persist.test.ts @@ -17,7 +17,7 @@ import { taskOutputSizeBytes, writeTask, type PersistedTask, -} from '../../../src/tools/background/persist'; +} from '../../../src/agent/background/persist'; let sessionDir: string; diff --git a/packages/agent-core/test/tools/background/reconcile.test.ts b/packages/agent-core/test/tools/background/reconcile.test.ts index b2e69072..6c2e4f07 100644 --- a/packages/agent-core/test/tools/background/reconcile.test.ts +++ b/packages/agent-core/test/tools/background/reconcile.test.ts @@ -1,5 +1,5 @@ /** - * BackgroundProcessManager reconcile + persistence integration tests. + * BackgroundManager reconcile + persistence integration tests. */ import { mkdir, rm } from 'node:fs/promises'; @@ -8,8 +8,8 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; -import { writeTask, listTasks } from '../../../src/tools/background/persist'; +import { BackgroundManager } from '../../../src/agent/background'; +import { writeTask, listTasks } from '../../../src/agent/background/persist'; let sessionDir: string; @@ -25,9 +25,9 @@ afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }); }); -describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { +describe('BackgroundManager — loadFromDisk + reconcile', () => { it('loadFromDisk does nothing when sessionDir not attached', async () => { - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); await mgr.loadFromDisk(); expect(mgr.list(false)).toEqual([]); }); @@ -45,7 +45,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); const result = await mgr.reconcile(); @@ -80,7 +80,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); const result = await mgr.reconcile(); @@ -103,7 +103,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); await mgr.reconcile(); @@ -124,7 +124,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); await mgr.reconcile(); @@ -143,7 +143,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); await mgr.reconcile(); @@ -153,7 +153,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { }); it('reconcile returns empty when no ghosts loaded', async () => { - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); const result = await mgr.reconcile(); @@ -172,7 +172,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); const fired: { taskId: string; status: string }[] = []; mgr.onTerminal((info) => { fired.push({ taskId: info.taskId, status: info.status }); @@ -201,7 +201,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { status: 'awaiting_approval', approval_reason: 'ghost reason that should be cleared', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); const result = await mgr.reconcile(); @@ -222,7 +222,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); const fired: string[] = []; mgr.onTerminal((info) => { fired.push(info.taskId); @@ -250,7 +250,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { exit_code: null, status: 'running', }); - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.attachSessionDir(sessionDir); await mgr.loadFromDisk(); const result = await mgr.reconcile(); @@ -278,7 +278,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { status: 'running', }); const fired: { taskId: string; status: string }[] = []; - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.onTerminal((info) => { fired.push({ taskId: info.taskId, status: info.status }); }); @@ -302,7 +302,7 @@ describe('BackgroundProcessManager — loadFromDisk + reconcile', () => { status: 'completed', }); const fired: string[] = []; - const mgr = new BackgroundProcessManager(); + const mgr = new BackgroundManager(); mgr.onTerminal((info) => { fired.push(info.taskId); }); diff --git a/packages/agent-core/test/tools/background/state-transitions.test.ts b/packages/agent-core/test/tools/background/state-transitions.test.ts index 6ec71784..6747fd66 100644 --- a/packages/agent-core/test/tools/background/state-transitions.test.ts +++ b/packages/agent-core/test/tools/background/state-transitions.test.ts @@ -20,7 +20,7 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; +import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; function pendingProcess(): { proc: KaosProcess; resolve: (code: number) => void } { let resolveWait: (code: number) => void = () => {}; @@ -55,8 +55,8 @@ function pendingProcess(): { proc: KaosProcess; resolve: (code: number) => void }; } -describe('BackgroundProcessManager — awaiting_approval state', () => { - const manager = new BackgroundProcessManager(); +describe('BackgroundManager — awaiting_approval state', () => { + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); @@ -180,10 +180,10 @@ describe('BackgroundProcessManager — awaiting_approval state', () => { this.name = 'RunCancelled'; } } - const taskId = manager.registerAgentTask( + const taskId = manager.registerTask(new AgentBackgroundTask( Promise.reject(new RunCancelled()), 'run cancelled bg', - ); + )); const info = await manager.waitForTerminal(taskId); expect(info?.status).toBe('killed'); }); diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index 7d648bc1..ff4551f1 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -13,9 +13,8 @@ import type { Writable } from 'node:stream'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../../src/tools/background/manager'; -import { writeTask } from '../../../src/tools/background/persist'; -import { BACKGROUND_TASK_TIMEOUT_STOP_REASON } from '../../../src/tools/background/task'; +import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; +import { writeTask } from '../../../src/agent/background/persist'; import { TaskListTool } from '../../../src/tools/background/task-list'; import { TaskOutputTool } from '../../../src/tools/background/task-output'; import { TaskStopTool } from '../../../src/tools/background/task-stop'; @@ -94,7 +93,7 @@ function processExitingAfterTimer(exitCode = 143, delayMs = 5): KaosProcess { } async function waitForPersistedOutput( - manager: BackgroundProcessManager, + manager: BackgroundManager, taskId: string, expectedOutput: string, ) { @@ -119,7 +118,7 @@ async function waitForPersistedOutput( } async function waitForLiveOutput( - manager: BackgroundProcessManager, + manager: BackgroundManager, taskId: string, expectedOutput: string, ): Promise { @@ -133,7 +132,7 @@ async function waitForLiveOutput( } describe('TaskListTool', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new TaskListTool(manager); afterEach(() => { @@ -286,7 +285,7 @@ describe('TaskListTool', () => { }); describe('TaskOutputTool', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new TaskOutputTool(manager); afterEach(() => { @@ -310,7 +309,7 @@ describe('TaskOutputTool', () => { // manager + a terminal task keep teardown free of the cleanup race // that non-terminal tasks (still flushing persistence) would cause. const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-output-tool-')); - const ownManager = new BackgroundProcessManager(); + const ownManager = new BackgroundManager(); ownManager.attachSessionDir(sessionDir); try { const taskId = ownManager.register( @@ -362,14 +361,14 @@ describe('TaskOutputTool', () => { }); it('returns agent metadata and final summary without process fields', async () => { - const taskId = manager.registerAgentTask( + const taskId = manager.registerTask(new AgentBackgroundTask( Promise.resolve({ result: 'SUBAGENT-FINAL-SUMMARY\n' }), 'agent output test', { agentId: 'agent-child', subagentType: 'coder', }, - ); + )); await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); const result = await executeTool(tool, context('c_agent_output', { task_id: taskId })); @@ -378,7 +377,7 @@ describe('TaskOutputTool', () => { const content = toolContentString(result); expect(content).toContain('kind: agent'); expect(content).toContain('agent_id: agent-child'); - expect(content).toContain('actual_subagent_type: coder'); + expect(content).toContain('subagent_type: coder'); expect(content).toContain('[output]\nSUBAGENT-FINAL-SUMMARY'); expect(content).not.toMatch(/^pid:/m); expect(content).not.toMatch(/^command:/m); @@ -388,7 +387,7 @@ describe('TaskOutputTool', () => { it('reads persisted output for a task loaded after restart', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-output-')); try { - const writer = new BackgroundProcessManager(); + const writer = new BackgroundManager(); writer.attachSessionDir(sessionDir); const taskId = writer.register( immediateProcess(0, 'persisted output\n'), @@ -398,7 +397,7 @@ describe('TaskOutputTool', () => { await expect(writer.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - const reader = new BackgroundProcessManager(); + const reader = new BackgroundManager(); reader.attachSessionDir(sessionDir); const { result, content } = await waitForPersistedOutput(reader, taskId, 'persisted output'); @@ -468,7 +467,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { it('truncates output > 32 KiB to a tail preview and reports paging metadata', async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-trunc-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { // 200 KiB of distinct content: head marker ... tail marker. @@ -503,7 +502,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { it('does not silently drop the head of a > 1 MiB running task', async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-ring-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { // Stream > 1 MiB so the in-memory ring buffer (1 MiB cap) would @@ -542,7 +541,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { }); it('exposes paging guidance (Read + output_path) in the tool description', () => { - const tool = new TaskOutputTool(new BackgroundProcessManager()); + const tool = new TaskOutputTool(new BackgroundManager()); const desc = tool.description; // Guideline 6 from the parity source: when the preview is truncated, // page the full log with the `Read` tool and the returned output_path. @@ -555,7 +554,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { it('does not mark small output (< 32 KiB) as truncated', async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-small-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { const small = 'small output line\n'; @@ -578,7 +577,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { it('flags truncation when the tail window starts mid-multibyte character', async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-utf8-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { // A 3-byte char at the head, then ASCII filler so the log is exactly @@ -612,7 +611,7 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { it('keeps preview and metadata consistent from a single log-size snapshot', async () => { sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-grow-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { // A 40 KiB ASCII log — larger than the 32 KiB preview window. @@ -648,20 +647,20 @@ describe('TaskOutputTool — large output truncation + paging protocol', () => { describe('TaskOutputTool — terminal metadata fields', () => { it('exposes stop_reason and terminal_reason for an agent task aborted by its deadline', async () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); try { // An agent task whose completion never resolves: the external deadline - // fires and finalizes the task with a timeout stop reason. - const taskId = manager.registerAgentTask( + // fires and finalizes the task with the timed_out status. + const taskId = manager.registerTask(new AgentBackgroundTask( new Promise<{ result: string }>(() => {}), 'slow agent', { timeoutMs: 1, }, - ); + )); await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ - status: 'failed', - stopReason: BACKGROUND_TASK_TIMEOUT_STOP_REASON, + status: 'timed_out', + stopReason: undefined, }); const result = await executeTool( @@ -670,7 +669,8 @@ describe('TaskOutputTool — terminal metadata fields', () => { ); expect(result.isError).toBe(false); const content = toolContentString(result); - expect(content).toContain(`stop_reason: ${BACKGROUND_TASK_TIMEOUT_STOP_REASON}`); + expect(content).toContain('status: timed_out'); + expect(content).not.toContain('stop_reason:'); expect(content).toContain('terminal_reason: timed_out'); expect(content).not.toContain('timed_out:'); } finally { @@ -679,7 +679,7 @@ describe('TaskOutputTool — terminal metadata fields', () => { }); it('exposes stop_reason and terminal_reason for a task stopped via TaskStop', async () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); try { const proc = pendingProcess(); const taskId = manager.register(proc, 'sleep 60', 'stoppable task'); @@ -704,7 +704,7 @@ describe('TaskOutputTool — terminal metadata fields', () => { it('omits stop_reason / terminal_reason for a normally completed task', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-meta-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { const taskId = manager.register(immediateProcess(0, 'done\n'), 'echo done', 'normal task'); @@ -729,7 +729,7 @@ describe('TaskOutputTool — terminal metadata fields', () => { describe('TaskOutputTool — full-output guidance', () => { it('does not advertise an output_path when the persisted log file does not exist', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-empty-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { const taskId = manager.register(immediateProcess(0), 'sleep 1', 'silent task'); @@ -753,7 +753,7 @@ describe('TaskOutputTool — full-output guidance', () => { it('emits full_output_available / full_output_tool even when output is not truncated', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-untrunc-')); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); manager.attachSessionDir(sessionDir); try { const small = 'small output line\n'; @@ -779,7 +779,7 @@ describe('TaskOutputTool — full-output guidance', () => { }); describe('TaskStopTool', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new TaskStopTool(manager); afterEach(() => { @@ -835,7 +835,7 @@ describe('TaskStopTool', () => { it('persists stop reason when attached to a session directory', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-reason-')); try { - const writer = new BackgroundProcessManager(); + const writer = new BackgroundManager(); writer.attachSessionDir(sessionDir); const taskId = writer.register(pendingProcess(), 'sleep 60', 'persist stop reason test'); @@ -844,7 +844,7 @@ describe('TaskStopTool', () => { ); expect(result.isError).toBe(false); - const reader = new BackgroundProcessManager(); + const reader = new BackgroundManager(); reader.attachSessionDir(sessionDir); await reader.loadFromDisk(); expect(reader.getTask(taskId)?.stopReason).toBe('operator cancelled'); @@ -911,7 +911,7 @@ describe('TaskStopTool', () => { status: 'killed', stop_reason: '', }); - const reader = new BackgroundProcessManager(); + const reader = new BackgroundManager(); reader.attachSessionDir(sessionDir); await reader.loadFromDisk(); @@ -932,7 +932,7 @@ describe('TaskStopTool', () => { // ── py-aligned envelope contracts ────────────────────────────────────── describe('TaskOutputTool — py envelope contract', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new TaskOutputTool(manager); afterEach(() => { @@ -948,7 +948,7 @@ describe('TaskOutputTool — py envelope contract', () => { const { mkdtemp, rm } = await import('node:fs/promises'); const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-env-')); try { - const m2 = new BackgroundProcessManager(); + const m2 = new BackgroundManager(); m2.attachSessionDir(sessionDir); const t = new TaskOutputTool(m2); const proc = immediateProcess(0, 'build line 1\nbuild line 2\n'); @@ -1005,7 +1005,7 @@ describe('TaskOutputTool — py envelope contract', () => { const { mkdtemp, readdir, rm } = await import('node:fs/promises'); const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-missing-')); try { - const m2 = new BackgroundProcessManager(); + const m2 = new BackgroundManager(); m2.attachSessionDir(sessionDir); const t = new TaskOutputTool(m2); const r = await executeTool(t, context('c_missing', { task_id: 'bash-noex0000' })); @@ -1018,22 +1018,21 @@ describe('TaskOutputTool — py envelope contract', () => { } }); - // For a task that timed out (failed terminal state with timeout stop reason), - // the envelope surfaces: status:failed + stop_reason:Timed out + - // terminal_reason:timed_out. The Python contract also includes + // For a task that timed out, the envelope surfaces: + // status:timed_out + terminal_reason:timed_out. The Python contract also includes // `interrupted: true` and a standalone `reason:` line; TS deliberately // omits both — `interrupted` is not modeled, and the categorical // `terminal_reason` is preferred over a separate prose `reason` field // (PR#243 by-design exclusion). The TS contract assertions below // suffice; the dropped assertions are documented for traceability. it('a timed-out task surfaces the full timeout contract', async () => { - // Build a manager state where status=failed and stopReason=Timed out. - const taskId = manager.registerAgentTask(new Promise(() => {}), 'will time out', { + // Build a manager state where status=timed_out. + const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'will time out', { timeoutMs: 50, - }); + })); const info = await manager.waitForTerminal(taskId); - expect(info?.status).toBe('failed'); - expect(info?.stopReason).toBe(BACKGROUND_TASK_TIMEOUT_STOP_REASON); + expect(info?.status).toBe('timed_out'); + expect(info?.stopReason).toBeUndefined(); const result = await executeTool( tool, @@ -1041,8 +1040,8 @@ describe('TaskOutputTool — py envelope contract', () => { ); expect(result.isError).toBe(false); const text = toolContentString(result); - expect(text).toContain('status: failed'); - expect(text).toContain(`stop_reason: ${BACKGROUND_TASK_TIMEOUT_STOP_REASON}`); + expect(text).toContain('status: timed_out'); + expect(text).not.toContain('stop_reason:'); expect(text).toContain('terminal_reason: timed_out'); expect(text).not.toContain('timed_out:'); }); @@ -1057,7 +1056,7 @@ describe('TaskOutputTool — py envelope contract', () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-trunc-')); try { const big = 'first marker\n' + 'x'.repeat(33 * 1024) + '\nlast marker\n'; - const m2 = new BackgroundProcessManager(); + const m2 = new BackgroundManager(); m2.attachSessionDir(sessionDir); const taskId = m2.register(immediateProcess(0, big), 'big', 'big output'); await m2.wait(taskId, 5_000); @@ -1087,7 +1086,7 @@ describe('TaskOutputTool — py envelope contract', () => { }); describe('TaskListTool — py envelope contract', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new TaskListTool(manager); afterEach(() => { @@ -1129,7 +1128,7 @@ describe('TaskListTool — py envelope contract', () => { // agents know when to reach for each tool. describe('background tool descriptions', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); }); @@ -1175,7 +1174,7 @@ describe('background tool descriptions', () => { // `getOutput(taskId, tail)`. These tests exercise the TS surface to // lock down the underlying behavior, not the Python method names. describe('background store — partial output reads (TS surface)', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); afterEach(() => { manager._reset(); }); @@ -1184,7 +1183,7 @@ describe('background store — partial output reads (TS surface)', () => { const { mkdtemp, rm } = await import('node:fs/promises'); const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-range-')); try { - const m2 = new BackgroundProcessManager(); + const m2 = new BackgroundManager(); m2.attachSessionDir(sessionDir); const proc = immediateProcess(0, 'line1\nline2\nline3\n'); const taskId = m2.register(proc, 'echo lines', 'range read'); @@ -1223,7 +1222,7 @@ describe('background store — partial output reads (TS surface)', () => { // covered by ApprovalRuntime's own tests; this test scopes only the // status transition through the tool boundary. describe('TaskStopTool on awaiting-approval agents', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const stop = new TaskStopTool(manager); afterEach(() => { @@ -1235,13 +1234,13 @@ describe('TaskStopTool on awaiting-approval agents', () => { const completion = new Promise<{ result: string }>((_res, rej) => { rejectCompletion = rej; }); - const taskId = manager.registerAgentTask(completion, 'awaiting kill', { + const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'awaiting kill', { abort: () => { const abortError = new Error('cancelled'); abortError.name = 'AbortError'; rejectCompletion(abortError); }, - }); + })); manager.markAwaitingApproval(taskId, 'edit file'); const result = await executeTool(stop, context('c_stop_awaiting', { task_id: taskId })); expect(result.isError).toBe(false); diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index d4f5ecce..8cff5511 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -3,7 +3,7 @@ import { PassThrough, Readable, type Writable } from 'node:stream'; import type { Environment, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; -import { BackgroundProcessManager } from '../../src/tools/background/manager'; +import { BackgroundManager } from '../../src/agent/background'; import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash'; import { createFakeKaos } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; @@ -242,7 +242,7 @@ describe('BashTool', () => { const tool = new BashTool( createFakeKaos({ osEnv: posixEnv }), '/workspace', - new BackgroundProcessManager(), + new BackgroundManager(), ); expect(tool.description).toContain('Commands available'); @@ -454,7 +454,7 @@ describe('BashTool', () => { expect(unavailable.output).toContain('Background execution is not available'); expect(execWithEnv).not.toHaveBeenCalled(); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const withManager = new BashTool( createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', @@ -472,7 +472,7 @@ describe('BashTool', () => { it('registers background commands and returns a task id', async () => { const proc = processWithOutput(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -485,7 +485,7 @@ describe('BashTool', () => { }); it('does not spawn background commands when the task limit is reached', async () => { - const manager = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const manager = new BackgroundManager({ maxRunningTasks: 1 }); manager.register(processWithOutput(), 'sleep 10', 'existing task'); const execWithEnv = vi.fn().mockResolvedValue(processWithOutput()); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); @@ -502,7 +502,7 @@ describe('BashTool', () => { }); it('reserves a task slot before spawning concurrent background commands', async () => { - const manager = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const manager = new BackgroundManager({ maxRunningTasks: 1 }); const execWithEnv = vi .fn() .mockResolvedValueOnce( @@ -533,7 +533,7 @@ describe('BashTool', () => { }); it('preserves background reservations while using Git Bash semantics on Windows', async () => { - const manager = new BackgroundProcessManager({ maxRunningTasks: 1 }); + const manager = new BackgroundManager({ maxRunningTasks: 1 }); const execWithEnv = vi .fn() .mockResolvedValueOnce( @@ -587,7 +587,7 @@ describe('BashTool', () => { try { const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -623,7 +623,7 @@ describe('BashTool', () => { try { const proc = processThatNeverExits(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -648,7 +648,7 @@ describe('BashTool', () => { try { const proc = processThatNeverExits(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -779,7 +779,7 @@ describe('BashTool', () => { it('reports background task startup with task_id, status, automatic_notification, and a human-shell hint', async () => { const proc = processWithOutput(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool( @@ -797,7 +797,7 @@ describe('BashTool', () => { }); it('rejects background command without description (description-required guard)', async () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); const execWithEnv = vi.fn().mockResolvedValue(processWithOutput()); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); @@ -838,7 +838,7 @@ describe('BashTool', () => { const tool = new BashTool( createFakeKaos({ osEnv: posixEnv }), '/workspace', - new BackgroundProcessManager(), + new BackgroundManager(), ); const description = tool.description; @@ -862,7 +862,7 @@ describe('BashTool prompt / runtime consistency', () => { const enabledTool = new BashTool( createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', - new BackgroundProcessManager(), + new BackgroundManager(), ); const promptToolNames = new Set( [...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]), diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index ad44a1fb..8d7527fe 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { SkillRegistry } from '../../src/skill'; -import { BackgroundProcessManager } from '../../src/tools/background/manager'; +import { BackgroundManager } from '../../src/agent/background'; import { TaskListInputSchema } from '../../src/tools/background/task-list'; import { TaskOutputInputSchema } from '../../src/tools/background/task-output'; import { TaskStopInputSchema } from '../../src/tools/background/task-stop'; @@ -310,7 +310,7 @@ describe('current builtin collaboration tools', () => { describe('current builtin background tool schemas', () => { it('background task schemas and manager-backed tools are covered', () => { - const manager = new BackgroundProcessManager(); + const manager = new BackgroundManager(); expect(TaskListInputSchema.safeParse({ active_only: true }).success).toBe(true); expect(TaskOutputInputSchema.safeParse({ task_id: 'bash-1' }).success).toBe(true); From ee96df7c0c19ea7ac057721c13cd01c3f564b9a8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 17:29:43 +0800 Subject: [PATCH 03/21] refactor: simplify background manager API --- .../agent-core/src/agent/background/index.ts | 199 ++++---------- packages/agent-core/src/index.ts | 1 - .../src/tools/builtin/shell/bash.ts | 14 +- .../test/tools/background/lifecycle.test.ts | 259 ------------------ .../test/tools/background/manager.test.ts | 4 +- 5 files changed, 54 insertions(+), 423 deletions(-) delete mode 100644 packages/agent-core/test/tools/background/lifecycle.test.ts diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 3727271e..9c2ac7eb 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -6,14 +6,12 @@ * Each task gets a unique ID, captures stdout+stderr to a ring buffer, * and supports status query / output retrieval / stop operations. * - * Accepts `KaosProcess` (not `ChildProcess`) so there is no unsafe cast - * at the BashTool call site. Lifecycle detection uses `wait()` instead - * of EventEmitter `on('exit')`. + * Concrete task classes own execution details; the manager owns task + * registration, lifecycle state, persistence, output, and notifications. */ import { randomBytes } from 'node:crypto'; -import type { KaosProcess } from '@moonshot-ai/kaos'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; @@ -33,7 +31,6 @@ import { writeTask, type PersistedTask, } from './persist'; -import { ProcessBackgroundTask } from './process-task'; import { TERMINAL_BACKGROUND_TASK_STATUSES, type BackgroundTask, @@ -76,9 +73,6 @@ export type { BackgroundTaskStatus, } from './task'; -/** Lifecycle phases observed by `onLifecycle` subscribers. */ -export type BackgroundLifecycleEvent = 'started' | 'updated' | 'terminated'; - interface ManagedTask { readonly taskId: string; readonly task: BackgroundTask; @@ -155,11 +149,6 @@ export interface ReconcileResult { readonly lostInfo: readonly BackgroundTaskInfo[]; } -export interface BackgroundManagerOptions { - readonly maxRunningTasks?: number; - readonly sessionDir?: string; -} - export interface BackgroundTaskReservation { release(): void; } @@ -210,7 +199,7 @@ const NOTIFICATION_TAIL_BYTES = 3_000; export class BackgroundManager { private readonly tasks = new Map(); private reservedTaskSlots = 0; - public readonly agent?: Agent; + public readonly agent: Agent; private readonly maxRunningTasks?: number; /** * Ghosts: tasks loaded from disk during reconcile that have no live @@ -228,65 +217,13 @@ export class BackgroundManager { private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = []; - /** - * Registered lifecycle callbacks. Fired for every observable - * transition (started / updated / terminated). Errors thrown by - * callbacks are silently swallowed so the BPM main flow never breaks - * because of a buggy subscriber. - */ - private readonly lifecycleCallbacks: Array< - (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void - > = []; private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); - constructor(agentOrOptions: Agent | BackgroundManagerOptions = {}) { - if (isAgent(agentOrOptions)) { - this.agent = agentOrOptions; - this.maxRunningTasks = agentOrOptions.kimiConfig?.background?.maxRunningTasks; - this.sessionDir = agentOrOptions.homedir; - } else { - this.maxRunningTasks = agentOrOptions.maxRunningTasks; - this.sessionDir = agentOrOptions.sessionDir; - } - - const agent = this.agent; - if (agent === undefined) return; - - this.onLifecycle((event, info) => { - switch (event) { - case 'started': - agent.emitEvent({ type: 'background.task.started', info }); - agent.telemetry.track('background_task_created', { - kind: info.kind === 'agent' ? 'agent' : 'bash', - }); - return; - case 'updated': - agent.emitEvent({ type: 'background.task.updated', info }); - return; - case 'terminated': { - agent.emitEvent({ type: 'background.task.terminated', info }); - const success = info.status === 'completed'; - const duration_s = - info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; - const properties: Record = { - kind: info.kind === 'agent' ? 'agent' : 'bash', - success, - duration_s, - }; - if (!success) { - properties['reason'] = - info.status === 'timed_out' - ? 'timeout' - : info.status === 'killed' - ? 'killed' - : 'error'; - } - agent.telemetry.track('background_task_completed', properties); - return; - } - } - }); + constructor(agent: Agent) { + this.agent = agent; + this.maxRunningTasks = agent.kimiConfig?.background?.maxRunningTasks; + this.sessionDir = agent.homedir; } /** @@ -299,33 +236,6 @@ export class BackgroundManager { this.terminalCallbacks.push(callback); } - /** - * Register a callback that fires on every lifecycle transition: - * - 'started': task just registered (either bash or agent) - * - 'updated': awaiting_approval entered / cleared - * - 'terminated': task reached a terminal state (also triggers - * onTerminal); fires exactly once per task. - * - * Synchronous callback. Errors are swallowed so the BPM lifecycle - * machinery (status updates, persistence, waiters) cannot be blocked - * by a buggy subscriber. Use it for fan-out to RPC events; do not put - * heavy work in it (defer to microtask if needed). - */ - onLifecycle(callback: (event: BackgroundLifecycleEvent, info: BackgroundTaskInfo) => void): void { - this.lifecycleCallbacks.push(callback); - } - - /** Fan out a lifecycle event to subscribers. */ - private fireLifecycle(event: BackgroundLifecycleEvent, info: BackgroundTaskInfo): void { - for (const cb of this.lifecycleCallbacks) { - try { - cb(event, info); - } catch { - /* swallow callback errors */ - } - } - } - /** * Fire all registered terminal callbacks for a task. Idempotent: the * second invocation for the same task is a no-op so `reconcile()` / @@ -356,7 +266,38 @@ export class BackgroundManager { /* swallow callback errors */ } } - this.fireLifecycle('terminated', info); + this.emitTaskTerminated(info); + } + + private emitTaskStarted(info: BackgroundTaskInfo): void { + this.agent.emitEvent({ type: 'background.task.started', info }); + this.agent.telemetry.track('background_task_created', { + kind: info.kind === 'agent' ? 'agent' : 'bash', + }); + } + + private emitTaskUpdated(info: BackgroundTaskInfo): void { + this.agent.emitEvent({ type: 'background.task.updated', info }); + } + + private emitTaskTerminated(info: BackgroundTaskInfo): void { + this.agent.emitEvent({ type: 'background.task.terminated', info }); + const success = info.status === 'completed'; + const duration_s = info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; + const properties: Record = { + kind: info.kind === 'agent' ? 'agent' : 'bash', + success, + duration_s, + }; + if (!success) { + properties['reason'] = + info.status === 'timed_out' + ? 'timeout' + : info.status === 'killed' + ? 'killed' + : 'error'; + } + this.agent.telemetry.track('background_task_completed', properties); } private resolveWaiters(entry: ManagedTask): void { @@ -406,44 +347,6 @@ export class BackgroundManager { return count; } - /** - * Register a KaosProcess as a background task. - * Starts capturing stdout/stderr and monitors lifecycle via `wait()`. - * Returns the assigned task ID. - * - * `opts.kind` picks the id prefix. Defaults to `'bash'` because bash - * subprocess registration is the only caller on the process path today. - * Agent tasks are constructed by their caller and registered through - * `registerTask`. - */ - register( - proc: KaosProcess, - command: string, - description: string, - opts: - | { - kind?: string; - /** - * Optional shell metadata. Carried so the `/task` UI and the - * background persist snapshot can surface which dialect a - * task was launched under. Legacy callers omitting this - * field keep the implicit 'bash' default. - */ - shellInfo?: { - shellName: string; - shellPath: string; - cwd: string; - }; - reservation?: BackgroundTaskReservation; - } - | undefined = undefined, - ): string { - return this.registerTask( - new ProcessBackgroundTask(proc, command, description, { idPrefix: opts?.kind }), - opts?.reservation, - ); - } - registerTask( task: BackgroundTask, reservation?: BackgroundTaskReservation, @@ -488,7 +391,7 @@ export class BackgroundManager { // Initial persistence (snapshot at start). void this.persistLive(entry); - this.fireLifecycle('started', this.toInfo(entry)); + this.emitTaskStarted(this.toInfo(entry)); void entry.lifecyclePromise; @@ -685,7 +588,7 @@ export class BackgroundManager { /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { - this.agent?.records.logRecord({ + this.agent.records.logRecord({ type: 'background.stop', taskId, }); @@ -813,7 +716,7 @@ export class BackgroundManager { entry.status = 'awaiting_approval'; entry.approvalReason = reason; void this.persistLive(entry); - this.fireLifecycle('updated', this.toInfo(entry)); + this.emitTaskUpdated(this.toInfo(entry)); } /** @@ -829,7 +732,7 @@ export class BackgroundManager { entry.status = 'running'; entry.approvalReason = undefined; void this.persistLive(entry); - this.fireLifecycle('updated', this.toInfo(entry)); + this.emitTaskUpdated(this.toInfo(entry)); } // ── completion event (await lifecycle end) ──────────────────────── @@ -870,7 +773,7 @@ export class BackgroundManager { /** * Attach the manager to a session directory for persistence. Tasks - * created via `register()` after this call are written to + * created via `registerTask()` after this call are written to * `/tasks/.json` and updated on lifecycle change. * Tasks created before attach are NOT retroactively persisted. */ @@ -951,7 +854,7 @@ export class BackgroundManager { /** * Persist the current state of a live ManagedTask. Called from - * `register()` and the lifecycle finally block. No-op unless attached. + * `registerTask()` and the lifecycle finally block. No-op unless attached. */ private persistLive(entry: ManagedTask): Promise { if (this.sessionDir === undefined) return Promise.resolve(); @@ -997,20 +900,16 @@ export class BackgroundManager { } private async notifyBackgroundTask(info: BackgroundTaskInfo): Promise { - const agent = this.agent; - if (agent === undefined) return; const context = await this.buildBackgroundTaskNotificationContext(info); if (context === undefined) return; - agent.turn.steer(context.content, context.origin); + this.agent.turn.steer(context.content, context.origin); this.fireNotificationHook(context.notification); } private async restoreBackgroundTaskNotification(info: BackgroundTaskInfo): Promise { - const agent = this.agent; - if (agent === undefined) return; const context = await this.buildBackgroundTaskNotificationContext(info); if (context === undefined) return; - agent.context.appendUserMessage(context.content, context.origin); + this.agent.context.appendUserMessage(context.content, context.origin); this.fireNotificationHook(context.notification); } @@ -1189,10 +1088,6 @@ function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { }; } -function isAgent(value: Agent | BackgroundManagerOptions): value is Agent { - return typeof value === 'object' && value !== null && 'emitEvent' in value && 'turn' in value; -} - function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 37cf9dd9..f0be48bd 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -35,7 +35,6 @@ export type { UserPromptOrigin, } from './agent/context'; export type { - BackgroundLifecycleEvent, AgentBackgroundTaskInfo, BackgroundTaskInfo, BackgroundTaskKind, diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 9405e241..4d0135f7 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -29,7 +29,7 @@ import { StringDecoder } from 'node:string_decoder'; import type { Kaos, KaosProcess } from '@moonshot-ai/kaos'; import { z } from 'zod'; -import type { BackgroundManager } from '../../../agent/background'; +import { ProcessBackgroundTask, type BackgroundManager } from '../../../agent/background'; import type { BuiltinTool } from '../../../agent/tool'; import type { ExecutableToolResult, ToolExecution } from '../../../loop/types'; import { renderPrompt } from '../../../utils/render-prompt'; @@ -399,14 +399,10 @@ export class BashTool implements BuiltinTool { let taskId: string; try { - taskId = backgroundManager.register(proc, command, args.description.trim(), { + taskId = backgroundManager.registerTask( + new ProcessBackgroundTask(proc, command, args.description.trim()), reservation, - shellInfo: { - shellName: this.kaos.osEnv.shellName, - shellPath: this.kaos.osEnv.shellPath, - cwd: args.cwd ?? this.cwd, - }, - }); + ); } catch (error) { reservation.release(); try { @@ -435,7 +431,7 @@ export class BashTool implements BuiltinTool { }, timeoutMs); } - // register() synchronously inserts taskId into the manager's Map, so + // registerTask() synchronously inserts taskId into the manager's Map, so // this lookup in the same tick cannot return undefined. const status = backgroundManager.getTask(taskId)!.status; const builder = new ToolResultBuilder(); diff --git a/packages/agent-core/test/tools/background/lifecycle.test.ts b/packages/agent-core/test/tools/background/lifecycle.test.ts deleted file mode 100644 index f69a857d..00000000 --- a/packages/agent-core/test/tools/background/lifecycle.test.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * BackgroundManager — onLifecycle hook. - * - * Covers the three lifecycle events emitted to subscribers: - * - 'started' on register / registerAgentTask - * - 'updated' on awaiting_approval enter / leave - * - 'terminated' on natural exit / failure / stop / reconcile-as-lost - * - * Subscribers must receive each phase exactly once per task, in order, - * with the current `BackgroundTaskInfo` snapshot. - */ - -import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { Readable } from 'node:stream'; -import type { Writable } from 'node:stream'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AgentBackgroundTask, BackgroundManager, type BackgroundTaskInfo } from '../../../src/agent/background'; - -type LifecycleEvent = 'started' | 'updated' | 'terminated'; - -interface CallRecord { - event: LifecycleEvent; - info: BackgroundTaskInfo; -} - -function makeRecorder(): { - records: CallRecord[]; - callback: (event: LifecycleEvent, info: BackgroundTaskInfo) => void; -} { - const records: CallRecord[] = []; - return { - records, - callback: (event, info) => { - records.push({ event, info }); - }, - }; -} - -function immediateProcess(exitCode: number): KaosProcess { - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 20000 + exitCode, - exitCode, - wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; -} - -function pendingProcess(): KaosProcess { - let resolveWait: (code: number) => void = () => {}; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 88888, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: vi.fn(async () => { - if (currentExitCode === null) { - currentExitCode = 143; - resolveWait(143); - } - }) as unknown as KaosProcess['kill'], - }; -} - -describe('BackgroundManager — onLifecycle', () => { - let manager: BackgroundManager; - - beforeEach(() => { - manager = new BackgroundManager(); - }); - - afterEach(() => { - manager._reset(); - }); - - it("fires 'started' immediately on register()", () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - const taskId = manager.register(pendingProcess(), 'sleep 60', 'long task'); - - expect(records.length).toBe(1); - expect(records[0]!.event).toBe('started'); - expect(records[0]!.info.taskId).toBe(taskId); - expect(records[0]!.info.status).toBe('running'); - }); - - it("fires 'started' on registerAgentTask()", () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'an agent')); - - expect(records.length).toBe(1); - expect(records[0]!.event).toBe('started'); - expect(records[0]!.info.taskId).toBe(taskId); - expect(records[0]!.info.taskId).toMatch(/^agent-/); - }); - - it("fires 'updated' on markAwaitingApproval / clearAwaitingApproval", () => { - const { records, callback } = makeRecorder(); - const taskId = manager.register(pendingProcess(), 'sleep', 'demo'); - manager.onLifecycle(callback); - - manager.markAwaitingApproval(taskId, 'needs permission'); - manager.clearAwaitingApproval(taskId); - - const events = records.map((r) => r.event); - expect(events).toEqual(['updated', 'updated']); - expect(records[0]!.info.status).toBe('awaiting_approval'); - expect(records[0]!.info.approvalReason).toBe('needs permission'); - expect(records[1]!.info.status).toBe('running'); - expect(records[1]!.info.approvalReason).toBeUndefined(); - }); - - it("does not fire 'updated' for no-op markAwaitingApproval / clearAwaitingApproval", () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - // unknown task - manager.markAwaitingApproval('bash-deadbeef', 'nope'); - manager.clearAwaitingApproval('bash-deadbeef'); - - // clear when not in awaiting_approval state is a no-op - const taskId = manager.register(pendingProcess(), 'sleep', 'demo'); - records.length = 0; - manager.clearAwaitingApproval(taskId); - - expect(records.length).toBe(0); - }); - - it("fires 'terminated' on natural process exit (completed)", async () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - manager.register(immediateProcess(0), 'echo', 'done'); - await new Promise((r) => setTimeout(r, 20)); - - const terminated = records.filter((r) => r.event === 'terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info).toMatchObject({ - kind: 'process', - status: 'completed', - exitCode: 0, - }); - }); - - it("fires 'terminated' on non-zero exit (failed)", async () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - manager.register(immediateProcess(2), 'false', 'fail'); - await new Promise((r) => setTimeout(r, 20)); - - const terminated = records.filter((r) => r.event === 'terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info).toMatchObject({ - kind: 'process', - status: 'failed', - exitCode: 2, - }); - }); - - it("fires 'terminated' exactly once for the same task (idempotent)", async () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - manager.register(immediateProcess(0), 'echo', 'done'); - await new Promise((r) => setTimeout(r, 20)); - // Manual settle attempt — should not produce a second terminated event. - await manager.settlePendingExits(); - - const terminated = records.filter((r) => r.event === 'terminated'); - expect(terminated.length).toBe(1); - }); - - it("fires 'started' -> 'terminated' in order on full lifecycle", async () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - manager.register(immediateProcess(0), 'echo', 'done'); - await new Promise((r) => setTimeout(r, 20)); - - const events = records.map((r) => r.event); - expect(events).toEqual(['started', 'terminated']); - }); - - it("fires 'terminated' with status='killed' on stop()", async () => { - const { records, callback } = makeRecorder(); - manager.onLifecycle(callback); - - const taskId = manager.register(pendingProcess(), 'sleep 60', 'long'); - await manager.stop(taskId, 'user requested'); - - const terminated = records.filter((r) => r.event === 'terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('killed'); - expect(terminated[0]!.info.stopReason).toBe('user requested'); - }); - - it("fires 'terminated' for ghost reconcile (lost)", async () => { - const dir = mkdtempSync(join(tmpdir(), 'bpm-lifecycle-')); - try { - // Seed disk with a running ghost the way persist would. - const tasksDir = join(dir, 'tasks'); - mkdirSync(tasksDir, { recursive: true }); - const ghost = { - task_id: 'bash-deadbeef', - command: 'sleep 9999', - description: 'ghost task', - pid: 99999, - started_at: Date.now() - 60_000, - ended_at: null, - exit_code: null, - status: 'running', - approval_reason: undefined, - stop_reason: undefined, - }; - writeFileSync(join(tasksDir, 'bash-deadbeef.json'), JSON.stringify(ghost)); - - const { records, callback } = makeRecorder(); - manager.attachSessionDir(dir); - await manager.loadFromDisk(); - manager.onLifecycle(callback); - - const result = await manager.reconcile(); - - expect(result.lost).toEqual(['bash-deadbeef']); - const terminated = records.filter((r) => r.event === 'terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('lost'); - expect(terminated[0]!.info.taskId).toBe('bash-deadbeef'); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it('swallows subscriber errors so main flow is unaffected', () => { - manager.onLifecycle(() => { - throw new Error('boom'); - }); - expect(() => manager.register(pendingProcess(), 'sleep', 'x')).not.toThrow(); - }); -}); diff --git a/packages/agent-core/test/tools/background/manager.test.ts b/packages/agent-core/test/tools/background/manager.test.ts index a10f7070..c16276b6 100644 --- a/packages/agent-core/test/tools/background/manager.test.ts +++ b/packages/agent-core/test/tools/background/manager.test.ts @@ -472,8 +472,8 @@ describe('BackgroundManager', () => { try { const local = new BackgroundManager(); const terminated: string[] = []; - local.onLifecycle((event, info) => { - if (event === 'terminated') terminated.push(info.status); + local.onTerminal((info) => { + terminated.push(info.status); }); const { proc, killSpy } = processExitingAfterSigkill(137, 25); const taskId = local.register(proc, 'sleep 60', 'forced kill test'); From e4fc891acebe3acaa2db26f80d2425ffda984d95 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 17:39:53 +0800 Subject: [PATCH 04/21] fix --- .../agent-core/src/agent/background/index.ts | 10 +++------ .../src/tools/builtin/shell/bash.ts | 22 ++++--------------- 2 files changed, 7 insertions(+), 25 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 9c2ac7eb..6596bc6d 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -377,17 +377,13 @@ export class BackgroundManager { this.tasks.set(taskId, entry); const sink = this.createTaskSink(entry); - try { - entry.lifecyclePromise = Promise.resolve(task.start(sink)).catch(async () => { + entry.lifecyclePromise = Promise.resolve() + .then(() => task.start(sink)) + .catch(async () => { await this.settleTask(entry, { status: entry.abortController.signal.aborted ? 'killed' : 'failed', }); }); - } catch { - entry.lifecyclePromise = this.settleTask(entry, { - status: entry.abortController.signal.aborted ? 'killed' : 'failed', - }).then(() => {}); - } // Initial persistence (snapshot at start). void this.persistLive(entry); diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 4d0135f7..2f766371 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -397,24 +397,10 @@ export class BashTool implements BuiltinTool { /* process already gone */ } - let taskId: string; - try { - taskId = backgroundManager.registerTask( - new ProcessBackgroundTask(proc, command, args.description.trim()), - reservation, - ); - } catch (error) { - reservation.release(); - try { - await proc.kill('SIGTERM'); - } catch { - /* process already gone */ - } - return { - isError: true, - output: error instanceof Error ? error.message : String(error), - }; - } + const taskId = backgroundManager.registerTask( + new ProcessBackgroundTask(proc, command, args.description.trim()), + reservation, + ); if (timeoutMs !== undefined) { setTimeout(() => { From f6cbd3eeadbf8f4ef2045a0a675833797dbdc9cb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 17:51:00 +0800 Subject: [PATCH 05/21] fix --- .../agent-core/src/agent/background/index.ts | 168 ++---------------- .../src/agent/background/persist.ts | 27 +-- .../agent-core/src/agent/background/task.ts | 2 - 3 files changed, 16 insertions(+), 181 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 6596bc6d..2c37fa93 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -23,7 +23,6 @@ import { listTasks, readTaskOutput, readTaskOutputBytes, - removeTask, taskOutputExists, taskOutputExistsSync, taskOutputFile, @@ -84,7 +83,7 @@ interface ManagedTask { endedAt: number | null; /** Listeners awaiting task completion. */ readonly waiters: Array<() => void>; - /** True once `fireTerminalCallbacks` has already run. */ + /** True once terminal notification/event side effects have already run. */ terminalFired: boolean; /** Reason carried while awaiting approval. */ approvalReason?: string | undefined; @@ -92,8 +91,6 @@ interface ManagedTask { stopReason?: string | undefined; /** Deadline supplied at registration; surfaced via task info. */ timeoutMs?: number | undefined; - /** Non-terminal-reclassification reason (e.g. stale heartbeat). */ - failureReason?: string | undefined; /** Cancellation signal owned by the manager and observed by the concrete task. */ readonly abortController: AbortController; /** Session dir captured at registration for output.log writes. */ @@ -111,8 +108,8 @@ interface ManagedTask { * terminal notifications only — it deliberately discards old output to * cap memory. It is NOT the authoritative full output: the complete, * never-truncated log lives on disk at `/tasks//output.log`. - * Callers that need the full output (e.g. `TaskOutput`) must read the - * disk log via `getOutputSizeBytes` / `readOutputBytesFromDisk`. + * Callers that need task output should use `getOutputSnapshot()`, which + * reads the persisted log when available. */ const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB @@ -210,13 +207,6 @@ export class BackgroundManager { /** When set, register/lifecycle changes persist to disk. */ private sessionDir: string | undefined; - /** - * Registered terminal-state callbacks. Fired once per task when the - * task reaches a terminal state (completed / failed / timed_out / killed). - */ - private readonly terminalCallbacks: Array<(info: BackgroundTaskInfo) => void | Promise> = - []; - private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); @@ -227,45 +217,16 @@ export class BackgroundManager { } /** - * Register a callback that fires when any task reaches a terminal - * state. The callback receives the task's `BackgroundTaskInfo` - * snapshot. Multiple callbacks may be registered; they are invoked in - * registration order. Errors thrown by callbacks are silently swallowed. - */ - onTerminal(callback: (info: BackgroundTaskInfo) => void | Promise): void { - this.terminalCallbacks.push(callback); - } - - /** - * Fire all registered terminal callbacks for a task. Idempotent: the - * second invocation for the same task is a no-op so `reconcile()` / - * a lagging `wait()` resolver / a race between `stop()` and natural - * exit cannot yield duplicate notifications. This is the manager-side - * half of the dedupe pact with `NotificationManager.dedupe_key`. + * Fire terminal side effects for a live task. Idempotent: the second + * invocation for the same task is a no-op so a lagging `wait()` + * resolver or a race between `stop()` and natural exit cannot yield + * duplicate notifications/events. */ - private fireTerminalCallbacks(entry: ManagedTask): void { + private fireTerminalEffects(entry: ManagedTask): void { if (entry.terminalFired) return; entry.terminalFired = true; const info = this.toInfo(entry); - try { - void this.notifyBackgroundTask(info).catch(() => {}); - } catch { - /* swallow */ - } - this.fireTerminalSubscribers(info); - } - - private fireTerminalSubscribers(info: BackgroundTaskInfo): void { - for (const cb of this.terminalCallbacks) { - try { - const result = cb(info); - if (result && typeof result.catch === 'function') { - result.catch(() => {}); - } - } catch { - /* swallow callback errors */ - } - } + void this.notifyBackgroundTask(info).catch(() => {}); this.emitTaskTerminated(info); } @@ -389,8 +350,6 @@ export class BackgroundManager { void this.persistLive(entry); this.emitTaskStarted(this.toInfo(entry)); - void entry.lifecyclePromise; - return taskId; } @@ -449,9 +408,8 @@ export class BackgroundManager { * * Output chunks are persisted to disk on an async queue, so a task can * reach a terminal state before its final chunks have landed on disk. - * Callers that read the on-disk log (`getOutputSizeBytes` / - * `readOutputBytesFromDisk`) should `await flushOutput()` first so they - * observe the complete log. No-op for unknown/ghost tasks. + * Callers should `await flushOutput()` before reading the on-disk log + * directly. No-op for unknown/ghost tasks. */ async flushOutput(taskId: string): Promise { const entry = this.tasks.get(taskId); @@ -459,41 +417,6 @@ export class BackgroundManager { await entry.outputWriteQueue; } - /** - * Total byte size of a task's full output as stored on disk. - * - * Reads `/tasks//output.log`, which is the complete, - * never-truncated log — unlike the in-memory ring buffer it never drops - * old chunks. Returns 0 when the manager is detached, the task is - * unknown, or the task has produced no output yet. - */ - async getOutputSizeBytes(taskId: string): Promise { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return 0; - return taskOutputSizeBytes(outputSessionDir, taskId); - } - - /** - * Read a byte range of a task's full output from the on-disk log. - * - * Reads up to `maxBytes` bytes starting at `offset` of `output.log`, - * straight from disk so it never loses the head of a large task the way - * the in-memory ring buffer would. Callers derive `offset` and `maxBytes` - * from a single `getOutputSizeBytes` snapshot, so the bytes returned stay - * consistent with the size used for metadata even when a still-running - * task keeps growing its log. Returns an empty string when the manager - * is detached, the task is unknown, or the log is absent. - */ - async readOutputBytesFromDisk( - taskId: string, - offset: number, - maxBytes: number, - ): Promise { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return ''; - return readTaskOutputBytes(outputSessionDir, taskId, offset, maxBytes); - } - /** * Return the output snapshot used by TaskOutput. * @@ -731,58 +654,12 @@ export class BackgroundManager { this.emitTaskUpdated(this.toInfo(entry)); } - // ── completion event (await lifecycle end) ──────────────────────── - - /** - * Resolve when the task reaches a terminal state. If the task is - * already terminal, resolves synchronously on the next microtask. - * Intended for integration code that wants to `await` a specific - * task's exit without installing a full `onTerminal` subscriber. - * Returns `undefined` for unknown ids (matching `getTask`). Ghost - * (reconciled-lost) entries are considered terminal from the - * manager's perspective. - */ - async waitForTerminal(taskId: string): Promise { - const entry = this.tasks.get(taskId); - if (entry === undefined) return this.ghosts.get(taskId); - if (TERMINAL_STATUSES.has(entry.status)) { - await entry.persistWriteQueue; - return this.toInfo(entry); - } - await new Promise((resolve) => { - entry.waiters.push(resolve); - }); - await entry.persistWriteQueue; - return this.toInfo(entry); - } - - /** Reset internal state (for testing). */ - _reset(): void { - this.tasks.clear(); - this.ghosts.clear(); - this.sessionDir = undefined; - this.scheduledNotificationKeys.clear(); - this.deliveredNotificationKeys.clear(); - } - // ── persistence + reconcile ──────────────────────────────────────── - /** - * Attach the manager to a session directory for persistence. Tasks - * created via `registerTask()` after this call are written to - * `/tasks/.json` and updated on lifecycle change. - * Tasks created before attach are NOT retroactively persisted. - */ - attachSessionDir(sessionDir: string): void { - this.sessionDir = sessionDir; - } - /** * Load persisted task records into the ghost map. Does NOT reconcile * (call `reconcile()` after `loadFromDisk()`). Idempotent; subsequent * calls overwrite the ghost map. - * - * Requires `attachSessionDir()` first; no-op otherwise. */ async loadFromDisk(): Promise { if (this.sessionDir === undefined) return; @@ -814,7 +691,6 @@ export class BackgroundManager { status: 'lost', endedAt: info.endedAt ?? Date.now(), approvalReason: undefined, - failureReason: 'Background worker heartbeat expired', }; this.ghosts.set(id, updated); if (this.sessionDir !== undefined) { @@ -828,26 +704,13 @@ export class BackgroundManager { async reconcile(): Promise { const result = await this.markLoadedTasksLost(); - // Fire onTerminal for newly-lost ghosts so NotificationManager - // receives a `task.lost` notification. Dedupe on the consumer side - // is by `dedupe_key`; a second reconcile() on the same ghost is a - // no-op because the status flips to `lost` above and we guard on - // TERMINAL_STATUSES on the next pass. for (const info of result.lostInfo) { - this.fireTerminalSubscribers(info); + this.emitTaskTerminated(info); } await this.restoreBackgroundTaskNotifications(); return result; } - /** Drop a persisted task from disk and ghost map. */ - async forgetTask(taskId: string): Promise { - this.ghosts.delete(taskId); - if (this.sessionDir !== undefined) { - await removeTask(this.sessionDir, taskId); - } - } - /** * Persist the current state of a live ManagedTask. Called from * `registerTask()` and the lifecycle finally block. No-op unless attached. @@ -951,7 +814,7 @@ export class BackgroundManager { } private fireNotificationHook(notification: BackgroundTaskNotification): void { - void this.agent?.hooks?.fireAndForgetTrigger('Notification', { + void this.agent.hooks?.fireAndForgetTrigger('Notification', { matcherValue: notification.type, inputData: { sink: 'context', @@ -984,7 +847,7 @@ export class BackgroundManager { if (entry.status === 'killed' && settlement.status === 'killed') { entry.endedAt = Math.max(Date.now(), (entry.endedAt ?? 0) + 1); await this.persistLive(entry); - this.fireTerminalCallbacks(entry); + this.fireTerminalEffects(entry); this.resolveWaiters(entry); } return false; @@ -1000,7 +863,7 @@ export class BackgroundManager { // that here for the awaiting → terminal path. entry.approvalReason = undefined; await this.persistLive(entry); - this.fireTerminalCallbacks(entry); + this.fireTerminalEffects(entry); this.resolveWaiters(entry); return true; } @@ -1026,7 +889,6 @@ export class BackgroundManager { approvalReason: entry.approvalReason, stopReason: entry.stopReason, timeoutMs: entry.timeoutMs, - failureReason: entry.failureReason, }; return entry.task.toInfo(base); } diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index 564ee3c2..fba5b331 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -14,7 +14,7 @@ */ import { statSync } from 'node:fs'; -import { appendFile, mkdir, open, readFile, rm, stat } from 'node:fs/promises'; +import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; @@ -53,18 +53,6 @@ export interface PersistedTask { readonly timed_out?: boolean | undefined; /** Reason recorded when a task is explicitly stopped or aborted. */ readonly stop_reason?: string | undefined; - /** - * Shell origin metadata (name / path / cwd) captured when - * `BackgroundManager.register` attached a `shellInfo` option. - * Persisted so restart can reconstruct the spawn environment. - */ - readonly shell_info?: - | { - readonly name: string; - readonly path?: string | undefined; - readonly cwd?: string | undefined; - } - | undefined; /** * Subagent identifier for agent-* tasks (the id `subagentHost.resume` * accepts). Persisted so a session restart can re-emit recovery @@ -260,16 +248,3 @@ function isValidPersistedTask(obj: unknown): obj is PersistedTask { typeof o['status'] === 'string' ); } - -/** - * Remove a task — both the per-id JSON spec and the task's `output.log` - * directory. Idempotent: missing spec or missing output dir is not an - * error. Throws for an invalid `taskId` (path-traversal guard fires - * before any FS call). - */ -export async function removeTask(sessionDir: string, taskId: string): Promise { - await storeFor(sessionDir).remove(taskId); - // `taskOutputDir` re-validates the id, so a malformed id throws here - // even if the spec rm above silently returned; matches prior behavior. - await rm(taskOutputDir(sessionDir, taskId), { recursive: true, force: true }); -} diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index b9a3a96c..4284e1ef 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -36,8 +36,6 @@ export interface BackgroundTaskInfoBase { readonly stopReason?: string; /** Deadline supplied at registration; surfaced via task info. */ readonly timeoutMs?: number; - /** Human-readable reason recorded when a task is reclassified. */ - readonly failureReason?: string; } export interface BackgroundTaskSink { From 0ee4cd566377f666dc32f06f29cb24d95c6f8a21 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 19:04:25 +0800 Subject: [PATCH 06/21] fix --- .../agent-core/src/agent/background/index.ts | 67 ++--------------- .../src/agent/background/persist.ts | 71 ++----------------- .../agent-core/src/utils/per-id-json-store.ts | 32 +++++---- 3 files changed, 30 insertions(+), 140 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 2c37fa93..7972de80 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -28,7 +28,6 @@ import { taskOutputFile, taskOutputSizeBytes, writeTask, - type PersistedTask, } from './persist'; import { TERMINAL_BACKGROUND_TASK_STATUSES, @@ -197,7 +196,6 @@ export class BackgroundManager { private readonly tasks = new Map(); private reservedTaskSlots = 0; public readonly agent: Agent; - private readonly maxRunningTasks?: number; /** * Ghosts: tasks loaded from disk during reconcile that have no live * KaosProcess. They appear in `list()` / `getTask()` with status @@ -212,7 +210,6 @@ export class BackgroundManager { constructor(agent: Agent) { this.agent = agent; - this.maxRunningTasks = agent.kimiConfig?.background?.maxRunningTasks; this.sessionDir = agent.homedir; } @@ -277,14 +274,14 @@ export class BackgroundManager { } assertCanRegister(): void { - const maxRunningTasks = this.maxRunningTasks; + const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; if (maxRunningTasks === undefined) return; if (this.activeTaskCount() + this.reservedTaskSlots < maxRunningTasks) return; throw new Error('Too many background tasks are already running.'); } reserveSlot(): BackgroundTaskReservation { - const maxRunningTasks = this.maxRunningTasks; + const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; if (maxRunningTasks === undefined) { return { release: () => {} }; } @@ -667,8 +664,8 @@ export class BackgroundManager { const persisted = await listTasks(this.sessionDir); for (const t of persisted) { // Skip ids that already exist as live processes — live wins. - if (this.tasks.has(t.task_id)) continue; - this.ghosts.set(t.task_id, persistedToInfo(t)); + if (this.tasks.has(t.taskId)) continue; + this.ghosts.set(t.taskId, t); } } @@ -694,7 +691,7 @@ export class BackgroundManager { }; this.ghosts.set(id, updated); if (this.sessionDir !== undefined) { - await writeTask(this.sessionDir, infoToPersisted(updated)); + await writeTask(this.sessionDir, updated); } lost.push(id); lostInfo.push(updated); @@ -719,9 +716,8 @@ export class BackgroundManager { if (this.sessionDir === undefined) return Promise.resolve(); const sessionDir = this.sessionDir; const info = this.toInfo(entry); - const task: PersistedTask = infoToPersisted(info); entry.persistWriteQueue = entry.persistWriteQueue - .then(() => writeTask(sessionDir, task)) + .then(() => writeTask(sessionDir, info)) .catch(() => {}); return entry.persistWriteQueue; } @@ -895,57 +891,6 @@ export class BackgroundManager { } -// ── persistence shape <-> in-memory shape ────────────────────────────── - -function persistedToInfo(t: PersistedTask): BackgroundTaskInfo { - const status = t.timed_out === true ? 'timed_out' : t.status; - const base: BackgroundTaskInfoBase = { - taskId: t.task_id, - kind: t.kind ?? (t.task_id.startsWith('agent-') ? 'agent' : 'process'), - description: t.description, - status, - startedAt: t.started_at, - endedAt: t.ended_at, - approvalReason: t.approval_reason, - stopReason: t.stop_reason, - }; - if (base.kind === 'agent') { - return { - ...base, - kind: 'agent', - agentId: t.agent_id, - subagentType: t.subagent_type, - }; - } - return { - ...base, - kind: 'process', - command: t.command, - pid: t.pid, - exitCode: t.exit_code, - }; -} - -function infoToPersisted(info: BackgroundTaskInfo): PersistedTask { - const command = info.kind === 'process' ? info.command : `[agent] ${info.description}`; - const pid = info.kind === 'process' ? info.pid : 0; - return { - task_id: info.taskId, - kind: info.kind, - command, - description: info.description, - pid, - started_at: info.startedAt, - ended_at: info.endedAt, - exit_code: info.kind === 'process' ? info.exitCode : null, - status: info.status, - approval_reason: info.approvalReason, - stop_reason: info.stopReason, - agent_id: info.kind === 'agent' ? info.agentId : undefined, - subagent_type: info.kind === 'agent' ? info.subagentType : undefined, - }; -} - function notificationKey(origin: BackgroundTaskOrigin): string { return `${origin.taskId}\0${origin.status}\0${origin.notificationId}`; } diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index fba5b331..0ff097ee 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -1,11 +1,11 @@ /** * Background task persistence helpers. * - * Each task lives at `/tasks/.json` so a CLI + * Each task lives at `/tasks/.json` so a CLI * restart can list previously-running tasks (now lost) and emit terminal * notifications. * - * The per-id JSON layer (write / read / list / remove) is delegated to + * The per-id JSON layer (write / read / list) is delegated to * `createPerIdJsonStore`, which centralises atomic-write + * path-traversal-guarded readdir for cron / background / anything else * that needs session-scoped per-id JSON. This module keeps the @@ -18,7 +18,7 @@ import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; -import type { BackgroundTaskKind, BackgroundTaskStatus } from './task'; +import type { BackgroundTaskInfo } from './task'; /** * Task id format: `{bash|agent}-{8 chars of [0-9a-z]}`. @@ -29,43 +29,7 @@ import type { BackgroundTaskKind, BackgroundTaskStatus } from './task'; */ export const VALID_TASK_ID: RegExp = /^(bash|agent)-[0-9a-z]{8}$/; -/** On-disk task representation (snake_case, Python-friendly). */ -export interface PersistedTask { - readonly task_id: string; - readonly kind?: BackgroundTaskKind; - readonly command: string; - readonly description: string; - readonly pid: number; - readonly started_at: number; - readonly ended_at: number | null; - readonly exit_code: number | null; - readonly status: BackgroundTaskStatus; - /** - * Reason supplied when the task is marked `awaiting_approval`. - * Cleared (omitted) when the task leaves that state. - */ - readonly approval_reason?: string | undefined; - /** - * Legacy timeout marker from older persisted task files. New task info - * records timeout as `status: "timed_out"`; this field is retained only - * so old session state can be read and normalized. - */ - readonly timed_out?: boolean | undefined; - /** Reason recorded when a task is explicitly stopped or aborted. */ - readonly stop_reason?: string | undefined; - /** - * Subagent identifier for agent-* tasks (the id `subagentHost.resume` - * accepts). Persisted so a session restart can re-emit recovery - * instructions in the next `` without forcing the LLM to - * cross-reference the original spawn-success ToolResult. Omitted for - * bash tasks. Optional in the schema for forward/backward compatibility: - * pre-PR sessions reload without it and simply skip the recovery hint. - */ - readonly agent_id?: string | undefined; - /** Subagent profile name (agent-* tasks only). Persisted for symmetry - * with `agent_id` so resume surfaces match between disk and memory. */ - readonly subagent_type?: string | undefined; -} +export type PersistedTask = BackgroundTaskInfo; function tasksDirOf(sessionDir: string): string { return join(sessionDir, 'tasks'); @@ -99,7 +63,6 @@ function storeFor(sessionDir: string): PerIdJsonStore { rootDir: sessionDir, subdir: 'tasks', idRegex: VALID_TASK_ID, - isValid: isValidPersistedTask, entityName: 'task id', }); storeCache.set(sessionDir, store); @@ -108,7 +71,7 @@ function storeFor(sessionDir: string): PerIdJsonStore { /** Atomically write a task's persisted state. Creates dirs as needed. */ export async function writeTask(sessionDir: string, task: PersistedTask): Promise { - await storeFor(sessionDir).write(task.task_id, task); + await storeFor(sessionDir).write(task.taskId, task); } /** Read a single task file. Returns undefined when missing/corrupt. */ @@ -217,8 +180,7 @@ export async function readTaskOutputBytes( * - basenames that don't match `VALID_TASK_ID` (stray files, legacy * `bg_*` leftovers, partially-written temp files); * - files that fail to read / parse; - * - records that fail `isValidPersistedTask` (canonical "spec with - * missing fields" failure mode). + * - files that are not valid JSON. * * `writeTask` uses atomic temp+rename so a genuinely truncated file in * production is rare; if it happens we accept the loss rather than @@ -227,24 +189,3 @@ export async function readTaskOutputBytes( export async function listTasks(sessionDir: string): Promise { return storeFor(sessionDir).list(); } - -/** - * Validate that the parsed JSON actually shapes like a PersistedTask. - * Cheap shape check (not a full zod schema) — rejects the canonical - * "spec with missing fields" failure mode. - */ -function isValidPersistedTask(obj: unknown): obj is PersistedTask { - if (typeof obj !== 'object' || obj === null) return false; - const o = obj as Record; - return ( - typeof o['task_id'] === 'string' && - (o['kind'] === undefined || o['kind'] === 'process' || o['kind'] === 'agent') && - typeof o['command'] === 'string' && - typeof o['description'] === 'string' && - typeof o['pid'] === 'number' && - typeof o['started_at'] === 'number' && - (o['ended_at'] === null || typeof o['ended_at'] === 'number') && - (o['exit_code'] === null || typeof o['exit_code'] === 'number') && - typeof o['status'] === 'string' - ); -} diff --git a/packages/agent-core/src/utils/per-id-json-store.ts b/packages/agent-core/src/utils/per-id-json-store.ts index 3b1feb7f..7dfff744 100644 --- a/packages/agent-core/src/utils/per-id-json-store.ts +++ b/packages/agent-core/src/utils/per-id-json-store.ts @@ -5,14 +5,15 @@ * future "session-scoped, per-id, small-JSON" persistence can share the * same atomic-write + path-traversal-guarded readdir loop. The store has * no opinion on `T` — callers supply an id regex (also the basename - * validator) and a cheap shape guard for ignoring corrupt files on - * `list()`. + * validator) and may optionally supply a cheap shape guard for ignoring + * incompatible files on `list()`. * * Crash safety: writes go through `atomicWrite` (write-tmp, fsync, * rename) so a kill mid-write never leaves a torn file. `list()` * silently drops basenames that don't match `idRegex`, files that fail - * to read, JSON parse errors, and values that fail `isValid` — the - * caller wants "everything that's safely loadable", not a partial throw. + * to read, JSON parse errors, and values that fail `isValid` when a + * validator is supplied — the caller wants "everything that's safely + * loadable", not a partial throw. * * Not concurrent-process-safe by itself: two CLI processes writing to * the same id will race on the rename. We accept this because the @@ -35,14 +36,16 @@ export interface PerIdJsonStore { write(id: string, value: T): Promise; /** * Read a single record. Returns `undefined` for missing files, - * unreadable files, parse errors, or values that fail `isValid`. - * Throws only for an invalid id (path-traversal guard). + * unreadable files, parse errors, or values that fail `isValid` when + * a validator is supplied. Throws only for an invalid id + * (path-traversal guard). */ read(id: string): Promise; /** * Enumerate every record in the subdir whose basename matches - * `idRegex` AND whose parsed content satisfies `isValid`. Silently - * drops everything else (corrupt JSON, stray files, partial writes). + * `idRegex` and, when a validator is supplied, whose parsed content + * satisfies `isValid`. Silently drops everything else (corrupt JSON, + * stray files, partial writes). */ list(): Promise; /** @@ -64,12 +67,12 @@ export interface PerIdJsonStoreOptions { */ readonly idRegex: RegExp; /** - * Cheap structural validator. Run on every parsed JSON value; failing - * values are silently dropped from `list()` (and `read()` returns - * `undefined`). Should be inexpensive — it runs once per file per - * `list()`. + * Optional cheap structural validator. Run on every parsed JSON value; + * failing values are silently dropped from `list()` (and `read()` + * returns `undefined`). Should be inexpensive — it runs once per file + * per `list()`. */ - readonly isValid: (obj: unknown) => obj is T; + readonly isValid?: (obj: unknown) => obj is T; /** * Human-readable name used in path-traversal rejection errors — * `Invalid : ""`. Lets each caller preserve its own @@ -112,7 +115,8 @@ export function createPerIdJsonStore( } catch { return undefined; } - return isValid(parsed) ? parsed : undefined; + if (isValid !== undefined && !isValid(parsed)) return undefined; + return parsed as T; } async function list(): Promise { From 5720d9aaf76c4ce1fe29e621767a3aec36ca2bb5 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 19:13:44 +0800 Subject: [PATCH 07/21] fix --- .../agent-core/src/agent/background/index.ts | 37 ++----------------- .../src/tools/builtin/collaboration/agent.ts | 12 ------ .../src/tools/builtin/shell/bash.ts | 31 ++++++++-------- 3 files changed, 20 insertions(+), 60 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 7972de80..d03abcc5 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -145,10 +145,6 @@ export interface ReconcileResult { readonly lostInfo: readonly BackgroundTaskInfo[]; } -export interface BackgroundTaskReservation { - release(): void; -} - export interface BackgroundTaskOutputSnapshot { readonly outputPath?: string; readonly outputSizeBytes: number; @@ -194,7 +190,6 @@ const NOTIFICATION_TAIL_BYTES = 3_000; export class BackgroundManager { private readonly tasks = new Map(); - private reservedTaskSlots = 0; public readonly agent: Agent; /** * Ghosts: tasks loaded from disk during reconcile that have no live @@ -273,30 +268,13 @@ export class BackgroundManager { }; } - assertCanRegister(): void { + private assertCanRegister(): void { const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; if (maxRunningTasks === undefined) return; - if (this.activeTaskCount() + this.reservedTaskSlots < maxRunningTasks) return; + if (this.activeTaskCount() < maxRunningTasks) return; throw new Error('Too many background tasks are already running.'); } - reserveSlot(): BackgroundTaskReservation { - const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; - if (maxRunningTasks === undefined) { - return { release: () => {} }; - } - this.assertCanRegister(); - this.reservedTaskSlots++; - let released = false; - return { - release: () => { - if (released) return; - released = true; - this.reservedTaskSlots--; - }, - }; - } - private activeTaskCount(): number { let count = 0; for (const entry of this.tasks.values()) { @@ -305,15 +283,8 @@ export class BackgroundManager { return count; } - registerTask( - task: BackgroundTask, - reservation?: BackgroundTaskReservation, - ): string { - if (reservation) { - reservation.release(); - } else { - this.assertCanRegister(); - } + registerTask(task: BackgroundTask): string { + this.assertCanRegister(); const taskId = generateTaskId(task.idPrefix); const entry: ManagedTask = { taskId, diff --git a/packages/agent-core/src/tools/builtin/collaboration/agent.ts b/packages/agent-core/src/tools/builtin/collaboration/agent.ts index 859b4299..633f4886 100644 --- a/packages/agent-core/src/tools/builtin/collaboration/agent.ts +++ b/packages/agent-core/src/tools/builtin/collaboration/agent.ts @@ -182,7 +182,6 @@ export class AgentTool implements BuiltinTool { }; } - let reservation: ReturnType | undefined; if (runInBackground) { if (this.backgroundManager === undefined) { return { @@ -190,14 +189,6 @@ export class AgentTool implements BuiltinTool { isError: true, }; } - try { - reservation = this.backgroundManager.reserveSlot(); - } catch (error) { - return { - output: error instanceof Error ? error.message : String(error), - isError: true, - }; - } } const backgroundController = runInBackground ? new AbortController() : undefined; const timeoutMs = args.timeout === undefined ? undefined : args.timeout * 1000; @@ -224,7 +215,6 @@ export class AgentTool implements BuiltinTool { handle = await this.subagentHost.spawn(profileName, options); } } catch (error) { - reservation?.release(); this.log?.warn('subagent launch failed', { toolCallId, runInBackground, @@ -248,10 +238,8 @@ export class AgentTool implements BuiltinTool { backgroundController?.abort(); }, }), - reservation, ); } catch (error) { - reservation?.release(); backgroundController?.abort(); void handle.completion.catch(() => {}); this.log?.warn('background agent task registration failed', { diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 2f766371..20ab7b5d 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -366,16 +366,6 @@ export class BashTool implements BuiltinTool { }; } - let reservation: ReturnType; - try { - reservation = backgroundManager.reserveSlot(); - } catch (error) { - return { - isError: true, - output: error instanceof Error ? error.message : String(error), - }; - } - const timeoutMs = args.disable_timeout ? undefined : normalizeTimeoutMs(args.timeout, true); let proc: KaosProcess; @@ -384,7 +374,6 @@ export class BashTool implements BuiltinTool { const effectiveCwd = args.cwd ?? this.cwd; proc = await this.spawn(effectiveCwd, command); } catch (error) { - reservation.release(); return { isError: true, output: error instanceof Error ? error.message : String(error), @@ -397,10 +386,22 @@ export class BashTool implements BuiltinTool { /* process already gone */ } - const taskId = backgroundManager.registerTask( - new ProcessBackgroundTask(proc, command, args.description.trim()), - reservation, - ); + let taskId: string; + try { + taskId = backgroundManager.registerTask( + new ProcessBackgroundTask(proc, command, args.description.trim()), + ); + } catch (error) { + try { + await proc.kill('SIGTERM'); + } catch { + /* process already gone */ + } + return { + isError: true, + output: error instanceof Error ? error.message : String(error), + }; + } if (timeoutMs !== undefined) { setTimeout(() => { From 350a465992c524d07e6b03d1f3235592c187ddaf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 19:46:29 +0800 Subject: [PATCH 08/21] refactor: simplify background task persistence --- .../src/agent/background/agent-task.ts | 8 +- .../agent-core/src/agent/background/index.ts | 102 +++---- .../src/agent/background/persist.ts | 254 ++++++++---------- .../src/agent/background/process-task.ts | 4 +- .../agent-core/src/agent/background/task.ts | 4 +- packages/agent-core/src/agent/index.ts | 7 +- .../src/tools/background/task-output.ts | 7 +- 7 files changed, 175 insertions(+), 211 deletions(-) diff --git a/packages/agent-core/src/agent/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts index 3a0f41fb..63eabd24 100644 --- a/packages/agent-core/src/agent/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -1,6 +1,6 @@ import { sleep } from '@antfu/utils'; -import { isAbortError } from '../../loop/errors'; +import { errorMessage, isAbortError } from '../../loop/errors'; import { type BackgroundTask, type BackgroundTaskInfoBase, @@ -75,11 +75,7 @@ export class AgentBackgroundTask implements BackgroundTask { await sink.settle({ status: 'killed' }); return; } - if (error instanceof Error && error.name === 'RunCancelled') { - await sink.settle({ status: 'killed' }); - return; - } - await sink.settle({ status: 'failed' }); + await sink.settle({ status: 'failed', stopReason: errorMessage(error) }); } finally { sink.signal.removeEventListener('abort', requestAbort); } diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index d03abcc5..e959fe04 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -15,26 +15,18 @@ import { randomBytes } from 'node:crypto'; import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; +import { errorMessage } from '../../loop/errors'; import type { TelemetryPropertyValue } from '../../telemetry'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; -import { - appendTaskOutput, - listTasks, - readTaskOutput, - readTaskOutputBytes, - taskOutputExists, - taskOutputExistsSync, - taskOutputFile, - taskOutputSizeBytes, - writeTask, -} from './persist'; +import { type BackgroundTaskPersistence } from './persist'; import { TERMINAL_BACKGROUND_TASK_STATUSES, type BackgroundTask, type BackgroundTaskInfo, type BackgroundTaskInfoBase, type BackgroundTaskSink, + type BackgroundTaskSettlement, type BackgroundTaskStatus, } from './task'; @@ -64,7 +56,7 @@ export { AgentBackgroundTask } from './agent-task'; export type { AgentBackgroundTaskInfo } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; export type { ProcessBackgroundTaskInfo } from './process-task'; -export { VALID_TASK_ID } from './persist'; +export { BackgroundTaskPersistence, VALID_TASK_ID } from './persist'; export type { BackgroundTaskInfo, BackgroundTaskKind, @@ -86,14 +78,12 @@ interface ManagedTask { terminalFired: boolean; /** Reason carried while awaiting approval. */ approvalReason?: string | undefined; - /** Reason recorded when a task is explicitly stopped or aborted. */ + /** Human-readable reason for the terminal status, when available. */ stopReason?: string | undefined; /** Deadline supplied at registration; surfaced via task info. */ timeoutMs?: number | undefined; /** Cancellation signal owned by the manager and observed by the concrete task. */ readonly abortController: AbortController; - /** Session dir captured at registration for output.log writes. */ - readonly outputSessionDir?: string | undefined; lifecyclePromise: Promise; persistWriteQueue: Promise; outputWriteQueue: Promise; @@ -190,23 +180,20 @@ const NOTIFICATION_TAIL_BYTES = 3_000; export class BackgroundManager { private readonly tasks = new Map(); - public readonly agent: Agent; /** * Ghosts: tasks loaded from disk during reconcile that have no live * KaosProcess. They appear in `list()` / `getTask()` with status * `lost` so users see what was running before the crash/restart. */ private readonly ghosts = new Map(); - /** When set, register/lifecycle changes persist to disk. */ - private sessionDir: string | undefined; private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); - constructor(agent: Agent) { - this.agent = agent; - this.sessionDir = agent.homedir; - } + constructor( + private readonly agent: Agent, + private readonly persistence?: BackgroundTaskPersistence, + ) {} /** * Fire terminal side effects for a live task. Idempotent: the second @@ -298,7 +285,6 @@ export class BackgroundManager { terminalFired: false, abortController: new AbortController(), timeoutMs: task.timeoutMs, - outputSessionDir: this.sessionDir, lifecyclePromise: Promise.resolve(), persistWriteQueue: Promise.resolve(), outputWriteQueue: Promise.resolve(), @@ -308,9 +294,11 @@ export class BackgroundManager { const sink = this.createTaskSink(entry); entry.lifecyclePromise = Promise.resolve() .then(() => task.start(sink)) - .catch(async () => { + .catch(async (error: unknown) => { + const aborted = entry.abortController.signal.aborted; await this.settleTask(entry, { - status: entry.abortController.signal.aborted ? 'killed' : 'failed', + status: aborted ? 'killed' : 'failed', + stopReason: aborted ? undefined : errorMessage(error), }); }); @@ -403,19 +391,14 @@ export class BackgroundManager { await this.flushOutput(taskId); const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir !== undefined && (await taskOutputExists(outputSessionDir, taskId))) { - const outputSizeBytes = await taskOutputSizeBytes(outputSessionDir, taskId); + const persistence = this.persistenceFor(taskId); + if (persistence !== undefined && (await persistence.taskOutputExists(taskId))) { + const outputSizeBytes = await persistence.taskOutputSizeBytes(taskId); const previewOffset = Math.max(0, outputSizeBytes - previewLimit); const previewBytes = outputSizeBytes - previewOffset; - const preview = await readTaskOutputBytes( - outputSessionDir, - taskId, - previewOffset, - previewBytes, - ); + const preview = await persistence.readTaskOutputBytes(taskId, previewOffset, previewBytes); return { - outputPath: taskOutputFile(outputSessionDir, taskId), + outputPath: persistence.taskOutputFile(taskId), outputSizeBytes, previewBytes, truncated: previewOffset > 0, @@ -452,10 +435,10 @@ export class BackgroundManager { async readOutput(taskId: string, tail?: number): Promise { const entry = this.tasks.get(taskId); - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir !== undefined) { + const persistence = this.persistenceFor(taskId); + if (persistence !== undefined) { await entry?.outputWriteQueue; - const persisted = await readTaskOutput(outputSessionDir, taskId); + const persisted = await persistence.readTaskOutput(taskId); if (persisted.length > 0) { if (tail !== undefined && tail < persisted.length) { return persisted.slice(-tail); @@ -467,10 +450,10 @@ export class BackgroundManager { } getOutputPath(taskId: string): string | undefined { - const outputSessionDir = this.outputSessionDirFor(taskId); - if (outputSessionDir === undefined) return undefined; - if (!taskOutputExistsSync(outputSessionDir, taskId)) return undefined; - return taskOutputFile(outputSessionDir, taskId); + const persistence = this.persistenceFor(taskId); + if (persistence === undefined) return undefined; + if (!persistence.taskOutputExistsSync(taskId)) return undefined; + return persistence.taskOutputFile(taskId); } /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ @@ -630,9 +613,10 @@ export class BackgroundManager { * calls overwrite the ghost map. */ async loadFromDisk(): Promise { - if (this.sessionDir === undefined) return; + const persistence = this.persistence; + if (persistence === undefined) return; this.ghosts.clear(); - const persisted = await listTasks(this.sessionDir); + const persisted = await persistence.listTasks(); for (const t of persisted) { // Skip ids that already exist as live processes — live wins. if (this.tasks.has(t.taskId)) continue; @@ -649,6 +633,7 @@ export class BackgroundManager { protected async markLoadedTasksLost(): Promise { const lost: string[] = []; const lostInfo: BackgroundTaskInfo[] = []; + const persistence = this.persistence; for (const [id, info] of this.ghosts) { // Any non-terminal ghost is lost. Includes `awaiting_approval` // (the approval context died with the previous process so it @@ -661,8 +646,8 @@ export class BackgroundManager { approvalReason: undefined, }; this.ghosts.set(id, updated); - if (this.sessionDir !== undefined) { - await writeTask(this.sessionDir, updated); + if (persistence !== undefined) { + await persistence.writeTask(updated); } lost.push(id); lostInfo.push(updated); @@ -684,11 +669,11 @@ export class BackgroundManager { * `registerTask()` and the lifecycle finally block. No-op unless attached. */ private persistLive(entry: ManagedTask): Promise { - if (this.sessionDir === undefined) return Promise.resolve(); - const sessionDir = this.sessionDir; + const persistence = this.persistence; + if (persistence === undefined) return Promise.resolve(); const info = this.toInfo(entry); entry.persistWriteQueue = entry.persistWriteQueue - .then(() => writeTask(sessionDir, info)) + .then(() => persistence.writeTask(info)) .catch(() => {}); return entry.persistWriteQueue; } @@ -704,17 +689,16 @@ export class BackgroundManager { total -= removed.length; } - const outputSessionDir = entry.outputSessionDir; - if (outputSessionDir === undefined) return; + const persistence = this.persistence; + if (persistence === undefined) return; entry.outputWriteQueue = entry.outputWriteQueue - .then(() => appendTaskOutput(outputSessionDir, entry.taskId, chunk)) + .then(() => persistence.appendTaskOutput(entry.taskId, chunk)) .catch(() => {}); } - private outputSessionDirFor(taskId: string): string | undefined { - const entry = this.tasks.get(taskId); - if (entry !== undefined) return entry.outputSessionDir; - if (this.ghosts.has(taskId)) return this.sessionDir; + private persistenceFor(taskId: string): BackgroundTaskPersistence | undefined { + if (this.tasks.has(taskId)) return this.persistence; + if (this.ghosts.has(taskId)) return this.persistence; return undefined; } @@ -805,10 +789,7 @@ export class BackgroundManager { private async settleTask( entry: ManagedTask, - settlement: { - readonly status: 'completed' | 'failed' | 'timed_out' | 'killed'; - readonly stopReason?: string; - }, + settlement: BackgroundTaskSettlement, ): Promise { if (TERMINAL_STATUSES.has(entry.status)) { if (entry.status === 'killed' && settlement.status === 'killed') { @@ -859,7 +840,6 @@ export class BackgroundManager { }; return entry.task.toInfo(base); } - } function notificationKey(origin: BackgroundTaskOrigin): string { diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index 0ff097ee..b85a7677 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -8,9 +8,8 @@ * The per-id JSON layer (write / read / list) is delegated to * `createPerIdJsonStore`, which centralises atomic-write + * path-traversal-guarded readdir for cron / background / anything else - * that needs session-scoped per-id JSON. This module keeps the - * background-specific shape, the output.log helpers, and the named - * exports (`writeTask`, …) the rest of `background/` already imports. + * that needs session-scoped per-id JSON. This class keeps the + * background-specific shape and the output.log helpers together. */ import { statSync } from 'node:fs'; @@ -21,13 +20,14 @@ import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-js import type { BackgroundTaskInfo } from './task'; /** - * Task id format: `{bash|agent}-{8 chars of [0-9a-z]}`. + * Task id format: `{prefix}-{8 chars of [0-9a-z]}`. * - * Strictly enforced by `taskFile()` so neither path-traversal (`../`) - * nor a legacy `bg_` format can escape through the persistence - * layer. + * Strictly enforced before deriving task paths so neither path-traversal + * (`../`) nor a legacy `bg_` format can escape through the + * persistence layer. The prefix is intentionally open-ended so new task + * kinds do not need persistence-layer changes. */ -export const VALID_TASK_ID: RegExp = /^(bash|agent)-[0-9a-z]{8}$/; +export const VALID_TASK_ID: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; export type PersistedTask = BackgroundTaskInfo; @@ -42,150 +42,132 @@ function taskOutputDir(sessionDir: string, taskId: string): string { return join(tasksDirOf(sessionDir), taskId); } -export function taskOutputFile(sessionDir: string, taskId: string): string { +function taskOutputFile(sessionDir: string, taskId: string): string { return join(taskOutputDir(sessionDir, taskId), 'output.log'); } -/** - * Cache of `createPerIdJsonStore` instances keyed by sessionDir. - * - * Per-id stores hold no state beyond their options object, so reusing - * the same instance across calls into this module is purely an allocation - * micro-optimisation. The cache is unbounded; the number of distinct - * session directories per process is small (typically 1) and the - * lifetime matches the process. - */ -const storeCache = new Map>(); -function storeFor(sessionDir: string): PerIdJsonStore { - const cached = storeCache.get(sessionDir); - if (cached !== undefined) return cached; - const store = createPerIdJsonStore({ - rootDir: sessionDir, - subdir: 'tasks', - idRegex: VALID_TASK_ID, - entityName: 'task id', - }); - storeCache.set(sessionDir, store); - return store; -} +export class BackgroundTaskPersistence { + private readonly store: PerIdJsonStore; -/** Atomically write a task's persisted state. Creates dirs as needed. */ -export async function writeTask(sessionDir: string, task: PersistedTask): Promise { - await storeFor(sessionDir).write(task.taskId, task); -} + constructor(private readonly sessionDir: string) { + this.store = createPerIdJsonStore({ + rootDir: sessionDir, + subdir: 'tasks', + idRegex: VALID_TASK_ID, + entityName: 'task id', + }); + } -/** Read a single task file. Returns undefined when missing/corrupt. */ -export async function readTask( - sessionDir: string, - taskId: string, -): Promise { - return storeFor(sessionDir).read(taskId); -} + taskOutputFile(taskId: string): string { + return taskOutputFile(this.sessionDir, taskId); + } -export async function appendTaskOutput( - sessionDir: string, - taskId: string, - chunk: string, -): Promise { - const path = taskOutputFile(sessionDir, taskId); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await appendFile(path, chunk, 'utf-8'); -} + /** Atomically write a task's persisted state. Creates dirs as needed. */ + async writeTask(task: PersistedTask): Promise { + await this.store.write(task.taskId, task); + } -export async function readTaskOutput(sessionDir: string, taskId: string): Promise { - try { - return await readFile(taskOutputFile(sessionDir, taskId), 'utf-8'); - } catch { - return ''; + /** Read a single task file. Returns undefined when missing/corrupt. */ + async readTask(taskId: string): Promise { + return this.store.read(taskId); } -} -/** - * Total byte size of a task's `output.log`. Returns 0 when the log does - * not exist yet (the task has produced no output, or is unknown). - * - * This is the authoritative full-output size — unlike the in-memory ring - * buffer it is never truncated, so callers can report how much output a - * task has actually produced. - */ -export async function taskOutputSizeBytes(sessionDir: string, taskId: string): Promise { - try { - const st = await stat(taskOutputFile(sessionDir, taskId)); - return st.size; - } catch { - return 0; + async appendTaskOutput(taskId: string, chunk: string): Promise { + const path = this.taskOutputFile(taskId); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await appendFile(path, chunk, 'utf-8'); } -} -export async function taskOutputExists(sessionDir: string, taskId: string): Promise { - try { - return (await stat(taskOutputFile(sessionDir, taskId))).isFile(); - } catch { - return false; + async readTaskOutput(taskId: string): Promise { + try { + return await readFile(this.taskOutputFile(taskId), 'utf-8'); + } catch { + return ''; + } } -} -export function taskOutputExistsSync(sessionDir: string, taskId: string): boolean { - try { - return statSync(taskOutputFile(sessionDir, taskId)).isFile(); - } catch { - return false; + /** + * Total byte size of a task's `output.log`. Returns 0 when the log does + * not exist yet (the task has produced no output, or is unknown). + * + * This is the authoritative full-output size — unlike the in-memory ring + * buffer it is never truncated, so callers can report how much output a + * task has actually produced. + */ + async taskOutputSizeBytes(taskId: string): Promise { + try { + const st = await stat(this.taskOutputFile(taskId)); + return st.size; + } catch { + return 0; + } } -} -/** - * Read a byte window of a task's `output.log`. - * - * Reads at most `maxBytes` bytes starting at byte `offset`. A window that - * runs past EOF is clamped to whatever remains; an `offset` at/after EOF - * yields an empty string. Returns an empty string when the log is absent. - * - * Byte-level (not line-level) paging mirrors how the full log is stored - * on disk, so callers can page arbitrarily large logs without loading the - * whole file into memory. - */ -export async function readTaskOutputBytes( - sessionDir: string, - taskId: string, - offset: number, - maxBytes: number, -): Promise { - const start = Math.max(0, Math.trunc(offset)); - const limit = Math.max(0, Math.trunc(maxBytes)); - if (limit === 0) return ''; - let handle; - try { - handle = await open(taskOutputFile(sessionDir, taskId), 'r'); - } catch { - return ''; + async taskOutputExists(taskId: string): Promise { + try { + return (await stat(this.taskOutputFile(taskId))).isFile(); + } catch { + return false; + } } - try { - const size = (await handle.stat()).size; - if (start >= size) return ''; - const length = Math.min(limit, size - start); - const buffer = Buffer.allocUnsafe(length); - const { bytesRead } = await handle.read(buffer, 0, length, start); - return buffer.toString('utf-8', 0, bytesRead); - } catch { - return ''; - } finally { - await handle.close(); + + taskOutputExistsSync(taskId: string): boolean { + try { + return statSync(this.taskOutputFile(taskId)).isFile(); + } catch { + return false; + } } -} -/** - * Enumerate all persisted tasks for a session. - * - * Skips, silently: - * - basenames that don't match `VALID_TASK_ID` (stray files, legacy - * `bg_*` leftovers, partially-written temp files); - * - files that fail to read / parse; - * - files that are not valid JSON. - * - * `writeTask` uses atomic temp+rename so a genuinely truncated file in - * production is rare; if it happens we accept the loss rather than - * emit a ghost with no recoverable metadata beyond the filename. - */ -export async function listTasks(sessionDir: string): Promise { - return storeFor(sessionDir).list(); + /** + * Read a byte window of a task's `output.log`. + * + * Reads at most `maxBytes` bytes starting at byte `offset`. A window that + * runs past EOF is clamped to whatever remains; an `offset` at/after EOF + * yields an empty string. Returns an empty string when the log is absent. + * + * Byte-level (not line-level) paging mirrors how the full log is stored + * on disk, so callers can page arbitrarily large logs without loading the + * whole file into memory. + */ + async readTaskOutputBytes(taskId: string, offset: number, maxBytes: number): Promise { + const start = Math.max(0, Math.trunc(offset)); + const limit = Math.max(0, Math.trunc(maxBytes)); + if (limit === 0) return ''; + let handle; + try { + handle = await open(this.taskOutputFile(taskId), 'r'); + } catch { + return ''; + } + try { + const size = (await handle.stat()).size; + if (start >= size) return ''; + const length = Math.min(limit, size - start); + const buffer = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + return buffer.toString('utf-8', 0, bytesRead); + } catch { + return ''; + } finally { + await handle.close(); + } + } + + /** + * Enumerate all persisted tasks for a session. + * + * Skips, silently: + * - basenames that don't match `VALID_TASK_ID` (stray files, legacy + * `bg_*` leftovers, partially-written temp files); + * - files that fail to read / parse; + * - files that are not valid JSON. + * + * `writeTask` uses atomic temp+rename so a genuinely truncated file in + * production is rare; if it happens we accept the loss rather than + * emit a ghost with no recoverable metadata beyond the filename. + */ + async listTasks(): Promise { + return this.store.list(); + } } diff --git a/packages/agent-core/src/agent/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts index 2809943d..29bf7e5b 100644 --- a/packages/agent-core/src/agent/background/process-task.ts +++ b/packages/agent-core/src/agent/background/process-task.ts @@ -1,5 +1,6 @@ import type { KaosProcess } from '@moonshot-ai/kaos'; +import { errorMessage } from '../../loop/errors'; import type { BackgroundTask, BackgroundTaskInfoBase, @@ -54,10 +55,11 @@ export class ProcessBackgroundTask implements BackgroundTask { await sink.settle({ status: sink.signal.aborted ? 'killed' : exitCode === 0 ? 'completed' : 'failed', }); - } catch { + } catch (error: unknown) { this.exitCode = this.proc.exitCode; await sink.settle({ status: sink.signal.aborted ? 'killed' : 'failed', + stopReason: sink.signal.aborted ? undefined : errorMessage(error), }); } finally { sink.signal.removeEventListener('abort', requestStop); diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 4284e1ef..37d6a33b 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -19,7 +19,7 @@ export type BackgroundTaskSettlementStatus = 'completed' | 'failed' | 'timed_out export interface BackgroundTaskSettlement { readonly status: BackgroundTaskSettlementStatus; - /** Reason recorded when a task is explicitly stopped or aborted. */ + /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; } @@ -32,7 +32,7 @@ export interface BackgroundTaskInfoBase { readonly endedAt: number | null; /** Populated only while `status === 'awaiting_approval'`. */ readonly approvalReason?: string; - /** Reason recorded when a task is explicitly stopped or aborted. */ + /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; /** Deadline supplied at registration; surfaced via task info. */ readonly timeoutMs?: number; diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index d38e0c32..617a1c4f 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -26,7 +26,7 @@ import { estimateTokensForTools, } from '../utils/tokens'; import type { PromisableMethods } from '../utils/types'; -import { BackgroundManager } from './background'; +import { BackgroundManager, BackgroundTaskPersistence } from './background'; import { FullCompaction, MicroCompaction, @@ -165,7 +165,10 @@ export class Agent { this.usage = new UsageRecorder(this); this.skills = options.skills ? new SkillManager(this, options.skills) : null; this.tools = new ToolManager(this); - this.background = new BackgroundManager(this); + this.background = new BackgroundManager( + this, + this.homedir === undefined ? undefined : new BackgroundTaskPersistence(this.homedir), + ); this.cron = this.type === 'sub' ? null : new CronManager(this); this.replayBuilder = new ReplayBuilder(this); } diff --git a/packages/agent-core/src/tools/background/task-output.ts b/packages/agent-core/src/tools/background/task-output.ts index 3c2834f4..4835a6c5 100644 --- a/packages/agent-core/src/tools/background/task-output.ts +++ b/packages/agent-core/src/tools/background/task-output.ts @@ -9,7 +9,7 @@ * * For terminal tasks the output also surfaces why the task ended: * `stop_reason` records the concrete reason; `terminal_reason` classifies - * timeout vs. explicit stop for callers that need stable labels. + * timeout vs. explicit stop vs. failure for callers that need stable labels. */ import { z } from 'zod'; @@ -69,9 +69,10 @@ function retrievalStatus( return block ? 'timeout' : 'not_ready'; } -function terminalReason(info: BackgroundTaskInfo): 'timed_out' | 'stopped' | undefined { +function terminalReason(info: BackgroundTaskInfo): 'timed_out' | 'stopped' | 'failed' | undefined { if (info.status === 'timed_out') return 'timed_out'; - if (info.stopReason !== undefined) return 'stopped'; + if (info.status === 'killed' && info.stopReason !== undefined) return 'stopped'; + if (info.status === 'failed' && info.stopReason !== undefined) return 'failed'; return undefined; } From 184af872f04babaf9b4164b2625d5e47fcf2ae33 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 20:02:00 +0800 Subject: [PATCH 09/21] fix --- .../agent-core/src/agent/background/index.ts | 86 ++++--------------- .../src/agent/background/process-task.ts | 4 - .../agent-core/src/agent/background/task.ts | 10 ++- .../src/tools/background/task-list.ts | 1 - .../src/tools/background/task-output.ts | 1 - .../src/tools/background/task-stop.ts | 1 - .../src/tools/builtin/shell/bash.ts | 5 +- 7 files changed, 27 insertions(+), 81 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index e959fe04..71c5014f 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -16,16 +16,14 @@ import type { ContentPart } from '@moonshot-ai/kosong'; import type { Agent } from '../..'; import { errorMessage } from '../../loop/errors'; -import type { TelemetryPropertyValue } from '../../telemetry'; import type { BackgroundTaskOrigin } from '../context'; import { renderNotificationXml } from '../context/notification-xml'; import { type BackgroundTaskPersistence } from './persist'; import { - TERMINAL_BACKGROUND_TASK_STATUSES, + TERMINAL_STATUSES, type BackgroundTask, type BackgroundTaskInfo, type BackgroundTaskInfoBase, - type BackgroundTaskSink, type BackgroundTaskSettlement, type BackgroundTaskStatus, } from './task'; @@ -45,9 +43,6 @@ import { * preserved because `awaiting_approval` in BPM does not leak permission * vocabulary into the loop. */ -/** Terminal states tasks never leave once reached. */ -const TERMINAL_STATUSES = TERMINAL_BACKGROUND_TASK_STATUSES; - export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { return TERMINAL_STATUSES.has(status); } @@ -103,7 +98,6 @@ interface ManagedTask { const MAX_OUTPUT_BYTES = 1024 * 1024; // 1 MiB const SIGTERM_GRACE_MS = 5_000; -const EXIT_SETTLE_GRACE_MS = 10; const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; @@ -193,7 +187,7 @@ export class BackgroundManager { constructor( private readonly agent: Agent, private readonly persistence?: BackgroundTaskPersistence, - ) {} + ) { } /** * Fire terminal side effects for a live task. Idempotent: the second @@ -205,7 +199,7 @@ export class BackgroundManager { if (entry.terminalFired) return; entry.terminalFired = true; const info = this.toInfo(entry); - void this.notifyBackgroundTask(info).catch(() => {}); + void this.notifyBackgroundTask(info).catch(() => { }); this.emitTaskTerminated(info); } @@ -222,22 +216,11 @@ export class BackgroundManager { private emitTaskTerminated(info: BackgroundTaskInfo): void { this.agent.emitEvent({ type: 'background.task.terminated', info }); - const success = info.status === 'completed'; - const duration_s = info.endedAt !== null ? (info.endedAt - info.startedAt) / 1000 : null; - const properties: Record = { - kind: info.kind === 'agent' ? 'agent' : 'bash', - success, - duration_s, - }; - if (!success) { - properties['reason'] = - info.status === 'timed_out' - ? 'timeout' - : info.status === 'killed' - ? 'killed' - : 'error'; - } - this.agent.telemetry.track('background_task_completed', properties); + this.agent.telemetry.track('background_task_completed', { + kind: info.kind, + duration: info.endedAt !== null ? info.endedAt - info.startedAt : null, + status: info.status, + }); } private resolveWaiters(entry: ManagedTask): void { @@ -245,16 +228,6 @@ export class BackgroundManager { for (const resolve of waiters) resolve(); } - private createTaskSink(entry: ManagedTask): BackgroundTaskSink { - return { - signal: entry.abortController.signal, - appendOutput: (chunk) => { - this.appendOutput(entry, chunk); - }, - settle: (settlement) => this.settleTask(entry, settlement), - }; - } - private assertCanRegister(): void { const maxRunningTasks = this.agent.kimiConfig?.background?.maxRunningTasks; if (maxRunningTasks === undefined) return; @@ -291,9 +264,14 @@ export class BackgroundManager { }; this.tasks.set(taskId, entry); - const sink = this.createTaskSink(entry); entry.lifecyclePromise = Promise.resolve() - .then(() => task.start(sink)) + .then(() => task.start({ + signal: entry.abortController.signal, + appendOutput: (chunk) => { + this.appendOutput(entry, chunk); + }, + settle: (settlement) => this.settleTask(entry, settlement), + })) .catch(async (error: unknown) => { const aborted = entry.abortController.signal.aborted; await this.settleTask(entry, { @@ -318,21 +296,6 @@ export class BackgroundManager { return this.ghosts.get(taskId); } - /** - * Give just-ended processes a short grace period to settle their `wait()` - * promise, then return with whatever lifecycle state has been finalized. - */ - async settlePendingExits(): Promise { - const pendingCompletions = this.observedExitCompletions(); - if (pendingCompletions.length === 0) return; - await Promise.race([ - Promise.allSettled(pendingCompletions).then(() => {}), - new Promise((resolve) => { - setTimeout(resolve, EXIT_SETTLE_GRACE_MS); - }), - ]); - } - /** * List tasks, optionally filtering to active-only. * @@ -674,7 +637,7 @@ export class BackgroundManager { const info = this.toInfo(entry); entry.persistWriteQueue = entry.persistWriteQueue .then(() => persistence.writeTask(info)) - .catch(() => {}); + .catch(() => { }); return entry.persistWriteQueue; } @@ -693,7 +656,7 @@ export class BackgroundManager { if (persistence === undefined) return; entry.outputWriteQueue = entry.outputWriteQueue .then(() => persistence.appendTaskOutput(entry.taskId, chunk)) - .catch(() => {}); + .catch(() => { }); } private persistenceFor(taskId: string): BackgroundTaskPersistence | undefined { @@ -816,16 +779,6 @@ export class BackgroundManager { return true; } - private observedExitCompletions(): Promise[] { - const completions: Promise[] = []; - for (const entry of this.tasks.values()) { - if (!TERMINAL_STATUSES.has(entry.status) && entry.task.hasObservedTerminal?.() === true) { - completions.push(entry.lifecyclePromise); - } - } - return completions; - } - private toInfo(entry: ManagedTask): BackgroundTaskInfo { const base: BackgroundTaskInfoBase = { taskId: entry.taskId, @@ -851,9 +804,8 @@ function buildBackgroundTaskNotificationBody(info: BackgroundTaskInfo): string { info.status === 'timed_out' ? `${info.description} timed out.` : info.stopReason - ? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${ - info.stopReason - }.` + ? `${info.description} ${info.status === 'killed' ? 'was killed' : info.status}: ${info.stopReason + }.` : `${info.description} ${info.status}.`; if (info.kind !== 'agent') return baseLine; diff --git a/packages/agent-core/src/agent/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts index 29bf7e5b..39190855 100644 --- a/packages/agent-core/src/agent/background/process-task.ts +++ b/packages/agent-core/src/agent/background/process-task.ts @@ -71,10 +71,6 @@ export class ProcessBackgroundTask implements BackgroundTask { await this.proc.kill('SIGKILL'); } - hasObservedTerminal(): boolean { - return this.proc.exitCode !== null; - } - toInfo(base: BackgroundTaskInfoBase): ProcessBackgroundTaskInfo { return { ...base, diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 37d6a33b..5d5f40af 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -10,8 +10,13 @@ export type BackgroundTaskStatus = | 'killed' | 'lost'; -export const TERMINAL_BACKGROUND_TASK_STATUSES: ReadonlySet = - new Set(['completed', 'failed', 'timed_out', 'killed', 'lost']); +export const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); export type BackgroundTaskInfo = ProcessBackgroundTaskInfo | AgentBackgroundTaskInfo; export type BackgroundTaskKind = BackgroundTaskInfo['kind']; @@ -52,6 +57,5 @@ export interface BackgroundTask { start(sink: BackgroundTaskSink): void | Promise; forceStop?(): Promise; - hasObservedTerminal?(): boolean; toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo; } diff --git a/packages/agent-core/src/tools/background/task-list.ts b/packages/agent-core/src/tools/background/task-list.ts index 35bf8064..755a94b8 100644 --- a/packages/agent-core/src/tools/background/task-list.ts +++ b/packages/agent-core/src/tools/background/task-list.ts @@ -57,7 +57,6 @@ export class TaskListTool implements BuiltinTool { approvalRule: this.name, matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, listScope), execute: async () => { - await this.manager.settlePendingExits(); const activeOnly = args.active_only ?? true; const tasks = this.manager.list(activeOnly, args.limit ?? 20); return { diff --git a/packages/agent-core/src/tools/background/task-output.ts b/packages/agent-core/src/tools/background/task-output.ts index 4835a6c5..5b03572f 100644 --- a/packages/agent-core/src/tools/background/task-output.ts +++ b/packages/agent-core/src/tools/background/task-output.ts @@ -111,7 +111,6 @@ export class TaskOutputTool implements BuiltinTool { } private async execute(args: TaskOutputInput): Promise { - await this.manager.settlePendingExits(); const info = this.manager.getTask(args.task_id); if (!info) { return { isError: true, output: `Task not found: ${args.task_id}` }; diff --git a/packages/agent-core/src/tools/background/task-stop.ts b/packages/agent-core/src/tools/background/task-stop.ts index e3827312..dcb71f1d 100644 --- a/packages/agent-core/src/tools/background/task-stop.ts +++ b/packages/agent-core/src/tools/background/task-stop.ts @@ -42,7 +42,6 @@ export class TaskStopTool implements BuiltinTool { approvalRule: this.name, matchesRule: (ruleArgs) => matchesGlobRuleSubject(ruleArgs, args.task_id), execute: async () => { - await this.manager.settlePendingExits(); const info = this.manager.getTask(args.task_id); if (!info) { return { isError: true, output: `Task not found: ${args.task_id}` }; diff --git a/packages/agent-core/src/tools/builtin/shell/bash.ts b/packages/agent-core/src/tools/builtin/shell/bash.ts index 20ab7b5d..3c4ffafe 100644 --- a/packages/agent-core/src/tools/builtin/shell/bash.ts +++ b/packages/agent-core/src/tools/builtin/shell/bash.ts @@ -406,10 +406,7 @@ export class BashTool implements BuiltinTool { if (timeoutMs !== undefined) { setTimeout(() => { void (async (): Promise => { - if (proc.exitCode !== null) { - await backgroundManager.settlePendingExits(); - return; - } + if (proc.exitCode !== null) return; const info = backgroundManager.getTask(taskId); if (info && info.status === 'running') { void backgroundManager.stop(taskId, 'Timed out'); From 446c1690dba7bce6414658d58e3b3d378ee01fe0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 20:12:25 +0800 Subject: [PATCH 10/21] fix --- .../web/src/components/wire/WireHeadline.tsx | 10 --------- apps/vis/web/src/components/wire/typeMeta.ts | 2 -- .../agent-core/src/agent/background/index.ts | 22 ++----------------- .../agent-core/src/agent/records/index.ts | 2 -- .../agent-core/src/agent/records/types.ts | 4 ---- 5 files changed, 2 insertions(+), 38 deletions(-) diff --git a/apps/vis/web/src/components/wire/WireHeadline.tsx b/apps/vis/web/src/components/wire/WireHeadline.tsx index a743b3a7..dcdc3e09 100644 --- a/apps/vis/web/src/components/wire/WireHeadline.tsx +++ b/apps/vis/web/src/components/wire/WireHeadline.tsx @@ -328,16 +328,6 @@ export function renderHeadline(r: AgentRecord): HeadlineRender { ), }; - - case 'background.stop': - return { - main: ( - - task - {r.taskId} - - ), - }; } // `r` is `never` here under TypeScript exhaustiveness, but at runtime // best-effort parsing of unknown/future protocols can deliver records diff --git a/apps/vis/web/src/components/wire/typeMeta.ts b/apps/vis/web/src/components/wire/typeMeta.ts index 66d5a0ee..cc115df7 100644 --- a/apps/vis/web/src/components/wire/typeMeta.ts +++ b/apps/vis/web/src/components/wire/typeMeta.ts @@ -27,7 +27,6 @@ export const TYPE_TONE: Record = { 'plan_mode.enter': 'lifecycle', 'plan_mode.cancel': 'warning', 'plan_mode.exit': 'success', - 'background.stop': 'warning', }; /** Compact human label for each record type (used in the type badge). */ @@ -54,5 +53,4 @@ export const TYPE_LABEL: Record = { 'plan_mode.enter': 'plan↻', 'plan_mode.cancel': 'plan×', 'plan_mode.exit': 'plan✓', - 'background.stop': 'bg-stop', }; diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 71c5014f..f21913c1 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -322,20 +322,6 @@ export class BackgroundManager { return result; } - /** - * Await all pending `output.log` appends for a task to settle. - * - * Output chunks are persisted to disk on an async queue, so a task can - * reach a terminal state before its final chunks have landed on disk. - * Callers should `await flushOutput()` before reading the on-disk log - * directly. No-op for unknown/ghost tasks. - */ - async flushOutput(taskId: string): Promise { - const entry = this.tasks.get(taskId); - if (entry === undefined) return; - await entry.outputWriteQueue; - } - /** * Return the output snapshot used by TaskOutput. * @@ -351,7 +337,7 @@ export class BackgroundManager { ): Promise { if (this.getTask(taskId) === undefined) return emptyOutputSnapshot(); - await this.flushOutput(taskId); + await this.tasks.get(taskId)?.outputWriteQueue; const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); const persistence = this.persistenceFor(taskId); @@ -386,7 +372,7 @@ export class BackgroundManager { } /** Get the combined output of a task (tail of the ring buffer). */ - getOutput(taskId: string, tail?: number): string { + private getOutput(taskId: string, tail?: number): string { const entry = this.tasks.get(taskId); if (!entry) return ''; const full = entry.outputChunks.join(''); @@ -421,10 +407,6 @@ export class BackgroundManager { /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { - this.agent.records.logRecord({ - type: 'background.stop', - taskId, - }); const entry = this.tasks.get(taskId); if (!entry) return undefined; // Normalize at this shared boundary: every public stop path (the TaskStop diff --git a/packages/agent-core/src/agent/records/index.ts b/packages/agent-core/src/agent/records/index.ts index cf79270f..6265e4bc 100644 --- a/packages/agent-core/src/agent/records/index.ts +++ b/packages/agent-core/src/agent/records/index.ts @@ -35,8 +35,6 @@ function restoreAgentRecord(agent: Agent, input: AgentRecord): void { case 'turn.cancel': agent.turn.cancel(input.turnId); return; - case 'background.stop': - return; case 'config.update': agent.config.update(input); return; diff --git a/packages/agent-core/src/agent/records/types.ts b/packages/agent-core/src/agent/records/types.ts index c8acefb7..a3e13c79 100644 --- a/packages/agent-core/src/agent/records/types.ts +++ b/packages/agent-core/src/agent/records/types.ts @@ -52,10 +52,6 @@ export interface AgentRecordEvents { names: readonly string[]; }; - 'background.stop': { - taskId: string; - }; - 'usage.record': { model: string; usage: TokenUsage; From 47aff1f78e2efdda00c44c2ff42b80a33cf7e720 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 20:16:10 +0800 Subject: [PATCH 11/21] fix --- .../agent-core/src/agent/background/index.ts | 34 +++---------------- .../src/agent/background/persist.ts | 19 +---------- packages/agent-core/src/agent/index.ts | 1 - packages/agent-core/src/rpc/core-api.ts | 4 --- packages/agent-core/src/rpc/core-impl.ts | 8 ----- packages/agent-core/src/session/rpc.ts | 8 ----- packages/node-sdk/src/rpc.ts | 11 ------ packages/node-sdk/src/session.ts | 18 ---------- 8 files changed, 5 insertions(+), 98 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index f21913c1..28ddcd88 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -371,38 +371,12 @@ export class BackgroundManager { }; } - /** Get the combined output of a task (tail of the ring buffer). */ - private getOutput(taskId: string, tail?: number): string { - const entry = this.tasks.get(taskId); - if (!entry) return ''; - const full = entry.outputChunks.join(''); - if (tail !== undefined && tail < full.length) { - return full.slice(-tail); - } - return full; - } - async readOutput(taskId: string, tail?: number): Promise { - const entry = this.tasks.get(taskId); - const persistence = this.persistenceFor(taskId); - if (persistence !== undefined) { - await entry?.outputWriteQueue; - const persisted = await persistence.readTaskOutput(taskId); - if (persisted.length > 0) { - if (tail !== undefined && tail < persisted.length) { - return persisted.slice(-tail); - } - return persisted; - } + const output = (await this.getOutputSnapshot(taskId, Number.MAX_SAFE_INTEGER)).preview; + if (tail !== undefined && tail < output.length) { + return output.slice(-tail); } - return this.getOutput(taskId, tail); - } - - getOutputPath(taskId: string): string | undefined { - const persistence = this.persistenceFor(taskId); - if (persistence === undefined) return undefined; - if (!persistence.taskOutputExistsSync(taskId)) return undefined; - return persistence.taskOutputFile(taskId); + return output; } /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index b85a7677..c008adf5 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -12,8 +12,7 @@ * background-specific shape and the output.log helpers together. */ -import { statSync } from 'node:fs'; -import { appendFile, mkdir, open, readFile, stat } from 'node:fs/promises'; +import { appendFile, mkdir, open, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; @@ -78,14 +77,6 @@ export class BackgroundTaskPersistence { await appendFile(path, chunk, 'utf-8'); } - async readTaskOutput(taskId: string): Promise { - try { - return await readFile(this.taskOutputFile(taskId), 'utf-8'); - } catch { - return ''; - } - } - /** * Total byte size of a task's `output.log`. Returns 0 when the log does * not exist yet (the task has produced no output, or is unknown). @@ -111,14 +102,6 @@ export class BackgroundTaskPersistence { } } - taskOutputExistsSync(taskId: string): boolean { - try { - return statSync(this.taskOutputFile(taskId)).isFile(); - } catch { - return false; - } - } - /** * Read a byte window of a task's `output.log`. * diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 617a1c4f..452c8960 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -377,7 +377,6 @@ export class Agent { this.skills.activate(payload); }, getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), - getBackgroundOutputPath: (payload) => this.background.getOutputPath(payload.taskId), getContext: () => this.context.data(), getConfig: () => this.config.data(), getPermission: () => this.permission.data(), diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 648c07d0..e9da0f40 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -174,9 +174,6 @@ export interface GetBackgroundOutputPayload { readonly taskId: string; readonly tail?: number; } -export interface GetBackgroundOutputPathPayload { - readonly taskId: string; -} export interface GetBackgroundPayload { /** * When omitted, returns all tasks (including terminal/lost). Pass @@ -281,7 +278,6 @@ export interface AgentAPI { clearContext: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; - getBackgroundOutputPath: (payload: GetBackgroundOutputPathPayload) => string | undefined; getContext: (payload: EmptyPayload) => AgentContextData; getConfig: (payload: EmptyPayload) => AgentConfigData; getPermission: (payload: EmptyPayload) => PermissionData; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 26e0f7aa..f2048afa 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -53,7 +53,6 @@ import type { ExportSessionPayload, ExportSessionResult, ForkSessionPayload, - GetBackgroundOutputPathPayload, GetBackgroundOutputPayload, GetBackgroundPayload, GetKimiConfigPayload, @@ -501,13 +500,6 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).getBackgroundOutput(payload); } - getBackgroundOutputPath({ - sessionId, - ...payload - }: SessionAgentPayload) { - return this.sessionApi(sessionId).getBackgroundOutputPath(payload); - } - getContext({ sessionId, ...payload }: SessionAgentPayload) { return this.sessionApi(sessionId).getContext(payload); } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index be5eac82..82d1fa72 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -6,7 +6,6 @@ import type { CancelPayload, CancelPlanPayload, EmptyPayload, - GetBackgroundOutputPathPayload, GetBackgroundOutputPayload, GetBackgroundPayload, McpServerInfo, @@ -170,13 +169,6 @@ export class SessionAPIImpl implements PromisableMethods { return this.getAgent(agentId).getBackgroundOutput(payload); } - getBackgroundOutputPath({ - agentId, - ...payload - }: AgentScopedPayload) { - return this.getAgent(agentId).getBackgroundOutputPath(payload); - } - getContext({ agentId, ...payload }: AgentScopedPayload) { return this.getAgent(agentId).getContext(payload); } diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 7346e5a5..49065396 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -403,17 +403,6 @@ export class SDKRpcClient { }); } - async getBackgroundTaskOutputPath( - input: SessionIdRpcInput & { taskId: string }, - ): Promise { - const rpc = await this.getRpc(); - return rpc.getBackgroundOutputPath({ - sessionId: input.sessionId, - agentId: this.interactiveAgentId, - taskId: input.taskId, - }); - } - async stopBackgroundTask( input: SessionIdRpcInput & { taskId: string; reason?: string }, ): Promise { diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index 6dc395ef..d3e34c0a 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -250,24 +250,6 @@ export class Session { }); } - /** - * Return the absolute path to the task's `output.log` on disk, or - * `undefined` when the task is unknown or has no persisted output. - * Callers can hand the path to an external pager. - */ - async getBackgroundTaskOutputPath(taskId: string): Promise { - this.ensureOpen(); - const trimmedTaskId = normalizeRequiredString( - taskId, - 'Task id cannot be empty', - ErrorCodes.BACKGROUND_TASK_ID_EMPTY, - ); - return this.rpc.getBackgroundTaskOutputPath({ - sessionId: this.id, - taskId: trimmedTaskId, - }); - } - async listMcpServers(): Promise { this.ensureOpen(); return this.rpc.listMcpServers({ sessionId: this.id }); From a62df54db771746b7737ddaca8a0ba0396f38688 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 20:34:14 +0800 Subject: [PATCH 12/21] fix --- .../components/dialogs/task-output-viewer.ts | 3 - .../tui/components/dialogs/tasks-browser.ts | 19 +- .../src/tui/controllers/tasks-browser.ts | 6 +- .../src/tui/utils/background-task-status.ts | 13 +- .../test/tui/background-task-status.test.ts | 10 - .../kimi-code/test/tui/message-replay.test.ts | 4 +- apps/kimi-code/test/tui/tasks-browser.test.ts | 1 - .../agent-core/src/agent/background/index.ts | 69 +------ .../agent-core/src/agent/background/task.ts | 3 - .../test/agent/background-manager.test.ts | 13 -- .../test/tools/background/reconcile.test.ts | 25 --- .../background/state-transitions.test.ts | 190 ------------------ .../test/tools/background/task-tools.test.ts | 73 ------- 13 files changed, 11 insertions(+), 418 deletions(-) delete mode 100644 packages/agent-core/test/tools/background/state-transitions.test.ts diff --git a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts index 77bb6e94..fbd11d76 100644 --- a/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts +++ b/apps/kimi-code/src/tui/components/dialogs/task-output-viewer.ts @@ -36,7 +36,6 @@ export interface TaskOutputViewerProps { const STATUS_LABEL: Record = { running: 'running', - awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', timed_out: 'timed out', @@ -48,8 +47,6 @@ function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string switch (status) { case 'running': return colors.success; - case 'awaiting_approval': - return colors.warning; case 'completed': return colors.textMuted; case 'failed': diff --git a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts index d94ac8eb..5ccbadc5 100644 --- a/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts +++ b/apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts @@ -54,7 +54,6 @@ export interface TasksBrowserProps { const STATUS_LABEL: Record = { running: 'running', - awaiting_approval: 'awaiting', completed: 'completed', failed: 'failed', timed_out: 'timed out', @@ -78,8 +77,6 @@ function statusColor(colors: ColorPalette, status: BackgroundTaskStatus): string switch (status) { case 'running': return colors.success; - case 'awaiting_approval': - return colors.warning; case 'completed': return colors.textMuted; case 'failed': @@ -148,21 +145,17 @@ function compareTasks(a: BackgroundTaskInfo, b: BackgroundTaskInfo): number { interface StatusCounts { running: number; - awaiting: number; completed: number; terminalFailed: number; } function countByStatus(tasks: readonly BackgroundTaskInfo[]): StatusCounts { - const counts: StatusCounts = { running: 0, awaiting: 0, completed: 0, terminalFailed: 0 }; + const counts: StatusCounts = { running: 0, completed: 0, terminalFailed: 0 }; for (const t of tasks) { switch (t.status) { case 'running': counts.running += 1; break; - case 'awaiting_approval': - counts.awaiting += 1; - break; case 'completed': counts.completed += 1; break; @@ -345,8 +338,6 @@ export class TasksBrowserApp extends Container implements Focusable { const countSegments: string[] = []; if (counts.running > 0) countSegments.push(chalk.hex(colors.success)(` ${String(counts.running)} running `)); - if (counts.awaiting > 0) - countSegments.push(chalk.hex(colors.warning)(` ${String(counts.awaiting)} awaiting `)); if (counts.completed > 0) countSegments.push(chalk.hex(colors.textDim)(` ${String(counts.completed)} completed `)); if (counts.terminalFailed > 0) @@ -555,7 +546,7 @@ export class TasksBrowserApp extends Container implements Focusable { lines.push(`${label('Agent type:')}${value(task.subagentType)}`); } const timing = - task.status === 'running' || task.status === 'awaiting_approval' + task.status === 'running' ? `running ${formatRelativeTime(task.startedAt)}` : task.endedAt !== null && task.endedAt !== undefined ? `finished ${formatRelativeTime(task.endedAt)}` @@ -570,12 +561,6 @@ export class TasksBrowserApp extends Container implements Focusable { if (task.stopReason !== undefined && task.stopReason.length > 0) { lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`); } - if (task.approvalReason !== undefined && task.approvalReason.length > 0) { - lines.push( - `${label('Awaiting:')}${chalk.hex(colors.warning)(singleLine(task.approvalReason))}`, - ); - } - while (lines.length < innerHeight) lines.push(''); return this.renderFrame('Detail', lines, width, height); } diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 1d00f622..a7a0c205 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -193,11 +193,7 @@ export class TasksBrowserController { t.status !== 'lost', ); if (candidates.length === 0) return undefined; - return ( - candidates.find( - (t) => t.status === 'running' || t.status === 'awaiting_approval', - )?.taskId ?? candidates[0]!.taskId - ); + return candidates.find((t) => t.status === 'running')?.taskId ?? candidates[0]!.taskId; } private async refresh(opts: { silent?: boolean } = {}): Promise { diff --git a/apps/kimi-code/src/tui/utils/background-task-status.ts b/apps/kimi-code/src/tui/utils/background-task-status.ts index 4ff76bd5..b3e23481 100644 --- a/apps/kimi-code/src/tui/utils/background-task-status.ts +++ b/apps/kimi-code/src/tui/utils/background-task-status.ts @@ -2,9 +2,9 @@ * Format a `BackgroundTaskInfo` snapshot into the transcript card data * consumed by `BackgroundAgentStatusComponent`. * - * Background tasks have several statuses (running / awaiting_approval / - * completed / failed / timed_out / killed / lost) but the transcript card only - * renders three visual phases (started / completed / failed). The + * Background tasks have several statuses (running / completed / failed / + * timed_out / killed / lost) but the transcript card only renders three + * visual phases (started / completed / failed). The * mapping packs the extra nuance — exit code, kill reason, lost-reason * — into the dim detail line so the user still sees it. */ @@ -28,7 +28,6 @@ export type BackgroundTaskTranscriptPhase = 'started' | 'updated' | 'terminal'; function phaseFromStatus(status: BackgroundTaskStatus): BackgroundAgentStatusPhase { switch (status) { case 'running': - case 'awaiting_approval': return 'started'; case 'completed': return 'completed'; @@ -49,8 +48,6 @@ function headlineFor(info: BackgroundTaskInfo): string { switch (info.status) { case 'running': return `${subject} started in background`; - case 'awaiting_approval': - return `${subject} awaiting approval`; case 'completed': return `${subject} completed in background`; case 'failed': @@ -83,10 +80,6 @@ function detailFor(info: BackgroundTaskInfo): string | undefined { if (reason !== undefined) parts.push(reason); } if (info.status === 'timed_out') parts.push('timed out'); - if (info.status === 'awaiting_approval') { - const reason = truncate(info.approvalReason); - if (reason !== undefined) parts.push(`awaiting: ${reason}`); - } if (info.status === 'lost') { parts.push('session restarted before completion'); } diff --git a/apps/kimi-code/test/tui/background-task-status.test.ts b/apps/kimi-code/test/tui/background-task-status.test.ts index 8ed114ab..b8bb8d7a 100644 --- a/apps/kimi-code/test/tui/background-task-status.test.ts +++ b/apps/kimi-code/test/tui/background-task-status.test.ts @@ -83,15 +83,6 @@ describe('formatBackgroundTaskTranscript', () => { expect(data.detail).toContain('session restarted'); }); - it('surfaces awaiting_approval reason', () => { - const data = formatBackgroundTaskTranscript( - task({ status: 'awaiting_approval', approvalReason: 'needs network' }), - ); - expect(data.phase).toBe('started'); - expect(data.headline).toContain('awaiting'); - expect(data.detail).toContain('needs network'); - }); - it('surfaces timeout stop reason for agent deadlines', () => { const data = formatBackgroundTaskTranscript( task({ @@ -107,7 +98,6 @@ describe('formatBackgroundTaskTranscript', () => { it('handles every BackgroundTaskStatus without throwing', () => { const statuses: BackgroundTaskStatus[] = [ 'running', - 'awaiting_approval', 'completed', 'failed', 'killed', diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 6ba52821..26e91795 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -212,7 +212,7 @@ function backgroundTask( description, status, startedAt: 1, - endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + endedAt: status === 'running' ? null : 2, }; } return { @@ -224,7 +224,7 @@ function backgroundTask( pid: 0, exitCode: status === 'completed' ? 0 : null, startedAt: 1, - endedAt: status === 'running' || status === 'awaiting_approval' ? null : 2, + endedAt: status === 'running' ? null : 2, }; } diff --git a/apps/kimi-code/test/tui/tasks-browser.test.ts b/apps/kimi-code/test/tui/tasks-browser.test.ts index 8513e2bd..e01432a6 100644 --- a/apps/kimi-code/test/tui/tasks-browser.test.ts +++ b/apps/kimi-code/test/tui/tasks-browser.test.ts @@ -198,7 +198,6 @@ describe('TasksBrowserApp — full-screen rendering', () => { it('renders without throwing for every BackgroundTaskStatus', () => { const statuses: BackgroundTaskStatus[] = [ 'running', - 'awaiting_approval', 'completed', 'failed', 'killed', diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 28ddcd88..e3aa3999 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -34,14 +34,6 @@ import { * `'lost'` is a reconcile-only terminal state. Tasks loaded from disk * that were marked `running` at startup but have no live KaosProcess * (the previous CLI process died) are reclassified as lost. - * - * `'awaiting_approval'` is a non-terminal state entered when a background - * agent task is paused waiting for tool-call approval from the root - * agent. The BPM state machine is the single source of truth for "is - * this task actively running vs. gated on approval" — UI reads from BPM - * instead of reverse-querying the ApprovalRuntime. The loop boundary is - * preserved because `awaiting_approval` in BPM does not leak permission - * vocabulary into the loop. */ export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean { return TERMINAL_STATUSES.has(status); @@ -71,8 +63,6 @@ interface ManagedTask { readonly waiters: Array<() => void>; /** True once terminal notification/event side effects have already run. */ terminalFired: boolean; - /** Reason carried while awaiting approval. */ - approvalReason?: string | undefined; /** Human-readable reason for the terminal status, when available. */ stopReason?: string | undefined; /** Deadline supplied at registration; surfaced via task info. */ @@ -306,9 +296,6 @@ export class BackgroundManager { list(activeOnly = true, limit?: number): BackgroundTaskInfo[] { const result: BackgroundTaskInfo[] = []; for (const entry of this.tasks.values()) { - // An awaiting_approval task is non-terminal and therefore counts - // as active in listings (UI needs to show it alongside plain - // running tasks). if (activeOnly && TERMINAL_STATUSES.has(entry.status)) continue; result.push(this.toInfo(entry)); if (limit !== undefined && result.length >= limit) return result; @@ -389,15 +376,12 @@ export class BackgroundManager { const trimmedReason = reason?.trim(); const stopReason = trimmedReason === undefined || trimmedReason.length === 0 ? undefined : trimmedReason; - // Terminal tasks short-circuit. awaiting_approval tasks can still - // be stopped (the approval gate is lifted when we transition to - // 'killed'). + // Terminal tasks short-circuit. if (TERMINAL_STATUSES.has(entry.status)) { await entry.persistWriteQueue; return this.toInfo(entry); } - entry.approvalReason = undefined; entry.stopReason = stopReason; entry.abortController.abort(stopReason); @@ -444,9 +428,7 @@ export class BackgroundManager { } async stopAll(reason?: string): Promise { - const taskIds = Array.from(this.tasks.values()) - .filter((entry) => !TERMINAL_STATUSES.has(entry.status)) - .map((entry) => entry.taskId); + const taskIds = Array.from(this.tasks.keys()); const results = await Promise.all(taskIds.map((taskId) => this.stop(taskId, reason))); return results.filter((info): info is BackgroundTaskInfo => info !== undefined); } @@ -489,41 +471,6 @@ export class BackgroundManager { return this.toInfo(entry); } - // ── awaiting_approval state transitions ──────────────────────────── - - /** - * Mark a running task as paused pending approval. The approval reason - * (tool call description) is retained until the task either returns - * to `'running'` via `clearAwaitingApproval()` or reaches a terminal - * state. Calls on terminal or unknown tasks are silently ignored so - * the ApprovalRuntime callback path is race-safe. - */ - markAwaitingApproval(taskId: string, reason: string): void { - const entry = this.tasks.get(taskId); - if (!entry) return; - if (TERMINAL_STATUSES.has(entry.status)) return; - entry.status = 'awaiting_approval'; - entry.approvalReason = reason; - void this.persistLive(entry); - this.emitTaskUpdated(this.toInfo(entry)); - } - - /** - * Drop the approval gate and return to `'running'`. Clears the stored - * reason so stale text cannot leak into a future `awaiting_approval` - * cycle. No-op unless the task is currently in the awaiting_approval - * state. - */ - clearAwaitingApproval(taskId: string): void { - const entry = this.tasks.get(taskId); - if (!entry) return; - if (entry.status !== 'awaiting_approval') return; - entry.status = 'running'; - entry.approvalReason = undefined; - void this.persistLive(entry); - this.emitTaskUpdated(this.toInfo(entry)); - } - // ── persistence + reconcile ──────────────────────────────────────── /** @@ -554,15 +501,12 @@ export class BackgroundManager { const lostInfo: BackgroundTaskInfo[] = []; const persistence = this.persistence; for (const [id, info] of this.ghosts) { - // Any non-terminal ghost is lost. Includes `awaiting_approval` - // (the approval context died with the previous process so it - // cannot be resumed). + // Any non-terminal ghost is lost. if (TERMINAL_STATUSES.has(info.status)) continue; const updated: BackgroundTaskInfo = { ...info, status: 'lost', endedAt: info.endedAt ?? Date.now(), - approvalReason: undefined, }; this.ghosts.set(id, updated); if (persistence !== undefined) { @@ -723,12 +667,6 @@ export class BackgroundManager { entry.endedAt = Date.now(); entry.stopReason = settlement.stopReason ?? (settlement.status === 'killed' ? entry.stopReason : undefined); - // A task that ended while still in awaiting_approval (e.g. crashed - // mid-prompt, deadline fired, or got killed) must not leak the - // stale approvalReason onto the terminal record. The awaiting → - // running path (clearAwaitingApproval) already clears it; mirror - // that here for the awaiting → terminal path. - entry.approvalReason = undefined; await this.persistLive(entry); this.fireTerminalEffects(entry); this.resolveWaiters(entry); @@ -743,7 +681,6 @@ export class BackgroundManager { status: entry.status, startedAt: entry.startedAt, endedAt: entry.endedAt, - approvalReason: entry.approvalReason, stopReason: entry.stopReason, timeoutMs: entry.timeoutMs, }; diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 5d5f40af..70f9871d 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -3,7 +3,6 @@ import type { ProcessBackgroundTaskInfo } from './process-task'; export type BackgroundTaskStatus = | 'running' - | 'awaiting_approval' | 'completed' | 'failed' | 'timed_out' @@ -35,8 +34,6 @@ export interface BackgroundTaskInfoBase { readonly status: BackgroundTaskStatus; readonly startedAt: number; readonly endedAt: number | null; - /** Populated only while `status === 'awaiting_approval'`. */ - readonly approvalReason?: string; /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; /** Deadline supplied at registration; surfaced via task info. */ diff --git a/packages/agent-core/test/agent/background-manager.test.ts b/packages/agent-core/test/agent/background-manager.test.ts index eb5f314f..f0e93c74 100644 --- a/packages/agent-core/test/agent/background-manager.test.ts +++ b/packages/agent-core/test/agent/background-manager.test.ts @@ -127,19 +127,6 @@ describe('BackgroundManager — RPC event emission', () => { }); }); - it('emits background.task.updated on awaiting_approval transitions', () => { - const taskId = agent.background.register(pendingProcess(), 'sleep', 'demo'); - agent.emittedEvents.length = 0; - - agent.background.markAwaitingApproval(taskId, 'needs approval'); - agent.background.clearAwaitingApproval(taskId); - - const updated = agent.emittedEvents.filter((e) => e.type === 'background.task.updated'); - expect(updated.length).toBe(2); - expect(updated[0]!.info.status).toBe('awaiting_approval'); - expect(updated[1]!.info.status).toBe('running'); - }); - it('emits background.task.terminated on natural exit', async () => { agent.background.register(immediateProcess(0), 'echo', 'done'); await new Promise((r) => setTimeout(r, 20)); diff --git a/packages/agent-core/test/tools/background/reconcile.test.ts b/packages/agent-core/test/tools/background/reconcile.test.ts index 6c2e4f07..b555ad63 100644 --- a/packages/agent-core/test/tools/background/reconcile.test.ts +++ b/packages/agent-core/test/tools/background/reconcile.test.ts @@ -186,31 +186,6 @@ describe('BackgroundManager — loadFromDisk + reconcile', () => { expect(fired[0]?.status).toBe('lost'); }); - it('reconcile treats corrupted runtime (awaiting_approval ghost) as lost', async () => { - // awaiting_approval is non-terminal — when the previous process - // died mid-approval, the task cannot possibly resume; reconcile - // must downgrade it to `lost` just like a running ghost. - await writeTask(sessionDir, { - task_id: 'bash-corrupt0', - command: 'do_approval', - description: 'corrupted approval', - pid: 7777, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'awaiting_approval', - approval_reason: 'ghost reason that should be cleared', - }); - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const result = await mgr.reconcile(); - - expect(result.lost).toEqual(['bash-corrupt0']); - expect(result.lostInfo[0]?.status).toBe('lost'); - expect(result.lostInfo[0]?.approvalReason).toBeUndefined(); - }); - it('reconcile does not republish already-lost ghosts on second pass', async () => { await writeTask(sessionDir, { task_id: 'bash-nodup000', diff --git a/packages/agent-core/test/tools/background/state-transitions.test.ts b/packages/agent-core/test/tools/background/state-transitions.test.ts deleted file mode 100644 index 6747fd66..00000000 --- a/packages/agent-core/test/tools/background/state-transitions.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -/** - * `awaiting_approval` state transitions. - * - * BPM has 6 states: - * running ↔ awaiting_approval → {completed, failed, killed, lost} - * - * Semantics: - * - mark / clear are no-ops unless the target task exists and is not - * terminal - * - UI reads the BPM state directly (ApprovalRuntime remains the - * policy layer); BPM is the single source of truth for "is this - * task actively running or gated" - * - `stop()` applied to an awaiting_approval task transitions - * straight to `killed` with the approvalReason cleared - */ - -import { Readable } from 'node:stream'; -import type { Writable } from 'node:stream'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; - -function pendingProcess(): { proc: KaosProcess; resolve: (code: number) => void } { - let resolveWait: (code: number) => void = () => {}; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 42_042, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: vi.fn(async () => { - if (currentExitCode === null) { - currentExitCode = 143; - resolveWait(143); - } - }) as unknown as KaosProcess['kill'], - }; - return { - proc, - resolve: (code) => { - if (currentExitCode === null) { - currentExitCode = code; - resolveWait(code); - } - }, - }; -} - -describe('BackgroundManager — awaiting_approval state', () => { - const manager = new BackgroundManager(); - - afterEach(() => { - manager._reset(); - }); - - it('markAwaitingApproval flips running → awaiting_approval and stores reason', () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'sleep 999', 'approval test'); - - manager.markAwaitingApproval(taskId, 'Write to /etc/hosts'); - const info = manager.getTask(taskId); - expect(info?.status).toBe('awaiting_approval'); - expect(info?.approvalReason).toBe('Write to /etc/hosts'); - }); - - it('clearAwaitingApproval flips awaiting_approval → running and drops reason', () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - manager.markAwaitingApproval(taskId, 'do thing'); - - manager.clearAwaitingApproval(taskId); - const info = manager.getTask(taskId); - expect(info?.status).toBe('running'); - expect(info?.approvalReason).toBeUndefined(); - }); - - it('markAwaitingApproval is a no-op on terminal tasks', async () => { - const { proc, resolve } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - resolve(0); - await new Promise((r) => { - setTimeout(r, 20); - }); - expect(manager.getTask(taskId)?.status).toBe('completed'); - - manager.markAwaitingApproval(taskId, 'too late'); - // Status and approvalReason unchanged. - const info = manager.getTask(taskId); - expect(info?.status).toBe('completed'); - expect(info?.approvalReason).toBeUndefined(); - }); - - it('stop on an awaiting_approval task flips to killed and clears reason', async () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - manager.markAwaitingApproval(taskId, 'waiting…'); - - const stopped = await manager.stop(taskId); - expect(stopped?.status).toBe('killed'); - expect(stopped?.approvalReason).toBeUndefined(); - }); - - it('list(true) includes awaiting_approval (non-terminal is active)', () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - manager.markAwaitingApproval(taskId, 'waiting…'); - - const active = manager.list(true); - expect(active).toHaveLength(1); - expect(active[0]?.status).toBe('awaiting_approval'); - }); - - it('clearAwaitingApproval on a non-awaiting task is a no-op', () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - // Task is still `running` — nothing to clear. - manager.clearAwaitingApproval(taskId); - expect(manager.getTask(taskId)?.status).toBe('running'); - }); - - // _mark_task_running is a no-op if the task is already in a terminal - // state. Prevents a late approval-resolve from clobbering `killed` - // or `completed`. - it('a state transition does not overwrite a terminal status', async () => { - const { proc, resolve } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - resolve(0); - await new Promise((r) => { - setTimeout(r, 20); - }); - expect(manager.getTask(taskId)?.status).toBe('completed'); - - // Try to flip to awaiting_approval and back — both must be no-ops. - manager.markAwaitingApproval(taskId, 'too late'); - manager.clearAwaitingApproval(taskId); - expect(manager.getTask(taskId)?.status).toBe('completed'); - }); - - // State transitions out of awaiting_approval must clear failure_reason - // (which carried the approval prompt). Both transitions back to - // running AND straight to completed must clear it. - it('leaving awaiting_approval clears the carried approval reason', async () => { - const { proc, resolve } = pendingProcess(); - const taskId = manager.register(proc, 'x', 'd'); - manager.markAwaitingApproval(taskId, 'pending approval prompt'); - expect(manager.getTask(taskId)?.approvalReason).toBe('pending approval prompt'); - - // Path 1: awaiting → running clears reason. - manager.clearAwaitingApproval(taskId); - expect(manager.getTask(taskId)?.approvalReason).toBeUndefined(); - - // Path 2: awaiting → completed must ALSO clear approval reason. - manager.markAwaitingApproval(taskId, 'second prompt'); - resolve(0); - await new Promise((r) => { - setTimeout(r, 20); - }); - const finalInfo = manager.getTask(taskId); - expect(finalInfo?.status).toBe('completed'); - expect(finalInfo?.approvalReason).toBeUndefined(); - }); - - // RunCancelled propagating from the background runner marks the task - // as `killed` (not `failed`) — Ctrl+C is cancel, not failure. The TS - // agent code today maps internal rejections to `failed`; this is the - // py contract that diverges at the agent-runner layer. - it('RunCancelled in an agent run marks the task as killed (not failed)', async () => { - class RunCancelled extends Error { - constructor() { - super('run cancelled'); - this.name = 'RunCancelled'; - } - } - const taskId = manager.registerTask(new AgentBackgroundTask( - Promise.reject(new RunCancelled()), - 'run cancelled bg', - )); - const info = await manager.waitForTerminal(taskId); - expect(info?.status).toBe('killed'); - }); -}); diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index ff4551f1..e9fa428c 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -410,19 +410,6 @@ describe('TaskOutputTool', () => { } }); - it('reports awaiting_approval as not_ready when block=false', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'approval output test'); - manager.markAwaitingApproval(taskId, 'waiting for root approval'); - - const result = await executeTool(tool, context('c_awaiting_output', { task_id: taskId })); - - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('retrieval_status: not_ready'); - expect(content).toContain('status: awaiting_approval'); - }); - it('settles an already-exited process before reporting non-blocking output', async () => { const proc = processExitingAfterTimer(143, 0); const taskId = manager.register(proc, 'sleep 60', 'external kill output test'); @@ -439,20 +426,6 @@ describe('TaskOutputTool', () => { expect(content).toContain('exit_code: 143'); }); - it('waits on awaiting_approval when block=true and reports timeout if still non-terminal', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'approval blocking output test'); - manager.markAwaitingApproval(taskId, 'waiting for root approval'); - - const result = await executeTool(tool, - context('c_awaiting_output_block', { task_id: taskId, block: true, timeout: 0 }), - ); - - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('retrieval_status: timeout'); - expect(content).toContain('status: awaiting_approval'); - }); }); describe('TaskOutputTool — large output truncation + paging protocol', () => { @@ -820,18 +793,6 @@ describe('TaskStopTool', () => { expect(manager.getTask(taskId)?.stopReason).toBe('custom stop reason'); }); - it('stops an awaiting_approval task', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'approval stop test'); - manager.markAwaitingApproval(taskId, 'waiting for root approval'); - - const result = await executeTool(tool, context('c_awaiting_stop', { task_id: taskId })); - - expect(result.isError).toBe(false); - expect(toolContentString(result)).toContain('status: killed'); - expect(manager.getTask(taskId)?.approvalReason).toBeUndefined(); - }); - it('persists stop reason when attached to a session directory', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-reason-')); try { @@ -1215,39 +1176,5 @@ describe('background store — partial output reads (TS surface)', () => { }); }); -// A background agent paused in `awaiting_approval` can be killed via -// the TaskStop tool — the task transitions to `killed`. The -// downstream side effect (clearing pending approvals on the -// ApprovalRuntime) lives outside the BPM in TS by design and is -// covered by ApprovalRuntime's own tests; this test scopes only the -// status transition through the tool boundary. -describe('TaskStopTool on awaiting-approval agents', () => { - const manager = new BackgroundManager(); - const stop = new TaskStopTool(manager); - - afterEach(() => { - manager._reset(); - }); - - it('TaskStop on an awaiting_approval agent transitions the task to killed', async () => { - let rejectCompletion!: (err: unknown) => void; - const completion = new Promise<{ result: string }>((_res, rej) => { - rejectCompletion = rej; - }); - const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'awaiting kill', { - abort: () => { - const abortError = new Error('cancelled'); - abortError.name = 'AbortError'; - rejectCompletion(abortError); - }, - })); - manager.markAwaitingApproval(taskId, 'edit file'); - const result = await executeTool(stop, context('c_stop_awaiting', { task_id: taskId })); - expect(result.isError).toBe(false); - const info = manager.getTask(taskId); - expect(info?.status).toBe('killed'); - }); -}); - // Reuse imports from the top of the file. The helper-suite needs // mkdtemp/tmpdir/join — already imported at the top. From 387bab154e7f31ed36e41e23a5df3dc8357b2bc2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 21:07:20 +0800 Subject: [PATCH 13/21] fix --- apps/kimi-code/src/cli/run-prompt.ts | 1 - .../tui/controllers/session-event-handler.ts | 5 +- .../src/agent/background/agent-task.ts | 11 +--- .../agent-core/src/agent/background/index.ts | 66 ++++--------------- .../src/agent/background/persist.ts | 9 +-- .../src/agent/background/process-task.ts | 21 ++---- .../agent-core/src/agent/background/task.ts | 22 +++++-- packages/agent-core/src/index.ts | 3 - packages/agent-core/src/rpc/events.ts | 6 -- packages/node-sdk/src/events.ts | 1 - packages/node-sdk/src/types.ts | 3 - 11 files changed, 37 insertions(+), 111 deletions(-) diff --git a/apps/kimi-code/src/cli/run-prompt.ts b/apps/kimi-code/src/cli/run-prompt.ts index a542fb27..1d1df97a 100644 --- a/apps/kimi-code/src/cli/run-prompt.ts +++ b/apps/kimi-code/src/cli/run-prompt.ts @@ -389,7 +389,6 @@ function runPromptTurn( case 'agent.status.updated': case 'background.task.started': case 'background.task.terminated': - case 'background.task.updated': case 'compaction.blocked': case 'compaction.cancelled': case 'compaction.completed': diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 3df4644f..0a27d008 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -5,7 +5,6 @@ import type { BackgroundTaskInfo, BackgroundTaskStartedEvent, BackgroundTaskTerminatedEvent, - BackgroundTaskUpdatedEvent, CompactionCancelledEvent, CompactionCompletedEvent, CompactionStartedEvent, @@ -204,7 +203,6 @@ export class SessionEventHandler { case 'subagent.completed': this.handleSubagentCompleted(event); break; case 'subagent.failed': this.handleSubagentFailed(event); break; case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': this.handleBackgroundTaskEvent(event); break; case 'cron.fired': this.handleCronFired(event); break; @@ -279,7 +277,6 @@ export class SessionEventHandler { return true; } case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': case 'compaction.blocked': case 'compaction.cancelled': @@ -874,7 +871,7 @@ export class SessionEventHandler { // --------------------------------------------------------------------------- private handleBackgroundTaskEvent( - event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent, + event: BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent, ): void { const { state } = this.host; const { info } = event; diff --git a/packages/agent-core/src/agent/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts index 63eabd24..912b4ca0 100644 --- a/packages/agent-core/src/agent/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -3,18 +3,11 @@ import { sleep } from '@antfu/utils'; import { errorMessage, isAbortError } from '../../loop/errors'; import { type BackgroundTask, + type BackgroundTaskInfo, type BackgroundTaskInfoBase, type BackgroundTaskSink, } from './task'; -export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { - readonly kind: 'agent'; - /** Subagent identifier accepted by Agent(resume=...). */ - readonly agentId?: string; - /** Subagent profile name. */ - readonly subagentType?: string; -} - export interface AgentBackgroundTaskOptions { readonly timeoutMs?: number; readonly abort?: () => void; @@ -81,7 +74,7 @@ export class AgentBackgroundTask implements BackgroundTask { } } - toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo { + toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo { return { ...base, kind: 'agent', diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index e3aa3999..114eacc7 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -40,13 +40,10 @@ export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean } export { AgentBackgroundTask } from './agent-task'; -export type { AgentBackgroundTaskInfo } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; -export type { ProcessBackgroundTaskInfo } from './process-task'; -export { BackgroundTaskPersistence, VALID_TASK_ID } from './persist'; +export { BackgroundTaskPersistence } from './persist'; export type { BackgroundTaskInfo, - BackgroundTaskKind, BackgroundTaskStatus, } from './task'; @@ -65,8 +62,6 @@ interface ManagedTask { terminalFired: boolean; /** Human-readable reason for the terminal status, when available. */ stopReason?: string | undefined; - /** Deadline supplied at registration; surfaced via task info. */ - timeoutMs?: number | undefined; /** Cancellation signal owned by the manager and observed by the concrete task. */ readonly abortController: AbortController; lifecyclePromise: Promise; @@ -98,7 +93,7 @@ const _ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; * over an 8-char suffix yields ~36^8 ≈ 2.8e12 distinct ids which is * more than enough uniqueness for per-session task ids. */ -export function generateTaskId(kind: string): string { +function generateTaskId(kind: string): string { const bytes = randomBytes(8); let suffix = ''; for (let i = 0; i < 8; i++) { @@ -107,18 +102,6 @@ export function generateTaskId(kind: string): string { return `${kind}-${suffix}`; } -/** - * Terminal-state info for tasks reconciled as lost on resume. They - * have no live KaosProcess and no captured output (the buffer died - * with the previous process), so list/get returns this minimal record. - */ -export interface ReconcileResult { - /** Task IDs that were marked `lost` because their process is gone. */ - readonly lost: readonly string[]; - /** Snapshot of each lost task's persisted info for terminal notifications. */ - readonly lostInfo: readonly BackgroundTaskInfo[]; -} - export interface BackgroundTaskOutputSnapshot { readonly outputPath?: string; readonly outputSizeBytes: number; @@ -200,10 +183,6 @@ export class BackgroundManager { }); } - private emitTaskUpdated(info: BackgroundTaskInfo): void { - this.agent.emitEvent({ type: 'background.task.updated', info }); - } - private emitTaskTerminated(info: BackgroundTaskInfo): void { this.agent.emitEvent({ type: 'background.task.terminated', info }); this.agent.telemetry.track('background_task_completed', { @@ -247,7 +226,6 @@ export class BackgroundManager { waiters: [], terminalFired: false, abortController: new AbortController(), - timeoutMs: task.timeoutMs, lifecyclePromise: Promise.resolve(), persistWriteQueue: Promise.resolve(), outputWriteQueue: Promise.resolve(), @@ -327,7 +305,7 @@ export class BackgroundManager { await this.tasks.get(taskId)?.outputWriteQueue; const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes)); - const persistence = this.persistenceFor(taskId); + const persistence = this.persistence; if (persistence !== undefined && (await persistence.taskOutputExists(taskId))) { const outputSizeBytes = await persistence.taskOutputSizeBytes(taskId); const previewOffset = Math.max(0, outputSizeBytes - previewLimit); @@ -494,10 +472,9 @@ export class BackgroundManager { * Reconcile loaded ghost tasks. Any ghost with status `running` is * reclassified as `lost` (its previous CLI process died without * writing a terminal state). Updates the on-disk record and returns - * the lost task ids so the caller can emit user-facing notifications. + * the lost task snapshots so the caller can emit user-facing notifications. */ - protected async markLoadedTasksLost(): Promise { - const lost: string[] = []; + private async markLoadedTasksLost(): Promise { const lostInfo: BackgroundTaskInfo[] = []; const persistence = this.persistence; for (const [id, info] of this.ghosts) { @@ -512,19 +489,17 @@ export class BackgroundManager { if (persistence !== undefined) { await persistence.writeTask(updated); } - lost.push(id); lostInfo.push(updated); } - return { lost, lostInfo }; + return lostInfo; } - async reconcile(): Promise { - const result = await this.markLoadedTasksLost(); - for (const info of result.lostInfo) { + async reconcile(): Promise { + const lostInfo = await this.markLoadedTasksLost(); + for (const info of lostInfo) { this.emitTaskTerminated(info); } await this.restoreBackgroundTaskNotifications(); - return result; } /** @@ -559,12 +534,6 @@ export class BackgroundManager { .catch(() => { }); } - private persistenceFor(taskId: string): BackgroundTaskPersistence | undefined { - if (this.tasks.has(taskId)) return this.persistence; - if (this.ghosts.has(taskId)) return this.persistence; - return undefined; - } - private async restoreBackgroundTaskNotifications(): Promise { for (const info of this.list(false)) { if (!isBackgroundTaskTerminal(info.status)) continue; @@ -595,25 +564,21 @@ export class BackgroundManager { status: info.status, notificationId: `task:${info.taskId}:${info.status}`, }; - const notificationId = origin.notificationId; const key = notificationKey(origin); if (this.scheduledNotificationKeys.has(key)) return; - if (this.hasDeliveredNotification(origin)) return; + if (this.deliveredNotificationKeys.has(key)) return; this.scheduledNotificationKeys.add(key); const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) .preview; - if (this.hasDeliveredNotification(origin)) return; - const isAgentTask = info.kind === 'agent'; - const label = isAgentTask ? 'agent' : 'task'; const notification: BackgroundTaskNotification = { - id: notificationId, + id: origin.notificationId, category: 'task', type: `task.${info.status}`, source_kind: 'background_task', source_id: info.taskId, agent_id: info.kind === 'agent' ? info.agentId : undefined, - title: `Background ${label} ${info.status}`, + title: `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', body: buildBackgroundTaskNotificationBody(info), tail_output: tailOutput, @@ -646,10 +611,6 @@ export class BackgroundManager { this.deliveredNotificationKeys.add(notificationKey(origin)); } - private hasDeliveredNotification(origin: BackgroundTaskOrigin): boolean { - return this.deliveredNotificationKeys.has(notificationKey(origin)); - } - private async settleTask( entry: ManagedTask, settlement: BackgroundTaskSettlement, @@ -676,13 +637,12 @@ export class BackgroundManager { private toInfo(entry: ManagedTask): BackgroundTaskInfo { const base: BackgroundTaskInfoBase = { taskId: entry.taskId, - kind: entry.task.kind, description: entry.task.description, status: entry.status, startedAt: entry.startedAt, endedAt: entry.endedAt, stopReason: entry.stopReason, - timeoutMs: entry.timeoutMs, + timeoutMs: entry.task.timeoutMs, }; return entry.task.toInfo(base); } diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index c008adf5..de961ea4 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -26,9 +26,9 @@ import type { BackgroundTaskInfo } from './task'; * persistence layer. The prefix is intentionally open-ended so new task * kinds do not need persistence-layer changes. */ -export const VALID_TASK_ID: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; +const VALID_TASK_ID: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; -export type PersistedTask = BackgroundTaskInfo; +type PersistedTask = BackgroundTaskInfo; function tasksDirOf(sessionDir: string): string { return join(sessionDir, 'tasks'); @@ -66,11 +66,6 @@ export class BackgroundTaskPersistence { await this.store.write(task.taskId, task); } - /** Read a single task file. Returns undefined when missing/corrupt. */ - async readTask(taskId: string): Promise { - return this.store.read(taskId); - } - async appendTaskOutput(taskId: string, chunk: string): Promise { const path = this.taskOutputFile(taskId); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); diff --git a/packages/agent-core/src/agent/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts index 39190855..59cd7d46 100644 --- a/packages/agent-core/src/agent/background/process-task.ts +++ b/packages/agent-core/src/agent/background/process-task.ts @@ -3,34 +3,21 @@ import type { KaosProcess } from '@moonshot-ai/kaos'; import { errorMessage } from '../../loop/errors'; import type { BackgroundTask, + BackgroundTaskInfo, BackgroundTaskInfoBase, BackgroundTaskSink, } from './task'; -export interface ProcessBackgroundTaskInfo extends BackgroundTaskInfoBase { - readonly kind: 'process'; - readonly command: string; - readonly pid: number; - readonly exitCode: number | null; -} - -export interface ProcessBackgroundTaskOptions { - readonly idPrefix?: string; -} - export class ProcessBackgroundTask implements BackgroundTask { readonly kind = 'process' as const; - readonly idPrefix: string; + readonly idPrefix = 'bash'; private exitCode: number | null = null; constructor( readonly proc: KaosProcess, readonly command: string, readonly description: string, - options: ProcessBackgroundTaskOptions = {}, - ) { - this.idPrefix = options.idPrefix ?? 'bash'; - } + ) {} async start(sink: BackgroundTaskSink): Promise { for (const stream of [this.proc.stdout, this.proc.stderr]) { @@ -71,7 +58,7 @@ export class ProcessBackgroundTask implements BackgroundTask { await this.proc.kill('SIGKILL'); } - toInfo(base: BackgroundTaskInfoBase): ProcessBackgroundTaskInfo { + toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo { return { ...base, kind: 'process', diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 70f9871d..1b3ec903 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -1,6 +1,3 @@ -import type { AgentBackgroundTaskInfo } from './agent-task'; -import type { ProcessBackgroundTaskInfo } from './process-task'; - export type BackgroundTaskStatus = | 'running' | 'completed' @@ -16,9 +13,6 @@ export const TERMINAL_STATUSES: ReadonlySet = new Set void; export type { AgentReplayRecord, - AgentBackgroundTaskInfo, BackgroundConfig, BackgroundTaskInfo, - BackgroundTaskKind, BackgroundTaskStatus, ContextMessage, ExportSessionManifest, @@ -38,7 +36,6 @@ export type { PluginMcpServerInfo, PluginSource, PluginSummary, - ProcessBackgroundTaskInfo, PromptOrigin, ProviderConfig, ProviderType, From 3d39c6c5d2b6e85fd2e0d55b66706f9a0e48c9d0 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 21:11:45 +0800 Subject: [PATCH 14/21] refactor background task info types --- .../src/agent/background/agent-task.ts | 11 ++++++++-- .../agent-core/src/agent/background/index.ts | 2 ++ .../src/agent/background/process-task.ts | 10 ++++++++-- .../agent-core/src/agent/background/task.ts | 20 +++++-------------- packages/agent-core/src/index.ts | 2 ++ packages/node-sdk/src/types.ts | 2 ++ 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/packages/agent-core/src/agent/background/agent-task.ts b/packages/agent-core/src/agent/background/agent-task.ts index 912b4ca0..63eabd24 100644 --- a/packages/agent-core/src/agent/background/agent-task.ts +++ b/packages/agent-core/src/agent/background/agent-task.ts @@ -3,11 +3,18 @@ import { sleep } from '@antfu/utils'; import { errorMessage, isAbortError } from '../../loop/errors'; import { type BackgroundTask, - type BackgroundTaskInfo, type BackgroundTaskInfoBase, type BackgroundTaskSink, } from './task'; +export interface AgentBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'agent'; + /** Subagent identifier accepted by Agent(resume=...). */ + readonly agentId?: string; + /** Subagent profile name. */ + readonly subagentType?: string; +} + export interface AgentBackgroundTaskOptions { readonly timeoutMs?: number; readonly abort?: () => void; @@ -74,7 +81,7 @@ export class AgentBackgroundTask implements BackgroundTask { } } - toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo { + toInfo(base: BackgroundTaskInfoBase): AgentBackgroundTaskInfo { return { ...base, kind: 'agent', diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 114eacc7..22e9bf2f 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -40,7 +40,9 @@ export function isBackgroundTaskTerminal(status: BackgroundTaskStatus): boolean } export { AgentBackgroundTask } from './agent-task'; +export type { AgentBackgroundTaskInfo } from './agent-task'; export { ProcessBackgroundTask } from './process-task'; +export type { ProcessBackgroundTaskInfo } from './process-task'; export { BackgroundTaskPersistence } from './persist'; export type { BackgroundTaskInfo, diff --git a/packages/agent-core/src/agent/background/process-task.ts b/packages/agent-core/src/agent/background/process-task.ts index 59cd7d46..d1a2e03b 100644 --- a/packages/agent-core/src/agent/background/process-task.ts +++ b/packages/agent-core/src/agent/background/process-task.ts @@ -3,11 +3,17 @@ import type { KaosProcess } from '@moonshot-ai/kaos'; import { errorMessage } from '../../loop/errors'; import type { BackgroundTask, - BackgroundTaskInfo, BackgroundTaskInfoBase, BackgroundTaskSink, } from './task'; +export interface ProcessBackgroundTaskInfo extends BackgroundTaskInfoBase { + readonly kind: 'process'; + readonly command: string; + readonly pid: number; + readonly exitCode: number | null; +} + export class ProcessBackgroundTask implements BackgroundTask { readonly kind = 'process' as const; readonly idPrefix = 'bash'; @@ -58,7 +64,7 @@ export class ProcessBackgroundTask implements BackgroundTask { await this.proc.kill('SIGKILL'); } - toInfo(base: BackgroundTaskInfoBase): BackgroundTaskInfo { + toInfo(base: BackgroundTaskInfoBase): ProcessBackgroundTaskInfo { return { ...base, kind: 'process', diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 1b3ec903..25a7c3ac 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -1,3 +1,6 @@ +import type { AgentBackgroundTaskInfo } from './agent-task'; +import type { ProcessBackgroundTaskInfo } from './process-task'; + export type BackgroundTaskStatus = | 'running' | 'completed' @@ -33,20 +36,7 @@ export interface BackgroundTaskInfoBase { readonly timeoutMs?: number; } -export type BackgroundTaskInfo = - | (BackgroundTaskInfoBase & { - readonly kind: 'process'; - readonly command: string; - readonly pid: number; - readonly exitCode: number | null; - }) - | (BackgroundTaskInfoBase & { - readonly kind: 'agent'; - /** Subagent identifier accepted by Agent(resume=...). */ - readonly agentId?: string; - /** Subagent profile name. */ - readonly subagentType?: string; - }); +export type BackgroundTaskInfo = ProcessBackgroundTaskInfo | AgentBackgroundTaskInfo; export interface BackgroundTaskSink { readonly signal: AbortSignal; @@ -56,7 +46,7 @@ export interface BackgroundTaskSink { export interface BackgroundTask { readonly idPrefix: string; - readonly kind: string; + readonly kind: BackgroundTaskInfo['kind']; readonly description: string; readonly timeoutMs?: number; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index e28f6cac..b46704f4 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -35,8 +35,10 @@ export type { UserPromptOrigin, } from './agent/context'; export type { + AgentBackgroundTaskInfo, BackgroundTaskInfo, BackgroundTaskStatus, + ProcessBackgroundTaskInfo, } from './agent/background'; export type { ToolServices } from './tools/support/services'; export { SingleModelProvider } from './session/provider-manager'; diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 9f17f32c..fc4fe4cb 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -17,6 +17,7 @@ export type Unsubscribe = () => void; export type { AgentReplayRecord, + AgentBackgroundTaskInfo, BackgroundConfig, BackgroundTaskInfo, BackgroundTaskStatus, @@ -36,6 +37,7 @@ export type { PluginMcpServerInfo, PluginSource, PluginSummary, + ProcessBackgroundTaskInfo, PromptOrigin, ProviderConfig, ProviderType, From 04612baa99731501b1c3715ff13341b3653f42cb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 21:54:08 +0800 Subject: [PATCH 15/21] compat legacy background task persistence --- .../src/agent/background/persist.ts | 114 +++++++++++++++- .../background-persistence-compat.test.ts | 126 ++++++++++++++++++ 2 files changed, 235 insertions(+), 5 deletions(-) create mode 100644 packages/agent-core/test/agent/background-persistence-compat.test.ts diff --git a/packages/agent-core/src/agent/background/persist.ts b/packages/agent-core/src/agent/background/persist.ts index de961ea4..290521df 100644 --- a/packages/agent-core/src/agent/background/persist.ts +++ b/packages/agent-core/src/agent/background/persist.ts @@ -16,7 +16,7 @@ import { appendFile, mkdir, open, stat } from 'node:fs/promises'; import { dirname, join } from 'pathe'; import { createPerIdJsonStore, type PerIdJsonStore } from '../../utils/per-id-json-store'; -import type { BackgroundTaskInfo } from './task'; +import type { BackgroundTaskInfo, BackgroundTaskStatus } from './task'; /** * Task id format: `{prefix}-{8 chars of [0-9a-z]}`. @@ -30,6 +30,8 @@ const VALID_TASK_ID: RegExp = /^[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-z]{8}$/; type PersistedTask = BackgroundTaskInfo; +type DiskPersistedTask = PersistedTask | LegacyPersistedTask; + function tasksDirOf(sessionDir: string): string { return join(sessionDir, 'tasks'); } @@ -46,13 +48,14 @@ function taskOutputFile(sessionDir: string, taskId: string): string { } export class BackgroundTaskPersistence { - private readonly store: PerIdJsonStore; + private readonly store: PerIdJsonStore; constructor(private readonly sessionDir: string) { - this.store = createPerIdJsonStore({ + this.store = createPerIdJsonStore({ rootDir: sessionDir, subdir: 'tasks', idRegex: VALID_TASK_ID, + isValid: isReadablePersistedTask, entityName: 'task id', }); } @@ -66,6 +69,12 @@ export class BackgroundTaskPersistence { await this.store.write(task.taskId, task); } + /** Read a single task file. Returns undefined when missing/corrupt/unrecognized. */ + async readTask(taskId: string): Promise { + const task = await this.store.read(taskId); + return task === undefined ? undefined : normalizePersistedTask(task); + } + async appendTaskOutput(taskId: string, chunk: string): Promise { const path = this.taskOutputFile(taskId); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); @@ -139,13 +148,108 @@ export class BackgroundTaskPersistence { * - basenames that don't match `VALID_TASK_ID` (stray files, legacy * `bg_*` leftovers, partially-written temp files); * - files that fail to read / parse; - * - files that are not valid JSON. + * - records that are neither identifiable as the current camelCase + * shape nor the previous snake_case task shape. + * + * Legacy snake_case records are normalized to current `BackgroundTaskInfo` + * in memory. The next lifecycle/reconcile write stores them back in the + * current format, so compatibility is read-only and opportunistically + * migrates without a separate migration step. * * `writeTask` uses atomic temp+rename so a genuinely truncated file in * production is rare; if it happens we accept the loss rather than * emit a ghost with no recoverable metadata beyond the filename. */ async listTasks(): Promise { - return this.store.list(); + const tasks = await this.store.list(); + return tasks.map(normalizePersistedTask); + } +} + +function normalizePersistedTask(task: DiskPersistedTask): PersistedTask { + if (isLegacyPersistedTask(task)) return legacyPersistedTaskToInfo(task); + return task; +} + +type LegacyBackgroundTaskStatus = + | 'running' + | 'awaiting_approval' + | 'completed' + | 'failed' + | 'killed' + | 'lost'; + +interface LegacyPersistedTask { + readonly task_id: string; + readonly command: string; + readonly description: string; + readonly pid: number; + readonly started_at: number; + readonly ended_at: number | null; + readonly exit_code: number | null; + readonly status: LegacyBackgroundTaskStatus; + readonly timed_out?: boolean; + readonly stop_reason?: string; + readonly timeout_ms?: number; + readonly agent_id?: string; + readonly subagent_type?: string; +} + +function legacyPersistedTaskToInfo(task: LegacyPersistedTask): PersistedTask { + const status = legacyStatusToCurrent(task); + const stopReason = optionalNonEmptyString(task.stop_reason); + const timeoutMs = typeof task.timeout_ms === 'number' ? task.timeout_ms : undefined; + const base = { + taskId: task.task_id, + description: task.description, + status, + startedAt: task.started_at, + endedAt: task.ended_at, + stopReason, + timeoutMs, + }; + + if (task.task_id.startsWith('agent-')) { + return { + ...base, + kind: 'agent', + agentId: optionalNonEmptyString(task.agent_id), + subagentType: optionalNonEmptyString(task.subagent_type), + }; } + + return { + ...base, + kind: 'process', + command: task.command, + pid: task.pid, + exitCode: task.exit_code, + }; +} + +function legacyStatusToCurrent(task: LegacyPersistedTask): BackgroundTaskStatus { + if (task.status === 'awaiting_approval') return 'running'; + if (task.status === 'failed' && task.timed_out === true) return 'timed_out'; + return task.status; +} + +function isReadablePersistedTask(obj: unknown): obj is DiskPersistedTask { + return ( + isRecord(obj) && + (typeof obj['taskId'] === 'string' || typeof obj['task_id'] === 'string') + ); +} + +function isLegacyPersistedTask(task: DiskPersistedTask): task is LegacyPersistedTask { + return 'task_id' in task; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function optionalNonEmptyString(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } diff --git a/packages/agent-core/test/agent/background-persistence-compat.test.ts b/packages/agent-core/test/agent/background-persistence-compat.test.ts new file mode 100644 index 00000000..32d963ab --- /dev/null +++ b/packages/agent-core/test/agent/background-persistence-compat.test.ts @@ -0,0 +1,126 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BackgroundManager, BackgroundTaskPersistence } from '../../src/agent/background'; + +let sessionDir: string; + +beforeEach(async () => { + sessionDir = join( + tmpdir(), + `kimi-bg-persist-compat-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(join(sessionDir, 'tasks'), { recursive: true }); +}); + +afterEach(async () => { + await rm(sessionDir, { recursive: true, force: true }); +}); + +async function writeLegacyTask(taskId: string, task: Record): Promise { + await writeFile(join(sessionDir, 'tasks', `${taskId}.json`), JSON.stringify(task), 'utf-8'); +} + +describe('BackgroundTaskPersistence legacy compatibility', () => { + it('normalizes legacy snake_case process task records', async () => { + await writeLegacyTask('bash-legacy01', { + task_id: 'bash-legacy01', + command: 'sleep 60', + description: 'legacy shell task', + pid: 12345, + started_at: 1_700_000_000, + ended_at: null, + exit_code: null, + status: 'running', + }); + + const persistence = new BackgroundTaskPersistence(sessionDir); + + expect(await persistence.readTask('bash-legacy01')).toMatchObject({ + taskId: 'bash-legacy01', + kind: 'process', + command: 'sleep 60', + description: 'legacy shell task', + pid: 12345, + startedAt: 1_700_000_000, + endedAt: null, + exitCode: null, + status: 'running', + }); + }); + + it('normalizes legacy timed-out agent records', async () => { + await writeLegacyTask('agent-timeout1', { + task_id: 'agent-timeout1', + command: '[agent] slow task', + description: 'slow legacy agent', + pid: 0, + started_at: 1_700_000_000, + ended_at: 1_700_000_100, + exit_code: 1, + status: 'failed', + timed_out: true, + stop_reason: 'deadline', + agent_id: 'agent-session-id', + subagent_type: 'reviewer', + }); + + const persistence = new BackgroundTaskPersistence(sessionDir); + const tasks = await persistence.listTasks(); + + expect(tasks).toHaveLength(1); + expect(tasks[0]).toMatchObject({ + taskId: 'agent-timeout1', + kind: 'agent', + description: 'slow legacy agent', + startedAt: 1_700_000_000, + endedAt: 1_700_000_100, + status: 'timed_out', + stopReason: 'deadline', + agentId: 'agent-session-id', + subagentType: 'reviewer', + }); + }); + + it('migrates legacy records through load/reconcile writeback', async () => { + await writeLegacyTask('bash-orphan01', { + task_id: 'bash-orphan01', + command: 'sleep 60', + description: 'legacy orphan', + pid: 12345, + started_at: 1_700_000_000, + ended_at: null, + exit_code: null, + status: 'running', + }); + + const persistence = new BackgroundTaskPersistence(sessionDir); + const agent = { + emitEvent: vi.fn(), + telemetry: { track: vi.fn() }, + context: { appendUserMessage: vi.fn() }, + turn: { steer: vi.fn() }, + hooks: undefined, + }; + const manager = new BackgroundManager(agent as never, persistence); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(manager.getTask('bash-orphan01')).toMatchObject({ + taskId: 'bash-orphan01', + kind: 'process', + status: 'lost', + }); + const raw = JSON.parse( + await readFile(join(sessionDir, 'tasks', 'bash-orphan01.json'), 'utf-8'), + ) as Record; + expect(raw['taskId']).toBe('bash-orphan01'); + expect(raw['task_id']).toBeUndefined(); + expect(raw['kind']).toBe('process'); + expect(raw['status']).toBe('lost'); + }); +}); From 98f49657f9440b28fef5787de87a8b5854e9e94e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 22:19:04 +0800 Subject: [PATCH 16/21] test: update background task manager coverage --- .../test/agent/background-manager.test.ts | 679 ---------- .../background/agent-timeout.test.ts | 41 +- .../agent/background/heartbeat-stale.test.ts | 78 ++ .../test/agent/background/helpers.ts | 101 ++ .../test/agent/background/ids.test.ts | 65 + .../test/agent/background/manager.test.ts | 511 +++++++ .../agent/background/output-access.test.ts | 133 ++ .../test/agent/background/persist.test.ts | 161 +++ .../persistence-compat.test.ts} | 2 +- .../test/agent/background/reconcile.test.ts | 224 +++ .../test/agent/background/rpc-events.test.ts | 591 ++++++++ .../agent/bg-idle-notification-repro.test.ts | 42 +- .../agent-core/test/agent/harness/agent.ts | 2 + packages/agent-core/test/agent/resume.test.ts | 41 +- .../test/session/lifecycle-hooks.test.ts | 9 +- packages/agent-core/test/tools/agent.test.ts | 25 +- .../tools/background/heartbeat-stale.test.ts | 88 -- .../test/tools/background/ids.test.ts | 75 - .../test/tools/background/manager.test.ts | 749 ---------- .../tools/background/output-access.test.ts | 141 -- .../test/tools/background/persist.test.ts | 276 ---- .../test/tools/background/reconcile.test.ts | 292 ---- .../test/tools/background/task-tools.test.ts | 1203 ++++------------- packages/agent-core/test/tools/bash.test.ts | 72 +- .../test/tools/builtin-current.test.ts | 4 +- 25 files changed, 2280 insertions(+), 3325 deletions(-) delete mode 100644 packages/agent-core/test/agent/background-manager.test.ts rename packages/agent-core/test/{tools => agent}/background/agent-timeout.test.ts (83%) create mode 100644 packages/agent-core/test/agent/background/heartbeat-stale.test.ts create mode 100644 packages/agent-core/test/agent/background/helpers.ts create mode 100644 packages/agent-core/test/agent/background/ids.test.ts create mode 100644 packages/agent-core/test/agent/background/manager.test.ts create mode 100644 packages/agent-core/test/agent/background/output-access.test.ts create mode 100644 packages/agent-core/test/agent/background/persist.test.ts rename packages/agent-core/test/agent/{background-persistence-compat.test.ts => background/persistence-compat.test.ts} (99%) create mode 100644 packages/agent-core/test/agent/background/reconcile.test.ts create mode 100644 packages/agent-core/test/agent/background/rpc-events.test.ts delete mode 100644 packages/agent-core/test/tools/background/heartbeat-stale.test.ts delete mode 100644 packages/agent-core/test/tools/background/ids.test.ts delete mode 100644 packages/agent-core/test/tools/background/manager.test.ts delete mode 100644 packages/agent-core/test/tools/background/output-access.test.ts delete mode 100644 packages/agent-core/test/tools/background/persist.test.ts delete mode 100644 packages/agent-core/test/tools/background/reconcile.test.ts diff --git a/packages/agent-core/test/agent/background-manager.test.ts b/packages/agent-core/test/agent/background-manager.test.ts deleted file mode 100644 index f0e93c74..00000000 --- a/packages/agent-core/test/agent/background-manager.test.ts +++ /dev/null @@ -1,679 +0,0 @@ -/** - * Covers: BackgroundManager (the agent-aware subclass). - * - * Confirms that BPM lifecycle transitions are translated into - * agent.emitEvent({ type: 'background.task.*' }) so SDK / TUI - * subscribers can react in real time without polling. - */ - -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { Readable } from 'node:stream'; -import type { Writable } from 'node:stream'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { AgentBackgroundTask, BackgroundManager } from '../../src/agent/background'; -import type { AgentEvent } from '../../src/rpc/events'; -import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; - -interface FakeAgent { - emitEvent: (event: AgentEvent) => void; - emittedEvents: AgentEvent[]; - hooks?: { fireAndForgetTrigger: ReturnType }; - turn: { - hasActiveTurn: boolean; - waitForCurrentTurn: () => Promise; - steer: (...args: unknown[]) => number | null; - }; - context: { appendUserMessage: (...args: unknown[]) => void }; - records: { restoring: boolean; logRecord: (record: unknown) => void }; - telemetry: { track: ReturnType }; - background: BackgroundManager; -} - -function makeAgent(options: { hooks?: FakeAgent['hooks'] } = {}): FakeAgent { - const emitted: AgentEvent[] = []; - const agent = { - emittedEvents: emitted, - emitEvent: (event: AgentEvent) => { - emitted.push(event); - }, - hooks: options.hooks, - turn: { - hasActiveTurn: false, - waitForCurrentTurn: vi.fn(() => Promise.resolve()), - steer: vi.fn(() => 1), - }, - context: { appendUserMessage: vi.fn() }, - records: { restoring: false, logRecord: vi.fn() }, - telemetry: { track: vi.fn() }, - } as unknown as FakeAgent; - const manager = new BackgroundManager(agent as never); - agent.background = manager; - return agent; -} - -function immediateProcess(exitCode: number): KaosProcess { - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 30000 + exitCode, - exitCode, - wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; -} - -function pendingProcess(): KaosProcess { - let resolveWait: (code: number) => void = () => {}; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 99999, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: vi.fn(async () => { - if (currentExitCode === null) { - currentExitCode = 143; - resolveWait(143); - } - }) as unknown as KaosProcess['kill'], - }; -} - -describe('BackgroundManager — RPC event emission', () => { - let agent: FakeAgent; - - beforeEach(() => { - agent = makeAgent(); - }); - - afterEach(() => { - agent.background._reset(); - }); - - it('emits background.task.started on register()', () => { - const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'demo'); - - const started = agent.emittedEvents.filter((e) => e.type === 'background.task.started'); - expect(started.length).toBe(1); - expect(started[0]!.info.taskId).toBe(taskId); - expect(started[0]!.info.status).toBe('running'); - expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { - kind: 'bash', - }); - }); - - it('emits background.task.started on registerAgentTask()', () => { - const taskId = agent.background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'agent task')); - - const started = agent.emittedEvents.filter((e) => e.type === 'background.task.started'); - expect(started.length).toBe(1); - expect(started[0]!.info.taskId).toBe(taskId); - expect(taskId).toMatch(/^agent-/); - expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { - kind: 'agent', - }); - }); - - it('emits background.task.terminated on natural exit', async () => { - agent.background.register(immediateProcess(0), 'echo', 'done'); - await new Promise((r) => setTimeout(r, 20)); - - const terminated = agent.emittedEvents.filter((e) => e.type === 'background.task.terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('completed'); - }); - - it('tracks successful task completion with duration and no reason', async () => { - const taskId = agent.background.register(immediateProcess(0), 'echo ok', 'done'); - agent.telemetry.track.mockClear(); - - await agent.background.waitForTerminal(taskId); - - expect(agent.telemetry.track).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ - kind: 'bash', - success: true, - duration_s: expect.any(Number), - }), - ); - expect(agent.telemetry.track.mock.calls[0]?.[1]).not.toHaveProperty('reason'); - }); - - it('tracks failed task completion with reason=error', async () => { - const taskId = agent.background.register(immediateProcess(1), 'false', 'failed'); - agent.telemetry.track.mockClear(); - - await agent.background.waitForTerminal(taskId); - - expect(agent.telemetry.track).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ - kind: 'bash', - success: false, - reason: 'error', - duration_s: expect.any(Number), - }), - ); - }); - - it('tracks timed-out agent tasks with reason=timeout', async () => { - const taskId = agent.background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'slow agent', { - timeoutMs: 1, - })); - agent.telemetry.track.mockClear(); - - await agent.background.waitForTerminal(taskId); - - expect(agent.telemetry.track).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ - kind: 'agent', - success: false, - reason: 'timeout', - duration_s: expect.any(Number), - }), - ); - }); - - it('emits background.task.terminated on stop()', async () => { - const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long'); - agent.emittedEvents.length = 0; - - await agent.background.stop(taskId, 'user'); - - const terminated = agent.emittedEvents.filter((e) => e.type === 'background.task.terminated'); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info.status).toBe('killed'); - }); - - it('emits background.task.terminated when a restored task is marked lost', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-reconcile-')); - try { - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'bash-orphan00', - command: 'sleep 60', - description: 'orphan task', - pid: 99999, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - agent.emittedEvents.length = 0; - - await agent.background.loadFromDisk(); - await agent.background.reconcile(); - - const terminated = agent.emittedEvents.filter( - (e) => e.type === 'background.task.terminated', - ); - expect(terminated.length).toBe(1); - expect(terminated[0]!.info.taskId).toBe('bash-orphan00'); - expect(terminated[0]!.info.status).toBe('lost'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('steers completed agent task notifications into the turn flow', async () => { - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'final subagent summary' }), - 'agent task', - )); - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); - expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); - - const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId, - status: 'completed', - notificationId: `task:${taskId}:completed`, - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain( - 'Background agent completed', - ); - expect((content as Array<{ text: string }>)[0]!.text).toContain('final subagent summary'); - }); - - it('steers completed bash task notifications into the turn flow', async () => { - const taskId = agent.background.register(immediateProcess(0), 'echo ok', 'shell task'); - - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); - expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); - - const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId, - status: 'completed', - notificationId: `task:${taskId}:completed`, - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain( - 'Background task completed', - ); - expect((content as Array<{ text: string }>)[0]!.text).toContain('shell task completed.'); - }); - - it('steers stopped bash task notifications into the turn flow', async () => { - const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long shell task'); - - await agent.background.stop(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalledTimes(1); - }); - const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId, - status: 'killed', - notificationId: `task:${taskId}:killed`, - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain('Background task killed'); - expect((content as Array<{ text: string }>)[0]!.text).toContain('long shell task killed.'); - }); - - it('queues background agent notifications without waiting for an active turn', async () => { - agent.turn.hasActiveTurn = true; - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'active turn summary' }), - 'agent task', - )); - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.waitForCurrentTurn).not.toHaveBeenCalled(); - const [content, origin] = vi.mocked(agent.turn.steer).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId, - status: 'completed', - notificationId: `task:${taskId}:completed`, - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain('active turn summary'); - }); - - it('replays restored terminal agent task notifications when they were not delivered', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); - try { - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'agent-done0000', - command: '[agent] restored task', - description: 'restored task', - pid: 0, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - await appendTaskOutput(sessionDir, 'agent-done0000', 'restored subagent summary'); - - await agent.background.loadFromDisk(); - const result = await agent.background.reconcile(); - - expect(result.lost).toEqual([]); - await vi.waitFor(() => { - expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.steer).not.toHaveBeenCalled(); - const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId: 'agent-done0000', - status: 'completed', - notificationId: 'task:agent-done0000:completed', - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain( - 'Background agent completed', - ); - expect((content as Array<{ text: string }>)[0]!.text).toContain('restored subagent summary'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('replays restored terminal bash task notifications when they were not delivered', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-replay-')); - try { - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'bash-done0000', - command: 'echo done', - description: 'restored shell task', - pid: 12345, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - await appendTaskOutput(sessionDir, 'bash-done0000', 'restored shell output'); - - await agent.background.loadFromDisk(); - const result = await agent.background.reconcile(); - - expect(result.lost).toEqual([]); - await vi.waitFor(() => { - expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.steer).not.toHaveBeenCalled(); - const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId: 'bash-done0000', - status: 'completed', - notificationId: 'task:bash-done0000:completed', - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain( - 'Background task completed', - ); - expect((content as Array<{ text: string }>)[0]!.text).toContain('restored shell output'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('reads only a bounded output tail for restored bash task notifications', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-')); - try { - const taskId = 'bash-large000'; - const largeOutput = `early-output-marker\n${'x'.repeat(8_000)}\nfinal output line`; - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: taskId, - command: 'generate large output', - description: 'large shell task', - pid: 12345, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - await appendTaskOutput(sessionDir, taskId, largeOutput); - const readOutputSpy = vi.spyOn(agent.background, 'readOutput'); - const snapshotSpy = vi.spyOn(agent.background, 'getOutputSnapshot'); - - await agent.background.loadFromDisk(); - await agent.background.reconcile(); - - await vi.waitFor(() => { - expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); - }); - expect(readOutputSpy).not.toHaveBeenCalled(); - expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number)); - expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length); - const [content] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; - const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).toContain('final output line'); - expect(text).not.toContain('early-output-marker'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('does not replay restored agent task notifications already marked delivered', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); - try { - const origin = { - kind: 'background_task', - taskId: 'agent-seen0000', - status: 'completed', - notificationId: 'task:agent-seen0000:completed', - } as const; - agent.background.markDeliveredNotification(origin); - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'agent-seen0000', - command: '[agent] already delivered', - description: 'already delivered', - pid: 0, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - await appendTaskOutput(sessionDir, 'agent-seen0000', 'already delivered summary'); - - await agent.background.loadFromDisk(); - await agent.background.reconcile(); - - expect(agent.turn.steer).not.toHaveBeenCalled(); - expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('does not double-notify newly lost restored agent tasks', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); - try { - agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'agent-run00000', - command: '[agent] interrupted task', - description: 'interrupted task', - pid: 0, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - - await agent.background.loadFromDisk(); - const result = await agent.background.reconcile(); - - expect(result.lost).toEqual(['agent-run00000']); - await vi.waitFor(() => { - expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); - }); - expect(agent.turn.steer).not.toHaveBeenCalled(); - const [content, origin] = vi.mocked(agent.context.appendUserMessage).mock.calls[0]!; - expect(origin).toEqual({ - kind: 'background_task', - taskId: 'agent-run00000', - status: 'lost', - notificationId: 'task:agent-run00000:lost', - }); - expect((content as Array<{ text: string }>)[0]!.text).toContain('Background agent lost'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('fires a Notification hook when a background agent notification is delivered', async () => { - const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); - agent = makeAgent({ hooks: { fireAndForgetTrigger } }); - - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'final agent output' }), - 'inspect repository', - )); - await agent.background.wait(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - expect(fireAndForgetTrigger).toHaveBeenCalled(); - }); - expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { - matcherValue: 'task.completed', - inputData: { - sink: 'context', - notificationType: 'task.completed', - title: 'Background agent completed', - body: 'inspect repository completed.', - severity: 'info', - sourceKind: 'background_task', - sourceId: taskId, - }, - }); - }); - - it('does not let Notification hook failures interrupt background notification delivery', async () => { - const fireAndForgetTrigger = vi.fn(() => { - throw new Error('notification hook failed'); - }); - agent = makeAgent({ hooks: { fireAndForgetTrigger } }); - - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'final agent output' }), - 'inspect repository', - )); - await agent.background.wait(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - expect(fireAndForgetTrigger).toHaveBeenCalled(); - }); - expect(agent.turn.steer).toHaveBeenCalledWith( - [ - { - type: 'text', - text: expect.stringContaining(`source_id="${taskId}"`), - }, - ], - { - kind: 'background_task', - taskId, - status: 'completed', - notificationId: `task:${taskId}:completed`, - }, - ); - }); - - it('fires Notification hooks for bash background task notifications', async () => { - const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); - agent = makeAgent({ hooks: { fireAndForgetTrigger } }); - - const taskId = agent.background.register(immediateProcess(0), 'echo', 'done'); - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - expect(fireAndForgetTrigger).toHaveBeenCalled(); - }); - expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); - expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { - matcherValue: 'task.completed', - inputData: { - sink: 'context', - notificationType: 'task.completed', - title: 'Background task completed', - body: 'done completed.', - severity: 'info', - sourceKind: 'background_task', - sourceId: taskId, - }, - }); - }); - - it('tracks stopped tasks as killed even without a stop reason', async () => { - const taskId = agent.background.register(pendingProcess(), 'sleep 60', 'long'); - agent.telemetry.track.mockClear(); - - await agent.background.stop(taskId); - - expect(agent.telemetry.track).toHaveBeenCalledWith( - 'background_task_completed', - expect.objectContaining({ - success: false, - reason: 'killed', - }), - ); - }); - - describe('agent task failure body — actionable recovery instructions', () => { - // For agent-* tasks that end non-successfully (lost / failed / killed), - // the notification body must carry enough information for the LLM to - // recover via `Agent(resume=...)` without digging through old context. - // Three things must land in the body: - // 1. The agent_id (NOT the task_id / source_id) — that is what - // `subagentHost.resume` actually takes. - // 2. An explicit disambiguation between agent_id and source_id — - // they look alike and the LLM regularly confuses them. - // 3. The notification must also surface agent_id as a structural - // XML attribute, not just buried in prose. - it('failed agent task body includes resume instructions with the correct agent_id', async () => { - // Promise.reject (non-AbortError) routes through the registerAgentTask - // `.catch` branch and lands at status `failed`, which is the same - // agent-* failure branch reconcile uses for `lost` tasks. - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.reject(new Error('subagent crashed')), - 'inspect repository', - { agentId: 'agent-7' }, - )); - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - }); - const [content] = vi.mocked(agent.turn.steer).mock.calls[0]!; - const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).toContain('agent_id="agent-7"'); - expect(text).toMatch(/Agent\(resume="agent-7"/); - expect(text).toMatch(/agent_id.*not.*source_id|source_id.*not.*agent_id/i); - }); - - it('completed agent task body does NOT add resume instructions', async () => { - const taskId = agent.background.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'all good' }), - 'inspect repository', - { agentId: 'agent-8' }, - )); - await agent.background.wait(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - }); - const [content] = vi.mocked(agent.turn.steer).mock.calls[0]!; - const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).toContain('agent_id="agent-8"'); - // Recovery prose belongs to failure bodies only. - expect(text).not.toMatch(/Agent\(resume="agent-8"/); - }); - - it('bash task body never mentions resume — bash background tasks are not resumable', async () => { - const taskId = agent.background.register(immediateProcess(1), 'false', 'shell'); - await agent.background.waitForTerminal(taskId); - - await vi.waitFor(() => { - expect(agent.turn.steer).toHaveBeenCalled(); - }); - const [content] = vi.mocked(agent.turn.steer).mock.calls[0]!; - const text = (content as Array<{ text: string }>)[0]!.text; - expect(text).not.toContain('agent_id='); - expect(text).not.toMatch(/Agent\(resume=/); - }); - }); - - // Note: the `records.restoring` guard is enforced inside `Agent.emitEvent` - // (see agent/index.ts). BackgroundManager unconditionally forwards - // lifecycle events to the agent; suppression is the agent's job. -}); diff --git a/packages/agent-core/test/tools/background/agent-timeout.test.ts b/packages/agent-core/test/agent/background/agent-timeout.test.ts similarity index 83% rename from packages/agent-core/test/tools/background/agent-timeout.test.ts rename to packages/agent-core/test/agent/background/agent-timeout.test.ts index e4532251..ef7a5063 100644 --- a/packages/agent-core/test/tools/background/agent-timeout.test.ts +++ b/packages/agent-core/test/agent/background/agent-timeout.test.ts @@ -11,17 +11,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; +import { AgentBackgroundTask } from '../../../src/agent/background'; +import { createBackgroundManager } from './helpers'; describe('AgentBackgroundTask — timeoutMs', () => { - const manager = new BackgroundManager(); - afterEach(() => { - manager._reset(); vi.useRealTimers(); }); it('external deadline marks task timed_out', async () => { + const { manager } = createBackgroundManager(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); // A never-resolving completion — only the deadline will fire. const hangForever = new Promise<{ result: string }>(() => {}); @@ -29,7 +28,7 @@ describe('AgentBackgroundTask — timeoutMs', () => { // Advance past the deadline; awaitTerminal resolves once the race // finishes and the `.finally` block runs. - const terminalPromise = manager.waitForTerminal(taskId); + const terminalPromise = manager.wait(taskId); await vi.advanceTimersByTimeAsync(2_100); const info = await terminalPromise; @@ -38,6 +37,7 @@ describe('AgentBackgroundTask — timeoutMs', () => { }); it('omitting timeoutMs lets the task run to completion (no wrapper)', async () => { + const { manager } = createBackgroundManager(); let resolveFn!: (r: { result: string }) => void; const completion = new Promise<{ result: string }>((res) => { resolveFn = res; @@ -45,12 +45,13 @@ describe('AgentBackgroundTask — timeoutMs', () => { const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'no deadline')); resolveFn({ result: 'finished' }); - const info = await manager.waitForTerminal(taskId); + const info = await manager.wait(taskId); expect(info?.status).toBe('completed'); expect(info?.stopReason).toBeUndefined(); }); - it('internal TimeoutError rejection = generic failure, stop reason unset', async () => { + it('internal TimeoutError rejection = generic failure with error reason', async () => { + const { manager } = createBackgroundManager(); // Even with a deadline set, an internal TimeoutError that fires // BEFORE the deadline must land as a plain `failed` (not as a // deadline-driven timeout). @@ -61,10 +62,12 @@ describe('AgentBackgroundTask — timeoutMs', () => { timeoutMs: 900_000, })); - const info = await manager.waitForTerminal(taskId); + const info = await manager.wait(taskId); expect(info?.status).toBe('failed'); - // Deadline never fired → timeout stop reason must NOT be set. - expect(info?.stopReason).toBeUndefined(); + // Deadline never fired: this is a normal task failure, so the original + // error is preserved as the stop reason rather than being reported as a + // caller-driven timeout. + expect(info?.stopReason).toBe('aiohttp sock_read timeout'); }); // Explicit per-task timeoutMs must be surfaced on the task info so @@ -76,6 +79,7 @@ describe('AgentBackgroundTask — timeoutMs', () => { // the `completion` promise here never resolves, so the lifecycle // promise's `.finally(clearTimeout)` would not run under real time. it('explicit timeoutMs is persisted on the task info', () => { + const { manager } = createBackgroundManager(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'persist timeout', { timeoutMs: 1_800_000, @@ -97,6 +101,7 @@ describe('AgentBackgroundTask — timeoutMs', () => { // guard: if someone later adds a hard-coded default in // registerAgentTask, the assertion below catches it. it('omitted timeoutMs leaves the task info field undefined', () => { + const { manager } = createBackgroundManager(); const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'default timeout')); const info = manager.getTask(taskId); expect((info as unknown as { timeoutMs?: number }).timeoutMs).toBeUndefined(); @@ -110,6 +115,7 @@ describe('AgentBackgroundTask — timeoutMs', () => { // zero so a caller writing `0` does not lose its task to an // immediate kill. it('timeoutMs=0 is preserved on the task info and does not arm a deadline', async () => { + const { manager } = createBackgroundManager(); const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'zero timeout', { timeoutMs: 0, })); @@ -121,16 +127,11 @@ describe('AgentBackgroundTask — timeoutMs', () => { // with a short race so the test does not hang on the never- // settling completion promise; the racing branch winning is the // expected outcome. - const raced = await Promise.race<{ status: string; stopReason?: string } | undefined>([ - manager.waitForTerminal(taskId).then((info) => - info === undefined ? undefined : { status: info.status, stopReason: info.stopReason }, - ), - new Promise<{ status: string; stopReason?: string }>((res) => { - setTimeout(() => { - res({ status: 'running' }); - }, 100); - }), - ]); + const info = await manager.wait(taskId, 5); + const raced = info === undefined ? undefined : { + status: info.status, + stopReason: info.stopReason, + }; expect(raced?.status).toBe('running'); expect(raced?.stopReason).toBeUndefined(); }); diff --git a/packages/agent-core/test/agent/background/heartbeat-stale.test.ts b/packages/agent-core/test/agent/background/heartbeat-stale.test.ts new file mode 100644 index 00000000..f25dad37 --- /dev/null +++ b/packages/agent-core/test/agent/background/heartbeat-stale.test.ts @@ -0,0 +1,78 @@ +/** + * Reconcile marks running persisted tasks from a prior process as lost. + */ + +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BackgroundTaskPersistence, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; +import { createBackgroundManager } from './helpers'; + +let sessionDir: string; +let persistence: BackgroundTaskPersistence; + +function runningGhost(taskId: string): Extract { + return { + taskId, + kind: 'process', + command: 'some_old_cmd', + description: 'ghost from a prior crash', + pid: 1234, + startedAt: Date.now() - 60 * 60 * 1000, + endedAt: null, + exitCode: null, + status: 'running', + }; +} + +beforeEach(async () => { + sessionDir = join( + tmpdir(), + `kimi-hb-stale-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(sessionDir, { recursive: true }); + persistence = new BackgroundTaskPersistence(sessionDir); +}); + +afterEach(async () => { + await rm(sessionDir, { recursive: true, force: true }); +}); + +describe('Background reconcile — stale ghost detection', () => { + it('emits a terminated event with status=lost for a running ghost', async () => { + await persistence.writeTask(runningGhost('bash-stale000')); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.terminated', + info: expect.objectContaining({ + taskId: 'bash-stale000', + status: 'lost', + }), + }); + }); + + it('second reconcile does not emit a duplicate termination event', async () => { + await persistence.writeTask(runningGhost('bash-dedup000')); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + await manager.reconcile(); + + expect( + agent.emittedEvents.filter( + (event) => event.type === 'background.task.terminated', + ), + ).toHaveLength(1); + }); +}); diff --git a/packages/agent-core/test/agent/background/helpers.ts b/packages/agent-core/test/agent/background/helpers.ts new file mode 100644 index 00000000..c11eec1f --- /dev/null +++ b/packages/agent-core/test/agent/background/helpers.ts @@ -0,0 +1,101 @@ +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { vi } from 'vitest'; + +import { + BackgroundManager, + BackgroundTaskPersistence, + ProcessBackgroundTask, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; +import type { AgentEvent } from '../../../src/rpc/events'; + +export interface FakeBackgroundAgent { + emitEvent: ReturnType; + emittedEvents: AgentEvent[]; + kimiConfig?: { background?: { maxRunningTasks?: number } }; + telemetry: { track: ReturnType }; + context: { appendUserMessage: ReturnType }; + turn: { steer: ReturnType }; + hooks?: { fireAndForgetTrigger: ReturnType }; +} + +export interface BackgroundManagerFixture { + agent: FakeBackgroundAgent; + manager: BackgroundManager; + persistence?: BackgroundTaskPersistence; +} + +export function createBackgroundManager(options: { + sessionDir?: string; + maxRunningTasks?: number; + hooks?: FakeBackgroundAgent['hooks']; +} = {}): BackgroundManagerFixture { + const emittedEvents: AgentEvent[] = []; + const agent: FakeBackgroundAgent = { + emittedEvents, + emitEvent: vi.fn((event: AgentEvent) => { + emittedEvents.push(event); + }), + kimiConfig: + options.maxRunningTasks === undefined + ? undefined + : { background: { maxRunningTasks: options.maxRunningTasks } }, + telemetry: { track: vi.fn() }, + context: { appendUserMessage: vi.fn() }, + turn: { steer: vi.fn() }, + hooks: options.hooks, + }; + const persistence = + options.sessionDir === undefined + ? undefined + : new BackgroundTaskPersistence(options.sessionDir); + return { + agent, + manager: new BackgroundManager(agent as never, persistence), + persistence, + }; +} + +export function registerProcess( + manager: BackgroundManager, + proc: KaosProcess, + command: string, + description: string, +): string { + return manager.registerTask(new ProcessBackgroundTask(proc, command, description)); +} + +export async function waitForTerminal( + manager: BackgroundManager, + taskId: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() <= deadline) { + const info = await manager.wait(taskId, 5); + if ( + info?.status === 'completed' || + info?.status === 'failed' || + info?.status === 'timed_out' || + info?.status === 'killed' || + info?.status === 'lost' + ) { + return info; + } + await new Promise((resolve) => setTimeout(resolve, 1)); + } + return manager.getTask(taskId); +} + +export async function waitForOutput( + manager: BackgroundManager, + taskId: string, + expected: string, +): Promise { + for (let i = 0; i < 20; i++) { + const output = await manager.readOutput(taskId); + if (output.includes(expected)) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`Timed out waiting for output: ${expected}`); +} diff --git a/packages/agent-core/test/agent/background/ids.test.ts b/packages/agent-core/test/agent/background/ids.test.ts new file mode 100644 index 00000000..a4acfbd1 --- /dev/null +++ b/packages/agent-core/test/agent/background/ids.test.ts @@ -0,0 +1,65 @@ +/** + * Background task id format. + */ + +import { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { describe, expect, it, vi } from 'vitest'; + +import { AgentBackgroundTask, BackgroundTaskPersistence } from '../../../src/agent/background'; +import { createBackgroundManager, registerProcess } from './helpers'; + +function pendingProcess(): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 54321, + exitCode: null, + wait: () => new Promise(() => {}), + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +describe('background task id format', () => { + it('assigns bash-prefixed ids to process tasks', () => { + const { manager } = createBackgroundManager(); + const id = registerProcess(manager, pendingProcess(), 'sleep 60', 'process task'); + + expect(id).toMatch(/^bash-[0-9a-z]{8}$/); + expect(manager.getTask(id)).toMatchObject({ taskId: id, kind: 'process' }); + }); + + it('assigns agent-prefixed ids to agent tasks', () => { + const { manager } = createBackgroundManager(); + const id = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'agent task'), + ); + + expect(id).toMatch(/^agent-[0-9a-z]{8}$/); + expect(manager.getTask(id)).toMatchObject({ taskId: id, kind: 'agent' }); + }); + + it('rejects malformed ids at the persistence path boundary', () => { + const persistence = new BackgroundTaskPersistence('/tmp/kimi-bg-id-test'); + const rejected = [ + '', + 'x', + '-bash', + 'BASH-12345678', + 'bash_12345678', + '../escape', + 'bash-1234567', + 'bash-123456789', + 'agent-ABCDEFGH', + 'bg_12345678', + 'a'.repeat(26), + ]; + + for (const bad of rejected) { + expect(() => persistence.taskOutputFile(bad)).toThrow(/Invalid task id/); + } + }); +}); diff --git a/packages/agent-core/test/agent/background/manager.test.ts b/packages/agent-core/test/agent/background/manager.test.ts new file mode 100644 index 00000000..0dc04326 --- /dev/null +++ b/packages/agent-core/test/agent/background/manager.test.ts @@ -0,0 +1,511 @@ +/** + * Covers: BackgroundManager. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; +import { join } from 'pathe'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + AgentBackgroundTask, + BackgroundTaskPersistence, + type BackgroundManager, +} from '../../../src/agent/background'; +import { + createBackgroundManager, + registerProcess, + waitForOutput, +} from './helpers'; + +function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from(stdoutText ? [stdoutText] : []), + stderr: Readable.from([]), + pid: 10000 + exitCode, + exitCode, + wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +function rejectedProcess(error: Error): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 99999, + exitCode: null, + wait: vi.fn().mockRejectedValue(error) as KaosProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +function pendingProcess(exitOnKill = 143): { + proc: KaosProcess; + killSpy: ReturnType; +} { + let resolveWait: (n: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + let currentExitCode: number | null = null; + const killSpy = vi.fn(async () => { + if (currentExitCode !== null) return; + currentExitCode = exitOnKill; + resolveWait(exitOnKill); + }); + const proc: KaosProcess = { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 54321, + get exitCode(): number | null { + return currentExitCode; + }, + wait: () => waitPromise, + kill: killSpy as unknown as KaosProcess['kill'], + }; + return { proc, killSpy }; +} + +function manuallyResolvedProcess(): { + proc: KaosProcess; + killSpy: ReturnType; + resolve: (exitCode: number) => void; +} { + let resolveWait: (n: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + let currentExitCode: number | null = null; + const killSpy = vi.fn().mockResolvedValue(undefined); + const proc: KaosProcess = { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 54324, + get exitCode(): number | null { + return currentExitCode; + }, + wait: () => waitPromise, + kill: killSpy as unknown as KaosProcess['kill'], + }; + return { + proc, + killSpy, + resolve: (exitCode) => { + if (currentExitCode !== null) return; + currentExitCode = exitCode; + resolveWait(exitCode); + }, + }; +} + +function processWithVisibleExitCodeBeforeWait(exitCode = 143): { + proc: KaosProcess; + markExited: () => void; +} { + let currentExitCode: number | null = null; + const proc: KaosProcess = { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 54322, + get exitCode(): number | null { + return currentExitCode; + }, + wait: () => new Promise(() => {}), + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; + return { + proc, + markExited: () => { + currentExitCode = exitCode; + }, + }; +} + +function waiterCount(manager: BackgroundManager, taskId: string): number { + const tasks = ( + manager as unknown as { + tasks: Map void> }>; + } + ).tasks; + return tasks.get(taskId)?.waiters.length ?? 0; +} + +describe('BackgroundManager', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('registers process tasks and exposes process metadata', () => { + const { manager } = createBackgroundManager(); + const proc = immediateProcess(0); + + const taskId = registerProcess(manager, proc, 'echo hello', 'test echo'); + + expect(taskId).toMatch(/^bash-[0-9a-z]{8}$/); + expect(manager.getTask(taskId)).toMatchObject({ + taskId, + kind: 'process', + command: 'echo hello', + description: 'test echo', + pid: proc.pid, + status: 'running', + }); + }); + + it('registers agent tasks and exposes agent metadata', () => { + const { manager } = createBackgroundManager(); + + const taskId = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'investigate bug', { + agentId: 'agent-child', + subagentType: 'coder', + }), + ); + + expect(taskId).toMatch(/^agent-[0-9a-z]{8}$/); + expect(manager.getTask(taskId)).toMatchObject({ + taskId, + kind: 'agent', + description: 'investigate bug', + agentId: 'agent-child', + subagentType: 'coder', + status: 'running', + }); + }); + + it('lists active tasks by default', () => { + const { manager } = createBackgroundManager(); + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'task 1'); + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'task 2'); + + expect(manager.list()).toHaveLength(2); + }); + + it('rejects new tasks when maxRunningTasks is reached', () => { + const { manager } = createBackgroundManager({ maxRunningTasks: 1 }); + + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'first task'); + + expect(() => { + registerProcess(manager, pendingProcess().proc, 'sleep 60', 'second task'); + }).toThrow('Too many background tasks are already running.'); + expect(() => { + manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'agent task')); + }).toThrow('Too many background tasks are already running.'); + }); + + it('captures process output', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess( + manager, + immediateProcess(0, 'captured output\n'), + 'echo captured output', + 'capture test', + ); + + await waitForOutput(manager, taskId, 'captured output'); + + expect(await manager.readOutput(taskId)).toContain('captured output'); + }); + + it('transitions process status from exit code', async () => { + const { manager } = createBackgroundManager(); + const successId = registerProcess(manager, immediateProcess(0), 'echo done', 'ok'); + const failureId = registerProcess(manager, immediateProcess(42), 'exit 42', 'fail'); + + expect(await manager.wait(successId)).toMatchObject({ + kind: 'process', + status: 'completed', + exitCode: 0, + }); + expect(await manager.wait(failureId)).toMatchObject({ + kind: 'process', + status: 'failed', + exitCode: 42, + }); + }); + + it('records failed runtime when proc.wait rejects', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess( + manager, + rejectedProcess(new Error('launch failed')), + '/bogus/cmd', + 'broken launch', + ); + + const info = await manager.wait(taskId); + + expect(info).toMatchObject({ + status: 'failed', + stopReason: 'launch failed', + }); + expect(info?.endedAt).not.toBeNull(); + }); + + it('does not finalize from a visible process exit code before wait settles', async () => { + const { manager } = createBackgroundManager(); + const { proc, markExited } = processWithVisibleExitCodeBeforeWait(143); + const taskId = registerProcess(manager, proc, 'sleep 60', 'external kill test'); + + markExited(); + + expect(manager.getTask(taskId)).toMatchObject({ + kind: 'process', + status: 'running', + exitCode: null, + endedAt: null, + }); + expect(await manager.wait(taskId, 1)).toMatchObject({ + kind: 'process', + status: 'running', + exitCode: null, + }); + }); + + it('stop kills a running process and records the stop reason', async () => { + const { manager } = createBackgroundManager(); + const { proc, killSpy } = pendingProcess(143); + const taskId = registerProcess(manager, proc, 'sleep 60', 'kill test'); + + const result = await manager.stop(taskId, 'user requested'); + + expect(result).toMatchObject({ + status: 'killed', + stopReason: 'user requested', + exitCode: 143, + }); + expect(killSpy).toHaveBeenCalledWith('SIGTERM'); + }); + + it('stop normalizes blank reasons', async () => { + const { manager } = createBackgroundManager(); + const { proc, resolve } = manuallyResolvedProcess(); + const taskId = registerProcess(manager, proc, 'sleep 60', 'blank reason test'); + + const stopPromise = manager.stop(taskId, ' '); + resolve(0); + const result = await stopPromise; + + expect(result).toMatchObject({ status: 'killed' }); + expect(result?.stopReason).toBeUndefined(); + }); + + it('stop keeps graceful process shutdown classified as killed', async () => { + const { manager } = createBackgroundManager(); + const { proc, killSpy, resolve } = manuallyResolvedProcess(); + const taskId = registerProcess(manager, proc, 'sleep 60', 'process race test'); + + const stopPromise = manager.stop(taskId, 'user requested'); + resolve(0); + const result = await stopPromise; + + expect(result).toMatchObject({ + status: 'killed', + stopReason: 'user requested', + exitCode: 0, + }); + expect(killSpy).toHaveBeenCalledWith('SIGTERM'); + expect(killSpy).not.toHaveBeenCalledWith('SIGKILL'); + }); + + it('persists graceful process shutdown as killed when stop was requested', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-race-')); + try { + const writer = createBackgroundManager({ sessionDir }).manager; + const { proc, resolve } = manuallyResolvedProcess(); + const taskId = registerProcess(writer, proc, 'sleep 60', 'persisted race'); + + const stopPromise = writer.stop(taskId, 'user requested'); + resolve(0); + await stopPromise; + + const reader = createBackgroundManager({ sessionDir }).manager; + await reader.loadFromDisk(); + + expect(reader.getTask(taskId)).toMatchObject({ + kind: 'process', + status: 'killed', + exitCode: 0, + stopReason: 'user requested', + }); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('stop preserves agent completion when it wins the stop race', async () => { + const { manager } = createBackgroundManager(); + let resolveCompletion!: (value: { result: string }) => void; + const completion = new Promise<{ result: string }>((resolve) => { + resolveCompletion = resolve; + }); + const abort = vi.fn(); + const taskId = manager.registerTask( + new AgentBackgroundTask(completion, 'agent race test', { abort }), + ); + + const stopPromise = manager.stop(taskId, 'user requested'); + resolveCompletion({ result: 'finished naturally' }); + const result = await stopPromise; + + expect(result).toMatchObject({ status: 'completed' }); + expect(result?.stopReason).toBeUndefined(); + expect(await manager.readOutput(taskId)).toContain('finished naturally'); + expect(abort).toHaveBeenCalled(); + }); + + it('stop preserves agent failure when a non-abort rejection wins', async () => { + const { manager } = createBackgroundManager(); + let rejectCompletion!: (error: Error) => void; + const completion = new Promise<{ result: string }>((_resolve, reject) => { + rejectCompletion = reject; + }); + const abort = vi.fn(); + const taskId = manager.registerTask( + new AgentBackgroundTask(completion, 'agent failure race test', { abort }), + ); + + const stopPromise = manager.stop(taskId, 'user requested'); + rejectCompletion(new Error('model failed')); + const result = await stopPromise; + + expect(result).toMatchObject({ + status: 'failed', + stopReason: 'model failed', + }); + expect(abort).toHaveBeenCalled(); + }); + + it('stop marks agent task killed when abort rejection wins', async () => { + const { manager } = createBackgroundManager(); + let rejectCompletion!: (error: Error) => void; + const completion = new Promise<{ result: string }>((_resolve, reject) => { + rejectCompletion = reject; + }); + const abortError = new Error('The operation was aborted.'); + abortError.name = 'AbortError'; + const abort = vi.fn(() => { + rejectCompletion(abortError); + }); + const taskId = manager.registerTask( + new AgentBackgroundTask(completion, 'agent abort test', { abort }), + ); + + const result = await manager.stop(taskId, 'user requested'); + + expect(result).toMatchObject({ + status: 'killed', + stopReason: 'user requested', + }); + expect(abort).toHaveBeenCalled(); + }); + + it('stop finalizes a never-settling agent task after the grace window', async () => { + vi.useFakeTimers(); + const { manager } = createBackgroundManager(); + const abort = vi.fn(); + const taskId = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'hung agent task', { abort }), + ); + + const stopPromise = manager.stop(taskId, 'user requested'); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(5_000); + const stopped = await stopPromise; + + expect(stopped).toMatchObject({ + status: 'killed', + stopReason: 'user requested', + }); + expect(abort).toHaveBeenCalled(); + }); + + it('wait resolves on completion and removes timed-out waiters', async () => { + const { manager } = createBackgroundManager(); + const completedId = registerProcess(manager, immediateProcess(0), 'echo fast', 'wait test'); + + expect(await manager.wait(completedId, 5_000)).toMatchObject({ status: 'completed' }); + + const runningId = registerProcess(manager, pendingProcess().proc, 'sleep 60', 'timeout'); + expect(await manager.wait(runningId, 0)).toMatchObject({ status: 'running' }); + expect(waiterCount(manager, runningId)).toBe(0); + }); + + it('returns undefined or empty output for unknown task ids', async () => { + const { manager } = createBackgroundManager(); + + expect(manager.getTask('bash-nonexist')).toBeUndefined(); + expect(await manager.readOutput('bash-nonexist')).toBe(''); + expect(await manager.stop('bash-nonexist')).toBeUndefined(); + }); + + it('stop returns terminal info for an already-exited task', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(0), 'echo done', 'already done'); + + await manager.wait(taskId); + + expect(await manager.stop(taskId, 'too late')).toMatchObject({ + status: 'completed', + stopReason: undefined, + }); + }); + + it('getTask on an unknown id does not create persisted state', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-mgr-missing-')); + try { + const { manager } = createBackgroundManager({ sessionDir }); + + expect(manager.getTask('bash-bogusss0')).toBeUndefined(); + + expect(await new BackgroundTaskPersistence(sessionDir).listTasks()).toEqual([]); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('launches a real process and waits to completion', async () => { + const { spawn } = await import('node:child_process'); + const { manager } = createBackgroundManager(); + const child = spawn( + process.execPath, + ['-e', "process.stdout.write('bg-ok\\n')"], + { stdio: 'pipe' }, + ); + const proc: KaosProcess = { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: child.stdout, + stderr: child.stderr, + pid: child.pid ?? 0, + get exitCode(): number | null { + return child.exitCode; + }, + wait: () => + new Promise((resolve) => { + child.on('exit', (code) => { + resolve(code ?? 0); + }); + }), + kill: vi.fn(async (signal?: NodeJS.Signals) => { + child.kill(signal ?? 'SIGTERM'); + }) as unknown as KaosProcess['kill'], + }; + + const taskId = registerProcess(manager, proc, 'node -e ', 'real worker'); + const info = await manager.wait(taskId, 10_000); + + expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); + expect(await manager.readOutput(taskId)).toContain('bg-ok'); + }, 15_000); +}); diff --git a/packages/agent-core/test/agent/background/output-access.test.ts b/packages/agent-core/test/agent/background/output-access.test.ts new file mode 100644 index 00000000..a2937710 --- /dev/null +++ b/packages/agent-core/test/agent/background/output-access.test.ts @@ -0,0 +1,133 @@ +/** + * BackgroundManager output retrieval surface. + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; +import { join } from 'pathe'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { BackgroundManager } from '../../../src/agent/background'; +import { + createBackgroundManager, + registerProcess, + waitForOutput, +} from './helpers'; + +function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from(stdoutText ? [stdoutText] : []), + stderr: Readable.from([]), + pid: 50000 + exitCode, + exitCode, + wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +describe('BackgroundManager — readOutput / getOutputSnapshot', () => { + let sessionDir: string; + let manager: BackgroundManager; + let persistence: NonNullable['persistence']>; + + beforeEach(() => { + sessionDir = mkdtempSync(join(tmpdir(), 'bpm-output-')); + const fixture = createBackgroundManager({ sessionDir }); + manager = fixture.manager; + persistence = fixture.persistence!; + }); + + afterEach(() => { + rmSync(sessionDir, { recursive: true, force: true }); + }); + + it('getOutputSnapshot returns output.log path when persisted output exists', async () => { + const taskId = registerProcess(manager, immediateProcess(0, 'hello\n'), 'echo', 'demo'); + + await waitForOutput(manager, taskId, 'hello'); + const snapshot = await manager.getOutputSnapshot(taskId, 1_000); + + expect(snapshot.outputPath).toBeDefined(); + expect(snapshot.outputPath).toContain(sessionDir); + expect(snapshot.outputPath).toContain(taskId); + expect(snapshot.outputPath!.endsWith('output.log')).toBe(true); + expect(snapshot.fullOutputAvailable).toBe(true); + }); + + it('getOutputSnapshot omits outputPath when no persisted log file exists', async () => { + const taskId = registerProcess(manager, immediateProcess(0), 'sleep 1', 'silent task'); + + await manager.wait(taskId); + const snapshot = await manager.getOutputSnapshot(taskId, 1_000); + + expect(snapshot.outputPath).toBeUndefined(); + expect(snapshot.fullOutputAvailable).toBe(false); + }); + + it('getOutputSnapshot returns an empty snapshot for unknown task ids', async () => { + await expect(manager.getOutputSnapshot('bash-deadbeef', 1_000)).resolves.toEqual({ + outputSizeBytes: 0, + previewBytes: 0, + truncated: false, + fullOutputAvailable: false, + preview: '', + }); + }); + + it('readOutput returns live ring-buffer content while task is in memory', async () => { + const taskId = registerProcess( + manager, + immediateProcess(0, 'live content\n'), + 'echo', + 'demo', + ); + + await waitForOutput(manager, taskId, 'live content'); + + expect(await manager.readOutput(taskId)).toContain('live content'); + }); + + it('readOutput prefers disk over the live ring buffer when persisted output exists', async () => { + const taskId = registerProcess(manager, immediateProcess(0, 'ring-only\n'), 'echo', 'demo'); + + await waitForOutput(manager, taskId, 'ring-only'); + await persistence.appendTaskOutput(taskId, 'disk-only\n'); + + expect(await manager.readOutput(taskId)).toContain('disk-only'); + }); + + it('readOutput falls back to disk for ghost tasks', async () => { + const taskId = registerProcess( + manager, + immediateProcess(0, 'persisted line\n'), + 'echo', + 'demo', + ); + await waitForOutput(manager, taskId, 'persisted line'); + await manager.wait(taskId); + + const fresh = createBackgroundManager({ sessionDir }).manager; + await fresh.loadFromDisk(); + await fresh.reconcile(); + + expect(await fresh.readOutput(taskId)).toContain('persisted line'); + }); + + it('readOutput respects tail length', async () => { + const taskId = registerProcess( + manager, + immediateProcess(0, 'aaaaa-bbbbb-ccccc-ddddd'), + 'echo', + 'demo', + ); + + await waitForOutput(manager, taskId, 'ddddd'); + + expect(await manager.readOutput(taskId, 5)).toBe('ddddd'); + }); +}); diff --git a/packages/agent-core/test/agent/background/persist.test.ts b/packages/agent-core/test/agent/background/persist.test.ts new file mode 100644 index 00000000..4dc0305f --- /dev/null +++ b/packages/agent-core/test/agent/background/persist.test.ts @@ -0,0 +1,161 @@ +/** + * Background task persistence tests. + */ + +import { mkdir, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BackgroundTaskPersistence, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; + +let sessionDir: string; +let persistence: BackgroundTaskPersistence; + +function sample(overrides: Partial> = {}): Extract { + return { + taskId: 'bash-11111111', + kind: 'process', + command: 'npm install', + description: 'install deps', + pid: 12345, + startedAt: 1_700_000_000, + endedAt: null, + exitCode: null, + status: 'running', + ...overrides, + }; +} + +beforeEach(async () => { + sessionDir = join( + tmpdir(), + `kimi-bg-persist-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(sessionDir, { recursive: true }); + persistence = new BackgroundTaskPersistence(sessionDir); +}); + +afterEach(async () => { + await rm(sessionDir, { recursive: true, force: true }); +}); + +describe('BackgroundTaskPersistence', () => { + it('round-trips a task via write/read', async () => { + await persistence.writeTask(sample()); + const loaded = await persistence.readTask('bash-11111111'); + expect(loaded).toEqual(sample()); + }); + + it('returns undefined when task file is missing', async () => { + expect(await persistence.readTask('bash-missing0')).toBeUndefined(); + }); + + it('overwrites on subsequent write', async () => { + await persistence.writeTask(sample({ status: 'running' })); + await persistence.writeTask( + sample({ status: 'completed', exitCode: 0, endedAt: 1_700_000_100 }), + ); + const task = await persistence.readTask('bash-11111111'); + expect(task).toMatchObject({ + status: 'completed', + kind: 'process', + exitCode: 0, + endedAt: 1_700_000_100, + }); + }); + + it('listTasks enumerates all persisted entries', async () => { + await persistence.writeTask(sample({ taskId: 'bash-11111111' })); + await persistence.writeTask(sample({ taskId: 'bash-22222222', command: 'pnpm test' })); + const all = await persistence.listTasks(); + expect(all).toHaveLength(2); + expect(all.map((task) => task.taskId).toSorted()).toEqual([ + 'bash-11111111', + 'bash-22222222', + ]); + }); + + it('listTasks returns empty when tasks dir does not exist', async () => { + expect(await persistence.listTasks()).toEqual([]); + }); + + it('listTasks skips corrupt files', async () => { + await persistence.writeTask(sample()); + await writeFile(join(sessionDir, 'tasks', 'bash-baaaaaaa.json'), '{not json', 'utf-8'); + const all = await persistence.listTasks(); + expect(all.map((task) => task.taskId)).toEqual(['bash-11111111']); + }); + + it('writeTask creates tasks dir with mode 0700', async () => { + await persistence.writeTask(sample()); + const st = await stat(join(sessionDir, 'tasks')); + // eslint-disable-next-line no-bitwise + expect(st.mode & 0o777).toBe(0o700); + }); + + it('rejects path-traversal task ids', async () => { + await expect( + persistence.writeTask(sample({ taskId: '../../etc/passwd' })), + ).rejects.toThrow(/Invalid task id/); + await expect(persistence.readTask('../etc/passwd')).rejects.toThrow(/Invalid task id/); + expect(() => persistence.taskOutputFile('../etc/passwd')).toThrow(/Invalid task id/); + }); + + it('listTasks silently skips non-validating task id files', async () => { + await persistence.writeTask(sample()); + await writeFile( + join(sessionDir, 'tasks', 'BAD-ID!!!.json'), + JSON.stringify(sample({ taskId: 'BAD-ID!!!' })), + 'utf-8', + ); + const all = await persistence.listTasks(); + expect(all.map((task) => task.taskId)).toEqual(['bash-11111111']); + }); + + it('listTasks skips unrecognized records', async () => { + await persistence.writeTask(sample()); + await writeFile( + join(sessionDir, 'tasks', 'bash-cccccccc.json'), + JSON.stringify({ oops: 1 }), + 'utf-8', + ); + const all = await persistence.listTasks(); + expect(all.map((task) => task.taskId)).toEqual(['bash-11111111']); + }); + + it('readTask for an unknown task does not create a directory', async () => { + const { readdir } = await import('node:fs/promises'); + expect(await persistence.readTask('bash-noexis00')).toBeUndefined(); + const top = await readdir(sessionDir); + expect(top.includes('tasks')).toBe(false); + }); + + describe('readTaskOutputBytes / taskOutputSizeBytes', () => { + it('taskOutputSizeBytes reports the full byte size of output.log', async () => { + await persistence.appendTaskOutput('bash-size0000', 'abcdefghij'); + expect(await persistence.taskOutputSizeBytes('bash-size0000')).toBe(10); + }); + + it('taskOutputSizeBytes returns 0 when output.log is absent', async () => { + expect(await persistence.taskOutputSizeBytes('bash-none0000')).toBe(0); + }); + + it('readTaskOutputBytes returns the exact byte window for offset + maxBytes', async () => { + await persistence.appendTaskOutput('bash-page0000', 'abcdefghijklmnopqrstuvwxyz'); + + expect(await persistence.readTaskOutputBytes('bash-page0000', 5, 10)).toBe('fghijklmno'); + expect(await persistence.readTaskOutputBytes('bash-page0000', 0, 3)).toBe('abc'); + expect(await persistence.readTaskOutputBytes('bash-page0000', 20, 100)).toBe('uvwxyz'); + expect(await persistence.readTaskOutputBytes('bash-page0000', 26, 10)).toBe(''); + }); + + it('readTaskOutputBytes returns empty string when output.log is absent', async () => { + expect(await persistence.readTaskOutputBytes('bash-none0001', 0, 100)).toBe(''); + }); + }); +}); diff --git a/packages/agent-core/test/agent/background-persistence-compat.test.ts b/packages/agent-core/test/agent/background/persistence-compat.test.ts similarity index 99% rename from packages/agent-core/test/agent/background-persistence-compat.test.ts rename to packages/agent-core/test/agent/background/persistence-compat.test.ts index 32d963ab..7efb8374 100644 --- a/packages/agent-core/test/agent/background-persistence-compat.test.ts +++ b/packages/agent-core/test/agent/background/persistence-compat.test.ts @@ -4,7 +4,7 @@ import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { BackgroundManager, BackgroundTaskPersistence } from '../../src/agent/background'; +import { BackgroundManager, BackgroundTaskPersistence } from '../../../src/agent/background'; let sessionDir: string; diff --git a/packages/agent-core/test/agent/background/reconcile.test.ts b/packages/agent-core/test/agent/background/reconcile.test.ts new file mode 100644 index 00000000..24af3ffa --- /dev/null +++ b/packages/agent-core/test/agent/background/reconcile.test.ts @@ -0,0 +1,224 @@ +/** + * BackgroundManager reconcile + persistence integration tests. + */ + +import { mkdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + BackgroundTaskPersistence, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; +import { createBackgroundManager } from './helpers'; + +let sessionDir: string; +let persistence: BackgroundTaskPersistence; + +function persistedProcess( + overrides: Partial> = {}, +): Extract { + return { + taskId: 'bash-orphan00', + kind: 'process', + command: 'npm install', + description: 'install', + pid: 99999, + startedAt: 1_700_000_000, + endedAt: null, + exitCode: null, + status: 'running', + ...overrides, + }; +} + +beforeEach(async () => { + sessionDir = join( + tmpdir(), + `kimi-bg-reconcile-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + await mkdir(sessionDir, { recursive: true }); + persistence = new BackgroundTaskPersistence(sessionDir); +}); + +afterEach(async () => { + await rm(sessionDir, { recursive: true, force: true }); +}); + +describe('BackgroundManager — loadFromDisk + reconcile', () => { + it('loadFromDisk does nothing when persistence is not configured', async () => { + const { manager } = createBackgroundManager(); + + await manager.loadFromDisk(); + + expect(manager.list(false)).toEqual([]); + }); + + it('reconciles a previously-running task as lost', async () => { + await persistence.writeTask(persistedProcess()); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(manager.getTask('bash-orphan00')).toMatchObject({ + taskId: 'bash-orphan00', + status: 'lost', + }); + expect(await persistence.readTask('bash-orphan00')).toMatchObject({ + taskId: 'bash-orphan00', + status: 'lost', + }); + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.terminated', + info: expect.objectContaining({ + taskId: 'bash-orphan00', + status: 'lost', + }), + }); + }); + + it('does not reclassify already-terminal tasks', async () => { + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-done0000', + command: 'echo hi', + description: 'echo', + pid: 88888, + endedAt: 1_700_000_010, + exitCode: 0, + status: 'completed', + }), + ); + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-running0', + command: 'sleep 1000', + description: 'sleep', + pid: 77777, + }), + ); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(await persistence.readTask('bash-done0000')).toMatchObject({ + status: 'completed', + }); + expect(await persistence.readTask('bash-running0')).toMatchObject({ + status: 'lost', + }); + expect(agent.emittedEvents).toHaveLength(1); + expect(agent.emittedEvents[0]).toMatchObject({ + type: 'background.task.terminated', + info: { taskId: 'bash-running0', status: 'lost' }, + }); + }); + + it('list(activeOnly=false) includes ghosts; list(true) excludes them', async () => { + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-lost0000', + command: 'x', + description: 'd', + pid: 1, + }), + ); + const { manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(manager.list(true)).toEqual([]); + expect(manager.list(false)).toEqual([ + expect.objectContaining({ taskId: 'bash-lost0000', status: 'lost' }), + ]); + }); + + it('getTask returns ghost when the live process map has no entry', async () => { + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-ghost000', + command: 'x', + description: 'd', + pid: 1, + }), + ); + const { manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(manager.getTask('bash-ghost000')).toMatchObject({ + taskId: 'bash-ghost000', + status: 'lost', + }); + }); + + it('reconcile emits nothing when no ghosts were loaded', async () => { + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(agent.emittedEvents).toEqual([]); + }); + + it('does not emit duplicate termination events on a second reconcile pass', async () => { + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-nodup000', + command: 'sleep 9999', + description: 'dedupe check', + pid: 42, + }), + ); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + await manager.reconcile(); + + expect( + agent.emittedEvents.filter( + (event) => event.type === 'background.task.terminated', + ), + ).toHaveLength(1); + }); + + it('restores terminal ghost notifications into context', async () => { + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-done0001', + command: 'echo done', + description: 'one-shot', + pid: 42, + endedAt: 1_700_000_010, + exitCode: 0, + status: 'completed', + }), + ); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(agent.context.appendUserMessage).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('task.completed'), + }), + ], + expect.objectContaining({ + kind: 'background_task', + taskId: 'bash-done0001', + status: 'completed', + }), + ); + expect(agent.emittedEvents).toEqual([]); + }); +}); diff --git a/packages/agent-core/test/agent/background/rpc-events.test.ts b/packages/agent-core/test/agent/background/rpc-events.test.ts new file mode 100644 index 00000000..34c896f4 --- /dev/null +++ b/packages/agent-core/test/agent/background/rpc-events.test.ts @@ -0,0 +1,591 @@ +/** + * Covers BackgroundManager event emission and notification delivery. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { Readable } from 'node:stream'; +import type { Writable } from 'node:stream'; +import { join } from 'pathe'; + +import type { KaosProcess } from '@moonshot-ai/kaos'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + AgentBackgroundTask, + BackgroundTaskPersistence, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; +import { + createBackgroundManager, + registerProcess, +} from './helpers'; + +function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from(stdoutText ? [stdoutText] : []), + stderr: Readable.from([]), + pid: 30000 + exitCode, + exitCode, + wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], + kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + }; +} + +function pendingProcess(): KaosProcess { + let resolveWait: (code: number) => void = () => {}; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; + }); + let currentExitCode: number | null = null; + return { + stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, + stdout: Readable.from([]), + stderr: Readable.from([]), + pid: 99999, + get exitCode(): number | null { + return currentExitCode; + }, + wait: () => waitPromise, + kill: vi.fn(async () => { + if (currentExitCode !== null) return; + currentExitCode = 143; + resolveWait(143); + }) as unknown as KaosProcess['kill'], + }; +} + +function persistedProcess( + overrides: Partial> = {}, +): Extract { + return { + taskId: 'bash-done0000', + kind: 'process', + command: 'echo done', + description: 'restored shell task', + pid: 12345, + startedAt: 1_700_000_000, + endedAt: 1_700_000_010, + exitCode: 0, + status: 'completed', + ...overrides, + }; +} + +function persistedAgent( + overrides: Partial> = {}, +): Extract { + return { + taskId: 'agent-done0000', + kind: 'agent', + description: 'restored task', + startedAt: 1_700_000_000, + endedAt: 1_700_000_010, + status: 'completed', + agentId: 'agent-session-id', + subagentType: 'coder', + ...overrides, + }; +} + +describe('BackgroundManager — event emission', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('emits background.task.started for process tasks', () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'demo'); + + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.started', + info: expect.objectContaining({ + taskId, + kind: 'process', + status: 'running', + }), + }); + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { + kind: 'bash', + }); + }); + + it('emits background.task.started for agent tasks', () => { + const { agent, manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'agent task'), + ); + + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.started', + info: expect.objectContaining({ + taskId, + kind: 'agent', + status: 'running', + }), + }); + expect(agent.telemetry.track).toHaveBeenCalledWith('background_task_created', { + kind: 'agent', + }); + }); + + it('emits background.task.terminated and telemetry on natural exit', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(0), 'echo', 'done'); + agent.telemetry.track.mockClear(); + + await manager.wait(taskId); + + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.terminated', + info: expect.objectContaining({ + taskId, + status: 'completed', + }), + }); + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ + kind: 'process', + duration: expect.any(Number), + status: 'completed', + }), + ); + }); + + it('tracks failed and timed-out terminal statuses', async () => { + const { agent, manager } = createBackgroundManager(); + const failedId = registerProcess(manager, immediateProcess(1), 'false', 'failed'); + const timedOutId = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'slow agent', { timeoutMs: 1 }), + ); + agent.telemetry.track.mockClear(); + + await manager.wait(failedId); + await manager.wait(timedOutId); + + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ kind: 'process', status: 'failed' }), + ); + expect(agent.telemetry.track).toHaveBeenCalledWith( + 'background_task_completed', + expect.objectContaining({ kind: 'agent', status: 'timed_out' }), + ); + }); + + it('emits background.task.terminated on stop', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long'); + agent.emittedEvents.length = 0; + + await manager.stop(taskId, 'user'); + + expect(agent.emittedEvents).toEqual([ + { + type: 'background.task.terminated', + info: expect.objectContaining({ + taskId, + status: 'killed', + }), + }, + ]); + }); + + it('emits background.task.terminated when a restored task is marked lost', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-reconcile-')); + try { + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-orphan00', + command: 'sleep 60', + description: 'orphan task', + endedAt: null, + exitCode: null, + status: 'running', + }), + ); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(agent.emittedEvents).toContainEqual({ + type: 'background.task.terminated', + info: expect.objectContaining({ + taskId: 'bash-orphan00', + status: 'lost', + }), + }); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); +}); + +describe('BackgroundManager — notification delivery', () => { + it('steers completed agent task notifications into the turn flow', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.resolve({ result: 'final subagent summary' }), + 'agent task', + ), + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + const [content, origin] = agent.turn.steer.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('Background agent completed'); + expect(text).toContain('final subagent summary'); + }); + + it('steers completed process task notifications into the turn flow', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(0), 'echo ok', 'shell task'); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + const [content, origin] = agent.turn.steer.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('Background process completed'); + expect(text).toContain('shell task completed.'); + }); + + it('steers stopped process task notifications into the turn flow', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'long shell task'); + + await manager.stop(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalledTimes(1); + }); + const [content, origin] = agent.turn.steer.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId, + status: 'killed', + notificationId: `task:${taskId}:killed`, + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background process killed', + ); + }); + + it('replays restored terminal agent task notifications when undelivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); + try { + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask(persistedAgent()); + await persistence.appendTaskOutput('agent-done0000', 'restored subagent summary'); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = agent.context.appendUserMessage.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'agent-done0000', + status: 'completed', + notificationId: 'task:agent-done0000:completed', + }); + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('Background agent completed'); + expect(text).toContain('restored subagent summary'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('replays restored terminal process task notifications when undelivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-replay-')); + try { + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask(persistedProcess()); + await persistence.appendTaskOutput('bash-done0000', 'restored shell output'); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = agent.context.appendUserMessage.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'bash-done0000', + status: 'completed', + notificationId: 'task:bash-done0000:completed', + }); + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('Background process completed'); + expect(text).toContain('restored shell output'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('reads only a bounded output tail for restored process task notifications', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-bash-tail-')); + try { + const taskId = 'bash-large000'; + const largeOutput = `early-output-marker\n${'x'.repeat(8_000)}\nfinal output line`; + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask(persistedProcess({ taskId })); + await persistence.appendTaskOutput(taskId, largeOutput); + const { agent, manager } = createBackgroundManager({ sessionDir }); + const readOutputSpy = vi.spyOn(manager, 'readOutput'); + const snapshotSpy = vi.spyOn(manager, 'getOutputSnapshot'); + + await manager.loadFromDisk(); + await manager.reconcile(); + + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(readOutputSpy).not.toHaveBeenCalled(); + expect(snapshotSpy).toHaveBeenCalledWith(taskId, expect.any(Number)); + expect(snapshotSpy.mock.calls[0]![1]).toBeLessThan(largeOutput.length); + const [content] = agent.context.appendUserMessage.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('final output line'); + expect(text).not.toContain('early-output-marker'); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not replay restored notifications already marked delivered', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-replay-')); + try { + const origin = { + kind: 'background_task', + taskId: 'agent-seen0000', + status: 'completed', + notificationId: 'task:agent-seen0000:completed', + } as const; + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask(persistedAgent({ taskId: 'agent-seen0000' })); + await persistence.appendTaskOutput('agent-seen0000', 'already delivered summary'); + const { agent, manager } = createBackgroundManager({ sessionDir }); + manager.markDeliveredNotification(origin); + + await manager.loadFromDisk(); + await manager.reconcile(); + + expect(agent.turn.steer).not.toHaveBeenCalled(); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('does not double-notify newly lost restored agent tasks', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-lost-')); + try { + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask( + persistedAgent({ + taskId: 'agent-run00000', + description: 'interrupted task', + endedAt: null, + status: 'running', + }), + ); + const { agent, manager } = createBackgroundManager({ sessionDir }); + + await manager.loadFromDisk(); + await manager.reconcile(); + await manager.reconcile(); + + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + expect(agent.turn.steer).not.toHaveBeenCalled(); + const [content, origin] = agent.context.appendUserMessage.mock.calls[0]!; + expect(origin).toEqual({ + kind: 'background_task', + taskId: 'agent-run00000', + status: 'lost', + notificationId: 'task:agent-run00000:lost', + }); + expect((content as Array<{ text: string }>)[0]!.text).toContain( + 'Background agent lost', + ); + } finally { + await rm(sessionDir, { recursive: true, force: true }); + } + }); + + it('fires a Notification hook when a background agent notification is delivered', async () => { + const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); + const { agent, manager } = createBackgroundManager({ + hooks: { fireAndForgetTrigger }, + }); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.resolve({ result: 'final agent output' }), + 'inspect repository', + ), + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { + matcherValue: 'task.completed', + inputData: { + sink: 'context', + notificationType: 'task.completed', + title: 'Background agent completed', + body: 'inspect repository completed.', + severity: 'info', + sourceKind: 'background_task', + sourceId: taskId, + }, + }); + }); + + it('does not let Notification hook failures interrupt notification delivery', async () => { + const fireAndForgetTrigger = vi.fn(() => { + throw new Error('notification hook failed'); + }); + const { agent, manager } = createBackgroundManager({ + hooks: { fireAndForgetTrigger }, + }); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.resolve({ result: 'final agent output' }), + 'inspect repository', + ), + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + }); + + it('fires Notification hooks for process task notifications', async () => { + const fireAndForgetTrigger = vi.fn(() => Promise.resolve([])); + const { agent, manager } = createBackgroundManager({ + hooks: { fireAndForgetTrigger }, + }); + const taskId = registerProcess(manager, immediateProcess(0), 'echo', 'done'); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + expect(fireAndForgetTrigger).toHaveBeenCalled(); + }); + expect(fireAndForgetTrigger).toHaveBeenCalledWith('Notification', { + matcherValue: 'task.completed', + inputData: { + sink: 'context', + notificationType: 'task.completed', + title: 'Background process completed', + body: 'done completed.', + severity: 'info', + sourceKind: 'background_task', + sourceId: taskId, + }, + }); + }); +}); + +describe('BackgroundManager — agent recovery notification bodies', () => { + it('failed agent task body includes resume instructions with the correct agent_id', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.reject(new Error('subagent crashed')), + 'inspect repository', + { agentId: 'agent-7' }, + ), + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + }); + const [content] = agent.turn.steer.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('agent_id="agent-7"'); + expect(text).toMatch(/Agent\(resume="agent-7"/); + expect(text).toMatch(/agent_id.*NOT source_id|source_id.*NOT agent_id/); + }); + + it('completed agent task body does not add resume instructions', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.resolve({ result: 'all good' }), + 'inspect repository', + { agentId: 'agent-8' }, + ), + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + }); + const [content] = agent.turn.steer.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).toContain('agent_id="agent-8"'); + expect(text).not.toMatch(/Agent\(resume="agent-8"/); + }); + + it('process task body never mentions resume', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(1), 'false', 'shell'); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(agent.turn.steer).toHaveBeenCalled(); + }); + const [content] = agent.turn.steer.mock.calls[0]!; + const text = (content as Array<{ text: string }>)[0]!.text; + expect(text).not.toContain('agent_id='); + expect(text).not.toMatch(/Agent\(resume=/); + expect(text).toContain(`source_id="${taskId}"`); + }); +}); diff --git a/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts b/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts index 81f3dbe6..0c5bc79a 100644 --- a/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts +++ b/packages/agent-core/test/agent/bg-idle-notification-repro.test.ts @@ -22,9 +22,8 @@ import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; -import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; import { testAgent } from './harness/agent'; -import { AgentBackgroundTask } from '../../src/agent/background'; +import { AgentBackgroundTask, BackgroundTaskPersistence } from '../../src/agent/background'; describe('background notification → main agent (real Agent instance)', () => { it('IDLE: completed bg agent auto-starts a new turn with XML', async () => { @@ -42,7 +41,7 @@ describe('background notification → main agent (real Agent instance)', () => { 'idle-state repro', )); - await ctx.agent.background.waitForTerminal(taskId); + await ctx.agent.background.wait(taskId); // Give the steer→launch→turnWorker→generate chain time to run. await vi.waitFor( @@ -148,7 +147,7 @@ describe('background notification → main agent (real Agent instance)', () => { ]; for (const id of taskIds) { - await ctx.agent.background.waitForTerminal(id); + await ctx.agent.background.wait(id); } await vi.waitFor( @@ -211,7 +210,7 @@ describe('background notification → main agent (real Agent instance)', () => { 'race-after-turn', )); - await ctx.agent.background.waitForTerminal(taskId); + await ctx.agent.background.wait(taskId); // The notification arriving while idle should auto-launch a turn. await vi.waitFor( @@ -245,44 +244,43 @@ describe('background notification → main agent (real Agent instance)', () => { try { // Simulate a previous session's bash bg task that completed // before exit and an agent bg task that didn't (will be lost). - await writeTask(sessionDir, { - task_id: 'bash-prev0000', + const backgroundPersistence = new BackgroundTaskPersistence(sessionDir); + await backgroundPersistence.writeTask({ + taskId: 'bash-prev0000', + kind: 'process', command: 'echo previous', description: 'previous bash task', pid: 12345, - started_at: 1_700_000_000, - ended_at: 1_700_000_005, - exit_code: 0, + startedAt: 1_700_000_000, + endedAt: 1_700_000_005, + exitCode: 0, status: 'completed', }); - await appendTaskOutput(sessionDir, 'bash-prev0000', 'previous bash output'); + await backgroundPersistence.appendTaskOutput('bash-prev0000', 'previous bash output'); - await writeTask(sessionDir, { - task_id: 'agent-prev0000', - command: '[agent] previous agent task', + await backgroundPersistence.writeTask({ + taskId: 'agent-prev0000', + kind: 'agent', description: 'previous agent task', - pid: 0, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, + startedAt: 1_700_000_000, + endedAt: null, status: 'running', }); - const ctx = testAgent(); + const ctx = testAgent({ homedir: sessionDir }); ctx.configure({ tools: [] }); // We do NOT mock any LLM response. If the resume path // mistakenly launches a turn, scripted-generate throws // "Unexpected generate call" and the test fails loudly. - ctx.agent.background.attachSessionDir(sessionDir); const steerSpy = vi.spyOn(ctx.agent.turn, 'steer'); // Reproduce Agent.resume()'s post-replay sequence. await ctx.agent.background.loadFromDisk(); - const reconcileResult = await ctx.agent.background.reconcile(); + await ctx.agent.background.reconcile(); // The agent-* running task should now be lost. - expect(reconcileResult.lost).toContain('agent-prev0000'); + expect(ctx.agent.background.getTask('agent-prev0000')?.status).toBe('lost'); // Give the silent append a beat. await vi.waitFor(() => { diff --git a/packages/agent-core/test/agent/harness/agent.ts b/packages/agent-core/test/agent/harness/agent.ts index d5b09607..c4499eb3 100644 --- a/packages/agent-core/test/agent/harness/agent.ts +++ b/packages/agent-core/test/agent/harness/agent.ts @@ -104,6 +104,7 @@ export interface TestAgentOptions { readonly subagentHost?: AgentOptions['subagentHost']; readonly onEvent?: ((event: AgentRecord) => AgentRecord | undefined) | undefined; readonly persistence?: AgentRecordPersistence | undefined; + readonly homedir?: AgentOptions['homedir']; readonly telemetry?: TelemetryClient | undefined; readonly log?: Logger; } @@ -180,6 +181,7 @@ export class AgentTestContext { toolServices, config: this.kimiConfig, rpc: this.createRpcProxy(), + homedir: options.homedir, persistence, generate: options.generate ?? this.scriptedGenerate.generate, compactionStrategy: options.compactionStrategy, diff --git a/packages/agent-core/test/agent/resume.test.ts b/packages/agent-core/test/agent/resume.test.ts index 0d906621..049403a7 100644 --- a/packages/agent-core/test/agent/resume.test.ts +++ b/packages/agent-core/test/agent/resume.test.ts @@ -9,7 +9,7 @@ import { AGENT_WIRE_PROTOCOL_VERSION, InMemoryAgentRecordPersistence, } from '../../src/agent/records'; -import { appendTaskOutput, writeTask } from '../../src/agent/background/persist'; +import { BackgroundTaskPersistence } from '../../src/agent/background'; import { createFakeKaos } from '../tools/fixtures/fake-kaos'; import { testAgent } from './harness/agent'; import { DEFAULT_TEST_SYSTEM_PROMPT } from './harness/snapshots'; @@ -194,19 +194,20 @@ describe('Agent resume', () => { ]); const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-resume-delivered-')); try { - const ctx = testAgent({ persistence }); - ctx.agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'agent-seen0000', - command: '[agent] already delivered', + const backgroundPersistence = new BackgroundTaskPersistence(sessionDir); + const ctx = testAgent({ persistence, homedir: sessionDir }); + await backgroundPersistence.writeTask({ + taskId: 'agent-seen0000', + kind: 'agent', description: 'already delivered', - pid: 0, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, + startedAt: 1_700_000_000, + endedAt: 1_700_000_010, status: 'completed', }); - await appendTaskOutput(sessionDir, 'agent-seen0000', 'already delivered summary'); + await backgroundPersistence.appendTaskOutput( + 'agent-seen0000', + 'already delivered summary', + ); const steer = vi.spyOn(ctx.agent.turn, 'steer'); await ctx.agent.resume(); @@ -233,19 +234,17 @@ describe('Agent resume', () => { ]); const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-resume-undelivered-')); try { - const ctx = testAgent({ persistence }); - ctx.agent.background.attachSessionDir(sessionDir); - await writeTask(sessionDir, { - task_id: 'agent-new00000', - command: '[agent] newly delivered', + const backgroundPersistence = new BackgroundTaskPersistence(sessionDir); + const ctx = testAgent({ persistence, homedir: sessionDir }); + await backgroundPersistence.writeTask({ + taskId: 'agent-new00000', + kind: 'agent', description: 'newly delivered', - pid: 0, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, + startedAt: 1_700_000_000, + endedAt: 1_700_000_010, status: 'completed', }); - await appendTaskOutput(sessionDir, 'agent-new00000', 'newly delivered summary'); + await backgroundPersistence.appendTaskOutput('agent-new00000', 'newly delivered summary'); const steer = vi.spyOn(ctx.agent.turn, 'steer'); await ctx.agent.resume(); diff --git a/packages/agent-core/test/session/lifecycle-hooks.test.ts b/packages/agent-core/test/session/lifecycle-hooks.test.ts index cc50f753..a83e2e4d 100644 --- a/packages/agent-core/test/session/lifecycle-hooks.test.ts +++ b/packages/agent-core/test/session/lifecycle-hooks.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; +import { ProcessBackgroundTask } from '../../src/agent/background'; const tempDirs: string[] = []; @@ -121,7 +122,9 @@ describe('Session lifecycle hooks', () => { }); const agent = await session.createMain(); const { proc, killSpy } = pendingProcess(); - const taskId = agent.background.register(proc, 'sleep 60', 'exit cleanup'); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'exit cleanup'), + ); await session.close(); @@ -142,7 +145,9 @@ describe('Session lifecycle hooks', () => { }); const agent = await session.createMain(); const { proc, killSpy } = pendingProcess(); - const taskId = agent.background.register(proc, 'sleep 60', 'env cleanup'); + const taskId = agent.background.registerTask( + new ProcessBackgroundTask(proc, 'sleep 60', 'env cleanup'), + ); await session.close(); diff --git a/packages/agent-core/test/tools/agent.test.ts b/packages/agent-core/test/tools/agent.test.ts index eb89c493..805086b4 100644 --- a/packages/agent-core/test/tools/agent.test.ts +++ b/packages/agent-core/test/tools/agent.test.ts @@ -4,9 +4,10 @@ import { ToolAccesses } from '../../src/loop'; import type { Logger, LogPayload } from '../../src/logging'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; -import { AgentBackgroundTask, BackgroundManager } from '../../src/agent/background'; +import { AgentBackgroundTask } from '../../src/agent/background'; import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent'; import { userCancellationReason } from '../../src/utils/abort'; +import { createBackgroundManager } from '../agent/background/helpers'; import { executeTool } from './fixtures/execute-tool'; const signal = new AbortController().signal; @@ -109,7 +110,7 @@ describe('AgentTool', () => { it('explains background timeout fallback in the background-enabled description without claiming a 15min default', () => { const host = mockSubagentHost({ spawn: vi.fn() }); - const tool = new AgentTool(host, new BackgroundManager()); + const tool = new AgentTool(host, createBackgroundManager().manager); // #5: the background-enabled variant describes the real timeout fallback — // an omitted timeout falls back to the operator-configured background @@ -315,7 +316,7 @@ describe('AgentTool', () => { it('does not consume a background task slot when validation fails before launch', async () => { const completion = new Promise<{ result: string }>(() => {}); - const background = new BackgroundManager({ maxRunningTasks: 1 }); + const background = createBackgroundManager({ maxRunningTasks: 1 }).manager; const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ agentId: 'agent-child', @@ -425,7 +426,7 @@ describe('AgentTool', () => { completion, }), }); - const background = new BackgroundManager(); + const background = createBackgroundManager().manager; const tool = new AgentTool(host, background); const result = await executeTool(tool, @@ -456,7 +457,7 @@ describe('AgentTool', () => { completion: new Promise<{ result: string }>(() => {}), }), }); - const background = new BackgroundManager(); + const background = createBackgroundManager().manager; const tool = new AgentTool(host, background); const result = await executeTool(tool, @@ -516,8 +517,8 @@ describe('AgentTool', () => { expect(host.spawn).not.toHaveBeenCalled(); }); - it('does not spawn background subagents when the task limit is reached', async () => { - const background = new BackgroundManager({ maxRunningTasks: 1 }); + it('returns an error when background registration hits the task limit', async () => { + const background = createBackgroundManager({ maxRunningTasks: 1 }).manager; background.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'existing agent')); const host = mockSubagentHost({ spawn: vi.fn().mockResolvedValue({ @@ -541,11 +542,11 @@ describe('AgentTool', () => { isError: true, output: 'Too many background tasks are already running.', }); - expect(host.spawn).not.toHaveBeenCalled(); + expect(host.spawn).toHaveBeenCalledTimes(1); }); - it('reserves a task slot before spawning concurrent background subagents', async () => { - const background = new BackgroundManager({ maxRunningTasks: 1 }); + it('rejects one of two concurrent background subagents when the task limit is reached', async () => { + const background = createBackgroundManager({ maxRunningTasks: 1 }).manager; const host = mockSubagentHost({ spawn: vi .fn() @@ -581,7 +582,7 @@ describe('AgentTool', () => { const results = await Promise.all([first, second]); - expect(host.spawn).toHaveBeenCalledTimes(1); + expect(host.spawn).toHaveBeenCalledTimes(2); expect(results).toContainEqual( expect.objectContaining({ output: expect.stringContaining('status: running') }), ); @@ -635,7 +636,7 @@ describe('AgentTool', () => { completion: new Promise<{ result: string }>(() => {}), }), }); - const background = new BackgroundManager(); + const background = createBackgroundManager().manager; vi.spyOn(background, 'registerTask').mockImplementation(() => { throw error; }); diff --git a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts b/packages/agent-core/test/tools/background/heartbeat-stale.test.ts deleted file mode 100644 index 562eda61..00000000 --- a/packages/agent-core/test/tools/background/heartbeat-stale.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * BPM reconcile identifies stale ghost tasks on startup and fires a - * single `onTerminal` callback (lost) per ghost, deduped on a second - * reconcile. - * - * Uses **real timers**: reconcile is a batch operation driven by - * `started_at` comparisons, not setTimeout, so fake timers would only - * add noise. - * - * The broader BPM ↔ notification wiring used to live in a host integration - * test; here we validate the BPM surface (callback shape + idempotency) in - * isolation. - */ - -import { mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { BackgroundManager } from '../../../src/agent/background'; -import { writeTask } from '../../../src/agent/background/persist'; - -let sessionDir: string; - -beforeEach(async () => { - sessionDir = join( - tmpdir(), - `kimi-hb-stale-${String(Date.now())}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(sessionDir, { recursive: true }); -}); - -afterEach(async () => { - await rm(sessionDir, { recursive: true, force: true }); -}); - -describe('BPM reconcile — stale heartbeat ghost detection', () => { - it('fires onTerminal with status=lost for a stale running ghost', async () => { - // Seed a ghost that started 1 hour ago and was never closed out. - const oneHourAgo = Date.now() - 60 * 60 * 1000; - await writeTask(sessionDir, { - task_id: 'bash-stale000', - command: 'some_old_cmd', - description: 'ghost from a prior crash', - pid: 1234, - started_at: oneHourAgo, - ended_at: null, - exit_code: null, - status: 'running', - }); - - const mgr = new BackgroundManager(); - const fired: Array<{ taskId: string; status: string }> = []; - mgr.onTerminal((info) => { - fired.push({ taskId: info.taskId, status: info.status }); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - - expect(fired).toEqual([{ taskId: 'bash-stale000', status: 'lost' }]); - }); - - it('second reconcile does NOT refire onTerminal for the same ghost', async () => { - await writeTask(sessionDir, { - task_id: 'bash-dedup000', - command: 'x', - description: 'dedupe stale', - pid: 99, - started_at: Date.now() - 30 * 60 * 1000, - ended_at: null, - exit_code: null, - status: 'running', - }); - - const mgr = new BackgroundManager(); - const fired: string[] = []; - mgr.onTerminal((info) => { - fired.push(info.taskId); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - await mgr.reconcile(); - expect(fired).toEqual(['bash-dedup000']); - }); -}); diff --git a/packages/agent-core/test/tools/background/ids.test.ts b/packages/agent-core/test/tools/background/ids.test.ts deleted file mode 100644 index e010aa92..00000000 --- a/packages/agent-core/test/tools/background/ids.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Task id format: `{bash|agent}-{8 base36 chars}`. - * - * Legacy `bg_` format is NOT accepted. - */ - -import { describe, expect, it } from 'vitest'; - -import { generateTaskId, VALID_TASK_ID } from '../../../src/agent/background'; - -describe('background task id format', () => { - it('generated ids pass VALID_TASK_ID for every kind', () => { - for (const kind of ['bash', 'agent'] as const) { - for (let i = 0; i < 32; i++) { - const id = generateTaskId(kind); - expect(id).toMatch(VALID_TASK_ID); - expect(id.startsWith(`${kind}-`)).toBe(true); - } - } - }); - - it('rejects malformed ids', () => { - const rejected = [ - '', // empty - 'x', // too short - '-bash', // wrong prefix - 'BASH-12345678', // uppercase - 'bash_12345678', // underscore separator - '../escape', // path traversal - 'bash-1234567', // 7-char suffix - 'bash-123456789', // 9-char suffix - 'agent-ABCDEFGH', // uppercase suffix - 'bg_12345678', // legacy format is no longer accepted - 'a'.repeat(26), // long junk - ]; - for (const bad of rejected) { - expect(VALID_TASK_ID.test(bad)).toBe(false); - } - // Spot-check one *valid* id so the negative assertions aren't - // drifting (a regex that rejects everything would pass the block - // above on its own). - expect(VALID_TASK_ID.test('bash-00000000')).toBe(true); - expect(VALID_TASK_ID.test('agent-zzzzzzzz')).toBe(true); - }); - - // Cross-module invariant: every id produced by the generator must - // satisfy the validation regex used by the persistence store. Run - // multiple iterations because the suffix is random. - it('every generated id passes VALID_TASK_ID for every kind', () => { - for (const kind of ['bash', 'agent'] as const) { - for (let i = 0; i < 128; i++) { - const id = generateTaskId(kind); - expect(VALID_TASK_ID.test(id)).toBe(true); - expect(id.startsWith(`${kind}-`)).toBe(true); - } - } - }); - - // Negative invariant: empty / too-short / wrong-prefix / uppercase / - // underscore / path-traversal must all be rejected. - it('explicit rejection set', () => { - const cases = [ - '', - 'x', - '-bash', - 'BASH-12345678', - 'bash_12345678', - '../escape', - 'a'.repeat(26), - ]; - for (const bad of cases) { - expect(VALID_TASK_ID.test(bad)).toBe(false); - } - }); -}); diff --git a/packages/agent-core/test/tools/background/manager.test.ts b/packages/agent-core/test/tools/background/manager.test.ts deleted file mode 100644 index c16276b6..00000000 --- a/packages/agent-core/test/tools/background/manager.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -/** - * Covers: BackgroundManager. - * - * Uses KaosProcess fakes — the manager accepts KaosProcess directly, - * with no ChildProcess dependency. - */ - -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { Readable } from 'node:stream'; -import type { Writable } from 'node:stream'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; - -/** - * Creates a KaosProcess that completes immediately with the given exit code. - * stdout emits `stdoutText` if provided. - */ -function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from(stdoutText ? [stdoutText] : []), - stderr: Readable.from([]), - pid: 10000 + exitCode, - exitCode, - wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; -} - -/** - * Creates a KaosProcess that stays running until `kill()` is called. - * Calling `kill()` resolves `wait()` with `exitOnKill`. - */ -function pendingProcess(exitOnKill = 143): { - proc: KaosProcess; - killSpy: ReturnType; -} { - let resolveWait: (n: number) => void = () => { - /* replaced below */ - }; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - const killSpy = vi.fn(async () => { - if (currentExitCode === null) { - currentExitCode = exitOnKill; - resolveWait(exitOnKill); - } - }); - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 54321, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: killSpy as unknown as KaosProcess['kill'], - }; - return { proc, killSpy }; -} - -function manuallyResolvedProcess(): { - proc: KaosProcess; - killSpy: ReturnType; - resolve: (exitCode: number) => void; -} { - let resolveWait: (n: number) => void = () => { - /* replaced below */ - }; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - const killSpy = vi.fn().mockResolvedValue(undefined); - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 54324, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: killSpy as unknown as KaosProcess['kill'], - }; - return { - proc, - killSpy, - resolve: (exitCode) => { - if (currentExitCode !== null) return; - currentExitCode = exitCode; - resolveWait(exitCode); - }, - }; -} - -function waiterCount(manager: BackgroundManager, taskId: string): number { - const tasks = ( - manager as unknown as { - tasks: Map void> }>; - } - ).tasks; - return tasks.get(taskId)?.waiters.length ?? 0; -} - -function processExitingAfterSigkill( - exitOnKill = 137, - delayMs = 25, -): { - proc: KaosProcess; - killSpy: ReturnType; -} { - let resolveWait: (n: number) => void = () => { - /* replaced below */ - }; - const waitPromise = new Promise((res) => { - resolveWait = res; - }); - let currentExitCode: number | null = null; - const killSpy = vi.fn(async (signal?: NodeJS.Signals) => { - if (signal !== 'SIGKILL' || currentExitCode !== null) return; - setTimeout(() => { - currentExitCode = exitOnKill; - resolveWait(exitOnKill); - }, delayMs); - }); - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 54323, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - kill: killSpy as unknown as KaosProcess['kill'], - }; - return { proc, killSpy }; -} - -function processWithVisibleExitCodeBeforeWait(exitCode = 143): { - proc: KaosProcess; - markExited: () => void; -} { - let currentExitCode: number | null = null; - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 54322, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => new Promise(() => {}), - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; - return { - proc, - markExited: () => { - currentExitCode = exitCode; - }, - }; -} - -describe('BackgroundManager', () => { - const manager = new BackgroundManager(); - - afterEach(() => { - manager._reset(); - }); - - it('register returns a task ID and tracks the process', () => { - const proc = immediateProcess(0); - const taskId = manager.register(proc, 'echo hello', 'test echo'); - // Id format is `{bash|agent}-{8 base36}`. - expect(taskId).toMatch(/^bash-[0-9a-z]{8}$/); - const info = manager.getTask(taskId); - expect(info).toBeDefined(); - expect(info!.kind).toBe('process'); - if (info!.kind !== 'process') throw new Error('expected process task'); - expect(info!.command).toBe('echo hello'); - expect(info!.description).toBe('test echo'); - expect(info!.pid).toBe(proc.pid); - }); - - it('records failed runtime when proc.wait() rejects', async () => { - // Simulate a Kaos launch that resolves into a KaosProcess whose - // subsequent wait() rejects (e.g. shell fork failure mid-exec). - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 99999, - exitCode: null, - wait: vi.fn().mockRejectedValue(new Error('launch failed')) as KaosProcess['wait'], - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; - const taskId = manager.register(proc, '/bogus/cmd', 'broken launch'); - - // Let the wait() rejection propagate through the .finally block. - await new Promise((r) => { - setTimeout(r, 20); - }); - - const info = manager.getTask(taskId); - expect(info!.status).toBe('failed'); - expect(info!.endedAt).not.toBeNull(); - }); - - it('registerAgentTask registers as running with agent- id prefix', () => { - // Promise that never resolves — we only inspect the initial register - // snapshot here. - const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'agent task')); - expect(taskId).toMatch(/^agent-[0-9a-z]{8}$/); - const info = manager.getTask(taskId); - expect(info).toBeDefined(); - expect(info!.kind).toBe('agent'); - expect(info!.status).toBe('running'); - expect('pid' in info!).toBe(false); - expect('command' in info!).toBe(false); - }); - - it('getTask on an unknown id does not touch disk or create state', () => { - // Live + ghost maps stay untouched; no partial creation. - const before = manager.list(false).length; - expect(manager.getTask('bash-deadbeef')).toBeUndefined(); - const after = manager.list(false).length; - expect(after).toBe(before); - }); - - it('list returns active tasks by default', () => { - const { proc: proc1 } = pendingProcess(); - const { proc: proc2 } = pendingProcess(); - manager.register(proc1, 'sleep 60', 'task 1'); - manager.register(proc2, 'sleep 60', 'task 2'); - const active = manager.list(true); - expect(active.length).toBe(2); - }); - - it('rejects new bash tasks when maxRunningTasks is reached', () => { - const limited = new BackgroundManager({ maxRunningTasks: 1 }); - const { proc: first } = pendingProcess(); - const { proc: second } = pendingProcess(); - - limited.register(first, 'sleep 60', 'first task'); - - expect(() => { - limited.register(second, 'sleep 60', 'second task'); - }).toThrow('Too many background tasks are already running.'); - }); - - it('rejects new agent tasks when maxRunningTasks is reached', () => { - const limited = new BackgroundManager({ maxRunningTasks: 1 }); - - limited.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'first agent')); - - expect(() => { - limited.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'second agent')); - }).toThrow('Too many background tasks are already running.'); - }); - - it('getOutput returns captured stdout', async () => { - const proc = immediateProcess(0, 'captured output\n'); - const taskId = manager.register(proc, 'echo captured output', 'capture test'); - - // Allow the wait() promise and stream data events to settle. - await new Promise((r) => { - setTimeout(r, 50); - }); - - const output = manager.getOutput(taskId); - expect(output).toContain('captured output'); - }); - - it('task status transitions to completed on exit code 0', async () => { - const proc = immediateProcess(0, 'done'); - const taskId = manager.register(proc, 'echo done', 'completion test'); - - // Allow the wait() promise to settle. - await new Promise((r) => { - setTimeout(r, 20); - }); - - const info = manager.getTask(taskId); - expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); - }); - - it('task status transitions to failed on non-zero exit', async () => { - const proc = immediateProcess(42); - const taskId = manager.register(proc, 'exit 42', 'fail test'); - - await new Promise((r) => { - setTimeout(r, 20); - }); - - const info = manager.getTask(taskId); - expect(info).toMatchObject({ kind: 'process', status: 'failed', exitCode: 42 }); - }); - - it('does not finalize task status from a visible process exit code before wait settles', () => { - const { proc, markExited } = processWithVisibleExitCodeBeforeWait(143); - const taskId = manager.register(proc, 'sleep 60', 'external kill test'); - - markExited(); - - const info = manager.getTask(taskId); - expect(info).toMatchObject({ kind: 'process', status: 'running', exitCode: null }); - expect(info!.endedAt).toBeNull(); - }); - - it('does not resolve wait from a visible process exit code before wait settles', async () => { - const { proc, markExited } = processWithVisibleExitCodeBeforeWait(143); - const taskId = manager.register(proc, 'sleep 60', 'external kill wait test'); - - markExited(); - - const info = await manager.wait(taskId, 1); - expect(info).toMatchObject({ kind: 'process', status: 'running', exitCode: null }); - }); - - it('stop kills a running task via KaosProcess.kill()', async () => { - const { proc, killSpy } = pendingProcess(143); - const taskId = manager.register(proc, 'sleep 60', 'kill test'); - - const result = await manager.stop(taskId); - expect(result).toBeDefined(); - expect(result!.status).toBe('killed'); - expect(killSpy).toHaveBeenCalledWith('SIGTERM'); - }); - - it('stop normalizes a blank reason instead of recording an empty stopReason', async () => { - const { proc, resolve } = manuallyResolvedProcess(); - const taskId = manager.register(proc, 'sleep 60', 'blank reason test'); - - const stopPromise = manager.stop(taskId, ' '); - resolve(0); - const result = await stopPromise; - - // A whitespace-only reason must not be persisted as a blank stopReason. - // Public callers (SDK/RPC) reach manager.stop() directly, bypassing the - // TaskStop tool's own normalization, so the boundary must guard it. - expect(result!.stopReason).toBeUndefined(); - }); - - it('stop keeps graceful process shutdown classified as killed', async () => { - const { proc, killSpy, resolve } = manuallyResolvedProcess(); - const taskId = manager.register(proc, 'sleep 60', 'process race test'); - - const stopPromise = manager.stop(taskId, 'user requested'); - resolve(0); - const result = await stopPromise; - - expect(result!.status).toBe('killed'); - expect(result!.stopReason).toBe('user requested'); - expect(killSpy).toHaveBeenCalledWith('SIGTERM'); - expect(killSpy).not.toHaveBeenCalledWith('SIGKILL'); - }); - - it('persists graceful process shutdown as killed when stop requested', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-race-')); - try { - const writer = new BackgroundManager(); - writer.attachSessionDir(sessionDir); - const { proc, resolve } = manuallyResolvedProcess(); - const taskId = writer.register(proc, 'sleep 60', 'persisted process race test'); - - const stopPromise = writer.stop(taskId, 'user requested'); - resolve(0); - await stopPromise; - - const reader = new BackgroundManager(); - reader.attachSessionDir(sessionDir); - await reader.loadFromDisk(); - - const persisted = reader.getTask(taskId); - expect(persisted).toMatchObject({ kind: 'process', status: 'killed', exitCode: 0 }); - expect(persisted?.stopReason).toBe('user requested'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - it('stop preserves agent task completion that settles during the grace window', async () => { - let resolveCompletion!: (value: { result: string }) => void; - const completion = new Promise<{ result: string }>((resolve) => { - resolveCompletion = resolve; - }); - const abort = vi.fn(); - const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent race test', { abort })); - - const stopPromise = manager.stop(taskId, 'user requested'); - resolveCompletion({ result: 'finished naturally' }); - const result = await stopPromise; - - expect(result!.status).toBe('completed'); - expect(result!.stopReason).toBeUndefined(); - expect(manager.getOutput(taskId)).toContain('finished naturally'); - expect(abort).toHaveBeenCalled(); - }); - - it('stop preserves agent task failure when a non-abort rejection wins', async () => { - let rejectCompletion!: (error: Error) => void; - const completion = new Promise<{ result: string }>((_resolve, reject) => { - rejectCompletion = reject; - }); - const abort = vi.fn(); - const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent failure race test', { abort })); - - const stopPromise = manager.stop(taskId, 'user requested'); - rejectCompletion(new Error('model failed')); - const result = await stopPromise; - - expect(result!.status).toBe('failed'); - expect(result!.stopReason).toBeUndefined(); - expect(abort).toHaveBeenCalled(); - }); - - it('stop marks agent task killed when abort rejection wins', async () => { - let rejectCompletion!: (error: Error) => void; - const completion = new Promise<{ result: string }>((_resolve, reject) => { - rejectCompletion = reject; - }); - const abortError = new Error('The operation was aborted.'); - abortError.name = 'AbortError'; - const abort = vi.fn(() => { - rejectCompletion(abortError); - }); - const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'agent abort test', { abort })); - - const result = await manager.stop(taskId, 'user requested'); - - expect(result!.status).toBe('killed'); - expect(result!.stopReason).toBe('user requested'); - expect(abort).toHaveBeenCalled(); - }); - - it('stop finalizes a never-settling agent task after the grace window', async () => { - vi.useFakeTimers(); - try { - const local = new BackgroundManager(); - const abort = vi.fn(); - const taskId = local.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'hung agent task', { abort })); - const terminalPromise = local.waitForTerminal(taskId); - - const stopPromise = local.stop(taskId, 'user requested'); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(5_000); - const [stopped, terminal] = await Promise.all([stopPromise, terminalPromise]); - - expect(stopped?.status).toBe('killed'); - expect(stopped?.stopReason).toBe('user requested'); - expect(terminal?.status).toBe('killed'); - expect(abort).toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); - - it('updates endedAt when a killed task finally exits after SIGKILL', async () => { - vi.useFakeTimers(); - try { - const local = new BackgroundManager(); - const terminated: string[] = []; - local.onTerminal((info) => { - terminated.push(info.status); - }); - const { proc, killSpy } = processExitingAfterSigkill(137, 25); - const taskId = local.register(proc, 'sleep 60', 'forced kill test'); - - const stopPromise = local.stop(taskId); - await vi.advanceTimersByTimeAsync(5_000); - const stopped = await stopPromise; - const stopEndedAt = stopped!.endedAt; - - expect(stopped!.status).toBe('killed'); - expect(killSpy).toHaveBeenCalledWith('SIGKILL'); - - await vi.advanceTimersByTimeAsync(25); - - const info = local.getTask(taskId); - expect(info).toMatchObject({ kind: 'process', exitCode: 137 }); - expect(info!.endedAt).toBeGreaterThan(stopEndedAt!); - expect(terminated).toEqual(['killed']); - } finally { - vi.useRealTimers(); - } - }); - - it('wait resolves when task completes', async () => { - const proc = immediateProcess(0, 'fast'); - const taskId = manager.register(proc, 'echo fast', 'wait test'); - - const info = await manager.wait(taskId, 5000); - expect(info).toBeDefined(); - expect(info!.status).toBe('completed'); - }); - - it('wait removes its waiter when the timeout branch wins', async () => { - const { proc } = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'timeout cleanup test'); - - const info = await manager.wait(taskId, 0); - - expect(info).toBeDefined(); - expect(info!.status).toBe('running'); - expect(waiterCount(manager, taskId)).toBe(0); - }); - - it('getTask returns undefined for unknown ID', () => { - expect(manager.getTask('bash-nonexist')).toBeUndefined(); - }); - - it('getOutput returns empty string for unknown ID', () => { - expect(manager.getOutput('bash-nonexist')).toBe(''); - }); - - it('stop returns terminal info for already-exited task', async () => { - const proc = immediateProcess(0); - const taskId = manager.register(proc, 'echo done', 'already done'); - - // Let wait() settle first. - await new Promise((r) => { - setTimeout(r, 20); - }); - - const result = await manager.stop(taskId); - expect(result).toBeDefined(); - expect(result!.status).toBe('completed'); - }); -}); - -// ── py-aligned coverage for bash + agent registration semantics ──────── - -describe('BackgroundManager — registration semantics', () => { - const manager = new BackgroundManager(); - - afterEach(() => { - manager._reset(); - }); - - // The freshly-registered bash task should be immediately observable - // in `starting` (or `running`) with the worker pid wired in. Py - // distinguishes `starting` vs `running`; TS collapses to `running` - // and exposes that pre-output. - it('a newly-registered bash task is immediately visible with a starting/running state and worker pid', () => { - const proc = pendingProcess().proc; - const taskId = manager.register(proc, 'sleep 1', 'short sleep'); - expect(taskId.startsWith('bash-')).toBe(true); - const info = manager.getTask(taskId); - expect(info).toBeDefined(); - expect(info!.kind).toBe('process'); - if (info!.kind !== 'process') throw new Error('expected process task'); - // Py: 'starting' state visible. TS: starting status is collapsed - // into 'running' here — the assertion lives at the py level. - expect((info!.status as string) === 'starting' || info!.status === 'running').toBe(true); - expect(info!.pid).toBe(proc.pid); - }); - - // Race-safety invariant: if the worker writes a terminal state - // (completed) DURING register's startup transition, the registrar - // must NOT clobber it back to `starting`/`running`. - it('register does not overwrite a worker-written terminal completion', async () => { - const proc = immediateProcess(0, 'done\n'); - const taskId = manager.register(proc, 'echo done', 'instant completion'); - // Let the immediate-exit `wait()` settle. - await new Promise((r) => { - setTimeout(r, 20); - }); - const info = manager.getTask(taskId); - expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); - }); - - // Worker launch raises → manager re-raises, AND persists a `failed` - // runtime record so the orphan never leaks as a zombie `running`. - it('records a failed runtime when the worker launch raises', async () => { - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 99999, - exitCode: null, - wait: vi.fn().mockRejectedValue(new Error('launch boom')) as KaosProcess['wait'], - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; - const taskId = manager.register(proc, '/bogus', 'broken launch'); - await new Promise((r) => { - setTimeout(r, 20); - }); - const info = manager.getTask(taskId); - expect(info!.status).toBe('failed'); - expect(info!.endedAt).not.toBeNull(); - }); - - // Agent task registration places kind_payload-style info on the task - // info (agent_id / subagent_type carried through), status visible. - it('agent task registration exposes agent metadata on the task info', () => { - const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'investigate bug', { - agentId: 'agent-child', - subagentType: 'coder', - })); - expect(taskId.startsWith('agent-')).toBe(true); - const info = manager.getTask(taskId); - expect(info).toBeDefined(); - expect(info!.kind).toBe('agent'); - if (info!.kind !== 'agent') throw new Error('expected agent task'); - expect(info!.agentId).toBe('agent-child'); - expect(info!.subagentType).toBe('coder'); - }); - - // Lookup for an unknown task id must return undefined AND must NOT - // create any task directory on disk. - it('getTask on an unknown id never creates on-disk state', async () => { - const sessionDir = await import('node:fs/promises').then((m) => - m.mkdtemp(join(tmpdir(), 'kimi-bg-mgr-missing-')), - ); - try { - const m2 = new BackgroundManager(); - m2.attachSessionDir(sessionDir); - expect(m2.getTask('bash-bogusss0')).toBeUndefined(); - const { readdir } = await import('node:fs/promises'); - // The tasks/ dir may not exist at all — the lookup must not have - // touched it. - const top = await readdir(sessionDir); - expect(top.includes('tasks')).toBe(false); - } finally { - const { rm } = await import('node:fs/promises'); - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - // Terminal-notification dedupe behavior: a subscriber that maintains - // its own seen-set should observe each task exactly once. Python - // expressed this via a `publish_terminal_notifications(limit=N)` - // entry point that skipped tasks whose dedupe_key was already - // recorded; TS pushes the dedupe responsibility to the consumer (the - // `BackgroundManager` subclass in `agent/background/index.ts` uses - // `scheduledNotificationKeys` for the same effect). The behavior we - // care about is "duplicate terminal events are filterable by the - // consumer"; the entry-point method itself is not part of the TS BPM - // surface. - it('terminal-notification dedupe via onTerminal subscriber yields each task once', async () => { - const seen = new Set(); - const published: string[] = []; - manager.onTerminal((info) => { - if (seen.has(info.taskId)) return; - seen.add(info.taskId); - published.push(info.taskId); - }); - - const taskId = manager.register(immediateProcess(0), 'echo a', 'a'); - await new Promise((r) => { - setTimeout(r, 20); - }); - // A second subscriber observing the same terminal event must not - // cause the first subscriber's published list to grow. - manager.onTerminal(() => { - /* no-op */ - }); - expect(published).toEqual([taskId]); - }); - - // E2E: launch a real child process and wait for it to land in - // `completed` with the output captured. - it('launches a real worker and waits to completion', async () => { - const { spawn } = await import('node:child_process'); - const child = spawn( - process.execPath, - ['-e', "process.stdout.write('bg-ok\\n')"], - { stdio: 'pipe' }, - ); - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: child.stdout, - stderr: child.stderr, - pid: child.pid ?? 0, - get exitCode(): number | null { - return child.exitCode; - }, - wait: () => - new Promise((resolve) => { - child.on('exit', (code) => { - resolve(code ?? 0); - }); - }), - kill: vi.fn(async (sig?: NodeJS.Signals) => { - child.kill(sig ?? 'SIGTERM'); - }) as unknown as KaosProcess['kill'], - }; - const taskId = manager.register(proc, 'node -e ', 'real worker smoke'); - const info = await manager.wait(taskId, 10_000); - expect(info).toMatchObject({ kind: 'process', status: 'completed', exitCode: 0 }); - expect(manager.getOutput(taskId)).toContain('bg-ok'); - }, 15_000); - - // Calling stop(taskId) on a running bg agent transitions runtime to - // `killed` with stopReason carried from the caller; failure_reason - // not overwritten by the late agent_runner CancelledError handler. - it('stop on a running agent transitions to killed with caller-supplied reason', async () => { - // Wire abort → reject completion so stop() doesn't have to ride - // the 5s SIGTERM grace period. The rejection must carry - // `name: 'AbortError'` so the lifecycle catch handler can - // distinguish it from an unrelated model failure that happens to - // race against the stop (the "non-abort rejection wins" case is - // covered separately and must remain `failed`). - let rejectCompletion!: (err: unknown) => void; - const completion = new Promise<{ result: string }>((_res, rej) => { - rejectCompletion = rej; - }); - const taskId = manager.registerTask(new AgentBackgroundTask(completion, 'killable', { - abort: () => { - const abortError = new Error('cancelled'); - abortError.name = 'AbortError'; - rejectCompletion(abortError); - }, - })); - const stopped = await manager.stop(taskId, 'test kill'); - expect(stopped?.status).toBe('killed'); - expect(stopped?.stopReason).toBe('test kill'); - }); - - // kill() on an already-completed task is a no-op: returns the current - // view unchanged; failure_reason stays null; subagent record stays - // `idle` (the completion side already cleaned up). - it('stop on an already-completed task is a no-op', async () => { - const proc = immediateProcess(0, 'done'); - const taskId = manager.register(proc, 'echo done', 'quick'); - await new Promise((r) => { - setTimeout(r, 20); - }); - expect(manager.getTask(taskId)?.status).toBe('completed'); - - const after = await manager.stop(taskId, 'too late'); - expect(after?.status).toBe('completed'); - // No stopReason should be recorded on a noop stop. - expect(after?.stopReason).toBeUndefined(); - }); -}); diff --git a/packages/agent-core/test/tools/background/output-access.test.ts b/packages/agent-core/test/tools/background/output-access.test.ts deleted file mode 100644 index 9de7e0c5..00000000 --- a/packages/agent-core/test/tools/background/output-access.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * BackgroundManager — output retrieval surface. - * - * Covers the two methods consumed by the `/tasks` UI: - * - `readOutput(taskId, tail?)` reads the persisted - * `/tasks//output.log` first so callers are not - * limited by the in-memory ring buffer. - * - `getOutputPath(taskId)` returns the absolute path when the - * persisted output log exists so callers can hand it to a pager. - */ - -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; -import { Readable } from 'node:stream'; -import type { Writable } from 'node:stream'; - -import type { KaosProcess } from '@moonshot-ai/kaos'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { BackgroundManager } from '../../../src/agent/background'; -import { appendTaskOutput } from '../../../src/agent/background/persist'; - -function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { - return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from(stdoutText ? [stdoutText] : []), - stderr: Readable.from([]), - pid: 50000 + exitCode, - exitCode, - wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; -} - -async function waitForLiveOutput( - manager: BackgroundManager, - taskId: string, - expected: string, -): Promise { - for (let i = 0; i < 20; i++) { - if (manager.getOutput(taskId).includes(expected)) return; - await new Promise((resolve) => setTimeout(resolve, 5)); - } - throw new Error(`Timed out waiting for live output: ${expected}`); -} - -describe('BackgroundManager — readOutput / getOutputPath', () => { - let sessionDir: string; - let manager: BackgroundManager; - - beforeEach(() => { - sessionDir = mkdtempSync(join(tmpdir(), 'bpm-output-')); - manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - }); - - afterEach(() => { - manager._reset(); - rmSync(sessionDir, { recursive: true, force: true }); - }); - - it('getOutputPath returns /tasks//output.log when persisted output exists', async () => { - const taskId = manager.register(immediateProcess(0, 'hello\n'), 'echo', 'demo'); - await waitForLiveOutput(manager, taskId, 'hello'); - await manager.flushOutput(taskId); - - const path = manager.getOutputPath(taskId); - expect(path).toBeDefined(); - expect(path).toContain(sessionDir); - expect(path).toContain(taskId); - expect(path!.endsWith('output.log')).toBe(true); - }); - - it('getOutputPath returns undefined when no persisted log file exists', async () => { - const taskId = manager.register(immediateProcess(0), 'sleep 1', 'silent task'); - await manager.wait(taskId); - await manager.flushOutput(taskId); - - expect(manager.getOutputPath(taskId)).toBeUndefined(); - }); - - it('getOutputPath returns undefined for unknown task ids', () => { - expect(manager.getOutputPath('bash-deadbeef')).toBeUndefined(); - }); - - it('readOutput returns live ring-buffer content while task is in memory', async () => { - const taskId = manager.register(immediateProcess(0, 'live content\n'), 'echo', 'demo'); - await new Promise((r) => setTimeout(r, 30)); - const out = await manager.readOutput(taskId); - expect(out).toContain('live content'); - }); - - it('readOutput prefers disk over the live ring buffer when persisted output exists', async () => { - const taskId = manager.register(immediateProcess(0, 'ring-only\n'), 'echo', 'demo'); - await waitForLiveOutput(manager, taskId, 'ring-only'); - await appendTaskOutput(sessionDir, taskId, 'disk-only\n'); - - const out = await manager.readOutput(taskId); - - expect(out).toContain('disk-only'); - }); - - it('readOutput falls back to disk for ghost (reconciled lost) tasks', async () => { - // Stage 1: live manager appends output to disk. - // Wait deterministically: `manager.wait` resolves only after - // `persistWriteQueue` drains (so `task.json` is on disk), and - // `flushOutput` drains `outputWriteQueue` (so `output.log` is too). - // Sleeping 30ms here was flaky on slow CI disks — `task.json` might - // still be in flight when the fresh manager scans the session dir, - // and a missing ghost makes readOutput return ''. - const taskId = manager.register(immediateProcess(0, 'persisted line\n'), 'echo', 'demo'); - await manager.wait(taskId); - await manager.flushOutput(taskId); - expect((await manager.readOutput(taskId)).length).toBeGreaterThan(0); - - // Stage 2: simulate a fresh restart — new manager, same sessionDir. - const fresh = new BackgroundManager(); - fresh.attachSessionDir(sessionDir); - await fresh.loadFromDisk(); - await fresh.reconcile(); - - // The reloaded task is a ghost (terminal); the in-memory ring buffer - // is empty but readOutput should still find the disk log. - const recovered = await fresh.readOutput(taskId); - expect(recovered).toContain('persisted line'); - fresh._reset(); - }); - - it('readOutput respects tail length', async () => { - const taskId = manager.register( - immediateProcess(0, 'aaaaa-bbbbb-ccccc-ddddd'), - 'echo', - 'demo', - ); - await new Promise((r) => setTimeout(r, 30)); - const tail = await manager.readOutput(taskId, 5); - expect(tail.length).toBeLessThanOrEqual(5); - expect(tail).toBe('ddddd'); - }); -}); diff --git a/packages/agent-core/test/tools/background/persist.test.ts b/packages/agent-core/test/tools/background/persist.test.ts deleted file mode 100644 index 953a805e..00000000 --- a/packages/agent-core/test/tools/background/persist.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Background task persistence tests. - */ - -import { mkdir, rm, stat } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - appendTaskOutput, - listTasks, - readTask, - readTaskOutputBytes, - removeTask, - taskOutputSizeBytes, - writeTask, - type PersistedTask, -} from '../../../src/agent/background/persist'; - -let sessionDir: string; - -function sample(overrides: Partial = {}): PersistedTask { - return { - task_id: 'bash-11111111', - command: 'npm install', - description: 'install deps', - pid: 12345, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - ...overrides, - }; -} - -beforeEach(async () => { - sessionDir = join( - tmpdir(), - `kimi-bg-persist-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(sessionDir, { recursive: true }); -}); - -afterEach(async () => { - await rm(sessionDir, { recursive: true, force: true }); -}); - -describe('background/persist', () => { - it('round-trips a task via write/read', async () => { - await writeTask(sessionDir, sample()); - const loaded = await readTask(sessionDir, 'bash-11111111'); - expect(loaded).toEqual(sample()); - }); - - it('returns undefined when task file is missing', async () => { - expect(await readTask(sessionDir, 'bash-missing0')).toBeUndefined(); - }); - - it('overwrites on subsequent write', async () => { - await writeTask(sessionDir, sample({ status: 'running' })); - await writeTask( - sessionDir, - sample({ status: 'completed', exit_code: 0, ended_at: 1_700_000_100 }), - ); - const t = await readTask(sessionDir, 'bash-11111111'); - expect(t?.status).toBe('completed'); - expect(t?.exit_code).toBe(0); - expect(t?.ended_at).toBe(1_700_000_100); - }); - - it('listTasks enumerates all persisted entries', async () => { - await writeTask(sessionDir, sample({ task_id: 'bash-11111111' })); - await writeTask(sessionDir, sample({ task_id: 'bash-22222222', command: 'pnpm test' })); - const all = await listTasks(sessionDir); - expect(all).toHaveLength(2); - expect(all.map((t) => t.task_id).toSorted()).toEqual(['bash-11111111', 'bash-22222222']); - }); - - it('listTasks returns empty when tasks dir does not exist', async () => { - expect(await listTasks(sessionDir)).toEqual([]); - }); - - it('listTasks skips corrupt files', async () => { - await writeTask(sessionDir, sample()); - // Corrupt a sibling file - const { writeFile } = await import('node:fs/promises'); - // Needs a *valid-format* id for listTasks to even attempt parsing - // (invalid-id files are silently skipped). - await writeFile(join(sessionDir, 'tasks', 'bash-baaaaaaa.json'), '{not json', 'utf-8'); - const all = await listTasks(sessionDir); - expect(all).toHaveLength(1); - expect(all[0]?.task_id).toBe('bash-11111111'); - }); - - it('removeTask deletes file (idempotent)', async () => { - await writeTask(sessionDir, sample()); - await removeTask(sessionDir, 'bash-11111111'); - expect(await readTask(sessionDir, 'bash-11111111')).toBeUndefined(); - // Second remove no-op - await expect(removeTask(sessionDir, 'bash-11111111')).resolves.toBeUndefined(); - }); - - it('writeTask creates tasks dir with mode 0700', async () => { - await writeTask(sessionDir, sample()); - const st = await stat(join(sessionDir, 'tasks')); - // eslint-disable-next-line no-bitwise - expect(st.mode & 0o777).toBe(0o700); - }); - - it('rejects path-traversal task ids', async () => { - await expect(writeTask(sessionDir, sample({ task_id: '../../etc/passwd' }))).rejects.toThrow( - /Invalid task id/, - ); - await expect(readTask(sessionDir, '../etc/passwd')).rejects.toThrow(/Invalid task id/); - await expect(removeTask(sessionDir, '../etc/passwd')).rejects.toThrow(/Invalid task id/); - }); - - it('listTasks silently skips non-validating task_id files', async () => { - // Seed a valid task alongside a sibling file whose basename does - // NOT match `^(bash|agent)-[0-9a-z]{8}$`. - await writeTask(sessionDir, sample()); - const { writeFile } = await import('node:fs/promises'); - await writeFile( - join(sessionDir, 'tasks', 'BAD-ID!!!.json'), - JSON.stringify(sample({ task_id: 'BAD-ID!!!' })), - 'utf-8', - ); - const all = await listTasks(sessionDir); - expect(all).toHaveLength(1); - expect(all[0]?.task_id).toBe('bash-11111111'); - }); - - it('listTasks skips specs missing required fields', async () => { - await writeTask(sessionDir, sample()); - const { writeFile } = await import('node:fs/promises'); - // Valid JSON, valid filename, but shape is wrong (missing status / pid). - await writeFile( - join(sessionDir, 'tasks', 'bash-cccccccc.json'), - JSON.stringify({ task_id: 'bash-cccccccc', command: 'x' }), - 'utf-8', - ); - const all = await listTasks(sessionDir); - expect(all).toHaveLength(1); - expect(all[0]?.task_id).toBe('bash-11111111'); - }); - - // ── shell_info round-trip ───────────────────────────────────────── - - it('shell_info round-trips through write/read', async () => { - const task = sample({ - task_id: 'bash-abc12345', - shell_info: { - name: 'bash', - path: '/bin/bash', - cwd: '/tmp/work', - }, - }); - await writeTask(sessionDir, task); - const loaded = await readTask(sessionDir, 'bash-abc12345'); - expect(loaded?.shell_info).toEqual({ - name: 'bash', - path: '/bin/bash', - cwd: '/tmp/work', - }); - }); - - it('stop_reason round-trips through write/read', async () => { - await writeTask( - sessionDir, - sample({ - task_id: 'bash-stop0000', - status: 'killed', - stop_reason: 'no longer needed', - }), - ); - const loaded = await readTask(sessionDir, 'bash-stop0000'); - expect(loaded?.stop_reason).toBe('no longer needed'); - }); - - // All read paths for an unknown task must return defaults AND must - // NOT create the on-disk task directory as a side effect. - it('readTask for an unknown task does not create a directory', async () => { - const { readdir } = await import('node:fs/promises'); - expect(await readTask(sessionDir, 'bash-noexis00')).toBeUndefined(); - const top = await readdir(sessionDir); - expect(top.includes('tasks')).toBe(false); - }); - - // listTasks must filter directories whose name does not match the - // valid task-id format — stray subdirectories must not appear. - it('listTasks skips invalid task directories', async () => { - await writeTask(sessionDir, sample({ task_id: 'bash-88888888' })); - const { mkdir } = await import('node:fs/promises'); - await mkdir(join(sessionDir, 'tasks', 'b-invalid'), { recursive: true }); - const all = await listTasks(sessionDir); - expect(all.map((t) => t.task_id)).toEqual(['bash-88888888']); - }); - - // Even if a stray directory has a spec.json file inside, list_views - // still skips it when the directory name fails task-id validation. - it('listTasks skips a directory whose name fails validation even when it contains a spec file', async () => { - await writeTask(sessionDir, sample({ task_id: 'bash-77777777' })); - const { mkdir, writeFile } = await import('node:fs/promises'); - await mkdir(join(sessionDir, 'tasks', 'bad-task!'), { recursive: true }); - await writeFile( - join(sessionDir, 'tasks', 'bad-task!', 'spec.json'), - JSON.stringify({}), - 'utf-8', - ); - const all = await listTasks(sessionDir); - expect(all.map((t) => t.task_id)).toEqual(['bash-77777777']); - }); - - // Corrupt runtime.json content (truncated JSON missing closing brace) - // must NOT raise — readTask returns undefined and listTasks silently - // skips that entry. Py treats this as "fall back to default runtime". - it('readTask on truncated JSON returns undefined (does not throw)', async () => { - const { writeFile, mkdir } = await import('node:fs/promises'); - await mkdir(join(sessionDir, 'tasks'), { recursive: true }); - await writeFile( - join(sessionDir, 'tasks', 'bash-99999998.json'), - '{"status":"running"', - 'utf-8', - ); - const loaded = await readTask(sessionDir, 'bash-99999998'); - expect(loaded).toBeUndefined(); - }); - - // listTasks skips a task whose spec is unparseable / missing required - // fields, while still returning sibling valid tasks. - it('listTasks skips a task with a corrupted spec while keeping siblings', async () => { - await writeTask(sessionDir, sample({ task_id: 'bash-99999996' })); - const { writeFile, mkdir } = await import('node:fs/promises'); - await mkdir(join(sessionDir, 'tasks'), { recursive: true }); - await writeFile( - join(sessionDir, 'tasks', 'bash-99999997.json'), - JSON.stringify({ oops: 1 }), - 'utf-8', - ); - const all = await listTasks(sessionDir); - expect(all.map((t) => t.task_id)).toEqual(['bash-99999996']); - }); - - // ── byte-paged output read ──────────────────────────────────────── - - describe('readTaskOutputBytes / taskOutputSizeBytes', () => { - it('taskOutputSizeBytes reports the full byte size of output.log', async () => { - await appendTaskOutput(sessionDir, 'bash-size0000', 'abcdefghij'); // 10 bytes - expect(await taskOutputSizeBytes(sessionDir, 'bash-size0000')).toBe(10); - }); - - it('taskOutputSizeBytes returns 0 when output.log is absent', async () => { - expect(await taskOutputSizeBytes(sessionDir, 'bash-none0000')).toBe(0); - }); - - it('readTaskOutputBytes returns the exact byte window for offset + maxBytes', async () => { - // 26 single-byte ASCII chars. - await appendTaskOutput(sessionDir, 'bash-page0000', 'abcdefghijklmnopqrstuvwxyz'); - - // A middle window. - expect(await readTaskOutputBytes(sessionDir, 'bash-page0000', 5, 10)).toBe('fghijklmno'); - // From the start. - expect(await readTaskOutputBytes(sessionDir, 'bash-page0000', 0, 3)).toBe('abc'); - // A window that runs past EOF is clamped to whatever remains. - expect(await readTaskOutputBytes(sessionDir, 'bash-page0000', 20, 100)).toBe('uvwxyz'); - // An offset at/after EOF yields an empty string. - expect(await readTaskOutputBytes(sessionDir, 'bash-page0000', 26, 10)).toBe(''); - }); - - it('readTaskOutputBytes returns empty string when output.log is absent', async () => { - expect(await readTaskOutputBytes(sessionDir, 'bash-none0001', 0, 100)).toBe(''); - }); - }); -}); diff --git a/packages/agent-core/test/tools/background/reconcile.test.ts b/packages/agent-core/test/tools/background/reconcile.test.ts deleted file mode 100644 index b555ad63..00000000 --- a/packages/agent-core/test/tools/background/reconcile.test.ts +++ /dev/null @@ -1,292 +0,0 @@ -/** - * BackgroundManager reconcile + persistence integration tests. - */ - -import { mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'pathe'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { BackgroundManager } from '../../../src/agent/background'; -import { writeTask, listTasks } from '../../../src/agent/background/persist'; - -let sessionDir: string; - -beforeEach(async () => { - sessionDir = join( - tmpdir(), - `kimi-bg-reconcile-${Date.now()}-${Math.random().toString(36).slice(2)}`, - ); - await mkdir(sessionDir, { recursive: true }); -}); - -afterEach(async () => { - await rm(sessionDir, { recursive: true, force: true }); -}); - -describe('BackgroundManager — loadFromDisk + reconcile', () => { - it('loadFromDisk does nothing when sessionDir not attached', async () => { - const mgr = new BackgroundManager(); - await mgr.loadFromDisk(); - expect(mgr.list(false)).toEqual([]); - }); - - it('reconciles a previously-running task as lost', async () => { - // Seed disk as if a previous CLI process registered a task. - await writeTask(sessionDir, { - task_id: 'bash-orphan00', - command: 'npm install', - description: 'install', - pid: 99999, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const result = await mgr.reconcile(); - - expect(result.lost).toEqual(['bash-orphan00']); - expect(result.lostInfo).toHaveLength(1); - expect(result.lostInfo[0]?.status).toBe('lost'); - // Persisted state updated - const onDisk = await listTasks(sessionDir); - expect(onDisk[0]?.status).toBe('lost'); - }); - - it('does not reclassify already-terminal tasks', async () => { - await writeTask(sessionDir, { - task_id: 'bash-done0000', - command: 'echo hi', - description: 'echo', - pid: 88888, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - await writeTask(sessionDir, { - task_id: 'bash-running0', - command: 'sleep 1000', - description: 'sleep', - pid: 77777, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const result = await mgr.reconcile(); - expect([...result.lost].toSorted()).toEqual(['bash-running0']); - - const all = await listTasks(sessionDir); - const byId = new Map(all.map((t) => [t.task_id, t])); - expect(byId.get('bash-done0000')?.status).toBe('completed'); - expect(byId.get('bash-running0')?.status).toBe('lost'); - }); - - it('list(activeOnly=false) includes ghosts; list(true) excludes them', async () => { - await writeTask(sessionDir, { - task_id: 'bash-lost0000', - command: 'x', - description: 'd', - pid: 1, - started_at: 0, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - expect(mgr.list(true)).toEqual([]); // active-only: no live tasks - const all = mgr.list(false); - expect(all).toHaveLength(1); - expect(all[0]?.status).toBe('lost'); - }); - - it('getTask returns ghost when the live process map has no entry', async () => { - await writeTask(sessionDir, { - task_id: 'bash-ghost000', - command: 'x', - description: 'd', - pid: 1, - started_at: 0, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - const t = mgr.getTask('bash-ghost000'); - expect(t?.status).toBe('lost'); - }); - - it('forgetTask drops ghost and disk entry', async () => { - await writeTask(sessionDir, { - task_id: 'bash-forget00', - command: 'x', - description: 'd', - pid: 1, - started_at: 0, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - await mgr.forgetTask('bash-forget00'); - expect(mgr.getTask('bash-forget00')).toBeUndefined(); - expect(await listTasks(sessionDir)).toEqual([]); - }); - - it('reconcile returns empty when no ghosts loaded', async () => { - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const result = await mgr.reconcile(); - expect(result.lost).toEqual([]); - expect(result.lostInfo).toEqual([]); - }); - - it('reconcile fires onTerminal for each newly-lost ghost', async () => { - await writeTask(sessionDir, { - task_id: 'bash-publish0', - command: 'sleep 9999', - description: 'publish lost', - pid: 42, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - const fired: { taskId: string; status: string }[] = []; - mgr.onTerminal((info) => { - fired.push({ taskId: info.taskId, status: info.status }); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - - expect(fired).toHaveLength(1); - expect(fired[0]?.taskId).toBe('bash-publish0'); - expect(fired[0]?.status).toBe('lost'); - }); - - it('reconcile does not republish already-lost ghosts on second pass', async () => { - await writeTask(sessionDir, { - task_id: 'bash-nodup000', - command: 'sleep 9999', - description: 'dedupe check', - pid: 42, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - const fired: string[] = []; - mgr.onTerminal((info) => { - fired.push(info.taskId); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - // Second reconcile should find nothing to downgrade. - const again = await mgr.reconcile(); - expect(again.lost).toEqual([]); - expect(fired).toEqual(['bash-nodup000']); - }); - - // Stale running task with heartbeat older than threshold gets - // reclassified as `lost` AND its failure_reason is set to the - // canonical "heartbeat expired" string. - it('recover marks a stale heartbeat as lost with the expected failure reason', async () => { - await writeTask(sessionDir, { - task_id: 'bash-stale001', - command: 'sleep 10', - description: 'stale task', - pid: 111, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - const mgr = new BackgroundManager(); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const result = await mgr.reconcile(); - expect(result.lost).toEqual(['bash-stale001']); - const ghost = mgr.getTask('bash-stale001'); - // Py: failure_reason == "Background worker heartbeat expired". - // TS does not carry a failureReason field — gap at the manager - // surface. Assert the py contract. - expect((ghost as unknown as { failureReason?: string }).failureReason).toBe( - 'Background worker heartbeat expired', - ); - }); - - // Full reconcile integration: stale-running task is downgraded to - // lost AND a single `task.lost` notification is published. - it('full reconcile downgrades a stale task and publishes exactly one task.lost notification', async () => { - await writeTask(sessionDir, { - task_id: 'bash-publish1', - command: 'sleep 10', - description: 'publish lost', - pid: 333, - started_at: 1_700_000_000, - ended_at: null, - exit_code: null, - status: 'running', - }); - const fired: { taskId: string; status: string }[] = []; - const mgr = new BackgroundManager(); - mgr.onTerminal((info) => { - fired.push({ taskId: info.taskId, status: info.status }); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - await mgr.reconcile(); - expect(fired).toEqual([{ taskId: 'bash-publish1', status: 'lost' }]); - }); - - // Idempotency: a second reconcile() pass over the same already-terminal - // task does NOT republish its notification (dedupe by task_id). - it('idempotent reconcile: a second pass over a terminal task does not republish', async () => { - await writeTask(sessionDir, { - task_id: 'bash-once0001', - command: 'echo done', - description: 'one-shot', - pid: 42, - started_at: 1_700_000_000, - ended_at: 1_700_000_010, - exit_code: 0, - status: 'completed', - }); - const fired: string[] = []; - const mgr = new BackgroundManager(); - mgr.onTerminal((info) => { - fired.push(info.taskId); - }); - mgr.attachSessionDir(sessionDir); - await mgr.loadFromDisk(); - const first = await mgr.reconcile(); - const second = await mgr.reconcile(); - expect(first.lost).toEqual([]); - expect(second.lost).toEqual([]); - expect(fired).toEqual([]); - }); -}); diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index e9fa428c..c85c2ad0 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -1,33 +1,35 @@ /** * Covers: TaskListTool, TaskOutputTool, TaskStopTool. - * - * Uses KaosProcess fakes. */ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'pathe'; import { Readable } from 'node:stream'; import type { Writable } from 'node:stream'; +import { join } from 'pathe'; import type { KaosProcess } from '@moonshot-ai/kaos'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { AgentBackgroundTask, BackgroundManager } from '../../../src/agent/background'; -import { writeTask } from '../../../src/agent/background/persist'; +import { + AgentBackgroundTask, + BackgroundTaskPersistence, + type BackgroundManager, + type BackgroundTaskInfo, +} from '../../../src/agent/background'; import { TaskListTool } from '../../../src/tools/background/task-list'; import { TaskOutputTool } from '../../../src/tools/background/task-output'; import { TaskStopTool } from '../../../src/tools/background/task-stop'; -import { toolContentString } from '../fixtures/fake-kaos'; +import { + createBackgroundManager, + registerProcess, + waitForOutput, +} from '../../agent/background/helpers'; import { executeTool } from '../fixtures/execute-tool'; +import { toolContentString } from '../fixtures/fake-kaos'; const signal = new AbortController().signal; -async function flushMicrotasks(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - function context(toolCallId: string, args: Input) { return { turnId: '0', toolCallId, args, signal }; } @@ -40,22 +42,20 @@ function immediateProcess(exitCode: number, stdoutText = ''): KaosProcess { pid: 10000 + exitCode, exitCode, wait: vi.fn().mockResolvedValue(exitCode) as KaosProcess['wait'], - // oxlint-disable-next-line unicorn/no-useless-undefined kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], }; } function pendingProcess(): KaosProcess { let resolveWait: (n: number) => void = () => {}; - const waitPromise = new Promise((res) => { - resolveWait = res; + const waitPromise = new Promise((resolve) => { + resolveWait = resolve; }); let currentExitCode: number | null = null; const killSpy = vi.fn(async () => { - if (currentExitCode === null) { - currentExitCode = 143; - resolveWait(143); - } + if (currentExitCode !== null) return; + currentExitCode = 143; + resolveWait(143); }); return { stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, @@ -70,311 +70,216 @@ function pendingProcess(): KaosProcess { }; } -function processExitingAfterTimer(exitCode = 143, delayMs = 5): KaosProcess { - let currentExitCode: number | null = null; - const waitPromise = new Promise((resolve) => { - setTimeout(() => { - currentExitCode = exitCode; - resolve(exitCode); - }, delayMs); - }); +function persistedProcess( + overrides: Partial> = {}, +): Extract { return { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from([]), - stderr: Readable.from([]), - pid: 54322, - get exitCode(): number | null { - return currentExitCode; - }, - wait: () => waitPromise, - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], + taskId: 'bash-deadbeef', + kind: 'process', + command: 'sleep 60', + description: 'persisted task', + pid: 999, + startedAt: 1_700_000_000, + endedAt: 1_700_000_001, + exitCode: null, + status: 'killed', + ...overrides, }; } -async function waitForPersistedOutput( - manager: BackgroundManager, - taskId: string, - expectedOutput: string, -) { - const tool = new TaskOutputTool(manager); - let lastContent = ''; - for (let i = 0; i < 20; i++) { - await manager.loadFromDisk(); - const result = await executeTool(tool, context('c_persisted', { task_id: taskId })); - lastContent = toolContentString(result); - if ( - result.isError === false && - lastContent.includes('status: completed') && - lastContent.includes(expectedOutput) - ) { - return { result, content: lastContent }; - } - await new Promise((r) => { - setTimeout(r, 10); - }); - } - throw new Error(`Task ${taskId} did not persist expected output. Last output:\n${lastContent}`); -} - -async function waitForLiveOutput( - manager: BackgroundManager, - taskId: string, - expectedOutput: string, -): Promise { - for (let i = 0; i < 20; i++) { - if (manager.getOutput(taskId).includes(expectedOutput)) return; - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - } - throw new Error(`Task ${taskId} did not capture expected live output: ${expectedOutput}`); +async function taskOutput(manager: BackgroundManager, taskId: string, block = false): Promise { + const result = await executeTool( + new TaskOutputTool(manager), + context('task_output', { task_id: taskId, block, timeout: 1 }), + ); + expect(result.isError).toBe(false); + return toolContentString(result); } describe('TaskListTool', () => { - const manager = new BackgroundManager(); - const tool = new TaskListTool(manager); - afterEach(() => { - manager._reset(); + vi.useRealTimers(); }); it('has name "TaskList"', () => { - expect(tool.name).toBe('TaskList'); + expect(new TaskListTool(createBackgroundManager().manager).name).toBe('TaskList'); }); it('returns "No background tasks found." when empty', async () => { - const result = await executeTool(tool, context('c1', { active_only: true })); - expect(toolContentString(result)).toContain('No background tasks found'); - }); + const tool = new TaskListTool(createBackgroundManager().manager); - it('lists active tasks', () => { - const proc = pendingProcess(); - manager.register(proc, 'sleep 60', 'test task'); - // Synchronous check — the task is running immediately after register. - const tasks = manager.list(true); - expect(tasks.length).toBe(1); - expect(tasks[0]!.kind).toBe('process'); - if (tasks[0]!.kind !== 'process') throw new Error('expected process task'); - expect(tasks[0]!.command).toBe('sleep 60'); - }); + const result = await executeTool(tool, context('c_empty', { active_only: true })); - it('does not sleep when listing a normally running task', async () => { - vi.useFakeTimers(); - try { - const proc = pendingProcess(); - manager.register(proc, 'sleep 60', 'running list latency test'); + expect(toolContentString(result)).toContain('No background tasks found'); + }); - let settled = false; - const resultPromise = executeTool(tool, context('c_running_list_latency', { active_only: true })); - void resultPromise.then(() => { - settled = true; - }); + it('lists active process tasks', async () => { + const { manager } = createBackgroundManager(); + registerProcess(manager, pendingProcess(), 'sleep 60', 'test task'); - await flushMicrotasks(); + const result = await executeTool( + new TaskListTool(manager), + context('c_active', { active_only: true }), + ); + const content = toolContentString(result); - expect(settled).toBe(true); - const result = await resultPromise; - expect(toolContentString(result)).toContain('sleep 60'); - } finally { - vi.useRealTimers(); - } + expect(content).toMatch(/^active_background_tasks:\s*1/); + expect(content).toContain('kind: process'); + expect(content).toContain('command: sleep 60'); + expect(content).toContain('description: test task'); }); - it('does not list an already-exited process as active', async () => { - const proc = processExitingAfterTimer(143, 0); - manager.register(proc, 'sleep 60', 'external kill list test'); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); + it('excludes terminal tasks from active_only=true', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(0), 'echo done', 'done'); + await manager.wait(taskId); - const result = await executeTool(tool, context('c_just_exited_list', { active_only: true })); + const result = await executeTool( + new TaskListTool(manager), + context('c_active_terminal', { active_only: true }), + ); expect(toolContentString(result)).toContain('No background tasks found'); }); - it('includes a task-count header in the output body', async () => { - const proc = pendingProcess(); - manager.register(proc, 'sleep 60', 'header test'); - const result = await executeTool(tool, context('c_header', { active_only: true })); - expect(toolContentString(result)).toMatch(/^active_background_tasks:\s*1/); - }); + it('includes terminal tasks and exit_code when active_only=false', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(7), 'exit 7', 'exit code test'); + await manager.wait(taskId); - it('reports a zero task count when no tasks exist', async () => { - const result = await executeTool(tool, context('c_header_empty', { active_only: true })); - expect(toolContentString(result)).toMatch(/^active_background_tasks:\s*0/); - }); - - it('labels the header background_tasks (not active) when active_only=false', async () => { - const proc = immediateProcess(0); - manager.register(proc, 'echo done', 'header label test'); - await flushMicrotasks(); - const result = await executeTool(tool, context('c_header_all', { active_only: false })); + const result = await executeTool( + new TaskListTool(manager), + context('c_all_terminal', { active_only: false }), + ); const content = toolContentString(result); - // A terminal task is not "active"; the all-tasks view must use a neutral label. + expect(content).toMatch(/^background_tasks:\s*1/); - expect(content).not.toContain('active_background_tasks'); + expect(content).toContain(taskId); + expect(content).toContain('status: failed'); + expect(content).toContain('exit_code: 7'); }); - it('includes exit_code for terminal tasks', async () => { - const proc = immediateProcess(7); - manager.register(proc, 'exit 7', 'exit code test'); - await flushMicrotasks(); - const result = await executeTool(tool, context('c_exit_code', { active_only: false })); + it('honours the limit parameter', async () => { + const { manager } = createBackgroundManager(); + const first = registerProcess(manager, pendingProcess(), 'sleep 1', 'one'); + const second = registerProcess(manager, pendingProcess(), 'sleep 2', 'two'); + + const result = await executeTool( + new TaskListTool(manager), + context('c_limit', { active_only: true, limit: 1 }), + ); const content = toolContentString(result); - expect(content).toContain('exit_code: 7'); + + expect(content).toContain('active_background_tasks: 1'); + expect(content).toContain(first); + expect(content).not.toContain(second); }); - it('includes the stop reason for tasks ended by TaskStop', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'stop reason test'); + it('includes stop_reason for stopped tasks in all-tasks view', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'stop reason'); await manager.stop(taskId, 'superseded by newer task'); - const result = await executeTool(tool, context('c_stop_reason', { active_only: false })); - const content = toolContentString(result); - expect(content).toContain('stop_reason: superseded by newer task'); - }); - it('omits exit_code and reason for non-terminal tasks', async () => { - const proc = pendingProcess(); - manager.register(proc, 'sleep 60', 'non-terminal test'); - const result = await executeTool(tool, context('c_non_terminal', { active_only: true })); - const content = toolContentString(result); - expect(content).not.toContain('exit_code:'); - expect(content).not.toContain('reason:'); + const result = await executeTool( + new TaskListTool(manager), + context('c_stop_reason', { active_only: false }), + ); + + expect(toolContentString(result)).toContain( + 'stop_reason: superseded by newer task', + ); }); - describe('description', () => { - it('explains the core purpose of enumerating background tasks', () => { - expect(tool.description).toContain('background tasks'); - expect(tool.description.length).toBeGreaterThan(120); - }); - - it('documents the limit parameter bounds and default', () => { - expect(tool.description).toContain('limit'); - expect(tool.description).toContain('20'); - expect(tool.description).toMatch(/1\s*(to|-|–|and)\s*100|between 1 and 100/i); - }); - - it('warns that active_only=false may include lost tasks from a prior process', () => { - expect(tool.description).toMatch(/lost/i); - }); - - it('includes a Guidelines section', () => { - expect(tool.description).toContain('Guidelines:'); - }); - - it('guides re-enumerating tasks after compaction or when task IDs are lost', () => { - expect(tool.description).toMatch(/compaction/i); - expect(tool.description).toMatch(/re-?enumerate|re-?discover/i); - }); - - it('recommends keeping the default active_only=true', () => { - expect(tool.description).toContain('active_only'); - expect(tool.description).toContain('true'); - }); - - it('directs locating a task ID here before using TaskOutput for detail', () => { - expect(tool.description).toContain('TaskOutput'); - }); - - it('states the tool is read-only and safe in plan mode', () => { - expect(tool.description).toMatch(/read-only/i); - expect(tool.description).toMatch(/plan mode/i); - }); + it('does not sleep when listing a running task', async () => { + vi.useFakeTimers(); + const { manager } = createBackgroundManager(); + registerProcess(manager, pendingProcess(), 'sleep 60', 'running list'); + const resultPromise = executeTool( + new TaskListTool(manager), + context('c_latency', { active_only: true }), + ); + + await Promise.resolve(); + const result = await resultPromise; + + expect(toolContentString(result)).toContain('running list'); }); }); describe('TaskOutputTool', () => { - const manager = new BackgroundManager(); - const tool = new TaskOutputTool(manager); - afterEach(() => { - manager._reset(); + vi.useRealTimers(); }); it('has name "TaskOutput"', () => { - expect(tool.name).toBe('TaskOutput'); + expect(new TaskOutputTool(createBackgroundManager().manager).name).toBe('TaskOutput'); }); it('returns error for unknown task', async () => { - const result = await executeTool(tool, context('c1', { task_id: 'bash-unknown0' })); + const result = await executeTool( + new TaskOutputTool(createBackgroundManager().manager), + context('c_unknown', { task_id: 'bash-unknown0' }), + ); + expect(result.isError).toBe(true); expect(toolContentString(result)).toContain('Task not found'); }); - it('returns output for a completed task', async () => { - // TaskOutput reads output exclusively from the on-disk log, so this - // test runs with a session directory attached — matching production, - // where the manager is always constructed with one. A self-contained - // manager + a terminal task keep teardown free of the cleanup race - // that non-terminal tasks (still flushing persistence) would cause. + it('returns live output when no persisted log is available', async () => { + const { manager } = createBackgroundManager(); + const payload = 'DETACHED-PAYLOAD-LINE\n'; + const taskId = registerProcess(manager, immediateProcess(0, payload), 'echo demo', 'demo'); + + await manager.wait(taskId); + await waitForOutput(manager, taskId, 'DETACHED-PAYLOAD-LINE'); + const content = await taskOutput(manager, taskId); + + expect(content).toContain('retrieval_status: success'); + expect(content).toContain('status: completed'); + expect(content).toContain('[output]\nDETACHED-PAYLOAD-LINE'); + expect(content).toContain(`output_size_bytes: ${Buffer.byteLength(payload).toString()}`); + expect(content).not.toContain('output_path:'); + }); + + it('returns persisted output path and guidance when a log is available', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-output-tool-')); - const ownManager = new BackgroundManager(); - ownManager.attachSessionDir(sessionDir); try { - const taskId = ownManager.register( + const { manager } = createBackgroundManager({ sessionDir }); + const taskId = registerProcess( + manager, immediateProcess(0, 'STDOUT-PAYLOAD-LINE\n'), 'echo demo', 'output test', ); - await expect(ownManager.wait(taskId, 5_000)).resolves.toMatchObject({ - status: 'completed', - }); - const result = await executeTool(new TaskOutputTool(ownManager), - context('c2', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); + await manager.wait(taskId); + await waitForOutput(manager, taskId, 'STDOUT-PAYLOAD-LINE'); + const content = await taskOutput(manager, taskId, true); + expect(content).toContain('status: completed'); - // Assert on a payload marker that does NOT collide with the command - // or description, so this genuinely verifies output retrieval rather - // than matching an echoed metadata field. + expect(content).toContain('output_path:'); + expect(content).toContain('full_output_available: true'); + expect(content).toContain('full_output_tool: Read'); + expect(content).toContain('full_output_hint:'); expect(content).toContain('[output]\nSTDOUT-PAYLOAD-LINE'); } finally { - ownManager._reset(); await rm(sessionDir, { recursive: true, force: true }); } }); - it('returns live output when no persisted log is available', async () => { - const taskId = manager.register( - immediateProcess(0, 'DETACHED-PAYLOAD-LINE\n'), - 'echo demo', - 'detached output test', - ); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ - status: 'completed', - }); - await waitForLiveOutput(manager, taskId, 'DETACHED-PAYLOAD-LINE'); - - const result = await executeTool(tool, context('c_detached_output', { task_id: taskId })); - - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('status: completed'); - expect(content).toContain('[output]\nDETACHED-PAYLOAD-LINE'); - expect(content).toContain( - `output_size_bytes: ${String(Buffer.byteLength('DETACHED-PAYLOAD-LINE\n'))}`, + it('returns agent metadata and final summary without process fields', async () => { + const { manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask( + Promise.resolve({ result: 'SUBAGENT-FINAL-SUMMARY\n' }), + 'agent output test', + { agentId: 'agent-child', subagentType: 'coder' }, + ), ); - expect(content).not.toContain('output_path:'); - }); - it('returns agent metadata and final summary without process fields', async () => { - const taskId = manager.registerTask(new AgentBackgroundTask( - Promise.resolve({ result: 'SUBAGENT-FINAL-SUMMARY\n' }), - 'agent output test', - { - agentId: 'agent-child', - subagentType: 'coder', - }, - )); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const result = await executeTool(tool, context('c_agent_output', { task_id: taskId })); + await manager.wait(taskId); + const content = await taskOutput(manager, taskId); - expect(result.isError).toBe(false); - const content = toolContentString(result); expect(content).toContain('kind: agent'); expect(content).toContain('agent_id: agent-child'); expect(content).toContain('subagent_type: coder'); @@ -387,21 +292,21 @@ describe('TaskOutputTool', () => { it('reads persisted output for a task loaded after restart', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-output-')); try { - const writer = new BackgroundManager(); - writer.attachSessionDir(sessionDir); - const taskId = writer.register( + const writer = createBackgroundManager({ sessionDir }).manager; + const taskId = registerProcess( + writer, immediateProcess(0, 'persisted output\n'), 'echo persisted output', 'persist output test', ); + await writer.wait(taskId); + await waitForOutput(writer, taskId, 'persisted output'); - await expect(writer.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const reader = new BackgroundManager(); - reader.attachSessionDir(sessionDir); - const { result, content } = await waitForPersistedOutput(reader, taskId, 'persisted output'); + const reader = createBackgroundManager({ sessionDir }).manager; + await reader.loadFromDisk(); + await reader.reconcile(); + const content = await taskOutput(reader, taskId); - expect(result.isError).toBe(false); expect(content).toContain('status: completed'); expect(content).toContain('output_path:'); expect(content).toContain('persisted output'); @@ -410,403 +315,154 @@ describe('TaskOutputTool', () => { } }); - it('settles an already-exited process before reporting non-blocking output', async () => { - const proc = processExitingAfterTimer(143, 0); - const taskId = manager.register(proc, 'sleep 60', 'external kill output test'); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); + it('returns not_ready for non-blocking running tasks', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'running task'); - const result = await executeTool(tool, context('c_just_exited_output', { task_id: taskId })); + const content = await taskOutput(manager, taskId); - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('retrieval_status: success'); - expect(content).toContain('status: failed'); - expect(content).toContain('exit_code: 143'); + expect(content).toContain('retrieval_status: not_ready'); + expect(content).toContain('status: running'); }); -}); + it('returns timeout for block=true when a running task does not finish', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'blocking task'); -describe('TaskOutputTool — large output truncation + paging protocol', () => { - let sessionDir: string | undefined; + const content = await taskOutput(manager, taskId, true); - afterEach(async () => { - if (sessionDir !== undefined) { - await rm(sessionDir, { recursive: true, force: true }); - sessionDir = undefined; - } + expect(content).toContain('retrieval_status: timeout'); + expect(content).toContain('status: running'); }); - it('truncates output > 32 KiB to a tail preview and reports paging metadata', async () => { - sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-trunc-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - try { - // 200 KiB of distinct content: head marker ... tail marker. - const head = 'HEAD-MARKER\n'; - const tail = 'TAIL-MARKER\n'; - const filler = 'x'.repeat(200 * 1024); - const big = head + filler + tail; - const taskId = manager.register(immediateProcess(0, big), 'echo big', 'large output test'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); + it('surfaces timeout terminal metadata', async () => { + const { manager } = createBackgroundManager(); + const taskId = manager.registerTask( + new AgentBackgroundTask(new Promise(() => {}), 'will time out', { timeoutMs: 1 }), + ); - const tool = new TaskOutputTool(manager); - const result = await executeTool(tool, context('c_big', { task_id: taskId })); - expect(result.isError).toBe(false); - const content = toolContentString(result); + await manager.wait(taskId); + const content = await taskOutput(manager, taskId, true); - // Structured paging metadata. - expect(content).toContain('output_truncated: true'); - expect(content).toContain(`output_size_bytes: ${String(Buffer.byteLength(big))}`); - expect(content).toMatch(/output_preview_bytes: \d+/); - expect(content).toContain('full_output_available: true'); - expect(content).toContain('full_output_tool: Read'); - expect(content).toMatch(/full_output_hint:.*Read/); - - // Preview is the TAIL, not the head, and carries the truncation banner. - expect(content).toContain('[Truncated. Full output:'); - expect(content).toContain('TAIL-MARKER'); - expect(content).not.toContain('HEAD-MARKER'); - } finally { - manager._reset(); - } + expect(content).toContain('status: timed_out'); + expect(content).not.toContain('stop_reason:'); + expect(content).toContain('terminal_reason: timed_out'); }); - it('does not silently drop the head of a > 1 MiB running task', async () => { - sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-ring-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - try { - // Stream > 1 MiB so the in-memory ring buffer (1 MiB cap) would - // otherwise shift() away the earliest chunks. - const chunks = [ - 'FIRST-CHUNK\n', - 'a'.repeat(700 * 1024), - 'b'.repeat(700 * 1024), - 'LAST-CHUNK\n', - ]; - const proc: KaosProcess = { - stdin: { write: vi.fn(), end: vi.fn() } as unknown as Writable, - stdout: Readable.from(chunks), - stderr: Readable.from([]), - pid: 60001, - exitCode: 0, - wait: vi.fn().mockResolvedValue(0) as KaosProcess['wait'], - // oxlint-disable-next-line unicorn/no-useless-undefined - kill: vi.fn().mockResolvedValue(undefined) as KaosProcess['kill'], - }; - const totalBytes = chunks.reduce((s, c) => s + Buffer.byteLength(c), 0); - const taskId = manager.register(proc, 'echo huge', 'huge running output'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const tool = new TaskOutputTool(manager); - const result = await executeTool(tool, context('c_huge', { task_id: taskId })); - expect(result.isError).toBe(false); - const content = toolContentString(result); + it('surfaces stopped terminal metadata', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'stoppable task'); - // The reported size is the FULL disk size — nothing was dropped. - expect(content).toContain(`output_size_bytes: ${String(totalBytes)}`); - expect(content).toContain('output_truncated: true'); - } finally { - manager._reset(); - } - }); + await manager.stop(taskId, 'operator cancelled'); + const content = await taskOutput(manager, taskId); - it('exposes paging guidance (Read + output_path) in the tool description', () => { - const tool = new TaskOutputTool(new BackgroundManager()); - const desc = tool.description; - // Guideline 6 from the parity source: when the preview is truncated, - // page the full log with the `Read` tool and the returned output_path. - expect(desc).toContain('Read'); - expect(desc).toContain('output_path'); - expect(desc.toLowerCase()).toContain('truncat'); - // Must not leak the Python tool name. - expect(desc).not.toContain('ReadFile'); + expect(content).toContain('status: killed'); + expect(content).toContain('stop_reason: operator cancelled'); + expect(content).toContain('terminal_reason: stopped'); }); - it('does not mark small output (< 32 KiB) as truncated', async () => { - sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-small-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); + it('does not advertise output_path when the persisted log file does not exist', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-empty-')); try { - const small = 'small output line\n'; - const taskId = manager.register(immediateProcess(0, small), 'echo small', 'small test'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); + const { manager } = createBackgroundManager({ sessionDir }); + const taskId = registerProcess(manager, immediateProcess(0), 'sleep 1', 'silent task'); - const tool = new TaskOutputTool(manager); - const result = await executeTool(tool, context('c_small', { task_id: taskId })); - expect(result.isError).toBe(false); - const content = toolContentString(result); + await manager.wait(taskId); + const content = await taskOutput(manager, taskId); - expect(content).toContain('output_truncated: false'); - expect(content).not.toContain('[Truncated. Full output:'); - expect(content).toContain('small output line'); - expect(content).toContain(`output_size_bytes: ${String(Buffer.byteLength(small))}`); + expect(content).not.toContain('output_path:'); + expect(content).toContain('output_size_bytes: 0'); + expect(content).toContain('full_output_available: false'); } finally { - manager._reset(); + await rm(sessionDir, { recursive: true, force: true }); } }); - it('flags truncation when the tail window starts mid-multibyte character', async () => { - sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-utf8-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); + it('truncates output > 32 KiB to a tail preview and reports paging metadata', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-trunc-')); try { - // A 3-byte char at the head, then ASCII filler so the log is exactly - // one byte over the 32 KiB preview window. The tail window therefore - // starts at byte offset 1 — inside the leading multibyte char — so - // its first bytes decode to replacement chars (each 3 bytes in - // UTF-8). Counting decoded-string bytes would overshoot the real - // window size and mis-report this truncated output as untruncated. - const previewWindow = 32 * 1024; - const text = '中' + 'a'.repeat(previewWindow - 2); - const sizeBytes = Buffer.byteLength(text); - expect(sizeBytes).toBe(previewWindow + 1); - - const taskId = manager.register(immediateProcess(0, text), 'echo utf8', 'utf8 boundary test'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const tool = new TaskOutputTool(manager); - const result = await executeTool(tool, context('c_utf8', { task_id: taskId })); - expect(result.isError).toBe(false); - const content = toolContentString(result); + const { manager } = createBackgroundManager({ sessionDir }); + const head = 'HEAD-MARKER\n'; + const tail = 'TAIL-MARKER\n'; + const big = head + 'x'.repeat(200 * 1024) + tail; + const taskId = registerProcess(manager, immediateProcess(0, big), 'echo big', 'large'); + + await manager.wait(taskId); + const content = await taskOutput(manager, taskId); - expect(content).toContain(`output_size_bytes: ${String(sizeBytes)}`); expect(content).toContain('output_truncated: true'); - expect(content).toContain(`output_preview_bytes: ${String(previewWindow)}`); + expect(content).toContain(`output_size_bytes: ${Buffer.byteLength(big).toString()}`); expect(content).toContain('full_output_available: true'); + expect(content).toContain('full_output_tool: Read'); expect(content).toContain('[Truncated. Full output:'); + expect(content).toContain('TAIL-MARKER'); + expect(content).not.toContain('HEAD-MARKER'); } finally { - manager._reset(); - } - }); - - it('keeps preview and metadata consistent from a single log-size snapshot', async () => { - sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-grow-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - try { - // A 40 KiB ASCII log — larger than the 32 KiB preview window. - const big = 'a'.repeat(40 * 1024); - const taskId = manager.register(immediateProcess(0, big), 'echo grow', 'growing output'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - // Simulate the race: the size snapshot driving the metadata is taken - // while the log is still smaller (30000 bytes) than its real on-disk - // size (40 KiB) — e.g. a running task that grew after flushOutput(). - // The preview window and the reported metadata must agree regardless. - vi.spyOn(manager, 'getOutputSizeBytes').mockResolvedValue(30_000); - - const result = await executeTool(new TaskOutputTool(manager), - context('c_grow', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - - // The [output] section must contain exactly output_preview_bytes - // bytes — never a 32 KiB tail paired with stale, smaller metadata. - const previewBytesMatch = content.match(/output_preview_bytes: (\d+)/); - expect(previewBytesMatch).not.toBeNull(); - const marker = '[output]\n'; - const outputSection = content.slice(content.indexOf(marker) + marker.length); - expect(Buffer.byteLength(outputSection)).toBe(Number(previewBytesMatch![1])); - } finally { - vi.restoreAllMocks(); - manager._reset(); - } - }); -}); - -describe('TaskOutputTool — terminal metadata fields', () => { - it('exposes stop_reason and terminal_reason for an agent task aborted by its deadline', async () => { - const manager = new BackgroundManager(); - try { - // An agent task whose completion never resolves: the external deadline - // fires and finalizes the task with the timed_out status. - const taskId = manager.registerTask(new AgentBackgroundTask( - new Promise<{ result: string }>(() => {}), - 'slow agent', - { - timeoutMs: 1, - }, - )); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ - status: 'timed_out', - stopReason: undefined, - }); - - const result = await executeTool( - new TaskOutputTool(manager), - context('c_timeout', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('status: timed_out'); - expect(content).not.toContain('stop_reason:'); - expect(content).toContain('terminal_reason: timed_out'); - expect(content).not.toContain('timed_out:'); - } finally { - manager._reset(); - } - }); - - it('exposes stop_reason and terminal_reason for a task stopped via TaskStop', async () => { - const manager = new BackgroundManager(); - try { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'stoppable task'); - await manager.stop(taskId, 'operator cancelled'); - expect(manager.getTask(taskId)).toMatchObject({ - status: 'killed', - stopReason: 'operator cancelled', - }); - - const result = await executeTool( - new TaskOutputTool(manager), - context('c_stopped', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('stop_reason: operator cancelled'); - expect(content).toContain('terminal_reason: stopped'); - } finally { - manager._reset(); - } - }); - - it('omits stop_reason / terminal_reason for a normally completed task', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-meta-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - try { - const taskId = manager.register(immediateProcess(0, 'done\n'), 'echo done', 'normal task'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const result = await executeTool( - new TaskOutputTool(manager), - context('c_normal', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).not.toContain('timed_out:'); - expect(content).not.toContain('stop_reason:'); - expect(content).not.toContain('terminal_reason:'); - } finally { - manager._reset(); - await rm(sessionDir, { recursive: true, force: true }); - } - }); -}); - -describe('TaskOutputTool — full-output guidance', () => { - it('does not advertise an output_path when the persisted log file does not exist', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-empty-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); - try { - const taskId = manager.register(immediateProcess(0), 'sleep 1', 'silent task'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); - - const result = await executeTool( - new TaskOutputTool(manager), - context('c_no_output_file', { task_id: taskId }), - ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - - expect(content).not.toContain('output_path:'); - expect(content).toContain('output_size_bytes: 0'); - expect(content).toContain('full_output_available: false'); - } finally { - manager._reset(); await rm(sessionDir, { recursive: true, force: true }); } }); - it('emits full_output_available / full_output_tool even when output is not truncated', async () => { - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-untrunc-')); - const manager = new BackgroundManager(); - manager.attachSessionDir(sessionDir); + it('lookup of a non-existent task does not create persisted state', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-missing-')); try { - const small = 'small output line\n'; - const taskId = manager.register(immediateProcess(0, small), 'echo small', 'small test'); - await expect(manager.wait(taskId, 5_000)).resolves.toMatchObject({ status: 'completed' }); + const { manager } = createBackgroundManager({ sessionDir }); const result = await executeTool( new TaskOutputTool(manager), - context('c_untrunc', { task_id: taskId }), + context('c_missing', { task_id: 'bash-noex0000' }), ); - expect(result.isError).toBe(false); - const content = toolContentString(result); - expect(content).toContain('output_truncated: false'); - expect(content).toContain('full_output_available: true'); - expect(content).toContain('full_output_tool: Read'); - expect(content).toMatch(/full_output_hint:.*Read/); + expect(result.isError).toBe(true); + expect(await new BackgroundTaskPersistence(sessionDir).listTasks()).toEqual([]); } finally { - manager._reset(); await rm(sessionDir, { recursive: true, force: true }); } }); }); describe('TaskStopTool', () => { - const manager = new BackgroundManager(); - const tool = new TaskStopTool(manager); - - afterEach(() => { - manager._reset(); - }); - it('has name "TaskStop"', () => { - expect(tool.name).toBe('TaskStop'); - }); - - it('description warns about destructive side effects and usage constraints', () => { - const description = tool.description; - // Only use when cancellation is genuinely required. - expect(description).toContain('TaskOutput'); - expect(description.toLowerCase()).toContain('cancel'); - // Destructive risk warning. - expect(description.toLowerCase()).toContain('destructive'); - expect(description.toLowerCase()).toContain('side effect'); - // Already-finished tasks just return current status. - expect(description.toLowerCase()).toContain('already'); + expect(new TaskStopTool(createBackgroundManager().manager).name).toBe('TaskStop'); }); it('returns error for unknown task', async () => { - const result = await executeTool(tool, context('c1', { task_id: 'bash-unknown0' })); + const result = await executeTool( + new TaskStopTool(createBackgroundManager().manager), + context('c_unknown', { task_id: 'bash-unknown0' }), + ); + expect(result.isError).toBe(true); expect(toolContentString(result)).toContain('Task not found'); }); - it('stops a running task', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'stop test'); - const result = await executeTool(tool, - context('c2', { task_id: taskId, reason: 'custom stop reason' }), + it('stops a running task and records the reason', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'stop test'); + + const result = await executeTool( + new TaskStopTool(manager), + context('c_stop', { task_id: taskId, reason: 'custom stop reason' }), ); + expect(result.isError).toBe(false); - expect(toolContentString(result)).toContain('killed'); + expect(toolContentString(result)).toContain('status: killed'); expect(toolContentString(result)).toContain('custom stop reason'); expect(manager.getTask(taskId)?.stopReason).toBe('custom stop reason'); }); - it('persists stop reason when attached to a session directory', async () => { + it('persists stop reason when the manager has persistence', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-reason-')); try { - const writer = new BackgroundManager(); - writer.attachSessionDir(sessionDir); - const taskId = writer.register(pendingProcess(), 'sleep 60', 'persist stop reason test'); + const writer = createBackgroundManager({ sessionDir }).manager; + const taskId = registerProcess(writer, pendingProcess(), 'sleep 60', 'persist stop'); - const result = await executeTool(new TaskStopTool(writer), + const result = await executeTool( + new TaskStopTool(writer), context('c_stop_reason', { task_id: taskId, reason: 'operator cancelled' }), ); expect(result.isError).toBe(false); - const reader = new BackgroundManager(); - reader.attachSessionDir(sessionDir); + const reader = createBackgroundManager({ sessionDir }).manager; await reader.loadFromDisk(); expect(reader.getTask(taskId)?.stopReason).toBe('operator cancelled'); } finally { @@ -814,66 +470,48 @@ describe('TaskStopTool', () => { } }); - // The empty-string case is the core of the fix: `args.reason ?? default` - // does NOT coalesce `''` (since `'' ?? x === ''`), so the implementation - // must trim-and-`||` instead. An explicit `''` case guards that, alongside - // a whitespace-only string and an omitted `reason`. it.each([ { label: 'an empty-string reason', reason: '' }, { label: 'a whitespace-only reason', reason: ' ' }, { label: 'an omitted reason', reason: undefined as string | undefined }, ])('falls back to default reason given $label', async ({ reason }) => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'empty reason test'); - const result = await executeTool(tool, + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'empty reason test'); + + const result = await executeTool( + new TaskStopTool(manager), context('c_empty_reason', { task_id: taskId, reason }), ); + expect(result.isError).toBe(false); expect(toolContentString(result)).toContain('reason: Stopped by TaskStop'); expect(manager.getTask(taskId)?.stopReason).toBe('Stopped by TaskStop'); }); - it('returns info when task is already in terminal state', async () => { - const proc = immediateProcess(0); - const taskId = manager.register(proc, 'echo done', 'terminal test'); + it('returns info when task is already terminal', async () => { + const { manager } = createBackgroundManager(); + const taskId = registerProcess(manager, immediateProcess(0), 'echo done', 'terminal test'); + await manager.wait(taskId); - // Let wait() settle. - await new Promise((r) => { - setTimeout(r, 20); - }); + const result = await executeTool( + new TaskStopTool(manager), + context('c_terminal', { task_id: taskId }), + ); - const result = await executeTool(tool, context('c3', { task_id: taskId })); expect(result.isError).toBe(false); - // Terminal-state path uses the same structured multi-line format as the - // normal stop path: task_id / status / reason — each on its own line. - const lines = toolContentString(result).trim().split('\n'); - expect(lines).toHaveLength(3); - expect(lines[0]).toBe(`task_id: ${taskId}`); - expect(lines[1]).toBe('status: completed'); - // A cleanly exited task has no stopReason, so this exercises the - // placeholder fallback used when `stopReason` is undefined. - expect(lines[2]).toBe('reason: Task already in terminal state'); + expect(toolContentString(result).trim().split('\n')).toEqual([ + `task_id: ${taskId}`, + 'status: completed', + 'reason: Task already in terminal state', + ]); }); it('falls back to the placeholder when a terminal task has a blank stored reason', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-blank-stored-reason-')); try { - // A task persisted by an older build (or a caller that passed `''`) - // can carry a blank `stop_reason` on disk; the terminal-state branch - // must not surface it as a bare `reason: ` line. - await writeTask(sessionDir, { - task_id: 'bash-deadbeef', - command: 'sleep 60', - description: 'legacy blank reason', - pid: 999, - started_at: 1_700_000_000, - ended_at: 1_700_000_001, - exit_code: null, - status: 'killed', - stop_reason: '', - }); - const reader = new BackgroundManager(); - reader.attachSessionDir(sessionDir); + const persistence = new BackgroundTaskPersistence(sessionDir); + await persistence.writeTask(persistedProcess({ stopReason: '' })); + const reader = createBackgroundManager({ sessionDir }).manager; await reader.loadFromDisk(); const result = await executeTool( @@ -882,299 +520,42 @@ describe('TaskStopTool', () => { ); expect(result.isError).toBe(false); - const lines = toolContentString(result).trim().split('\n'); - expect(lines[2]).toBe('reason: Task already in terminal state'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); -}); - -// ── py-aligned envelope contracts ────────────────────────────────────── - -describe('TaskOutputTool — py envelope contract', () => { - const manager = new BackgroundManager(); - const tool = new TaskOutputTool(manager); - - afterEach(() => { - manager._reset(); - }); - - // Completed task envelope must include: retrieval_status:success + - // status:completed + output_path + output_truncated:false + - // full_output_tool: Read + full_output_hint. - // TS only emits the full_output_tool / hint pair when a persisted log - // exists, so the manager must be attached to a session dir. - it('completed task returns a rich envelope with output_truncated/full_output_tool/full_output_hint', async () => { - const { mkdtemp, rm } = await import('node:fs/promises'); - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-env-')); - try { - const m2 = new BackgroundManager(); - m2.attachSessionDir(sessionDir); - const t = new TaskOutputTool(m2); - const proc = immediateProcess(0, 'build line 1\nbuild line 2\n'); - const taskId = m2.register(proc, 'make build', 'completed envelope'); - await m2.wait(taskId, 5_000); - const result = await executeTool(t, context('c_env', { task_id: taskId, block: true, timeout: 1 })); - expect(result.isError).toBe(false); - const text = toolContentString(result); - expect(text).toContain('retrieval_status: success'); - expect(text).toContain('status: completed'); - expect(text).toContain('output_truncated: false'); - // TS uses `Read` rather than Python's `ReadFile`; test asserts the - // TS-native tool name (see decision in PR description). - expect(text).toContain('full_output_tool: Read'); - expect(text).toContain('full_output_hint:'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - // block omitted defaults to non-blocking → not_ready + a brief - // "Task snapshot retrieved." surface. - it('omitting block defaults to non-blocking with a "Task snapshot retrieved." brief', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'default block'); - const result = await executeTool(tool, context('c_default', { task_id: taskId })); - expect(result.isError).toBe(false); - const text = toolContentString(result); - expect(text).toContain('retrieval_status: not_ready'); - expect(text).toContain('status: running'); - // Py: result.message == "Task snapshot retrieved." - const message = (result as unknown as { message?: string }).message; - expect(message).toBe('Task snapshot retrieved.'); - }); - - // block=True + timeout=0 on a still-running task surfaces - // retrieval_status:timeout (not_ready is for non-blocking only). - it('block=true with timeout=0 on a running task surfaces retrieval_status:timeout', async () => { - const proc = pendingProcess(); - const taskId = manager.register(proc, 'sleep 60', 'blocking timeout'); - const result = await executeTool( - tool, - context('c_block_timeout', { task_id: taskId, block: true, timeout: 0 }), - ); - expect(result.isError).toBe(false); - const text = toolContentString(result); - expect(text).toContain('retrieval_status: timeout'); - expect(text).toContain('status: running'); - }); - - // Lookup of a non-existent task returns error AND must not create a - // ghost entry or any task file on disk. - it('lookup of a non-existent task does not pollute the store', async () => { - const { mkdtemp, readdir, rm } = await import('node:fs/promises'); - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-missing-')); - try { - const m2 = new BackgroundManager(); - m2.attachSessionDir(sessionDir); - const t = new TaskOutputTool(m2); - const r = await executeTool(t, context('c_missing', { task_id: 'bash-noex0000' })); - expect(r.isError).toBe(true); - expect(toolContentString(r)).toContain('Task not found'); - const top = await readdir(sessionDir); - expect(top.includes('tasks')).toBe(false); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); - - // For a task that timed out, the envelope surfaces: - // status:timed_out + terminal_reason:timed_out. The Python contract also includes - // `interrupted: true` and a standalone `reason:` line; TS deliberately - // omits both — `interrupted` is not modeled, and the categorical - // `terminal_reason` is preferred over a separate prose `reason` field - // (PR#243 by-design exclusion). The TS contract assertions below - // suffice; the dropped assertions are documented for traceability. - it('a timed-out task surfaces the full timeout contract', async () => { - // Build a manager state where status=timed_out. - const taskId = manager.registerTask(new AgentBackgroundTask(new Promise(() => {}), 'will time out', { - timeoutMs: 50, - })); - const info = await manager.waitForTerminal(taskId); - expect(info?.status).toBe('timed_out'); - expect(info?.stopReason).toBeUndefined(); - - const result = await executeTool( - tool, - context('c_timeout_contract', { task_id: taskId, block: true, timeout: 1 }), - ); - expect(result.isError).toBe(false); - const text = toolContentString(result); - expect(text).toContain('status: timed_out'); - expect(text).not.toContain('stop_reason:'); - expect(text).toContain('terminal_reason: timed_out'); - expect(text).not.toContain('timed_out:'); - }); - - // Oversized output (>32KB): the envelope truncates to a preview - // (32KB tail) + output_path + output_preview_bytes:32768 + - // output_size_bytes + output_truncated:true + a "Truncated. Full - // output: ${path}" banner + ReadFile hint with line_offset/n_lines. - // (gap #5.) - it('oversized output surfaces a truncated preview and full log path', async () => { - const { mkdtemp, rm } = await import('node:fs/promises'); - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-trunc-')); - try { - const big = 'first marker\n' + 'x'.repeat(33 * 1024) + '\nlast marker\n'; - const m2 = new BackgroundManager(); - m2.attachSessionDir(sessionDir); - const taskId = m2.register(immediateProcess(0, big), 'big', 'big output'); - await m2.wait(taskId, 5_000); - - const t = new TaskOutputTool(m2); - const r = await executeTool(t, context('c_trunc', { task_id: taskId, block: true, timeout: 1 })); - expect(r.isError).toBe(false); - const text = toolContentString(r); - expect(text).toContain('output_preview_bytes: 32768'); - expect(text).toContain('output_truncated: true'); - expect(text).toMatch(/output_size_bytes: \d+/); - expect(text).toMatch(/\[Truncated\. Full output: .*\]/); - // Tail preview should keep the last marker, drop the head. - expect(text).toContain('last marker'); - expect(text).not.toContain('first marker'); - // TS uses prose ("Use the Read tool with the output_path ... - // parameters: path, line_offset, n_lines") instead of Python's - // literal `ReadFile(path=..., line_offset=1, n_lines=N)` call - // syntax. Assert the keywords that signal the same intent. - expect(text).toMatch(/Read/); - expect(text).toMatch(/line_offset/); - expect(text).toMatch(/n_lines/); + expect(toolContentString(result).trim().split('\n')[2]).toBe( + 'reason: Task already in terminal state', + ); } finally { await rm(sessionDir, { recursive: true, force: true }); } }); }); -describe('TaskListTool — py envelope contract', () => { - const manager = new BackgroundManager(); - const tool = new TaskListTool(manager); - - afterEach(() => { - manager._reset(); - }); - - // TaskList(active_only=True) emits an 'active_background_tasks: N' - // header so the LLM can see the count distinct from the per-task body. - it('active_only=true emits an active_background_tasks header', async () => { - const proc = pendingProcess(); - manager.register(proc, 'sleep 60', 'running'); - const result = await executeTool(tool, context('c_active_header', { active_only: true })); - expect(result.isError).toBe(false); - expect(toolContentString(result)).toContain('active_background_tasks: 1'); - }); - - // TaskList(active_only=False, limit=1) emits a 'background_tasks: N' - // header and honours the limit. - it('active_only=false emits a background_tasks header and honours limit', async () => { - const proc = immediateProcess(0); - const taskId = manager.register(proc, 'echo done', 'done'); - await new Promise((r) => { - setTimeout(r, 20); - }); - expect(manager.getTask(taskId)?.status).toBe('completed'); - const result = await executeTool( - tool, - context('c_all_header', { active_only: false, limit: 1 }), - ); - expect(result.isError).toBe(false); - const text = toolContentString(result); - expect(text).toContain('background_tasks: 1'); - expect(text).toContain(taskId); - }); -}); - -// task-list / task-stop / task-output description copy contracts — -// the LLM-facing description text must mention specific keywords so -// agents know when to reach for each tool. - describe('background tool descriptions', () => { - const manager = new BackgroundManager(); - afterEach(() => { - manager._reset(); - }); + const manager = createBackgroundManager().manager; - it('TaskOutput description mentions background tasks, block param, output_path, Read fallback', () => { - const tool = new TaskOutputTool(manager); - const desc = tool.description; - expect(desc).toMatch(/background/i); - expect(desc).toContain('Agent(run_in_background=true)'); - expect(desc).toMatch(/block/); - expect(desc).toMatch(/output_path/); - // TS uses `Read` rather than Python's `ReadFile` for the full-log - // fallback tool; assert the TS-native name. - expect(desc).toMatch(/Read/); - }); + it('TaskOutput description mentions background tasks, block, output_path, and Read', () => { + const description = new TaskOutputTool(manager).description; - it('TaskList description mentions active_only default, read-only, plan-mode safe', () => { - const tool = new TaskListTool(manager); - const desc = tool.description; - expect(desc).toMatch(/active_only/); - expect(desc).toMatch(/read[- ]only/i); - expect(desc).toMatch(/plan[- ]mode/i); - expect(desc).toMatch(/background tasks?/i); + expect(description).toMatch(/background/i); + expect(description).toMatch(/block/); + expect(description).toMatch(/output_path/); + expect(description).toMatch(/Read/); }); - it('TaskStop description clarifies destructive cancellation and is generic (not bash-only)', () => { - const tool = new TaskStopTool(manager); - const desc = tool.description; - expect(desc).toMatch(/destructive/i); - expect(desc).toMatch(/cancel/i); - // TS phrasing uses "general-purpose"; Python uses "generic". Accept - // either since they convey the same "not bash-specific" intent. - expect(desc).toMatch(/general[-\s]?purpose|generic/i); - expect(desc).not.toMatch(/bash[- ]?only/i); - }); -}); + it('TaskList description mentions active_only default, read-only, and plan-mode safety', () => { + const description = new TaskListTool(manager).description; -// Behavioral coverage for partial-output reads. Python exposes -// `read_output(offset, max_bytes) → {text, next_offset, eof}` and a -// dedicated `tail_output(max_bytes, max_lines)` on the BPM. TS solves -// the same problems through different surfaces: byte-range reads go -// through `readOutputBytesFromDisk` and line-bounded tails go through -// `getOutput(taskId, tail)`. These tests exercise the TS surface to -// lock down the underlying behavior, not the Python method names. -describe('background store — partial output reads (TS surface)', () => { - const manager = new BackgroundManager(); - afterEach(() => { - manager._reset(); + expect(description).toMatch(/active_only/); + expect(description).toMatch(/read[- ]only/i); + expect(description).toMatch(/plan[- ]mode/i); + expect(description).toMatch(/background tasks?/i); }); - it('readOutputBytesFromDisk returns the requested byte window', async () => { - const { mkdtemp, rm } = await import('node:fs/promises'); - const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-range-')); - try { - const m2 = new BackgroundManager(); - m2.attachSessionDir(sessionDir); - const proc = immediateProcess(0, 'line1\nline2\nline3\n'); - const taskId = m2.register(proc, 'echo lines', 'range read'); - await m2.wait(taskId, 5_000); - // Behavior contract: byte offset 0, length 7 returns "line1\nl". - const chunk = await m2.readOutputBytesFromDisk(taskId, 0, 7); - expect(chunk).toBe('line1\nl'); - // A second window that starts where the first ended returns the - // next slice — i.e. callers can paginate by tracking the offset. - const next = await m2.readOutputBytesFromDisk(taskId, 7, 7); - expect(next).toBe('ine2\nli'); - } finally { - await rm(sessionDir, { recursive: true, force: true }); - } - }); + it('TaskStop description clarifies destructive cancellation and generic behavior', () => { + const description = new TaskStopTool(manager).description; - it('getOutput with a tail arg returns the last N characters', async () => { - const proc = immediateProcess(0, 'line1\nline2\nline3\n'); - const taskId = manager.register(proc, 'echo lines', 'tail read'); - await new Promise((r) => { - setTimeout(r, 50); - }); - // Behavior contract: TS's tail is character-bounded (Python's was - // line-bounded; the line-tail concern is satisfied at the - // task-output.ts layer instead). Asking for the last 12 chars of - // "line1\nline2\nline3\n" yields "line2\nline3\n". - const tail = manager.getOutput(taskId, 12); - expect(tail).toBe('line2\nline3\n'); + expect(description).toMatch(/destructive/i); + expect(description).toMatch(/cancel/i); + expect(description).toMatch(/general[-\s]?purpose|generic/i); + expect(description).not.toMatch(/bash[- ]?only/i); }); }); - -// Reuse imports from the top of the file. The helper-suite needs -// mkdtemp/tmpdir/join — already imported at the top. diff --git a/packages/agent-core/test/tools/bash.test.ts b/packages/agent-core/test/tools/bash.test.ts index 8cff5511..c1ce2a6a 100644 --- a/packages/agent-core/test/tools/bash.test.ts +++ b/packages/agent-core/test/tools/bash.test.ts @@ -3,8 +3,8 @@ import { PassThrough, Readable, type Writable } from 'node:stream'; import type { Environment, KaosProcess } from '@moonshot-ai/kaos'; import { describe, expect, it, vi } from 'vitest'; -import { BackgroundManager } from '../../src/agent/background'; import { type BashInput, BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash'; +import { createBackgroundManager, registerProcess } from '../agent/background/helpers'; import { createFakeKaos } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; @@ -242,7 +242,7 @@ describe('BashTool', () => { const tool = new BashTool( createFakeKaos({ osEnv: posixEnv }), '/workspace', - new BackgroundManager(), + createBackgroundManager().manager, ); expect(tool.description).toContain('Commands available'); @@ -454,7 +454,7 @@ describe('BashTool', () => { expect(unavailable.output).toContain('Background execution is not available'); expect(execWithEnv).not.toHaveBeenCalled(); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const withManager = new BashTool( createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', @@ -472,7 +472,7 @@ describe('BashTool', () => { it('registers background commands and returns a task id', async () => { const proc = processWithOutput(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -484,10 +484,11 @@ describe('BashTool', () => { expect(manager.list(false)).toHaveLength(1); }); - it('does not spawn background commands when the task limit is reached', async () => { - const manager = new BackgroundManager({ maxRunningTasks: 1 }); - manager.register(processWithOutput(), 'sleep 10', 'existing task'); - const execWithEnv = vi.fn().mockResolvedValue(processWithOutput()); + it('kills a spawned background command when the task limit is reached', async () => { + const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager; + registerProcess(manager, processWithOutput(), 'sleep 10', 'existing task'); + const rejectedProc = processWithOutput(); + const execWithEnv = vi.fn().mockResolvedValue(rejectedProc); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -498,19 +499,20 @@ describe('BashTool', () => { isError: true, output: 'Too many background tasks are already running.', }); - expect(execWithEnv).not.toHaveBeenCalled(); + expect(execWithEnv).toHaveBeenCalledTimes(1); + expect(rejectedProc.kill).toHaveBeenCalledWith('SIGTERM'); }); - it('reserves a task slot before spawning concurrent background commands', async () => { - const manager = new BackgroundManager({ maxRunningTasks: 1 }); + it('rejects one of two concurrent background commands when the task limit is reached', async () => { + const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager; + const firstProc = processWithOutput({ + wait: () => new Promise(() => {}), + }); + const secondProc = processWithOutput(); const execWithEnv = vi .fn() - .mockResolvedValueOnce( - processWithOutput({ - wait: () => new Promise(() => {}), - }), - ) - .mockResolvedValueOnce(processWithOutput()); + .mockResolvedValueOnce(firstProc) + .mockResolvedValueOnce(secondProc); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const first = executeTool(tool, @@ -522,7 +524,8 @@ describe('BashTool', () => { const results = await Promise.all([first, second]); - expect(execWithEnv).toHaveBeenCalledTimes(1); + expect(execWithEnv).toHaveBeenCalledTimes(2); + expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); expect(results).toContainEqual(expect.objectContaining({ isError: false })); expect(results).toContainEqual( expect.objectContaining({ @@ -532,16 +535,16 @@ describe('BashTool', () => { ); }); - it('preserves background reservations while using Git Bash semantics on Windows', async () => { - const manager = new BackgroundManager({ maxRunningTasks: 1 }); + it('uses Git Bash semantics and rejects the concurrent command at the task limit', async () => { + const manager = createBackgroundManager({ maxRunningTasks: 1 }).manager; + const firstProc = processWithOutput({ + wait: () => new Promise(() => {}), + }); + const secondProc = processWithOutput(); const execWithEnv = vi .fn() - .mockResolvedValueOnce( - processWithOutput({ - wait: () => new Promise(() => {}), - }), - ) - .mockResolvedValueOnce(processWithOutput()); + .mockResolvedValueOnce(firstProc) + .mockResolvedValueOnce(secondProc); const tool = new BashTool( createFakeKaos({ execWithEnv, osEnv: windowsBashEnv }), 'C:\\Users\\me\\project', @@ -565,7 +568,7 @@ describe('BashTool', () => { const results = await Promise.all([first, second]); - expect(execWithEnv).toHaveBeenCalledTimes(1); + expect(execWithEnv).toHaveBeenCalledTimes(2); const [argv, env] = execWithEnv.mock.calls[0]!; expect(argv).toEqual([ 'C:\\Program Files\\Git\\bin\\bash.exe', @@ -573,6 +576,7 @@ describe('BashTool', () => { "cd '/c/Users/me/project' && echo ok 2>/dev/null", ]); expect(env).toMatchObject({ SHELL: 'C:\\Program Files\\Git\\bin\\bash.exe' }); + expect(secondProc.kill).toHaveBeenCalledWith('SIGTERM'); expect(results).toContainEqual(expect.objectContaining({ isError: false })); expect(results).toContainEqual( expect.objectContaining({ @@ -587,7 +591,7 @@ describe('BashTool', () => { try { const { proc, finishWait, markExited } = processWithVisibleExitBeforeWait(0); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -623,7 +627,7 @@ describe('BashTool', () => { try { const proc = processThatNeverExits(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -648,7 +652,7 @@ describe('BashTool', () => { try { const proc = processThatNeverExits(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool(tool, @@ -779,7 +783,7 @@ describe('BashTool', () => { it('reports background task startup with task_id, status, automatic_notification, and a human-shell hint', async () => { const proc = processWithOutput(); const execWithEnv = vi.fn().mockResolvedValue(proc); - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); const result = await executeTool( @@ -797,7 +801,7 @@ describe('BashTool', () => { }); it('rejects background command without description (description-required guard)', async () => { - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; const execWithEnv = vi.fn().mockResolvedValue(processWithOutput()); const tool = new BashTool(createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', manager); @@ -838,7 +842,7 @@ describe('BashTool', () => { const tool = new BashTool( createFakeKaos({ osEnv: posixEnv }), '/workspace', - new BackgroundManager(), + createBackgroundManager().manager, ); const description = tool.description; @@ -862,7 +866,7 @@ describe('BashTool prompt / runtime consistency', () => { const enabledTool = new BashTool( createFakeKaos({ execWithEnv, osEnv: posixEnv }), '/workspace', - new BackgroundManager(), + createBackgroundManager().manager, ); const promptToolNames = new Set( [...enabledTool.description.matchAll(/`(Task[A-Za-z]+)`/g)].map((match) => match[1]), diff --git a/packages/agent-core/test/tools/builtin-current.test.ts b/packages/agent-core/test/tools/builtin-current.test.ts index 8d7527fe..ad6c9a0c 100644 --- a/packages/agent-core/test/tools/builtin-current.test.ts +++ b/packages/agent-core/test/tools/builtin-current.test.ts @@ -13,7 +13,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { Agent } from '../../src/agent'; import type { SessionSubagentHost } from '../../src/session/subagent-host'; import { SkillRegistry } from '../../src/skill'; -import { BackgroundManager } from '../../src/agent/background'; import { TaskListInputSchema } from '../../src/tools/background/task-list'; import { TaskOutputInputSchema } from '../../src/tools/background/task-output'; import { TaskStopInputSchema } from '../../src/tools/background/task-stop'; @@ -32,6 +31,7 @@ import { BashInputSchema, BashTool } from '../../src/tools/builtin/shell/bash'; import type { WorkspaceConfig } from '../../src/tools/support/workspace'; import { createFakeKaos } from './fixtures/fake-kaos'; import { executeTool } from './fixtures/execute-tool'; +import { createBackgroundManager } from '../agent/background/helpers'; const signal = new AbortController().signal; const workspace: WorkspaceConfig = { workspaceDir: '/workspace', additionalDirs: [] }; @@ -310,7 +310,7 @@ describe('current builtin collaboration tools', () => { describe('current builtin background tool schemas', () => { it('background task schemas and manager-backed tools are covered', () => { - const manager = new BackgroundManager(); + const manager = createBackgroundManager().manager; expect(TaskListInputSchema.safeParse({ active_only: true }).success).toBe(true); expect(TaskOutputInputSchema.safeParse({ task_id: 'bash-1' }).success).toBe(true); From 0b8810f17f243b859b5142ea8a512299582a1df4 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 22:28:14 +0800 Subject: [PATCH 17/21] test: update sdk background task typing --- .../node-sdk/examples/kimi-harness-prompt-demo.ts | 1 - packages/node-sdk/examples/runtime-smoke-helpers.ts | 1 - .../node-sdk/test/session-background-tasks.test.ts | 13 ++----------- packages/node-sdk/test/session-event-types.test.ts | 1 - 4 files changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/node-sdk/examples/kimi-harness-prompt-demo.ts b/packages/node-sdk/examples/kimi-harness-prompt-demo.ts index 34aee28c..8ea4792e 100644 --- a/packages/node-sdk/examples/kimi-harness-prompt-demo.ts +++ b/packages/node-sdk/examples/kimi-harness-prompt-demo.ts @@ -119,7 +119,6 @@ function handleEvent( case 'compaction.cancelled': case 'compaction.completed': case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': break; } diff --git a/packages/node-sdk/examples/runtime-smoke-helpers.ts b/packages/node-sdk/examples/runtime-smoke-helpers.ts index 9d5fed85..02242287 100644 --- a/packages/node-sdk/examples/runtime-smoke-helpers.ts +++ b/packages/node-sdk/examples/runtime-smoke-helpers.ts @@ -235,7 +235,6 @@ function logEvent(event: Event): void { case 'compaction.cancelled': case 'compaction.completed': case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': break; } diff --git a/packages/node-sdk/test/session-background-tasks.test.ts b/packages/node-sdk/test/session-background-tasks.test.ts index 735df1a6..72293b8a 100644 --- a/packages/node-sdk/test/session-background-tasks.test.ts +++ b/packages/node-sdk/test/session-background-tasks.test.ts @@ -11,7 +11,7 @@ afterEach(async () => { await removeTempDirs(tempDirs); }); -describe('Session.listBackgroundTasks / getBackgroundTaskOutput / getBackgroundTaskOutputPath', () => { +describe('Session.listBackgroundTasks / getBackgroundTaskOutput', () => { it('lists an empty task set for a fresh session', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-bgtask-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-bgtask-work-'); @@ -29,7 +29,7 @@ describe('Session.listBackgroundTasks / getBackgroundTaskOutput / getBackgroundT } }); - it('returns empty output and undefined path for an unknown task id', async () => { + it('returns empty output for an unknown task id', async () => { const homeDir = await makeTempDir(tempDirs, 'kimi-sdk-bgtask-home-'); const workDir = await makeTempDir(tempDirs, 'kimi-sdk-bgtask-work-'); const harness = new KimiHarness({ homeDir, identity: TEST_IDENTITY }); @@ -38,7 +38,6 @@ describe('Session.listBackgroundTasks / getBackgroundTaskOutput / getBackgroundT const session = await harness.createSession({ id: 'ses_bg_unknown', workDir }); // Unknown task ids must not throw — UI fetches output speculatively. await expect(session.getBackgroundTaskOutput('bash-deadbeef')).resolves.toBe(''); - await expect(session.getBackgroundTaskOutputPath('bash-deadbeef')).resolves.toBeUndefined(); } finally { await harness.close(); } @@ -55,10 +54,6 @@ describe('Session.listBackgroundTasks / getBackgroundTaskOutput / getBackgroundT name: 'KimiError', code: 'background.task_id_empty', } satisfies Partial); - await expect(session.getBackgroundTaskOutputPath(' ')).rejects.toMatchObject({ - name: 'KimiError', - code: 'background.task_id_empty', - } satisfies Partial); await expect(session.stopBackgroundTask('')).rejects.toMatchObject({ name: 'KimiError', code: 'background.task_id_empty', @@ -85,10 +80,6 @@ describe('Session.listBackgroundTasks / getBackgroundTaskOutput / getBackgroundT name: 'KimiError', code: 'session.closed', } satisfies Partial); - await expect(session.getBackgroundTaskOutputPath('bash-aaaaaaaa')).rejects.toMatchObject({ - name: 'KimiError', - code: 'session.closed', - } satisfies Partial); await expect(session.stopBackgroundTask('bash-aaaaaaaa')).rejects.toMatchObject({ name: 'KimiError', code: 'session.closed', diff --git a/packages/node-sdk/test/session-event-types.test.ts b/packages/node-sdk/test/session-event-types.test.ts index 040a9b28..bf865f97 100644 --- a/packages/node-sdk/test/session-event-types.test.ts +++ b/packages/node-sdk/test/session-event-types.test.ts @@ -81,7 +81,6 @@ describe('Event public types', () => { case 'compaction.cancelled': case 'compaction.completed': case 'background.task.started': - case 'background.task.updated': case 'background.task.terminated': case 'cron.fired': return; From 0473cca202c87fcdd5b6c148632a7cf36521660a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 1 Jun 2026 22:34:23 +0800 Subject: [PATCH 18/21] test: align timeout background task status --- apps/kimi-code/test/tui/background-task-status.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/test/tui/background-task-status.test.ts b/apps/kimi-code/test/tui/background-task-status.test.ts index b8bb8d7a..70066784 100644 --- a/apps/kimi-code/test/tui/background-task-status.test.ts +++ b/apps/kimi-code/test/tui/background-task-status.test.ts @@ -87,8 +87,7 @@ describe('formatBackgroundTaskTranscript', () => { const data = formatBackgroundTaskTranscript( task({ taskId: 'agent-aaaaaaaa', - status: 'failed', - stopReason: 'Timed out', + status: 'timed_out', endedAt: Date.now(), }), ); @@ -100,6 +99,7 @@ describe('formatBackgroundTaskTranscript', () => { 'running', 'completed', 'failed', + 'timed_out', 'killed', 'lost', ]; From 57a3a7b71f3ebf0b94e9a5ff351193df6e68044a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 11:32:07 +0800 Subject: [PATCH 19/21] fix --- .../kimi-code/src/tui/utils/message-replay.ts | 5 ++- .../kimi-code/test/tui/message-replay.test.ts | 40 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/kimi-code/src/tui/utils/message-replay.ts b/apps/kimi-code/src/tui/utils/message-replay.ts index 9dc00ff5..793194b0 100644 --- a/apps/kimi-code/src/tui/utils/message-replay.ts +++ b/apps/kimi-code/src/tui/utils/message-replay.ts @@ -92,8 +92,9 @@ export function replayBackgroundProjection( for (const info of background) { if (info.kind !== 'agent') continue; if (isTerminalBackgroundTask(info)) continue; - backgroundAgentMetadata.set(info.taskId, { - agentId: info.agentId ?? info.taskId, + const agentId = info.agentId ?? info.taskId; + backgroundAgentMetadata.set(agentId, { + agentId, parentToolCallId: info.taskId, description: info.description, }); diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 26e91795..4f6f8fca 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -317,6 +317,46 @@ describe('KimiTUI resume message replay', () => { expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('bash-bg1')).toBe(true); }); + it('matches completed resumed background agents by agent id when task id differs', async () => { + const driver = await replayIntoDriver([], { + background: [ + { + taskId: 'task-bg1', + kind: 'agent', + agentId: 'agent-bg1', + subagentType: 'coder', + description: 'Review long-running work', + status: 'running', + startedAt: 1, + endedAt: null, + }, + ], + }); + + expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg1')).toBe(true); + expect(driver.sessionEventHandler.backgroundAgentMetadata.has('task-bg1')).toBe(false); + + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.completed', + agentId: 'main', + sessionId: 'ses-replay', + subagentId: 'agent-bg1', + parentToolCallId: 'task-bg1', + resultSummary: 'Reviewed the long-running work.', + }, + () => {}, + ); + + const status = driver.state.transcriptEntries.find( + (entry) => entry.backgroundAgentStatus?.phase === 'completed', + ); + + expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg1')).toBe(false); + expect(status?.backgroundAgentStatus?.headline).toBe('agent completed in background'); + expect(status?.backgroundAgentStatus?.detail).toContain('Review long-running work'); + }); + it('renders replayed bash background notifications as bash tasks', async () => { const driver = await replayIntoDriver( [ From 3fb3a5d0e2a7df19171c4108bbc96144db9efc90 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 11:42:04 +0800 Subject: [PATCH 20/21] fix --- .../tui/controllers/session-event-handler.ts | 22 +++++++-- .../kimi-code/test/tui/message-replay.test.ts | 49 +++++++++++++++++++ 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 0a27d008..3a7bb43a 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -743,9 +743,9 @@ export class SessionEventHandler { const { streamingUI } = this.host; const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId(event.subagentId, backgroundMeta); this.backgroundAgentMetadata.delete(event.subagentId); this.syncBackgroundAgentBadge(); - const taskId = this.findAgentTaskId(event.subagentId); if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { return; } @@ -771,8 +771,15 @@ export class SessionEventHandler { const { streamingUI } = this.host; const backgroundMeta = this.backgroundAgentMetadata.get(event.subagentId); if (backgroundMeta !== undefined) { + const taskId = this.findAgentTaskId(event.subagentId, backgroundMeta); + const task = taskId === undefined ? undefined : this.backgroundTasks.get(taskId); this.backgroundAgentMetadata.delete(event.subagentId); this.syncBackgroundAgentBadge(); + if (task?.kind === 'agent' && task.status === 'timed_out') { + // The deadline path already stamped the Agent card as timed out; the + // abort-triggered child failure should not downgrade it to failed. + return; + } // Push the real subagent error onto the parent Agent card too — // `background.task.terminated` arrives separately (possibly later) // with no error string and would only stamp the generic @@ -784,7 +791,6 @@ export class SessionEventHandler { status: 'failed', errorText: event.error, }); - const taskId = this.findAgentTaskId(event.subagentId); if (taskId !== undefined && this.backgroundTaskTranscriptedTerminal.has(taskId)) { return; } @@ -819,9 +825,15 @@ export class SessionEventHandler { return streamingUI.getToolComponent(event.parentToolCallId); } - private findAgentTaskId(subagentId: string): string | undefined { - const meta = this.backgroundAgentMetadata.get(subagentId); - const description = meta?.description ?? meta?.agentName; + private findAgentTaskId( + subagentId: string, + meta: BackgroundAgentMetadata, + ): string | undefined { + for (const info of this.backgroundTasks.values()) { + if (info.kind !== 'agent') continue; + if (info.agentId === subagentId) return info.taskId; + } + const description = meta.description ?? meta.agentName; if (description === undefined) return undefined; let match: string | undefined; for (const info of this.backgroundTasks.values()) { diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 4f6f8fca..2f8a8659 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -357,6 +357,55 @@ describe('KimiTUI resume message replay', () => { expect(status?.backgroundAgentStatus?.detail).toContain('Review long-running work'); }); + it('keeps timed-out status when an aborted resumed background agent later fails', async () => { + const info: BackgroundTaskInfo = { + taskId: 'task-bg-timeout', + kind: 'agent', + agentId: 'agent-bg-timeout', + subagentType: 'coder', + description: 'Review timeout handling', + status: 'running', + startedAt: 1, + endedAt: null, + timeoutMs: 1000, + }; + const driver = await replayIntoDriver([], { background: [info] }); + const applyTerminalStatus = vi + .spyOn(driver.streamingUI, 'applyBackgroundTaskTerminalStatus') + .mockReturnValue(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'background.task.terminated', + agentId: 'main', + sessionId: 'ses-replay', + info: { ...info, status: 'timed_out', endedAt: 2 }, + }, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'subagent.failed', + agentId: 'main', + sessionId: 'ses-replay', + subagentId: 'agent-bg-timeout', + parentToolCallId: 'task-bg-timeout', + error: 'The subagent was aborted.', + }, + () => {}, + ); + + expect(applyTerminalStatus.mock.calls.map(([args]) => args.status)).toEqual(['timed_out']); + expect(driver.sessionEventHandler.backgroundAgentMetadata.has('agent-bg-timeout')).toBe(false); + expect(driver.sessionEventHandler.backgroundTaskTranscriptedTerminal.has('task-bg-timeout')) + .toBe(true); + expect( + driver.state.transcriptEntries.some( + (entry) => entry.backgroundAgentStatus?.phase === 'failed', + ), + ).toBe(false); + }); + it('renders replayed bash background notifications as bash tasks', async () => { const driver = await replayIntoDriver( [ From 82d92f0d0160ffd5c7d8572f9709d061e2c55978 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 2 Jun 2026 12:22:18 +0800 Subject: [PATCH 21/21] fix --- .../agent-core/src/agent/background/index.ts | 19 ++++++++++++ .../agent-core/src/agent/background/task.ts | 2 ++ .../src/tools/background/task-stop.ts | 1 + .../test/tools/background/task-tools.test.ts | 29 +++++++++++++++++-- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/agent-core/src/agent/background/index.ts b/packages/agent-core/src/agent/background/index.ts index 22e9bf2f..38649d29 100644 --- a/packages/agent-core/src/agent/background/index.ts +++ b/packages/agent-core/src/agent/background/index.ts @@ -64,6 +64,8 @@ interface ManagedTask { terminalFired: boolean; /** Human-readable reason for the terminal status, when available. */ stopReason?: string | undefined; + /** Suppress automatic terminal notifications/reminders for this task. */ + terminalNotificationSuppressed?: boolean | undefined; /** Cancellation signal owned by the manager and observed by the concrete task. */ readonly abortController: AbortController; lifecyclePromise: Promise; @@ -346,6 +348,13 @@ export class BackgroundManager { return output; } + async suppressTerminalNotification(taskId: string): Promise { + const entry = this.tasks.get(taskId); + if (entry === undefined || entry.terminalNotificationSuppressed === true) return; + entry.terminalNotificationSuppressed = true; + await this.persistLive(entry); + } + /** Stop a running task. SIGTERM → 5s grace → SIGKILL. */ async stop(taskId: string, reason?: string): Promise { const entry = this.tasks.get(taskId); @@ -560,6 +569,7 @@ export class BackgroundManager { private async buildBackgroundTaskNotificationContext( info: BackgroundTaskInfo, ): Promise { + if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; const origin: BackgroundTaskOrigin = { kind: 'background_task', taskId: info.taskId, @@ -573,6 +583,7 @@ export class BackgroundManager { this.scheduledNotificationKeys.add(key); const tailOutput = (await this.getOutputSnapshot(info.taskId, NOTIFICATION_TAIL_BYTES)) .preview; + if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; const notification: BackgroundTaskNotification = { id: origin.notificationId, category: 'task', @@ -613,6 +624,13 @@ export class BackgroundManager { this.deliveredNotificationKeys.add(notificationKey(origin)); } + private isTerminalNotificationSuppressed(taskId: string): boolean { + return ( + this.tasks.get(taskId)?.terminalNotificationSuppressed === true || + this.ghosts.get(taskId)?.terminalNotificationSuppressed === true + ); + } + private async settleTask( entry: ManagedTask, settlement: BackgroundTaskSettlement, @@ -644,6 +662,7 @@ export class BackgroundManager { startedAt: entry.startedAt, endedAt: entry.endedAt, stopReason: entry.stopReason, + terminalNotificationSuppressed: entry.terminalNotificationSuppressed, timeoutMs: entry.task.timeoutMs, }; return entry.task.toInfo(base); diff --git a/packages/agent-core/src/agent/background/task.ts b/packages/agent-core/src/agent/background/task.ts index 25a7c3ac..168a6e1d 100644 --- a/packages/agent-core/src/agent/background/task.ts +++ b/packages/agent-core/src/agent/background/task.ts @@ -32,6 +32,8 @@ export interface BackgroundTaskInfoBase { readonly endedAt: number | null; /** Human-readable reason for the terminal status, when available. */ readonly stopReason?: string; + /** Suppress automatic terminal notifications/reminders for this task. */ + readonly terminalNotificationSuppressed?: boolean; /** Deadline supplied at registration; surfaced via task info. */ readonly timeoutMs?: number; } diff --git a/packages/agent-core/src/tools/background/task-stop.ts b/packages/agent-core/src/tools/background/task-stop.ts index dcb71f1d..d8e2d2e6 100644 --- a/packages/agent-core/src/tools/background/task-stop.ts +++ b/packages/agent-core/src/tools/background/task-stop.ts @@ -69,6 +69,7 @@ export class TaskStopTool implements BuiltinTool { }; } + await this.manager.suppressTerminalNotification(args.task_id); const result = await this.manager.stop(args.task_id, reason); if (!result) { return { isError: true, output: `Failed to stop task: ${args.task_id}` }; diff --git a/packages/agent-core/test/tools/background/task-tools.test.ts b/packages/agent-core/test/tools/background/task-tools.test.ts index c85c2ad0..ca16c419 100644 --- a/packages/agent-core/test/tools/background/task-tools.test.ts +++ b/packages/agent-core/test/tools/background/task-tools.test.ts @@ -450,6 +450,25 @@ describe('TaskStopTool', () => { expect(manager.getTask(taskId)?.stopReason).toBe('custom stop reason'); }); + it('does not steer a terminal notification for model-requested stops', async () => { + const { agent, manager } = createBackgroundManager(); + const taskId = registerProcess(manager, pendingProcess(), 'sleep 60', 'stop test'); + + const result = await executeTool( + new TaskStopTool(manager), + context('c_stop_silent', { task_id: taskId }), + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(result.isError).toBe(false); + expect(toolContentString(result)).toContain('status: killed'); + expect(agent.turn.steer).not.toHaveBeenCalled(); + expect(manager.getTask(taskId)).toMatchObject({ + status: 'killed', + terminalNotificationSuppressed: true, + }); + }); + it('persists stop reason when the manager has persistence', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-stop-reason-')); try { @@ -462,9 +481,14 @@ describe('TaskStopTool', () => { ); expect(result.isError).toBe(false); - const reader = createBackgroundManager({ sessionDir }).manager; + const { agent, manager: reader } = createBackgroundManager({ sessionDir }); await reader.loadFromDisk(); - expect(reader.getTask(taskId)?.stopReason).toBe('operator cancelled'); + expect(reader.getTask(taskId)).toMatchObject({ + stopReason: 'operator cancelled', + terminalNotificationSuppressed: true, + }); + await reader.reconcile(); + expect(agent.context.appendUserMessage).not.toHaveBeenCalled(); } finally { await rm(sessionDir, { recursive: true, force: true }); } @@ -504,6 +528,7 @@ describe('TaskStopTool', () => { 'status: completed', 'reason: Task already in terminal state', ]); + expect(manager.getTask(taskId)?.terminalNotificationSuppressed).not.toBe(true); }); it('falls back to the placeholder when a terminal task has a blank stored reason', async () => {