mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
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:
parent
f85dbdbf03
commit
536c4c0d44
8 changed files with 302 additions and 28 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
154
apps/kimi-code/test/cli/v2/v2-session.test.ts
Normal file
154
apps/kimi-code/test/cli/v2/v2-session.test.ts
Normal 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']));
|
||||
});
|
||||
});
|
||||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue