taskplane 0.29.2 → 0.30.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.
Files changed (43) hide show
  1. package/bin/gitignore-patterns.mjs +11 -8
  2. package/bin/rpc-wrapper.mjs +410 -357
  3. package/bin/taskplane.mjs +533 -250
  4. package/dashboard/public/app.js +124 -15
  5. package/dashboard/public/style.css +83 -2
  6. package/extensions/reviewer-extension.ts +17 -11
  7. package/extensions/taskplane/abort.ts +50 -18
  8. package/extensions/taskplane/agent-bridge-extension.ts +232 -105
  9. package/extensions/taskplane/agent-host.ts +224 -97
  10. package/extensions/taskplane/cleanup.ts +71 -42
  11. package/extensions/taskplane/config-loader.ts +142 -58
  12. package/extensions/taskplane/config-schema.ts +6 -13
  13. package/extensions/taskplane/config.ts +10 -2
  14. package/extensions/taskplane/diagnostic-reports.ts +59 -47
  15. package/extensions/taskplane/diagnostics.ts +13 -13
  16. package/extensions/taskplane/discovery.ts +78 -63
  17. package/extensions/taskplane/engine-worker.ts +53 -46
  18. package/extensions/taskplane/engine.ts +1760 -602
  19. package/extensions/taskplane/execution.ts +469 -207
  20. package/extensions/taskplane/extension.ts +1073 -598
  21. package/extensions/taskplane/formatting.ts +136 -124
  22. package/extensions/taskplane/git.ts +0 -2
  23. package/extensions/taskplane/lane-runner.ts +652 -319
  24. package/extensions/taskplane/mailbox.ts +57 -49
  25. package/extensions/taskplane/merge.ts +662 -383
  26. package/extensions/taskplane/messages.ts +109 -51
  27. package/extensions/taskplane/migrations.ts +1 -1
  28. package/extensions/taskplane/path-resolver.ts +8 -9
  29. package/extensions/taskplane/persistence.ts +425 -262
  30. package/extensions/taskplane/process-registry.ts +36 -7
  31. package/extensions/taskplane/quality-gate.ts +107 -55
  32. package/extensions/taskplane/resume.ts +832 -280
  33. package/extensions/taskplane/sessions.ts +1 -1
  34. package/extensions/taskplane/settings-tui.ts +505 -164
  35. package/extensions/taskplane/sidecar-telemetry.ts +25 -10
  36. package/extensions/taskplane/supervisor.ts +477 -270
  37. package/extensions/taskplane/task-executor-core.ts +178 -53
  38. package/extensions/taskplane/types.ts +209 -108
  39. package/extensions/taskplane/verification.ts +27 -22
  40. package/extensions/taskplane/waves.ts +59 -43
  41. package/extensions/taskplane/workspace.ts +14 -12
  42. package/extensions/taskplane/worktree.ts +218 -196
  43. package/package.json +14 -2
@@ -2,13 +2,50 @@
2
2
  * State persistence, serialization, orphan detection
3
3
  * @module orch/persistence
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync, readdirSync, statSync } from "fs";
5
+ import {
6
+ readFileSync,
7
+ writeFileSync,
8
+ existsSync,
9
+ unlinkSync,
10
+ renameSync,
11
+ mkdirSync,
12
+ appendFileSync,
13
+ readdirSync,
14
+ statSync,
15
+ } from "fs";
6
16
  import { join, dirname, basename } from "path";
7
17
 
8
18
  import { execLog } from "./execution.ts";
9
- import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics, runtimeRoot, runtimeManifestPath } from "./types.ts";
19
+ import {
20
+ BATCH_STATE_SCHEMA_VERSION,
21
+ StateFileError,
22
+ batchStatePath,
23
+ BATCH_HISTORY_MAX_ENTRIES,
24
+ defaultResilienceState,
25
+ defaultBatchDiagnostics,
26
+ runtimeRoot,
27
+ runtimeManifestPath,
28
+ } from "./types.ts";
10
29
  import type { BatchHistorySummary, RuntimeAgentManifest } from "./types.ts";
11
- import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
30
+ import type {
31
+ AllocatedLane,
32
+ DiscoveryResult,
33
+ EngineEvent,
34
+ EscalationContext,
35
+ LaneTaskOutcome,
36
+ LaneTaskStatus,
37
+ MonitorState,
38
+ OrchBatchPhase,
39
+ OrchBatchRuntimeState,
40
+ PersistedBatchState,
41
+ PersistedLaneRecord,
42
+ PersistedMergeResult,
43
+ PersistedSegmentRecord,
44
+ PersistedTaskRecord,
45
+ TaskMonitorSnapshot,
46
+ Tier0RecoveryPattern,
47
+ WorkspaceMode,
48
+ } from "./types.ts";
12
49
  import { sleepSync } from "./worktree.ts";
13
50
  import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
14
51
  import { normalizeLaneSessionAlias, readLaneSessionAliases } from "./tmux-compat.ts";
