sensorium-mcp 3.1.0-refactor.8 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/dist/config/agent-settings.d.ts +6 -0
  2. package/dist/config/agent-settings.d.ts.map +1 -1
  3. package/dist/config/agent-settings.js +22 -0
  4. package/dist/config/agent-settings.js.map +1 -1
  5. package/dist/config/detail-grant.d.ts +39 -0
  6. package/dist/config/detail-grant.d.ts.map +1 -0
  7. package/dist/config/detail-grant.js +61 -0
  8. package/dist/config/detail-grant.js.map +1 -0
  9. package/dist/config/index.d.ts +2 -0
  10. package/dist/config/index.d.ts.map +1 -1
  11. package/dist/config/index.js +5 -3
  12. package/dist/config/index.js.map +1 -1
  13. package/dist/config/misc-settings.d.ts +2 -0
  14. package/dist/config/misc-settings.d.ts.map +1 -1
  15. package/dist/config/misc-settings.js +22 -0
  16. package/dist/config/misc-settings.js.map +1 -1
  17. package/dist/config/settings-store.d.ts +41 -2
  18. package/dist/config/settings-store.d.ts.map +1 -1
  19. package/dist/config/settings-store.js +151 -14
  20. package/dist/config/settings-store.js.map +1 -1
  21. package/dist/dashboard/routes/copilot.d.ts.map +1 -1
  22. package/dist/dashboard/routes/copilot.js +17 -1
  23. package/dist/dashboard/routes/copilot.js.map +1 -1
  24. package/dist/dashboard/routes/settings.d.ts +2 -0
  25. package/dist/dashboard/routes/settings.d.ts.map +1 -1
  26. package/dist/dashboard/routes/settings.js +25 -1
  27. package/dist/dashboard/routes/settings.js.map +1 -1
  28. package/dist/dashboard/routes/threads-read.d.ts.map +1 -1
  29. package/dist/dashboard/routes/threads-read.js +15 -3
  30. package/dist/dashboard/routes/threads-read.js.map +1 -1
  31. package/dist/dashboard/routes/threads.d.ts.map +1 -1
  32. package/dist/dashboard/routes/threads.js +13 -4
  33. package/dist/dashboard/routes/threads.js.map +1 -1
  34. package/dist/dashboard/routes.d.ts.map +1 -1
  35. package/dist/dashboard/routes.js +14 -5
  36. package/dist/dashboard/routes.js.map +1 -1
  37. package/dist/dashboard/spa.html +11 -11
  38. package/dist/data/daily-marker.js +3 -3
  39. package/dist/data/daily-marker.js.map +1 -1
  40. package/dist/data/file-storage.d.ts.map +1 -1
  41. package/dist/data/file-storage.js +3 -3
  42. package/dist/data/file-storage.js.map +1 -1
  43. package/dist/data/memory/bootstrap.d.ts.map +1 -1
  44. package/dist/data/memory/bootstrap.js +2 -3
  45. package/dist/data/memory/bootstrap.js.map +1 -1
  46. package/dist/data/memory/migration-runner.d.ts.map +1 -1
  47. package/dist/data/memory/migration-runner.js +4 -7
  48. package/dist/data/memory/migration-runner.js.map +1 -1
  49. package/dist/data/memory/schema-ddl.d.ts +1 -1
  50. package/dist/data/memory/schema-ddl.d.ts.map +1 -1
  51. package/dist/data/memory/schema-ddl.js +7 -1
  52. package/dist/data/memory/schema-ddl.js.map +1 -1
  53. package/dist/data/memory/schema.js +2 -2
  54. package/dist/data/memory/schema.js.map +1 -1
  55. package/dist/index.js +67 -21
  56. package/dist/index.js.map +1 -1
  57. package/dist/lib/logger.d.ts.map +1 -1
  58. package/dist/lib/logger.js +21 -2
  59. package/dist/lib/logger.js.map +1 -1
  60. package/dist/lib/paths.d.ts +29 -0
  61. package/dist/lib/paths.d.ts.map +1 -0
  62. package/dist/lib/paths.js +36 -0
  63. package/dist/lib/paths.js.map +1 -0
  64. package/dist/lib/types.d.ts +1 -0
  65. package/dist/lib/types.d.ts.map +1 -1
  66. package/dist/server/factory.js +16 -6
  67. package/dist/server/factory.js.map +1 -1
  68. package/dist/server/http-server.d.ts.map +1 -1
  69. package/dist/server/http-server.js +52 -20
  70. package/dist/server/http-server.js.map +1 -1
  71. package/dist/services/agent-spawn.service.d.ts +3 -3
  72. package/dist/services/agent-spawn.service.d.ts.map +1 -1
  73. package/dist/services/agent-spawn.service.js +30 -9
  74. package/dist/services/agent-spawn.service.js.map +1 -1
  75. package/dist/services/background-runner.d.ts.map +1 -1
  76. package/dist/services/background-runner.js +22 -12
  77. package/dist/services/background-runner.js.map +1 -1
  78. package/dist/services/broker-client.d.ts +209 -0
  79. package/dist/services/broker-client.d.ts.map +1 -0
  80. package/dist/services/broker-client.js +359 -0
  81. package/dist/services/broker-client.js.map +1 -0
  82. package/dist/services/claude-spawn.service.d.ts +1 -1
  83. package/dist/services/claude-spawn.service.d.ts.map +1 -1
  84. package/dist/services/claude-spawn.service.js +25 -25
  85. package/dist/services/claude-spawn.service.js.map +1 -1
  86. package/dist/services/codex-spawn.service.d.ts +1 -1
  87. package/dist/services/codex-spawn.service.d.ts.map +1 -1
  88. package/dist/services/codex-spawn.service.js +19 -24
  89. package/dist/services/codex-spawn.service.js.map +1 -1
  90. package/dist/services/copilot-spawn.service.d.ts +1 -1
  91. package/dist/services/copilot-spawn.service.d.ts.map +1 -1
  92. package/dist/services/copilot-spawn.service.js +86 -37
  93. package/dist/services/copilot-spawn.service.js.map +1 -1
  94. package/dist/services/dispatcher/broker.d.ts +4 -3
  95. package/dist/services/dispatcher/broker.d.ts.map +1 -1
  96. package/dist/services/dispatcher/broker.js +23 -11
  97. package/dist/services/dispatcher/broker.js.map +1 -1
  98. package/dist/services/dispatcher/lock.js +2 -2
  99. package/dist/services/dispatcher/lock.js.map +1 -1
  100. package/dist/services/dispatcher/poller.d.ts.map +1 -1
  101. package/dist/services/dispatcher/poller.js +24 -5
  102. package/dist/services/dispatcher/poller.js.map +1 -1
  103. package/dist/services/flight-recorder.service.d.ts +37 -0
  104. package/dist/services/flight-recorder.service.d.ts.map +1 -0
  105. package/dist/services/flight-recorder.service.js +210 -0
  106. package/dist/services/flight-recorder.service.js.map +1 -0
  107. package/dist/services/keeper.service.d.ts +9 -0
  108. package/dist/services/keeper.service.d.ts.map +1 -1
  109. package/dist/services/keeper.service.js +198 -39
  110. package/dist/services/keeper.service.js.map +1 -1
  111. package/dist/services/maintenance-signal.d.ts.map +1 -1
  112. package/dist/services/maintenance-signal.js +2 -3
  113. package/dist/services/maintenance-signal.js.map +1 -1
  114. package/dist/services/process.service.d.ts +76 -61
  115. package/dist/services/process.service.d.ts.map +1 -1
  116. package/dist/services/process.service.js +224 -608
  117. package/dist/services/process.service.js.map +1 -1
  118. package/dist/services/reconnect-snapshot.service.js +2 -2
  119. package/dist/services/reconnect-snapshot.service.js.map +1 -1
  120. package/dist/services/scheduler.d.ts +1 -0
  121. package/dist/services/scheduler.d.ts.map +1 -1
  122. package/dist/services/scheduler.js +69 -14
  123. package/dist/services/scheduler.js.map +1 -1
  124. package/dist/services/self-update.service.d.ts +113 -15
  125. package/dist/services/self-update.service.d.ts.map +1 -1
  126. package/dist/services/self-update.service.js +382 -109
  127. package/dist/services/self-update.service.js.map +1 -1
  128. package/dist/services/spawn-common.d.ts +49 -23
  129. package/dist/services/spawn-common.d.ts.map +1 -1
  130. package/dist/services/spawn-common.js +262 -47
  131. package/dist/services/spawn-common.js.map +1 -1
  132. package/dist/services/spawn-exit-watcher.d.ts +43 -0
  133. package/dist/services/spawn-exit-watcher.d.ts.map +1 -0
  134. package/dist/services/spawn-exit-watcher.js +127 -0
  135. package/dist/services/spawn-exit-watcher.js.map +1 -0
  136. package/dist/services/telegram.d.ts +3 -2
  137. package/dist/services/telegram.d.ts.map +1 -1
  138. package/dist/services/telegram.js +8 -5
  139. package/dist/services/telegram.js.map +1 -1
  140. package/dist/services/thread-health.service.d.ts +1 -1
  141. package/dist/services/thread-health.service.d.ts.map +1 -1
  142. package/dist/services/thread-health.service.js +11 -7
  143. package/dist/services/thread-health.service.js.map +1 -1
  144. package/dist/services/thread-lifecycle.service.js +2 -2
  145. package/dist/services/thread-lifecycle.service.js.map +1 -1
  146. package/dist/services/topic.service.d.ts +1 -1
  147. package/dist/services/topic.service.d.ts.map +1 -1
  148. package/dist/services/topic.service.js +3 -2
  149. package/dist/services/topic.service.js.map +1 -1
  150. package/dist/services/worker-cleanup.service.d.ts.map +1 -1
  151. package/dist/services/worker-cleanup.service.js +25 -99
  152. package/dist/services/worker-cleanup.service.js.map +1 -1
  153. package/dist/test-bootstrap.d.ts +39 -0
  154. package/dist/test-bootstrap.d.ts.map +1 -0
  155. package/dist/test-bootstrap.js +78 -0
  156. package/dist/test-bootstrap.js.map +1 -0
  157. package/dist/tools/defs/session-defs.js +1 -1
  158. package/dist/tools/defs/session-defs.js.map +1 -1
  159. package/dist/tools/defs/utility-defs.d.ts.map +1 -1
  160. package/dist/tools/defs/utility-defs.js +4 -3
  161. package/dist/tools/defs/utility-defs.js.map +1 -1
  162. package/dist/tools/delegate-tool.d.ts +1 -1
  163. package/dist/tools/delegate-tool.d.ts.map +1 -1
  164. package/dist/tools/delegate-tool.js +37 -6
  165. package/dist/tools/delegate-tool.js.map +1 -1
  166. package/dist/tools/session-tools.d.ts.map +1 -1
  167. package/dist/tools/session-tools.js +34 -20
  168. package/dist/tools/session-tools.js.map +1 -1
  169. package/dist/tools/utility-tools.js +1 -1
  170. package/dist/tools/utility-tools.js.map +1 -1
  171. package/dist/tools/wait/context.d.ts +13 -0
  172. package/dist/tools/wait/context.d.ts.map +1 -1
  173. package/dist/tools/wait/message-delivery.d.ts.map +1 -1
  174. package/dist/tools/wait/message-delivery.js +2 -3
  175. package/dist/tools/wait/message-delivery.js.map +1 -1
  176. package/dist/tools/wait/poll-loop.d.ts.map +1 -1
  177. package/dist/tools/wait/poll-loop.js +8 -1
  178. package/dist/tools/wait/poll-loop.js.map +1 -1
  179. package/package.json +2 -2
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Flight-recorder heartbeat — the "progress" liveness signal from the
3
+ * 2026-07-26 process-lifecycle plan (§4.4).
4
+ *
5
+ * A dedicated worker thread (immune to main-loop stalls) writes a small
6
+ * heartbeat file every ~5s containing `{ts, eventLoopLagMs}`. The main
7
+ * thread pings the worker every second with its own measured timer drift;
8
+ * the worker combines that with how overdue the last ping is (its own
9
+ * monotonic clock). The result: when the main event loop freezes, the file
10
+ * KEEPS updating with a rising lag number — so the supervisor can tell
11
+ * "alive but stalled" from "dead", and the lag at kill time is on record.
12
+ *
13
+ * Clocks are never compared across threads: the main thread measures its
14
+ * lag with its own performance.now() delta, the worker measures beat
15
+ * staleness with its own. Both are monotonic within their thread.
16
+ */
17
+ import { Worker } from "node:worker_threads";
18
+ import { log } from "../lib/logger.js";
19
+ import { dataPath } from "../lib/paths.js";
20
+ /** Where the supervisor looks for the heartbeat (keep in sync with SupervisorWorker.cs). */
21
+ export const HEARTBEAT_FILE_PATH = dataPath("server-heartbeat.json");
22
+ const DEFAULT_BEAT_INTERVAL_MS = 1_000;
23
+ const DEFAULT_WRITE_INTERVAL_MS = 5_000;
24
+ /**
25
+ * Stall persistence (2026-07-28 forensics): the heartbeat file is overwritten
26
+ * every write interval, so a lag SPIKE leaves no durable record — the 15:11
27
+ * freeze had to be reconstructed from telemetry gaps. Spikes above this
28
+ * threshold are appended to a dedicated spike log by the WORKER (immune to the
29
+ * stall, survives a mid-stall kill) and WARN-logged by the main thread once the
30
+ * loop unblocks (records the full stall duration).
31
+ */
32
+ const LAG_SPIKE_THRESHOLD_MS = 1_000;
33
+ /** Min gap between spike-log appends so a long stall writes a line per write
34
+ * interval, not an unbounded stream. */
35
+ const SPIKE_APPEND_MIN_GAP_MS = 5_000;
36
+ /**
37
+ * How many times a dead heartbeat worker is respawned before giving up. The
38
+ * heartbeat is the supervisor's stalled-vs-dead evidence — losing it silently
39
+ * would put every future probe failure back on the kill-blind hung path.
40
+ */
41
+ const MAX_WORKER_RESPAWNS = 3;
42
+ // Worker body as a CommonJS eval string: avoids a separate compiled worker
43
+ // file, so it works identically under tsx (dev) and dist (production).
44
+ const WORKER_SOURCE = `
45
+ const { parentPort, workerData } = require("node:worker_threads");
46
+ const fs = require("node:fs");
47
+ const { filePath, beatIntervalMs, writeIntervalMs, spikeFilePath, spikeThresholdMs, spikeMinGapMs } = workerData;
48
+
49
+ let lastBeatAt = performance.now();
50
+ let lastMainLagMs = 0;
51
+ let lastSpikeAppendAt = 0;
52
+
53
+ parentPort.on("message", (m) => {
54
+ // Test hook: lets the respawn path be exercised through a real worker crash.
55
+ if (m.crashForTest) throw new Error("flight-recorder test crash");
56
+ lastBeatAt = performance.now();
57
+ lastMainLagMs = m.lagMs;
58
+ });
59
+
60
+ setInterval(() => {
61
+ // How overdue is the main thread's ping, by the worker's own clock?
62
+ const msSinceLastBeat = performance.now() - lastBeatAt;
63
+ const overdueMs = Math.max(0, msSinceLastBeat - beatIntervalMs);
64
+ const eventLoopLagMs = Math.round(Math.max(lastMainLagMs, overdueMs));
65
+ // Durable stall evidence: the heartbeat file below is overwritten each tick,
66
+ // so append spikes to their own log — written from THIS thread, which keeps
67
+ // running (and keeps appending a rising number) while the main loop is
68
+ // frozen, and survives the process being killed mid-stall.
69
+ if (eventLoopLagMs >= spikeThresholdMs && performance.now() - lastSpikeAppendAt >= spikeMinGapMs) {
70
+ lastSpikeAppendAt = performance.now();
71
+ try {
72
+ fs.appendFileSync(
73
+ spikeFilePath,
74
+ JSON.stringify({ ts: new Date().toISOString(), pid: process.pid, eventLoopLagMs }) + "\\n",
75
+ );
76
+ } catch { /* best-effort */ }
77
+ }
78
+ const payload = JSON.stringify({
79
+ ts: Date.now(),
80
+ pid: process.pid,
81
+ eventLoopLagMs,
82
+ mainThreadLagMs: Math.round(lastMainLagMs),
83
+ msSinceLastBeat: Math.round(msSinceLastBeat),
84
+ });
85
+ // Prefer atomic tmp+rename; fall back to a direct write if the destination
86
+ // is transiently locked (Windows AV/indexer). Readers tolerate a rare torn
87
+ // read by treating unparseable content as "no heartbeat".
88
+ const tmp = filePath + ".tmp";
89
+ try {
90
+ fs.writeFileSync(tmp, payload);
91
+ fs.renameSync(tmp, filePath);
92
+ } catch {
93
+ try {
94
+ fs.writeFileSync(filePath, payload);
95
+ } catch {
96
+ /* heartbeat is best-effort; never throw from the recorder */
97
+ }
98
+ }
99
+ }, writeIntervalMs);
100
+ `;
101
+ /**
102
+ * Start the flight recorder. Never throws — a broken recorder must not take
103
+ * down the server it instruments. Both the worker and the main-thread timer
104
+ * are unref'd, so they never keep the process alive.
105
+ */
106
+ export function startFlightRecorder(options = {}) {
107
+ const filePath = options.filePath ?? HEARTBEAT_FILE_PATH;
108
+ const beatIntervalMs = options.beatIntervalMs ?? DEFAULT_BEAT_INTERVAL_MS;
109
+ const writeIntervalMs = options.writeIntervalMs ?? DEFAULT_WRITE_INTERVAL_MS;
110
+ let worker = null;
111
+ let beatTimer = null;
112
+ let stopped = false;
113
+ let respawns = 0;
114
+ function stopBeat() {
115
+ if (beatTimer)
116
+ clearInterval(beatTimer);
117
+ beatTimer = null;
118
+ }
119
+ function startBeat(w) {
120
+ let lastFire = performance.now();
121
+ beatTimer = setInterval(() => {
122
+ const now = performance.now();
123
+ // Timer drift = how late this 1s tick fired — the event-loop lag.
124
+ const lagMs = Math.max(0, now - lastFire - beatIntervalMs);
125
+ lastFire = now;
126
+ // This code runs AFTER the loop unblocked, so lagMs here is the FULL
127
+ // stall duration — record it where post-mortems will look (server.log),
128
+ // with what the loop was late for. The worker already streamed the
129
+ // rising lag to the spike file while the stall was in progress.
130
+ if (lagMs >= LAG_SPIKE_THRESHOLD_MS)
131
+ log.warn(`[flight-recorder] event-loop stall ended: the loop was blocked for ~${Math.round(lagMs)}ms`);
132
+ w.postMessage({ lagMs });
133
+ }, beatIntervalMs);
134
+ beatTimer.unref();
135
+ }
136
+ // A dead worker means the supervisor loses its stalled-vs-dead evidence for
137
+ // the rest of the process's life — never let that happen silently. Stop the
138
+ // beat timer (no more postMessage into a corpse) and respawn a bounded
139
+ // number of times; past the cap, say loudly that the signal is down.
140
+ function onWorkerDown(cause) {
141
+ stopBeat();
142
+ worker = null;
143
+ if (stopped)
144
+ return;
145
+ if (respawns >= MAX_WORKER_RESPAWNS) {
146
+ log.error(`[flight-recorder] heartbeat worker down (${cause}) after ${respawns} respawns — ` +
147
+ `giving up: the progress signal is DOWN for the rest of this process`);
148
+ return;
149
+ }
150
+ respawns++;
151
+ log.error(`[flight-recorder] heartbeat worker down (${cause}) — respawning (${respawns}/${MAX_WORKER_RESPAWNS})`);
152
+ try {
153
+ spawnWorker();
154
+ }
155
+ catch (err) {
156
+ // Never let the recorder take down the server it instruments — this
157
+ // runs inside a worker event handler, where a throw would be uncaught.
158
+ worker = null;
159
+ log.error(`[flight-recorder] respawn failed (progress signal is DOWN): ${err}`);
160
+ }
161
+ }
162
+ function spawnWorker() {
163
+ const w = new Worker(WORKER_SOURCE, {
164
+ eval: true,
165
+ workerData: {
166
+ filePath,
167
+ beatIntervalMs,
168
+ writeIntervalMs,
169
+ spikeFilePath: filePath.replace(/\.json$/, "") + "-spikes.log",
170
+ spikeThresholdMs: LAG_SPIKE_THRESHOLD_MS,
171
+ spikeMinGapMs: SPIKE_APPEND_MIN_GAP_MS,
172
+ },
173
+ });
174
+ worker = w;
175
+ w.unref();
176
+ // "error" is always followed by "exit"; the worker===w guard makes the
177
+ // second event (or a stale exit after a respawn) a no-op.
178
+ w.on("error", (err) => {
179
+ if (worker === w)
180
+ onWorkerDown(`error: ${err}`);
181
+ });
182
+ w.on("exit", (code) => {
183
+ if (worker === w)
184
+ onWorkerDown(`exit code ${code}`);
185
+ });
186
+ startBeat(w);
187
+ }
188
+ try {
189
+ spawnWorker();
190
+ log.info(`[flight-recorder] heartbeat → ${filePath} every ${writeIntervalMs}ms`);
191
+ }
192
+ catch (err) {
193
+ worker = null;
194
+ stopBeat();
195
+ log.warn(`[flight-recorder] failed to start (continuing without heartbeat): ${err}`);
196
+ }
197
+ return {
198
+ async stop() {
199
+ stopped = true;
200
+ stopBeat();
201
+ if (worker)
202
+ await worker.terminate().catch(() => { });
203
+ worker = null;
204
+ },
205
+ crashWorkerForTest() {
206
+ worker?.postMessage({ crashForTest: true });
207
+ },
208
+ };
209
+ }
210
+ //# sourceMappingURL=flight-recorder.service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flight-recorder.service.js","sourceRoot":"","sources":["../../src/services/flight-recorder.service.ts"],"names":[],"mappings":"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"}
@@ -15,6 +15,15 @@ export declare class KeeperService {
15
15
  start(): void;
16
16
  stop(): void;
17
17
  private syncKeepers;
18
+ /** One keeper cycle with persistence: whenever a cycle changed any durable
19
+ * policy counter, push the new state to the broker's registry so it survives
20
+ * an MCP restart (plan §4.3). Single choke point — the cycle body has many
21
+ * early returns and each mutation site would otherwise need its own save. */
22
+ private runKeeperCycle;
23
+ private static durableStateOf;
24
+ /** Restore persisted counters when a keeper starts (max-merge so counters that
25
+ * already advanced in this session are never rolled back). */
26
+ private hydrateFromRegistry;
18
27
  private checkAndRestart;
19
28
  private notifyDeath;
20
29
  }
