Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/background-agent-runtime.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@moonshot-ai/agent-core": patch
"@moonshot-ai/kimi-code": patch
Comment thread
kermanx marked this conversation as resolved.
---

Consolidate background task management under the agent background runtime.
1 change: 0 additions & 1 deletion apps/kimi-code/src/cli/run-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ export interface TaskOutputViewerProps {

const STATUS_LABEL: Record<BackgroundTaskStatus, string> = {
running: 'running',
awaiting_approval: 'awaiting',
completed: 'completed',
failed: 'failed',
timed_out: 'timed out',
killed: 'killed',
lost: 'lost',
};
Expand All @@ -47,11 +47,10 @@ 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':
case 'timed_out':
case 'killed':
case 'lost':
return colors.error;
Expand Down Expand Up @@ -191,7 +190,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) {
Expand Down
53 changes: 26 additions & 27 deletions apps/kimi-code/src/tui/components/dialogs/tasks-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ export interface TasksBrowserProps {

const STATUS_LABEL: Record<BackgroundTaskStatus, string> = {
running: 'running',
awaiting_approval: 'awaiting',
completed: 'completed',
failed: 'failed',
timed_out: 'timed out',
killed: 'killed',
lost: 'lost',
};
Expand All @@ -77,11 +77,10 @@ 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':
case 'timed_out':
case 'killed':
case 'lost':
return colors.error;
Expand All @@ -90,7 +89,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'
);
}

Expand Down Expand Up @@ -142,25 +145,22 @@ 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;
case 'failed':
case 'timed_out':
case 'killed':
case 'lost':
counts.terminalFailed += 1;
Expand Down Expand Up @@ -338,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)
Expand Down Expand Up @@ -467,7 +465,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
Expand All @@ -484,7 +482,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);
}
Expand Down Expand Up @@ -536,32 +536,31 @@ 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'
task.status === 'running'
? `running ${formatRelativeTime(task.startedAt)}`
: task.endedAt !== null && task.endedAt !== undefined
? `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)}`);
lines.push(`${label('Reason:')}${chalk.hex(colors.textMuted)(task.stopReason)}`);
}
if (task.timedOut === true) {
lines.push(`${label('Timed out:')}${chalk.hex(colors.warning)('yes')}`);
}
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);
}
Expand Down
6 changes: 4 additions & 2 deletions apps/kimi-code/src/tui/components/messages/tool-call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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';
Expand Down
39 changes: 25 additions & 14 deletions apps/kimi-code/src/tui/controllers/session-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type {
BackgroundTaskInfo,
BackgroundTaskStartedEvent,
BackgroundTaskTerminatedEvent,
BackgroundTaskUpdatedEvent,
CompactionCancelledEvent,
CompactionCompletedEvent,
CompactionStartedEvent,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -746,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;
}
Expand All @@ -774,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
Expand All @@ -787,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;
}
Expand Down Expand Up @@ -822,13 +825,19 @@ 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()) {
if (!info.taskId.startsWith('agent-')) continue;
if (info.kind !== 'agent') continue;
if (info.description !== description) continue;
if (match !== undefined) return undefined;
match = info.taskId;
Expand Down Expand Up @@ -874,7 +883,7 @@ export class SessionEventHandler {
// ---------------------------------------------------------------------------

private handleBackgroundTaskEvent(
event: BackgroundTaskStartedEvent | BackgroundTaskUpdatedEvent | BackgroundTaskTerminatedEvent,
event: BackgroundTaskStartedEvent | BackgroundTaskTerminatedEvent,
): void {
const { state } = this.host;
const { info } = event;
Expand All @@ -889,11 +898,12 @@ export class SessionEventHandler {
const isTerminal =
info.status === 'completed' ||
info.status === 'failed' ||
info.status === 'timed_out' ||
Comment thread
kermanx marked this conversation as resolved.
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;
Expand All @@ -905,7 +915,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`.
Expand All @@ -917,7 +927,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);
Expand Down Expand Up @@ -955,12 +965,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;
Expand Down
10 changes: 8 additions & 2 deletions apps/kimi-code/src/tui/controllers/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
) {
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
2 changes: 1 addition & 1 deletion apps/kimi-code/src/tui/controllers/streaming-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading