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
5 changes: 5 additions & 0 deletions .changeset/fix-background-tasks-session-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Request task-owned work to stop on session close, honoring `background.keep_alive_on_exit` for independent processes and `background.kill_grace_period_ms` before attempting force-stop.
5 changes: 5 additions & 0 deletions .changeset/fix-per-agent-task-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Store background task records per agent again, so tasks written by older versions are found on resume and one agent's restore no longer marks another agent's tasks as lost.
5 changes: 5 additions & 0 deletions .changeset/fix-wire-migration-rewrite-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Fix possible record loss when resuming sessions whose wire log needs migration, and reject session logs missing the version envelope instead of silently misreading them.
1 change: 1 addition & 0 deletions docs/en/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ You can also switch models temporarily without touching the config file — by s
| --- | --- | --- | --- |
| `max_running_tasks` | `integer` | — | Maximum number of background tasks running concurrently |
| `keep_alive_on_exit` | `boolean` | `false` | Whether to keep still-running background tasks when the session closes. By default, Kimi Code requests that all background tasks stop before the process exits; set this to `true` only when you want tasks to outlive the session. In print mode (`kimi -p`), this is only a legacy fallback used when `print_background_mode` is unset: `true` is equivalent to `print_background_mode = "drain"` |
| `kill_grace_period_ms` | `integer` | `5000` | Grace period in milliseconds after session close, a manual stop, or a task timeout requests graceful termination. If a task is still running after this period, Kimi Code attempts to force-stop it |
| `bash_auto_background_on_timeout` | `boolean` | `true` | When a foreground `Bash` command hits its timeout, move it to a background task instead of killing it — the agent is notified when it completes, and the backgrounded command is bounded by the 600s default background timeout. Set to `false` to kill timed-out foreground commands instead |
| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | Print mode (`kimi -p`) only. Governs how pending background tasks are handled once the main agent's turn ends: `"exit"` exits immediately; `"drain"` waits for every background task to reach a terminal state before exiting (results are not fed back to the main agent); `"steer"` stays alive so a completing background task — like a background subagent — injects a synthetic user message that steers the main agent into a new turn, looping until a turn ends with no pending background tasks or a limit is hit. Takes precedence over the `keep_alive_on_exit` print fallback |
| `print_wait_ceiling_s` | `integer` | `3600` | In print mode (`kimi -p`), the wall-clock ceiling (seconds) for the wait/steer loop when `print_background_mode` is `"drain"` or `"steer"`. Has no effect outside print mode or when it is `"exit"` |
Expand Down
1 change: 1 addition & 0 deletions docs/zh/configuration/config-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ display_name = "Kimi for Coding (custom)"
| --- | --- | --- | --- |
| `max_running_tasks` | `integer` | — | 同时运行的最大后台任务数 |
| `keep_alive_on_exit` | `boolean` | `false` | 会话关闭时是否保留仍在运行的后台任务。默认情况下,Kimi Code 会在进程退出前请求停止所有后台任务;只有希望任务在会话结束后继续运行时才设为 `true`。在 print 模式(`kimi -p`)下,本字段仅作为 `print_background_mode` 未设置时的兼容回退:`true` 等价于 `print_background_mode = "drain"` |
| `kill_grace_period_ms` | `integer` | `5000` | 会话关闭、手动停止或任务超时请求正常终止后,等待任务自行结束的宽限时间(毫秒)。超过该时间仍在运行时,Kimi Code 会尝试强制停止该任务 |
| `bash_auto_background_on_timeout` | `boolean` | `true` | 前台 `Bash` 命令触及超时时间时,将其转为后台任务而不是直接终止:命令完成时 agent 会收到通知,转入后台的命令受 600s 默认后台超时约束。设为 `false` 则恢复超时即终止的行为 |
| `print_background_mode` | `"exit" \| "drain" \| "steer"` | `"exit"` | 仅 print 模式(`kimi -p`)生效,决定主 agent 的 turn 结束后如何处理未返回的后台任务:`"exit"` 立即退出;`"drain"` 退出前等待所有后台任务进入终态(结果不回馈给主 agent);`"steer"` 不退出,让后台任务完成时像后台子代理一样以合成 user 消息 steer 主 agent 进入新 turn,直到某 turn 结束时无未决后台任务或触及上限。设置后优先级高于 `keep_alive_on_exit` 的 print 回退 |
| `print_wait_ceiling_s` | `integer` | `3600` | print 模式(`kimi -p`)下,`print_background_mode` 为 `"drain"` 或 `"steer"` 时,等待/steer 循环的墙钟上限(秒)。在非 print 模式或 `"exit"` 时无效 |
Expand Down
38 changes: 23 additions & 15 deletions packages/agent-core-v2/src/agent/task/configSection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
/**
* `task` domain (L5) — task config-section schema.
* `task` domain (L5) — task config-section schema and env bindings.
*
* Owns the `[task]` configuration section (task limits and lifecycle tuning).
* The legacy `[background]` section is registered with the same schema so old
* configs continue to load while callers migrate. Self-registered at module
* load via `registerConfigSection`, so the `config` domain never imports this
* domain's types.
* configs continue to load while callers migrate; effective values use legacy
* fields as the base and let `[task]` override matching fields.
* `keepAliveOnExit` also
* accepts the v1 env override `KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT`
* (applied live by the config env overlay, never persisted). Self-registered
* at module load via `registerConfigSection`, so the `config` domain never
* imports this domain's types.
*/