@@ -53,23 +90,28 @@ export function hasTaskDoneMarker(taskFolder: string): boolean {
53
90
  /**
54
91
  * Compare optional embedded outcome telemetry.
55
92
  */
56
- function sameOutcomeTelemetry(a: LaneTaskOutcome["telemetry"], b: LaneTaskOutcome["telemetry"]): boolean {
93
+ function sameOutcomeTelemetry(
94
+ a: LaneTaskOutcome["telemetry"],
95
+ b: LaneTaskOutcome["telemetry"],
96
+ ): boolean {
57
97
  if (!a && !b) return true;
58
98
  if (!a || !b) return false;
59
- return a.inputTokens === b.inputTokens
60
- && a.outputTokens === b.outputTokens
61
- && a.cacheReadTokens === b.cacheReadTokens
62
- && a.cacheWriteTokens === b.cacheWriteTokens
63
- && a.costUsd === b.costUsd
64
- && a.toolCalls === b.toolCalls
65
- && a.durationMs === b.durationMs;
99
+ return (
100
+ a.inputTokens === b.inputTokens &&
101
+ a.outputTokens === b.outputTokens &&
102
+ a.cacheReadTokens === b.cacheReadTokens &&
103
+ a.cacheWriteTokens === b.cacheWriteTokens &&
104
+ a.costUsd === b.costUsd &&
105
+ a.toolCalls === b.toolCalls &&
106
+ a.durationMs === b.durationMs
107
+ );
66
108
  }
67
109
 
68
110
  /**
69
111
  * Upsert a task outcome in-place. Returns true if changed.
70
112
  */
71
113
  export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOutcome): boolean {
72
- const idx = outcomes.findIndex(o => o.taskId === next.taskId);
114
+ const idx = outcomes.findIndex((o) => o.taskId === next.taskId);
73
115
  if (idx < 0) {
74
116
  outcomes.push(next);
75
117
  return true;
@@ -120,7 +162,7 @@ export function applyPartialProgressToOutcomes(
120
162
  let updated = 0;
121
163
  for (const r of ppResult.results) {
122
164
  if (!r.saved || !r.savedBranch) continue;
123
- const outcome = outcomes.find(o => o.taskId === r.taskId);
165
+ const outcome = outcomes.find((o) => o.taskId === r.taskId);
124
166
  if (outcome) {
125
167
  outcome.partialProgressCommits = r.commitCount;
126
168
  outcome.partialProgressBranch = r.savedBranch;
@@ -143,18 +185,19 @@ export function seedPendingOutcomesForAllocatedLanes(
143
185
  let changed = false;
144
186
  for (const lane of lanes) {
145
187
  for (const laneTask of lane.tasks) {
146
- const existing = outcomes.find(o => o.taskId === laneTask.taskId);
188
+ const existing = outcomes.find((o) => o.taskId === laneTask.taskId);
147
189
  if (existing) continue;
148
- changed = upsertTaskOutcome(outcomes, {
149
- taskId: laneTask.taskId,
150
- status: "pending",
151
- startTime: null,
152
- endTime: null,
153
- exitReason: "Pending execution",
154
- sessionName: lane.laneSessionId,
155
- doneFileFound: false,
156
- laneNumber: lane.laneNumber,
157
- }) || changed;
190
+ changed =
191
+ upsertTaskOutcome(outcomes, {
192
+ taskId: laneTask.taskId,
193
+ status: "pending",
194
+ startTime: null,
195
+ endTime: null,
196
+ exitReason: "Pending execution",
197
+ sessionName: lane.laneSessionId,
198
+ doneFileFound: false,
199
+ laneNumber: lane.laneNumber,
200
+ }) || changed;
158
201
  }
159
202
  }
160
203
  return changed;
@@ -175,70 +218,78 @@ export function syncTaskOutcomesFromMonitor(
175
218
  for (const lane of monitorState.lanes) {
176
219
  // Remaining tasks => pending
177
220
  for (const taskId of lane.remainingTasks) {
178
- const existing = outcomes.find(o => o.taskId === taskId);
179
- if (existing && (existing.status === "succeeded" || existing.status === "failed" || existing.status === "stalled")) {
221
+ const existing = outcomes.find((o) => o.taskId === taskId);
222
+ if (
223
+ existing &&
224
+ (existing.status === "succeeded" ||
225
+ existing.status === "failed" ||
226
+ existing.status === "stalled")
227
+ ) {
180
228
  continue;
181
229
  }
182
- changed = upsertTaskOutcome(outcomes, {
183
- taskId,
184
- status: "pending",
185
- startTime: existing?.startTime ?? null,
186
- endTime: null,
187
- exitReason: existing?.exitReason || "Pending execution",
188
- sessionName: existing?.sessionName || lane.sessionName,
189
- doneFileFound: false,
190
- laneNumber: existing?.laneNumber ?? lane.laneNumber,
191
- telemetry: existing?.telemetry,
192
- partialProgressCommits: existing?.partialProgressCommits,
193
- partialProgressBranch: existing?.partialProgressBranch,
194
- exitDiagnostic: existing?.exitDiagnostic,
195
- }) || changed;
230
+ changed =
231
+ upsertTaskOutcome(outcomes, {
232
+ taskId,
233
+ status: "pending",
234
+ startTime: existing?.startTime ?? null,
235
+ endTime: null,
236
+ exitReason: existing?.exitReason || "Pending execution",
237
+ sessionName: existing?.sessionName || lane.sessionName,
238
+ doneFileFound: false,
239
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
240
+ telemetry: existing?.telemetry,
241
+ partialProgressCommits: existing?.partialProgressCommits,
242
+ partialProgressBranch: existing?.partialProgressBranch,
243
+ exitDiagnostic: existing?.exitDiagnostic,
244
+ }) || changed;
196
245
  }
197
246
 
198
247
  // Completed tasks => succeeded
199
248
  // Use existing endTime if already set — prevents changed=true on every
200
249
  // poll tick (lastPollTime differs each tick, causing persist log spam).
201
250
  for (const taskId of lane.completedTasks) {
202
- const existing = outcomes.find(o => o.taskId === taskId);
203
- changed = upsertTaskOutcome(outcomes, {
204
- taskId,
205
- status: "succeeded",
206
- startTime: existing?.startTime ?? null,
207
- endTime: existing?.endTime ?? monitorState.lastPollTime,
208
- exitReason: existing?.exitReason || ".DONE file created by task-runner",
209
- sessionName: existing?.sessionName || lane.sessionName,
210
- doneFileFound: true,
211
- laneNumber: existing?.laneNumber ?? lane.laneNumber,
212
- telemetry: existing?.telemetry,
213
- partialProgressCommits: existing?.partialProgressCommits,
214
- partialProgressBranch: existing?.partialProgressBranch,
215
- exitDiagnostic: existing?.exitDiagnostic,
216
- }) || changed;
251
+ const existing = outcomes.find((o) => o.taskId === taskId);
252
+ changed =
253
+ upsertTaskOutcome(outcomes, {
254
+ taskId,
255
+ status: "succeeded",
256
+ startTime: existing?.startTime ?? null,
257
+ endTime: existing?.endTime ?? monitorState.lastPollTime,
258
+ exitReason: existing?.exitReason || ".DONE file created by task-runner",
259
+ sessionName: existing?.sessionName || lane.sessionName,
260
+ doneFileFound: true,
261
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
262
+ telemetry: existing?.telemetry,
263
+ partialProgressCommits: existing?.partialProgressCommits,
264
+ partialProgressBranch: existing?.partialProgressBranch,
265
+ exitDiagnostic: existing?.exitDiagnostic,
266
+ }) || changed;
217
267
  }
218
268
 
219
269
  // Failed tasks => failed
220
270
  for (const taskId of lane.failedTasks) {
221
- const existing = outcomes.find(o => o.taskId === taskId);
222
- changed = upsertTaskOutcome(outcomes, {
223
- taskId,
224
- status: "failed",
225
- startTime: existing?.startTime ?? null,
226
- endTime: existing?.endTime ?? monitorState.lastPollTime,
227
- exitReason: existing?.exitReason || "Task failed or stalled",
228
- sessionName: existing?.sessionName || lane.sessionName,
229
- doneFileFound: false,
230
- laneNumber: existing?.laneNumber ?? lane.laneNumber,
231
- telemetry: existing?.telemetry,
232
- partialProgressCommits: existing?.partialProgressCommits,
233
- partialProgressBranch: existing?.partialProgressBranch,
234
- exitDiagnostic: existing?.exitDiagnostic,
235
- }) || changed;
271
+ const existing = outcomes.find((o) => o.taskId === taskId);
272
+ changed =
273
+ upsertTaskOutcome(outcomes, {
274
+ taskId,
275
+ status: "failed",
276
+ startTime: existing?.startTime ?? null,
277
+ endTime: existing?.endTime ?? monitorState.lastPollTime,
278
+ exitReason: existing?.exitReason || "Task failed or stalled",
279
+ sessionName: existing?.sessionName || lane.sessionName,
280
+ doneFileFound: false,
281
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
282
+ telemetry: existing?.telemetry,
283
+ partialProgressCommits: existing?.partialProgressCommits,
284
+ partialProgressBranch: existing?.partialProgressBranch,
285
+ exitDiagnostic: existing?.exitDiagnostic,
286
+ }) || changed;
236
287
  }
237
288
 
238
289
  // Current task snapshot => running/stalled/succeeded/failed/skipped
239
290
  if (lane.currentTaskId && lane.currentTaskSnapshot) {
240
291
  const snap = lane.currentTaskSnapshot;
241
- const existing = outcomes.find(o => o.taskId === lane.currentTaskId);
292
+ const existing = outcomes.find((o) => o.taskId === lane.currentTaskId);
242
293
  const monitorToLane: Record<TaskMonitorSnapshot["status"], LaneTaskStatus> = {
243
294
  pending: "pending",
244
295
  running: "running",
@@ -249,26 +300,35 @@ export function syncTaskOutcomesFromMonitor(
249
300
  unknown: existing?.status || "running",
250
301
  };
251
302
  const mappedStatus = monitorToLane[snap.status];
252
- const terminal = mappedStatus === "succeeded" || mappedStatus === "failed" || mappedStatus === "stalled" || mappedStatus === "skipped";
303
+ const terminal =
304
+ mappedStatus === "succeeded" ||
305
+ mappedStatus === "failed" ||
306
+ mappedStatus === "stalled" ||
307
+ mappedStatus === "skipped";
253
308
 
254
309
  // TP-051: Use snap.observedAt (Date.now() from monitor poll) instead of
255
310
  // snap.lastHeartbeat (STATUS.md mtime) for task start time. The mtime
256
311
  // reflects when STATUS.md was last edited, which may be long before
257
312
  // actual execution started (e.g., during task staging).
258
- changed = upsertTaskOutcome(outcomes, {
259
- taskId: lane.currentTaskId,
260
- status: mappedStatus,
261
- startTime: existing?.startTime ?? snap.observedAt,
262
- endTime: terminal ? (existing?.endTime ?? snap.observedAt) : null,
263
- exitReason: existing?.exitReason || (mappedStatus === "running" ? "Task in progress" : (snap.stallReason || "Task reached terminal state")),
264
- sessionName: existing?.sessionName || lane.sessionName,
265
- doneFileFound: snap.doneFileFound,
266
- laneNumber: existing?.laneNumber ?? lane.laneNumber,
267
- telemetry: existing?.telemetry,
268
- partialProgressCommits: existing?.partialProgressCommits,
269
- partialProgressBranch: existing?.partialProgressBranch,
270
- exitDiagnostic: existing?.exitDiagnostic,
271
- }) || changed;
313
+ changed =
314
+ upsertTaskOutcome(outcomes, {
315
+ taskId: lane.currentTaskId,
316
+ status: mappedStatus,
317
+ startTime: existing?.startTime ?? snap.observedAt,
318
+ endTime: terminal ? (existing?.endTime ?? snap.observedAt) : null,
319
+ exitReason:
320
+ existing?.exitReason ||
321
+ (mappedStatus === "running"
322
+ ? "Task in progress"
323
+ : snap.stallReason || "Task reached terminal state"),
324
+ sessionName: existing?.sessionName || lane.sessionName,
325
+ doneFileFound: snap.doneFileFound,
326
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
327
+ telemetry: existing?.telemetry,
328
+ partialProgressCommits: existing?.partialProgressCommits,
329
+ partialProgressBranch: existing?.partialProgressBranch,
330
+ exitDiagnostic: existing?.exitDiagnostic,
331
+ }) || changed;
272
332
  }
273
333
  }
274
334
 
@@ -322,13 +382,19 @@ export function persistRuntimeState(
322
382
  if ((taskRecord as any).packetRepoId === undefined && parsedTask.packetRepoId !== undefined) {
323
383
  (taskRecord as any).packetRepoId = parsedTask.packetRepoId;
324
384
  }
325
- if ((taskRecord as any).packetTaskPath === undefined && parsedTask.packetTaskPath !== undefined) {
385
+ if (
386
+ (taskRecord as any).packetTaskPath === undefined &&
387
+ parsedTask.packetTaskPath !== undefined
388
+ ) {
326
389
  (taskRecord as any).packetTaskPath = parsedTask.packetTaskPath;
327
390
  }
328
391
  if ((taskRecord as any).segmentIds === undefined && parsedTask.segmentIds !== undefined) {
329
392
  (taskRecord as any).segmentIds = parsedTask.segmentIds;
330
393
  }
331
- if ((taskRecord as any).activeSegmentId === undefined && parsedTask.activeSegmentId !== undefined) {
394
+ if (
395
+ (taskRecord as any).activeSegmentId === undefined &&
396
+ parsedTask.activeSegmentId !== undefined
397
+ ) {
332
398
  (taskRecord as any).activeSegmentId = parsedTask.activeSegmentId;
333
399
  }
334
400
  }
@@ -344,9 +410,12 @@ export function persistRuntimeState(
344
410
  waveIndex: batchState.currentWaveIndex,
345
411
  });
346
412
  } catch (err: unknown) {
347
- const msg = err instanceof StateFileError
348
- ? `[${err.code}] ${err.message}`
349
- : (err instanceof Error ? err.message : String(err));
413
+ const msg =
414
+ err instanceof StateFileError
415
+ ? `[${err.code}] ${err.message}`
416
+ : err instanceof Error
417
+ ? err.message
418
+ : String(err);
350
419
  execLog("state", batchState.batchId, `write failed: ${msg}`, {
351
420
  reason,
352
421
  phase: batchState.phase,
@@ -355,22 +424,36 @@ export function persistRuntimeState(
355
424
  }
356
425
  }
357
426
 
358
-
359
427
  // ── State Validation ─────────────────────────────────────────────────
360
428
 
361
429
  /** All valid OrchBatchPhase values for validation. */
362
430
  export const VALID_BATCH_PHASES: ReadonlySet<string> = new Set([
363
- "idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed",
431
+ "idle",
432
+ "launching",
433
+ "planning",
434
+ "executing",
435
+ "merging",
436
+ "paused",
437
+ "stopped",
438
+ "completed",
439
+ "failed",
364
440
  ]);
365
441
 
366
442
  /** All valid LaneTaskStatus values for validation. */
367
443
  export const VALID_TASK_STATUSES: ReadonlySet<string> = new Set([
368
- "pending", "running", "succeeded", "failed", "stalled", "skipped",
444
+ "pending",
445
+ "running",
446
+ "succeeded",
447
+ "failed",
448
+ "stalled",
449
+ "skipped",
369
450
  ]);
370
451
 
371
452
  /** All valid merge result statuses for persisted state. */
372
453
  export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet<string> = new Set([
373
- "succeeded", "failed", "partial",
454
+ "succeeded",
455
+ "failed",
456
+ "partial",
374
457
  ]);
375
458
 
376
459
  /**
@@ -462,10 +545,7 @@ export function upconvertV3toV4(obj: Record<string, unknown>): void {
462
545
  */
463
546
  export function validatePersistedState(data: unknown): PersistedBatchState {
464
547
  if (!data || typeof data !== "object") {
465
- throw new StateFileError(
466
- "STATE_SCHEMA_INVALID",
467
- "Batch state must be a non-null object",
468
- );
548
+ throw new StateFileError("STATE_SCHEMA_INVALID", "Batch state must be a non-null object");
469
549
  }
470
550
 
471
551
  const obj = data as Record<string, unknown>;
@@ -484,8 +564,8 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
484
564
  throw new StateFileError(
485
565
  "STATE_SCHEMA_INVALID",
486
566
  `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). ` +
487
- `Upgrade taskplane to a version that supports schema v${obj.schemaVersion}, ` +
488
- `or delete .pi/batch-state.json and re-run the batch.`,
567
+ `Upgrade taskplane to a version that supports schema v${obj.schemaVersion}, ` +
568
+ `or delete .pi/batch-state.json and re-run the batch.`,
489
569
  );
490
570
  }
491
571
  const isV1 = obj.schemaVersion === 1;
@@ -552,8 +632,15 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
552
632
 
553
633
  // ── Required number fields ───────────────────────────────────
554
634
  for (const field of [
555
- "startedAt", "updatedAt", "currentWaveIndex", "totalWaves",
556
- "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
635
+ "startedAt",
636
+ "updatedAt",
637
+ "currentWaveIndex",
638
+ "totalWaves",
639
+ "totalTasks",
640
+ "succeededTasks",
641
+ "failedTasks",
642
+ "skippedTasks",
643
+ "blockedTasks",
557
644
  ] as const) {
558
645
  if (typeof obj[field] !== "number") {
559
646
  throw new StateFileError(
@@ -572,7 +659,14 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
572
659
  }
573
660
 
574
661
  // ── Required arrays ──────────────────────────────────────────
575
- for (const field of ["wavePlan", "lanes", "tasks", "mergeResults", "blockedTaskIds", "errors"] as const) {
662
+ for (const field of [
663
+ "wavePlan",
664
+ "lanes",
665
+ "tasks",
666
+ "mergeResults",
667
+ "blockedTaskIds",
668
+ "errors",
669
+ ] as const) {
576
670
  if (!Array.isArray(obj[field])) {
577
671
  throw new StateFileError(
578
672
  "STATE_SCHEMA_INVALID",
@@ -585,10 +679,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
585
679
  const wavePlan = obj.wavePlan as unknown[];
586
680
  for (let i = 0; i < wavePlan.length; i++) {
587
681
  if (!Array.isArray(wavePlan[i])) {
588
- throw new StateFileError(
589
- "STATE_SCHEMA_INVALID",
590
- `wavePlan[${i}] is not an array`,
591
- );
682
+ throw new StateFileError("STATE_SCHEMA_INVALID", `wavePlan[${i}] is not an array`);
592
683
  }
593
684
  for (const taskId of wavePlan[i] as unknown[]) {
594
685
  if (typeof taskId !== "string") {
@@ -605,10 +696,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
605
696
  for (let i = 0; i < tasks.length; i++) {
606
697
  const t = tasks[i] as Record<string, unknown>;
607
698
  if (!t || typeof t !== "object") {
608
- throw new StateFileError(
609
- "STATE_SCHEMA_INVALID",
610
- `tasks[${i}] is not an object`,
611
- );
699
+ throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}] is not an object`);
612
700
  }
613
701
  for (const field of ["taskId", "sessionName", "taskFolder", "exitReason"] as const) {
614
702
  if (typeof t[field] !== "string") {
@@ -637,10 +725,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
637
725
  );
638
726
  }
639
727
  if (t.endedAt !== null && typeof t.endedAt !== "number") {
640
- throw new StateFileError(
641
- "STATE_SCHEMA_INVALID",
642
- `tasks[${i}].endedAt is not a number or null`,
643
- );
728
+ throw new StateFileError("STATE_SCHEMA_INVALID", `tasks[${i}].endedAt is not a number or null`);
644
729
  }
645
730
  if (typeof t.doneFileFound !== "boolean") {
646
731
  throw new StateFileError(
@@ -676,7 +761,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
676
761
  }
677
762
  // TP-026 optional field: exitDiagnostic (object with classification string | undefined)
678
763
  if (t.exitDiagnostic !== undefined) {
679
- if (typeof t.exitDiagnostic !== "object" || t.exitDiagnostic === null || Array.isArray(t.exitDiagnostic)) {
764
+ if (
765
+ typeof t.exitDiagnostic !== "object" ||
766
+ t.exitDiagnostic === null ||
767
+ Array.isArray(t.exitDiagnostic)
768
+ ) {
680
769
  throw new StateFileError(
681
770
  "STATE_SCHEMA_INVALID",
682
771
  `tasks[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(t.exitDiagnostic) ? "array" : typeof t.exitDiagnostic})`,
@@ -697,10 +786,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
697
786
  for (let i = 0; i < lanes.length; i++) {
698
787
  const l = lanes[i] as Record<string, unknown>;
699
788
  if (!l || typeof l !== "object") {
700
- throw new StateFileError(
701
- "STATE_SCHEMA_INVALID",
702
- `lanes[${i}] is not an object`,
703
- );
789
+ throw new StateFileError("STATE_SCHEMA_INVALID", `lanes[${i}] is not an object`);
704
790
  }
705
791
  for (const field of ["laneId", "worktreePath", "branch"] as const) {
706
792
  if (typeof l[field] !== "string") {
@@ -763,7 +849,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
763
849
  if (legacyTmuxSessionLaneIndexes.length > 0) {
764
850
  console.error(
765
851
  "[taskplane] migration: detected legacy lanes[].tmuxSessionName in .pi/batch-state.json; " +
766
- "normalized to lanes[].laneSessionId for this release. Re-save state (or re-run /orch-resume) to persist canonical fields.",
852
+ "normalized to lanes[].laneSessionId for this release. Re-save state (or re-run /orch-resume) to persist canonical fields.",
767
853
  );
768
854
  }
769
855
 
@@ -772,10 +858,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
772
858
  for (let i = 0; i < mergeResults.length; i++) {
773
859
  const m = mergeResults[i] as Record<string, unknown>;
774
860
  if (!m || typeof m !== "object") {
775
- throw new StateFileError(
776
- "STATE_SCHEMA_INVALID",
777
- `mergeResults[${i}] is not an object`,
778
- );
861
+ throw new StateFileError("STATE_SCHEMA_INVALID", `mergeResults[${i}] is not an object`);
779
862
  }
780
863
  if (typeof m.waveIndex !== "number") {
781
864
  throw new StateFileError(
@@ -824,10 +907,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
824
907
  // ── Validate lastError ───────────────────────────────────────
825
908
  if (obj.lastError !== null) {
826
909
  if (typeof obj.lastError !== "object") {
827
- throw new StateFileError(
828
- "STATE_SCHEMA_INVALID",
829
- `lastError is not an object or null`,
830
- );
910
+ throw new StateFileError("STATE_SCHEMA_INVALID", `lastError is not an object or null`);
831
911
  }
832
912
  const le = obj.lastError as Record<string, unknown>;
833
913
  if (typeof le.code !== "string" || typeof le.message !== "string") {
@@ -881,7 +961,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
881
961
  `resilience.resumeForced must be a boolean (got ${typeof res.resumeForced})`,
882
962
  );
883
963
  }
884
- if (!res.retryCountByScope || typeof res.retryCountByScope !== "object" || Array.isArray(res.retryCountByScope)) {
964
+ if (
965
+ !res.retryCountByScope ||
966
+ typeof res.retryCountByScope !== "object" ||
967
+ Array.isArray(res.retryCountByScope)
968
+ ) {
885
969
  throw new StateFileError(
886
970
  "STATE_SCHEMA_INVALID",
887
971
  `resilience.retryCountByScope must be an object (got ${typeof res.retryCountByScope})`,
@@ -1064,7 +1148,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
1064
1148
  }
1065
1149
  }
1066
1150
  // v4 optional field: activeSegmentId (string | null | undefined)
1067
- if (t.activeSegmentId !== undefined && t.activeSegmentId !== null && typeof t.activeSegmentId !== "string") {
1151
+ if (
1152
+ t.activeSegmentId !== undefined &&
1153
+ t.activeSegmentId !== null &&
1154
+ typeof t.activeSegmentId !== "string"
1155
+ ) {
1068
1156
  throw new StateFileError(
1069
1157
  "STATE_SCHEMA_INVALID",
1070
1158
  `tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`,
@@ -1083,13 +1171,19 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
1083
1171
  for (let i = 0; i < segments.length; i++) {
1084
1172
  const s = segments[i] as Record<string, unknown>;
1085
1173
  if (!s || typeof s !== "object") {
1086
- throw new StateFileError(
1087
- "STATE_SCHEMA_INVALID",
1088
- `segments[${i}] is not an object`,
1089
- );
1174
+ throw new StateFileError("STATE_SCHEMA_INVALID", `segments[${i}] is not an object`);
1090
1175
  }
1091
1176
  // Required string fields
1092
- for (const field of ["segmentId", "taskId", "repoId", "laneId", "sessionName", "worktreePath", "branch", "exitReason"] as const) {
1177
+ for (const field of [
1178
+ "segmentId",
1179
+ "taskId",
1180
+ "repoId",
1181
+ "laneId",
1182
+ "sessionName",
1183
+ "worktreePath",
1184
+ "branch",
1185
+ "exitReason",
1186
+ ] as const) {
1093
1187
  if (typeof s[field] !== "string") {
1094
1188
  throw new StateFileError(
1095
1189
  "STATE_SCHEMA_INVALID",
@@ -1153,7 +1247,11 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
1153
1247
  }
1154
1248
  // Optional exitDiagnostic
1155
1249
  if (s.exitDiagnostic !== undefined) {
1156
- if (!s.exitDiagnostic || typeof s.exitDiagnostic !== "object" || Array.isArray(s.exitDiagnostic)) {
1250
+ if (
1251
+ !s.exitDiagnostic ||
1252
+ typeof s.exitDiagnostic !== "object" ||
1253
+ Array.isArray(s.exitDiagnostic)
1254
+ ) {
1157
1255
  throw new StateFileError(
1158
1256
  "STATE_SCHEMA_INVALID",
1159
1257
  `segments[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(s.exitDiagnostic) ? "array" : typeof s.exitDiagnostic})`,
@@ -1173,12 +1271,31 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
1173
1271
  // serialization. This protects against data loss from future schema
1174
1272
  // extensions or external tools writing additional fields.
1175
1273
  const KNOWN_TOP_LEVEL_FIELDS = new Set([
1176
- "schemaVersion", "phase", "batchId", "baseBranch", "orchBranch", "mode",
1177
- "startedAt", "updatedAt", "endedAt", "currentWaveIndex", "totalWaves",
1178
- "wavePlan", "lanes", "tasks", "mergeResults",
1179
- "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
1180
- "blockedTaskIds", "lastError", "errors",
1181
- "resilience", "diagnostics",
1274
+ "schemaVersion",
1275
+ "phase",
1276
+ "batchId",
1277
+ "baseBranch",
1278
+ "orchBranch",
1279
+ "mode",
1280
+ "startedAt",
1281
+ "updatedAt",
1282
+ "endedAt",
1283
+ "currentWaveIndex",
1284
+ "totalWaves",
1285
+ "wavePlan",
1286
+ "lanes",
1287
+ "tasks",
1288
+ "mergeResults",
1289
+ "totalTasks",
1290
+ "succeededTasks",
1291
+ "failedTasks",
1292
+ "skippedTasks",
1293
+ "blockedTasks",
1294
+ "blockedTaskIds",
1295
+ "lastError",
1296
+ "errors",
1297
+ "resilience",
1298
+ "diagnostics",
1182
1299
  "segments",
1183
1300
  "_extraFields",
1184
1301
  ]);
@@ -1241,69 +1358,70 @@ export function serializeBatchState(
1241
1358
  }
1242
1359
 
1243
1360
  // Build a lookup from taskId → AllocatedTask (which holds the ParsedTask with repo fields).
1244
- const allocatedTaskByTaskId = new Map<string, { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }>();
1361
+ const allocatedTaskByTaskId = new Map<
1362
+ string,
1363
+ { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }
1364
+ >();
1245
1365
  for (const lane of lanes) {
1246
1366
  for (const allocTask of lane.tasks) {
1247
1367
  allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
1248
1368
  }
1249
1369
  }
1250
1370
 
1251
- const taskRecords: PersistedTaskRecord[] = [...taskIdSet]
1252
- .sort()
1253
- .map((taskId) => {
1254
- const lane = laneByTaskId.get(taskId);
1255
- const outcome = outcomeByTaskId.get(taskId);
1256
- const allocated = allocatedTaskByTaskId.get(taskId);
1371
+ const taskRecords: PersistedTaskRecord[] = [...taskIdSet].sort().map((taskId) => {
1372
+ const lane = laneByTaskId.get(taskId);
1373
+ const outcome = outcomeByTaskId.get(taskId);
1374
+ const allocated = allocatedTaskByTaskId.get(taskId);
1257
1375
 
1258
- const record: PersistedTaskRecord = {
1259
- taskId,
1260
- laneNumber: lane?.laneNumber ?? outcome?.laneNumber ?? 0,
1261
- sessionName: outcome?.sessionName || lane?.laneSessionId || "",
1262
- status: outcome?.status ?? "pending",
1263
- taskFolder: "", // Enriched by caller from discovery
1264
- startedAt: outcome?.startTime ?? null,
1265
- endedAt: outcome?.endTime ?? null,
1266
- doneFileFound: outcome?.doneFileFound ?? false,
1267
- exitReason: outcome?.exitReason ?? "",
1268
- };
1376
+ const record: PersistedTaskRecord = {
1377
+ taskId,
1378
+ laneNumber: lane?.laneNumber ?? outcome?.laneNumber ?? 0,
1379
+ sessionName: outcome?.sessionName || lane?.laneSessionId || "",
1380
+ status: outcome?.status ?? "pending",
1381
+ taskFolder: "", // Enriched by caller from discovery
1382
+ startedAt: outcome?.startTime ?? null,
1383
+ endedAt: outcome?.endTime ?? null,
1384
+ doneFileFound: outcome?.doneFileFound ?? false,
1385
+ exitReason: outcome?.exitReason ?? "",
1386
+ };
1269
1387
 
1270
- // v2: Serialize repo-aware fields from the ParsedTask
1271
- if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
1272
- record.repoId = allocated.allocatedTask.task.promptRepoId;
1273
- }
1274
- if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
1275
- record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
1276
- }
1388
+ // v2: Serialize repo-aware fields from the ParsedTask
1389
+ if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
1390
+ record.repoId = allocated.allocatedTask.task.promptRepoId;
1391
+ }
1392
+ if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
1393
+ record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
1394
+ }
1277
1395
 
1278
- // TP-028: Serialize partial progress fields from task outcome
1279
- if (outcome?.partialProgressCommits !== undefined) {
1280
- record.partialProgressCommits = outcome.partialProgressCommits;
1281
- }
1282
- if (outcome?.partialProgressBranch !== undefined) {
1283
- record.partialProgressBranch = outcome.partialProgressBranch;
1284
- }
1396
+ // TP-028: Serialize partial progress fields from task outcome
1397
+ if (outcome?.partialProgressCommits !== undefined) {
1398
+ record.partialProgressCommits = outcome.partialProgressCommits;
1399
+ }
1400
+ if (outcome?.partialProgressBranch !== undefined) {
1401
+ record.partialProgressBranch = outcome.partialProgressBranch;
1402
+ }
1285
1403
 
1286
- // TP-030 v3: Serialize exit diagnostic from task outcome
1287
- if (outcome?.exitDiagnostic !== undefined) {
1288
- record.exitDiagnostic = outcome.exitDiagnostic;
1289
- }
1404
+ // TP-030 v3: Serialize exit diagnostic from task outcome
1405
+ if (outcome?.exitDiagnostic !== undefined) {
1406
+ record.exitDiagnostic = outcome.exitDiagnostic;
1407
+ }
1290
1408
 
1291
- // TP-081 v4: Serialize segment-level fields from ParsedTask or existing state
1292
- if (allocated?.allocatedTask.task?.packetRepoId !== undefined) {
1293
- (record as any).packetRepoId = allocated.allocatedTask.task.packetRepoId;
1294
- }
1295
- if (allocated?.allocatedTask.task?.packetTaskPath !== undefined) {
1296
- (record as any).packetTaskPath = allocated.allocatedTask.task.packetTaskPath;
1297
- }
1298
- if (allocated?.allocatedTask.task?.segmentIds !== undefined) {
1299
- (record as any).segmentIds = allocated.allocatedTask.task.segmentIds;
1300
- }
1301
- if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) {
1302
- (record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId;
1303
- }
1409
+ // TP-081 v4: Serialize segment-level fields from ParsedTask or existing state
1410
+ if (allocated?.allocatedTask.task?.packetRepoId !== undefined) {
1411
+ (record as any).packetRepoId = allocated.allocatedTask.task.packetRepoId;
1412
+ }
1413
+ if (allocated?.allocatedTask.task?.packetTaskPath !== undefined) {
1414
+ (record as any).packetTaskPath = allocated.allocatedTask.task.packetTaskPath;
1415
+ }
1416
+ if (allocated?.allocatedTask.task?.segmentIds !== undefined) {
1417
+ (record as any).segmentIds = allocated.allocatedTask.task.segmentIds;
1418
+ }
1419
+ if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) {
1420
+ (record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId;
1421
+ }
1304
1422
 
1305
- return record;
1306
- });
1423
+ return record;
1424
+ });
1307
1425
 
1308
1426
  // Build lane records
1309
1427
  const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => {
@@ -1326,26 +1444,25 @@ export function serializeBatchState(
1326
1444
  // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1").
1327
1445
  // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1,
1328
1446
  // which would produce -2 without clamping.
1329
- const mergeResults: PersistedMergeResult[] = (state.mergeResults || [])
1330
- .map((mr) => {
1331
- const record: PersistedMergeResult = {
1332
- waveIndex: Math.max(0, mr.waveIndex - 1),
1333
- status: mr.status,
1334
- failedLane: mr.failedLane,
1335
- failureReason: mr.failureReason,
1336
- };
1337
- // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode).
1338
- if (mr.repoResults && mr.repoResults.length > 0) {
1339
- record.repoResults = mr.repoResults.map((rr) => ({
1340
- repoId: rr.repoId,
1341
- status: rr.status,
1342
- laneNumbers: rr.laneResults.map((lr) => lr.laneNumber),
1343
- failedLane: rr.failedLane,
1344
- failureReason: rr.failureReason,
1345
- }));
1346
- }
1347
- return record;
1348
- });
1447
+ const mergeResults: PersistedMergeResult[] = (state.mergeResults || []).map((mr) => {
1448
+ const record: PersistedMergeResult = {
1449
+ waveIndex: Math.max(0, mr.waveIndex - 1),
1450
+ status: mr.status,
1451
+ failedLane: mr.failedLane,
1452
+ failureReason: mr.failureReason,
1453
+ };
1454
+ // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode).
1455
+ if (mr.repoResults && mr.repoResults.length > 0) {
1456
+ record.repoResults = mr.repoResults.map((rr) => ({
1457
+ repoId: rr.repoId,
1458
+ status: rr.status,
1459
+ laneNumbers: rr.laneResults.map((lr) => lr.laneNumber),
1460
+ failedLane: rr.failedLane,
1461
+ failureReason: rr.failureReason,
1462
+ }));
1463
+ }
1464
+ return record;
1465
+ });
1349
1466
 
1350
1467
  const persisted: PersistedBatchState = {
1351
1468
  schemaVersion: BATCH_STATE_SCHEMA_VERSION,
@@ -1372,9 +1489,10 @@ export function serializeBatchState(
1372
1489
  skippedTasks: state.skippedTasks,
1373
1490
  blockedTasks: state.blockedTasks,
1374
1491
  blockedTaskIds: [...state.blockedTaskIds],
1375
- lastError: state.errors.length > 0
1376
- ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] }
1377
- : null,
1492
+ lastError:
1493
+ state.errors.length > 0
1494
+ ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] }
1495
+ : null,
1378
1496
  errors: [...state.errors],
1379
1497
  resilience: state.resilience ?? defaultResilienceState(),
1380
1498
  diagnostics: state.diagnostics ?? defaultBatchDiagnostics(),
@@ -1385,7 +1503,11 @@ export function serializeBatchState(
1385
1503
  // Extra fields are placed at the end of the object (after known schema fields)
1386
1504
  // and will not overwrite any known field.
1387
1505
  if (state._extraFields) {
1388
- const output = persisted as Record<string, unknown>;
1506
+ // TP-195: 2-step `as unknown as` widening. PersistedBatchState is
1507
+ // structurally a string-keyed record at runtime; the cast lets us
1508
+ // add unknown extra fields for serialization roundtrip fidelity
1509
+ // without TypeScript requiring sufficient type overlap.
1510
+ const output = persisted as unknown as Record<string, unknown>;
1389
1511
  for (const [key, value] of Object.entries(state._extraFields)) {
1390
1512
  if (!(key in output)) {
1391
1513
  output[key] = value;
@@ -1461,12 +1583,16 @@ export function saveBatchState(json: string, repoRoot: string): void {
1461
1583
  }
1462
1584
 
1463
1585
  // All retries exhausted — clean up temp file if possible
1464
- try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
1586
+ try {
1587
+ unlinkSync(tmpPath);
1588
+ } catch {
1589
+ /* ignore cleanup errors */
1590
+ }
1465
1591
 
1466
1592
  throw new StateFileError(
1467
1593
  "STATE_FILE_IO_ERROR",
1468
1594
  `Failed to atomically save state file "${finalPath}" after ` +
1469
- `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
1595
+ `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
1470
1596
  );
1471
1597
  }
1472
1598
 
@@ -1533,7 +1659,6 @@ export function deleteBatchState(repoRoot: string): void {
1533
1659
  }
1534
1660
  }
1535
1661
 
1536
-
1537
1662
  // ── Orphan Detection (TS-009 Step 3) ─────────────────────────────────
1538
1663
 
1539
1664
  /**
@@ -1555,7 +1680,12 @@ export type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error";
1555
1680
  * - "paused-corrupt" — No orphans + corrupt/unreadable state file: do NOT auto-delete; notify user to inspect or manually remove
1556
1681
  * - "start-fresh" — No orphans, no state file: proceed normally
1557
1682
  */
1558
- export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh";
1683
+ export type OrphanRecommendedAction =
1684
+ | "resume"
1685
+ | "abort-orphans"
1686
+ | "cleanup-stale"
1687
+ | "paused-corrupt"
1688
+ | "start-fresh";
1559
1689
 
1560
1690
  /**
1561
1691
  * Result of orphan detection analysis.
@@ -1597,8 +1727,8 @@ export function parseOrchSessionNames(stdout: string, prefix: string): string[]
1597
1727
 
1598
1728
  return stdout
1599
1729
  .split("\n")
1600
- .map(line => line.trim())
1601
- .filter(name => name.length > 0 && name.startsWith(filterPrefix))
1730
+ .map((line) => line.trim())
1731
+ .filter((name) => name.length > 0 && name.startsWith(filterPrefix))
1602
1732
  .sort();
1603
1733
  }
1604
1734
 
@@ -1687,8 +1817,8 @@ export function analyzeOrchestratorStartupState(
1687
1817
 
1688
1818
  if (stateStatus === "valid" && loadedState) {
1689
1819
  // Check if all tasks completed (all have .DONE files)
1690
- const allTaskIds = loadedState.tasks.map(t => t.taskId);
1691
- const allDone = allTaskIds.length > 0 && allTaskIds.every(id => doneTaskIds.has(id));
1820
+ const allTaskIds = loadedState.tasks.map((t) => t.taskId);
1821
+ const allDone = allTaskIds.length > 0 && allTaskIds.every((id) => doneTaskIds.has(id));
1692
1822
 
1693
1823
  if (allDone) {
1694
1824
  return {
@@ -1704,7 +1834,7 @@ export function analyzeOrchestratorStartupState(
1704
1834
  }
1705
1835
 
1706
1836
  // Not all tasks done — batch was interrupted (crashed orchestrator)
1707
- const completedCount = allTaskIds.filter(id => doneTaskIds.has(id)).length;
1837
+ const completedCount = allTaskIds.filter((id) => doneTaskIds.has(id)).length;
1708
1838
 
1709
1839
  // Only phases that resumeOrchBatch can actually handle should get "resume".
1710
1840
  // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing
@@ -1734,10 +1864,10 @@ export function analyzeOrchestratorStartupState(
1734
1864
  recommendedAction: isResumable ? "resume" : "cleanup-stale",
1735
1865
  userMessage: isResumable
1736
1866
  ? `🔄 Found interrupted batch ${loadedState.batchId} (${loadedState.phase}).\n` +
1737
- ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` +
1738
- ` Use /orch-resume to continue, or /orch-abort to clean up.`
1867
+ ` ${completedCount}/${allTaskIds.length} task(s) completed.\n` +
1868
+ ` Use /orch-resume to continue, or /orch-abort to clean up.`
1739
1869
  : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` +
1740
- ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`,
1870
+ ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`,
1741
1871
  };
1742
1872
  }
1743
1873
 
@@ -1823,7 +1953,6 @@ export function detectOrphanSessions(prefix: string, repoRoot: string): OrphanDe
1823
1953
  );
1824
1954
  }
1825
1955
 
1826
-
1827
1956
  // ── Batch History ────────────────────────────────────────────────────
1828
1957
 
1829
1958
  /** Path to the batch history file. */
@@ -1858,7 +1987,7 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary)
1858
1987
  const history = loadBatchHistory(repoRoot);
1859
1988
  // Upsert by batchId so resumed batches replace their earlier partial entry
1860
1989
  // instead of creating duplicates.
1861
- const nextHistory = history.filter(entry => entry.batchId !== summary.batchId);
1990
+ const nextHistory = history.filter((entry) => entry.batchId !== summary.batchId);
1862
1991
  // Prepend newest first
1863
1992
  nextHistory.unshift(summary);
1864
1993
  // Trim to max
@@ -1884,13 +2013,21 @@ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary)
1884
2013
  *
1885
2014
  * @since TP-179
1886
2015
  */
1887
- export function updateBatchHistoryIntegration(repoRoot: string, batchId: string, integratedAt: number): void {
2016
+ export function updateBatchHistoryIntegration(
2017
+ repoRoot: string,
2018
+ batchId: string,
2019
+ integratedAt: number,
2020
+ ): void {
1888
2021
  const filePath = batchHistoryPath(repoRoot);
1889
2022
  try {
1890
2023
  const history = loadBatchHistory(repoRoot);
1891
- const entry = history.find(e => e.batchId === batchId);
2024
+ const entry = history.find((e) => e.batchId === batchId);
1892
2025
  if (!entry) {
1893
- execLog("batch", "history", `no history entry found for batchId=${batchId}, skipping integratedAt update`);
2026
+ execLog(
2027
+ "batch",
2028
+ "history",
2029
+ `no history entry found for batchId=${batchId}, skipping integratedAt update`,
2030
+ );
1894
2031
  return;
1895
2032
  }
1896
2033
  entry.integratedAt = integratedAt;
@@ -1905,7 +2042,6 @@ export function updateBatchHistoryIntegration(repoRoot: string, batchId: string,
1905
2042
  }
1906
2043
  }
1907
2044
 
1908
-
1909
2045
  // ── Tier 0 Supervisor Event Logging (TP-039 Step 2) ─────────────────
1910
2046
 
1911
2047
  /**
@@ -1986,7 +2122,10 @@ export function buildTier0EventBase(
1986
2122
  pattern: Tier0RecoveryPattern | "merge_timeout",
1987
2123
  attempt: number,
1988
2124
  maxAttempts: number,
1989
- ): Pick<Tier0Event, "timestamp" | "type" | "batchId" | "waveIndex" | "pattern" | "attempt" | "maxAttempts"> {
2125
+ ): Pick<
2126
+ Tier0Event,
2127
+ "timestamp" | "type" | "batchId" | "waveIndex" | "pattern" | "attempt" | "maxAttempts"
2128
+ > {
1990
2129
  return {
1991
2130
  timestamp: new Date().toISOString(),
1992
2131
  type,
@@ -2028,7 +2167,6 @@ export function emitTier0Event(stateRoot: string, event: Tier0Event): void {
2028
2167
  }
2029
2168
  }
2030
2169
 
2031
-
2032
2170
  // ── Engine Event Logging (TP-040) ───────────────────────────────────
2033
2171
 
2034
2172
  /**
@@ -2085,7 +2223,6 @@ export function emitEngineEvent(
2085
2223
  }
2086
2224
  }
2087
2225
 
2088
-
2089
2226
  // ── TP-187 (#539): Batch-Meta Runtime Artifact ─────────────────────
2090
2227
  //
2091
2228
  // Small JSON file written at batch-start to `.pi/runtime/<batchId>/batch-meta.json`.
@@ -2129,10 +2266,7 @@ function batchMetaPath(stateRoot: string, batchId: string): string {
2129
2266
  *
2130
2267
  * @since TP-187 (#539)
2131
2268
  */
2132
- export function saveBatchMetaRuntimeArtifact(
2133
- stateRoot: string,
2134
- artifact: BatchMetaArtifact,
2135
- ): void {
2269
+ export function saveBatchMetaRuntimeArtifact(stateRoot: string, artifact: BatchMetaArtifact): void {
2136
2270
  try {
2137
2271
  const path = batchMetaPath(stateRoot, artifact.batchId);
2138
2272
  mkdirSync(dirname(path), { recursive: true });
@@ -2144,7 +2278,11 @@ export function saveBatchMetaRuntimeArtifact(
2144
2278
  tasks: artifact.wavePlan.reduce((sum, w) => sum + w.length, 0),
2145
2279
  });
2146
2280
  } catch (err) {
2147
- execLog("state", artifact.batchId, `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`);
2281
+ execLog(
2282
+ "state",
2283
+ artifact.batchId,
2284
+ `batch-meta write failed: ${err instanceof Error ? err.message : String(err)}`,
2285
+ );
2148
2286
  }
2149
2287
  }
2150
2288
 
@@ -2184,7 +2322,6 @@ export function loadBatchMetaRuntimeArtifact(
2184
2322
  }
2185
2323
  }
2186
2324
 
2187
-
2188
2325
  // ── TP-187 (#539): Reconstruct PersistedBatchState from runtime artifacts ──
2189
2326
 
2190
2327
  /**
@@ -2195,8 +2332,19 @@ export function loadBatchMetaRuntimeArtifact(
2195
2332
  *
2196
2333
  * @since TP-187 (#539)
2197
2334
  */
2335
+ // TP-195: `error?: undefined` on the success branch makes this a well-formed
2336
+ // discriminated union under `strict: false`. Without it, `if (!result.ok)`
2337
+ // does not narrow `error` because non-strict narrowing requires every
2338
+ // member of the union to share the discriminating fields. Runtime semantics
2339
+ // unchanged — the success branch never carries an error.
2198
2340
  export type ReconstructResult =
2199
- | { ok: true; state: PersistedBatchState; batchId: string; selectionNote: string }
2341
+ | {
2342
+ ok: true;
2343
+ state: PersistedBatchState;
2344
+ batchId: string;
2345
+ selectionNote: string;
2346
+ error?: undefined;
2347
+ }
2200
2348
  | { ok: false; error: string };
2201
2349
 
2202
2350
  /**
@@ -2296,7 +2444,9 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2296
2444
  failures.push(`${cand.batchId}: no worker manifests`);
2297
2445
  continue;
2298
2446
  }
2299
- const workerManifestsWithWorktree = manifests.filter(m => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd));
2447
+ const workerManifestsWithWorktree = manifests.filter(
2448
+ (m) => typeof m.cwd === "string" && m.cwd.length > 0 && existsSync(m.cwd),
2449
+ );
2300
2450
  if (workerManifestsWithWorktree.length === 0) {
2301
2451
  failures.push(`${cand.batchId}: worktree paths from manifests no longer exist on disk`);
2302
2452
  continue;
@@ -2322,16 +2472,19 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2322
2472
  if (distinctRepoIds.size > 1) {
2323
2473
  failures.push(
2324
2474
  `${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.`
2475
+ `${[...distinctRepoIds].slice(0, 4).join(", ")}` +
2476
+ `${distinctRepoIds.size > 4 ? ", ..." : ""}); reconstruction would lose segment ` +
2477
+ `expansion state and is refused. Restore .pi/batch-state.json from backup or start a new batch.`,
2328
2478
  );
2329
2479
  continue;
2330
2480
  }
2331
2481
  }
2332
2482
 
2333
2483
  // Build per-lane aggregation from worker manifests.
2334
- const laneMap = new Map<number, { laneNumber: number; agentId: string; worktreePath: string; repoId: string; taskIds: string[] }>();
2484
+ const laneMap = new Map<
2485
+ number,
2486
+ { laneNumber: number; agentId: string; worktreePath: string; repoId: string; taskIds: string[] }
2487
+ >();
2335
2488
  for (const m of workerManifestsWithWorktree) {
2336
2489
  if (typeof m.laneNumber !== "number") continue;
2337
2490
  const lane = laneMap.get(m.laneNumber) ?? {
@@ -2373,9 +2526,12 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2373
2526
  for (const taskId of knownTaskIds) {
2374
2527
  const m = manifestByTaskId.get(taskId);
2375
2528
  const lane = m ? laneMap.get(m.laneNumber) : undefined;
2529
+ // TP-195: dropped `taskName: taskId` — not on `PersistedTaskRecord`
2530
+ // schema; no consumer reads `.taskName` from persisted records
2531
+ // (only from `ParsedTask`). Was being added via untyped property
2532
+ // bag cast that the Step 0 typecheck inventory flagged.
2376
2533
  const taskRecord: PersistedTaskRecord = {
2377
2534
  taskId,
2378
- taskName: taskId,
2379
2535
  taskFolder: m?.packet?.taskFolder ?? "",
2380
2536
  status: "pending",
2381
2537
  sessionName: m?.agentId ?? "",
@@ -2386,15 +2542,20 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2386
2542
  doneFileFound: false,
2387
2543
  };
2388
2544
  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;
2545
+ // TP-195: dropped dead reads of `m.packet.packetRepoId` /
2546
+ // `.packetTaskPath`. `m.packet` is `PacketPaths` which has only
2547
+ // `promptPath`/`statusPath`/`donePath`/`reviewsDir`/`taskFolder`
2548
+ // — the `packetRepoId`/`packetTaskPath` fields exist on
2549
+ // `PersistedTaskRecord` and `ParsedTask`, not on `PacketPaths`,
2550
+ // so these reads always returned undefined and the if-branches
2551
+ // never fired. Removed under the no-behavior-change guarantee.
2391
2552
  tasks.push(taskRecord);
2392
2553
  }
2393
2554
 
2394
2555
  // Build lane records.
2395
2556
  const lanes: PersistedLaneRecord[] = Array.from(laneMap.values())
2396
2557
  .sort((a, b) => a.laneNumber - b.laneNumber)
2397
- .map(l => {
2558
+ .map((l) => {
2398
2559
  const sessionId = l.agentId.replace(/-(worker|reviewer)$/, "");
2399
2560
  const rec: PersistedLaneRecord = {
2400
2561
  laneId: `lane-${l.laneNumber}`,
@@ -2426,7 +2587,7 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2426
2587
  failedTasks: 0,
2427
2588
  skippedTasks: 0,
2428
2589
  blockedTasks: 0,
2429
- wavePlan: meta.wavePlan.map(wave => [...wave]),
2590
+ wavePlan: meta.wavePlan.map((wave) => [...wave]),
2430
2591
  lanes,
2431
2592
  tasks,
2432
2593
  mergeResults: [],
@@ -2443,14 +2604,17 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2443
2604
  const json = JSON.stringify(reconstructed);
2444
2605
  validatePersistedState(JSON.parse(json));
2445
2606
  } catch (err) {
2446
- failures.push(`${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`);
2607
+ failures.push(
2608
+ `${cand.batchId}: reconstructed state failed validation: ${err instanceof Error ? err.message : String(err)}`,
2609
+ );
2447
2610
  continue;
2448
2611
  }
2449
2612
 
2450
2613
  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))`;
2614
+ const selectionNote =
2615
+ totalCandidates === 1
2616
+ ? `single batch in .pi/runtime/`
2617
+ : `selected from ${totalCandidates} candidate(s) by mtime newest-first (skipped ${idx} earlier candidate(s))`;
2454
2618
  return { ok: true, state: reconstructed, batchId: meta.batchId, selectionNote };
2455
2619
  }
2456
2620
 
@@ -2459,4 +2623,3 @@ export function reconstructBatchStateFromRuntime(stateRoot: string): Reconstruct
2459
2623
  error: `no reconstructable batch found in .pi/runtime/ (${failures.length} candidate(s) inspected: ${failures.slice(0, 3).join("; ")}${failures.length > 3 ? "; ..." : ""})`,
2460
2624
  };
2461
2625
  }
2462
-