diff --git a/.changeset/task-liveness-probe.md b/.changeset/task-liveness-probe.md new file mode 100644 index 0000000000..74c5630940 --- /dev/null +++ b/.changeset/task-liveness-probe.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Probe pid liveness before marking restored background tasks as lost. Resuming a session whose tasks were still running in another process no longer reports those tasks as `lost`, which previously invited the model to resume them and start a duplicate worker alongside the live one. diff --git a/packages/agent-core-v2/src/agent/task/pidAlive.ts b/packages/agent-core-v2/src/agent/task/pidAlive.ts new file mode 100644 index 0000000000..c532b13959 --- /dev/null +++ b/packages/agent-core-v2/src/agent/task/pidAlive.ts @@ -0,0 +1,24 @@ +/** + * Liveness probe for persisted task pids. + * + * Task records keep the OS pid of the process they started, so a ghost task + * restored from disk can be checked against the running system before it is + * reclassified. Mirrors the probe the server-side instance registry uses + * (`packages/kap-server/src/instanceRegistry.ts`). + */ + +/** `process.kill(pid, 0)` probe — true if the pid exists, false on ESRCH. */ +export function pidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return false; + // EPERM = process exists but we can't signal it (different user). Treat as alive. + if (code === 'EPERM') return true; + // Anything else: be safe, assume alive so we don't clobber a live task. + return true; + } +} diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 774572c5c4..80418eddce 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -81,6 +81,7 @@ import { import { resolveAgentTaskConfig } from './configSection'; import { AgentTaskPersistence } from './persist'; import { TaskModel, taskStarted, taskTerminated } from './taskOps'; +import { pidAlive } from '#/agent/task/pidAlive'; import { formatTaskList } from '#/agent/task/tools/task-list'; import '#/agent/task/tools/task-output'; import '#/agent/task/tools/task-stop'; @@ -860,6 +861,11 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { const persistence = this.persistence; for (const [taskId, info] of this.ghosts) { if (TERMINAL_STATUSES.has(info.status)) continue; + // A ghost whose OS process is still running belongs to another live + // kimi-code process that is still driving it. Marking it `lost` tells the + // model it may resume the task, which starts a second worker alongside + // the first one. + if (info.kind === 'process' && pidAlive(info.pid)) continue; const updated: AgentTaskInfo = { ...info, status: 'lost', diff --git a/packages/agent-core-v2/test/agent/task/pidAlive.test.ts b/packages/agent-core-v2/test/agent/task/pidAlive.test.ts new file mode 100644 index 0000000000..1b1c3ddfac --- /dev/null +++ b/packages/agent-core-v2/test/agent/task/pidAlive.test.ts @@ -0,0 +1,61 @@ +/** + * Liveness-probe unit tests. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { pidAlive } from '#/agent/task/pidAlive'; + +function killError(code: string): NodeJS.ErrnoException { + const err = new Error(code) as NodeJS.ErrnoException; + err.code = code; + return err; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('pidAlive', () => { + it('reports the current process as alive', () => { + expect(pidAlive(process.pid)).toBe(true); + }); + + it('rejects pids that cannot identify a process', () => { + expect(pidAlive(0)).toBe(false); + expect(pidAlive(-1)).toBe(false); + expect(pidAlive(1.5)).toBe(false); + expect(pidAlive(Number.NaN)).toBe(false); + }); + + it('treats ESRCH as dead', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw killError('ESRCH'); + }); + + expect(pidAlive(4242)).toBe(false); + }); + + it('treats EPERM as alive — the process exists but belongs to another user', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw killError('EPERM'); + }); + + expect(pidAlive(4242)).toBe(true); + }); + + it('assumes alive on an unrecognised errno rather than clobbering a live task', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + throw killError('EINVAL'); + }); + + expect(pidAlive(4242)).toBe(true); + }); + + it('probes with signal 0 so it never disturbs the target', () => { + const kill = vi.spyOn(process, 'kill').mockReturnValue(true); + + expect(pidAlive(4242)).toBe(true); + expect(kill).toHaveBeenCalledWith(4242, 0); + }); +}); diff --git a/packages/agent-core-v2/test/agent/task/reconcile.test.ts b/packages/agent-core-v2/test/agent/task/reconcile.test.ts index d4c55e8b35..05e9861bfe 100644 --- a/packages/agent-core-v2/test/agent/task/reconcile.test.ts +++ b/packages/agent-core-v2/test/agent/task/reconcile.test.ts @@ -6,12 +6,13 @@ import { mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentTaskService, type AgentTaskInfo, } from '#/agent/task/task'; +import { pidAlive } from '#/agent/task/pidAlive'; import { IEventBus } from '#/app/event/eventBus'; import { taskServices, @@ -24,6 +25,10 @@ import { type TaskServiceTestManager, } from './stubs'; +vi.mock('#/agent/task/pidAlive', () => ({ + pidAlive: vi.fn(() => false), +})); + let sessionDir: string; let persistence: ReturnType; @@ -45,6 +50,7 @@ function persistedProcess( } beforeEach(async () => { + vi.mocked(pidAlive).mockReturnValue(false); sessionDir = join( tmpdir(), `kimi-bg-reconcile-${Date.now()}-${Math.random().toString(36).slice(2)}`, @@ -263,6 +269,63 @@ describe('AgentTaskService — loadFromDisk + reconcile', () => { ).toHaveLength(1); }); + it('keeps a ghost running when its process is still alive', async () => { + vi.mocked(pidAlive).mockReturnValue(true); + await persistence.writeTask( + persistedProcess({ + taskId: 'bash-alive000', + command: 'sleep 9999', + description: 'still running elsewhere', + pid: 4242, + }), + ); + + await background.loadFromDisk(); + await background.reconcile(); + + expect(pidAlive).toHaveBeenCalledWith(4242); + expect(background.getTask('bash-alive000')).toMatchObject({ + taskId: 'bash-alive000', + status: 'running', + }); + expect(await persistence.readTask('bash-alive000')).toMatchObject({ + taskId: 'bash-alive000', + status: 'running', + }); + expect( + emittedEvents.filter( + (event) => (event as { type?: string }).type === 'task.terminated', + ), + ).toEqual([]); + }); + + it('marks only the dead process lost when a live one shares the session', async () => { + vi.mocked(pidAlive).mockImplementation((pid: number) => pid === 4242); + await persistence.writeTask( + persistedProcess({ taskId: 'bash-alive001', pid: 4242 }), + ); + await persistence.writeTask( + persistedProcess({ taskId: 'bash-dead0001', pid: 99999 }), + ); + + await background.loadFromDisk(); + await background.reconcile(); + + expect(await persistence.readTask('bash-alive001')).toMatchObject({ + status: 'running', + }); + expect(await persistence.readTask('bash-dead0001')).toMatchObject({ + status: 'lost', + }); + const terminationEvents = emittedEvents.filter( + (event) => (event as { type?: string }).type === 'task.terminated', + ); + expect(terminationEvents).toHaveLength(1); + expect(terminationEvents[0]).toMatchObject({ + info: { taskId: 'bash-dead0001', status: 'lost' }, + }); + }); + it('restores terminal ghost notifications into context', async () => { await persistence.writeTask( persistedProcess({