taskplane 0.28.7 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -86,15 +86,38 @@ export function getNpmGlobalRoot(): string {
86
86
  return _npmGlobalRoot;
87
87
  }
88
88
 
89
+ /**
90
+ * Pi CLI npm package scopes that taskplane resolves at runtime, ordered with
91
+ * the canonical (current) scope FIRST and legacy scopes after for backward
92
+ * compatibility. Issue #560: the Pi coding agent was renamed from
93
+ * `@mariozechner/pi-coding-agent` to `@earendil-works/pi-coding-agent` in
94
+ * Pi v0.74.0. Pi's own extension loader bundles BOTH scope aliases at runtime
95
+ * for in-process module imports, but spawn-side path resolution (this file)
96
+ * has to look on disk under whichever scope was actually installed.
97
+ *
98
+ * Order matters: the new scope is preferred so a system that has BOTH
99
+ * installed (e.g., during a transition window) picks up the current Pi.
100
+ */
101
+ const PI_PACKAGE_SCOPES = ["@earendil-works", "@mariozechner"] as const;
102
+
89
103
  /**
90
104
  * Resolve the absolute path to the Pi coding agent CLI entrypoint (`cli.js`).
91
105
  *
92
- * The Pi CLI is installed as `@mariozechner/pi-coding-agent`. On Windows, invoking
93
- * `pi` directly executes a `.CMD` shim that cannot be spawned with `shell: false`.
94
- * This function locates the underlying `dist/cli.js` so callers can spawn it with
95
- * `node` directly, without a shell intermediary.
106
+ * The Pi CLI is installed under one of two npm scopes:
107
+ * - `@earendil-works/pi-coding-agent` (current, as of Pi v0.74.0)
108
+ * - `@mariozechner/pi-coding-agent` (legacy)
96
109
  *
97
- * Resolution order:
110
+ * On Windows, invoking `pi` directly executes a `.CMD` shim that cannot be
111
+ * spawned with `shell: false`. This function locates the underlying
112
+ * `dist/cli.js` so callers can spawn it with `node` directly, without a shell
113
+ * intermediary.
114
+ *
115
+ * Resolution order: the cross product of base directories × package scopes,
116
+ * with each base directory tried for the new scope before any base directory
117
+ * is tried for the legacy scope. (Equivalently: scope is the inner loop, base
118
+ * is the outer loop.)
119
+ *
120
+ * Base directories (outer loop):
98
121
  * 1. `npm root -g` result (dynamic — covers all setups: nvm, Homebrew, volta, etc.)
99
122
  * 2. `%APPDATA%\npm\node_modules\...` (Windows, APPDATA env var)
100
123
  * 3. `%USERPROFILE%\AppData\Roaming\npm\node_modules\...` (Windows, HOME-relative)
@@ -102,40 +125,53 @@ export function getNpmGlobalRoot(): string {
102
125
  * 5. `/usr/local/lib/node_modules/...` (macOS system Node, Linux)
103
126
  * 6. `/opt/homebrew/lib/node_modules/...` (macOS Homebrew)
104
127
  *
105
- * @returns Absolute path to `@mariozechner/pi-coding-agent/dist/cli.js`
106
- * @throws {Error} If the CLI entrypoint cannot be found in any known location.
107
- * The error message includes the `npm root -g` value for diagnosis.
128
+ * Scopes per base (inner loop):
129
+ * a. `@earendil-works/pi-coding-agent/dist/cli.js`
130
+ * b. `@mariozechner/pi-coding-agent/dist/cli.js`
131
+ *
132
+ * @returns Absolute path to a Pi CLI `dist/cli.js` (under whichever scope was found).
133
+ * @throws {Error} If the CLI entrypoint cannot be found under any base × scope
134
+ * combination. The error message includes the `npm root -g` value
135
+ * AND lists both scopes searched, for operator diagnosis.
108
136
  */
