taskplane 0.28.8 → 0.29.1

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.
@@ -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
+
@@ -7,7 +7,7 @@ import { join } from "path";
7
7
 
8
8
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
9
9
  import { runDiscovery } from "./discovery.ts";
10
- import { executeOrchBatch, resolveDisplayWaveNumber } from "./engine.ts";
10
+ import { executeOrchBatch, resolveDisplayWaveNumber, buildSpawnFailureAlertExtras } from "./engine.ts";
11
11
  import { buildReviewerEnv, buildWorkerEnv, buildWorkerExcludeEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
12
12
  import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
13
13
  import { selectRuntimeBackend } from "./engine.ts";
@@ -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);
@@ -2110,11 +2160,17 @@ export async function resumeOrchBatch(
2110
2160
  const frontierSummary = segmentFrontier
2111
2161
  ? ` Segment frontier: ${segmentFrontier.terminalSegments}/${segmentFrontier.totalSegments} terminal\n`
2112
2162
  : "";
2163
+ // TP-190 (#561): Mirror engine.ts emission — propagate the structured
2164
+ // exit category so /orch-resume task-failure alerts route through the
2165
+ // same supervisor playbook branches as /orch. Shared helper enforces
2166
+ // payload parity between the two emission sites.
2167
+ const { exitCategory, summaryLine: spawnFailureLine } = buildSpawnFailureAlertExtras(outcome);
2113
2168
  emitAlert({
2114
2169
  category: "task-failure",
2115
2170
  summary:
2116
2171
  `⚠️ Task failure: ${taskId}\n` +
2117
2172
  ` Exit reason: ${exitReason}\n` +
2173
+ spawnFailureLine +
2118
2174
  segmentSummary +
2119
2175
  frontierSummary +
2120
2176
  ` Lane: ${laneForTask?.laneId ?? "unknown"} (lane ${laneForTask?.laneNumber ?? "?"})\n` +
@@ -2134,6 +2190,7 @@ export async function resumeOrchBatch(
2134
2190
  laneNumber: laneForTask?.laneNumber,
2135
2191
  waveIndex: waveIdx,
2136
2192
  exitReason,
2193
+ exitCategory,
2137
2194
  partialProgress: hasPartialProgress,
2138
2195
  batchProgress: buildBatchProgressSnapshot(batchState),
2139
2196
  },
@@ -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";
@@ -4,6 +4,10 @@
4
4
  */
5
5
  import { join } from "path";
6
6
  import type { ExitClassification, TaskExitDiagnostic } from "./diagnostics.js";
7
+ // TP-189 (Cluster B): single source of truth for the worker user-tools
8
+ // default literal. The constants module is import-free so this does NOT
9
+ // create a cycle (types.ts -> tool-allowlist-constants.ts is a leaf).
10
+ import { DEFAULT_WORKER_USER_TOOLS } from "./tool-allowlist-constants.ts";
7
11
 
8
12
  // ── Types ────────────────────────────────────────────────────────────
9
13
 
@@ -390,11 +394,12 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
390
394
  },
391
395
  merge: {
392
396
  model: "",
393
- // NOTE (TP-184): Mirrors `DEFAULT_WORKER_USER_TOOLS` in
394
- // `agent-host.ts`. Kept as a literal here because types.ts anchors
395
- // the module-import graph (agent-host.ts imports from types.ts), so
396
- // importing the constant the other direction would create a cycle.
397
- tools: "read,write,edit,bash,grep,find,ls",
397
+ // TP-189 (Cluster B): merge default sourced from the import-free
398
+ // `tool-allowlist-constants.ts` module. The previous concern about
399
+ // importing from `agent-host.ts` (which DOES depend on types.ts and
400
+ // would create a cycle) no longer applies because the constant
401
+ // lives in a leaf module that imports nothing.
402
+ tools: DEFAULT_WORKER_USER_TOOLS,
398
403
  thinking: "off",
399
404
  verify: [],
400
405
  order: "fewest-files-first",
@@ -1805,8 +1810,13 @@ export type Tier0RecoveryPattern =
1805
1810
  *
1806
1811
  * These are transient failures where re-running the task has a reasonable
1807
1812
  * chance of success. Classifications NOT in this set (e.g., user_killed,
1808
- * stall_timeout, context_overflow) indicate persistent problems that
1809
- * won't be fixed by retrying.
1813
+ * stall_timeout, context_overflow, spawn_failure) indicate persistent
1814
+ * problems that won't be fixed by retrying.
1815
+ *
1816
+ * **TP-190 (#561):** `spawn_failure` is intentionally excluded — spawn-stage
1817
+ * errors (Pi CLI not findable, worktree provisioning failure, branch
1818
+ * collision) are never transient and require operator action. Retrying
1819
+ * silently would just burn the retry budget and delay the alert.
1810
1820
  *
1811
1821
  * @since TP-039
1812
1822
  */
@@ -2127,6 +2137,25 @@ export interface SupervisorAlertContext {
2127
2137
  waveIndex?: number;
2128
2138
  /** Exit reason string (for task-failure alerts) */
2129
2139
  exitReason?: string;
2140
+ /**
2141
+ * Structured exit category for task-failure alerts.
2142
+ *
2143
+ * Mirrors `LaneTaskOutcome.exitDiagnostic.classification` for IPC
2144
+ * consumption by the supervisor. Optional for backward compatibility
2145
+ * — absent when the engine produces a task-failure alert without
2146
+ * structured diagnostic data.
2147
+ *
2148
+ * Notable values consumed by the supervisor playbook:
2149
+ * - `"spawn_failure"` (TP-190, #561): worker process never spawned
2150
+ * (Pi CLI not findable, worktree provisioning error, etc.). Never
2151
+ * transient — the playbook MUST escalate immediately rather than
2152
+ * retry. When the post-wave phase-transition logic detects an
2153
+ * all-spawn-failed wave it also flips `batchState.phase` to
2154
+ * `"failed"`; that transition is independent of this alert.
2155
+ *
2156
+ * @since TP-190 (#561)
2157
+ */
2158
+ exitCategory?: ExitClassification;
2130
2159
  /** Segment frontier snapshot for task-failure diagnosis */
2131
2160
  segmentFrontier?: SupervisorSegmentFrontierSnapshot;
2132
2161
  /** Agent ID (for agent-message alerts) */
@@ -2189,6 +2218,30 @@ export interface SupervisorAlert {
2189
2218
  */
2190
2219
  export type SupervisorAlertCallback = (alert: SupervisorAlert) => void;
2191
2220
 
2221
+ /**
2222
+ * Information about a lane that has just reached a terminal state.
2223
+ *
2224
+ * Emitted at the no-progress kill and hard-fail decision points so the
2225
+ * supervisor process can mark the lane as terminated and drop any further
2226
+ * alerts queued for it (see {@link LaneTerminatedCallback}).
2227
+ *
2228
+ * @since TP-187 (#538)
2229
+ */
2230
+ export interface LaneTerminatedInfo {
2231
+ laneNumber: number;
2232
+ agentId: string;
2233
+ batchId: string;
2234
+ terminatedAt: number;
2235
+ reason: "no-progress-kill" | "hard-fail" | "supervisor-takeover";
2236
+ }
2237
+
2238
+ /**
2239
+ * Callback invoked when a lane reaches a terminal state.
2240
+ *
2241
+ * @since TP-187 (#538)
2242
+ */
2243
+ export type LaneTerminatedCallback = (info: LaneTerminatedInfo) => void;
2244
+
2192
2245
  /**
2193
2246
  * Build a batch progress snapshot from runtime state.
2194
2247
  *
@@ -1901,7 +1901,11 @@ export function runPreflight(config: OrchestratorConfig, repoRoot?: string): Pre
1901
1901
  switch (piResult.errorKind) {
1902
1902
  case "not-found":
1903
1903
  message = "Pi not found on PATH";
1904
- hint = "Install Pi: npm install -g @mariozechner/pi-coding-agent";
1904
+ // Issue #560: Pi was renamed from @mariozechner to @earendil-works
1905
+ // in v0.74.0. Recommend the new scope for new installs; the legacy
1906
+ // scope still resolves at runtime via Pi's bundled aliasing if a
1907
+ // transitional install has it.
1908
+ hint = "Install Pi: npm install -g @earendil-works/pi-coding-agent (legacy: @mariozechner/pi-coding-agent)";
1905
1909
  break;
1906
1910
  case "timeout":
1907
1911
  message = `Pi did not respond within ${PI_PREFLIGHT_TIMEOUT_MS / 1000}s (retried once)`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.28.8",
3
+ "version": "0.29.1",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",