Skip to content

[Bug] Resident child processes survive session end: terminal tasks are skipped at dispose and reconcile never signals the recorded pid #4028

Open

Description

@mikehasa

Version

  • Kimi Code desktop app: 1.0.4 (bundle id com.kimi.code.desktop), macOS 15.7.9 (24G830), arm64.
  • Engine running inside it: 2.1.1 — the desktop app runs kap-server in-process in the Electron main process, and its instance registry (~/.kimi-code/server/instances/<id>.json) reports "host_version":"2.1.1".
  • All file:line references below are against main @ be7d5f5fea7800778e4660cd5f36780ba783bddd (apps/kimi-code@2.1.1), which is the commit the 1.0.4 bundle was built from (packages/kap-server/src/... matches line-for-line).

Plan

Kimi Code subscription through the managed:kimi-code provider (OAuth, oauth_host = "https://auth.kimi.ai").

Model

DeepSeek/deepseek-flash (a custom provider in ~/.kimi-code/config.toml; it is the configured default_model). kimi-code/k3-256k was also in use in the same window.

Platform

uname -mprs → Darwin 24.6.0 arm64 arm (Apple M4 Max, 36 GiB).


What I'm seeing

A command that the agent runs through the Bash tool can spawn a process that outlives everything: the command, its shell, the task that owned it, the session, and the app. There is no code path anywhere that sends a signal to it. Concretely, on this machine four such processes were sitting at PPID=1 and were stable across 5 whole-machine ps -axww snapshots (50–78 s apart); two of them were still alive when this issue was written (40 min and 28 min old), and two only went away because they happen to carry their own idle/exit logic.

I think this is four separate gaps that compose into "nothing ever reaps it". Each is small on its own; together they mean a resident process started by an agent is effectively immortal.

1. detached: true is deliberate, but the sweep only ever runs for non-terminal tasks

Every Bash tool call spawns its shell as its own process group:

  • packages/agent-core-v2/src/agent/tools/os/bash/bashTool.ts:157-173 builds the shell command cd '<cwd>' && <command> (:164) and spawns it.
  • packages/agent-core-v2/src/os/backends/node-local/hostProcessService.ts:19-34 → detached = options.detached ?? !isWindows (on by default on POSIX); the same file kills by process group at :148 (process.kill(-this.pid, signal ?? 'SIGTERM')).
  • packages/kaos/src/local.ts:57-69 does the same for the older path, and its own comment at :145-147 already names this exact scenario: "a shell like bash -c 'sleep 100 & sleep 100' leaves grandchildren orphaned".