109
137
  export function resolvePiCliPath(): string {
110
- const relPath = join("@mariozechner", "pi-coding-agent", "dist", "cli.js");
111
- const candidates: string[] = [];
138
+ const bases: string[] = [];
112
139
 
113
140
  // 1. Dynamic: npm root -g (covers nvm, Homebrew, volta, custom npm prefix, etc.)
114
141
  const npmRoot = getNpmGlobalRoot();
115
- if (npmRoot) candidates.push(join(npmRoot, relPath));
142
+ if (npmRoot) bases.push(npmRoot);
116
143
 
117
144
  // 2-3. Static Windows fallbacks
118
145
  const home = process.env.HOME || process.env.USERPROFILE || "";
119
146
  if (process.env.APPDATA) {
120
- candidates.push(join(process.env.APPDATA, "npm", "node_modules", relPath));
147
+ bases.push(join(process.env.APPDATA, "npm", "node_modules"));
121
148
  }
122
149
  if (home) {
123
- candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", relPath));
150
+ bases.push(join(home, "AppData", "Roaming", "npm", "node_modules"));
124
151
  // 4. macOS/Linux custom global prefix
125
- candidates.push(join(home, ".npm-global", "lib", "node_modules", relPath));
152
+ bases.push(join(home, ".npm-global", "lib", "node_modules"));
126
153
  }
127
154
  // 5. macOS system Node / Linux
128
- candidates.push(join("/usr", "local", "lib", "node_modules", relPath));
155
+ bases.push(join("/usr", "local", "lib", "node_modules"));
129
156
  // 6. macOS Homebrew
130
- candidates.push(join("/opt", "homebrew", "lib", "node_modules", relPath));
157
+ bases.push(join("/opt", "homebrew", "lib", "node_modules"));
131
158
 
132
- for (const candidate of candidates) {
133
- if (existsSync(candidate)) return candidate;
159
+ // Cross product: scope is the inner loop so a single base directory is
160
+ // fully exhausted (new scope, then legacy scope) before falling back to
161
+ // the next base. This matches operator intuition ("check the most likely
162
+ // install location for either scope first").
163
+ for (const base of bases) {
164
+ for (const scope of PI_PACKAGE_SCOPES) {
165
+ const candidate = join(base, scope, "pi-coding-agent", "dist", "cli.js");
166
+ if (existsSync(candidate)) return candidate;
167
+ }
134
168
  }
135
169
 
136
170
  throw new Error(
137
- "Cannot find Pi CLI entrypoint (@mariozechner/pi-coding-agent/dist/cli.js). " +
138
- "Ensure the pi coding agent is installed globally via 'npm install -g @mariozechner/pi-coding-agent'. " +
171
+ "Cannot find Pi CLI entrypoint (pi-coding-agent/dist/cli.js) under any known npm scope " +
172
+ `(${PI_PACKAGE_SCOPES.join(" or ")}). ` +
173
+ "Install via 'npm install -g @earendil-works/pi-coding-agent' " +
174
+ "(or, for legacy installs, 'npm install -g @mariozechner/pi-coding-agent'). " +
139
175
  `npm root -g returned: ${npmRoot || "(empty — npm may not be on PATH)"}`,
140
176
  );
141
177
  }
@@ -189,12 +225,15 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string):
189
225
  candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
190
226
 
191
227
  // 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
192
- // pi is at: <npmRoot>/@mariozechner/pi-coding-agent/dist/cli.js
193
- // so piPkgDir = <npmRoot>/@mariozechner/pi-coding-agent (resolve up 2 levels from cli.js)
194
- // then go up TWO more levels to reach <npmRoot>, then into taskplane/
228
+ // pi is at: <npmRoot>/<scope>/pi-coding-agent/dist/cli.js (where <scope> is
229
+ // @earendil-works (current) or @mariozechner (legacy)).
230
+ // so piPkgDir = <npmRoot>/<scope>/pi-coding-agent (resolve up 2 levels from cli.js).
231
+ // Then go up TWO more levels to reach <npmRoot>, then into taskplane/.
232
+ // This works regardless of which scope Pi is installed under because we
233
+ // only walk up the directory tree — we never name the scope explicitly.
195
234
  try {
196
235
  const piPath = process.argv[1] || "";
197
- const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/@mariozechner/pi-coding-agent
236
+ const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/<scope>/pi-coding-agent
198
237
  const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
199
238
  candidates.push(join(npmRootFromPi, "taskplane", relPath));
200
239
  } catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
@@ -2,12 +2,12 @@
2
2
  * State persistence, serialization, orphan detection
3
3
  * @module orch/persistence
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync, readdirSync, statSync } from "fs";
6
6
  import { join, dirname, basename } from "path";
7
7
 
8
8
  import { execLog } from "./execution.ts";
