Skip to content
Open
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/goal-pause-daemon-crash.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion packages/agent-core-v2/src/agent/goal/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +33,11 @@ export interface IAgentGoalService {
getGoal(): GoalToolResult;
isGoalToolTarget(turnId: number, goalId: string): boolean;
createGoal(input: CreateGoalInput, actor?: GoalActor): Promise<GoalSnapshot>;
pauseGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
pauseGoal(
input?: GoalReasonInput,
actor?: GoalActor,
opts?: PauseGoalOptions,
): Promise<GoalSnapshot>;
resumeGoal(input?: ResumeGoalInput, actor?: GoalActor): Promise<GoalSnapshot>;
cancelGoal(input?: GoalReasonInput, actor?: GoalActor): Promise<GoalSnapshot>;
setBudgetLimits(
Expand Down
13 changes: 10 additions & 3 deletions packages/agent-core-v2/src/agent/goal/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -544,7 +544,11 @@ export class AgentGoalService extends Disposable implements IAgentGoalService {
this.clearInternal('system');
}

async pauseGoal(input: GoalReasonInput = {}, actor: GoalActor = 'user'): Promise<GoalSnapshot> {
async pauseGoal(
input: GoalReasonInput = {},
actor: GoalActor = 'user',
opts: PauseGoalOptions = {},
): Promise<GoalSnapshot> {
this.assertSupportedAgent();
const state = this.requireState();
if (state.status === 'paused') return this.toSnapshot(state);
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof sessionAgentConfigSchema>;

Expand Down
9 changes: 9 additions & 0 deletions packages/agent-core-v2/test/agent/goal/goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion packages/kap-server/src/protocol/rest-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions packages/telemetry/src/crash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down