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
1 change: 1 addition & 0 deletions packages/agent-core-v2/src/agent/task/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ export interface IAgentTaskService {
): Promise<AgentTaskOutputSnapshot>;
readOutput(taskId: string, tail?: number): Promise<string>;
suppressTerminalNotification(taskId: string): Promise<void>;
suppressAllTerminalNotifications(): Promise<void>;
markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void;
detach(taskId: string): AgentTaskInfo | undefined;
stop(taskId: string, reason?: string): Promise<AgentTaskInfo | undefined>;
Expand Down
39 changes: 25 additions & 14 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
declare readonly _serviceBrand: undefined;

private readonly tasks = new Map<string, ManagedTask>();
private exitSuppressionArmed = false;
private readonly buildingNotificationKeys = new Set<string>();
private readonly pendingNotificationRequests = new Map<string, LoopNotifyHandle>();
private readonly persistence: AgentTaskPersistence;
Expand Down Expand Up @@ -779,21 +780,16 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
return results.filter((info): info is AgentTaskInfo => info !== undefined);
}

async suppressAllTerminalNotifications(): Promise<void> {
this.exitSuppressionArmed = true;
Comment on lines +783 to +784

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor suppression in in-flight notification builds

When a detached task finishes just before remove() and notifyAgentTask() is already awaiting its output snapshot, the task's terminal state was recorded before this flag was armed. After the await, buildAgentTaskNotificationContext() checks only the task/ghost terminalNotificationSuppressed field, not exitSuppressionArmed, so it can still call loop.notify() and start a spurious turn or keep loop.settled() pending during teardown. Make the global flag visible to notification construction as well as settlement.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Drop already-queued task notifications when suppressing

If a detached task has already reached loop.notify() immediately before remove() arms suppression, its handle remains in pendingNotificationRequests; this method only blocks future notification construction and never drops that queued request. AgentLifecycleService.remove() cancels prompt reservations via pendingPromptIds, but task notifications are loop nudges, and AgentLoopService.settled() continues waiting for undropped nudges, so the notification can still start a turn and delay teardown. Drop the pending task-notification handles when arming suppression.

Useful? React with 👍 / 👎.

for (const [, request] of Array.from(this.pendingNotificationRequests)) {
request.drop();
}
}

async stopAllOnExit(reason: string): Promise<readonly AgentTaskInfo[]> {
await this.suppressAllTerminalNotifications();
if (this.keepAliveOnExit()) return [];
const active = this.list(true);
await Promise.allSettled(
active
.filter((task) => task.detached === true)
.map((task) =>
this.suppressTerminalNotification(task.taskId).catch((error: unknown) => {
this.log.error('terminal notification suppression failed', {
taskId: task.taskId,
error,
});
}),
),
);
return this.stopAll(reason);
}

Expand Down Expand Up @@ -834,6 +830,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
return this.sessionEventBus.isAgentActive(this.scopeContext.agentContext);
}

private marksTerminalNotificationSuppressed(entry: ManagedTask): boolean {
return this.exitSuppressionArmed && !this.keepAliveOnExit() && this.isDetached(entry);
}