9
- import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics } from "./types.ts";
10
- import type { BatchHistorySummary } from "./types.ts";
9
+ import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics, runtimeRoot, runtimeManifestPath } from "./types.ts";
10
+ import type { BatchHistorySummary, RuntimeAgentManifest } from "./types.ts";
11
11
  import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
12
12
  import { sleepSync } from "./worktree.ts";
13
13
  import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
@@ -2085,3 +2085,378 @@ export function emitEngineEvent(
2085
2085
  }
2086
2086
  }
2087
2087
 
2088
+
2089
+ // ── TP-187 (#539): Batch-Meta Runtime Artifact ─────────────────────
2090
+ //
2091
+ // Small JSON file written at batch-start to `.pi/runtime/<batchId>/batch-meta.json`.
2092
+ // Captures the wave plan and the few non-recoverable scalars (baseBranch,
2093
+ // orchBranch, mode, startedAt, totalWaves) so that `orch_resume(force=true)`
2094
+ // can deterministically reconstruct a validator-compliant PersistedBatchState
2095
+ // after `orch_abort()` deletes `.pi/batch-state.json`.
2096
+ //
2097
+ // Without this artifact the wave topology is unrecoverable from the surviving
2098
+ // runtime registry alone (manifests don't carry wave info) and a flattened
2099
+ // "single wave with all surviving tasks" reconstruction can violate DAG
2100
+ // dependency ordering. See R003 plan review.
2101
+
2102
+ /**
2103
+ * Schema-tagged batch metadata persisted alongside per-batch runtime state.
2104
+ *
2105
+ * @since TP-187 (#539)
2106
+ */
2107
+ export interface BatchMetaArtifact {
2108
+ schemaVersion: 1;
2109
+ batchId: string;
2110
+ wavePlan: string[][];
2111
+ baseBranch: string;
2112
+ orchBranch: string;
2113
+ mode: WorkspaceMode;
2114
+ startedAt: number;
2115
+ totalWaves: number;
2116
+ }
2117
+
2118
+ /** Path to the batch-meta artifact for a given batch. */
2119
+ function batchMetaPath(stateRoot: string, batchId: string): string {
2120
+ return join(runtimeRoot(stateRoot, batchId), "batch-meta.json");
2121
+ }
2122
+
2123
+ /**
2124
+ * Persist the wave plan and core batch metadata to the runtime artifact
2125
+ * directory. Best-effort: failures are logged but do NOT crash the batch.
2126
+ *
2127
+ * Called once at batch-start (after wavePlan is finalized) and re-written
2128
+ * whenever the wave plan mutates (segment expansion).
2129
+ *
2130
+ * @since TP-187 (#539)
2131
+ */
2132
+ export function saveBatchMetaRuntimeArtifact(
2133
+ stateRoot: string,
2134
+ artifact: BatchMetaArtifact,
2135
+ ): void {
2136
+ try {
2137
+ const path = batchMetaPath(stateRoot, artifact.batchId);
2138
+ mkdirSync(dirname(path), { recursive: true });
2139
+ const tmp = path + ".tmp";
2140
+ writeFileSync(tmp, JSON.stringify(artifact, null, 2) + "\n", "utf-8");
2141
+ renameSync(tmp, path);
2142
+ execLog("state", artifact.batchId, "persisted batch-meta runtime artifact", {
2143
+ waves: artifact.wavePlan.length,
2144
+ tasks: artifact.wavePlan.reduce((sum, w) => sum + w.length, 0),
2145
+ });
2146
+ } catch (err) {
2147
+ execLog("state", artifact.batchId, `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`);
2148
+ }
2149
+ }
2150
+
2151
+ /**
2152
+ * Load the batch-meta artifact for a given batch, or null if missing/invalid.
2153
+ *
2154
+ * @since TP-187 (#539)
2155
+ */
2156
+ export function loadBatchMetaRuntimeArtifact(
2157
+ stateRoot: string,
2158
+ batchId: string,
2159
+ ): BatchMetaArtifact | null {
2160
+ const path = batchMetaPath(stateRoot, batchId);
2161
+ if (!existsSync(path)) return null;
2162
+ try {
2163
+ const raw = readFileSync(path, "utf-8");
2164
+ const parsed = JSON.parse(raw);
2165
+ if (!parsed || typeof parsed !== "object") return null;
2166
+ const obj = parsed as Record<string, unknown>;
2167
+ if (obj.schemaVersion !== 1) return null;
2168
+ if (typeof obj.batchId !== "string" || obj.batchId !== batchId) return null;
2169
+ if (!Array.isArray(obj.wavePlan)) return null;
2170
+ for (const wave of obj.wavePlan) {
2171
+ if (!Array.isArray(wave)) return null;
2172
+ for (const taskId of wave) {
2173
+ if (typeof taskId !== "string") return null;
2174
+ }
2175
+ }
2176
+ if (typeof obj.baseBranch !== "string") return null;
2177
+ if (typeof obj.orchBranch !== "string") return null;
2178
+ if (obj.mode !== "repo" && obj.mode !== "workspace") return null;
2179
+ if (typeof obj.startedAt !== "number") return null;
2180
+ if (typeof obj.totalWaves !== "number") return null;
2181
+ return obj as unknown as BatchMetaArtifact;
2182
+ } catch {
2183
+ return null;
2184
+ }
2185
+ }
2186
+
2187
+
2188
+ // ── TP-187 (#539): Reconstruct PersistedBatchState from runtime artifacts ──
2189
+
2190
+ /**
2191
+ * Result of `reconstructBatchStateFromRuntime`. On success, contains the
2192
+ * validator-compliant state, the selected batchId, and a human-readable note
2193
+ * about how the selection was made (used by resume's onNotify output). On
2194
+ * failure, names the missing or corrupt artifact for fail-loud reporting.
2195
+ *
2196
+ * @since TP-187 (#539)
2197
+ */
2198
+ export type ReconstructResult =
2199
+ | { ok: true; state: PersistedBatchState; batchId: string; selectionNote: string }
2200
+ | { ok: false; error: string };
2201
+
2202
+ /**
2203
+ * List candidate `.pi/runtime/<batchId>/` directories newest-first by mtime,
2204
+ * with lex-largest tie-break for determinism.
2205
+ */
2206
+ function listRuntimeBatchDirs(stateRoot: string): { batchId: string; mtimeMs: number }[] {
2207
+ const root = join(stateRoot, ".pi", "runtime");
2208
+ if (!existsSync(root)) return [];
2209
+ let entries: string[] = [];
2210
+ try {
2211
+ entries = readdirSync(root);
2212
+ } catch {
2213
+ return [];
2214
+ }
2215
+ const candidates: { batchId: string; mtimeMs: number }[] = [];
2216
+ for (const name of entries) {
2217
+ const dir = join(root, name);
2218
+ try {
2219
+ const st = statSync(dir);
2220
+ if (!st.isDirectory()) continue;
2221
+ candidates.push({ batchId: name, mtimeMs: st.mtimeMs });
2222
+ } catch {
2223
+ continue;
2224
+ }
2225
+ }
2226
+ candidates.sort((a, b) => {
2227
+ if (b.mtimeMs !== a.mtimeMs) return b.mtimeMs - a.mtimeMs;
2228
+ return b.batchId.localeCompare(a.batchId);
2229
+ });
2230
+ return candidates;
2231
+ }
2232
+
2233
+ /**
2234
+ * Read all worker manifests under `.pi/runtime/<batchId>/agents/`.
2235
+ *
2236
+ * Returns an empty array if the agents directory is missing.
2237
+ */
2238
+ function readWorkerManifests(stateRoot: string, batchId: string): RuntimeAgentManifest[] {
2239
+ const agentsDir = join(runtimeRoot(stateRoot, batchId), "agents");
2240
+ if (!existsSync(agentsDir)) return [];
2241
+ let entries: string[] = [];
2242
+ try {
2243
+ entries = readdirSync(agentsDir);
2244
+ } catch {
2245
+ return [];
2246
+ }
2247
+ const manifests: RuntimeAgentManifest[] = [];
2248
+ for (const agentId of entries) {
2249
+ const manifestPath = runtimeManifestPath(stateRoot, batchId, agentId);
2250
+ if (!existsSync(manifestPath)) continue;
2251
+ try {
2252
+ const raw = readFileSync(manifestPath, "utf-8");
2253
+ const parsed = JSON.parse(raw) as RuntimeAgentManifest;
2254
+ if (parsed && typeof parsed === "object" && parsed.role === "worker") {
2255
+ manifests.push(parsed);
2256
+ }
2257
+ } catch {
2258
+ continue;
2259
+ }
2260
+ }
2261
+ return manifests;
2262
+ }
2263
+
2264
+ /**
2265
+ * Deterministically reconstruct a validator-compliant `PersistedBatchState`
2266
+ * from the surviving runtime artifacts after `.pi/batch-state.json` has been
2267
+ * deleted (typically by `orch_abort()`).
2268
+ *
2269
+ * Required artifacts: at least one `.pi/runtime/<batchId>/` directory whose
2270
+ * `batch-meta.json` parses cleanly AND has at least one worker manifest with
2271
+ * an existing worktree on disk. Anything else returns a fail-loud error so
2272
+ * the caller can surface a clear "no resumable state" message instead of
2273
+ * silently producing an invalid state.
2274
+ *
2275
+ * @since TP-187 (#539)
2276
+ */
2277
+ export function reconstructBatchStateFromRuntime(stateRoot: string): ReconstructResult {
2278
+ const candidates = listRuntimeBatchDirs(stateRoot);
2279
+ if (candidates.length === 0) {
2280
+ return { ok: false, error: "no .pi/runtime/ directory or no batch subdirectories" };
2281
+ }
2282
+
2283
+ // Try the newest batch first; if its required artifacts are missing, fall
2284
+ // through to the next candidate. We stop at the first batch with a parseable
2285
+ // batch-meta + at least one viable worker manifest.
2286
+ const failures: string[] = [];
2287
+ for (let idx = 0; idx < candidates.length; idx++) {
2288
+ const cand = candidates[idx];
2289
+ const meta = loadBatchMetaRuntimeArtifact(stateRoot, cand.batchId);
2290
+ if (!meta) {
2291
+ failures.push(`${cand.batchId}: batch-meta.json missing or invalid`);
2292
+ continue;
2293
+ }
2294
+ const manifests = readWorkerManifests(stateRoot, cand.batchId);
2295
+ if (manifests.length === 0) {
2296
+ failures.push(`${cand.batchId}: no worker manifests`);
2297
+ continue;
2298
+ }
2299
+ const workerManifestsWithWorktree = manifests.filter(m => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd));
2300
+ if (workerManifestsWithWorktree.length === 0) {
2301
+ failures.push(`${cand.batchId}: worktree paths from manifests no longer exist on disk`);
2302
+ continue;
2303
+ }
2304
+
2305
+ // TP-187 (#539) — sage post-integration follow-up: refuse reconstruction
2306
+ // when the runtime artifacts indicate this batch was multi-repo (segment
2307
+ // expansion). Reconstruction hardcodes `segments: []` and cannot recover
2308
+ // the per-segment topology that lives only in the deleted batch-state.
2309
+ // Resuming with `segments: []` for a multi-repo batch would silently lose
2310
+ // the expansion state and could re-execute already-done segments OR fail
2311
+ // dependency checks for cross-repo waves. Detection heuristic: if worker
2312
+ // manifests carry more than one distinct repoId, segment expansion was
2313
+ // active. Single-repo batches (the common case, including Taskplane's
2314
+ // own self-orchestration) are unaffected.
2315
+ {
2316
+ const distinctRepoIds = new Set<string>();
2317
+ for (const m of workerManifestsWithWorktree) {
2318
+ if (typeof m.repoId === "string" && m.repoId.length > 0) {
2319
+ distinctRepoIds.add(m.repoId);
2320
+ }
2321
+ }
2322
+ if (distinctRepoIds.size > 1) {
2323
+ failures.push(
2324
+ `${cand.batchId}: multi-repo batch detected (${distinctRepoIds.size} distinct repoIds: ` +
2325
+ `${[...distinctRepoIds].slice(0, 4).join(", ")}` +
2326
+ `${distinctRepoIds.size > 4 ? ", ..." : ""}); reconstruction would lose segment ` +
2327
+ `expansion state and is refused. Restore .pi/batch-state.json from backup or start a new batch.`
2328
+ );
2329
+ continue;
2330
+ }
2331
+ }
2332
+
2333
+ // Build per-lane aggregation from worker manifests.
2334
+ const laneMap = new Map<number, { laneNumber: number; agentId: string; worktreePath: string; repoId: string; taskIds: string[] }>();
2335
+ for (const m of workerManifestsWithWorktree) {
2336
+ if (typeof m.laneNumber !== "number") continue;
2337
+ const lane = laneMap.get(m.laneNumber) ?? {
2338
+ laneNumber: m.laneNumber,
2339
+ agentId: m.agentId,
2340
+ worktreePath: m.cwd,
2341
+ repoId: m.repoId ?? "default",
2342
+ taskIds: [] as string[],
2343
+ };
2344
+ if (typeof m.taskId === "string" && m.taskId && !lane.taskIds.includes(m.taskId)) {
2345
+ lane.taskIds.push(m.taskId);
2346
+ }
2347
+ laneMap.set(m.laneNumber, lane);
2348
+ }
2349
+ if (laneMap.size === 0) {
2350
+ failures.push(`${cand.batchId}: no lane numbers in manifests`);
2351
+ continue;
2352
+ }
2353
+
2354
+ // Tasks: union of taskIds across all lanes, plus any wavePlan tasks that
2355
+ // are not represented (they are pending, not yet executed).
2356
+ const knownTaskIds = new Set<string>();
2357
+ for (const lane of laneMap.values()) {
2358
+ for (const tid of lane.taskIds) knownTaskIds.add(tid);
2359
+ }
2360
+ for (const wave of meta.wavePlan) {
2361
+ for (const tid of wave) knownTaskIds.add(tid);
2362
+ }
2363
+
2364
+ // Build task records with conservative defaults; resume's reconciliation
2365
+ // pass will re-detect succeeded tasks via `.DONE` markers and STATUS.md.
2366
+ const tasks: PersistedTaskRecord[] = [];
2367
+ const manifestByTaskId = new Map<string, RuntimeAgentManifest>();
2368
+ for (const m of workerManifestsWithWorktree) {
2369
+ if (typeof m.taskId === "string" && m.taskId) {
2370
+ manifestByTaskId.set(m.taskId, m);
2371
+ }
2372
+ }
2373
+ for (const taskId of knownTaskIds) {
2374
+ const m = manifestByTaskId.get(taskId);
2375
+ const lane = m ? laneMap.get(m.laneNumber) : undefined;
2376
+ const taskRecord: PersistedTaskRecord = {
2377
+ taskId,
2378
+ taskName: taskId,
2379
+ taskFolder: m?.packet?.taskFolder ?? "",
2380
+ status: "pending",
2381
+ sessionName: m?.agentId ?? "",
2382
+ laneNumber: lane?.laneNumber ?? 0,
2383
+ startedAt: typeof m?.startedAt === "number" ? m.startedAt : null,
2384
+ endedAt: null,
2385
+ exitReason: "",
2386
+ doneFileFound: false,
2387
+ };
2388
+ if (m?.repoId) taskRecord.repoId = m.repoId;
2389
+ if (m?.packet?.packetRepoId) (taskRecord as Record<string, unknown>).packetRepoId = m.packet.packetRepoId;
2390
+ if (m?.packet?.packetTaskPath) (taskRecord as Record<string, unknown>).packetTaskPath = m.packet.packetTaskPath;
2391
+ tasks.push(taskRecord);
2392
+ }
2393
+
2394
+ // Build lane records.
2395
+ const lanes: PersistedLaneRecord[] = Array.from(laneMap.values())
2396
+ .sort((a, b) => a.laneNumber - b.laneNumber)
2397
+ .map(l => {
2398
+ const sessionId = l.agentId.replace(/-(worker|reviewer)$/, "");
2399
+ const rec: PersistedLaneRecord = {
2400
+ laneId: `lane-${l.laneNumber}`,
2401
+ laneNumber: l.laneNumber,
2402
+ laneSessionId: sessionId,
2403
+ worktreePath: l.worktreePath,
2404
+ branch: meta.orchBranch ? `${meta.orchBranch}-lane-${l.laneNumber}` : `lane-${l.laneNumber}`,
2405
+ taskIds: [...l.taskIds],
2406
+ };
2407
+ if (l.repoId && l.repoId !== "default") rec.repoId = l.repoId;
2408
+ return rec;
2409
+ });
2410
+
2411
+ const now = Date.now();
2412
+ const reconstructed: PersistedBatchState = {
2413
+ schemaVersion: BATCH_STATE_SCHEMA_VERSION,
2414
+ batchId: meta.batchId,
2415
+ phase: "stopped",
2416
+ baseBranch: meta.baseBranch,
2417
+ orchBranch: meta.orchBranch,
2418
+ mode: meta.mode,
2419
+ startedAt: meta.startedAt,
2420
+ endedAt: null,
2421
+ updatedAt: now,
2422
+ currentWaveIndex: 0,
2423
+ totalWaves: meta.totalWaves,
2424
+ totalTasks: tasks.length,
2425
+ succeededTasks: 0,
2426
+ failedTasks: 0,
2427
+ skippedTasks: 0,
2428
+ blockedTasks: 0,
2429
+ wavePlan: meta.wavePlan.map(wave => [...wave]),
2430
+ lanes,
2431
+ tasks,
2432
+ mergeResults: [],
2433
+ blockedTaskIds: [],
2434
+ errors: [],
2435
+ segments: [],
2436
+ lastError: null,
2437
+ resilience: { ...defaultResilienceState(), resumeForced: true },
2438
+ diagnostics: defaultBatchDiagnostics(),
2439
+ } as PersistedBatchState;
2440
+
2441
+ // Validate the reconstructed shape against the on-disk schema gate.
2442
+ try {
2443
+ const json = JSON.stringify(reconstructed);
2444
+ validatePersistedState(JSON.parse(json));
2445
+ } catch (err) {
2446
+ failures.push(`${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`);
2447
+ continue;
2448
+ }
2449
+
2450
+ const totalCandidates = candidates.length;
2451
+ const selectionNote = totalCandidates === 1
2452
+ ? `single batch in .pi/runtime/`
2453
+ : `selected from ${totalCandidates} candidate(s) by mtime newest-first (skipped ${idx} earlier candidate(s))`;
2454
+ return { ok: true, state: reconstructed, batchId: meta.batchId, selectionNote };
2455
+ }
2456
+
2457
+ return {
2458
+ ok: false,
2459
+ error: `no reconstructable batch found in .pi/runtime/ (${failures.length} candidate(s) inspected: ${failures.slice(0, 3).join("; ")}${failures.length > 3 ? "; ..." : ""})`,
2460
+ };
2461
+ }
2462
+
@@ -37,7 +37,7 @@ import { mergeWaveByRepo } from "./merge.ts";
37
37
  import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