@@ -1 +1 @@
1
- {"version":3,"file":"keeper.service.d.ts","sourceRoot":"","sources":["../../src/services/keeper.service.ts"],"names":[],"mappings":"AAcA,OAAO,EAAe,KAAK,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAGzF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AA2I5D,qBAAa,aAAa;IAKtB,OAAO,CAAC,IAAI;IAJd,OAAO,CAAC,OAAO,CAAkC;IACjD,OAAO,CAAC,SAAS,CAA+C;gBAGtD,IAAI,EAAE;QACZ,WAAW,EAAE,MAAM,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;QACnD,eAAe,EAAE,sBAAsB,CAAC;QACxC,QAAQ,EAAE;YACR,WAAW,CACT,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAAC;SAClB,CAAC;QACF,MAAM,EAAE,MAAM,CAAC;KAChB;IAGH,KAAK,IAAI,IAAI;IAMb,IAAI,IAAI,IAAI;IAWZ,OAAO,CAAC,WAAW;YAgEL,eAAe;YA4Of,WAAW;CAkB1B"}
1
+ {"version":3,"file":"keeper.service.d.ts","sourceRoot":"","sources":["../../src/services/keeper.service.ts"],"names":[],"mappings":"AAeA,OAAO,EAAe,KAAK,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAGzF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAmK5D,qBAAa,aAAa;IAKtB,OAAO,CAAC,IAAI;IAJd,OAAO,CAAC,OAAO,CAAkC;IACjD,OAAO,CAAC,SAAS,CAA+C;gBAGtD,IAAI,EAAE;QACZ,WAAW,EAAE,MAAM,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;QACnD,eAAe,EAAE,sBAAsB,CAAC;QACxC,QAAQ,EAAE;YACR,WAAW,CACT,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,SAAS,CAAC,EAAE,MAAM,EAClB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAAC;SAClB,CAAC;QACF,MAAM,EAAE,MAAM,CAAC;KAChB;IAGH,KAAK,IAAI,IAAI;IAMb,IAAI,IAAI,IAAI;IAWZ,OAAO,CAAC,WAAW;IAwEnB;;;kFAG8E;YAChE,cAAc;IAc5B,OAAO,CAAC,MAAM,CAAC,cAAc;IAY7B;mEAC+D;YACjD,mBAAmB;YAkCnB,eAAe;YA2Uf,WAAW;CAkB1B"}
@@ -1,13 +1,14 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { log } from "../lib/logger.js";
4
- import { findAliveThreadViaPidFile, isProcessAlive, killProcessTree, PROCESS_LOGS_DIR, THREAD_LOGS_DIR, } from "./process.service.js";
4
+ import { findAliveThreadViaPidFile, isProcessAlive, isThreadAlive, killThreadTree, PROCESS_LOGS_DIR, THREAD_LOGS_DIR, } from "./process.service.js";
5
5
  import { getThreadSessionCount } from "../sessions/mcp-registry.js";