So the kill mechanism is right. The problem is when it is not called:

  • packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts:69-86 — the task settles as soon as the shell exits (sink.settle({status: 'completed'})); its finally only tears down streams.

  • packages/agent-core-v2/src/agent/task/taskService.ts:797-817 — dispose() iterates the task table and skips every terminal task:

    for (const entry of this.tasks.values()) {
      if (TERMINAL_STATUSES.has(entry.status)) continue;   // taskService.ts:800

    TERMINAL_STATUSES = {completed, failed, timed_out, killed, lost} (packages/agent-core-v2/src/agent/task/types.ts:9-15).

⇒ For the dominant case — a command that starts something and returns (( cmd & ), nohup … &, setsid, or a script that spawns and exits) — the task is completed before anyone thinks about cleanup, and dispose() will never look at it again. The process group is unreachable forever, even while the group leader is still alive.

Residual force-kill is also weak: forceStop() in process-task.ts:89-97 is guarded by if (this.proc.exitCode === null) (:91) — i.e. it deliberately does nothing once the shell has exited — and its only non-terminal caller, forceStopOnDispose() (taskService.ts:819-829), is fire-and-forget (void forceStop().catch(() => {})).

2. On the next start, reconcile() marks the record lost but never signals the pid it just read off disk

  • taskService.ts:541-549 reconcile() → markLoadedTasksLost() (:931-946) rewrites every non-terminal record to status: "lost" + endedAt. Status only.
  • taskService.ts:1145-1173 appendPreviousSessionTasksReminder() then injects a sentence into the next session's context: "…so your background tasks from the previous session lost contact … they may still be running…".

That is a verbal reminder, not an OS action. The pid is right there, persisted per task:

  • ProcessTaskInfo.pid (process-task.ts:99-108) is written to ~/.kimi-code/sessions/<workspace>/<session>/agents/<agent>/tasks/<taskId>.json, alongside command, startedAt, timeoutMs, detached.
  • Nothing consumes it: there is no reaper — grep -rn "orphan|reap|staleProcess|pgid" over packages/agent-core-v2/src returns no process-reaping code.

A real record from this machine:

{"taskId":"bash-7uuzkk0a","status":"running","detached":true,"kind":"process",
 "pid":30518,"exitCode":null,"timeoutMs":600000,
 "command":"cd ~/Projects/<app-a>/.worktrees/<worktree>/design/uiux-proposals && for s in …; do node shot.mjs $s & done; wait"}

Note it was persisted as running with a live pid; the previous session ended; the pid was never signalled. Its cwd is the same directory as the live orphan node serve.mjs <port> found below.

3. Restart-time bookkeeping proves the harness knows it lost the process

The local registry is unambiguous about this failure mode:

  • ~/.kimi-code/sessions/**/agents/*/tasks/*.json: 685 records at the time of the incident — completed 602, killed 49, failed 13, running 7, lost 7, timed_out 7 (725 records when this was written: completed 642, killed 50, failed 13, running 5, lost 7, timed_out 8).
  • task.terminated events with kind:"process": 568, every one carrying a pid (completed 505, killed 45, failed 11, timed_out 6, lost 1). The single lost entry is bash-4zuz8220, pid 623, exitCode: null, command cd ~/Projects/<app-a>/.worktrees/<worktree> && { echo "=== TYPECHECK"; pnpm typecheck; … } — the harness's own admission that it lost contact with a 3 000 000 ms background task.
  • detached: true is the norm, not the exception: 709 of 725 records — i.e. essentially every process task is one a session-exit sweep would have to consider.
  • ~/.kimi-code/config.toml has no [task]/[background] section and no KIMI_CODE_BACKGROUND_* variables, so keepAliveOnExit is false (taskService.ts:825-827) — "stop tasks on exit" is the intended behaviour, not a configuration the user opted out of.

4. Even the intended teardown is optional on the graceful paths and absent on the abrupt ones

  • Session archive does not touch tasks at all. packages/kap-server/src/routes/sessions.ts:993-1001 archiveSessionAction only flips the archived metadata; the agent stays in the roster and its processes keep running.
  • Server shutdown does not await the engine. packages/kap-server/src/start.ts:315-355 — core.dispose() (:340) is a synchronous void, and InstantiationService.dispose() itself schedules void this.disposeAsync() (packages/agent-core-v2/src/_base/di/instantiationService.ts:663-665). It also sits at the end of close() (after telemetry and five drains), so any process exit cuts it off. RunningServer.close() returning therefore does not mean "children are stopped".
  • Desktop quit is not awaited (that end lives in MoonshotAI/kimi-code-app, not this repo — noting it for completeness). The bundle's before-quit handler runs a cleanup list (including the server-close handle) as try { Promise.resolve(cleanup()).catch(…) } — no await — and then calls app.quit(); a second path calls app.exit(0), which bypasses before-quit entirely. Because the engine is in-process, every agent shell is a direct child of the Electron main process, so when it exits they all reparent to PPID=1 with nothing left that knows their pids.
  • TUI SIGHUP leaks by design. apps/kimi-code/src/tui/kimi-tui.ts:1070-1105 maps SIGHUP (dead terminal) to emergencyTerminalExit() → process.exit(129) (:1125-1133) with no task cleanup.

5. Observed survivors (paths redacted; shapes and pids preserved)

Measured with ps -axww -o pid,ppid,pgid,sess,stat,etime,rss,lstart,command in 5 rounds, plus lsof -a -p <pid> -d cwd -Fn:

pid command (shape) how it detached state
1636 node serve.mjs 4400 a Bash tool call ran cd … && ( node serve.mjs 4400 > /tmp/<app>-design-serve.log 2>&1 & ) ; sleep 1.5; curl …; the tool call returned 1.5 s after the child was born, so the child was orphaned almost immediately PPID=1, PGID=1634 (leader dead), still alive at 40 min; no pid recorded anywhere in the session's event stream
35278 (+7 helpers) Google Chrome --headless=new --remote-debugging-port=<port> --user-data-dir=/tmp/<profile> spawned by a script that the agent wrote and executed; the script exits, Chrome does not PPID=1, PGID=35275 (leader dead), still alive at 28 min
16537 (+child) bun run ~/.claude/skills/<skill>/browse/src/server.ts the tool's own CLI launches its server with spawn(…, {detached:true}).unref() on purpose PPID=1, PGID=16537, STAT=Ss — its own session leader, so even a correct group-kill cannot reach it; it self-exited later via its documented 30 min idle timeout
14342 node ~/.<tool>/versions/<v>/<tool>.mjs sync --until-idle a plugin hook double-detaches by design PPID=1, PGID=14342, STAT=Ss; exited later on its own

Only ~4 development-toolchain orphans existed on the machine; the other ~600 PPID=1 entries in each snapshot were launchd daemons. Sessions that own these files are still open — this is not only an app-exit problem: nothing was ever going to clean up 1636 even if the app had quit gracefully, because its task record is completed.

6. Why it matters here: 12 node processes at ~4 GB, machine hard-rebooted

On 2026-09-24, ~/Library/Logs/DiagnosticReports/JetsamEvent-2026-09-24-*.ips contains 11 reports between 15:44:16 and 15:51:22 (−0700), in which 12 distinct node processes appear at ≥1 GiB resident — 11 of them peaking at 3.68–4.09 GiB — with largestProcess = node in 10 of the 11 reports. The machine became unresponsive and had to be hard-rebooted. In the 25 minutes that led up to it (15:30–15:55 local), 29 background tasks were started, 27 of them detached, and 19 of them pnpm/turbo/vitest toolchain runs (pnpm exec turbo run typecheck --force, pnpm -r test, pnpm typecheck) across several worktrees — exactly the workload where a per-session sweep would matter.

I am not asking for a concurrency limit. The request is only that when a session is done, what it left behind gets collected.


Steps to reproduce

Deterministic; no race, no load required.

  1. In a session, run one Bash tool call:
    bash -c 'sleep 300 & exit 0'
    (equivalently ( sleep 300 & ), nohup sleep 300 &, or any script that spawns and returns).
  2. The task settles as completed as soon as the shell exits. sleep 300 is now unreachable — no later action will ever signal it.
  3. Close or archive the session, and quit the app.
  4. ps -o pid,ppid,pgid,stat,command -p <pid> → sleep 300 is still running, PPID=1, and its process group leader is gone.
  5. Start the app again. /tasks shows the previous session's record as lost, and the new session receives the reminder "…they may still be running…" — but the pid on disk is still alive, because nothing ever signals it.

Session ids / context usage for the report: the four survivors above were left by three desktop sessions on this machine (session_2fafb115…, session_b13e1071…, and the plugin hook's own client). The task records carry their pid, command, startedAt and timeoutMs if you want to inspect them.

Expected behavior

  1. Session close / archive should sweep the session's tasks, including terminal ones. A shell exiting is not proof that its process group is empty. On close, for every process task of the session, check whether the group has live members and, if so, SIGTERM the group, wait the existing grace period, then SIGKILL — the same escalation terminateWithGrace() (taskService.ts:706-775) already implements.
  2. reconcile() should act on the pid it already persists, instead of only writing status: "lost" and printing a reminder. The record has pid + command + startedAt, which is enough to do it safely.
  3. Both must be pid-reuse-safe, since they signal by negative pid: only signal when ps -o pgid= -p <pid> confirms the process is still its own group leader (pgid == pid) and its command line still matches the persisted command; otherwise skip and log. process.kill(pid, 0) is already used as a liveness probe elsewhere in the repo (packages/kap-server/src/instanceRegistry.ts).
  4. Graceful exit should actually wait. RunningServer.close() should await the engine's disposal (await core.disposeAsync(), moved ahead of the drain/telemetry tail in start.ts:315-355) so that "close resolved" means "children are stopped"; the desktop side (private repo) should await that handle before app.quit(), and the app.exit(0) path should not bypass it.
  5. TUI: run the standard stop path on SIGHUP before process.exit(129).
  6. Worth considering: keepAliveOnExit already exists as the opt-out; the sweep should respect it and stay off by default for genuinely detached dev server-style tasks only if the user asks for that.

Related issues

Verified against the tracker today; none of these is this bug.

Notes

  • Everything above is read-only observation on one machine (ps, lsof, gh api, the local session/task registry, and the shipped app.asar). No process was killed and no configuration was changed to produce this report. I am running the latest desktop build (1.0.4 / engine 2.1.1).
  • The registry path ~/.kimi-code/sessions/<ws>/<session>/agents/<agent>/tasks/<taskId>.json is a good audit surface for any fix: status, pid, command, detached, startedAt, timeoutMs are all there.

Related observation (different bug, same area)

Hook payloads on the desktop carry cwd: "/" for every agent-level event, because agentExternalHooksService never passes a session cwd and the fallback is bootstrap.cwd = process.cwd() — and the Electron main process's process.cwd() is /. I filed that separately as its own small issue (#4029) so it does not get lost in this one.

Contribution

  • I am willing to submit a PR for this bug fix myself (please wait for maintainer approval in this issue first).

A patch is already prepared and scoped to what this repo can own:

  1. packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts — add an explicit reap() (group SIGTERM → grace → group SIGKILL), separate from forceStop(), with the exitCode === null guard intentionally absent.
  2. packages/agent-core-v2/src/agent/task/taskService.ts — call it from dispose() for detached process tasks that are already terminal, and from reconcile() for ghost records that still carry a pid.
  3. A pid-reuse guard (group-leader + command-line match) — with tests asserting that a mismatched pgid/command sends no signal.
  4. packages/kap-server/src/start.ts — await core.disposeAsync() and move it to the front of close().
  5. A changeset on @moonshot-ai/kimi-code (patch).

Per CONTRIBUTING.md I am not opening the PR before a maintainer comments /approve here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions