Repository navigation
Description
Version
- Kimi Code desktop app:
1.0.4(bundle idcom.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:linereferences below are againstmain@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-173builds the shell commandcd '<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-69does the same for the older path, and its own comment at:145-147already names this exact scenario: "a shell likebash -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'})); itsfinallyonly 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-549reconcile()→markLoadedTasksLost()(:931-946) rewrites every non-terminal record tostatus: "lost"+endedAt. Status only.taskService.ts:1145-1173appendPreviousSessionTasksReminder()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, alongsidecommand,startedAt,timeoutMs,detached.- Nothing consumes it: there is no reaper —
grep -rn "orphan|reap|staleProcess|pgid"overpackages/agent-core-v2/srcreturns 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 —completed602,killed49,failed13,running7,lost7,timed_out7 (725 records when this was written:completed642,killed50,failed13,running5,lost7,timed_out8).task.terminatedevents withkind:"process": 568, every one carrying a pid (completed505,killed45,failed11,timed_out6,lost1). The singlelostentry isbash-4zuz8220,pid 623,exitCode: null, commandcd ~/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: trueis 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.tomlhas no[task]/[background]section and noKIMI_CODE_BACKGROUND_*variables, sokeepAliveOnExitisfalse(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-1001archiveSessionActiononly 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 synchronousvoid, andInstantiationService.dispose()itself schedulesvoid this.disposeAsync()(packages/agent-core-v2/src/_base/di/instantiationService.ts:663-665). It also sits at the end ofclose()(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'sbefore-quithandler runs a cleanup list (including the server-close handle) astry { Promise.resolve(cleanup()).catch(…) }— noawait— and then callsapp.quit(); a second path callsapp.exit(0), which bypassesbefore-quitentirely. 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 toPPID=1with nothing left that knows their pids. - TUI SIGHUP leaks by design.
apps/kimi-code/src/tui/kimi-tui.ts:1070-1105maps SIGHUP (dead terminal) toemergencyTerminalExit()→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.
- In a session, run one Bash tool call:
(equivalently
bash -c 'sleep 300 & exit 0'( sleep 300 & ),nohup sleep 300 &, or any script that spawns and returns). - The task settles as
completedas soon as the shell exits.sleep 300is now unreachable — no later action will ever signal it. - Close or archive the session, and quit the app.
ps -o pid,ppid,pgid,stat,command -p <pid>→sleep 300is still running,PPID=1, and its process group leader is gone.- Start the app again.
/tasksshows the previous session's record aslost, 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
- 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,
SIGTERMthe group, wait the existing grace period, thenSIGKILL— the same escalationterminateWithGrace()(taskService.ts:706-775) already implements. reconcile()should act on the pid it already persists, instead of only writingstatus: "lost"and printing a reminder. The record haspid+command+startedAt, which is enough to do it safely.- 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 persistedcommand; 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). - Graceful exit should actually wait.
RunningServer.close()should await the engine's disposal (await core.disposeAsync(), moved ahead of the drain/telemetry tail instart.ts:315-355) so that "close resolved" means "children are stopped"; the desktop side (private repo) shouldawaitthat handle beforeapp.quit(), and theapp.exit(0)path should not bypass it. - TUI: run the standard stop path on SIGHUP before
process.exit(129). - Worth considering:
keepAliveOnExitalready exists as the opt-out; the sweep should respect it and stay off by default for genuinely detacheddev server-style tasks only if the user asks for that.
Related issues
Verified against the tracker today; none of these is this bug.
- Session close and server shutdown leave session-scoped resources alive #1246 Session close and server shutdown leave session-scoped resources alive — nearest anchor: same theme (session-scoped resources), and it explicitly lists "terminal records and pty processes are not cleaned up when a session closes". It is framed around record/terminal retention; the process-tree reaping described here is not covered by it or by its companion PRs (fix: prevent zombie sessions and session-scoped resource leaks #1247/fix: prevent unbounded session lifecycle leaks and cancellation hangs #1249 were closed unmerged). Related, not duplicate.
- fix(agent-core): terminate MCP stdio process tree on close #1038 fix(agent-core): terminate MCP stdio process tree on close — the exact patch pattern this needs (capture pid, escalate
SIGTERM→SIGKILLon the group, treatESRCHas success). Its scope is MCP stdio children only. - Background tasks show as running in UI but disappear from /tasks after a non-graceful exit #3924 Background tasks show as running in UI but disappear from /tasks after a non-graceful exit — same root cause, different symptom. It is the state dimension (a
task.startedwith notask.terminated); this report is the process dimension of the same missing teardown. - [Bug] Desktop Kimi Work python-run spawned thousands of runner.py processes on Windows #1118 Desktop Kimi Work python-run spawned thousands of runner.py processes — precedent for a resource-exhaustion report, but that is a runaway spawn loop; this is about survivors of a normal, finished command.
- Also adjacent, and useful if the maintainers prefer to fold this into existing work: Background tasks marked "lost" without liveness check; resume spawns duplicate concurrent workers #1924 / fix(agent-core): probe pid liveness before marking restored tasks lost #2190 (
losttasks marked without a liveness probe; aprocess.kill(pid, 0)fix), fix(server): prevent live session resource leaks on shutdown #1276 / fix(agent-core): dispose session terminal records on close #1277 (shutdown disposes the DI graph without closing live sessions), fix(agent-core-v2): notify the model of previous background tasks terminated by app exit #3292 (merged —stopAllOnExitcovers the normal exit path only, which is exactly why the abnormal path is the gap).
Notes
- Everything above is read-only observation on one machine (
ps,lsof,gh api, the local session/task registry, and the shippedapp.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>.jsonis a good audit surface for any fix:status,pid,command,detached,startedAt,timeoutMsare 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:
packages/agent-core-v2/src/agent/tools/os/bash/process-task.ts— add an explicitreap()(groupSIGTERM→ grace → groupSIGKILL), separate fromforceStop(), with theexitCode === nullguard intentionally absent.packages/agent-core-v2/src/agent/task/taskService.ts— call it fromdispose()for detached process tasks that are already terminal, and fromreconcile()for ghost records that still carry a pid.- A pid-reuse guard (group-leader + command-line match) — with tests asserting that a mismatched
pgid/command sends no signal. packages/kap-server/src/start.ts—await core.disposeAsync()and move it to the front ofclose().- A changeset on
@moonshot-ai/kimi-code(patch).
Per CONTRIBUTING.md I am not opening the PR before a maintainer comments /approve here.
Activity