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/task-liveness-probe.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions packages/agent-core-v2/src/agent/task/pidAlive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Liveness probe for persisted task pids.

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 Start the helper header with its domain identity

The new helper header starts with a free-form label instead of the required `<domain>` domain (Ln) — <role> identity line, so its layer and external responsibility are not documented consistently. Rewrite the first line in the scoped format and consolidate the later function and branch comments into that header.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L11-L15

Useful? React with 👍 / 👎.

*
* 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Verify process identity instead of PID existence

For a stale running record left by a crash, the OS may have reused its PID for an unrelated process before the session is restored. kill(pid, 0) then returns success even though the recorded task is dead, causing reconciliation to suppress the lost state and recovery indefinitely. Persist and compare a process-birth or owner-instance identity in addition to the numeric PID.

Useful? React with 👍 / 👎.

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;
}
}
6 changes: 6 additions & 0 deletions packages/agent-core-v2/src/agent/task/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Comment on lines +864 to +867

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 Move reconciliation rationale into the file header

This newly added explanatory block is beside method statements, while the scoped convention requires comments to live solely in the top-of-file header and forbids comments beside functions, methods, or statements. Fold the externally relevant rationale into the existing module header and leave this control flow self-explanatory.

AGENTS.md reference: packages/agent-core-v2/AGENTS.md:L11-L15

Useful? React with 👍 / 👎.

if (info.kind === 'process' && pidAlive(info.pid)) continue;

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 Reconcile skipped ghosts after their owner exits

When a second process restores the session while the original task is alive, this continue leaves the nonterminal task only in ghosts. Production invokes reconcile() once from restoreAfterReplay; afterward getTask() and wait() return the cached ghost without reloading its persisted record. When the owning process later writes completed or failed, the resumed process therefore reports the task as running forever, TaskOutput remains not_ready, and no terminal notification is emitted. Skipped ghosts need an ownership/status refresh mechanism so they converge after the original task terminates.

Useful? React with 👍 / 👎.

const updated: AgentTaskInfo = {
...info,
status: 'lost',
Expand Down
61 changes: 61 additions & 0 deletions packages/agent-core-v2/test/agent/task/pidAlive.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
65 changes: 64 additions & 1 deletion packages/agent-core-v2/test/agent/task/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +25,10 @@ import {
type TaskServiceTestManager,
} from './stubs';

vi.mock('#/agent/task/pidAlive', () => ({
pidAlive: vi.fn(() => false),
}));

let sessionDir: string;
let persistence: ReturnType<typeof createAgentTaskPersistence>;

Expand All @@ -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)}`,
Expand Down Expand Up @@ -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({
Expand Down