feat(v2): auto-mint session ids and harden print-mode background drain

- make CreateSessionOptions.sessionId optional; SessionLifecycleService.create and fork now mint `session_<lowercase-uuid>` via a shared createSessionId helper, so edge layers stop minting their own ids (drop randomUUID in the v2 harness, ulid in kap-server)
- rework V2Session.waitForBackgroundTasksOnPrint to re-enumerate each round, suppress terminal notifications while waiting, and bound the drain by [task].print_wait_ceiling_s (default 1h) instead of a hardcoded 30s cap, so kimi -p can run long tasks to completion without being steered into a new turn
- add v2-session unit tests; seed session/agent/bootstrap context in the tool-dedupe harness for the real executor
This commit is contained in:
haozhe.yang 2026-07-09 13:09:47 +08:00
parent f85dbdbf03
commit 536c4c0d44
8 changed files with 302 additions and 28 deletions

View file

@ -32,7 +32,6 @@ import {
type SessionSummary,
type TelemetryProperties,
} from '@moonshot-ai/kimi-code-sdk';
import { randomUUID } from 'node:crypto';
import { mkdir, open } from 'node:fs/promises';
import { dirname } from 'node:path';
@ -144,7 +143,6 @@ class V2PromptHarness implements PromptHarness {
async createSession(options: CreateSessionOptions): Promise<PromptSession> {
const session = await this.core.accessor.get(ISessionLifecycleService).create({
sessionId: randomUUID(),
workDir: options.workDir,
additionalDirs: options.additionalDirs,
});

View file

@ -13,6 +13,7 @@ import {
IAgentProfileService,
IAgentPromptLegacyService,
IAgentTaskService,
IConfigService,
IEventBus,
ISessionLegacyService,
type IAgentScopeHandle,
@ -33,9 +34,16 @@ import type {
} from '@moonshot-ai/kimi-code-sdk';
import type { PromptSession } from '../prompt-session';
import { subscribeAgentEvents } from './adapt-events';
const DEFAULT_PRINT_WAIT_CEILING_S = 3600;
const TASK_CONFIG_SECTION = 'task';
const LEGACY_BACKGROUND_CONFIG_SECTION = 'background';
interface TaskPrintWaitConfig {
readonly printWaitCeilingS?: number;
}
export interface V2SessionContext {
readonly core: Scope;
readonly session: ISessionScopeHandle;
@ -77,7 +85,9 @@ export class V2Session implements PromptSession {
}
async setPermission(mode: PermissionMode): Promise<void> {
this.agent.accessor.get(IAgentPermissionModeService).setMode(mode as 'yolo' | 'manual' | 'auto');
this.agent.accessor
.get(IAgentPermissionModeService)
.setMode(mode as 'yolo' | 'manual' | 'auto');
}
setApprovalHandler(_handler: ApprovalHandler | undefined): void {
@ -105,28 +115,60 @@ export class V2Session implements PromptSession {
}
async waitForBackgroundTasksOnPrint(): Promise<void> {
// Best-effort drain of background agents/tasks spawned during the turn.
// v2 has no direct equivalent of v1's keep_alive_on_exit drain; we wait on
// whatever active tasks the session currently reports, bounded so a wedged
// task cannot keep the process alive forever.
const agents = this.session.accessor.get(IAgentLifecycleService).list();
const waits: Promise<unknown>[] = [];
for (const handle of agents) {
const tasks = handle.accessor.get(IAgentTaskService).list(true);
for (const task of tasks) {
waits.push(handle.accessor.get(IAgentTaskService).wait(task.taskId));
// Drain background tasks (background bash and background subagents) spawned
// during the turn before a `kimi -p` run exits. `-p` must be able to run
// long tasks to completion, so we wait until every active task across every
// agent reaches a terminal state — bounded only by `[task]/[background]
// print_wait_ceiling_s` (default 1h) so a genuinely wedged task cannot keep
// the process alive forever.
//
// Tasks are re-enumerated each round: a subagent may fan out new background
// tasks after a previous enumeration, and a single pass could return while
// those later tasks are still running. Terminal notifications are suppressed
// for each task while we wait, so a task completing cannot `turn.steer` the
// (already finished) main agent into launching a new turn.
const deadline = Date.now() + this.readPrintWaitCeilingMs();
const seen = new Set<string>();
const allWaiters: Promise<unknown>[] = [];
while (Date.now() < deadline) {
const batch: Promise<unknown>[] = [];
const suppressions: Promise<void>[] = [];
let activeCount = 0;
for (const handle of this.session.accessor.get(IAgentLifecycleService).list()) {
const taskService = handle.accessor.get(IAgentTaskService);
for (const task of taskService.list(true)) {
activeCount++;
if (seen.has(task.taskId)) continue;
seen.add(task.taskId);
suppressions.push(taskService.suppressTerminalNotification(task.taskId));
const remaining = Math.max(1, deadline - Date.now());
const waiter = taskService.wait(task.taskId, remaining);
batch.push(waiter);
allWaiters.push(waiter);
}
}
if (suppressions.length > 0) await Promise.all(suppressions);
if (activeCount === 0 || batch.length === 0) break;
await Promise.all(batch);
}
if (waits.length === 0) return;
await Promise.race([
Promise.allSettled(waits),
new Promise<void>((resolve) => setTimeout(resolve, 30_000).unref()),
]);
if (allWaiters.length > 0) await Promise.all(allWaiters);
}
private readPrintWaitCeilingMs(): number {
const config = this.core.accessor.get(IConfigService);
const section =
config.get<TaskPrintWaitConfig>(TASK_CONFIG_SECTION) ??
config.get<TaskPrintWaitConfig>(LEGACY_BACKGROUND_CONFIG_SECTION);
const ceilingS = section?.printWaitCeilingS;
if (typeof ceilingS === 'number' && Number.isFinite(ceilingS) && ceilingS > 0) {
return ceilingS * 1000;
}
return DEFAULT_PRINT_WAIT_CEILING_S * 1000;
}
async createGoal(input: CreateGoalInput): Promise<GoalSnapshot> {
return (await this.agent
.accessor.get(IAgentGoalService)
return (await this.agent.accessor
.get(IAgentGoalService)
.createGoal(input)) as unknown as GoalSnapshot;
}

View file

@ -0,0 +1,154 @@
import {
IAgentLifecycleService,
IAgentTaskService,
IConfigService,
type AgentTaskInfo,
type IAgentScopeHandle,
type ISessionScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { describe, expect, it } from 'vitest';
import { V2Session } from '../../../src/cli/v2/v2-session';
interface FakeTask {
readonly taskId: string;
/** ms until this task completes once `wait` is called on it. */
readonly completesInMs: number;
/** Optional task to spawn (append to the active list) when this task completes. */
readonly spawnsOnComplete?: FakeTask;
active: boolean;
}
class FakeTaskService {
readonly suppressed: string[] = [];
readonly waitCalls: Array<{ taskId: string; timeoutMs: number | undefined }> = [];
constructor(private readonly tasks: FakeTask[]) {}
list(activeOnly?: boolean): readonly AgentTaskInfo[] {
return this.tasks
.filter((task) => !activeOnly || task.active)
.map((task) => ({ taskId: task.taskId, status: 'running' }) as unknown as AgentTaskInfo);
}
suppressTerminalNotification(taskId: string): Promise<void> {
this.suppressed.push(taskId);
return Promise.resolve();
}
wait(taskId: string, timeoutMs?: number): Promise<AgentTaskInfo | undefined> {
this.waitCalls.push({ taskId, timeoutMs });
const task = this.tasks.find((entry) => entry.taskId === taskId);
const completesInMs = task?.completesInMs ?? 0;
const completed =
task !== undefined && completesInMs <= (timeoutMs ?? Number.POSITIVE_INFINITY);
const waitMs = timeoutMs === undefined ? completesInMs : Math.min(completesInMs, timeoutMs);
return new Promise((resolve) => {
setTimeout(() => {
if (completed && task !== undefined) {
task.active = false;
if (task.spawnsOnComplete !== undefined) this.tasks.push(task.spawnsOnComplete);
}
resolve({
taskId,
status: completed ? 'completed' : 'running',
} as unknown as AgentTaskInfo);
}, waitMs);
});
}
}
function fakeAccessor(map: Map<unknown, unknown>) {
return { get: (token: unknown) => map.get(token) };
}
function buildSession(options: { ceilingS?: number; taskServices: FakeTaskService[] }): V2Session {
const coreMap = new Map<unknown, unknown>([
[
IConfigService,
{
get: (section: string) =>
section === 'task' && options.ceilingS !== undefined
? { printWaitCeilingS: options.ceilingS }
: undefined,
},
],
]);
const agentHandles: IAgentScopeHandle[] = options.taskServices.map((service) => {
const agentMap = new Map<unknown, unknown>([[IAgentTaskService, service]]);
return { accessor: fakeAccessor(agentMap) } as unknown as IAgentScopeHandle;
});
const sessionMap = new Map<unknown, unknown>([
[
IAgentLifecycleService,
{
list: () => agentHandles,
},
],
]);
return new V2Session({
core: { accessor: fakeAccessor(coreMap) } as unknown as Scope,
session: { id: 'sess-1', accessor: fakeAccessor(sessionMap) } as unknown as ISessionScopeHandle,
agent: { id: 'main', accessor: fakeAccessor(new Map()) } as unknown as IAgentScopeHandle,
});
}
describe('V2Session.waitForBackgroundTasksOnPrint', () => {
it('returns immediately when there are no active background tasks', async () => {
const service = new FakeTaskService([]);
const session = buildSession({ taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
expect(service.waitCalls).toHaveLength(0);
});
it('waits for a background task to complete and bounds the wait by the default ceiling, not 30s', async () => {
const service = new FakeTaskService([{ taskId: 'a', completesInMs: 20, active: true }]);
const session = buildSession({ taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
expect(service.waitCalls).toHaveLength(1);
expect(service.waitCalls[0]?.taskId).toBe('a');
// The old implementation hardcoded a 30s cap; the drain must use the 1h
// default ceiling so long tasks are allowed to finish.
expect(service.waitCalls[0]?.timeoutMs).toBeGreaterThan(30_000);
expect(service.suppressed).toContain('a');
});
it('honors [task].print_wait_ceiling_s as the wait bound', async () => {
const service = new FakeTaskService([
{ taskId: 'stuck', completesInMs: Number.POSITIVE_INFINITY, active: true },
]);
const session = buildSession({ ceilingS: 1, taskServices: [service] });
const startedAt = Date.now();
await session.waitForBackgroundTasksOnPrint();
const elapsed = Date.now() - startedAt;
expect(service.waitCalls[0]?.timeoutMs).toBeLessThanOrEqual(1000);
expect(service.waitCalls[0]?.timeoutMs).toBeGreaterThan(0);
// Returns near the 1s ceiling, never hangs until the (infinite) task.
expect(elapsed).toBeLessThan(5_000);
});
it('re-enumerates to drain tasks spawned by a completing task', async () => {
const spawned: FakeTask = { taskId: 'b', completesInMs: 20, active: true };
const service = new FakeTaskService([
{ taskId: 'a', completesInMs: 20, active: true, spawnsOnComplete: spawned },
]);
const session = buildSession({ taskServices: [service] });
await session.waitForBackgroundTasksOnPrint();
const waitedIds = service.waitCalls.map((call) => call.taskId);
expect(waitedIds).toContain('a');
expect(waitedIds).toContain('b');
expect(service.suppressed).toEqual(expect.arrayContaining(['a', 'b']));
});
});

View file

@ -19,7 +19,12 @@ import type { Event } from '#/_base/event';
import type { Hooks } from '#/hooks';
export interface CreateSessionOptions {
readonly sessionId: string;
/**
* Caller-supplied session id. When omitted, the lifecycle mints one in the
* canonical `session_<lowercase-uuid>` form (matches v1's `createSessionId`).
* Pass an explicit id only to resume/recreate a session under a known id.
*/
readonly sessionId?: string;
readonly workDir: string;
/** Extra workspace roots for this session; relative paths resolve against workDir. */
readonly additionalDirs?: readonly string[];

View file

@ -65,7 +65,8 @@ import {
ISessionLifecycleService,
} from './sessionLifecycle';
type MaterializeSessionOptions = CreateSessionOptions & {
type MaterializeSessionOptions = Omit<CreateSessionOptions, 'sessionId'> & {
readonly sessionId: string;
readonly workspaceId?: string;
};
@ -105,8 +106,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
}
async create(opts: CreateSessionOptions): Promise<ISessionScopeHandle> {
const handle = await this.materializeSession(opts);
await this.announceCreated({ sessionId: opts.sessionId, handle, source: 'startup' });
const sessionId = opts.sessionId ?? createSessionId();
const handle = await this.materializeSession({ ...opts, sessionId });
await this.announceCreated({ sessionId, handle, source: 'startup' });
return handle;
}
@ -299,7 +301,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
: await this.readMetaFromDisk(workspaceId, sourceId);
// 5. Mint the target id and reject collisions.
const targetId = opts.newSessionId ?? randomUUID();
const targetId = opts.newSessionId ?? createSessionId();
if (this.sessions.has(targetId) || (await this.index.get(targetId)) !== undefined) {
throw new KimiError(ErrorCodes.SESSION_ALREADY_EXISTS, `Session "${targetId}" already exists`);
}
@ -434,6 +436,18 @@ async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
return items;
}
/**
* Mint a session id in the canonical `session_<lowercase-uuid>` form, matching
* v1's `createSessionId` (`packages/agent-core/src/rpc/core-impl.ts`).
* `randomUUID` already returns lowercase hex, so the result is lowercase by
* construction. Used as the default for both `create` and `fork` when the
* caller does not supply an id, so every session id shares one format and the
* edge layers never mint their own.
*/
function createSessionId(): string {
return `session_${randomUUID()}`;
}
function freshMetadataRecord(): PersistedWireRecord {
return {
type: 'metadata',

View file

@ -685,5 +685,42 @@ describe('SessionLifecycleService', () => {
expect(dirsOf(target)).toEqual(['/tmp/extra']);
});
it('create mints a session_-prefixed lowercase id when none is supplied', async () => {
const svc = build();
const h = await svc.create({ workDir: '/tmp/proj' });
expect(h.id).toMatch(/^session_[0-9a-f-]{36}$/);
expect(h.id).toBe(h.id.toLowerCase());
expect(svc.get(h.id)).toBe(h);
});
it('fork mints a session_-prefixed lowercase id when newSessionId is omitted', async () => {
const svc = build([
stubPair(ISessionActivity, {
_serviceBrand: undefined,
status: () => 'idle' as const,
isIdle: () => true,
}),
stubPair(IWorkspaceRegistry, {
...workspaceRegistryStub(),
get: () =>
Promise.resolve({
id: 'wd_stub',
root: '/tmp/proj',
name: 'stub',
createdAt: 0,
lastOpenedAt: 0,
}),
}),
]);
await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
const target = await svc.fork({ sourceSessionId: 'src' });
expect(target.id).toMatch(/^session_[0-9a-f-]{36}$/);
expect(target.id).toBe(target.id.toLowerCase());
expect(target.id).not.toBe('src');
});
});
});

View file

@ -2,10 +2,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DisposableStore } from '#/_base/di/lifecycle';
import { createServices, type TestInstantiationService } from '#/_base/di/test';
import { IBootstrapService } from '#/app/bootstrap/bootstrap';
import { IEventBus } from '#/app/event/eventBus';
import { type ToolCall } from '#/app/llmProtocol/message';
import { emptyUsage } from '#/app/llmProtocol/usage';
import { ITelemetryService } from '#/app/telemetry/telemetry';
import { ISessionContext } from '#/session/sessionContext/sessionContext';
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
import { IAgentLoopService } from '#/agent/loop/loop';
import type { ExecutableTool, ExecutableToolContext, ExecutableToolResult, ToolExecution, ToolResult } from '#/agent/tool/toolContract';
import type { ToolDidExecuteContext, ToolWillExecuteContext } from '#/agent/tool/toolHooks';
@ -64,6 +67,29 @@ function createHarness(telemetry: ITelemetryService = recordingTelemetry(telemet
additionalServices: (reg) => {
reg.defineInstance(ITelemetryService, telemetry);
reg.defineInstance(IEventBus, noopEventBus);
// Seeds the real executor needs to derive its per-agent homedir (used by
// the tool-result budgeter). Dedupe outputs are small, so the budgeter
// never writes to disk; a fixed path is sufficient.
const homedir = '/tmp/tool-dedupe-homedir';
reg.defineInstance(ISessionContext, {
_serviceBrand: undefined,
sessionId: 'session-1',
workspaceId: 'workspace-1',
sessionDir: homedir,
metaScope: 'sessions/workspace-1/session-1',
cwd: homedir,
scope: (sub?: string): string =>
sub ? `sessions/workspace-1/session-1/${sub}` : 'sessions/workspace-1/session-1',
} satisfies ISessionContext);
reg.defineInstance(IAgentScopeContext, {
_serviceBrand: undefined,
agentId: 'main',
scope: (sub?: string): string => (sub ? `agents/main/${sub}` : 'agents/main'),
} satisfies IAgentScopeContext);
reg.defineInstance(IBootstrapService, {
homeDir: homedir,
agentHomedir: () => homedir,
} as unknown as IBootstrapService);
reg.defineInstance(IAgentLoopService, loop);
reg.defineInstance(IAgentTurnService, stubTurnWithHooks());
reg.define(IAgentToolRegistryService, AgentToolRegistryService);

View file

@ -94,7 +94,6 @@ import {
workspaceIdSchema,
} from '@moonshot-ai/protocol';
import type { Session, SessionStatus } from '@moonshot-ai/protocol';
import { ulid } from 'ulid';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
@ -278,7 +277,6 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
const touched = await registry.createOrTouch(workDir);
const handle = await core.accessor.get(ISessionLifecycleService).create({
sessionId: ulid(),
workDir,
});
if (typeof body.title === 'string') {