import { z } from 'zod';

import type { IConfigService } from '#/app/config/config';
import { parseBooleanEnv } from '#/_base/utils/env';
import { type EnvBindings, envBindings, type IConfigService } from '#/app/config/config';
import { registerConfigSection } from '#/app/config/configSectionContributions';

export const TASK_SECTION = 'task';
Expand All @@ -30,16 +35,19 @@ export const AgentTaskConfigSchema = z.object({

export type AgentTaskConfig = z.infer<typeof AgentTaskConfigSchema>;

/**
* Read the effective task config, falling back to the legacy `[background]`
* section when `[task]` is unset.
*/
export function resolveAgentTaskConfig(config: IConfigService): AgentTaskConfig | undefined {
return (
config.get<AgentTaskConfig | undefined>(TASK_SECTION) ??
config.get<AgentTaskConfig | undefined>(LEGACY_BACKGROUND_SECTION)
);
const legacy = config.get<AgentTaskConfig | undefined>(LEGACY_BACKGROUND_SECTION);
const current = config.get<AgentTaskConfig | undefined>(TASK_SECTION);
if (legacy === undefined) return current;
if (current === undefined) return legacy;
return { ...legacy, ...current };
}

registerConfigSection(TASK_SECTION, AgentTaskConfigSchema);
registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema);
export const KEEP_ALIVE_ON_EXIT_ENV = 'KIMI_CODE_BACKGROUND_KEEP_ALIVE_ON_EXIT';

export const taskEnvBindings: EnvBindings<AgentTaskConfig> = envBindings(AgentTaskConfigSchema, {
keepAliveOnExit: { env: KEEP_ALIVE_ON_EXIT_ENV, parse: parseBooleanEnv },
});

registerConfigSection(TASK_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings });
Comment thread
wbxl2000 marked this conversation as resolved.
registerConfigSection(LEGACY_BACKGROUND_SECTION, AgentTaskConfigSchema, { env: taskEnvBindings });
160 changes: 126 additions & 34 deletions packages/agent-core-v2/src/agent/task/persist.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
/**
* `task` domain (L5) — `AgentTaskPersistence`, the per-session
* `task` domain (L5) — `AgentTaskPersistence`, the per-agent
* persistence helper behind `AgentTaskService`.
*
* Persists task state (`<taskId>.json`) and raw task output (`output.log`)
* through the `storage` access-pattern stores (`IAtomicDocumentStore` for
* atomic whole-document state, `IFileSystemStorageService` byte primitives for ordered
* output append), addressed under the session's storage scope so the domain
* never touches the filesystem. Task ids are validated against the
* `{prefix}-{8 hex}` shape before use as path segments (path-traversal and
* legacy `bg_<hex>` guard), and legacy snake_case records are normalized to
* the current shape on read. Not scope-bound; constructed by
* `AgentTaskService`.
* output append), addressed under the owning agent's storage scope
* (`<sessionScope>/agents/<agentId>/tasks/…`) so the domain never touches the
* filesystem and each agent reads back exactly its own records — v1's
* per-agent `<sessionDir>/agents/<id>/tasks/` layout. An optional read-only
* fallback keeps the previous v2 session-level task root readable during the
* layout transition; primary agent keys and output files always win, while
* every write remains rooted at the owning agent. Task ids are validated
* against the `{prefix}-{8 hex}` shape before use as path segments
* (path-traversal and legacy `bg_<hex>` guard), and legacy snake_case records
* are normalized to the current shape on read. Not scope-bound; constructed
* by `AgentTaskService`.
*/

import { join } from 'pathe';
Expand All @@ -33,6 +38,29 @@ type PersistedTask = AgentTaskInfo;

