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.
- package/extensions/taskplane/agent-bridge-extension.ts +66 -8
- package/extensions/taskplane/agent-host.ts +170 -9
- package/extensions/taskplane/config-loader.ts +9 -0
- package/extensions/taskplane/config-schema.ts +47 -1
- package/extensions/taskplane/diagnostic-reports.ts +109 -6
- package/extensions/taskplane/diagnostics.ts +3 -0
- package/extensions/taskplane/engine-identity.ts +401 -0
- package/extensions/taskplane/engine-worker.ts +59 -3
- package/extensions/taskplane/engine.ts +137 -17
- package/extensions/taskplane/execution.ts +89 -8
- package/extensions/taskplane/extension.ts +1130 -96
- package/extensions/taskplane/git.ts +74 -0
- package/extensions/taskplane/lane-runner.ts +971 -95
- package/extensions/taskplane/process-registry.ts +7 -2
- package/extensions/taskplane/resume.ts +559 -23
- package/extensions/taskplane/review-analysis.ts +450 -0
- package/extensions/taskplane/segment-recovery.ts +192 -0
- package/extensions/taskplane/supervisor-primer.md +182 -2
- package/extensions/taskplane/supervisor.ts +225 -17
- package/extensions/taskplane/types.ts +142 -4
- package/extensions/taskplane/worktree.ts +143 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
468
|
+
safeDisconnect();
|
|
413
469
|
});
|
|
414
470
|
});
|
|
415
471
|
}
|