diff --git a/.changeset/goal-pause-daemon-crash.md b/.changeset/goal-pause-daemon-crash.md new file mode 100644 index 0000000000..6bf289e045 --- /dev/null +++ b/.changeset/goal-pause-daemon-crash.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the `kimi web` server process exiting when a goal with a queued continuation is paused. diff --git a/packages/agent-core-v2/src/agent/goal/goal.ts b/packages/agent-core-v2/src/agent/goal/goal.ts index e88c5952c1..224e873f0c 100644 --- a/packages/agent-core-v2/src/agent/goal/goal.ts +++ b/packages/agent-core-v2/src/agent/goal/goal.ts @@ -18,6 +18,10 @@ export interface GoalReasonInput { readonly reason?: string; } +export interface PauseGoalOptions { + readonly preserveLiveContinuation?: boolean; +} + export interface ResumeGoalInput extends GoalReasonInput { readonly continueIfPaused?: boolean; readonly continueIfBlocked?: boolean; @@ -29,7 +33,11 @@ export interface IAgentGoalService { getGoal(): GoalToolResult; isGoalToolTarget(turnId: number, goalId: string): boolean; createGoal(input: CreateGoalInput, actor?: GoalActor): Promise; - pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; + pauseGoal( + input?: GoalReasonInput, + actor?: GoalActor, + opts?: PauseGoalOptions, + ): Promise; resumeGoal(input?: ResumeGoalInput, actor?: GoalActor): Promise; cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise; setBudgetLimits( diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index ae56369e30..30ee981ae6 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -82,7 +82,7 @@ import { IWireService } from '#/wire/wire'; import { defineModel } from '#/wire/model'; import { IEventBus } from '#/app/event/eventBus'; -import { IAgentGoalService, type GoalReasonInput, type ResumeGoalInput } from './goal'; +import { IAgentGoalService, type GoalReasonInput, type PauseGoalOptions, type ResumeGoalInput } from './goal'; import { IGoalDeadlineScheduler } from './goalDeadlineScheduler'; import { clearGoal, createGoal, GoalModel, updateGoal, type GoalState } from './goalOps'; import type { @@ -544,7 +544,11 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.clearInternal('system'); } - async pauseGoal(input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise { + async pauseGoal( + input: GoalReasonInput = {}, + actor: GoalActor = 'user', + opts: PauseGoalOptions = {}, + ): Promise { this.assertSupportedAgent(); const state = this.requireState(); if (state.status === 'paused') return this.toSnapshot(state); @@ -554,7 +558,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { `Cannot pause a goal in status "${state.status}"`, ); } - return this.applyLifecycle(state, 'paused', input.reason, actor); + return this.applyLifecycle(state, 'paused', input.reason, actor, { + preserveLiveContinuation: opts.preserveLiveContinuation === true, + }); } async pauseActiveGoal( @@ -951,6 +957,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } return turn.result; }) + .catch(() => {}) .finally(() => { if (pending.turnId !== undefined) this.pendingContinuationGoals.delete(pending.turnId); if (this.pendingContinuation === pending) this.pendingContinuation = undefined; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 1ec77c346e..4bad9c7aac 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -142,6 +142,9 @@ export class SessionLegacyService implements ISessionLegacyService { case 'pause': await goal.pauseGoal({}); break; + case 'pause_graceful': + await goal.pauseGoal({}, 'user', { preserveLiveContinuation: true }); + break; case 'resume': await goal.resumeGoal({ continueIfPaused: true, continueIfBlocked: true }); break; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts index b1c0410168..89fcc07507 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts @@ -46,7 +46,7 @@ export const sessionAgentConfigSchema = z.object({ plan_mode: z.boolean().optional(), swarm_mode: z.boolean().optional(), goal_objective: z.string().optional(), - goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), + goal_control: z.enum(['pause', 'pause_graceful', 'resume', 'cancel']).optional(), }); export type SessionAgentConfig = z.infer; diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 07232ac97a..6cdb86dbb8 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -946,6 +946,15 @@ describe('AgentGoalService core workflow hooks', () => { expect(abort).toHaveBeenCalledOnce(); }); + it('keeps the live continuation running on a graceful pause', async () => { + const abort = await startLiveContinuation(); + + await goals.pauseGoal({}, 'user', { preserveLiveContinuation: true }); + + expect(abort).not.toHaveBeenCalled(); + expect(goals.getGoal().goal?.status).toBe('paused'); + }); + it('aborts a live continuation when the user cancels the goal', async () => { const abort = await startLiveContinuation(); diff --git a/packages/kap-server/src/protocol/rest-prompt.ts b/packages/kap-server/src/protocol/rest-prompt.ts index b04a2fe78a..f9e9b2a513 100644 --- a/packages/kap-server/src/protocol/rest-prompt.ts +++ b/packages/kap-server/src/protocol/rest-prompt.ts @@ -42,7 +42,7 @@ export const promptSubmissionSchema = z.object({ plan_mode: z.boolean().optional(), swarm_mode: z.boolean().optional(), goal_objective: z.string().optional(), - goal_control: z.enum(['pause', 'resume', 'cancel']).optional(), + goal_control: z.enum(['pause', 'pause_graceful', 'resume', 'cancel']).optional(), // Client-managed session tool denylist: full-replace on every submit; the // bound profile's own deny always survives. Omit to keep the persisted // value, send `[]` to clear the client portion. diff --git a/packages/telemetry/src/crash.ts b/packages/telemetry/src/crash.ts index 35674a834d..a5f6c2f2ec 100644 --- a/packages/telemetry/src/crash.ts +++ b/packages/telemetry/src/crash.ts @@ -55,11 +55,13 @@ export function installCrashHandlersForClient(client: TelemetryClient): () => vo // the only listener (print / server modes) rethrow to preserve it; the // dedupe set above keeps the monitor from double-reporting that path. installedRejectionHandler = (reason: unknown) => { + // AbortError rejections are expected cancellation noise (e.g. a goal's + // pending continuation aborted by pauseGoal) — never track or rethrow + // them, or the daemon dies on a perfectly normal cancel. + if (isAbortError(reason)) return; const soleListener = process.listenerCount('unhandledRejection') === 1; - if (!isAbortError(reason)) { - trackCrash(crashErrorType(reason), 'unhandledRejection'); - recordedRejections.add(reason); - } + trackCrash(crashErrorType(reason), 'unhandledRejection'); + recordedRejections.add(reason); if (soleListener) { throw reason; }