type DiskPersistedTask = PersistedTask | LegacyPersistedTask;

export interface AgentTaskPersistenceRoot {
readonly dir: string;
readonly scope: string;
}

export interface AgentTaskStoredOutputSnapshot {
readonly outputPath: string;
readonly outputSizeBytes: number;
readonly previewBytes: number;
readonly truncated: boolean;
readonly preview: string;
}

interface ListedTask {
readonly keyId: string;
readonly task: PersistedTask;
}

interface TaskOutputData {
readonly root: AgentTaskPersistenceRoot;
readonly data: Uint8Array;
}

function validateTaskId(taskId: string): void {
if (!VALID_TASK_ID.test(taskId)) {
throw new Error(`Invalid task id: "${taskId}"`);
Expand All @@ -41,24 +69,36 @@ function validateTaskId(taskId: string): void {

export class AgentTaskPersistence {
constructor(
private readonly sessionDir: string,
private readonly sessionScope: string,
private readonly agentDir: string,
private readonly agentScope: string,
private readonly docs: IAtomicDocumentStore,
private readonly bytes: IFileSystemStorageService,
private readonly fallbackRoot?: AgentTaskPersistenceRoot,
) {}

private tasksScope(): string {
return `${this.sessionScope}/${TASKS_SCOPE}`;
private primaryRoot(): AgentTaskPersistenceRoot {
return { dir: this.agentDir, scope: this.agentScope };
}

private tasksScope(root: AgentTaskPersistenceRoot = this.primaryRoot()): string {
return `${root.scope}/${TASKS_SCOPE}`;
}

private taskOutputScope(taskId: string): string {
private taskOutputScope(
taskId: string,
root: AgentTaskPersistenceRoot = this.primaryRoot(),
): string {
validateTaskId(taskId);
return `${this.sessionScope}/${TASKS_SCOPE}/${taskId}`;
return `${root.scope}/${TASKS_SCOPE}/${taskId}`;
}

taskOutputFile(taskId: string): string {
private taskOutputFileAt(taskId: string, root: AgentTaskPersistenceRoot): string {
validateTaskId(taskId);
return join(this.sessionDir, TASKS_SCOPE, taskId, OUTPUT_LOG_KEY);
return join(root.dir, TASKS_SCOPE, taskId, OUTPUT_LOG_KEY);
}

taskOutputFile(taskId: string): string {
return this.taskOutputFileAt(taskId, this.primaryRoot());
}

async writeTask(task: PersistedTask): Promise<void> {
Expand All @@ -68,12 +108,16 @@ export class AgentTaskPersistence {

async readTask(taskId: string): Promise<PersistedTask | undefined> {
validateTaskId(taskId);
const task = await this.docs.get<DiskPersistedTask>(
this.tasksScope(),
`${taskId}${JSON_SUFFIX}`,
);
if (task === undefined || !isReadablePersistedTask(task)) return undefined;
return normalizePersistedTask(task);
const key = `${taskId}${JSON_SUFFIX}`;
const task = await this.docs.get<DiskPersistedTask>(this.tasksScope(), key);
if (task !== undefined) {
return isReadablePersistedTask(task) ? normalizePersistedTask(task) : undefined;
}
const fallbackRoot = this.fallbackRoot;
if (fallbackRoot === undefined) return undefined;
const fallback = await this.docs.get<DiskPersistedTask>(this.tasksScope(fallbackRoot), key);
if (fallback === undefined || !isReadablePersistedTask(fallback)) return undefined;
return normalizePersistedTask(fallback);
}

async appendTaskOutput(taskId: string, chunk: string): Promise<void> {
Expand All @@ -82,43 +126,91 @@ export class AgentTaskPersistence {
}

async taskOutputSizeBytes(taskId: string): Promise<number> {
const data = await this.bytes.read(this.taskOutputScope(taskId), OUTPUT_LOG_KEY);
return data === undefined ? 0 : data.byteLength;
const output = await this.readTaskOutputData(taskId);
return output?.data.byteLength ?? 0;
}

async taskOutputExists(taskId: string): Promise<boolean> {
const entries = await this.bytes.list(this.taskOutputScope(taskId));
return entries.includes(OUTPUT_LOG_KEY);
return (await this.readTaskOutputData(taskId)) !== undefined;
}

async readTaskOutputBytes(taskId: string, offset: number, maxBytes: number): Promise<string> {
const start = Math.max(0, Math.trunc(offset));
const limit = Math.max(0, Math.trunc(maxBytes));
if (limit === 0) return '';
const data = await this.bytes.read(this.taskOutputScope(taskId), OUTPUT_LOG_KEY);
if (data === undefined || start >= data.byteLength) return '';
const end = Math.min(data.byteLength, start + limit);
return textDecoder.decode(data.subarray(start, end));
const output = await this.readTaskOutputData(taskId);
if (output === undefined || start >= output.data.byteLength) return '';
const end = Math.min(output.data.byteLength, start + limit);
return textDecoder.decode(output.data.subarray(start, end));
}

async readTaskOutputSnapshot(
taskId: string,
maxPreviewBytes: number,
): Promise<AgentTaskStoredOutputSnapshot | undefined> {
const output = await this.readTaskOutputData(taskId);
if (output === undefined) return undefined;
const previewLimit = Math.max(0, Math.trunc(maxPreviewBytes));
const previewBytes = Math.min(previewLimit, output.data.byteLength);
const previewOffset = output.data.byteLength - previewBytes;
return {
outputPath: this.taskOutputFileAt(taskId, output.root),
outputSizeBytes: output.data.byteLength,
previewBytes,
truncated: previewOffset > 0,
preview: textDecoder.decode(output.data.subarray(previewOffset)),
};
}

async listTasks(): Promise<readonly PersistedTask[]> {
const keys = (await this.docs.list(this.tasksScope())).toSorted();
const tasks: PersistedTask[] = [];
const primary = await this.listTasksAt(this.primaryRoot());
const tasks = [...primary.tasks];
const fallbackRoot = this.fallbackRoot;
if (fallbackRoot !== undefined) {
const fallback = await this.listTasksAt(fallbackRoot);
for (const entry of fallback.tasks) {
if (!primary.reservedIds.has(entry.keyId)) tasks.push(entry);
}
}
return tasks.map((entry) => entry.task).toSorted((a, b) => a.taskId.localeCompare(b.taskId));
}

private async listTasksAt(root: AgentTaskPersistenceRoot): Promise<{
readonly reservedIds: ReadonlySet<string>;
readonly tasks: readonly ListedTask[];
}> {
const keys = (await this.docs.list(this.tasksScope(root))).toSorted();
const reservedIds = new Set<string>();
const tasks: ListedTask[] = [];
for (const key of keys) {
if (!key.endsWith(JSON_SUFFIX)) continue;
const id = key.slice(0, -JSON_SUFFIX.length);
if (!VALID_TASK_ID.test(id)) continue;
reservedIds.add(id);
let task: DiskPersistedTask | undefined;
try {
task = await this.docs.get<DiskPersistedTask>(this.tasksScope(), key);
task = await this.docs.get<DiskPersistedTask>(this.tasksScope(root), key);
} catch {
// Skip files that fail to read / parse (corrupt or partially written).
continue;
}
if (task === undefined || !isReadablePersistedTask(task)) continue;
tasks.push(normalizePersistedTask(task));
tasks.push({ keyId: id, task: normalizePersistedTask(task) });
}
return tasks;
return { reservedIds, tasks };
}

private async readTaskOutputData(taskId: string): Promise<TaskOutputData | undefined> {
const primaryRoot = this.primaryRoot();
const primary = await this.bytes.read(this.taskOutputScope(taskId, primaryRoot), OUTPUT_LOG_KEY);
if (primary !== undefined) return { root: primaryRoot, data: primary };
const fallbackRoot = this.fallbackRoot;
if (fallbackRoot === undefined) return undefined;
const fallback = await this.bytes.read(
this.taskOutputScope(taskId, fallbackRoot),
OUTPUT_LOG_KEY,
);
return fallback === undefined ? undefined : { root: fallbackRoot, data: fallback };
}
}

Expand Down
4 changes: 3 additions & 1 deletion packages/agent-core-v2/src/agent/task/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* Defines the Agent-scoped task manager surface used for both foreground and
* detached work. Task execution adapters implement the generic `AgentTask`
* contract from this domain's type module; this service owns registration,
* output retention, persistence, detach/stop/wait, and terminal notifications.
* output retention, persistence, detach/stop/wait, terminal notifications,
* and session-close task teardown with a `keepAliveOnExit` opt-out.
* Bound at Agent scope.
*/

Expand Down Expand Up @@ -119,6 +120,7 @@ export interface IAgentTaskService {
detach(taskId: string): AgentTaskInfo | undefined;
stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined>;
stopAll(reason?: string): Promise<readonly AgentTaskInfo[]>;
stopAllOnExit(reason: string): Promise<readonly AgentTaskInfo[]>;
wait(
taskId: string,
timeoutMs?: number,
Expand Down
Loading
Loading