38
38
  import type { CleanupGateRepoFailure } from "./messages.ts";
39
39
  import { resolveOperatorId } from "./naming.ts";
40
- import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
40
+ import { applyPartialProgressToOutcomes, deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, reconstructBatchStateFromRuntime, saveBatchState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
41
41
  import { buildBatchProgressSnapshot, buildSupervisorSegmentFrontierSnapshot, defaultResilienceState, StateFileError } from "./types.ts";
42
42
  import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, PersistedSegmentRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
43
43
  import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
@@ -1070,6 +1070,18 @@ export async function resumeOrchBatch(
1070
1070
  force: boolean = false,
1071
1071
  onSupervisorAlert?: import("./types.ts").SupervisorAlertCallback | null,
1072
1072
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1073
+ /**
1074
+ * TP-187 (#538): Optional callback fired when a lane reaches a terminal
1075
+ * state during a resumed batch. Threaded through to executeWave so the
1076
+ * supervisor process keeps suppressing zombie alerts after resume too.
1077
+ */
1078
+ onLaneTerminated?: import("./types.ts").LaneTerminatedCallback | null,
1079
+ /**
1080
+ * TP-187 (#538): Optional callback fired when a lane is freshly
1081
+ * (re-)allocated during resume. The supervisor uses it to lift any
1082
+ * carried-over zombie-alert suppression.
1083
+ */
1084
+ onLaneRespawned?: ((laneNumber: number, agentId: string, batchId: string) => void) | null,
1073
1085
  ): Promise<void> {
1074
1086
  const repoRoot = cwd;
1075
1087
  // State files (.pi/batch-state.json, lane-state, etc.) belong in the workspace root,
@@ -1111,13 +1123,49 @@ export async function resumeOrchBatch(
1111
1123
  }
1112
1124
 
1113
1125
  if (!persistedState) {
1126
+ if (!force) {
1127
+ onNotify(
1128
+ ORCH_MESSAGES.resumeNoState(),
1129
+ "error",
1130
+ );
1131
+ // TP-040 R006: Reset phase on pre-execution early return
1132
+ batchState.phase = "idle";
1133
+ return;
1134
+ }
1135
+ // TP-187 (#539): On force-resume, attempt deterministic reconstruction
1136
+ // from .pi/runtime/<batchId>/ runtime artifacts (typically left intact
1137
+ // by `orch_abort()` even though `.pi/batch-state.json` is deleted).
1138
+ const reconstruction = reconstructBatchStateFromRuntime(stateRoot);
1139
+ if (!reconstruction.ok) {
1140
+ onNotify(
1141
+ ORCH_MESSAGES.resumeNoStateAfterAbort(reconstruction.error, null),
1142
+ "error",
1143
+ );
1144
+ // TP-040 R006: Reset phase on pre-execution early return
1145
+ batchState.phase = "idle";
1146
+ return;
1147
+ }
1148
+ // Successful reconstruction: persist so the rest of resumeOrchBatch
1149
+ // proceeds with a normal on-disk batch-state.json picture.
1114
1150
  onNotify(
1115
- ORCH_MESSAGES.resumeNoState(),
1116
- "error",
1151
+ ORCH_MESSAGES.resumeReconstructed(reconstruction.batchId, reconstruction.selectionNote),
1152
+ "warning",
1117
1153
  );
1118
- // TP-040 R006: Reset phase on pre-execution early return
1119
- batchState.phase = "idle";
1120
- return;
1154
+ try {
1155
+ saveBatchState(JSON.stringify(reconstruction.state, null, 2), stateRoot);
1156
+ } catch (err) {
1157
+ onNotify(
1158
+ ORCH_MESSAGES.resumeNoStateAfterAbort(
1159
+ `reconstructed state could not be persisted: ${err instanceof Error ? err.message : String(err)}`,
1160
+ reconstruction.batchId,
1161
+ ),
1162
+ "error",
1163
+ );
1164
+ // TP-040 R006: Reset phase on pre-execution early return
1165
+ batchState.phase = "idle";
1166
+ return;
1167
+ }
1168
+ persistedState = reconstruction.state;
1121
1169
  }
1122
1170
 
1123
1171
  // ── 2. Check eligibility ─────────────────────────────────────
@@ -2050,6 +2098,8 @@ export async function resumeOrchBatch(
2050
2098
  runnerConfig.reviewer,
2051
2099
  runnerConfig.worker,
2052
2100
  runnerConfig.workerExcludeExtensions ?? [],
2101
+ onLaneTerminated ?? undefined,
2102
+ onLaneRespawned ?? undefined,
2053
2103
  );
2054
2104
 
2055
2105
  batchState.waveResults.push(waveResult);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Lightweight, import-free constants module for the worker tool allowlist.
3
+ *
4
+ * This module exists so that pure-data layers (`config-schema.ts`,
5
+ * `types.ts`) can reference the canonical `DEFAULT_WORKER_USER_TOOLS`
6
+ * literal without pulling `agent-host.ts`'s heavy `child_process` / `fs`
7
+ * imports into the schema/types graph (which would either be circular
8
+ * or pull subprocess plumbing into pure-data files).
9
+ *
10
+ * **Strict invariant:** this module MUST NOT have any imports beyond
11
+ * TypeScript built-ins. Anything more would re-introduce the very
12
+ * coupling this module exists to break.
13
+ *
14
+ * The companion `agent-host.ts` re-exports `DEFAULT_WORKER_USER_TOOLS`
15
+ * from this module for backward compatibility — existing internal
16
+ * imports (e.g., `execution.ts`, `worker-tools-allowlist.test.ts`)
17
+ * continue to work via the agent-host re-export. New code may import
18
+ * from either location; this module is the source of truth.
19
+ *
20
+ * `ENGINE_BRIDGE_TOOLS` and the `buildWorkerToolsAllowlist()` helper
21
+ * remain in `agent-host.ts` because that's where their consumers live
22
+ * and there is no duplication problem to solve for them.
23
+ *
24
+ * @module taskplane/tool-allowlist-constants
25
+ * @since TP-189 (Cluster B)
26
+ */
27
+
28
+ /**
29
+ * Default user-tools portion of the worker `--tools` allowlist. This is the
30
+ * fallback used when neither `taskRunner.worker.tools` config nor the
31
+ * `TASKPLANE_WORKER_TOOLS` env var supplies a value. Engine bridge tools
32
+ * (review_step, notify_supervisor, escalate_to_supervisor,
33
+ * request_segment_expansion) are appended on top by
34
+ * `buildWorkerToolsAllowlist()` at the spawn site — they are NOT part of
35
+ * this default and should not be added by callers.
36
+ */
37
+ export const DEFAULT_WORKER_USER_TOOLS = "read,write,edit,bash,grep,find,ls";