pi-crew 0.6.1 → 0.6.3

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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -1,16 +1,31 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
4
- import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
5
- import { appendEvent } from "../state/event-log.ts";
6
- import { loadRunManifestById, saveRunManifest, updateRunStatus } from "../state/state-store.ts";
7
- import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
8
4
  import { loadConfig } from "../config/config.ts";
5
+ import { appendEvent } from "../state/event-log.ts";
6
+ import {
7
+ withRunLockSync,
8
+ } from "../state/locks.ts";
9
+ import {
10
+ loadRunManifestById,
11
+ saveRunManifest,
12
+ updateRunStatus,
13
+ } from "../state/state-store.ts";
14
+ import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
15
+ import { projectCrewRoot } from "../utils/paths.ts";
16
+ import {
17
+ allWorkflows,
18
+ discoverWorkflows,
19
+ } from "../workflows/discover-workflows.ts";
9
20
  // Heavy runtime — lazy-loaded to avoid pulling team-runner into background-runner
10
21
  // at module load time. Only needed when a background run actually starts.
11
22
  import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
23
+ import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
24
+
12
25
  let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
13
- async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
26
+ async function executeTeamRun(
27
+ ...args: Parameters<typeof ExecuteTeamRunFn>
28
+ ): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
14
29
  if (!_cachedExecuteTeamRun) {
15
30
  // LAZY: avoid pulling team-runner into background-runner at module load time.
16
31
  const mod = await import("./team-runner.ts");
@@ -18,13 +33,18 @@ async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Pro
18
33
  }
19
34
  return _cachedExecuteTeamRun(...args);
20
35
  }
21
- import { resolveCrewRuntime, runtimeResolutionState } from "./runtime-resolver.ts";
36
+
37
+ import { logInternalError } from "../utils/internal-error.ts";
38
+ import { writeAsyncStartMarker } from "./async-marker.ts";
22
39
  import { terminateActiveChildPiProcesses } from "./child-pi.ts";
40
+ import { unregisterWorker } from "./orphan-worker-registry.ts";
23
41
  import { directTeamAndWorkflowFromRun } from "./direct-run.ts";
24
42
  import { expandParallelResearchWorkflow } from "./parallel-research.ts";
25
- import { writeAsyncStartMarker } from "./async-marker.ts";
26
43
  import { startParentGuard, stopParentGuard } from "./parent-guard.ts";
27
- import { logInternalError } from "../utils/internal-error.ts";
44
+ import {
45
+ resolveCrewRuntime,
46
+ runtimeResolutionState,
47
+ } from "./runtime-resolver.ts";
28
48
 
29
49
  /**
30
50
  * Heartbeat mechanism: periodically write a heartbeat file so the stale reconciler
@@ -32,17 +52,28 @@ import { logInternalError } from "../utils/internal-error.ts";
32
52
  * Without this, the reconciler relies solely on process.kill(pid, 0) which can
33
53
  * false-positive when a process is SIGKILLed and the PID hasn't been recycled yet.
34
54
  */