6
6
  import { readThreadHeartbeat } from "../data/file-storage.js";
7
7
  import { dispatchSpawn } from "./agent-spawn.service.js";
8
8
  import { getKeepAliveThreads, getThread } from "../data/memory/thread-registry.js";
9
9
  import { ThreadState } from "./thread-lifecycle.service.js";
10
10
  import { errorMessage } from "../lib/utils.js";
11
+ import { brokerGetKeeperState, brokerSaveKeeperState } from "./broker-client.js";
11
12
  const KEEPER_CHECK_INTERVAL_MS = 120_000; // 2 min
12
13
  const KEEPER_MAX_RETRIES = 5;
13
14
  const KEEPER_COOLDOWN_MS = 300_000; // 5 min
@@ -26,8 +27,8 @@ const UNKILLABLE_RENOTIFY_MS = 3_600_000; // 1 hour — re-ping the operator at
26
27
  // tool calls) after startup. Used to avoid trusting a frozen pre-restart
27
28
  // heartbeat as an "untracked live worker" and needlessly delaying restart.
28
29
  const SERVER_START_TIME = Date.now();
29
- /** DIAGNOSTIC (read-only): newest stream-json stdout log file for a thread, or null. */
30
- function findLatestThreadLogFile(threadId) {
30
+ /** DIAGNOSTIC (read-only): newest stream-json stdout log for a thread (path + mtime + size), or null. */
31
+ function findLatestThreadLog(threadId) {
31
32
  let files;
32
33
  try {
33
34
  files = readdirSync(THREAD_LOGS_DIR);
@@ -42,15 +43,19 @@ function findLatestThreadLogFile(threadId) {
42
43
  continue;
43
44
  const full = join(THREAD_LOGS_DIR, file);
44
45
  try {
45
- const mtimeMs = statSync(full).mtimeMs;
46
- if (!best || mtimeMs > best.mtimeMs)
47
- best = { path: full, mtimeMs };
46
+ const st = statSync(full);
47
+ if (!best || st.mtimeMs > best.mtimeMs)
48
+ best = { path: full, mtimeMs: st.mtimeMs, sizeBytes: st.size };
48
49
  }
49
50
  catch {
50
51
  /* file vanished between readdir and stat */
51
52
  }
52
53
  }
53
- return best?.path ?? null;
54
+ return best;
55
+ }
56
+ /** DIAGNOSTIC (read-only): newest stream-json stdout log path for a thread, or null. */
57
+ function findLatestThreadLogFile(threadId) {
58
+ return findLatestThreadLog(threadId)?.path ?? null;
54
59
  }
55
60
  /**
56
61
  * DIAGNOSTIC (logging-only): capture what node knows about a STUCK thread at the
@@ -61,7 +66,7 @@ function findLatestThreadLogFile(threadId) {
61
66
  * NEVER affect the kill. This runs only when the keeper has ALREADY decided the
62
67
  * thread is stuck; it triggers no new kills.
63
68
  */
64
- function writeWedgeSnapshot(threadId, pid, reason, heartbeat, now) {
69
+ async function writeWedgeSnapshot(threadId, pid, reason, heartbeat, now) {
65
70
  try {
66
71
  const dir = join(PROCESS_LOGS_DIR, "wedge-snapshots");
67
72
  mkdirSync(dir, { recursive: true });
@@ -72,7 +77,7 @@ function writeWedgeSnapshot(threadId, pid, reason, heartbeat, now) {
72
77
  lines.push(`timestamp: ${new Date(now).toISOString()}`);
73
78
  let alive;
74
79
  try {
75
- alive = isProcessAlive(pid);
80
+ alive = await isProcessAlive(pid);
76
81
  }
77
82
  catch (err) {
78
83
  alive = `error: ${errorMessage(err)}`;
@@ -163,7 +168,7 @@ export class KeeperService {
163
168
  // Add new keepers
164
169
  for (const thread of threads) {
165
170
  if (!this.keepers.has(thread.threadId)) {
166
- this.keepers.set(thread.threadId, {
171
+ const entry = {
167
172
  threadId: thread.threadId,
168
173
  retryCount: 0,
169
174
  fastExitCount: 0,
@@ -175,15 +180,23 @@ export class KeeperService {
175
180
  limboWarned: false,
176
181
  survivedKillCount: 0,
177
182
  lastUnkillableNotifyMs: 0,
178
- });
183
+ lastLogSizeBytes: 0,
184
+ lastHeartbeatMs: 0,
185
+ };
186
+ this.keepers.set(thread.threadId, entry);
179
187
  log.info(`[keeper] Started keeper for thread ${thread.threadId} ('${thread.name}')`);
188
+ // Hydrate backoff counters from the broker's registry (plan §4.3): they
189
+ // used to reset to zero on every MCP restart — during the Jul 25 wave 15
190
+ // resets in 2h19m meant no backoff ever matured. Fire-and-forget; until
191
+ // it lands the entry runs on defaults (today's behavior).
192
+ void this.hydrateFromRegistry(entry);
180
193
  }
181
194
  }
182
195
  // Check all active keepers (skip if already in-flight to prevent duplicate spawns)
183
196
  for (const entry of this.keepers.values()) {
184
197
  if (!entry.stopped && !entry.checking) {
185
198
  entry.checking = true;
186
- this.checkAndRestart(entry)
199
+ this.runKeeperCycle(entry)
187
200
  .catch((err) => {
188
201
  log.warn(`[keeper] checkAndRestart error for thread ${entry.threadId}: ${errorMessage(err)}`);
189
202
  })
@@ -193,6 +206,64 @@ export class KeeperService {
193
206
  }
194
207
  }
195
208
  }
209
+ /** One keeper cycle with persistence: whenever a cycle changed any durable
210
+ * policy counter, push the new state to the broker's registry so it survives
211
+ * an MCP restart (plan §4.3). Single choke point — the cycle body has many
212
+ * early returns and each mutation site would otherwise need its own save. */
213
+ async runKeeperCycle(entry) {
214
+ const before = KeeperService.durableStateOf(entry);
215
+ try {
216
+ await this.checkAndRestart(entry);
217
+ }
218
+ finally {
219
+ const after = KeeperService.durableStateOf(entry);
220
+ if (JSON.stringify(before) !== JSON.stringify(after)) {
221
+ void brokerSaveKeeperState(after).catch(() => {
222
+ /* unreachable broker already WARN-logged by the client */
223
+ });
224
+ }
225
+ }
226
+ }
227
+ static durableStateOf(entry) {
228
+ return {
229
+ threadId: entry.threadId,
230
+ retryCount: entry.retryCount,
231
+ fastExitCount: entry.fastExitCount,
232
+ fastExitEscalation: entry.fastExitEscalation,
233
+ cooldownUntilMs: entry.cooldownUntil > 0 ? entry.cooldownUntil : null,
234
+ survivedKillCount: entry.survivedKillCount,
235
+ lastStartAtMs: entry.lastStartTime > 0 ? entry.lastStartTime : null,
236
+ };
237
+ }
238
+ /** Restore persisted counters when a keeper starts (max-merge so counters that
239
+ * already advanced in this session are never rolled back). */
240
+ async hydrateFromRegistry(entry) {
241
+ try {
242
+ const state = await brokerGetKeeperState(entry.threadId);
243
+ if (!state || entry.stopped)
244
+ return;
245
+ entry.retryCount = Math.max(entry.retryCount, state.retryCount);
246
+ entry.fastExitCount = Math.max(entry.fastExitCount, state.fastExitCount);
247
+ entry.fastExitEscalation = Math.max(entry.fastExitEscalation, state.fastExitEscalation);
248
+ entry.survivedKillCount = Math.max(entry.survivedKillCount, state.survivedKillCount);
249
+ if (state.cooldownUntilMs !== null && state.cooldownUntilMs > entry.cooldownUntil)
250
+ entry.cooldownUntil = state.cooldownUntilMs;
251
+ if (state.lastStartAtMs !== null && entry.lastStartTime === 0)
252
+ entry.lastStartTime = state.lastStartAtMs;
253
+ if (state.retryCount > 0 ||
254
+ state.fastExitCount > 0 ||
255
+ state.fastExitEscalation > 0 ||
256
+ state.survivedKillCount > 0 ||
257
+ (state.cooldownUntilMs !== null && state.cooldownUntilMs > Date.now())) {
258
+ log.info(`[keeper] Thread ${entry.threadId}: restored persisted keeper state (retry=${state.retryCount}, fastExit=${state.fastExitCount}, escalation=${state.fastExitEscalation}, survivedKill=${state.survivedKillCount}${state.cooldownUntilMs !== null && state.cooldownUntilMs > Date.now()
259
+ ? `, cooldown until ${new Date(state.cooldownUntilMs).toISOString()}`
260
+ : ""})`);
261
+ }
262
+ }
263
+ catch (err) {
264
+ log.debug(`[keeper] keeper-state hydration failed for thread ${entry.threadId}: ${errorMessage(err)}`);
265
+ }
266
+ }
196
267
  async checkAndRestart(entry) {
197
268
  const now = Date.now();
198
269
  // 1. Check cooldown
@@ -201,33 +272,80 @@ export class KeeperService {
201
272
  return;
202
273
  }
203
274
  // 2. Check if thread is running — reads PID file directly (authoritative OS evidence)
204
- const alivePid = findAliveThreadViaPidFile(entry.threadId);
205
- // 2a. PID file shows no live tracked process — but a fresh heartbeat is the
206
- // authoritative "someone is actively serving this thread" signal: the poll
207
- // loop rewrites it every ~2s (and every tool call). If it is fresh, a live
208
- // Claude worker is STILL serving the thread under a PID we failed to track
209
- // (e.g. on Windows a process-tree/launcher death leaves the real worker
210
- // running while the tracked PID is gone). Spawning now would create a
211
- // DUPLICATE process → the operator gets answered twice. Skip the restart;
212
- // when the real worker finally exits, its heartbeat goes stale and the
213
- // normal restart path takes over.
275
+ const aliveProc = await findAliveThreadViaPidFile(entry.threadId);
276
+ const alivePid = aliveProc?.pid;
277
+ // 2a. PID file shows no live tracked process. A fresh heartbeat USED to be
278
+ // trusted alone as "someone is actively serving this thread" — but with the
279
+ // tracked PID gone, a heartbeat < 1h old is NOT proof of life: the heartbeat
280
+ // file is only rewritten in the wait poll loop and on tool calls, so a long
281
+ // final model turn freezes it, and if the process then dies (e.g. Claude's
282
+ // 5-hour org limit 429s it) the frozen-but-recent heartbeat lingers for up to
283
+ // STUCK_THRESHOLD_MS. That is exactly how thread 1327's death was silently
284
+ // swallowed for ~67 minutes on 2026-07-27: the limbo guard fired every ~2-min
285
+ // cycle while BOTH the restart AND the operator-facing 💀 notification were
286
+ // suppressed, until the frozen heartbeat finally crossed 1h.
287
+ //
288
+ // The ONLY signals that prove a worker is alive RIGHT NOW are CROSS-CYCLE
289
+ // deltas measured between keeper cycles (~2 min apart) — symmetric to the
290
+ // 2026-07-26 stuck-branch fix, which likewise learned that a stale heartbeat
291
+ // alone does not prove/disprove a wedge because a long model turn freezes it:
292
+ // • heartbeat ADVANCED since the previous cycle — a live untracked worker
293
+ // idle in the long-poll rewrites it every ~2s, so it keeps advancing, OR
294
+ // • stdout GREW in bytes since the previous cycle (sizeGrew) — a live worker
295
+ // mid model turn emits tokens even while its heartbeat is frozen.
296
+ // A genuinely dead worker shows BOTH frozen: no heartbeat advance AND no
297
+ // stdout growth → fall through to the normal restart + notifyDeath path so the
298
+ // operator is warned within ~2 keeper cycles instead of ~1 hour. (mtime < 1h
299
+ // is deliberately NOT used here: a just-died process leaves a log whose mtime
300
+ // is recent for up to an hour — the same failure mode as the frozen heartbeat.)
214
301
  if (alivePid === undefined) {
215
302
  const heartbeat = readThreadHeartbeat(entry.threadId);
216
- if (heartbeat !== null &&
217
- heartbeat > SERVER_START_TIME &&
218
- now - heartbeat < STUCK_THRESHOLD_MS) {
219
- if (!entry.limboWarned) {
220
- log.warn(`[keeper] Thread ${entry.threadId}: no live tracked PID but heartbeat is fresh (${Math.round((now - heartbeat) / 1000)}s ago) — a live worker is still serving it under an untracked PID. Skipping restart to avoid a duplicate process.`);
221
- entry.limboWarned = true;
222
- }
223
- else {
224
- log.debug(`[keeper] Thread ${entry.threadId}: still in untracked-live-worker limbo (heartbeat ${Math.round((now - heartbeat) / 1000)}s ago) — skipping restart.`);
303
+ // Upper sanity bound: no heartbeat, a pre-restart heartbeat from an older
304
+ // server generation, or a heartbeat already older than 1h is definitely not
305
+ // a live untracked worker → fall through to the cleanup + restart path.
306
+ const heartbeatPlausiblyLive = heartbeat !== null && heartbeat > SERVER_START_TIME && now - heartbeat < STUCK_THRESHOLD_MS;
307
+ if (heartbeatPlausiblyLive) {
308
+ const streamLog = findLatestThreadLog(entry.threadId);
309
+ const heartbeatAdvanced = entry.lastHeartbeatMs > 0 && heartbeat > entry.lastHeartbeatMs;
310
+ const sizeGrew = entry.lastLogSizeBytes > 0 &&
311
+ streamLog !== null &&
312
+ streamLog.sizeBytes > entry.lastLogSizeBytes;
313
+ // First cycle of this episode: no baseline yet, so no cross-cycle delta can
314
+ // be measured. Record the baselines and grant exactly ONE ~2-min grace
315
+ // cycle (vs. the old up-to-1-hour hole) before demanding evidence of life.
316
+ const noBaseline = entry.lastHeartbeatMs === 0 && entry.lastLogSizeBytes === 0;
317
+ if (noBaseline || heartbeatAdvanced || sizeGrew) {
318
+ // Still (or possibly) a live untracked worker — refresh baselines so the
319
+ // next cycle measures a fresh delta, then skip the restart.
320
+ entry.lastHeartbeatMs = heartbeat;
321
+ if (streamLog !== null)
322
+ entry.lastLogSizeBytes = streamLog.sizeBytes;
323
+ const evidence = noBaseline
324
+ ? "first cycle — baselining"
325
+ : heartbeatAdvanced
326
+ ? "heartbeat advanced"
327
+ : "stdout grew";
328
+ if (!entry.limboWarned) {
329
+ log.warn(`[keeper] Thread ${entry.threadId}: no live tracked PID but a worker still shows life (${evidence}, heartbeat ${Math.round((now - heartbeat) / 1000)}s ago) — a live worker is still serving it under an untracked PID. Skipping restart to avoid a duplicate process.`);
330
+ entry.limboWarned = true;
331
+ }
332
+ else {
333
+ log.debug(`[keeper] Thread ${entry.threadId}: still in untracked-live-worker limbo (${evidence}, heartbeat ${Math.round((now - heartbeat) / 1000)}s ago) — skipping restart.`);
334
+ }
335
+ return;
225
336
  }
226
- return;
337
+ // Heartbeat recent but FROZEN across cycles AND stdout static → the worker
338
+ // is dead (the 2026-07-27 thread-1327 case). Do NOT skip — fall through to
339
+ // the cleanup + normal restart + notifyDeath path below.
227
340
  }
228
341
  // Process is genuinely gone — any prior survived-kill episode is over.
229
342
  entry.survivedKillCount = 0;
230
343
  entry.lastUnkillableNotifyMs = 0;
344
+ // A restart writes a fresh (empty) stdout log; drop the byte baseline so the
345
+ // next episode never compares sizes across two different log files.
346
+ entry.lastLogSizeBytes = 0;
347
+ // Drop the heartbeat liveness baseline too — the next episode measures fresh.
348
+ entry.lastHeartbeatMs = 0;
231
349
  }
232
350
  // Not in limbo (PID alive, or heartbeat stale/absent) — clear the flag so a
233
351
  // future episode logs afresh.
@@ -235,7 +353,7 @@ export class KeeperService {
235
353
  if (alivePid !== undefined) {
236
354
  // 3. Check if stuck via heartbeat
237
355
  const heartbeat = readThreadHeartbeat(entry.threadId);
238
- const isStuck = heartbeat !== null
356
+ const heartbeatStale = heartbeat !== null
239
357
  ? now - heartbeat > STUCK_THRESHOLD_MS
240
358
  : // No parseable heartbeat but process is alive — treat as stuck unless
241
359
  // freshly spawned by THIS keeper instance (lastStartTime within threshold).
@@ -246,6 +364,35 @@ export class KeeperService {
246
364
  : // Restored from PID file (lastStartTime=0): no heartbeat data at all
247
365
  // means the process never wrote one — definitely stuck.
248
366
  true;
367
+ // A stale heartbeat alone does NOT prove a wedge. The heartbeat file is only
368
+ // rewritten on MCP tool calls and inside the wait poll loop; a single long
369
+ // model turn (heavy thinking / a large generation) makes no tool calls and is
370
+ // not in the poll loop, so the heartbeat freezes for the whole turn even
371
+ // though the agent is alive and productively streaming tokens. The agent's
372
+ // stdout stream-json log grows on every emitted line, giving an INDEPENDENT
373
+ // liveness signal. We read it two ways so neither can silently fail:
374
+ // (a) mtime advanced within the window, and
375
+ // (b) the log GREW in bytes since the previous keeper cycle — immune to
376
+ // Windows buffering the inherited-fd write so mtime lags behind output.
377
+ // Only declare a wedge when the heartbeat is stale AND neither stream signal
378
+ // shows life — otherwise the keeper decapitates a working agent mid-answer
379
+ // (exactly what killed thread 1327 at 61m on 2026-07-26). Checked only when
380
+ // the heartbeat is already stale, so the healthy common case does no extra I/O.
381
+ const streamLog = heartbeatStale ? findLatestThreadLog(entry.threadId) : null;
382
+ const streamAgeMs = streamLog !== null ? now - streamLog.mtimeMs : null;
383
+ const mtimeFresh = streamAgeMs !== null && streamAgeMs >= 0 && streamAgeMs < STUCK_THRESHOLD_MS;
384
+ const sizeGrew = streamLog !== null &&
385
+ entry.lastLogSizeBytes > 0 &&
386
+ streamLog.sizeBytes > entry.lastLogSizeBytes;
387
+ if (streamLog !== null)
388
+ entry.lastLogSizeBytes = streamLog.sizeBytes;
389
+ const streamFresh = mtimeFresh || sizeGrew;
390
+ if (heartbeatStale && streamFresh) {
391
+ log.info(`[keeper] Thread ${entry.threadId} heartbeat is stale (${heartbeat !== null ? Math.round((now - heartbeat) / 60000) + "m" : "none"}) but its stdout stream shows life (${mtimeFresh
392
+ ? `mtime ${Math.round(streamAgeMs / 1000)}s ago`
393
+ : "grew in bytes"}) — agent is mid-turn (streaming), not wedged. Skipping kill.`);
394
+ }
395
+ const isStuck = heartbeatStale && !streamFresh;
249
396
  if (isStuck) {
250
397
  const reason = heartbeat !== null
251
398
  ? `no heartbeat for ${Math.round((now - heartbeat) / 60000)}m`
@@ -265,11 +412,21 @@ export class KeeperService {
265
412
  // DIAGNOSTIC (logging-only): forensic snapshot BEFORE the kill so we can
266
413
  // see whether the stuck agent was still MCP-connected or already orphaned,
267
414
  // plus the tail of its stdout. Best-effort; never blocks/affects the kill.
268
- writeWedgeSnapshot(entry.threadId, alivePid, reason, heartbeat, now);
269
- await killProcessTree(alivePid, entry.threadId);
270
- // Verify kill succeeded before restarting — if the process is still alive,
271
- // do NOT spawn a second instance. Keeper will retry on the next cycle.
272
- if (isProcessAlive(alivePid)) {
415
+ await writeWedgeSnapshot(entry.threadId, alivePid, reason, heartbeat, now);
416
+ // THREAD-scoped kill: the recorded alivePid is a launcher that has long
417
+ // since exited while its forked child lives on in the thread's job, so a
418
+ // killProcessTree(alivePid) routes to the broker's KillByPid AlreadyDead
419
+ // fast-path and never touches the live child — then isProcessAlive(launcherPid)
420
+ // reads false and the keeper falsely concludes success → duplicate spawn +
421
+ // orphaned wedged child. Route by threadId (broker KillByThread → job kill of
422
+ // the real child); pass the launcher pid + PID-file startedAt as the
423
+ // identity-checked degraded fallback.
424
+ await killThreadTree(entry.threadId, "keeper-stuck", alivePid, aliveProc?.startedAt);
425
+ // Verify kill succeeded before restarting — if the thread still has a live
426
+ // member, do NOT spawn a second instance. Keeper will retry next cycle.
427
+ // THREAD-scoped so a broker-unreachable/unknown verdict treats the thread as
428
+ // alive (never a false "killed" → never a duplicate spawn).
429
+ if (await isThreadAlive(entry.threadId, alivePid, aliveProc?.startedAt)) {
273
430
  entry.survivedKillCount++;
274
431
  log.warn(`[keeper] Thread ${entry.threadId} PID=${alivePid} survived kill (${entry.survivedKillCount}x) — skipping restart (will retry next cycle)`);
275
432
  // Escalate: a process that repeatedly refuses to die needs operator
@@ -290,6 +447,8 @@ export class KeeperService {
290
447
  entry.survivedKillCount = 0;
291
448
  entry.lastUnkillableNotifyMs = 0;
292
449
  entry.retryCount = 0;
450
+ // Restart writes a fresh stdout log — drop the byte baseline (see above).
451
+ entry.lastLogSizeBytes = 0;
293
452
  // Fall through to restart
294
453
  }
295
454
  else {
@@ -355,7 +514,7 @@ export class KeeperService {
355
514
  ? `last keeper-start ${Math.round((now - entry.lastStartTime) / 1000)}s ago`
356
515
  : "never started by this server (inherited PID file)";
357
516
  log.info(`[keeper] Thread ${entry.threadId} not running — restarting (attempt ${entry.retryCount + 1}/${KEEPER_MAX_RETRIES}, ${lastStartLabel})`);
358
- const result = dispatchSpawn((thread.client || "claude"), thread.name, entry.threadId, this.deps.threadLifecycle, thread.workingDirectory ?? undefined);
517
+ const result = await dispatchSpawn((thread.client || "claude"), thread.name, entry.threadId, this.deps.threadLifecycle, thread.workingDirectory ?? undefined);
359
518
  if ("pid" in result) {
360
519
  entry.lastStartTime = Date.now();
361
520
  entry.retryCount = 0;