taskplane 0.30.5 → 0.30.6

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.
@@ -0,0 +1,401 @@
1
+ /**
2
+ * engine-identity.ts — Persisted identity of the batch ENGINE process (#631).
3
+ *
4
+ * The orchestration engine runs as a forked child process of the supervisor's
5
+ * Pi session. If that session dies or is replaced while the batch is
6
+ * `executing`, the replacement supervisor inherits persisted state that says
7
+ * "executing" — but it has no engine attached, and it cannot tell from the
8
+ * supervisor lock alone whether the ORIGINAL engine is still alive (a dead
9
+ * supervisor pid does not imply a dead engine; a forked child survives its
10
+ * parent on Windows, and a wedged supervisor may leave a healthy engine
11
+ * driving lanes).
12
+ *
13
+ * This module gives a replacement session a verifiable answer:
14
+ *
15
+ * - `alive` the recorded engine pid still exists → refuse recovery
16
+ * mutations (double-drive risk); tell the operator the pid.
17
+ * - `dead` the recorded pid no longer exists → confirmed orphan; the
18
+ * persisted-state eligibility rules (`checkResumeEligibility`)
19
+ * may proceed.
20
+ * - `exited` the engine recorded its own exit (clean or crash) → same as dead.
21
+ * - `none` no identity recorded (older batch, or the engine never forked)
22
+ * → callers fall back to supervisor-lock evidence.
23
+ *
24
+ * Verified shutdown, not inferred inactivity: timestamps in batch-state.json
25
+ * are checkpoint times, not heartbeats, and are refreshed by recovery tools
26
+ * themselves — they are NOT used here.
27
+ *
28
+ * File: `{stateRoot}/.pi/runtime/{batchId}/engine.json` (additive; runtime
29
+ * dir already hosts registry.json). Best-effort I/O: a write failure never
30
+ * blocks batch start.
31
+ */
32
+
33
+ import { createHash } from "crypto";
34
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "fs";
35
+ import { dirname, join } from "path";
36
+ import { fileURLToPath } from "url";
37
+
38
+ import { isProcessAlive } from "./process-registry.ts";
39
+ import { runtimeRoot } from "./types.ts";
40
+
41
+ export interface EngineIdentity {
42
+ batchId: string;
43
+ /** Engine (forked child) pid */
44
+ pid: number;
45
+ /** Pid of the supervisor session that forked it */
46
+ supervisorPid: number;
47
+ /** Epoch ms when the fork happened */
48
+ startedAt: number;
49
+ /** Epoch ms when the engine exited (set by the parent on child exit) */
50
+ exitedAt?: number;
51
+ /** Exit code when known */
52
+ exitCode?: number | null;
53
+ /** Why the exit was recorded (e.g. "child-exit", "session-end-kill", "abort") */
54
+ exitReason?: string;
55
+ /**
56
+ * Build marker (Penster feedback on #632): which Taskplane code is actually
57
+ * driving this batch. `version` is package.json's; `build` is a short
58
+ * content fingerprint of the loaded extension source (sha256 prefix), so a
59
+ * local pre-release deploy is distinguishable from the published version
60
+ * even when the version string has not been bumped.
61
+ */
62
+ taskplaneVersion?: string;
63
+ taskplaneBuild?: string;
64
+ }
65
+
66
+ /**
67
+ * Compute the build marker for the currently loaded Taskplane: package.json
68
+ * version + a short sha256 of the extension entry sources. Cached. Never throws.
69
+ */
70
+ let cachedBuildMarker: { taskplaneVersion: string; taskplaneBuild: string } | null = null;
71
+ export function taskplaneBuildMarker(): { taskplaneVersion: string; taskplaneBuild: string } {
72
+ if (cachedBuildMarker) return cachedBuildMarker;
73
+ let version = "unknown";
74
+ let build = "unknown";
75
+ try {
76
+ const here = dirname(fileURLToPath(import.meta.url));
77
+ const pkg = JSON.parse(readFileSync(join(here, "..", "..", "package.json"), "utf-8")) as {
78
+ version?: string;
79
+ };
80
+ if (typeof pkg.version === "string") version = pkg.version;
81
+ const h = createHash("sha256");
82
+ for (const file of [
83
+ "extension.ts",
84
+ "lane-runner.ts",
85
+ "engine.ts",
86
+ "resume.ts",
87
+ "engine-identity.ts",
88
+ ]) {
89
+ try {
90
+ h.update(readFileSync(join(here, file)));
91
+ } catch {
92
+ /* skip */
93
+ }
94
+ }
95
+ build = h.digest("hex").slice(0, 12);
96
+ } catch {
97
+ /* best effort */
98
+ }
99
+ cachedBuildMarker = { taskplaneVersion: version, taskplaneBuild: build };
100
+ return cachedBuildMarker;
101
+ }
102
+
103
+ /**
104
+ * #631: ownership evidence ASSOCIATED WITH AN ORCH BRANCH, independent of full
105
+ * state reconstruction. Integration acts on a branch; the batch behind it may
106
+ * have no batch-state.json (aborted) and may not be *reconstructable* (worker
107
+ * manifests gone) while its engine identity still records a live pid.
108
+ * Reconstructability is a resumability requirement, not a prerequisite for
109
+ * recognising ownership. Scans `.pi/runtime/<batchId>/batch-meta.json` for
110
+ * `orchBranch === branch` and returns each such batch with its liveness.
111
+ * Unreadable/absent meta is skipped (no association can be established).
112
+ */
113
+ export function findBatchesForOrchBranch(
114
+ stateRoot: string,
115
+ orchBranch: string,
116
+ probe: (pid: number) => boolean = isProcessAlive,
117
+ ): Array<{ batchId: string; liveness: EngineLiveness }> {
118
+ const out: Array<{ batchId: string; liveness: EngineLiveness }> = [];
119
+ const runtimeDir = join(stateRoot, ".pi", "runtime");
120
+ if (!existsSync(runtimeDir)) return out;
121
+ let entries: string[] = [];
122
+ try {
123
+ entries = readdirSync(runtimeDir);
124
+ } catch {
125
+ return out;
126
+ }
127
+ for (const batchId of entries) {
128
+ try {
129
+ const metaPath = join(runtimeDir, batchId, "batch-meta.json");
130
+ if (!existsSync(metaPath)) continue;
131
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8")) as {
132
+ orchBranch?: unknown;
133
+ batchId?: unknown;
134
+ };
135
+ if (meta.orchBranch !== orchBranch) continue;
136
+ const id = typeof meta.batchId === "string" && meta.batchId ? meta.batchId : batchId;
137
+ out.push({ batchId: id, liveness: assessEngineLiveness(stateRoot, id, probe) });
138
+ } catch {
139
+ /* skip unreadable runtime dir */
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ export type EngineLivenessStatus = "alive" | "dead" | "exited" | "none";
146
+
147
+ export interface EngineLiveness {
148
+ status: EngineLivenessStatus;
149
+ identity: EngineIdentity | null;
150
+ }
151
+
152
+ export function engineIdentityPath(stateRoot: string, batchId: string): string {
153
+ return join(runtimeRoot(stateRoot, batchId), "engine.json");
154
+ }
155
+
156
+ /**
157
+ * Publish a freshly started engine's identity. Returns false when it could not
158
+ * be written — callers treat that as "cannot own this batch verifiably" and
159
+ * refuse to start the engine (the identity is what lets a successor prove
160
+ * shutdown; an engine without one is an unownable orphan-in-waiting).
161
+ * The write goes through a temp file + rename so a reader never sees a
162
+ * partial record.
163
+ */
164
+ export function writeEngineIdentity(
165
+ stateRoot: string,
166
+ identity: Omit<EngineIdentity, "exitedAt" | "exitCode" | "exitReason">,
167
+ ): boolean {
168
+ try {
169
+ const path = engineIdentityPath(stateRoot, identity.batchId);
170
+ mkdirSync(join(path, ".."), { recursive: true });
171
+ const tmp = `${path}.${process.pid}.tmp`;
172
+ writeFileSync(tmp, JSON.stringify({ ...identity, ...taskplaneBuildMarker() }, null, 2), "utf-8");
173
+ renameSync(tmp, path);
174
+ // Read-back: the record on disk must be the one we just published.
175
+ const check = readEngineIdentity(stateRoot, identity.batchId);
176
+ return check !== null && check.pid === identity.pid && check.exitedAt === undefined;
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Mark the recorded engine as exited (keeps the file for forensics). Best effort.
184
+ *
185
+ * ATTEMPT-SCOPED: only marks the file when its recorded `pid` matches the
186
+ * exiting engine's pid. A delayed exit callback from an OLD parent must never
187
+ * mark a NEW live engine (which has since overwritten the identity) as exited.
188
+ * Returns true when the mark was applied.
189
+ */
190
+ export function markEngineExited(
191
+ stateRoot: string,
192
+ batchId: string,
193
+ info: { pid: number; exitCode?: number | null; exitReason: string; exitedAt?: number },
194
+ ): boolean {
195
+ try {
196
+ const path = engineIdentityPath(stateRoot, batchId);
197
+ if (!existsSync(path)) return false;
198
+ const current = JSON.parse(readFileSync(path, "utf-8")) as EngineIdentity;
199
+ if (current.pid !== info.pid) return false; // a newer engine owns this file
200
+ current.exitedAt = info.exitedAt ?? Date.now();
201
+ current.exitCode = info.exitCode ?? null;
202
+ current.exitReason = info.exitReason;
203
+ writeFileSync(path, JSON.stringify(current, null, 2), "utf-8");
204
+ return true;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ /** Marker pid for an identity synthesized by operator confirmation (no real engine pid). */
211
+ export const OPERATOR_CONFIRMED_PID = 0;
212
+
213
+ /**
214
+ * The explicit, auditable LEGACY path (#631): for a batch with no engine
215
+ * identity (pre-#631 engine, or an engine that never got far enough to publish
216
+ * one), the runtime cannot verify shutdown and MUST fail closed. The operator
217
+ * verifies out-of-band that no engine process exists for this repo and records
218
+ * that confirmation here; it becomes an `exited` identity so every recovery
219
+ * gate proceeds through the normal verified path. Refuses (returns false) if a
220
+ * REAL identity exists — confirmation cannot override an alive engine.
221
+ */
222
+ export function recordOperatorConfirmedShutdown(
223
+ stateRoot: string,
224
+ batchId: string,
225
+ confirmedBy: { supervisorPid: number; note?: string },
226
+ ): { ok: boolean; reason: string } {
227
+ const existing = readEngineIdentity(stateRoot, batchId);
228
+ if (existing && existing.pid !== OPERATOR_CONFIRMED_PID) {
229
+ return {
230
+ ok: false,
231
+ reason: `an engine identity IS recorded (PID ${existing.pid}${existing.exitedAt ? ", exited" : ""}); confirmation is only for batches with no identity — use the pid-verified path`,
232
+ };
233
+ }
234
+ try {
235
+ const path = engineIdentityPath(stateRoot, batchId);
236
+ mkdirSync(join(path, ".."), { recursive: true });
237
+ const now = Date.now();
238
+ const identity: EngineIdentity = {
239
+ batchId,
240
+ pid: OPERATOR_CONFIRMED_PID,
241
+ supervisorPid: confirmedBy.supervisorPid,
242
+ startedAt: now,
243
+ exitedAt: now,
244
+ exitCode: null,
245
+ exitReason: `operator-confirmed-shutdown${confirmedBy.note ? `: ${confirmedBy.note.slice(0, 200)}` : ""}`,
246
+ };
247
+ writeFileSync(path, JSON.stringify(identity, null, 2), "utf-8");
248
+ return { ok: true, reason: `recorded operator-confirmed shutdown for ${batchId}` };
249
+ } catch (err) {
250
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) };
251
+ }
252
+ }
253
+
254
+ export function readEngineIdentity(stateRoot: string, batchId: string): EngineIdentity | null {
255
+ try {
256
+ const path = engineIdentityPath(stateRoot, batchId);
257
+ if (!existsSync(path)) return null;
258
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<EngineIdentity>;
259
+ if (typeof parsed.pid !== "number" || typeof parsed.batchId !== "string") return null;
260
+ return parsed as EngineIdentity;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Is the recorded engine for this batch still running?
268
+ *
269
+ * `probe` is injectable for tests; defaults to a real pid probe.
270
+ */
271
+ export function assessEngineLiveness(
272
+ stateRoot: string,
273
+ batchId: string,
274
+ probe: (pid: number) => boolean = isProcessAlive,
275
+ ): EngineLiveness {
276
+ const identity = readEngineIdentity(stateRoot, batchId);
277
+ if (!identity) return { status: "none", identity: null };
278
+ if (identity.exitedAt !== undefined) return { status: "exited", identity };
279
+ return { status: probe(identity.pid) ? "alive" : "dead", identity };
280
+ }
281
+
282
+ /**
283
+ * Decide whether a recovery mutation (resume / retry / skip / force-merge /
284
+ * administrative pause) may proceed against a batch whose persisted phase is
285
+ * ACTIVE but which has no engine attached to THIS process.
286
+ *
287
+ * Pure decision function (no I/O) so the policy is unit-testable:
288
+ *
289
+ * - engine `alive` → refuse (double-drive)
290
+ * - engine `dead` | `exited` → proceed (verified shutdown)
291
+ * - engine `none` → REFUSE, always. Unknown ownership is
292
+ * not confirmed shutdown (a forked
293
+ * engine outlives a dead supervisor).
294
+ * The message names the explicit,
295
+ * auditable legacy path:
296
+ * orch_confirm_engine_shutdown after
297
+ * out-of-band verification.
298
+ *
299
+ * `force` deliberately does NOT bypass an alive engine — that is the one case
300
+ * where proceeding can corrupt state.
301
+ */
302
+ /**
303
+ * THE recovery-ownership rule (#631), as a pure function so every bypass
304
+ * scenario is unit-testable. `extension.ts` wraps it with I/O (engine
305
+ * liveness probe, logging).
306
+ *
307
+ * 1. an engine runs IN THIS PROCESS (forked child not yet terminated, or the
308
+ * main-thread fallback) → refuse — even when the cached phase already
309
+ * reads paused/failed/completed (teardown still in flight).
310
+ * 2–4. otherwise defer to `decideInheritedActivePhase` against the ACTUAL
311
+ * target: alive elsewhere → refuse; none → refuse (confirm path);
312
+ * dead/exited → proceed.
313
+ */
314
+ export function decideRecoveryOwnership(input: {
315
+ operation: string;
316
+ local: { engineAttached: boolean; phase: string; batchId: string; pid: number | null };
317
+ target: { batchId: string; phase: string };
318
+ liveness: EngineLiveness;
319
+ priorSupervisor: { pid: number; alive: boolean } | null;
320
+ }): InheritedPhaseDecision {
321
+ const { operation, local, target, liveness, priorSupervisor } = input;
322
+ if (local.engineAttached) {
323
+ const pid = local.pid ?? "?";
324
+ const terminalCache =
325
+ local.phase === "paused" ||
326
+ local.phase === "failed" ||
327
+ local.phase === "stopped" ||
328
+ local.phase === "completed";
329
+ return {
330
+ proceed: false,
331
+ reason: terminalCache
332
+ ? `⏳ This session's engine (PID ${pid}) for batch ${local.batchId} is still shutting down — ${operation} would race its teardown. Retry in a moment.`
333
+ : `❌ Cannot ${operation} while batch ${local.batchId} is ${local.phase} in this session (engine PID ${pid}). Pause or wait for the current operation to finish first.`,
334
+ };
335
+ }
336
+ return decideInheritedActivePhase({
337
+ phase: target.phase,
338
+ batchId: target.batchId,
339
+ liveness,
340
+ priorSupervisor,
341
+ operation,
342
+ });
343
+ }
344
+
345
+ export interface InheritedPhaseDecision {
346
+ proceed: boolean;
347
+ /** Operator-facing explanation (refusal reason or proceed rationale) */
348
+ reason: string;
349
+ }
350
+
351
+ export function decideInheritedActivePhase(input: {
352
+ phase: string;
353
+ batchId: string;
354
+ liveness: EngineLiveness;
355
+ priorSupervisor: { pid: number; alive: boolean } | null;
356
+ operation: string;
357
+ }): InheritedPhaseDecision {
358
+ const { phase, batchId, liveness, priorSupervisor, operation } = input;
359
+ const id = liveness.identity;
360
+ switch (liveness.status) {
361
+ case "alive":
362
+ return {
363
+ proceed: false,
364
+ reason:
365
+ `❌ Batch ${batchId} is "${phase}" and its engine process (PID ${id!.pid}, forked ` +
366
+ `${new Date(id!.startedAt).toISOString()} by supervisor PID ${id!.supervisorPid}) is still ALIVE ` +
367
+ `in another process. This session has no engine attached and cannot drive or signal it; ` +
368
+ `${operation} now would double-drive the batch.\n` +
369
+ ` Wait for that engine to finish or wind down (it pauses itself when its supervisor ` +
370
+ `disconnects), or terminate it explicitly (Windows: taskkill /PID ${id!.pid} /T; ` +
371
+ `POSIX: kill ${id!.pid}) and re-run ${operation}. force does not bypass this check.`,
372
+ };
373
+ case "dead":
374
+ case "exited":
375
+ return {
376
+ proceed: true,
377
+ reason:
378
+ `engine PID ${id!.pid} is ${liveness.status === "exited" ? `exited (${id!.exitReason ?? "recorded"})` : "dead"} ` +
379
+ `— inherited "${phase}" phase treated as disconnected; persisted-state eligibility applies`,
380
+ };
381
+ case "none": {
382
+ const supervisorNote = priorSupervisor
383
+ ? priorSupervisor.alive
384
+ ? `The previous supervisor (PID ${priorSupervisor.pid}) is still ALIVE, so its engine may well be driving the batch.`
385
+ : `The previous supervisor (PID ${priorSupervisor.pid}) is dead — but a forked engine can outlive its supervisor, so that alone does not prove the engine is gone.`
386
+ : `No previous-supervisor record is available either.`;
387
+ return {
388
+ proceed: false,
389
+ reason:
390
+ `❌ Batch ${batchId} is "${phase}" but this session has no engine attached and NO engine identity is ` +
391
+ `recorded for it (pre-#631 engine, or it never published one). ${supervisorNote} ` +
392
+ `Refusing ${operation}: unknown ownership is not confirmed shutdown.
393
+ ` +
394
+ ` Verify out-of-band that no engine process exists for this repo — Windows: ` +
395
+ `Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match "engine-worker" } ; ` +
396
+ `POSIX: pgrep -af engine-worker — then record it with orch_confirm_engine_shutdown(note) ` +
397
+ `(audited) and re-run ${operation}. No hand-edit of batch-state.json is needed.`,
398
+ };
399
+ }
400
+ }
401
+ }
@@ -120,6 +120,14 @@ export interface EngineWorkerData {
120
120
  force?: boolean;
121
121
  /** Supervisor autonomy mode propagated to worker bridge tools. */
122
122
  supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
123
+ /**
124
+ * #631: the batch this engine is AUTHORIZED to drive. Preallocated by the
125
+ * parent so the engine identity (pid) is published BEFORE the engine starts
126
+ * — for a fresh batch this is the id the engine must adopt instead of
127
+ * generating its own; for resume it is the persisted/reconstructed target
128
+ * the parent gated ownership against, and resume verifies it matches.
129
+ */
130
+ authorizedBatchId?: string;
123
131
  }
124
132
 
125
133
  // ── Serialization helpers (used by both main thread and worker) ──────
@@ -211,13 +219,57 @@ export function applySerializedState(
211
219
 
212
220
  // Guard: only run engine main when launched via fork() with the sentinel env var.
213
221
  if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "function") {
222
+ // #631: a send() on a CLOSED channel does not throw synchronously — Node emits
223
+ // an asynchronous ERR_IPC_CHANNEL_CLOSED on `process`, which would surface as
224
+ // an uncaughtException and route an orphaned engine into reportFatalAndExit
225
+ // instead of its graceful paused wind-down. Gate every send on
226
+ // `process.connected`, and absorb any stray channel error.
214
227
  const send = (msg: WorkerToMainMessage) => {
228
+ if (!process.connected) return;
215
229
  try {
216
230
  process.send?.(msg);
217
231
  } catch {
218
232
  // best effort only
219
233
  }
220
234
  };
235
+ const safeDisconnect = () => {
236
+ if (!process.connected) return;
237
+ try {
238
+ process.disconnect?.();
239
+ } catch {
240
+ /* already closed */
241
+ }
242
+ };
243
+ process.on("error", (err: unknown) => {
244
+ const code = (err as { code?: string } | null)?.code;
245
+ if (code === "ERR_IPC_CHANNEL_CLOSED" || code === "EPIPE") return; // parent gone — expected while orphaned
246
+ throw err;
247
+ });
248
+
249
+ // #631: orphan detection must be armed BEFORE the async module imports below
250
+ // (a parent can die during engine startup). `batchState` is hoisted so the
251
+ // handler can pause it once it exists.
252
+ let batchState: OrchBatchRuntimeState | null = null;
253
+ let orphanedBeforeInit = false;
254
+ process.on("disconnect", () => {
255
+ if (!batchState) {
256
+ // Parent vanished before init/planning produced any state: nothing to
257
+ // checkpoint, nothing another session could inherit. Exit quietly.
258
+ orphanedBeforeInit = true;
259
+ process.exit(0);
260
+ return;
261
+ }
262
+ // We call process.disconnect() ourselves after a terminal state — that is
263
+ // not an orphaning; only act while the batch is still active.
264
+ const p = batchState.phase;
265
+ if (p === "completed" || p === "failed" || p === "paused" || p === "stopped") return;
266
+ batchState.pauseSignal.paused = true;
267
+ batchState.pauseSignal.cause = "operator";
268
+ process.stderr.write(
269
+ `[orch] engine-worker: supervisor disconnected (parent pid gone) — winding down as paused (#631)
270
+ `,
271
+ );
272
+ });
221
273
 
222
274
  const sendWithAck = (msg: WorkerToMainMessage, onFlushed: () => void) => {
223
275
  if (typeof process.send !== "function" || !process.connected) {
@@ -255,8 +307,8 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
255
307
  // Wait for the init message carrying workerData, then start the engine.
256
308
  process.once("message", async (initMsg: { type: string; data: EngineWorkerData }) => {
257
309
  if (initMsg?.type !== "init") return;
310
+ if (orphanedBeforeInit) return;
258
311
 
259
- let batchState: OrchBatchRuntimeState | null = null;
260
312
  let fatalHandled = false;
261
313
  const reportFatalAndExit = (source: WorkerErrorSource, err: unknown) => {
262
314
  if (fatalHandled) return;
@@ -292,6 +344,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
292
344
 
293
345
  // Create a fresh batch state for this process
294
346
  batchState = freshOrchBatchState();
347
+ if (data.authorizedBatchId) batchState.batchId = data.authorizedBatchId; // #631
295
348
  batchState.phase = "launching";
296
349
  batchState.startedAt = Date.now();
297
350
 
@@ -306,12 +359,15 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
306
359
  switch (msg.type) {
307
360
  case "pause":
308
361
  batchState.pauseSignal.paused = true;
362
+ batchState.pauseSignal.cause = "operator";
309
363
  break;
310
364
  case "resume":
311
365
  batchState.pauseSignal.paused = false;
366
+ batchState.pauseSignal.cause = undefined;
312
367
  break;
313
368
  case "abort":
314
369
  batchState.pauseSignal.paused = true;
370
+ batchState.pauseSignal.cause = "abort";
315
371
  break;
316
372
  }
317
373
  });
@@ -392,7 +448,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
392
448
  const finalState = serializeBatchState(batchState);
393
449
  send({ type: "complete", state: finalState });
394
450
  // Disconnect IPC so the child process can exit cleanly
395
- process.disconnect?.();
451
+ safeDisconnect();
396
452
  })
397
453
  .catch((err: unknown) => {
398
454
  const normalized = normalizeError(err);
@@ -409,7 +465,7 @@ if (process.env.TASKPLANE_ENGINE_FORK === "1" && typeof process.send === "functi
409
465
  message: normalized.message,
410
466
  stack: normalized.stack,
411
467
  });
412
- process.disconnect?.();
468
+ safeDisconnect();
413
469
  });
414
470
  });
415
471
  }