async wait(
taskId: string,
timeoutMs = 30_000,
Expand Down Expand Up @@ -1035,12 +1035,22 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
entry.timeoutHandle = undefined;
}
const foregroundRelease = entry.foregroundRelease;
if (this.marksTerminalNotificationSuppressed(entry)) {
entry.terminalNotificationSuppressed = true;
}
if (entry.outputPersistStarted) {
await this.persistLive(entry);
} else {
entry.pendingOutput = [];
entry.pendingOutputBytes = 0;
}
if (
this.marksTerminalNotificationSuppressed(entry) &&
entry.terminalNotificationSuppressed !== true
) {
entry.terminalNotificationSuppressed = true;
await this.persistLive(entry);
}
this.fireTerminalEffects(entry);
foregroundRelease?.resolve('terminal');
this.resolveWaiters(entry);
Expand Down Expand Up @@ -1095,7 +1105,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {
if (!this.lifecycleActive()) return;
const context = await this.buildAgentTaskNotificationContext(info);
if (context === undefined) return;
if (!this.lifecycleActive()) return;
if (!this.lifecycleActive() || this.isTerminalNotificationSuppressed(info.taskId)) return;
const key = notificationKey(context.origin);
if (this.deliveredNotificationKeys.has(key)) return;
const handle = this.loop.notify({
Expand Down Expand Up @@ -1302,6 +1312,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService {

private isTerminalNotificationSuppressed(taskId: string): boolean {
return (
this.exitSuppressionArmed ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Recheck suppression before calling loop.notify

When remove() starts in the microtask between buildAgentTaskNotificationContext() returning and notifyAgentTask() resuming from its await, this new global check has already run, while lifecycleActive() remains true until killSpace(). The caller can therefore still reach loop.notify(), allowing the non-turn-scoped task notification to start a teardown turn or keep loop.settled() pending. Fresh evidence after the earlier review is the remaining post-build await boundary at lines 1101–1106, which has no suppression recheck; consult isTerminalNotificationSuppressed() again immediately before notifying the loop.

Useful? React with 👍 / 👎.

this.tasks.get(taskId)?.terminalNotificationSuppressed === true ||
this.ghosts.get(taskId)?.terminalNotificationSuppressed === true
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
managed.closing = true;
this.onWillCloseEmitter.fire(agent);
const handle = managed.handle;
await handle.accessor.get(IAgentTaskService).suppressAllTerminalNotifications();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the required user-facing changeset

This fixes observable CLI behavior by preventing background-task notifications from initiating work while an agent is being removed, but the commit contains no .changeset/ entry, so the fix will be omitted from the user-facing release changelog. Add a patch changeset for @moonshot-ai/kimi-code as required by the repository workflow.

AGENTS.md reference: AGENTS.md:L86-L86

Useful? React with 👍 / 👎.

const loop = handle.accessor.get(IAgentLoopService);
const compaction = handle.accessor.get(IAgentFullCompactionService).compacting;
const compactionSettled = compaction?.promise.catch(() => undefined) ?? Promise.resolve();
Expand Down
66 changes: 50 additions & 16 deletions packages/agent-core-v2/test/agent/task/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,37 @@ describe('AgentTaskService', () => {
}
}

it('enqueues a terminal notification for a finished detached task', async () => {
const svc = ix.get(IAgentTaskService);
it('enqueues a terminal notification for a finished detached task, but not when suppression arms mid-build', async () => {
let armOnRead = false;
let svc!: IAgentTaskService;
ix.stub(IFileSystemStorageService, {
read: async () => {
if (armOnRead) await svc.suppressAllTerminalNotifications();
return undefined;
},
readStream: async function* () {},
write: async () => {},
writeStream: async () => {},
append: async () => {},
list: async () => [],
delete: async () => {},
flush: async () => {},
});
svc = ix.get(IAgentTaskService);
const taskId = svc.registerTask(outputtingTask('done\n'));

await svc.wait(taskId, 1000);
const loop = stubLoop();
await waitForCondition(() => loop.hasPendingRequests());

expect(loop.hasPendingRequests()).toBe(true);

loop.drainNextBatch({ append: () => {} });
armOnRead = true;
const second = svc.registerTask(outputtingTask('done\n'));
await svc.wait(second, 1000);
await new Promise((resolve) => setTimeout(resolve, 10));

expect(loop.hasPendingRequests()).toBe(false);
});

it('markTasksDeliveredViaWait suppresses the automatic terminal notification', async () => {
Expand All @@ -317,7 +339,7 @@ describe('AgentTaskService', () => {
expect(states.get(taskNotificationDeliveryKey)).toContain(deliveryKey);
});

it('aborts an already-enqueued terminal notification when the task is marked delivered via wait', async () => {
it('aborts an already-enqueued terminal notification when the task is marked delivered via wait or suppression arms', async () => {
const svc = ix.get(IAgentTaskService);
const taskId = svc.registerTask(outputtingTask('done\n'));

Expand All @@ -329,6 +351,15 @@ describe('AgentTaskService', () => {
svc.markTasksDeliveredViaWait([{ taskId, status: 'completed' }]);

expect(loop.hasPendingRequests()).toBe(false);

const second = svc.registerTask(outputtingTask('done\n'));
await svc.wait(second, 1000);
await waitForCondition(() => loop.hasPendingRequests());
expect(loop.hasPendingRequests()).toBe(true);

await svc.suppressAllTerminalNotifications();

expect(loop.hasPendingRequests()).toBe(false);
});

it('suppresses only the notification whose status was reported via wait', async () => {
Expand Down Expand Up @@ -507,26 +538,25 @@ describe('AgentTaskService', () => {
const first = svc.registerTask(fakeProcessTask());
const second = svc.registerTask(fakeProcessTask());

await svc.suppressAllTerminalNotifications();
const third = svc.registerTask(fakeProcessTask());

const stopped = await svc.stopAllOnExit('Session closed');

expect(stopped.map((info) => info.taskId).toSorted()).toEqual([first, second].toSorted());
for (const taskId of [first, second]) {
expect(stopped.map((info) => info.taskId).toSorted()).toEqual(
[first, second, third].toSorted(),
);
for (const taskId of [first, second, third]) {
const info = svc.getTask(taskId);
expect(info?.status).toBe('killed');
expect(info?.stopReason).toBe('Session closed');
expect(info?.terminalNotificationSuppressed).toBe(true);
const persisted = writes.filter((write) => write.taskId === taskId);
expect(
persisted.some(
(write) =>
write.status === 'running' && write.terminalNotificationSuppressed === true,
),
).toBe(true);
expect(persisted.at(-1)).toMatchObject({
expect(writes.filter((write) => write.taskId === taskId).at(-1)).toMatchObject({
status: 'killed',
terminalNotificationSuppressed: true,
});
}
expect(stubLoop().hasPendingRequests()).toBe(false);
});

it('stopAllOnExit does not persist a foreground-only task', async () => {
Expand All @@ -544,7 +574,7 @@ describe('AgentTaskService', () => {
});
});

it('stopAllOnExit still stops tasks when suppression persistence fails', async () => {
it('stopAllOnExit still stops tasks when persistence fails', async () => {
let writes = 0;
ix.stub(IAtomicDocumentStore, {
get: async () => undefined,
Expand All @@ -566,7 +596,7 @@ describe('AgentTaskService', () => {
expect(svc.getTask(second)?.status).toBe('killed');
});

it('stopAllOnExit leaves tasks running when keepAliveOnExit is set', async () => {
it('stopAllOnExit leaves tasks running and suppresses in flight without persisting the marker when keepAliveOnExit is set', async () => {
stubTaskConfig({ keepAliveOnExit: true });
const svc = ix.get(IAgentTaskService);
const taskId = svc.registerTask(fakeProcessTask());
Expand All @@ -577,6 +607,10 @@ describe('AgentTaskService', () => {
expect(svc.getTask(taskId)?.status).toBe('running');

await svc.stop(taskId);

expect(svc.getTask(taskId)?.status).toBe('killed');
expect(svc.getTask(taskId)?.terminalNotificationSuppressed).toBeUndefined();
expect(stubLoop().hasPendingRequests()).toBe(false);
});

it('dispose aborts live tasks as a last resort', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ class FakeTaskService implements IAgentTaskService {
} as AgentTaskInfo;
}

async suppressAllTerminalNotifications(): Promise<void> {
const active = this.list(true).filter((info) => info.detached === true);
await Promise.all(active.map((info) => this.suppressTerminalNotification(info.taskId)));
}

markTasksDeliveredViaWait(tasks: readonly AgentTaskWaitDelivery[]): void {
this.waitDeliveries.push(tasks);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,9 @@ function createFakeTaskService(
async suppressTerminalNotification(): Promise<void> {
},

async suppressAllTerminalNotifications(): Promise<void> {
},

markTasksDeliveredViaWait(): void {
},

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ describe('AgentLifecycleService', () => {
let atomicDocs: Map<string, unknown>;
let permissionModeSetMode: ReturnType<typeof vi.fn>;
let stopAllOnExit: ReturnType<typeof vi.fn>;
let suppressAllTerminalNotifications: ReturnType<typeof vi.fn>;
let loopActiveTurnId: number | undefined;
let loopPendingPromptIds: string[];
let loopCancel: ReturnType<typeof vi.fn<IAgentLoopService['cancel']>>;
Expand Down Expand Up @@ -463,9 +464,11 @@ describe('AgentLifecycleService', () => {
isBaselineServer: () => true,
} satisfies ISessionMcpHandle);
stopAllOnExit = vi.fn(async () => []);
suppressAllTerminalNotifications = vi.fn(async () => {});
ix.stub(IAgentTaskService, {
_serviceBrand: undefined,
stopAllOnExit,
suppressAllTerminalNotifications,
} as unknown as IAgentTaskService);
ix.stub(IAgentFullCompactionService, {
_serviceBrand: undefined,
Expand Down Expand Up @@ -709,6 +712,10 @@ describe('AgentLifecycleService', () => {

expect(stopAllOnExit).toHaveBeenCalledWith('Session closed');
expect(promptDrain).toHaveBeenCalledOnce();
expect(suppressAllTerminalNotifications).toHaveBeenCalledOnce();
expect(suppressAllTerminalNotifications.mock.invocationCallOrder[0]).toBeLessThan(
promptDrain.mock.invocationCallOrder[0]!,
);
expect(stopAllOnExit.mock.invocationCallOrder[0]).toBeGreaterThan(
promptDrain.mock.invocationCallOrder[0]!,
);
Expand Down
Loading