35
- function startHeartbeat(stateRoot: string, eventsPath: string, runId: string): () => void {
55
+ function startHeartbeat(
56
+ stateRoot: string,
57
+ eventsPath: string,
58
+ runId: string,
59
+ ): () => void {
36
60
  const heartbeatPath = path.join(stateRoot, "heartbeat.json");
37
61
  const writeHeartbeat = (): void => {
38
62
  try {
39
63
  const mem = process.memoryUsage();
40
- fs.writeFileSync(heartbeatPath, JSON.stringify({
41
- pid: process.pid,
42
- at: Date.now(),
43
- runId,
44
- memory: { heapUsedMb: Math.round(mem.heapUsed / 1024 / 1024), rssMb: Math.round(mem.rss / 1024 / 1024) },
45
- }), "utf-8");
64
+ fs.writeFileSync(
65
+ heartbeatPath,
66
+ JSON.stringify({
67
+ pid: process.pid,
68
+ at: Date.now(),
69
+ runId,
70
+ memory: {
71
+ heapUsedMb: Math.round(mem.heapUsed / 1024 / 1024),
72
+ rssMb: Math.round(mem.rss / 1024 / 1024),
73
+ },
74
+ }),
75
+ "utf-8",
76
+ );
46
77
  } catch {
47
78
  /* ignore — best-effort */
48
79
  }
@@ -69,29 +100,58 @@ function argValue(name: string): string | undefined {
69
100
  return process.argv[index + 1];
70
101
  }
71
102
 
72
- function startInterruptGuard(manifest: { runId: string; stateRoot: string; eventsPath: string }, abortController: AbortController): () => void {
73
- const controlPath = path.join(manifest.stateRoot, "foreground-control.json");
103
+ function startInterruptGuard(
104
+ manifest: { runId: string; stateRoot: string; eventsPath: string },
105
+ abortController: AbortController,
106
+ stopParentGuard: () => void,
107
+ ): () => void {
108
+ const controlPath = path.join(
109
+ manifest.stateRoot,
110
+ "foreground-control.json",
111
+ );
112
+ // FIX: Made configurable via PI_CREW_INTERRUPT_GUARD_INTERVAL_MS env var.
113
+ // Default 250ms balances fast SIGINT response against filesystem overhead.
114
+ const interruptGuardInterval =
115
+ Number(process.env.PI_CREW_INTERRUPT_GUARD_INTERVAL_MS) || 250;
74
116
  const interval = setInterval(() => {
75
117
  try {
76
118
  if (!fs.existsSync(controlPath)) return;
77
- const parsed = JSON.parse(fs.readFileSync(controlPath, "utf-8")) as { requests?: Array<{ type: string; acknowledged?: boolean }> };
119
+ const parsed = JSON.parse(
120
+ fs.readFileSync(controlPath, "utf-8"),
121
+ ) as { requests?: Array<{ type: string; acknowledged?: boolean }> };
78
122
  const last = parsed.requests?.at(-1);
79
123
  if (last?.type === "interrupt" && last?.acknowledged !== true) {
80
- appendEvent(manifest.eventsPath, { type: "async.interrupt_detected", runId: manifest.runId, message: "Background runner detected foreground interrupt — killing child processes and exiting." });
124
+ appendEvent(manifest.eventsPath, {
125
+ type: "async.interrupt_detected",
126
+ runId: manifest.runId,
127
+ message:
128
+ "Background runner detected foreground interrupt — killing child processes and exiting.",
129
+ });
81
130
  // FIX: Terminate ALL child-pi processes IMMEDIATELY before exiting.
82
131
  // Previously this was missing, causing orphaned child processes to run forever
83
132
  // after the background-runner exited. terminateActiveChildPiProcesses sends
84
133
  // SIGTERM then SIGKILL (after HARD_KILL_MS=3s) to every active child.
85
134
  const killed = terminateActiveChildPiProcesses();
86
- console.log(`[background-runner] interrupt: killed ${killed} child processes`);
135
+ console.log(
136
+ `[background-runner] interrupt: killed ${killed} child processes`,
137
+ );
87
138
  // Also abort the run signal so executeTeamRun exits quickly via its signal check.
88
139
  abortController.abort();
140
+ // FIX Issue #1: Call stopParentGuard() here since process.exit(130) bypasses
141
+ // the finally block in main() which would otherwise call runCleanup.
142
+ stopParentGuard();
143
+ // NOTE: process.exit() schedules exit handlers synchronously. The finally
144
+ // block in main() (stopParentGuard, cleanup, etc.) executes BEFORE the
145
+ // process actually terminates. This ordering is intentional — cleanup must
146
+ // run before exit handlers to ensure consistent state.
89
147
  process.exit(130);
148
+ } else if (last) {
149
+ console.warn(`[background-runner] Ignoring unknown foreground control request: ${last.type}`);
90
150
  }
91
151
  } catch {
92
152
  /* ignore read/parse errors */
93
153
  }
94
- }, 500); // FIX: Reduced from 3000ms to 500ms for faster cancel response
154
+ }, interruptGuardInterval);
95
155
  interval.unref();
96
156
  return () => clearInterval(interval);
97
157
  }
@@ -103,30 +163,120 @@ function startInterruptGuard(manifest: { runId: string; stateRoot: string; event
103
163
  * BEFORE async.completed is written to the event log.
104
164
  * This causes the async notifier to falsely detect a stuck run after quietMs expires.
105
165
  */
106
- function setupUnhandledRejectionGuard(state: { cwd?: string; runId?: string; eventsPath?: string }): void {
166
+ function setupUnhandledRejectionGuard(
167
+ state: {
168
+ cwd?: string;
169
+ runId?: string;
170
+ eventsPath?: string;
171
+ },
172
+ abortController: AbortController,
173
+ setExitFlag: () => void,
174
+ ): void {
107
175
  process.on("unhandledRejection", (reason, promise) => {
108
- const message = reason instanceof Error ? reason.message : String(reason);
176
+ const message =
177
+ reason instanceof Error ? reason.message : String(reason);
109
178
  console.error("[background-runner] UNHANDLED REJECTION:", reason);
110
- console.error("[background-runner] Stack:", reason instanceof Error ? reason.stack : "N/A");
179
+ console.error(
180
+ "[background-runner] Stack:",
181
+ reason instanceof Error ? reason.stack : "N/A",
182
+ );
111
183
  try {
112
184
  if (state.eventsPath && state.runId) {
113
185
  appendEvent(state.eventsPath, {
114
186
  type: "async.failed",
115
187
  runId: state.runId,
116
188
  message: `Unhandled rejection: ${message}`,
117
- data: { reason: String(reason), stack: reason instanceof Error ? reason.stack : undefined, handled: false },
189
+ data: {
190
+ reason: String(reason),
191
+ stack:
192
+ reason instanceof Error ? reason.stack : undefined,
193
+ handled: false,
194
+ },
118
195
  });
119
196
  }
120
197
  } catch (appendErr) {
121
- console.error("[background-runner] Failed to write async.failed event:", appendErr);
198
+ console.error(
199
+ "[background-runner] Failed to write async.failed event:",
200
+ appendErr,
201
+ );
122
202
  }
123
- // BUG #17 FIX: Do NOT call process.exit() here. Previously, unhandled
124
- // rejection from child Pi workers would kill the entire background runner.
125
- // Instead, set exitCode and let the run complete normally.
126
- process.exitCode = 1;
203
+ // FIX Issues #2& #4: Signal child processes to terminate via abortController,
204
+ // set the exit flag so main() exits after the finally block runs cleanup.
205
+ // Previously this called process.exit(1) directly, bypassing the finally block
206
+ // and leaving child processes orphaned.
207
+ abortController.abort();
208
+ setExitFlag();
127
209
  });
128
210
  }
129
211
 
212
+ /**
213
+ * FIX Issue #4: Shared cleanup function called by both the finally block
214
+ * and error handlers. This ensures consistent cleanup regardless of how
215
+ * the process exits (normal flow, unhandled rejection, or main() exception).
216
+ */
217
+ function runCleanup(
218
+ stopInterruptGuard: () => void,
219
+ stopParentGuard: () => void,
220
+ stopHeartbeat: () => void,
221
+ keepAlive: NodeJS.Timeout,
222
+ exitDueToRejection: boolean,
223
+ eventsPath?: string,
224
+ ): void {
225
+ console.log(
226
+ `[background-runner] DEBUG: runCleanup, exitDueToRejection=${exitDueToRejection}`,
227
+ );
228
+ stopInterruptGuard();
229
+ stopParentGuard();
230
+ stopHeartbeat();
231
+ // FIX: clearInterval FIRST, then kill children. This ensures the heartbeat
232
+ // interval is always cleaned up even if terminateActiveChildPiProcesses throws.
233
+ clearInterval(keepAlive);
234
+ // FIX Issues #1, #2, #4: Wrap child process termination in try/catch so errors
235
+ // don't prevent the cleanup from completing. We log but don't re-throw since
236
+ // we're already exiting.
237
+ let killed = 0;
238
+ try {
239
+ killed = terminateActiveChildPiProcesses();
240
+ } catch (error) {
241
+ console.log(
242
+ `[background-runner] runCleanup: terminateActiveChildPiProcesses error: ${error instanceof Error ? error.message : String(error)}`,
243
+ );
244
+ }
245
+ console.log(`[background-runner] runCleanup: killed ${killed} child processes`);
246
+ // FIX Issue #5: Unregister this worker from the orphan registry on exit.
247
+ // Previously this was only cleaned up on the next session_start cleanup cycle,
248
+ // causing unnecessary delay in removing stale registrations.
249
+ try {
250
+ unregisterWorker(process.pid);
251
+ } catch (error) {
252
+ console.log(
253
+ `[background-runner] runCleanup: unregisterWorker error: ${error instanceof Error ? error.message : String(error)}`,
254
+ );
255
+ if (eventsPath) {
256
+ try {
257
+ appendEvent(eventsPath, {
258
+ type: "background.unregister_worker_failed",
259
+ runId: argValue("--run-id") ?? "unknown",
260
+ message: `unregisterWorker failed: ${error instanceof Error ? error.message : String(error)}`,
261
+ data: { pid: process.pid },
262
+ });
263
+ } catch {
264
+ /* best-effort */
265
+ }
266
+ }
267
+ }
268
+ // FIX Issues #2 & #4: If an unhandled rejection occurred, exit with code 1
269
+ // after cleanup completes. This ensures the finally block runs cleanup first,
270
+ // then we exit with the appropriate code.
271
+ if (exitDueToRejection) {
272
+ process.exit(1);
273
+ }
274
+ }
275
+
276
+ // Module-level flag: set by unhandled rejection guard and main() catch.
277
+ // Used by the module-level catch to signal that finally should call process.exit(1).
278
+ let exitDueToRejection = false;
279
+
130
280
  async function main(): Promise<void> {
131
281
  // FIX: Store logFd so it can be closed on exit to prevent file descriptor leak
132
282
  let logFd: number | undefined;
@@ -136,31 +286,51 @@ async function main(): Promise<void> {
136
286
  const _runId = argValue("--run-id");
137
287
  if (_cwd && _runId) {
138
288
  try {
139
- const logPath = path.join(_cwd, ".crew/state/runs", _runId, "background.log");
289
+ // Use projectCrewRoot() so the background log lives next to the
290
+ // manifest in either .crew/state/runs/ or .pi/teams/state/runs/
291
+ // depending on the project's chosen layout (issue #29).
292
+ const logPath = path.join(
293
+ projectCrewRoot(_cwd),
294
+ "state",
295
+ "runs",
296
+ _runId,
297
+ "background.log",
298
+ );
140
299
  logFd = fs.openSync(logPath, "a");
141
- const origWrite = (_prefix: string) => (data: unknown, ...args: unknown[]) => {
142
- const msg = [data, ...args].map(String).join(" ") + "\n";
143
- fs.writeSync(logFd!, msg);
144
- };
300
+ const origWrite =
301
+ (_prefix: string) =>
302
+ (data: unknown, ...args: unknown[]) => {
303
+ const msg = [data, ...args].map(String).join(" ") + "\n";
304
+ fs.writeSync(logFd!, msg);
305
+ };
145
306
  console.log = origWrite("OUT");
146
307
  console.error = origWrite("ERR");
147
308
  // FIX: Close logFd on process exit to prevent file descriptor leak
148
309
  process.on("exit", () => {
149
- try { if (logFd !== undefined) fs.closeSync(logFd); } catch { /* ignore */ }
310
+ try {
311
+ if (logFd !== undefined) fs.closeSync(logFd);
312
+ } catch {
313
+ /* ignore */
314
+ }
150
315
  });
151
- } catch { /* best-effort */ }
316
+ } catch {
317
+ /* best-effort */
318
+ }
152
319
  }
153
320
 
154
321
  // Scrub macOS malloc vars BEFORE anything else — must be clean for all child processes
155
322
  scrubProcessEnv();
156
323
  // Install signal handlers EARLY — log events before exiting so we can distinguish
157
324
  // OOM/SIGKILL (no event) from SIGTERM/SIGINT (event written).
158
- const signalLog = (sig: string): void => {
159
- const cwd = argValue("--cwd");
325
+ const signalLog = (sig: string, eventsPath: string): void => {
160
326
  const runId = argValue("--run-id");
161
- if (cwd && runId) {
162
- const loaded = loadRunManifestById(cwd, runId);
163
- if (loaded) appendEvent(loaded.manifest.eventsPath, { type: "async.failed", runId, message: `Background runner received ${sig} — exiting.`, data: { signal: sig, pid: process.pid } });
327
+ if (runId && eventsPath) {
328
+ appendEvent(eventsPath, {
329
+ type: "async.failed",
330
+ runId,
331
+ message: `Background runner received ${sig} — exiting.`,
332
+ data: { signal: sig, pid: process.pid },
333
+ });
164
334
  }
165
335
  };
166
336
  // BUG #17 FIX: Compute exitCodePath at module load time using args,
@@ -169,40 +339,108 @@ async function main(): Promise<void> {
169
339
  const cwd = argValue("--cwd");
170
340
  const runId = argValue("--run-id");
171
341
  if (!cwd || !runId) return undefined;
172
- return path.join(cwd, ".crew", "state", "runs", runId, "exit-code.txt");
342
+ // Use projectCrewRoot() to honour the .pi/teams/ fallback (issue #29).
343
+ return path.join(
344
+ projectCrewRoot(cwd),
345
+ "state",
346
+ "runs",
347
+ runId,
348
+ "exit-code.txt",
349
+ );
173
350
  })();
174
351
  if (exitCodePath) {
175
352
  process.on("exit", (code) => {
353
+ // Only log non-zero exit codes to avoid noise in exit-code.txt
354
+ if (code === 0 || code === undefined) return;
176
355
  try {
177
- fs.appendFileSync(exitCodePath, `${new Date().toISOString()} exit_code=${code} pid=${process.pid}\n`);
356
+ fs.appendFileSync(
357
+ exitCodePath,
358
+ `${new Date().toISOString()} exit_code=${code} pid=${process.pid}\n`,
359
+ );
178
360
  } catch {}
179
361
  });
180
362
  }
363
+
364
+ // FIX Issue #1: Load manifest and create abortController BEFORE signal handlers
365
+ // are installed, since the handlers reference manifest.eventsPath and abortController.
366
+ const cwd = argValue("--cwd");
367
+ const runId = argValue("--run-id");
368
+ if (!cwd || !runId)
369
+ throw new Error(
370
+ "Usage: background-runner.ts --cwd <cwd> --run-id <runId>",
371
+ );
372
+ // FIX Issue #3: Wrap in withRunLockSync to prevent concurrent background-runners
373
+ // for the same runId from reading stale manifest state. If lock cannot be
374
+ // acquired within 5s, fail immediately rather than proceeding with stale data.
375
+ let loaded: { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined;
376
+ try {
377
+ loaded = withRunLockSync(
378
+ { stateRoot: "", runId, cwd } as TeamRunManifest,
379
+ () => loadRunManifestById(cwd, runId),
380
+ { staleMs: 30_000 },
381
+ );
382
+ } catch (lockErr) {
383
+ throw new Error(`Failed to acquire lock for run '${runId}': ${lockErr instanceof Error ? lockErr.message : String(lockErr)}`);
384
+ }
385
+ if (!loaded) throw new Error(`Run '${runId}' not found.`);
386
+ let { manifest, tasks } = loaded;
387
+ const abortController = new AbortController();
388
+
181
389
  process.on("SIGTERM", () => {
182
- // BUG #17 FIX: Ignore SIGTERM.
390
+ // BUG #17 FIX: Handle SIGTERM for graceful shutdown. Real I/O (appendEvent) flushes io_uring state before abort to prevent corruption..
183
391
  // IMPORTANT: Perform real I/O here to flush io_uring state after EINTR.
184
392
  // Without I/O, io_uring can enter corrupted state and cause silent crash.
185
- const cwd = argValue("--cwd");
393
+ // FIX Issue #3: Trigger graceful shutdown via abortController signal,
394
+ // allowing the finally block to run and clean up child processes.
395
+ // The io_uring I/O is still performed before abort takes effect.
186
396
  const runId = argValue("--run-id");
187
- if (cwd && runId) {
397
+ if (runId && manifest.eventsPath) {
188
398
  try {
189
- const loaded = loadRunManifestById(cwd, runId);
190
- if (loaded) appendEvent(loaded.manifest.eventsPath, { type: "async.sigterm_ignored", runId, message: `SIGTERM ignored pid=${process.pid}`, data: { pid: process.pid, ppid: process.ppid } });
191
- } catch { /* best-effort */ }
399
+ appendEvent(manifest.eventsPath, {
400
+ type: "async.sigterm_received_graceful_shutdown",
401
+ runId,
402
+ message: `SIGTERM received, graceful shutdown via abort pid=${process.pid}`,
403
+ data: { pid: process.pid, ppid: process.ppid },
404
+ });
405
+ } catch {
406
+ /* best-effort */
407
+ }
192
408
  }
409
+ // Trigger graceful shutdown via abort signal so finally block runs
410
+ abortController.abort();
411
+ });
412
+ process.on("SIGINT", () => {
413
+ signalLog("SIGINT", manifest.eventsPath);
414
+ process.exit(130);
193
415
  });
194
- process.on("SIGINT", () => { signalLog("SIGINT"); process.exit(130); });
195
416
  // BUG #17: Catch ALL signals to identify what kills the background runner
196
- for (const sig of ["SIGHUP", "SIGUSR1", "SIGUSR2", "SIGPIPE", "SIGALRM", "SIGPROF", "SIGIO", "SIGPWR", "SIGSYS", "SIGURG", "SIGWINCH", "SIGCONT", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGVTALRM", "SIGXCPU", "SIGXFSZ"] as const) {
417
+ for (const sig of [
418
+ "SIGHUP",
419
+ "SIGUSR1",
420
+ "SIGUSR2",
421
+ "SIGPIPE",
422
+ "SIGALRM",
423
+ "SIGPROF",
424
+ "SIGIO",
425
+ "SIGPWR",
426
+ "SIGSYS",
427
+ "SIGURG",
428
+ "SIGWINCH",
429
+ "SIGCONT",
430
+ "SIGTSTP",
431
+ "SIGTTIN",
432
+ "SIGTTOU",
433
+ "SIGVTALRM",
434
+ "SIGXCPU",
435
+ "SIGXFSZ",
436
+ ] as const) {
197
437
  try {
198
438
  process.on(sig, () => {
199
- signalLog(sig);
200
- try {
201
- const loaded = loadRunManifestById(cwd!, runId!);
202
- if (loaded) appendEvent(loaded.manifest.eventsPath, { type: "async.signal", runId: runId!, message: `Background runner received ${sig}`, data: { signal: sig, pid: process.pid } });
203
- } catch { /* best-effort */ }
439
+ signalLog(sig, manifest.eventsPath);
204
440
  });
205
- } catch { /* some signals not supported on this platform */ }
441
+ } catch {
442
+ /* some signals not supported on this platform */
443
+ }
206
444
  }
207
445
  // Hook Node.js abort — if process.exit is called with code 1 (uncaught exception, assert failure)
208
446
  // we log it before exiting so it appears in background.log
@@ -210,89 +448,146 @@ async function main(): Promise<void> {
210
448
  // Intercept all exit(code) calls to log them as async.exit events before exiting.
211
449
  // This surfaces uncaught exceptions / early exits that would otherwise vanish silently.
212
450
  process.exit = ((code?: number | string): never => {
213
- const cwd2 = argValue("--cwd");
214
451
  const runId2 = argValue("--run-id");
215
452
  const codeStr = code === undefined ? "<none>" : String(code);
216
- if (cwd2 && runId2) {
453
+ if (runId2 && manifest.eventsPath) {
217
454
  try {
218
- const loaded = loadRunManifestById(cwd2, runId2);
219
- if (loaded) {
220
- appendEvent(loaded.manifest.eventsPath, { type: "async.exit", runId: runId2, message: `Background runner exit(${codeStr}) pid=${process.pid}`, data: { code, pid: process.pid } });
221
- }
222
- } catch { /* best-effort */ }
455
+ appendEvent(manifest.eventsPath, {
456
+ type: "async.exit",
457
+ runId: runId2,
458
+ message: `Background runner exit(${codeStr}) pid=${process.pid}`,
459
+ data: { code, pid: process.pid },
460
+ });
461
+ } catch {
462
+ /* best-effort */
463
+ }
223
464
  }
224
465
  return origExit(code);
225
466
  }) as typeof process.exit;
226
467
 
227
- // Start parent guard FIRST — if parent is already dead, exit immediately
468
+ // Setup unhandled rejection guard FIRST — must be before any async operations
469
+ // that might produce unhandled rejections during cleanup. Without this, any unhandled
470
+ // rejection would crash the worker BEFORE async.failed events are written.
471
+ const rejectionGuardState = {
472
+ cwd,
473
+ runId,
474
+ eventsPath: manifest.eventsPath,
475
+ };
476
+ // FIX Issues #2& #4: Flag to signal that an unhandled rejection occurred.
477
+ // When set, runCleanup() will ensure process.exit(1) is called after cleanup.
478
+ exitDueToRejection = false;
479
+ const setExitFlag = (): void => {
480
+ exitDueToRejection = true;
481
+ };
482
+ setupUnhandledRejectionGuard(rejectionGuardState, abortController, setExitFlag);
483
+
484
+ // Start parent guard — if parent is already dead, exit immediately
228
485
  const parentPid = Number(process.env.PI_CREW_PARENT_PID);
229
486
  if (parentPid > 0) startParentGuard(parentPid);
487
+ // NOTE: intentionally no unref() — the guard keeps the event loop alive
488
+ // to prevent premature worker exit. See parent-guard.ts:86 for rationale.
230
489
 
231
- const cwd = argValue("--cwd");
232
- const runId = argValue("--run-id");
233
- if (!cwd || !runId) throw new Error("Usage: background-runner.ts --cwd <cwd> --run-id <runId>");
234
-
235
- // Log PGID and SID for debugging process group isolation
236
- try {
237
- const stat = fs.readFileSync("/proc/self/stat", "utf8").split(" ");
238
- console.log(`[background-runner] DEBUG: pid=${process.pid} ppid=${process.ppid} pgid=${stat[4]} sid=${stat[5]} cwd=${cwd} runId=${runId}`);
239
- } catch {
240
- console.log(`[background-runner] DEBUG: pid=${process.pid} ppid=${process.ppid} cwd=${cwd} runId=${runId}`);
241
- }
242
-
243
- const loaded = loadRunManifestById(cwd, runId);
244
- if (!loaded) throw new Error(`Run '${runId}' not found.`);
245
- let { manifest, tasks } = loaded;
246
-
247
- console.log(`[background-runner] DEBUG: manifest loaded, eventsPath=${manifest.eventsPath}`);
248
-
249
- // Setup unhandled rejection guard EARLY — must be before any async operations
250
- // that might produce unhandled rejections during cleanup.
251
- const rejectionGuardState = { cwd, runId, eventsPath: loaded.manifest.eventsPath };
252
- setupUnhandledRejectionGuard(rejectionGuardState);
253
-
254
- appendEvent(manifest.eventsPath, { type: "async.started", runId: manifest.runId, data: { pid: process.pid } });
255
- console.log(`[background-runner] DEBUG: async.started written, pid=${process.pid}`);
256
- writeAsyncStartMarker(manifest, { pid: process.pid, startedAt: new Date().toISOString() });
257
- // FIX: Create AbortController EARLY so interrupt guard can use it.
258
- // abortController.signal flows through: executeTeamRun → runTeamTask → runChildPi.
259
- // When interrupt guard detects cancel, abortController.abort() fires the abort
260
- // handler in runChildPi which kills child processes immediately.
261
- const abortController = new AbortController();
262
- const stopHeartbeat = startHeartbeat(manifest.stateRoot, manifest.eventsPath, manifest.runId);
263
- const stopInterruptGuard = startInterruptGuard(manifest, abortController);
490
+ appendEvent(manifest.eventsPath, {
491
+ type: "async.started",
492
+ runId: manifest.runId,
493
+ data: { pid: process.pid },
494
+ });
495
+ console.log(
496
+ `[background-runner] DEBUG: async.started written, pid=${process.pid}`,
497
+ );
498
+ writeAsyncStartMarker(manifest, {
499
+ pid: process.pid,
500
+ startedAt: new Date().toISOString(),
501
+ });
502
+ const stopHeartbeat = startHeartbeat(
503
+ manifest.stateRoot,
504
+ manifest.eventsPath,
505
+ manifest.runId,
506
+ );
507
+ const stopInterruptGuard = startInterruptGuard(manifest, abortController, stopParentGuard);
264
508
  console.log(`[background-runner] DEBUG: heartbeat+interrupt guard started`);
265
- // BUG #17: Keep-alive interval prevents event loop from exiting during
266
- // jiti compilation. Pure empty interval (no I/O to avoid io_uring issues).
509
+ // NOTE: Keep-alive interval is NOT unref'd (unlike heartbeat and interrupt
510
+ // guard intervals which ARE unref'd). This is intentional during jiti
511
+ // compilation of team-runner.ts, the event loop must not drain prematurely.
512
+ // The interval is always cleared in the finally block, so the delay is
513
+ // bounded by the 5s interval. The event loop exit is deferred at most 5s.
267
514
  const keepAlive = setInterval(() => {}, 5000);
268
515
 
269
-
270
516
  try {
271
517
  console.log(`[background-runner] DEBUG: about to call discoverAgents`);
272
518
  const agents = allAgents(discoverAgents(cwd));
273
- console.log(`[background-runner] DEBUG: discoverAgents done, ${agents.length} agents`);
274
- fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); // FORCE flush so we see this before death
275
- console.log(`[background-runner] DEBUG: calling directTeamAndWorkflowFromRun`);
519
+ console.log(
520
+ `[background-runner] DEBUG: discoverAgents done, ${agents.length} agents`,
521
+ );
522
+ try { fs.fsyncSync(fs.openSync(manifest.eventsPath, "a")); } catch { /* best-effort */ } // FORCE flush so we see this before death
523
+ console.log(
524
+ `[background-runner] DEBUG: calling directTeamAndWorkflowFromRun`,
525
+ );
276
526
  const direct = directTeamAndWorkflowFromRun(manifest, tasks, agents);
277
527
  console.log(`[background-runner] DEBUG: direct done, finding team`);
278
- const team = direct?.team ?? allTeams(discoverTeams(cwd)).find((candidate) => candidate.name === manifest.team);
528
+ const team =
529
+ direct?.team ??
530
+ allTeams(discoverTeams(cwd)).find(
531
+ (candidate) => candidate.name === manifest.team,
532
+ );
279
533
  if (!team) throw new Error(`Team '${manifest.team}' not found.`);
280
- console.log(`[background-runner] DEBUG: team=${team.name}, finding workflow`);
281
- const baseWorkflow = direct?.workflow ?? allWorkflows(discoverWorkflows(cwd)).find((candidate) => candidate.name === manifest.workflow);
282
- if (!baseWorkflow) throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
534
+ console.log(
535
+ `[background-runner] DEBUG: team=${team.name}, finding workflow`,
536
+ );
537
+ const baseWorkflow =
538
+ direct?.workflow ??
539
+ allWorkflows(discoverWorkflows(cwd)).find(
540
+ (candidate) => candidate.name === manifest.workflow,
541
+ );
542
+ if (!baseWorkflow)
543
+ throw new Error(`Workflow '${manifest.workflow ?? ""}' not found.`);
283
544
  console.log(`[background-runner] DEBUG: workflow=${baseWorkflow.name}`);
284
545
  const workflow = expandParallelResearchWorkflow(baseWorkflow, cwd);
285
546
  console.log(`[background-runner] DEBUG: loading config`);
286
547
  const loadedConfig = loadConfig(cwd);
287
- const runConfig = manifest.runConfig && typeof manifest.runConfig === "object" && !Array.isArray(manifest.runConfig) ? manifest.runConfig as typeof loadedConfig.config : loadedConfig.config;
288
- const runtime = manifest.runtimeResolution ? { kind: manifest.runtimeResolution.kind, requestedMode: manifest.runtimeResolution.requestedMode, available: manifest.runtimeResolution.available, fallback: manifest.runtimeResolution.fallback, steer: manifest.runtimeResolution.kind === "live-session", resume: manifest.runtimeResolution.kind === "live-session", liveToolActivity: manifest.runtimeResolution.kind === "live-session", transcript: manifest.runtimeResolution.kind !== "scaffold", reason: manifest.runtimeResolution.reason, safety: manifest.runtimeResolution.safety } : await resolveCrewRuntime(runConfig);
289
- const runtimeResolution = manifest.runtimeResolution ?? runtimeResolutionState(runtime);
290
- manifest = { ...manifest, runtimeResolution, runConfig, updatedAt: new Date().toISOString() };
548
+ const runConfig =
549
+ manifest.runConfig &&
550
+ typeof manifest.runConfig === "object" &&
551
+ !Array.isArray(manifest.runConfig)
552
+ ? (manifest.runConfig as typeof loadedConfig.config)
553
+ : loadedConfig.config;
554
+ const runtime = manifest.runtimeResolution
555
+ ? {
556
+ kind: manifest.runtimeResolution.kind,
557
+ requestedMode: manifest.runtimeResolution.requestedMode,
558
+ available: manifest.runtimeResolution.available,
559
+ fallback: manifest.runtimeResolution.fallback,
560
+ steer: manifest.runtimeResolution.kind === "live-session",
561
+ resume: manifest.runtimeResolution.kind === "live-session",
562
+ liveToolActivity:
563
+ manifest.runtimeResolution.kind === "live-session",
564
+ transcript: manifest.runtimeResolution.kind !== "scaffold",
565
+ reason: manifest.runtimeResolution.reason,
566
+ safety: manifest.runtimeResolution.safety,
567
+ }
568
+ : await resolveCrewRuntime(runConfig);
569
+ const runtimeResolution =
570
+ manifest.runtimeResolution ?? runtimeResolutionState(runtime);
571
+ manifest = {
572
+ ...manifest,
573
+ runtimeResolution,
574
+ runConfig,
575
+ updatedAt: new Date().toISOString(),
576
+ };
291
577
  saveRunManifest(manifest);
292
- appendEvent(manifest.eventsPath, { type: "runtime.resolved", runId: manifest.runId, message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`, data: { runtimeResolution, async: true } });
293
- if (runtime.safety === "blocked") throw new Error(runtime.reason ?? "Child worker execution is disabled; refusing to create no-op scaffold subagents.");
578
+ appendEvent(manifest.eventsPath, {
579
+ type: "runtime.resolved",
580
+ runId: manifest.runId,
581
+ message: `Runtime resolved: ${runtime.kind} safety=${runtime.safety}`,
582
+ data: { runtimeResolution, async: true },
583
+ });
584
+ if (runtime.safety === "blocked")
585
+ throw new Error(
586
+ runtime.reason ??
587
+ "Child worker execution is disabled; refusing to create no-op scaffold subagents.",
588
+ );
294
589
  const executeWorkers = runtime.kind !== "scaffold";
295
- // Use ownerSessionId for workspaceId to ensure agents are only visible to the session that spawned them.
590
+ // Use ownerSessionId for workspaceId to ensure agents are only visible to the session that spawned them.
296
591
  // manifest.cwd would cause cross-session visibility since all sessions share the same project directory.
297
592
  // Mark this as background mode so task-runner writes events to background.log for debugging.
298
593
  process.env.PI_CREW_BACKGROUND_MODE = "1";
@@ -305,48 +600,138 @@ async function main(): Promise<void> {
305
600
  console.log(`[background-runner] DEBUG: calling executeTeamRun`);
306
601
  let result;
307
602
  try {
308
- result = await executeTeamRun({ manifest, tasks, team, workflow, agents, executeWorkers, limits: runConfig.limits, runtime, runtimeConfig: runConfig.runtime, skillOverride: manifest.skillOverride, reliability: runConfig.reliability, workspaceId: manifest.ownerSessionId ?? manifest.cwd, signal: abortController.signal });
309
- console.log(`[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`);
603
+ result = await executeTeamRun({
604
+ manifest,
605
+ tasks,
606
+ team,
607
+ workflow,
608
+ agents,
609
+ executeWorkers,
610
+ limits: runConfig.limits,
611
+ runtime,
612
+ runtimeConfig: runConfig.runtime,
613
+ skillOverride: manifest.skillOverride,
614
+ reliability: runConfig.reliability,
615
+ workspaceId: manifest.ownerSessionId ?? manifest.cwd,
616
+ signal: abortController.signal,
617
+ });
618
+ console.log(
619
+ `[background-runner] DEBUG: executeTeamRun returned, status=${result.manifest.status}`,
620
+ );
310
621
  } catch (execError) {
311
- console.log(`[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`);
312
- console.log(`[background-runner] DEBUG: stack: ${execError instanceof Error ? execError.stack : "N/A"}`);
622
+ console.log(
623
+ `[background-runner] DEBUG: executeTeamRun THREW: ${execError instanceof Error ? execError.message : String(execError)}`,
624
+ );
625
+ console.log(
626
+ `[background-runner] DEBUG: stack: ${execError instanceof Error ? execError.stack : "N/A"}`,
627
+ );
313
628
  throw execError;
314
629
  }
315
630
  manifest = result.manifest;
316
631
  tasks = result.tasks;
317
- appendEvent(manifest.eventsPath, { type: "async.completed", runId: manifest.runId, data: { status: manifest.status, tasks: tasks.length } });
318
- console.log(`[background-runner] DEBUG: async.completed written, status=${manifest.status}`);
319
- if (manifest.status === "failed" || manifest.status === "cancelled" || manifest.status === "blocked") process.exitCode = 1;
632
+ appendEvent(manifest.eventsPath, {
633
+ type: "async.completed",
634
+ runId: manifest.runId,
635
+ data: { status: manifest.status, tasks: tasks.length },
636
+ });
637
+ console.log(
638
+ `[background-runner] DEBUG: async.completed written, status=${manifest.status}`,
639
+ );
640
+ if (
641
+ manifest.status === "failed" ||
642
+ manifest.status === "cancelled" ||
643
+ manifest.status === "blocked"
644
+ )
645
+ process.exitCode = 1;
320
646
  } catch (error) {
321
647
  // Terminate live agents on failure too — agents are done when the run fails
322
648
  try {
323
- const loaded = loadRunManifestById(cwd, runId);
324
- if (loaded) {
649
+ const loaded = withRunLockSync(
650
+ manifest,
651
+ () => loadRunManifestById(cwd, runId),
652
+ { staleMs: 30_000 },
653
+ ); // Use withRunLockSync to prevent race with concurrent writers (e.g., stale reconciler)
654
+ // between the read and the subsequent save.
655
+ const manifestToUse = loaded?.manifest ?? manifest;
656
+ if (manifestToUse) {
325
657
  // LAZY: live-agent-manager only needed on failure cleanup path; avoid module load at hot path.
326
- const { terminateLiveAgentsForRun } = await import("./live-agent-manager.ts");
327
- void terminateLiveAgentsForRun(loaded.manifest.runId, "failed", appendEvent, loaded.manifest.eventsPath).catch((error) => logInternalError("background-runner.terminate", error, `runId=${loaded.manifest.runId}`));
658
+ const { terminateLiveAgentsForRun } = await import(
659
+ "./live-agent-manager.ts"
660
+ );
661
+ void terminateLiveAgentsForRun(
662
+ manifestToUse.runId,
663
+ "failed",
664
+ appendEvent,
665
+ manifestToUse.eventsPath,
666
+ ).catch((error) =>
667
+ logInternalError(
668
+ "background-runner.terminate",
669
+ error,
670
+ `runId=${manifestToUse.runId}`,
671
+ ),
672
+ );
328
673
  }
329
- } catch { /* best-effort */ }
674
+ } catch {
675
+ /* best-effort */
676
+ }
330
677
  const message = error instanceof Error ? error.message : String(error);
331
678
  manifest = updateRunStatus(manifest, "failed", message);
332
- appendEvent(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message });
679
+ appendEvent(manifest.eventsPath, {
680
+ type: "async.failed",
681
+ runId: manifest.runId,
682
+ message,
683
+ });
333
684
  process.exitCode = 1;
334
- console.log(`[background-runner] DEBUG: catch block, error=${error instanceof Error ? error.message : String(error)}`);
685
+ console.log(
686
+ `[background-runner] DEBUG: catch block, error=${error instanceof Error ? error.message : String(error)}`,
687
+ );
335
688
  } finally {
336
- console.log(`[background-runner] DEBUG: finally block, exitCode=${process.exitCode}`);
337
- stopInterruptGuard();
338
- stopParentGuard();
339
- stopHeartbeat();
340
- clearInterval(keepAlive);
341
- // FIX: Always kill child processes on exit. executeTeamRun's terminateLiveAgentsForRun
342
- // only handles live-session agents, not child-pi processes. Without this, child-pi
343
- // processes can become orphaned if executeTeamRun throws before completing.
344
- const killed = terminateActiveChildPiProcesses();
345
- console.log(`[background-runner] finally: killed ${killed} child processes`);
689
+ // FIX Issue #4: Use shared runCleanup() function for consistent cleanup
690
+ // across all exit paths (normal, unhandled rejection, main() exception).
691
+ // FIX Issue #1: Wrap runCleanup in try/catch to ensure process.exit(1)
692
+ // is called even if runCleanup throws unexpectedly.
693
+ try {
694
+ runCleanup(
695
+ stopInterruptGuard,
696
+ stopParentGuard,
697
+ stopHeartbeat,
698
+ keepAlive,
699
+ exitDueToRejection,
700
+ manifest.eventsPath,
701
+ );
702
+ } catch (cleanupError) {
703
+ console.error(
704
+ `[background-runner] runCleanup threw: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
705
+ );
706
+ }
707
+ // NOTE: If exitDueToRejection was set, runCleanup() already called process.exit(1)
708
+ // so this finally block never continues past that point.
346
709
  }
347
710
  }
348
711
 
349
- await main().catch((err) => {
350
- console.error(`[background-runner] DEBUG: main() uncaught: ${err?.message ?? err}`);
351
- process.exit(1);
352
- });
712
+ // FIX Issue #1: Restructure so the finally block (which calls runCleanup) ALWAYS
713
+ // runs and decides when to exit. The old pattern: await main().catch((err) =>
714
+ // { process.exit(1); }) bypassed the finally block because .catch() intercepted
715
+ // the rejection and called process.exit(1) directly. If exitDueToRejection was
716
+ // already true, the finally called process.exit(1) first and .catch() was never
717
+ // reached. If exitDueToRejection was false (main() threw but unhandled rejection
718
+ // guard didn't fire), .catch() ran instead of the finally block doing the exit.
719
+ // New pattern: move await main() inside main() itself, wrapped in try/catch that
720
+ // sets exitDueToRejection so the finally block exits with code 1 after cleanup.
721
+ try {
722
+ await main();
723
+ } catch (err) {
724
+ console.error(
725
+ `[background-runner] DEBUG: main() uncaught: ${err instanceof Error ? err.message : String(err)}`,
726
+ );
727
+ // FIX Issue #1: Set the flag so the finally block's runCleanup() call
728
+ // will trigger process.exit(1) after cleanup completes. Previously this
729
+ // called process.exit(1) directly, bypassing the finally block and leaving
730
+ // orphaned child processes.
731
+ exitDueToRejection = true;
732
+ // FIX: Call stopParentGuard directly here as a safety net in case the
733
+ // finally block (which calls runCleanup→stopParentGuard) does not complete.
734
+ // This ensures the parent guard is stopped in ALL exit paths: normal
735
+ // completion, unhandled rejection, and fatal errors.
736
+ stopParentGuard();
737
+ }