taskplane 0.28.4 → 0.28.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +838 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1867
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +505 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,2087 +1,2087 @@
1
- /**
2
- * State persistence, serialization, orphan detection
3
- * @module orch/persistence
4
- */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync } from "fs";
6
- import { join, dirname, basename } from "path";
7
-
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";
11
- import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
12
- import { sleepSync } from "./worktree.ts";
13
- import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
14
- import { normalizeLaneSessionAlias, readLaneSessionAliases } from "./tmux-compat.ts";
15
-
16
- // ── State Persistence Helper (TS-009 Step 2) ────────────────────────
17
-
18
- /**
19
- * Candidate .DONE file locations for a task folder.
20
- *
21
- * Task-runner archives completed tasks by moving:
22
- * tasks/<task-folder>/ → tasks/archive/<task-folder>/
23
- *
24
- * During resume/orphan detection we must check both locations.
25
- */
26
- export function getTaskDoneFileCandidates(taskFolder: string): string[] {
27
- const candidates = [join(taskFolder, ".DONE")];
28
- const parent = dirname(taskFolder);
29
- const taskFolderName = basename(taskFolder);
30
-
31
- // If already in archive, avoid duplicate candidate.
32
- if (basename(parent).toLowerCase() !== "archive") {
33
- candidates.push(join(parent, "archive", taskFolderName, ".DONE"));
34
- }
35
-
36
- return candidates;
37
- }
38
-
39
- /**
40
- * Check whether a task has a .DONE marker in active or archived location.
41
- */
42
- export function hasTaskDoneMarker(taskFolder: string): boolean {
43
- for (const donePath of getTaskDoneFileCandidates(taskFolder)) {
44
- try {
45
- if (existsSync(donePath)) return true;
46
- } catch {
47
- // Ignore filesystem errors here; caller handles partial visibility.
48
- }
49
- }
50
- return false;
51
- }
52
-
53
- /**
54
- * Compare optional embedded outcome telemetry.
55
- */
56
- function sameOutcomeTelemetry(a: LaneTaskOutcome["telemetry"], b: LaneTaskOutcome["telemetry"]): boolean {
57
- if (!a && !b) return true;
58
- 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;
66
- }
67
-
68
- /**
69
- * Upsert a task outcome in-place. Returns true if changed.
70
- */
71
- export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOutcome): boolean {
72
- const idx = outcomes.findIndex(o => o.taskId === next.taskId);
73
- if (idx < 0) {
74
- outcomes.push(next);
75
- return true;
76
- }
77
-
78
- const prev = outcomes[idx];
79
- const mergedNext: LaneTaskOutcome = {
80
- ...next,
81
- laneNumber: next.laneNumber ?? prev.laneNumber,
82
- telemetry: next.telemetry ?? prev.telemetry,
83
- };
84
-
85
- const changed =
86
- prev.status !== mergedNext.status ||
87
- prev.startTime !== mergedNext.startTime ||
88
- prev.endTime !== mergedNext.endTime ||
89
- prev.exitReason !== mergedNext.exitReason ||
90
- prev.sessionName !== mergedNext.sessionName ||
91
- prev.doneFileFound !== mergedNext.doneFileFound ||
92
- prev.laneNumber !== mergedNext.laneNumber ||
93
- !sameOutcomeTelemetry(prev.telemetry, mergedNext.telemetry) ||
94
- prev.partialProgressCommits !== mergedNext.partialProgressCommits ||
95
- prev.partialProgressBranch !== mergedNext.partialProgressBranch ||
96
- prev.exitDiagnostic !== mergedNext.exitDiagnostic;
97
-
98
- if (changed) {
99
- outcomes[idx] = mergedNext;
100
- }
101
- return changed;
102
- }
103
-
104
- /**
105
- * Apply partial progress preservation results to task outcomes (TP-028).
106
- *
107
- * After `preserveFailedLaneProgress()` runs, call this to stamp each
108
- * successfully-preserved task outcome with the saved branch name and
109
- * commit count. This ensures the data flows into persistence and
110
- * diagnostics via the normal outcome → serialization path.
111
- *
112
- * @param ppResult - Result from `preserveFailedLaneProgress()`
113
- * @param outcomes - Mutable array of task outcomes to update in-place
114
- * @returns Number of outcomes that were updated
115
- */
116
- export function applyPartialProgressToOutcomes(
117
- ppResult: PreserveFailedLaneProgressResult,
118
- outcomes: LaneTaskOutcome[],
119
- ): number {
120
- let updated = 0;
121
- for (const r of ppResult.results) {
122
- if (!r.saved || !r.savedBranch) continue;
123
- const outcome = outcomes.find(o => o.taskId === r.taskId);
124
- if (outcome) {
125
- outcome.partialProgressCommits = r.commitCount;
126
- outcome.partialProgressBranch = r.savedBranch;
127
- updated++;
128
- }
129
- }
130
- return updated;
131
- }
132
-
133
- /**
134
- * Seed pending outcomes for all tasks in newly allocated lanes.
135
- *
136
- * Ensures the persisted state has a full task registry as soon as a wave starts,
137
- * including lane/session assignment, even before tasks finish.
138
- */
139
- export function seedPendingOutcomesForAllocatedLanes(
140
- lanes: AllocatedLane[],
141
- outcomes: LaneTaskOutcome[],
142
- ): boolean {
143
- let changed = false;
144
- for (const lane of lanes) {
145
- for (const laneTask of lane.tasks) {
146
- const existing = outcomes.find(o => o.taskId === laneTask.taskId);
147
- 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;
158
- }
159
- }
160
- return changed;
161
- }
162
-
163
- /**
164
- * Sync accumulated task outcomes from monitor snapshots.
165
- *
166
- * This captures in-wave task transitions (pending → running → terminal)
167
- * so state persistence does not lag until wave completion.
168
- */
169
- export function syncTaskOutcomesFromMonitor(
170
- monitorState: MonitorState,
171
- outcomes: LaneTaskOutcome[],
172
- ): boolean {
173
- let changed = false;
174
-
175
- for (const lane of monitorState.lanes) {
176
- // Remaining tasks => pending
177
- 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")) {
180
- continue;
181
- }
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;
196
- }
197
-
198
- // Completed tasks => succeeded
199
- // Use existing endTime if already set — prevents changed=true on every
200
- // poll tick (lastPollTime differs each tick, causing persist log spam).
201
- 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;
217
- }
218
-
219
- // Failed tasks => failed
220
- 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;
236
- }
237
-
238
- // Current task snapshot => running/stalled/succeeded/failed/skipped
239
- if (lane.currentTaskId && lane.currentTaskSnapshot) {
240
- const snap = lane.currentTaskSnapshot;
241
- const existing = outcomes.find(o => o.taskId === lane.currentTaskId);
242
- const monitorToLane: Record<TaskMonitorSnapshot["status"], LaneTaskStatus> = {
243
- pending: "pending",
244
- running: "running",
245
- succeeded: "succeeded",
246
- failed: "failed",
247
- stalled: "stalled",
248
- skipped: "skipped",
249
- unknown: existing?.status || "running",
250
- };
251
- const mappedStatus = monitorToLane[snap.status];
252
- const terminal = mappedStatus === "succeeded" || mappedStatus === "failed" || mappedStatus === "stalled" || mappedStatus === "skipped";
253
-
254
- // TP-051: Use snap.observedAt (Date.now() from monitor poll) instead of
255
- // snap.lastHeartbeat (STATUS.md mtime) for task start time. The mtime
256
- // reflects when STATUS.md was last edited, which may be long before
257
- // 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;
272
- }
273
- }
274
-
275
- return changed;
276
- }
277
-
278
- /**
279
- * Persist current runtime state to `.pi/batch-state.json`.
280
- *
281
- * Centralized helper that serializes runtime state, enriches task records
282
- * with folder paths from discovery, and writes atomically. Logs the reason,
283
- * batchId, phase, and waveIndex for each write.
284
- *
285
- * Write failures are non-fatal: logged as errors and added to
286
- * batchState.errors, but do NOT crash the batch execution.
287
- *
288
- * @param reason - Human-readable reason for this state write (e.g., "batch-start", "wave-index-change")
289
- * @param batchState - Current runtime batch state
290
- * @param wavePlan - Wave plan (array of arrays of task IDs)
291
- * @param lanes - Currently allocated lanes (latest wave's lanes)
292
- * @param allTaskOutcomes - All task outcomes accumulated across completed waves
293
- * @param discovery - Discovery result (for enriching taskFolder paths)
294
- * @param repoRoot - Absolute path to the repository root
295
- */
296
- export function persistRuntimeState(
297
- reason: string,
298
- batchState: OrchBatchRuntimeState,
299
- wavePlan: string[][],
300
- lanes: AllocatedLane[],
301
- allTaskOutcomes: LaneTaskOutcome[],
302
- discovery: DiscoveryResult | null,
303
- repoRoot: string,
304
- ): void {
305
- try {
306
- const json = serializeBatchState(batchState, wavePlan, lanes, allTaskOutcomes);
307
-
308
- // Enrich task records with folder paths and repo fields from discovery
309
- if (discovery) {
310
- const parsed = JSON.parse(json) as PersistedBatchState;
311
- for (const taskRecord of parsed.tasks) {
312
- const parsedTask = discovery.pending.get(taskRecord.taskId);
313
- if (parsedTask) {
314
- taskRecord.taskFolder = parsedTask.taskFolder;
315
- // v2: Enrich repo fields for tasks not yet allocated (pending in future waves)
316
- if (taskRecord.repoId === undefined && parsedTask.promptRepoId !== undefined) {
317
- taskRecord.repoId = parsedTask.promptRepoId;
318
- }
319
- if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) {
320
- taskRecord.resolvedRepoId = parsedTask.resolvedRepoId;
321
- }
322
- if ((taskRecord as any).packetRepoId === undefined && parsedTask.packetRepoId !== undefined) {
323
- (taskRecord as any).packetRepoId = parsedTask.packetRepoId;
324
- }
325
- if ((taskRecord as any).packetTaskPath === undefined && parsedTask.packetTaskPath !== undefined) {
326
- (taskRecord as any).packetTaskPath = parsedTask.packetTaskPath;
327
- }
328
- if ((taskRecord as any).segmentIds === undefined && parsedTask.segmentIds !== undefined) {
329
- (taskRecord as any).segmentIds = parsedTask.segmentIds;
330
- }
331
- if ((taskRecord as any).activeSegmentId === undefined && parsedTask.activeSegmentId !== undefined) {
332
- (taskRecord as any).activeSegmentId = parsedTask.activeSegmentId;
333
- }
334
- }
335
- }
336
- const enrichedJson = JSON.stringify(parsed, null, 2);
337
- saveBatchState(enrichedJson, repoRoot);
338
- } else {
339
- saveBatchState(json, repoRoot);
340
- }
341
-
342
- execLog("state", batchState.batchId, `persisted: ${reason}`, {
343
- phase: batchState.phase,
344
- waveIndex: batchState.currentWaveIndex,
345
- });
346
- } catch (err: unknown) {
347
- const msg = err instanceof StateFileError
348
- ? `[${err.code}] ${err.message}`
349
- : (err instanceof Error ? err.message : String(err));
350
- execLog("state", batchState.batchId, `write failed: ${msg}`, {
351
- reason,
352
- phase: batchState.phase,
353
- });
354
- batchState.errors.push(`State persistence failed (${reason}): ${msg}`);
355
- }
356
- }
357
-
358
-
359
- // ── State Validation ─────────────────────────────────────────────────
360
-
361
- /** All valid OrchBatchPhase values for validation. */
362
- export const VALID_BATCH_PHASES: ReadonlySet<string> = new Set([
363
- "idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed",
364
- ]);
365
-
366
- /** All valid LaneTaskStatus values for validation. */
367
- export const VALID_TASK_STATUSES: ReadonlySet<string> = new Set([
368
- "pending", "running", "succeeded", "failed", "stalled", "skipped",
369
- ]);
370
-
371
- /** All valid merge result statuses for persisted state. */
372
- export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet<string> = new Set([
373
- "succeeded", "failed", "partial",
374
- ]);
375
-
376
- /**
377
- * Upconvert a v1 state object to v2 in-memory.
378
- *
379
- * Applied automatically by `validatePersistedState()` when a v1 file is loaded.
380
- * The on-disk file is NOT rewritten — upconversion is purely in-memory.
381
- *
382
- * v1→v2 field defaults:
383
- * - `schemaVersion`: bumped from 1 → 2
384
- * - `baseBranch`: defaults to "" (was already handled in v1 validation)
385
- * - `mode`: defaults to "repo" (v1 was always single-repo)
386
- * - `tasks[].repoId`: remains undefined (repo mode has no repo routing)
387
- * - `tasks[].resolvedRepoId`: remains undefined (same reason)
388
- * - `lanes[].repoId`: preserved if present (was already serialized in v1
389
- * when workspace mode was partially implemented)
390
- *
391
- * This function is idempotent: calling it on an already-v2 object is a no-op.
392
- *
393
- * @param obj - Parsed state object (mutated in-place)
394
- */
395
- export function upconvertV1toV2(obj: Record<string, unknown>): void {
396
- if ((obj.schemaVersion as number) >= 2) return;
397
- obj.schemaVersion = 2;
398
- if (!obj.baseBranch) obj.baseBranch = "";
399
- if (!obj.mode) obj.mode = "repo";
400
- // Task and lane records: v2 optional fields default to undefined (omitted)
401
- // which is already their state in v1 objects. No mutation needed.
402
- }
403
-
404
- /**
405
- * Upconvert a v2 state object to v3 by adding resilience and diagnostics
406
- * sections with conservative defaults.
407
- *
408
- * Added fields:
409
- * - `resilience`: default empty resilience state (no retries, no repairs)
410
- * - `diagnostics`: default empty diagnostics (no task exits, zero batch cost)
411
- *
412
- * This function is idempotent: calling it on an already-v3 object is a no-op.
413
- *
414
- * @param obj - Parsed state object (mutated in-place)
415
- */
416
- export function upconvertV2toV3(obj: Record<string, unknown>): void {
417
- if ((obj.schemaVersion as number) >= 3) return;
418
- obj.schemaVersion = 3;
419
- // Backfill v3 sections with conservative defaults only during genuine
420
- // v1/v2→v3 migration. A native v3 file missing these sections is
421
- // malformed and must be rejected by validation — not silently patched.
422
- if (!obj.resilience) obj.resilience = defaultResilienceState();
423
- if (!obj.diagnostics) obj.diagnostics = defaultBatchDiagnostics();
424
- }
425
-
426
- /**
427
- * Upconvert a v3 state object to v4 by adding the `segments` array.
428
- *
429
- * Added fields:
430
- * - `segments`: empty array (no segment records exist in pre-v4 state)
431
- *
432
- * Task-level segment fields (`packetRepoId`, `packetTaskPath`,
433
- * `segmentIds`, `activeSegmentId`) are optional and default to
434
- * `undefined` (omitted from JSON). They are NOT backfilled here
435
- * because their values depend on runtime discovery, not on
436
- * migration defaults.
437
- *
438
- * This function is idempotent: calling it on an already-v4 object is a no-op.
439
- *
440
- * @param obj - Parsed state object (mutated in-place)
441
- */
442
- export function upconvertV3toV4(obj: Record<string, unknown>): void {
443
- if ((obj.schemaVersion as number) >= 4) return;
444
- obj.schemaVersion = 4;
445
- // Backfill v4 segments with empty array only during genuine v3→v4 migration.
446
- if (!obj.segments) obj.segments = [];
447
- }
448
-
449
- /**
450
- * Validate a parsed JSON object as a PersistedBatchState.
451
- *
452
- * Checks:
453
- * 1. Schema version is 1 (auto-upconverted to v2→v3), 2 (upconverted to v3), or 3 (current)
454
- * 2. All required fields are present with correct types
455
- * 3. Enum fields contain valid values (phase, task statuses, merge statuses)
456
- * 4. Arrays contain valid sub-records
457
- * 5. v2 optional fields (repoId, resolvedRepoId, mode) are valid when present
458
- *
459
- * @param data - Parsed JSON (unknown type)
460
- * @returns Validated PersistedBatchState (always v3, even if input was v1/v2)
461
- * @throws StateFileError with STATE_SCHEMA_INVALID on any validation failure
462
- */
463
- export function validatePersistedState(data: unknown): PersistedBatchState {
464
- if (!data || typeof data !== "object") {
465
- throw new StateFileError(
466
- "STATE_SCHEMA_INVALID",
467
- "Batch state must be a non-null object",
468
- );
469
- }
470
-
471
- const obj = data as Record<string, unknown>;
472
-
473
- // ── Schema version ───────────────────────────────────────────
474
- if (typeof obj.schemaVersion !== "number") {
475
- throw new StateFileError(
476
- "STATE_SCHEMA_INVALID",
477
- `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`,
478
- );
479
- }
480
- // Accept v1 (auto-upconvert to v2→v3→v4), v2 (upconvert to v3→v4), v3 (upconvert to v4), and v4 (current).
481
- // Reject anything else — including future versions from newer runtimes.
482
- const ACCEPTED_VERSIONS = [1, 2, 3, BATCH_STATE_SCHEMA_VERSION];
483
- if (!ACCEPTED_VERSIONS.includes(obj.schemaVersion as number)) {
484
- throw new StateFileError(
485
- "STATE_SCHEMA_INVALID",
486
- `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.`,
489
- );
490
- }
491
- const isV1 = obj.schemaVersion === 1;
492
-
493
- // ── Required string fields ───────────────────────────────────
494
- for (const field of ["phase", "batchId"] as const) {
495
- if (typeof obj[field] !== "string") {
496
- throw new StateFileError(
497
- "STATE_SCHEMA_INVALID",
498
- `Missing or invalid "${field}" field (expected string, got ${typeof obj[field]})`,
499
- );
500
- }
501
- }
502
-
503
- // ── Optional string fields (backward-compatible) ─────────────
504
- // baseBranch was added after schema v1; default to empty string if missing
505
- if (obj.baseBranch !== undefined && typeof obj.baseBranch !== "string") {
506
- throw new StateFileError(
507
- "STATE_SCHEMA_INVALID",
508
- `Invalid "baseBranch" field (expected string, got ${typeof obj.baseBranch})`,
509
- );
510
- }
511
-
512
- // ── Optional string fields: orchBranch ───────────────────────
513
- // orchBranch was added after schema v2 shipped; default to "" if missing.
514
- if (obj.orchBranch !== undefined && typeof obj.orchBranch !== "string") {
515
- throw new StateFileError(
516
- "STATE_SCHEMA_INVALID",
517
- `Invalid "orchBranch" field (expected string, got ${typeof obj.orchBranch})`,
518
- );
519
- }
520
- if (obj.orchBranch === undefined) {
521
- obj.orchBranch = "";
522
- }
523
-
524
- // ── v2: mode field ───────────────────────────────────────────
525
- // mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
526
- if (!isV1 && obj.mode === undefined) {
527
- throw new StateFileError(
528
- "STATE_SCHEMA_INVALID",
529
- `Missing required "mode" field in schema v2 (expected "repo" or "workspace")`,
530
- );
531
- }
532
- if (obj.mode !== undefined && typeof obj.mode !== "string") {
533
- throw new StateFileError(
534
- "STATE_SCHEMA_INVALID",
535
- `Invalid "mode" field (expected string, got ${typeof obj.mode})`,
536
- );
537
- }
538
- if (obj.mode !== undefined && obj.mode !== "repo" && obj.mode !== "workspace") {
539
- throw new StateFileError(
540
- "STATE_SCHEMA_INVALID",
541
- `Invalid "mode" value "${obj.mode}" (expected "repo" or "workspace")`,
542
- );
543
- }
544
-
545
- // ── Phase enum validation ────────────────────────────────────
546
- if (!VALID_BATCH_PHASES.has(obj.phase as string)) {
547
- throw new StateFileError(
548
- "STATE_SCHEMA_INVALID",
549
- `Invalid "phase" value "${obj.phase}" (expected one of: ${[...VALID_BATCH_PHASES].join(", ")})`,
550
- );
551
- }
552
-
553
- // ── Required number fields ───────────────────────────────────
554
- for (const field of [
555
- "startedAt", "updatedAt", "currentWaveIndex", "totalWaves",
556
- "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
557
- ] as const) {
558
- if (typeof obj[field] !== "number") {
559
- throw new StateFileError(
560
- "STATE_SCHEMA_INVALID",
561
- `Missing or invalid "${field}" field (expected number, got ${typeof obj[field]})`,
562
- );
563
- }
564
- }
565
-
566
- // ── Nullable number: endedAt ─────────────────────────────────
567
- if (obj.endedAt !== null && typeof obj.endedAt !== "number") {
568
- throw new StateFileError(
569
- "STATE_SCHEMA_INVALID",
570
- `Invalid "endedAt" field (expected number or null, got ${typeof obj.endedAt})`,
571
- );
572
- }
573
-
574
- // ── Required arrays ──────────────────────────────────────────
575
- for (const field of ["wavePlan", "lanes", "tasks", "mergeResults", "blockedTaskIds", "errors"] as const) {
576
- if (!Array.isArray(obj[field])) {
577
- throw new StateFileError(
578
- "STATE_SCHEMA_INVALID",
579
- `Missing or invalid "${field}" field (expected array, got ${typeof obj[field]})`,
580
- );
581
- }
582
- }
583
-
584
- // ── Validate wavePlan: array of arrays of strings ────────────
585
- const wavePlan = obj.wavePlan as unknown[];
586
- for (let i = 0; i < wavePlan.length; i++) {
587
- if (!Array.isArray(wavePlan[i])) {
588
- throw new StateFileError(
589
- "STATE_SCHEMA_INVALID",
590
- `wavePlan[${i}] is not an array`,
591
- );
592
- }
593
- for (const taskId of wavePlan[i] as unknown[]) {
594
- if (typeof taskId !== "string") {
595
- throw new StateFileError(
596
- "STATE_SCHEMA_INVALID",
597
- `wavePlan[${i}] contains non-string value: ${typeof taskId}`,
598
- );
599
- }
600
- }
601
- }
602
-
603
- // ── Validate task records ────────────────────────────────────
604
- const tasks = obj.tasks as unknown[];
605
- for (let i = 0; i < tasks.length; i++) {
606
- const t = tasks[i] as Record<string, unknown>;
607
- if (!t || typeof t !== "object") {
608
- throw new StateFileError(
609
- "STATE_SCHEMA_INVALID",
610
- `tasks[${i}] is not an object`,
611
- );
612
- }
613
- for (const field of ["taskId", "sessionName", "taskFolder", "exitReason"] as const) {
614
- if (typeof t[field] !== "string") {
615
- throw new StateFileError(
616
- "STATE_SCHEMA_INVALID",
617
- `tasks[${i}].${field} is missing or not a string`,
618
- );
619
- }
620
- }
621
- if (typeof t.laneNumber !== "number") {
622
- throw new StateFileError(
623
- "STATE_SCHEMA_INVALID",
624
- `tasks[${i}].laneNumber is missing or not a number`,
625
- );
626
- }
627
- if (typeof t.status !== "string" || !VALID_TASK_STATUSES.has(t.status)) {
628
- throw new StateFileError(
629
- "STATE_SCHEMA_INVALID",
630
- `tasks[${i}].status is invalid: "${t.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`,
631
- );
632
- }
633
- if (t.startedAt !== null && typeof t.startedAt !== "number") {
634
- throw new StateFileError(
635
- "STATE_SCHEMA_INVALID",
636
- `tasks[${i}].startedAt is not a number or null`,
637
- );
638
- }
639
- 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
- );
644
- }
645
- if (typeof t.doneFileFound !== "boolean") {
646
- throw new StateFileError(
647
- "STATE_SCHEMA_INVALID",
648
- `tasks[${i}].doneFileFound is missing or not a boolean`,
649
- );
650
- }
651
- // v2 optional fields: repoId, resolvedRepoId (string | undefined)
652
- if (t.repoId !== undefined && typeof t.repoId !== "string") {
653
- throw new StateFileError(
654
- "STATE_SCHEMA_INVALID",
655
- `tasks[${i}].repoId is not a string (got ${typeof t.repoId})`,
656
- );
657
- }
658
- if (t.resolvedRepoId !== undefined && typeof t.resolvedRepoId !== "string") {
659
- throw new StateFileError(
660
- "STATE_SCHEMA_INVALID",
661
- `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`,
662
- );
663
- }
664
- // TP-028 optional fields: partialProgressCommits (number | undefined), partialProgressBranch (string | undefined)
665
- if (t.partialProgressCommits !== undefined && typeof t.partialProgressCommits !== "number") {
666
- throw new StateFileError(
667
- "STATE_SCHEMA_INVALID",
668
- `tasks[${i}].partialProgressCommits is not a number (got ${typeof t.partialProgressCommits})`,
669
- );
670
- }
671
- if (t.partialProgressBranch !== undefined && typeof t.partialProgressBranch !== "string") {
672
- throw new StateFileError(
673
- "STATE_SCHEMA_INVALID",
674
- `tasks[${i}].partialProgressBranch is not a string (got ${typeof t.partialProgressBranch})`,
675
- );
676
- }
677
- // TP-026 optional field: exitDiagnostic (object with classification string | undefined)
678
- if (t.exitDiagnostic !== undefined) {
679
- if (typeof t.exitDiagnostic !== "object" || t.exitDiagnostic === null || Array.isArray(t.exitDiagnostic)) {
680
- throw new StateFileError(
681
- "STATE_SCHEMA_INVALID",
682
- `tasks[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(t.exitDiagnostic) ? "array" : typeof t.exitDiagnostic})`,
683
- );
684
- }
685
- if (typeof (t.exitDiagnostic as any).classification !== "string") {
686
- throw new StateFileError(
687
- "STATE_SCHEMA_INVALID",
688
- `tasks[${i}].exitDiagnostic.classification is not a string (got ${typeof (t.exitDiagnostic as any).classification})`,
689
- );
690
- }
691
- }
692
- }
693
-
694
- // ── Validate lane records ────────────────────────────────────
695
- const lanes = obj.lanes as unknown[];
696
- const legacyTmuxSessionLaneIndexes: number[] = [];
697
- for (let i = 0; i < lanes.length; i++) {
698
- const l = lanes[i] as Record<string, unknown>;
699
- if (!l || typeof l !== "object") {
700
- throw new StateFileError(
701
- "STATE_SCHEMA_INVALID",
702
- `lanes[${i}] is not an object`,
703
- );
704
- }
705
- for (const field of ["laneId", "worktreePath", "branch"] as const) {
706
- if (typeof l[field] !== "string") {
707
- throw new StateFileError(
708
- "STATE_SCHEMA_INVALID",
709
- `lanes[${i}].${field} is missing or not a string`,
710
- );
711
- }
712
- }
713
-
714
- const { laneSessionId, tmuxSessionName } = readLaneSessionAliases(l);
715
- if (laneSessionId !== undefined && typeof laneSessionId !== "string") {
716
- throw new StateFileError(
717
- "STATE_SCHEMA_INVALID",
718
- `lanes[${i}].laneSessionId is not a string (got ${typeof laneSessionId})`,
719
- );
720
- }
721
-
722
- if (tmuxSessionName !== undefined && typeof tmuxSessionName !== "string") {
723
- throw new StateFileError(
724
- "STATE_SCHEMA_INVALID",
725
- `lanes[${i}].tmuxSessionName is not a string (got ${typeof tmuxSessionName})`,
726
- );
727
- }
728
-
729
- if (typeof laneSessionId !== "string" && typeof tmuxSessionName !== "string") {
730
- throw new StateFileError(
731
- "STATE_SCHEMA_INVALID",
732
- `lanes[${i}] must include either laneSessionId or tmuxSessionName as a string`,
733
- );
734
- }
735
-
736
- if (typeof tmuxSessionName === "string") {
737
- legacyTmuxSessionLaneIndexes.push(i);
738
- }
739
-
740
- normalizeLaneSessionAlias(l);
741
-
742
- if (typeof l.laneNumber !== "number") {
743
- throw new StateFileError(
744
- "STATE_SCHEMA_INVALID",
745
- `lanes[${i}].laneNumber is missing or not a number`,
746
- );
747
- }
748
- if (!Array.isArray(l.taskIds)) {
749
- throw new StateFileError(
750
- "STATE_SCHEMA_INVALID",
751
- `lanes[${i}].taskIds is missing or not an array`,
752
- );
753
- }
754
- // v2 optional field: repoId (string | undefined)
755
- if (l.repoId !== undefined && typeof l.repoId !== "string") {
756
- throw new StateFileError(
757
- "STATE_SCHEMA_INVALID",
758
- `lanes[${i}].repoId is not a string (got ${typeof l.repoId})`,
759
- );
760
- }
761
- }
762
-
763
- if (legacyTmuxSessionLaneIndexes.length > 0) {
764
- console.error(
765
- "[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.",
767
- );
768
- }
769
-
770
- // ── Validate merge results ───────────────────────────────────
771
- const mergeResults = obj.mergeResults as unknown[];
772
- for (let i = 0; i < mergeResults.length; i++) {
773
- const m = mergeResults[i] as Record<string, unknown>;
774
- if (!m || typeof m !== "object") {
775
- throw new StateFileError(
776
- "STATE_SCHEMA_INVALID",
777
- `mergeResults[${i}] is not an object`,
778
- );
779
- }
780
- if (typeof m.waveIndex !== "number") {
781
- throw new StateFileError(
782
- "STATE_SCHEMA_INVALID",
783
- `mergeResults[${i}].waveIndex is missing or not a number`,
784
- );
785
- }
786
- if (typeof m.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(m.status)) {
787
- throw new StateFileError(
788
- "STATE_SCHEMA_INVALID",
789
- `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`,
790
- );
791
- }
792
- // v2 optional field: repoResults (array | undefined)
793
- if (m.repoResults !== undefined) {
794
- if (!Array.isArray(m.repoResults)) {
795
- throw new StateFileError(
796
- "STATE_SCHEMA_INVALID",
797
- `mergeResults[${i}].repoResults is not an array (got ${typeof m.repoResults})`,
798
- );
799
- }
800
- for (let j = 0; j < (m.repoResults as unknown[]).length; j++) {
801
- const rr = (m.repoResults as unknown[])[j] as Record<string, unknown>;
802
- if (!rr || typeof rr !== "object") {
803
- throw new StateFileError(
804
- "STATE_SCHEMA_INVALID",
805
- `mergeResults[${i}].repoResults[${j}] is not an object`,
806
- );
807
- }
808
- if (typeof rr.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(rr.status)) {
809
- throw new StateFileError(
810
- "STATE_SCHEMA_INVALID",
811
- `mergeResults[${i}].repoResults[${j}].status is invalid: "${rr.status}"`,
812
- );
813
- }
814
- if (!Array.isArray(rr.laneNumbers)) {
815
- throw new StateFileError(
816
- "STATE_SCHEMA_INVALID",
817
- `mergeResults[${i}].repoResults[${j}].laneNumbers is not an array`,
818
- );
819
- }
820
- }
821
- }
822
- }
823
-
824
- // ── Validate lastError ───────────────────────────────────────
825
- if (obj.lastError !== null) {
826
- if (typeof obj.lastError !== "object") {
827
- throw new StateFileError(
828
- "STATE_SCHEMA_INVALID",
829
- `lastError is not an object or null`,
830
- );
831
- }
832
- const le = obj.lastError as Record<string, unknown>;
833
- if (typeof le.code !== "string" || typeof le.message !== "string") {
834
- throw new StateFileError(
835
- "STATE_SCHEMA_INVALID",
836
- `lastError must have "code" (string) and "message" (string) fields`,
837
- );
838
- }
839
- }
840
-
841
- // ── Validate blockedTaskIds: array of strings ────────────────
842
- for (const id of obj.blockedTaskIds as unknown[]) {
843
- if (typeof id !== "string") {
844
- throw new StateFileError(
845
- "STATE_SCHEMA_INVALID",
846
- `blockedTaskIds contains non-string value: ${typeof id}`,
847
- );
848
- }
849
- }
850
-
851
- // ── Validate errors: array of strings ────────────────────────
852
- for (const err of obj.errors as unknown[]) {
853
- if (typeof err !== "string") {
854
- throw new StateFileError(
855
- "STATE_SCHEMA_INVALID",
856
- `errors array contains non-string value: ${typeof err}`,
857
- );
858
- }
859
- }
860
-
861
- // ── v1→v2→v3→v4 upconversion ─────────────────────────────────
862
- // Apply defaults for fields that may be absent in older state files.
863
- // The on-disk file is NOT rewritten; upconversion is in-memory only.
864
- // Chain: v1→v2 then v2→v3 then v3→v4 (each is idempotent / no-op if already at target).
865
- upconvertV1toV2(obj);
866
- upconvertV2toV3(obj);
867
- upconvertV3toV4(obj);
868
-
869
- // ── Validate v3 resilience section ───────────────────────────
870
- // After upconversion, resilience must be a valid object with correct types.
871
- if (!obj.resilience || typeof obj.resilience !== "object") {
872
- throw new StateFileError(
873
- "STATE_SCHEMA_INVALID",
874
- `Missing or invalid "resilience" section (expected object, got ${typeof obj.resilience})`,
875
- );
876
- }
877
- const res = obj.resilience as Record<string, unknown>;
878
- if (typeof res.resumeForced !== "boolean") {
879
- throw new StateFileError(
880
- "STATE_SCHEMA_INVALID",
881
- `resilience.resumeForced must be a boolean (got ${typeof res.resumeForced})`,
882
- );
883
- }
884
- if (!res.retryCountByScope || typeof res.retryCountByScope !== "object" || Array.isArray(res.retryCountByScope)) {
885
- throw new StateFileError(
886
- "STATE_SCHEMA_INVALID",
887
- `resilience.retryCountByScope must be an object (got ${typeof res.retryCountByScope})`,
888
- );
889
- }
890
- // Deep-validate retryCountByScope: all values must be numbers
891
- for (const [scope, count] of Object.entries(res.retryCountByScope as Record<string, unknown>)) {
892
- if (typeof count !== "number") {
893
- throw new StateFileError(
894
- "STATE_SCHEMA_INVALID",
895
- `resilience.retryCountByScope["${scope}"] must be a number (got ${typeof count})`,
896
- );
897
- }
898
- }
899
- if (res.lastFailureClass !== null && typeof res.lastFailureClass !== "string") {
900
- throw new StateFileError(
901
- "STATE_SCHEMA_INVALID",
902
- `resilience.lastFailureClass must be a string or null (got ${typeof res.lastFailureClass})`,
903
- );
904
- }
905
- if (!Array.isArray(res.repairHistory)) {
906
- throw new StateFileError(
907
- "STATE_SCHEMA_INVALID",
908
- `resilience.repairHistory must be an array (got ${typeof res.repairHistory})`,
909
- );
910
- }
911
- // Deep-validate repairHistory entries
912
- for (let i = 0; i < (res.repairHistory as unknown[]).length; i++) {
913
- const rec = (res.repairHistory as unknown[])[i];
914
- if (!rec || typeof rec !== "object") {
915
- throw new StateFileError(
916
- "STATE_SCHEMA_INVALID",
917
- `resilience.repairHistory[${i}] must be an object (got ${typeof rec})`,
918
- );
919
- }
920
- const r = rec as Record<string, unknown>;
921
- if (typeof r.id !== "string") {
922
- throw new StateFileError(
923
- "STATE_SCHEMA_INVALID",
924
- `resilience.repairHistory[${i}].id must be a string (got ${typeof r.id})`,
925
- );
926
- }
927
- if (typeof r.strategy !== "string") {
928
- throw new StateFileError(
929
- "STATE_SCHEMA_INVALID",
930
- `resilience.repairHistory[${i}].strategy must be a string (got ${typeof r.strategy})`,
931
- );
932
- }
933
- const VALID_REPAIR_STATUSES = new Set(["succeeded", "failed", "skipped"]);
934
- if (typeof r.status !== "string" || !VALID_REPAIR_STATUSES.has(r.status)) {
935
- throw new StateFileError(
936
- "STATE_SCHEMA_INVALID",
937
- `resilience.repairHistory[${i}].status must be "succeeded"|"failed"|"skipped" (got ${JSON.stringify(r.status)})`,
938
- );
939
- }
940
- if (typeof r.startedAt !== "number") {
941
- throw new StateFileError(
942
- "STATE_SCHEMA_INVALID",
943
- `resilience.repairHistory[${i}].startedAt must be a number (got ${typeof r.startedAt})`,
944
- );
945
- }
946
- if (typeof r.endedAt !== "number") {
947
- throw new StateFileError(
948
- "STATE_SCHEMA_INVALID",
949
- `resilience.repairHistory[${i}].endedAt must be a number (got ${typeof r.endedAt})`,
950
- );
951
- }
952
- // repoId is optional — validate type only if present
953
- if (r.repoId !== undefined && typeof r.repoId !== "string") {
954
- throw new StateFileError(
955
- "STATE_SCHEMA_INVALID",
956
- `resilience.repairHistory[${i}].repoId must be a string when present (got ${typeof r.repoId})`,
957
- );
958
- }
959
- }
960
-
961
- // ── Validate v3 diagnostics section ──────────────────────────
962
- // After upconversion, diagnostics must be a valid object with correct types.
963
- if (!obj.diagnostics || typeof obj.diagnostics !== "object") {
964
- throw new StateFileError(
965
- "STATE_SCHEMA_INVALID",
966
- `Missing or invalid "diagnostics" section (expected object, got ${typeof obj.diagnostics})`,
967
- );
968
- }
969
- const diag = obj.diagnostics as Record<string, unknown>;
970
- if (!diag.taskExits || typeof diag.taskExits !== "object" || Array.isArray(diag.taskExits)) {
971
- throw new StateFileError(
972
- "STATE_SCHEMA_INVALID",
973
- `diagnostics.taskExits must be an object (got ${typeof diag.taskExits})`,
974
- );
975
- }
976
- // Deep-validate taskExits entries
977
- for (const [taskId, entry] of Object.entries(diag.taskExits as Record<string, unknown>)) {
978
- if (!entry || typeof entry !== "object") {
979
- throw new StateFileError(
980
- "STATE_SCHEMA_INVALID",
981
- `diagnostics.taskExits["${taskId}"] must be an object (got ${typeof entry})`,
982
- );
983
- }
984
- const te = entry as Record<string, unknown>;
985
- if (typeof te.classification !== "string") {
986
- throw new StateFileError(
987
- "STATE_SCHEMA_INVALID",
988
- `diagnostics.taskExits["${taskId}"].classification must be a string (got ${typeof te.classification})`,
989
- );
990
- }
991
- if (typeof te.cost !== "number") {
992
- throw new StateFileError(
993
- "STATE_SCHEMA_INVALID",
994
- `diagnostics.taskExits["${taskId}"].cost must be a number (got ${typeof te.cost})`,
995
- );
996
- }
997
- if (typeof te.durationSec !== "number") {
998
- throw new StateFileError(
999
- "STATE_SCHEMA_INVALID",
1000
- `diagnostics.taskExits["${taskId}"].durationSec must be a number (got ${typeof te.durationSec})`,
1001
- );
1002
- }
1003
- // retries is optional — validate type only if present
1004
- if (te.retries !== undefined && typeof te.retries !== "number") {
1005
- throw new StateFileError(
1006
- "STATE_SCHEMA_INVALID",
1007
- `diagnostics.taskExits["${taskId}"].retries must be a number when present (got ${typeof te.retries})`,
1008
- );
1009
- }
1010
- }
1011
- if (typeof diag.batchCost !== "number") {
1012
- throw new StateFileError(
1013
- "STATE_SCHEMA_INVALID",
1014
- `diagnostics.batchCost must be a number (got ${typeof diag.batchCost})`,
1015
- );
1016
- }
1017
-
1018
- // ── Validate exitDiagnostic on task records (optional) ───────
1019
- for (let i = 0; i < tasks.length; i++) {
1020
- const t = tasks[i] as Record<string, unknown>;
1021
- if (t.exitDiagnostic !== undefined) {
1022
- if (!t.exitDiagnostic || typeof t.exitDiagnostic !== "object") {
1023
- throw new StateFileError(
1024
- "STATE_SCHEMA_INVALID",
1025
- `tasks[${i}].exitDiagnostic must be an object when present (got ${typeof t.exitDiagnostic})`,
1026
- );
1027
- }
1028
- const ed = t.exitDiagnostic as Record<string, unknown>;
1029
- if (typeof ed.classification !== "string") {
1030
- throw new StateFileError(
1031
- "STATE_SCHEMA_INVALID",
1032
- `tasks[${i}].exitDiagnostic.classification must be a string (got ${typeof ed.classification})`,
1033
- );
1034
- }
1035
- }
1036
- // v4 optional fields: packetRepoId, packetTaskPath (string | undefined)
1037
- if (t.packetRepoId !== undefined && typeof t.packetRepoId !== "string") {
1038
- throw new StateFileError(
1039
- "STATE_SCHEMA_INVALID",
1040
- `tasks[${i}].packetRepoId is not a string (got ${typeof t.packetRepoId})`,
1041
- );
1042
- }
1043
- if (t.packetTaskPath !== undefined && typeof t.packetTaskPath !== "string") {
1044
- throw new StateFileError(
1045
- "STATE_SCHEMA_INVALID",
1046
- `tasks[${i}].packetTaskPath is not a string (got ${typeof t.packetTaskPath})`,
1047
- );
1048
- }
1049
- // v4 optional field: segmentIds (string[] | undefined)
1050
- if (t.segmentIds !== undefined) {
1051
- if (!Array.isArray(t.segmentIds)) {
1052
- throw new StateFileError(
1053
- "STATE_SCHEMA_INVALID",
1054
- `tasks[${i}].segmentIds is not an array (got ${typeof t.segmentIds})`,
1055
- );
1056
- }
1057
- for (let j = 0; j < (t.segmentIds as unknown[]).length; j++) {
1058
- if (typeof (t.segmentIds as unknown[])[j] !== "string") {
1059
- throw new StateFileError(
1060
- "STATE_SCHEMA_INVALID",
1061
- `tasks[${i}].segmentIds[${j}] is not a string`,
1062
- );
1063
- }
1064
- }
1065
- }
1066
- // v4 optional field: activeSegmentId (string | null | undefined)
1067
- if (t.activeSegmentId !== undefined && t.activeSegmentId !== null && typeof t.activeSegmentId !== "string") {
1068
- throw new StateFileError(
1069
- "STATE_SCHEMA_INVALID",
1070
- `tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`,
1071
- );
1072
- }
1073
- }
1074
-
1075
- // ── Validate v4 segments array ───────────────────────────────
1076
- if (!Array.isArray(obj.segments)) {
1077
- throw new StateFileError(
1078
- "STATE_SCHEMA_INVALID",
1079
- `Missing or invalid "segments" field (expected array, got ${typeof obj.segments})`,
1080
- );
1081
- }
1082
- const segments = obj.segments as unknown[];
1083
- for (let i = 0; i < segments.length; i++) {
1084
- const s = segments[i] as Record<string, unknown>;
1085
- if (!s || typeof s !== "object") {
1086
- throw new StateFileError(
1087
- "STATE_SCHEMA_INVALID",
1088
- `segments[${i}] is not an object`,
1089
- );
1090
- }
1091
- // Required string fields
1092
- for (const field of ["segmentId", "taskId", "repoId", "laneId", "sessionName", "worktreePath", "branch", "exitReason"] as const) {
1093
- if (typeof s[field] !== "string") {
1094
- throw new StateFileError(
1095
- "STATE_SCHEMA_INVALID",
1096
- `segments[${i}].${field} is missing or not a string (got ${typeof s[field]})`,
1097
- );
1098
- }
1099
- }
1100
- // Required status field (same valid values as task status)
1101
- if (typeof s.status !== "string" || !VALID_TASK_STATUSES.has(s.status)) {
1102
- throw new StateFileError(
1103
- "STATE_SCHEMA_INVALID",
1104
- `segments[${i}].status is invalid: "${s.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`,
1105
- );
1106
- }
1107
- // Nullable number fields: startedAt, endedAt
1108
- if (s.startedAt !== null && typeof s.startedAt !== "number") {
1109
- throw new StateFileError(
1110
- "STATE_SCHEMA_INVALID",
1111
- `segments[${i}].startedAt is not a number or null (got ${typeof s.startedAt})`,
1112
- );
1113
- }
1114
- if (s.endedAt !== null && typeof s.endedAt !== "number") {
1115
- throw new StateFileError(
1116
- "STATE_SCHEMA_INVALID",
1117
- `segments[${i}].endedAt is not a number or null (got ${typeof s.endedAt})`,
1118
- );
1119
- }
1120
- // Required number: retries
1121
- if (typeof s.retries !== "number") {
1122
- throw new StateFileError(
1123
- "STATE_SCHEMA_INVALID",
1124
- `segments[${i}].retries is not a number (got ${typeof s.retries})`,
1125
- );
1126
- }
1127
- // Required array: dependsOnSegmentIds
1128
- if (!Array.isArray(s.dependsOnSegmentIds)) {
1129
- throw new StateFileError(
1130
- "STATE_SCHEMA_INVALID",
1131
- `segments[${i}].dependsOnSegmentIds is not an array (got ${typeof s.dependsOnSegmentIds})`,
1132
- );
1133
- }
1134
- for (let j = 0; j < (s.dependsOnSegmentIds as unknown[]).length; j++) {
1135
- if (typeof (s.dependsOnSegmentIds as unknown[])[j] !== "string") {
1136
- throw new StateFileError(
1137
- "STATE_SCHEMA_INVALID",
1138
- `segments[${i}].dependsOnSegmentIds[${j}] is not a string`,
1139
- );
1140
- }
1141
- }
1142
- if (s.expandedFrom !== undefined && typeof s.expandedFrom !== "string") {
1143
- throw new StateFileError(
1144
- "STATE_SCHEMA_INVALID",
1145
- `segments[${i}].expandedFrom is not a string when present (got ${typeof s.expandedFrom})`,
1146
- );
1147
- }
1148
- if (s.expansionRequestId !== undefined && typeof s.expansionRequestId !== "string") {
1149
- throw new StateFileError(
1150
- "STATE_SCHEMA_INVALID",
1151
- `segments[${i}].expansionRequestId is not a string when present (got ${typeof s.expansionRequestId})`,
1152
- );
1153
- }
1154
- // Optional exitDiagnostic
1155
- if (s.exitDiagnostic !== undefined) {
1156
- if (!s.exitDiagnostic || typeof s.exitDiagnostic !== "object" || Array.isArray(s.exitDiagnostic)) {
1157
- throw new StateFileError(
1158
- "STATE_SCHEMA_INVALID",
1159
- `segments[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(s.exitDiagnostic) ? "array" : typeof s.exitDiagnostic})`,
1160
- );
1161
- }
1162
- if (typeof (s.exitDiagnostic as Record<string, unknown>).classification !== "string") {
1163
- throw new StateFileError(
1164
- "STATE_SCHEMA_INVALID",
1165
- `segments[${i}].exitDiagnostic.classification is not a string`,
1166
- );
1167
- }
1168
- }
1169
- }
1170
-
1171
- // ── Capture unknown top-level fields for roundtrip preservation ──
1172
- // Any fields not in the known schema are preserved so they survive
1173
- // serialization. This protects against data loss from future schema
1174
- // extensions or external tools writing additional fields.
1175
- 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",
1182
- "segments",
1183
- "_extraFields",
1184
- ]);
1185
- const extraFields: Record<string, unknown> = {};
1186
- for (const key of Object.keys(obj)) {
1187
- if (!KNOWN_TOP_LEVEL_FIELDS.has(key)) {
1188
- extraFields[key] = obj[key];
1189
- }
1190
- }
1191
- if (Object.keys(extraFields).length > 0) {
1192
- obj._extraFields = extraFields;
1193
- }
1194
-
1195
- return obj as unknown as PersistedBatchState;
1196
- }
1197
-
1198
- // ── Serialization ────────────────────────────────────────────────────
1199
-
1200
- /**
1201
- * Serialize runtime batch state to a PersistedBatchState JSON string.
1202
- *
1203
- * Pure function: extracts the serializable subset from OrchBatchRuntimeState
1204
- * and its associated wave results, enriches with schema version and timestamps.
1205
- *
1206
- * @param state - Current runtime batch state
1207
- * @param wavePlan - Wave plan (array of arrays of task IDs)
1208
- * @param lanes - Currently allocated lanes (latest wave's lanes)
1209
- * @param allTaskOutcomes - All task outcomes across completed waves + current
1210
- * @returns JSON string (pretty-printed for debuggability)
1211
- */
1212
- export function serializeBatchState(
1213
- state: OrchBatchRuntimeState,
1214
- wavePlan: string[][],
1215
- lanes: AllocatedLane[],
1216
- allTaskOutcomes: LaneTaskOutcome[],
1217
- ): string {
1218
- const now = Date.now();
1219
-
1220
- // Build lookup maps for fast per-task enrichment.
1221
- const laneByTaskId = new Map<string, AllocatedLane>();
1222
- for (const lane of lanes) {
1223
- for (const task of lane.tasks) {
1224
- laneByTaskId.set(task.taskId, lane);
1225
- }
1226
- }
1227
-
1228
- // Latest outcome wins (allTaskOutcomes is append/replace ordered by time).
1229
- const outcomeByTaskId = new Map<string, LaneTaskOutcome>();
1230
- for (const outcome of allTaskOutcomes) {
1231
- outcomeByTaskId.set(outcome.taskId, outcome);
1232
- }
1233
-
1234
- // Build full task registry from wave plan + any outcomes seen so far.
1235
- const taskIdSet = new Set<string>();
1236
- for (const wave of wavePlan) {
1237
- for (const taskId of wave) taskIdSet.add(taskId);
1238
- }
1239
- for (const outcome of allTaskOutcomes) {
1240
- taskIdSet.add(outcome.taskId);
1241
- }
1242
-
1243
- // 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 }>();
1245
- for (const lane of lanes) {
1246
- for (const allocTask of lane.tasks) {
1247
- allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
1248
- }
1249
- }
1250
-
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);
1257
-
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
- };
1269
-
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
- }
1277
-
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
- }
1285
-
1286
- // TP-030 v3: Serialize exit diagnostic from task outcome
1287
- if (outcome?.exitDiagnostic !== undefined) {
1288
- record.exitDiagnostic = outcome.exitDiagnostic;
1289
- }
1290
-
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
- }
1304
-
1305
- return record;
1306
- });
1307
-
1308
- // Build lane records
1309
- const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => {
1310
- const record: PersistedLaneRecord = {
1311
- laneNumber: lane.laneNumber,
1312
- laneId: lane.laneId,
1313
- laneSessionId: lane.laneSessionId,
1314
- worktreePath: lane.worktreePath,
1315
- branch: lane.branch,
1316
- taskIds: lane.tasks.map((t) => t.taskId),
1317
- };
1318
- if (lane.repoId !== undefined) {
1319
- record.repoId = lane.repoId;
1320
- }
1321
- return record;
1322
- });
1323
-
1324
- // Build merge results from actual merge outcomes (accumulated on batchState).
1325
- // MergeWaveResult.waveIndex is 1-based (from merge module); normalize to
1326
- // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1").
1327
- // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1,
1328
- // 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
- });
1349
-
1350
- const persisted: PersistedBatchState = {
1351
- schemaVersion: BATCH_STATE_SCHEMA_VERSION,
1352
- phase: state.phase,
1353
- batchId: state.batchId,
1354
- baseBranch: state.baseBranch,
1355
- orchBranch: state.orchBranch ?? "",
1356
- mode: state.mode ?? "repo",
1357
- startedAt: state.startedAt,
1358
- updatedAt: now,
1359
- endedAt: state.endedAt,
1360
- currentWaveIndex: state.currentWaveIndex,
1361
- totalWaves: state.totalWaves,
1362
- // TP-166: Persist task-level wave metadata for correct display after resume
1363
- ...(state.taskLevelWaveCount != null ? { taskLevelWaveCount: state.taskLevelWaveCount } : {}),
1364
- ...(state.roundToTaskWave != null ? { roundToTaskWave: [...state.roundToTaskWave] } : {}),
1365
- wavePlan,
1366
- lanes: laneRecords,
1367
- tasks: taskRecords,
1368
- mergeResults,
1369
- totalTasks: state.totalTasks,
1370
- succeededTasks: state.succeededTasks,
1371
- failedTasks: state.failedTasks,
1372
- skippedTasks: state.skippedTasks,
1373
- blockedTasks: state.blockedTasks,
1374
- blockedTaskIds: [...state.blockedTaskIds],
1375
- lastError: state.errors.length > 0
1376
- ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] }
1377
- : null,
1378
- errors: [...state.errors],
1379
- resilience: state.resilience ?? defaultResilienceState(),
1380
- diagnostics: state.diagnostics ?? defaultBatchDiagnostics(),
1381
- segments: state.segments ?? [],
1382
- };
1383
-
1384
- // Merge unknown fields from loaded state to preserve roundtrip fidelity.
1385
- // Extra fields are placed at the end of the object (after known schema fields)
1386
- // and will not overwrite any known field.
1387
- if (state._extraFields) {
1388
- const output = persisted as Record<string, unknown>;
1389
- for (const [key, value] of Object.entries(state._extraFields)) {
1390
- if (!(key in output)) {
1391
- output[key] = value;
1392
- }
1393
- }
1394
- }
1395
-
1396
- return JSON.stringify(persisted, null, 2);
1397
- }
1398
-
1399
- // ── File Operations ──────────────────────────────────────────────────
1400
-
1401
- /** Maximum retries for atomic write (Windows file locking). */
1402
- export const STATE_WRITE_MAX_RETRIES = 3;
1403
-
1404
- /** Delay between write retries (ms). */
1405
- export const STATE_WRITE_RETRY_DELAY_MS = 500;
1406
-
1407
- /**
1408
- * Save batch state to `.pi/batch-state.json` with atomic write.
1409
- *
1410
- * Strategy: write to a temp file (`.pi/batch-state.json.tmp`), then
1411
- * rename to the final path. This prevents partial writes from corrupting
1412
- * the state file.
1413
- *
1414
- * On Windows, rename can fail if another process holds a handle on the
1415
- * target file. We retry up to STATE_WRITE_MAX_RETRIES times with a
1416
- * short delay.
1417
- *
1418
- * @param json - JSON string to write (from serializeBatchState)
1419
- * @param repoRoot - Absolute path to the repository root
1420
- * @throws StateFileError with STATE_FILE_IO_ERROR on failure
1421
- */
1422
- export function saveBatchState(json: string, repoRoot: string): void {
1423
- const finalPath = batchStatePath(repoRoot);
1424
- const tmpPath = `${finalPath}.tmp`;
1425
- const dir = dirname(finalPath);
1426
-
1427
- // Ensure .pi directory exists
1428
- if (!existsSync(dir)) {
1429
- try {
1430
- mkdirSync(dir, { recursive: true });
1431
- } catch (err: unknown) {
1432
- throw new StateFileError(
1433
- "STATE_FILE_IO_ERROR",
1434
- `Failed to create directory "${dir}": ${(err as Error).message}`,
1435
- );
1436
- }
1437
- }
1438
-
1439
- // Write to temp file
1440
- try {
1441
- writeFileSync(tmpPath, json, "utf-8");
1442
- } catch (err: unknown) {
1443
- throw new StateFileError(
1444
- "STATE_FILE_IO_ERROR",
1445
- `Failed to write temp state file "${tmpPath}": ${(err as Error).message}`,
1446
- );
1447
- }
1448
-
1449
- // Atomic rename with retry for Windows file locking
1450
- let lastError: Error | null = null;
1451
- for (let attempt = 1; attempt <= STATE_WRITE_MAX_RETRIES; attempt++) {
1452
- try {
1453
- renameSync(tmpPath, finalPath);
1454
- return; // Success
1455
- } catch (err: unknown) {
1456
- lastError = err as Error;
1457
- if (attempt < STATE_WRITE_MAX_RETRIES) {
1458
- sleepSync(STATE_WRITE_RETRY_DELAY_MS);
1459
- }
1460
- }
1461
- }
1462
-
1463
- // All retries exhausted — clean up temp file if possible
1464
- try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
1465
-
1466
- throw new StateFileError(
1467
- "STATE_FILE_IO_ERROR",
1468
- `Failed to atomically save state file "${finalPath}" after ` +
1469
- `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
1470
- );
1471
- }
1472
-
1473
- /**
1474
- * Load and validate batch state from `.pi/batch-state.json`.
1475
- *
1476
- * @param repoRoot - Absolute path to the repository root
1477
- * @returns Validated PersistedBatchState, or null if file doesn't exist
1478
- * @throws StateFileError with STATE_FILE_PARSE_ERROR if file contains invalid JSON
1479
- * @throws StateFileError with STATE_SCHEMA_INVALID if JSON fails validation
1480
- */
1481
- export function loadBatchState(repoRoot: string): PersistedBatchState | null {
1482
- const filePath = batchStatePath(repoRoot);
1483
-
1484
- if (!existsSync(filePath)) {
1485
- return null;
1486
- }
1487
-
1488
- let raw: string;
1489
- try {
1490
- raw = readFileSync(filePath, "utf-8");
1491
- } catch (err: unknown) {
1492
- throw new StateFileError(
1493
- "STATE_FILE_IO_ERROR",
1494
- `Failed to read state file "${filePath}": ${(err as Error).message}`,
1495
- );
1496
- }
1497
-
1498
- let parsed: unknown;
1499
- try {
1500
- parsed = JSON.parse(raw);
1501
- } catch (err: unknown) {
1502
- throw new StateFileError(
1503
- "STATE_FILE_PARSE_ERROR",
1504
- `State file "${filePath}" contains invalid JSON: ${(err as Error).message}`,
1505
- );
1506
- }
1507
-
1508
- return validatePersistedState(parsed);
1509
- }
1510
-
1511
- /**
1512
- * Delete the batch state file. Idempotent: no error if file doesn't exist.
1513
- *
1514
- * @param repoRoot - Absolute path to the repository root
1515
- * @throws StateFileError with STATE_FILE_IO_ERROR on unexpected deletion failure
1516
- */
1517
- export function deleteBatchState(repoRoot: string): void {
1518
- const filePath = batchStatePath(repoRoot);
1519
-
1520
- if (!existsSync(filePath)) {
1521
- return; // Already gone — idempotent
1522
- }
1523
-
1524
- try {
1525
- unlinkSync(filePath);
1526
- } catch (err: unknown) {
1527
- // If file was deleted between our check and unlink, that's fine
1528
- if (!existsSync(filePath)) return;
1529
- throw new StateFileError(
1530
- "STATE_FILE_IO_ERROR",
1531
- `Failed to delete state file "${filePath}": ${(err as Error).message}`,
1532
- );
1533
- }
1534
- }
1535
-
1536
-
1537
- // ── Orphan Detection (TS-009 Step 3) ─────────────────────────────────
1538
-
1539
- /**
1540
- * Status of the persisted batch state file.
1541
- *
1542
- * - "valid" — File exists, parsed, and validated successfully
1543
- * - "missing" — File does not exist (normal for fresh start)
1544
- * - "invalid" — File exists but has parse or schema errors
1545
- * - "io-error" — File could not be read due to I/O error
1546
- */
1547
- export type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error";
1548
-
1549
- /**
1550
- * Recommended action based on orphan detection analysis.
1551
- *
1552
- * - "resume" — Orphan sessions + valid state, or no orphans + valid state with incomplete tasks: suggest /orch-resume
1553
- * - "abort-orphans" — Orphan sessions without usable state: suggest /orch-abort
1554
- * - "cleanup-stale" — No orphans + stale/valid/completed state: auto-delete and start fresh
1555
- * - "paused-corrupt" — No orphans + corrupt/unreadable state file: do NOT auto-delete; notify user to inspect or manually remove
1556
- * - "start-fresh" — No orphans, no state file: proceed normally
1557
- */
1558
- export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh";
1559
-
1560
- /**
1561
- * Result of orphan detection analysis.
1562
- *
1563
- * Machine-usable fields enable both automated handling and user notification.
1564
- * The `userMessage` provides a human-readable summary for display.
1565
- */
1566
- export interface OrphanDetectionResult {
1567
- /** TMUX sessions matching the orchestrator prefix that were found alive */
1568
- orphanSessions: string[];
1569
- /** Status of the persisted batch state file */
1570
- stateStatus: OrphanStateStatus;
1571
- /** Loaded and validated batch state (null if missing, invalid, or io-error) */
1572
- loadedState: PersistedBatchState | null;
1573
- /** Error message if state loading failed (null otherwise) */
1574
- stateError: string | null;
1575
- /** Deterministic recommended action */
1576
- recommendedAction: OrphanRecommendedAction;
1577
- /** Human-readable message for user notification */
1578
- userMessage: string;
1579
- }
1580
-
1581
- /**
1582
- * Parse TMUX `list-sessions -F "#{session_name}"` output.
1583
- *
1584
- * Filters session names by the given prefix (e.g., "orch" matches "orch-lane-1").
1585
- * Handles empty output, blank lines, and whitespace-padded names gracefully.
1586
- *
1587
- * Pure function — no process or filesystem access.
1588
- *
1589
- * @param stdout - Raw stdout from `tmux list-sessions -F "#{session_name}"`
1590
- * @param prefix - Session name prefix to filter by (e.g., "orch")
1591
- * @returns Sorted array of matching session names
1592
- */
1593
- export function parseOrchSessionNames(stdout: string, prefix: string): string[] {
1594
- if (!stdout || !stdout.trim()) return [];
1595
-
1596
- const filterPrefix = `${prefix}-`;
1597
-
1598
- return stdout
1599
- .split("\n")
1600
- .map(line => line.trim())
1601
- .filter(name => name.length > 0 && name.startsWith(filterPrefix))
1602
- .sort();
1603
- }
1604
-
1605
- /**
1606
- * Analyze orchestrator startup state — pure deterministic decision logic.
1607
- *
1608
- * Given the current state of TMUX sessions, batch state file, and task
1609
- * completion markers, returns a deterministic recommendation for what
1610
- * the `/orch` command should do.
1611
- *
1612
- * Decision matrix:
1613
- * | Orphans? | State Status | Done? | Action |
1614
- * |----------|-------------|-------|-----------------|
1615
- * | Yes | valid | — | resume |
1616
- * | Yes | missing | — | abort-orphans |
1617
- * | Yes | invalid | — | abort-orphans |
1618
- * | Yes | io-error | — | abort-orphans |
1619
- * | No | valid | all | cleanup-stale |
1620
- * | No | valid | !all | resume |
1621
- * | No | missing | — | start-fresh |
1622
- * | No | invalid | — | paused-corrupt |
1623
- * | No | io-error | — | paused-corrupt |
1624
- *
1625
- * Pure function — no process or filesystem access.
1626
- *
1627
- * @param orphanSessions - TMUX sessions matching the orch prefix
1628
- * @param stateStatus - Status of the batch state file
1629
- * @param loadedState - Validated batch state (null if unavailable)
1630
- * @param stateError - Error message from state loading (null if no error)
1631
- * @param doneTaskIds - Set of task IDs whose .DONE files were found
1632
- * @returns OrphanDetectionResult with recommended action
1633
- */
1634
- export function analyzeOrchestratorStartupState(
1635
- orphanSessions: string[],
1636
- stateStatus: OrphanStateStatus,
1637
- loadedState: PersistedBatchState | null,
1638
- stateError: string | null,
1639
- doneTaskIds: ReadonlySet<string>,
1640
- ): OrphanDetectionResult {
1641
- const hasOrphans = orphanSessions.length > 0;
1642
- const sessionList = orphanSessions.join(", ");
1643
-
1644
- // ── Orphan sessions exist ────────────────────────────────────
1645
- if (hasOrphans) {
1646
- if (stateStatus === "valid" && loadedState) {
1647
- return {
1648
- orphanSessions,
1649
- stateStatus,
1650
- loadedState,
1651
- stateError,
1652
- recommendedAction: "resume",
1653
- userMessage:
1654
- `🔄 Found ${orphanSessions.length} running orchestrator session(s): ${sessionList}\n` +
1655
- ` Batch ${loadedState.batchId} (${loadedState.phase}) has persisted state.\n` +
1656
- ` Use /orch-resume to continue, or /orch-abort to clean up.`,
1657
- };
1658
- }
1659
-
1660
- // Orphans without usable state (missing, invalid, or io-error)
1661
- const errorCtx = stateError ? `\n State error: ${stateError}` : "";
1662
- return {
1663
- orphanSessions,
1664
- stateStatus,
1665
- loadedState: null,
1666
- stateError,
1667
- recommendedAction: "abort-orphans",
1668
- userMessage:
1669
- `⚠️ Found ${orphanSessions.length} orphan orchestrator session(s): ${sessionList}\n` +
1670
- ` No usable batch state file (status: ${stateStatus}).${errorCtx}\n` +
1671
- ` Use /orch-abort to clean up before starting a new batch.`,
1672
- };
1673
- }
1674
-
1675
- // ── No orphan sessions ───────────────────────────────────────
1676
-
1677
- if (stateStatus === "missing") {
1678
- return {
1679
- orphanSessions: [],
1680
- stateStatus,
1681
- loadedState: null,
1682
- stateError,
1683
- recommendedAction: "start-fresh",
1684
- userMessage: "", // No message needed for clean start
1685
- };
1686
- }
1687
-
1688
- if (stateStatus === "valid" && loadedState) {
1689
- // 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));
1692
-
1693
- if (allDone) {
1694
- return {
1695
- orphanSessions: [],
1696
- stateStatus,
1697
- loadedState,
1698
- stateError,
1699
- recommendedAction: "cleanup-stale",
1700
- userMessage:
1701
- `🧹 Found stale batch state file from batch ${loadedState.batchId}.\n` +
1702
- ` All ${allTaskIds.length} task(s) have .DONE files. Cleaning up state file.`,
1703
- };
1704
- }
1705
-
1706
- // Not all tasks done — batch was interrupted (crashed orchestrator)
1707
- const completedCount = allTaskIds.filter(id => doneTaskIds.has(id)).length;
1708
-
1709
- // Only phases that resumeOrchBatch can actually handle should get "resume".
1710
- // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing
1711
- // ran yet (completedCount === 0) the state file is pure noise; auto-clean it
1712
- // so /orch can start fresh without forcing the user through /orch-abort first.
1713
- const resumablePhases: OrchBatchPhase[] = ["paused", "executing", "merging"];
1714
- const isResumable = resumablePhases.includes(loadedState.phase as OrchBatchPhase);
1715
-
1716
- if (!isResumable && completedCount === 0) {
1717
- return {
1718
- orphanSessions: [],
1719
- stateStatus,
1720
- loadedState,
1721
- stateError,
1722
- recommendedAction: "cleanup-stale",
1723
- userMessage:
1724
- `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}, 0 tasks ran).\n` +
1725
- ` Cleaning up stale state file so a fresh batch can start.`,
1726
- };
1727
- }
1728
-
1729
- return {
1730
- orphanSessions: [],
1731
- stateStatus,
1732
- loadedState,
1733
- stateError,
1734
- recommendedAction: isResumable ? "resume" : "cleanup-stale",
1735
- userMessage: isResumable
1736
- ? `🔄 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.`
1739
- : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` +
1740
- ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`,
1741
- };
1742
- }
1743
-
1744
- // Invalid or io-error state with no orphans — corrupt state.
1745
- // Never auto-delete: enter paused-corrupt so the user can inspect the file
1746
- // and decide whether to manually recover or remove it.
1747
- return {
1748
- orphanSessions: [],
1749
- stateStatus,
1750
- loadedState: null,
1751
- stateError,
1752
- recommendedAction: "paused-corrupt",
1753
- userMessage:
1754
- `⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` +
1755
- (stateError ? ` Error: ${stateError}\n` : "") +
1756
- ` The file has NOT been deleted. Inspect .pi/batch-state.json manually,\n` +
1757
- ` then either fix it or delete it and run /orch again.`,
1758
- };
1759
- }
1760
-
1761
- /**
1762
- * Detect orphan orchestrator state and analyze startup recovery action.
1763
- *
1764
- * Runtime V2 no longer relies on TMUX session discovery. Startup decisions
1765
- * are based on persisted batch state plus task .DONE markers.
1766
- *
1767
- * @param prefix - Legacy orchestrator session prefix (unused in Runtime V2)
1768
- * @param repoRoot - Absolute path to the repository root
1769
- * @returns OrphanDetectionResult with recommended action
1770
- */
1771
- export function detectOrphanSessions(prefix: string, repoRoot: string): OrphanDetectionResult {
1772
- void prefix;
1773
-
1774
- // Runtime V2 uses persisted state as the source of truth for orphan analysis.
1775
- const orphanSessions: string[] = [];
1776
-
1777
- // ── 1. Load batch state file ─────────────────────────────────
1778
- let stateStatus: OrphanStateStatus = "missing";
1779
- let loadedState: PersistedBatchState | null = null;
1780
- let stateError: string | null = null;
1781
-
1782
- try {
1783
- loadedState = loadBatchState(repoRoot);
1784
- stateStatus = loadedState ? "valid" : "missing";
1785
- } catch (err: unknown) {
1786
- if (err instanceof StateFileError) {
1787
- switch (err.code) {
1788
- case "STATE_FILE_PARSE_ERROR":
1789
- case "STATE_SCHEMA_INVALID":
1790
- stateStatus = "invalid";
1791
- stateError = `[${err.code}] ${err.message}`;
1792
- break;
1793
- case "STATE_FILE_IO_ERROR":
1794
- stateStatus = "io-error";
1795
- stateError = `[${err.code}] ${err.message}`;
1796
- break;
1797
- }
1798
- } else {
1799
- stateStatus = "io-error";
1800
- stateError = err instanceof Error ? err.message : String(err);
1801
- }
1802
- }
1803
-
1804
- // ── 2. Check .DONE files for stale state detection ───────────
1805
- const doneTaskIds = new Set<string>();
1806
- if (loadedState && orphanSessions.length === 0) {
1807
- // Only check .DONE files when we have state but no orphans
1808
- // (stale state scenario — sessions finished while orchestrator was disconnected)
1809
- for (const task of loadedState.tasks) {
1810
- if (task.taskFolder && hasTaskDoneMarker(task.taskFolder)) {
1811
- doneTaskIds.add(task.taskId);
1812
- }
1813
- }
1814
- }
1815
-
1816
- // ── 3. Analyze and return ────────────────────────────────────
1817
- return analyzeOrchestratorStartupState(
1818
- orphanSessions,
1819
- stateStatus,
1820
- loadedState,
1821
- stateError,
1822
- doneTaskIds,
1823
- );
1824
- }
1825
-
1826
-
1827
- // ── Batch History ────────────────────────────────────────────────────
1828
-
1829
- /** Path to the batch history file. */
1830
- function batchHistoryPath(repoRoot: string): string {
1831
- return join(repoRoot, ".pi", "batch-history.json");
1832
- }
1833
-
1834
- /**
1835
- * Load existing batch history entries from disk.
1836
- * Returns empty array if file doesn't exist or is invalid.
1837
- */
1838
- export function loadBatchHistory(repoRoot: string): BatchHistorySummary[] {
1839
- const filePath = batchHistoryPath(repoRoot);
1840
- try {
1841
- if (!existsSync(filePath)) return [];
1842
- const raw = readFileSync(filePath, "utf-8");
1843
- const data = JSON.parse(raw);
1844
- if (!Array.isArray(data)) return [];
1845
- return data;
1846
- } catch {
1847
- return [];
1848
- }
1849
- }
1850
-
1851
- /**
1852
- * Append a batch summary to history and trim to max entries.
1853
- * Writes atomically via tmp+rename pattern.
1854
- */
1855
- export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary): void {
1856
- const filePath = batchHistoryPath(repoRoot);
1857
- try {
1858
- const history = loadBatchHistory(repoRoot);
1859
- // Upsert by batchId so resumed batches replace their earlier partial entry
1860
- // instead of creating duplicates.
1861
- const nextHistory = history.filter(entry => entry.batchId !== summary.batchId);
1862
- // Prepend newest first
1863
- nextHistory.unshift(summary);
1864
- // Trim to max
1865
- if (nextHistory.length > BATCH_HISTORY_MAX_ENTRIES) {
1866
- nextHistory.length = BATCH_HISTORY_MAX_ENTRIES;
1867
- }
1868
- const dir = dirname(filePath);
1869
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1870
- const tmpPath = filePath + ".tmp";
1871
- writeFileSync(tmpPath, JSON.stringify(nextHistory, null, 2));
1872
- renameSync(tmpPath, filePath);
1873
- execLog("batch", "history", `saved batch summary (${nextHistory.length} entries)`);
1874
- } catch (err) {
1875
- execLog("batch", "history", `failed to save batch history: ${err}`);
1876
- }
1877
- }
1878
-
1879
- /**
1880
- * Update an existing batch history entry with the integration timestamp.
1881
- *
1882
- * Sets `integratedAt` on the matching entry (by batchId). If no entry
1883
- * is found, this is a no-op — the batch may predate the history feature.
1884
- *
1885
- * @since TP-179
1886
- */
1887
- export function updateBatchHistoryIntegration(repoRoot: string, batchId: string, integratedAt: number): void {
1888
- const filePath = batchHistoryPath(repoRoot);
1889
- try {
1890
- const history = loadBatchHistory(repoRoot);
1891
- const entry = history.find(e => e.batchId === batchId);
1892
- if (!entry) {
1893
- execLog("batch", "history", `no history entry found for batchId=${batchId}, skipping integratedAt update`);
1894
- return;
1895
- }
1896
- entry.integratedAt = integratedAt;
1897
- const dir = dirname(filePath);
1898
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1899
- const tmpPath = filePath + ".tmp";
1900
- writeFileSync(tmpPath, JSON.stringify(history, null, 2));
1901
- renameSync(tmpPath, filePath);
1902
- execLog("batch", "history", `updated integratedAt for batchId=${batchId}`);
1903
- } catch (err) {
1904
- execLog("batch", "history", `failed to update integratedAt: ${err}`);
1905
- }
1906
- }
1907
-
1908
-
1909
- // ── Tier 0 Supervisor Event Logging (TP-039 Step 2) ─────────────────
1910
-
1911
- /**
1912
- * Event types emitted by Tier 0 recovery actions.
1913
- *
1914
- * - `tier0_recovery_attempt` — A recovery action is being tried
1915
- * - `tier0_recovery_success` — Recovery succeeded
1916
- * - `tier0_recovery_exhausted` — Retry budget exhausted, escalation needed
1917
- * - `tier0_escalation` — Escalation to supervisor (emitted alongside exhausted)
1918
- *
1919
- * @since TP-039
1920
- */
1921
- export type Tier0EventType =
1922
- | "tier0_recovery_attempt"
1923
- | "tier0_recovery_success"
1924
- | "tier0_recovery_exhausted"
1925
- | "tier0_escalation";
1926
-
1927
- /**
1928
- * Structured event written to `.pi/supervisor/events.jsonl`.
1929
- *
1930
- * Each event contains enough context for the supervisor agent (Tier 1)
1931
- * to understand what happened and decide next actions.
1932
- *
1933
- * @since TP-039
1934
- */
1935
- export interface Tier0Event {
1936
- /** ISO 8601 timestamp */
1937
- timestamp: string;
1938
- /** Event type */
1939
- type: Tier0EventType;
1940
- /** Batch identifier */
1941
- batchId: string;
1942
- /** Wave index (0-based) */
1943
- waveIndex: number;
1944
- /** Recovery pattern being applied */
1945
- pattern: Tier0RecoveryPattern | "merge_timeout";
1946
- /** Current attempt number (1-based) */
1947
- attempt: number;
1948
- /** Maximum attempts allowed */
1949
- maxAttempts: number;
1950
- /** Affected task ID (for task-scoped patterns like worker_crash) */
1951
- taskId?: string;
1952
- /** Lane number (for lane-scoped patterns) */
1953
- laneNumber?: number;
1954
- /** Repo ID (for workspace-mode attribution; null/undefined for repo-mode) */
1955
- repoId?: string | null;
1956
- /** Exit classification or error type */
1957
- classification?: string;
1958
- /** Error message (for exhausted events) */
1959
- error?: string;
1960
- /** Resolution description (for success events) */
1961
- resolution?: string;
1962
- /** Cooldown/timeout in milliseconds before retry (for attempt events) */
1963
- cooldownMs?: number;
1964
- /** Scope key used for retry counter tracking */
1965
- scopeKey?: string;
1966
- /** Affected task IDs (for escalation context in exhausted events) */
1967
- affectedTaskIds?: string[];
1968
- /** Suggested remediation (for exhausted events) */
1969
- suggestion?: string;
1970
- /** Typed escalation payload (present only on `tier0_escalation` events) */
1971
- escalation?: EscalationContext;
1972
- }
1973
-
1974
- /**
1975
- * Build the required base fields for a Tier 0 event.
1976
- *
1977
- * Ensures consistent field population across all emit sites so
1978
- * supervisor consumers get a deterministic event shape.
1979
- *
1980
- * @since TP-039 R004
1981
- */
1982
- export function buildTier0EventBase(
1983
- type: Tier0EventType,
1984
- batchId: string,
1985
- waveIndex: number,
1986
- pattern: Tier0RecoveryPattern | "merge_timeout",
1987
- attempt: number,
1988
- maxAttempts: number,
1989
- ): Pick<Tier0Event, "timestamp" | "type" | "batchId" | "waveIndex" | "pattern" | "attempt" | "maxAttempts"> {
1990
- return {
1991
- timestamp: new Date().toISOString(),
1992
- type,
1993
- batchId,
1994
- waveIndex,
1995
- pattern,
1996
- attempt,
1997
- maxAttempts,
1998
- };
1999
- }
2000
-
2001
- /**
2002
- * Emit a Tier 0 event to `.pi/supervisor/events.jsonl`.
2003
- *
2004
- * Best-effort: creates the directory if needed, appends the event as a
2005
- * single JSONL line. Failures are logged but never crash the batch.
2006
- *
2007
- * @param stateRoot - Root directory for state files (workspace root or repo root)
2008
- * @param event - The event to emit
2009
- *
2010
- * @since TP-039
2011
- */
2012
- export function emitTier0Event(stateRoot: string, event: Tier0Event): void {
2013
- try {
2014
- const supervisorDir = join(stateRoot, ".pi", "supervisor");
2015
- if (!existsSync(supervisorDir)) {
2016
- mkdirSync(supervisorDir, { recursive: true });
2017
- }
2018
- const eventsPath = join(supervisorDir, "events.jsonl");
2019
- const line = JSON.stringify(event) + "\n";
2020
- appendFileSync(eventsPath, line);
2021
- } catch (err: unknown) {
2022
- // Best-effort: log but don't crash the batch
2023
- const msg = err instanceof Error ? err.message : String(err);
2024
- execLog("batch", event.batchId, `tier0 event write failed: ${msg}`, {
2025
- eventType: event.type,
2026
- pattern: event.pattern,
2027
- });
2028
- }
2029
- }
2030
-
2031
-
2032
- // ── Engine Event Logging (TP-040) ───────────────────────────────────
2033
-
2034
- /**
2035
- * Emit an engine lifecycle event to `.pi/supervisor/events.jsonl`.
2036
- *
2037
- * Shares the same JSONL file as Tier 0 events for unified consumption
2038
- * by the supervisor agent. Engine events cover batch lifecycle transitions
2039
- * (wave start/end, task completion, merge phases, batch terminal states).
2040
- *
2041
- * Best-effort: creates the directory if needed, appends the event as a
2042
- * single JSONL line. Failures are logged but never crash the batch.
2043
- *
2044
- * Also invokes the optional event callback for in-process consumers
2045
- * (command handler, dashboard).
2046
- *
2047
- * @param stateRoot - Root directory for state files (workspace root or repo root)
2048
- * @param event - The engine event to emit
2049
- * @param callback - Optional in-process event callback
2050
- *
2051
- * @since TP-040
2052
- */
2053
- export function emitEngineEvent(
2054
- stateRoot: string,
2055
- event: EngineEvent,
2056
- callback?: ((event: EngineEvent) => void) | null,
2057
- ): void {
2058
- // Write to JSONL file (same path as Tier 0 events)
2059
- try {
2060
- const supervisorDir = join(stateRoot, ".pi", "supervisor");
2061
- if (!existsSync(supervisorDir)) {
2062
- mkdirSync(supervisorDir, { recursive: true });
2063
- }
2064
- const eventsPath = join(supervisorDir, "events.jsonl");
2065
- const line = JSON.stringify(event) + "\n";
2066
- appendFileSync(eventsPath, line);
2067
- } catch (err: unknown) {
2068
- // Best-effort: log but don't crash the batch
2069
- const msg = err instanceof Error ? err.message : String(err);
2070
- execLog("batch", event.batchId, `engine event write failed: ${msg}`, {
2071
- eventType: event.type,
2072
- });
2073
- }
2074
-
2075
- // Invoke in-process callback
2076
- if (callback) {
2077
- try {
2078
- callback(event);
2079
- } catch (err: unknown) {
2080
- const msg = err instanceof Error ? err.message : String(err);
2081
- execLog("batch", event.batchId, `engine event callback failed: ${msg}`, {
2082
- eventType: event.type,
2083
- });
2084
- }
2085
- }
2086
- }
2087
-
1
+ /**
2
+ * State persistence, serialization, orphan detection
3
+ * @module orch/persistence
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, renameSync, mkdirSync, appendFileSync } from "fs";
6
+ import { join, dirname, basename } from "path";
7
+
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";
11
+ import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
12
+ import { sleepSync } from "./worktree.ts";
13
+ import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
14
+ import { normalizeLaneSessionAlias, readLaneSessionAliases } from "./tmux-compat.ts";
15
+
16
+ // ── State Persistence Helper (TS-009 Step 2) ────────────────────────
17
+
18
+ /**
19
+ * Candidate .DONE file locations for a task folder.
20
+ *
21
+ * Task-runner archives completed tasks by moving:
22
+ * tasks/<task-folder>/ → tasks/archive/<task-folder>/
23
+ *
24
+ * During resume/orphan detection we must check both locations.
25
+ */
26
+ export function getTaskDoneFileCandidates(taskFolder: string): string[] {
27
+ const candidates = [join(taskFolder, ".DONE")];
28
+ const parent = dirname(taskFolder);
29
+ const taskFolderName = basename(taskFolder);
30
+
31
+ // If already in archive, avoid duplicate candidate.
32
+ if (basename(parent).toLowerCase() !== "archive") {
33
+ candidates.push(join(parent, "archive", taskFolderName, ".DONE"));
34
+ }
35
+
36
+ return candidates;
37
+ }
38
+
39
+ /**
40
+ * Check whether a task has a .DONE marker in active or archived location.
41
+ */
42
+ export function hasTaskDoneMarker(taskFolder: string): boolean {
43
+ for (const donePath of getTaskDoneFileCandidates(taskFolder)) {
44
+ try {
45
+ if (existsSync(donePath)) return true;
46
+ } catch {
47
+ // Ignore filesystem errors here; caller handles partial visibility.
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Compare optional embedded outcome telemetry.
55
+ */
56
+ function sameOutcomeTelemetry(a: LaneTaskOutcome["telemetry"], b: LaneTaskOutcome["telemetry"]): boolean {
57
+ if (!a && !b) return true;
58
+ 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;
66
+ }
67
+
68
+ /**
69
+ * Upsert a task outcome in-place. Returns true if changed.
70
+ */
71
+ export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOutcome): boolean {
72
+ const idx = outcomes.findIndex(o => o.taskId === next.taskId);
73
+ if (idx < 0) {
74
+ outcomes.push(next);
75
+ return true;
76
+ }
77
+
78
+ const prev = outcomes[idx];
79
+ const mergedNext: LaneTaskOutcome = {
80
+ ...next,
81
+ laneNumber: next.laneNumber ?? prev.laneNumber,
82
+ telemetry: next.telemetry ?? prev.telemetry,
83
+ };
84
+
85
+ const changed =
86
+ prev.status !== mergedNext.status ||
87
+ prev.startTime !== mergedNext.startTime ||
88
+ prev.endTime !== mergedNext.endTime ||
89
+ prev.exitReason !== mergedNext.exitReason ||
90
+ prev.sessionName !== mergedNext.sessionName ||
91
+ prev.doneFileFound !== mergedNext.doneFileFound ||
92
+ prev.laneNumber !== mergedNext.laneNumber ||
93
+ !sameOutcomeTelemetry(prev.telemetry, mergedNext.telemetry) ||
94
+ prev.partialProgressCommits !== mergedNext.partialProgressCommits ||
95
+ prev.partialProgressBranch !== mergedNext.partialProgressBranch ||
96
+ prev.exitDiagnostic !== mergedNext.exitDiagnostic;
97
+
98
+ if (changed) {
99
+ outcomes[idx] = mergedNext;
100
+ }
101
+ return changed;
102
+ }
103
+
104
+ /**
105
+ * Apply partial progress preservation results to task outcomes (TP-028).
106
+ *
107
+ * After `preserveFailedLaneProgress()` runs, call this to stamp each
108
+ * successfully-preserved task outcome with the saved branch name and
109
+ * commit count. This ensures the data flows into persistence and
110
+ * diagnostics via the normal outcome → serialization path.
111
+ *
112
+ * @param ppResult - Result from `preserveFailedLaneProgress()`
113
+ * @param outcomes - Mutable array of task outcomes to update in-place
114
+ * @returns Number of outcomes that were updated
115
+ */
116
+ export function applyPartialProgressToOutcomes(
117
+ ppResult: PreserveFailedLaneProgressResult,
118
+ outcomes: LaneTaskOutcome[],
119
+ ): number {
120
+ let updated = 0;
121
+ for (const r of ppResult.results) {
122
+ if (!r.saved || !r.savedBranch) continue;
123
+ const outcome = outcomes.find(o => o.taskId === r.taskId);
124
+ if (outcome) {
125
+ outcome.partialProgressCommits = r.commitCount;
126
+ outcome.partialProgressBranch = r.savedBranch;
127
+ updated++;
128
+ }
129
+ }
130
+ return updated;
131
+ }
132
+
133
+ /**
134
+ * Seed pending outcomes for all tasks in newly allocated lanes.
135
+ *
136
+ * Ensures the persisted state has a full task registry as soon as a wave starts,
137
+ * including lane/session assignment, even before tasks finish.
138
+ */
139
+ export function seedPendingOutcomesForAllocatedLanes(
140
+ lanes: AllocatedLane[],
141
+ outcomes: LaneTaskOutcome[],
142
+ ): boolean {
143
+ let changed = false;
144
+ for (const lane of lanes) {
145
+ for (const laneTask of lane.tasks) {
146
+ const existing = outcomes.find(o => o.taskId === laneTask.taskId);
147
+ 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;
158
+ }
159
+ }
160
+ return changed;
161
+ }
162
+
163
+ /**
164
+ * Sync accumulated task outcomes from monitor snapshots.
165
+ *
166
+ * This captures in-wave task transitions (pending → running → terminal)
167
+ * so state persistence does not lag until wave completion.
168
+ */
169
+ export function syncTaskOutcomesFromMonitor(
170
+ monitorState: MonitorState,
171
+ outcomes: LaneTaskOutcome[],
172
+ ): boolean {
173
+ let changed = false;
174
+
175
+ for (const lane of monitorState.lanes) {
176
+ // Remaining tasks => pending
177
+ 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")) {
180
+ continue;
181
+ }
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;
196
+ }
197
+
198
+ // Completed tasks => succeeded
199
+ // Use existing endTime if already set — prevents changed=true on every
200
+ // poll tick (lastPollTime differs each tick, causing persist log spam).
201
+ 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;
217
+ }
218
+
219
+ // Failed tasks => failed
220
+ 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;
236
+ }
237
+
238
+ // Current task snapshot => running/stalled/succeeded/failed/skipped
239
+ if (lane.currentTaskId && lane.currentTaskSnapshot) {
240
+ const snap = lane.currentTaskSnapshot;
241
+ const existing = outcomes.find(o => o.taskId === lane.currentTaskId);
242
+ const monitorToLane: Record<TaskMonitorSnapshot["status"], LaneTaskStatus> = {
243
+ pending: "pending",
244
+ running: "running",
245
+ succeeded: "succeeded",
246
+ failed: "failed",
247
+ stalled: "stalled",
248
+ skipped: "skipped",
249
+ unknown: existing?.status || "running",
250
+ };
251
+ const mappedStatus = monitorToLane[snap.status];
252
+ const terminal = mappedStatus === "succeeded" || mappedStatus === "failed" || mappedStatus === "stalled" || mappedStatus === "skipped";
253
+
254
+ // TP-051: Use snap.observedAt (Date.now() from monitor poll) instead of
255
+ // snap.lastHeartbeat (STATUS.md mtime) for task start time. The mtime
256
+ // reflects when STATUS.md was last edited, which may be long before
257
+ // 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;
272
+ }
273
+ }
274
+
275
+ return changed;
276
+ }
277
+
278
+ /**
279
+ * Persist current runtime state to `.pi/batch-state.json`.
280
+ *
281
+ * Centralized helper that serializes runtime state, enriches task records
282
+ * with folder paths from discovery, and writes atomically. Logs the reason,
283
+ * batchId, phase, and waveIndex for each write.
284
+ *
285
+ * Write failures are non-fatal: logged as errors and added to
286
+ * batchState.errors, but do NOT crash the batch execution.
287
+ *
288
+ * @param reason - Human-readable reason for this state write (e.g., "batch-start", "wave-index-change")
289
+ * @param batchState - Current runtime batch state
290
+ * @param wavePlan - Wave plan (array of arrays of task IDs)
291
+ * @param lanes - Currently allocated lanes (latest wave's lanes)
292
+ * @param allTaskOutcomes - All task outcomes accumulated across completed waves
293
+ * @param discovery - Discovery result (for enriching taskFolder paths)
294
+ * @param repoRoot - Absolute path to the repository root
295
+ */
296
+ export function persistRuntimeState(
297
+ reason: string,
298
+ batchState: OrchBatchRuntimeState,
299
+ wavePlan: string[][],
300
+ lanes: AllocatedLane[],
301
+ allTaskOutcomes: LaneTaskOutcome[],
302
+ discovery: DiscoveryResult | null,
303
+ repoRoot: string,
304
+ ): void {
305
+ try {
306
+ const json = serializeBatchState(batchState, wavePlan, lanes, allTaskOutcomes);
307
+
308
+ // Enrich task records with folder paths and repo fields from discovery
309
+ if (discovery) {
310
+ const parsed = JSON.parse(json) as PersistedBatchState;
311
+ for (const taskRecord of parsed.tasks) {
312
+ const parsedTask = discovery.pending.get(taskRecord.taskId);
313
+ if (parsedTask) {
314
+ taskRecord.taskFolder = parsedTask.taskFolder;
315
+ // v2: Enrich repo fields for tasks not yet allocated (pending in future waves)
316
+ if (taskRecord.repoId === undefined && parsedTask.promptRepoId !== undefined) {
317
+ taskRecord.repoId = parsedTask.promptRepoId;
318
+ }
319
+ if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) {
320
+ taskRecord.resolvedRepoId = parsedTask.resolvedRepoId;
321
+ }
322
+ if ((taskRecord as any).packetRepoId === undefined && parsedTask.packetRepoId !== undefined) {
323
+ (taskRecord as any).packetRepoId = parsedTask.packetRepoId;
324
+ }
325
+ if ((taskRecord as any).packetTaskPath === undefined && parsedTask.packetTaskPath !== undefined) {
326
+ (taskRecord as any).packetTaskPath = parsedTask.packetTaskPath;
327
+ }
328
+ if ((taskRecord as any).segmentIds === undefined && parsedTask.segmentIds !== undefined) {
329
+ (taskRecord as any).segmentIds = parsedTask.segmentIds;
330
+ }
331
+ if ((taskRecord as any).activeSegmentId === undefined && parsedTask.activeSegmentId !== undefined) {
332
+ (taskRecord as any).activeSegmentId = parsedTask.activeSegmentId;
333
+ }
334
+ }
335
+ }
336
+ const enrichedJson = JSON.stringify(parsed, null, 2);
337
+ saveBatchState(enrichedJson, repoRoot);
338
+ } else {
339
+ saveBatchState(json, repoRoot);
340
+ }
341
+
342
+ execLog("state", batchState.batchId, `persisted: ${reason}`, {
343
+ phase: batchState.phase,
344
+ waveIndex: batchState.currentWaveIndex,
345
+ });
346
+ } catch (err: unknown) {
347
+ const msg = err instanceof StateFileError
348
+ ? `[${err.code}] ${err.message}`
349
+ : (err instanceof Error ? err.message : String(err));
350
+ execLog("state", batchState.batchId, `write failed: ${msg}`, {
351
+ reason,
352
+ phase: batchState.phase,
353
+ });
354
+ batchState.errors.push(`State persistence failed (${reason}): ${msg}`);
355
+ }
356
+ }
357
+
358
+
359
+ // ── State Validation ─────────────────────────────────────────────────
360
+
361
+ /** All valid OrchBatchPhase values for validation. */
362
+ export const VALID_BATCH_PHASES: ReadonlySet<string> = new Set([
363
+ "idle", "launching", "planning", "executing", "merging", "paused", "stopped", "completed", "failed",
364
+ ]);
365
+
366
+ /** All valid LaneTaskStatus values for validation. */
367
+ export const VALID_TASK_STATUSES: ReadonlySet<string> = new Set([
368
+ "pending", "running", "succeeded", "failed", "stalled", "skipped",
369
+ ]);
370
+
371
+ /** All valid merge result statuses for persisted state. */
372
+ export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet<string> = new Set([
373
+ "succeeded", "failed", "partial",
374
+ ]);
375
+
376
+ /**
377
+ * Upconvert a v1 state object to v2 in-memory.
378
+ *
379
+ * Applied automatically by `validatePersistedState()` when a v1 file is loaded.
380
+ * The on-disk file is NOT rewritten — upconversion is purely in-memory.
381
+ *
382
+ * v1→v2 field defaults:
383
+ * - `schemaVersion`: bumped from 1 → 2
384
+ * - `baseBranch`: defaults to "" (was already handled in v1 validation)
385
+ * - `mode`: defaults to "repo" (v1 was always single-repo)
386
+ * - `tasks[].repoId`: remains undefined (repo mode has no repo routing)
387
+ * - `tasks[].resolvedRepoId`: remains undefined (same reason)
388
+ * - `lanes[].repoId`: preserved if present (was already serialized in v1
389
+ * when workspace mode was partially implemented)
390
+ *
391
+ * This function is idempotent: calling it on an already-v2 object is a no-op.
392
+ *
393
+ * @param obj - Parsed state object (mutated in-place)
394
+ */
395
+ export function upconvertV1toV2(obj: Record<string, unknown>): void {
396
+ if ((obj.schemaVersion as number) >= 2) return;
397
+ obj.schemaVersion = 2;
398
+ if (!obj.baseBranch) obj.baseBranch = "";
399
+ if (!obj.mode) obj.mode = "repo";
400
+ // Task and lane records: v2 optional fields default to undefined (omitted)
401
+ // which is already their state in v1 objects. No mutation needed.
402
+ }
403
+
404
+ /**
405
+ * Upconvert a v2 state object to v3 by adding resilience and diagnostics
406
+ * sections with conservative defaults.
407
+ *
408
+ * Added fields:
409
+ * - `resilience`: default empty resilience state (no retries, no repairs)
410
+ * - `diagnostics`: default empty diagnostics (no task exits, zero batch cost)
411
+ *
412
+ * This function is idempotent: calling it on an already-v3 object is a no-op.
413
+ *
414
+ * @param obj - Parsed state object (mutated in-place)
415
+ */
416
+ export function upconvertV2toV3(obj: Record<string, unknown>): void {
417
+ if ((obj.schemaVersion as number) >= 3) return;
418
+ obj.schemaVersion = 3;
419
+ // Backfill v3 sections with conservative defaults only during genuine
420
+ // v1/v2→v3 migration. A native v3 file missing these sections is
421
+ // malformed and must be rejected by validation — not silently patched.
422
+ if (!obj.resilience) obj.resilience = defaultResilienceState();
423
+ if (!obj.diagnostics) obj.diagnostics = defaultBatchDiagnostics();
424
+ }
425
+
426
+ /**
427
+ * Upconvert a v3 state object to v4 by adding the `segments` array.
428
+ *
429
+ * Added fields:
430
+ * - `segments`: empty array (no segment records exist in pre-v4 state)
431
+ *
432
+ * Task-level segment fields (`packetRepoId`, `packetTaskPath`,
433
+ * `segmentIds`, `activeSegmentId`) are optional and default to
434
+ * `undefined` (omitted from JSON). They are NOT backfilled here
435
+ * because their values depend on runtime discovery, not on
436
+ * migration defaults.
437
+ *
438
+ * This function is idempotent: calling it on an already-v4 object is a no-op.
439
+ *
440
+ * @param obj - Parsed state object (mutated in-place)
441
+ */
442
+ export function upconvertV3toV4(obj: Record<string, unknown>): void {
443
+ if ((obj.schemaVersion as number) >= 4) return;
444
+ obj.schemaVersion = 4;
445
+ // Backfill v4 segments with empty array only during genuine v3→v4 migration.
446
+ if (!obj.segments) obj.segments = [];
447
+ }
448
+
449
+ /**
450
+ * Validate a parsed JSON object as a PersistedBatchState.
451
+ *
452
+ * Checks:
453
+ * 1. Schema version is 1 (auto-upconverted to v2→v3), 2 (upconverted to v3), or 3 (current)
454
+ * 2. All required fields are present with correct types
455
+ * 3. Enum fields contain valid values (phase, task statuses, merge statuses)
456
+ * 4. Arrays contain valid sub-records
457
+ * 5. v2 optional fields (repoId, resolvedRepoId, mode) are valid when present
458
+ *
459
+ * @param data - Parsed JSON (unknown type)
460
+ * @returns Validated PersistedBatchState (always v3, even if input was v1/v2)
461
+ * @throws StateFileError with STATE_SCHEMA_INVALID on any validation failure
462
+ */
463
+ export function validatePersistedState(data: unknown): PersistedBatchState {
464
+ if (!data || typeof data !== "object") {
465
+ throw new StateFileError(
466
+ "STATE_SCHEMA_INVALID",
467
+ "Batch state must be a non-null object",
468
+ );
469
+ }
470
+
471
+ const obj = data as Record<string, unknown>;
472
+
473
+ // ── Schema version ───────────────────────────────────────────
474
+ if (typeof obj.schemaVersion !== "number") {
475
+ throw new StateFileError(
476
+ "STATE_SCHEMA_INVALID",
477
+ `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`,
478
+ );
479
+ }
480
+ // Accept v1 (auto-upconvert to v2→v3→v4), v2 (upconvert to v3→v4), v3 (upconvert to v4), and v4 (current).
481
+ // Reject anything else — including future versions from newer runtimes.
482
+ const ACCEPTED_VERSIONS = [1, 2, 3, BATCH_STATE_SCHEMA_VERSION];
483
+ if (!ACCEPTED_VERSIONS.includes(obj.schemaVersion as number)) {
484
+ throw new StateFileError(
485
+ "STATE_SCHEMA_INVALID",
486
+ `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.`,
489
+ );
490
+ }
491
+ const isV1 = obj.schemaVersion === 1;
492
+
493
+ // ── Required string fields ───────────────────────────────────
494
+ for (const field of ["phase", "batchId"] as const) {
495
+ if (typeof obj[field] !== "string") {
496
+ throw new StateFileError(
497
+ "STATE_SCHEMA_INVALID",
498
+ `Missing or invalid "${field}" field (expected string, got ${typeof obj[field]})`,
499
+ );
500
+ }
501
+ }
502
+
503
+ // ── Optional string fields (backward-compatible) ─────────────
504
+ // baseBranch was added after schema v1; default to empty string if missing
505
+ if (obj.baseBranch !== undefined && typeof obj.baseBranch !== "string") {
506
+ throw new StateFileError(
507
+ "STATE_SCHEMA_INVALID",
508
+ `Invalid "baseBranch" field (expected string, got ${typeof obj.baseBranch})`,
509
+ );
510
+ }
511
+
512
+ // ── Optional string fields: orchBranch ───────────────────────
513
+ // orchBranch was added after schema v2 shipped; default to "" if missing.
514
+ if (obj.orchBranch !== undefined && typeof obj.orchBranch !== "string") {
515
+ throw new StateFileError(
516
+ "STATE_SCHEMA_INVALID",
517
+ `Invalid "orchBranch" field (expected string, got ${typeof obj.orchBranch})`,
518
+ );
519
+ }
520
+ if (obj.orchBranch === undefined) {
521
+ obj.orchBranch = "";
522
+ }
523
+
524
+ // ── v2: mode field ───────────────────────────────────────────
525
+ // mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
526
+ if (!isV1 && obj.mode === undefined) {
527
+ throw new StateFileError(
528
+ "STATE_SCHEMA_INVALID",
529
+ `Missing required "mode" field in schema v2 (expected "repo" or "workspace")`,
530
+ );
531
+ }
532
+ if (obj.mode !== undefined && typeof obj.mode !== "string") {
533
+ throw new StateFileError(
534
+ "STATE_SCHEMA_INVALID",
535
+ `Invalid "mode" field (expected string, got ${typeof obj.mode})`,
536
+ );
537
+ }
538
+ if (obj.mode !== undefined && obj.mode !== "repo" && obj.mode !== "workspace") {
539
+ throw new StateFileError(
540
+ "STATE_SCHEMA_INVALID",
541
+ `Invalid "mode" value "${obj.mode}" (expected "repo" or "workspace")`,
542
+ );
543
+ }
544
+
545
+ // ── Phase enum validation ────────────────────────────────────
546
+ if (!VALID_BATCH_PHASES.has(obj.phase as string)) {
547
+ throw new StateFileError(
548
+ "STATE_SCHEMA_INVALID",
549
+ `Invalid "phase" value "${obj.phase}" (expected one of: ${[...VALID_BATCH_PHASES].join(", ")})`,
550
+ );
551
+ }
552
+
553
+ // ── Required number fields ───────────────────────────────────
554
+ for (const field of [
555
+ "startedAt", "updatedAt", "currentWaveIndex", "totalWaves",
556
+ "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
557
+ ] as const) {
558
+ if (typeof obj[field] !== "number") {
559
+ throw new StateFileError(
560
+ "STATE_SCHEMA_INVALID",
561
+ `Missing or invalid "${field}" field (expected number, got ${typeof obj[field]})`,
562
+ );
563
+ }
564
+ }
565
+
566
+ // ── Nullable number: endedAt ─────────────────────────────────
567
+ if (obj.endedAt !== null && typeof obj.endedAt !== "number") {
568
+ throw new StateFileError(
569
+ "STATE_SCHEMA_INVALID",
570
+ `Invalid "endedAt" field (expected number or null, got ${typeof obj.endedAt})`,
571
+ );
572
+ }
573
+
574
+ // ── Required arrays ──────────────────────────────────────────
575
+ for (const field of ["wavePlan", "lanes", "tasks", "mergeResults", "blockedTaskIds", "errors"] as const) {
576
+ if (!Array.isArray(obj[field])) {
577
+ throw new StateFileError(
578
+ "STATE_SCHEMA_INVALID",
579
+ `Missing or invalid "${field}" field (expected array, got ${typeof obj[field]})`,
580
+ );
581
+ }
582
+ }
583
+
584
+ // ── Validate wavePlan: array of arrays of strings ────────────
585
+ const wavePlan = obj.wavePlan as unknown[];
586
+ for (let i = 0; i < wavePlan.length; i++) {
587
+ if (!Array.isArray(wavePlan[i])) {
588
+ throw new StateFileError(
589
+ "STATE_SCHEMA_INVALID",
590
+ `wavePlan[${i}] is not an array`,
591
+ );
592
+ }
593
+ for (const taskId of wavePlan[i] as unknown[]) {
594
+ if (typeof taskId !== "string") {
595
+ throw new StateFileError(
596
+ "STATE_SCHEMA_INVALID",
597
+ `wavePlan[${i}] contains non-string value: ${typeof taskId}`,
598
+ );
599
+ }
600
+ }
601
+ }
602
+
603
+ // ── Validate task records ────────────────────────────────────
604
+ const tasks = obj.tasks as unknown[];
605
+ for (let i = 0; i < tasks.length; i++) {
606
+ const t = tasks[i] as Record<string, unknown>;
607
+ if (!t || typeof t !== "object") {
608
+ throw new StateFileError(
609
+ "STATE_SCHEMA_INVALID",
610
+ `tasks[${i}] is not an object`,
611
+ );
612
+ }
613
+ for (const field of ["taskId", "sessionName", "taskFolder", "exitReason"] as const) {
614
+ if (typeof t[field] !== "string") {
615
+ throw new StateFileError(
616
+ "STATE_SCHEMA_INVALID",
617
+ `tasks[${i}].${field} is missing or not a string`,
618
+ );
619
+ }
620
+ }
621
+ if (typeof t.laneNumber !== "number") {
622
+ throw new StateFileError(
623
+ "STATE_SCHEMA_INVALID",
624
+ `tasks[${i}].laneNumber is missing or not a number`,
625
+ );
626
+ }
627
+ if (typeof t.status !== "string" || !VALID_TASK_STATUSES.has(t.status)) {
628
+ throw new StateFileError(
629
+ "STATE_SCHEMA_INVALID",
630
+ `tasks[${i}].status is invalid: "${t.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`,
631
+ );
632
+ }
633
+ if (t.startedAt !== null && typeof t.startedAt !== "number") {
634
+ throw new StateFileError(
635
+ "STATE_SCHEMA_INVALID",
636
+ `tasks[${i}].startedAt is not a number or null`,
637
+ );
638
+ }
639
+ 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
+ );
644
+ }
645
+ if (typeof t.doneFileFound !== "boolean") {
646
+ throw new StateFileError(
647
+ "STATE_SCHEMA_INVALID",
648
+ `tasks[${i}].doneFileFound is missing or not a boolean`,
649
+ );
650
+ }
651
+ // v2 optional fields: repoId, resolvedRepoId (string | undefined)
652
+ if (t.repoId !== undefined && typeof t.repoId !== "string") {
653
+ throw new StateFileError(
654
+ "STATE_SCHEMA_INVALID",
655
+ `tasks[${i}].repoId is not a string (got ${typeof t.repoId})`,
656
+ );
657
+ }
658
+ if (t.resolvedRepoId !== undefined && typeof t.resolvedRepoId !== "string") {
659
+ throw new StateFileError(
660
+ "STATE_SCHEMA_INVALID",
661
+ `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`,
662
+ );
663
+ }
664
+ // TP-028 optional fields: partialProgressCommits (number | undefined), partialProgressBranch (string | undefined)
665
+ if (t.partialProgressCommits !== undefined && typeof t.partialProgressCommits !== "number") {
666
+ throw new StateFileError(
667
+ "STATE_SCHEMA_INVALID",
668
+ `tasks[${i}].partialProgressCommits is not a number (got ${typeof t.partialProgressCommits})`,
669
+ );
670
+ }
671
+ if (t.partialProgressBranch !== undefined && typeof t.partialProgressBranch !== "string") {
672
+ throw new StateFileError(
673
+ "STATE_SCHEMA_INVALID",
674
+ `tasks[${i}].partialProgressBranch is not a string (got ${typeof t.partialProgressBranch})`,
675
+ );
676
+ }
677
+ // TP-026 optional field: exitDiagnostic (object with classification string | undefined)
678
+ if (t.exitDiagnostic !== undefined) {
679
+ if (typeof t.exitDiagnostic !== "object" || t.exitDiagnostic === null || Array.isArray(t.exitDiagnostic)) {
680
+ throw new StateFileError(
681
+ "STATE_SCHEMA_INVALID",
682
+ `tasks[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(t.exitDiagnostic) ? "array" : typeof t.exitDiagnostic})`,
683
+ );
684
+ }
685
+ if (typeof (t.exitDiagnostic as any).classification !== "string") {
686
+ throw new StateFileError(
687
+ "STATE_SCHEMA_INVALID",
688
+ `tasks[${i}].exitDiagnostic.classification is not a string (got ${typeof (t.exitDiagnostic as any).classification})`,
689
+ );
690
+ }
691
+ }
692
+ }
693
+
694
+ // ── Validate lane records ────────────────────────────────────
695
+ const lanes = obj.lanes as unknown[];
696
+ const legacyTmuxSessionLaneIndexes: number[] = [];
697
+ for (let i = 0; i < lanes.length; i++) {
698
+ const l = lanes[i] as Record<string, unknown>;
699
+ if (!l || typeof l !== "object") {
700
+ throw new StateFileError(
701
+ "STATE_SCHEMA_INVALID",
702
+ `lanes[${i}] is not an object`,
703
+ );
704
+ }
705
+ for (const field of ["laneId", "worktreePath", "branch"] as const) {
706
+ if (typeof l[field] !== "string") {
707
+ throw new StateFileError(
708
+ "STATE_SCHEMA_INVALID",
709
+ `lanes[${i}].${field} is missing or not a string`,
710
+ );
711
+ }
712
+ }
713
+
714
+ const { laneSessionId, tmuxSessionName } = readLaneSessionAliases(l);
715
+ if (laneSessionId !== undefined && typeof laneSessionId !== "string") {
716
+ throw new StateFileError(
717
+ "STATE_SCHEMA_INVALID",
718
+ `lanes[${i}].laneSessionId is not a string (got ${typeof laneSessionId})`,
719
+ );
720
+ }
721
+
722
+ if (tmuxSessionName !== undefined && typeof tmuxSessionName !== "string") {
723
+ throw new StateFileError(
724
+ "STATE_SCHEMA_INVALID",
725
+ `lanes[${i}].tmuxSessionName is not a string (got ${typeof tmuxSessionName})`,
726
+ );
727
+ }
728
+
729
+ if (typeof laneSessionId !== "string" && typeof tmuxSessionName !== "string") {
730
+ throw new StateFileError(
731
+ "STATE_SCHEMA_INVALID",
732
+ `lanes[${i}] must include either laneSessionId or tmuxSessionName as a string`,
733
+ );
734
+ }
735
+
736
+ if (typeof tmuxSessionName === "string") {
737
+ legacyTmuxSessionLaneIndexes.push(i);
738
+ }
739
+
740
+ normalizeLaneSessionAlias(l);
741
+
742
+ if (typeof l.laneNumber !== "number") {
743
+ throw new StateFileError(
744
+ "STATE_SCHEMA_INVALID",
745
+ `lanes[${i}].laneNumber is missing or not a number`,
746
+ );
747
+ }
748
+ if (!Array.isArray(l.taskIds)) {
749
+ throw new StateFileError(
750
+ "STATE_SCHEMA_INVALID",
751
+ `lanes[${i}].taskIds is missing or not an array`,
752
+ );
753
+ }
754
+ // v2 optional field: repoId (string | undefined)
755
+ if (l.repoId !== undefined && typeof l.repoId !== "string") {
756
+ throw new StateFileError(
757
+ "STATE_SCHEMA_INVALID",
758
+ `lanes[${i}].repoId is not a string (got ${typeof l.repoId})`,
759
+ );
760
+ }
761
+ }
762
+
763
+ if (legacyTmuxSessionLaneIndexes.length > 0) {
764
+ console.error(
765
+ "[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.",
767
+ );
768
+ }
769
+
770
+ // ── Validate merge results ───────────────────────────────────
771
+ const mergeResults = obj.mergeResults as unknown[];
772
+ for (let i = 0; i < mergeResults.length; i++) {
773
+ const m = mergeResults[i] as Record<string, unknown>;
774
+ if (!m || typeof m !== "object") {
775
+ throw new StateFileError(
776
+ "STATE_SCHEMA_INVALID",
777
+ `mergeResults[${i}] is not an object`,
778
+ );
779
+ }
780
+ if (typeof m.waveIndex !== "number") {
781
+ throw new StateFileError(
782
+ "STATE_SCHEMA_INVALID",
783
+ `mergeResults[${i}].waveIndex is missing or not a number`,
784
+ );
785
+ }
786
+ if (typeof m.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(m.status)) {
787
+ throw new StateFileError(
788
+ "STATE_SCHEMA_INVALID",
789
+ `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`,
790
+ );
791
+ }
792
+ // v2 optional field: repoResults (array | undefined)
793
+ if (m.repoResults !== undefined) {
794
+ if (!Array.isArray(m.repoResults)) {
795
+ throw new StateFileError(
796
+ "STATE_SCHEMA_INVALID",
797
+ `mergeResults[${i}].repoResults is not an array (got ${typeof m.repoResults})`,
798
+ );
799
+ }
800
+ for (let j = 0; j < (m.repoResults as unknown[]).length; j++) {
801
+ const rr = (m.repoResults as unknown[])[j] as Record<string, unknown>;
802
+ if (!rr || typeof rr !== "object") {
803
+ throw new StateFileError(
804
+ "STATE_SCHEMA_INVALID",
805
+ `mergeResults[${i}].repoResults[${j}] is not an object`,
806
+ );
807
+ }
808
+ if (typeof rr.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(rr.status)) {
809
+ throw new StateFileError(
810
+ "STATE_SCHEMA_INVALID",
811
+ `mergeResults[${i}].repoResults[${j}].status is invalid: "${rr.status}"`,
812
+ );
813
+ }
814
+ if (!Array.isArray(rr.laneNumbers)) {
815
+ throw new StateFileError(
816
+ "STATE_SCHEMA_INVALID",
817
+ `mergeResults[${i}].repoResults[${j}].laneNumbers is not an array`,
818
+ );
819
+ }
820
+ }
821
+ }
822
+ }
823
+
824
+ // ── Validate lastError ───────────────────────────────────────
825
+ if (obj.lastError !== null) {
826
+ if (typeof obj.lastError !== "object") {
827
+ throw new StateFileError(
828
+ "STATE_SCHEMA_INVALID",
829
+ `lastError is not an object or null`,
830
+ );
831
+ }
832
+ const le = obj.lastError as Record<string, unknown>;
833
+ if (typeof le.code !== "string" || typeof le.message !== "string") {
834
+ throw new StateFileError(
835
+ "STATE_SCHEMA_INVALID",
836
+ `lastError must have "code" (string) and "message" (string) fields`,
837
+ );
838
+ }
839
+ }
840
+
841
+ // ── Validate blockedTaskIds: array of strings ────────────────
842
+ for (const id of obj.blockedTaskIds as unknown[]) {
843
+ if (typeof id !== "string") {
844
+ throw new StateFileError(
845
+ "STATE_SCHEMA_INVALID",
846
+ `blockedTaskIds contains non-string value: ${typeof id}`,
847
+ );
848
+ }
849
+ }
850
+
851
+ // ── Validate errors: array of strings ────────────────────────
852
+ for (const err of obj.errors as unknown[]) {
853
+ if (typeof err !== "string") {
854
+ throw new StateFileError(
855
+ "STATE_SCHEMA_INVALID",
856
+ `errors array contains non-string value: ${typeof err}`,
857
+ );
858
+ }
859
+ }
860
+
861
+ // ── v1→v2→v3→v4 upconversion ─────────────────────────────────
862
+ // Apply defaults for fields that may be absent in older state files.
863
+ // The on-disk file is NOT rewritten; upconversion is in-memory only.
864
+ // Chain: v1→v2 then v2→v3 then v3→v4 (each is idempotent / no-op if already at target).
865
+ upconvertV1toV2(obj);
866
+ upconvertV2toV3(obj);
867
+ upconvertV3toV4(obj);
868
+
869
+ // ── Validate v3 resilience section ───────────────────────────
870
+ // After upconversion, resilience must be a valid object with correct types.
871
+ if (!obj.resilience || typeof obj.resilience !== "object") {
872
+ throw new StateFileError(
873
+ "STATE_SCHEMA_INVALID",
874
+ `Missing or invalid "resilience" section (expected object, got ${typeof obj.resilience})`,
875
+ );
876
+ }
877
+ const res = obj.resilience as Record<string, unknown>;
878
+ if (typeof res.resumeForced !== "boolean") {
879
+ throw new StateFileError(
880
+ "STATE_SCHEMA_INVALID",
881
+ `resilience.resumeForced must be a boolean (got ${typeof res.resumeForced})`,
882
+ );
883
+ }
884
+ if (!res.retryCountByScope || typeof res.retryCountByScope !== "object" || Array.isArray(res.retryCountByScope)) {
885
+ throw new StateFileError(
886
+ "STATE_SCHEMA_INVALID",
887
+ `resilience.retryCountByScope must be an object (got ${typeof res.retryCountByScope})`,
888
+ );
889
+ }
890
+ // Deep-validate retryCountByScope: all values must be numbers
891
+ for (const [scope, count] of Object.entries(res.retryCountByScope as Record<string, unknown>)) {
892
+ if (typeof count !== "number") {
893
+ throw new StateFileError(
894
+ "STATE_SCHEMA_INVALID",
895
+ `resilience.retryCountByScope["${scope}"] must be a number (got ${typeof count})`,
896
+ );
897
+ }
898
+ }
899
+ if (res.lastFailureClass !== null && typeof res.lastFailureClass !== "string") {
900
+ throw new StateFileError(
901
+ "STATE_SCHEMA_INVALID",
902
+ `resilience.lastFailureClass must be a string or null (got ${typeof res.lastFailureClass})`,
903
+ );
904
+ }
905
+ if (!Array.isArray(res.repairHistory)) {
906
+ throw new StateFileError(
907
+ "STATE_SCHEMA_INVALID",
908
+ `resilience.repairHistory must be an array (got ${typeof res.repairHistory})`,
909
+ );
910
+ }
911
+ // Deep-validate repairHistory entries
912
+ for (let i = 0; i < (res.repairHistory as unknown[]).length; i++) {
913
+ const rec = (res.repairHistory as unknown[])[i];
914
+ if (!rec || typeof rec !== "object") {
915
+ throw new StateFileError(
916
+ "STATE_SCHEMA_INVALID",
917
+ `resilience.repairHistory[${i}] must be an object (got ${typeof rec})`,
918
+ );
919
+ }
920
+ const r = rec as Record<string, unknown>;
921
+ if (typeof r.id !== "string") {
922
+ throw new StateFileError(
923
+ "STATE_SCHEMA_INVALID",
924
+ `resilience.repairHistory[${i}].id must be a string (got ${typeof r.id})`,
925
+ );
926
+ }
927
+ if (typeof r.strategy !== "string") {
928
+ throw new StateFileError(
929
+ "STATE_SCHEMA_INVALID",
930
+ `resilience.repairHistory[${i}].strategy must be a string (got ${typeof r.strategy})`,
931
+ );
932
+ }
933
+ const VALID_REPAIR_STATUSES = new Set(["succeeded", "failed", "skipped"]);
934
+ if (typeof r.status !== "string" || !VALID_REPAIR_STATUSES.has(r.status)) {
935
+ throw new StateFileError(
936
+ "STATE_SCHEMA_INVALID",
937
+ `resilience.repairHistory[${i}].status must be "succeeded"|"failed"|"skipped" (got ${JSON.stringify(r.status)})`,
938
+ );
939
+ }
940
+ if (typeof r.startedAt !== "number") {
941
+ throw new StateFileError(
942
+ "STATE_SCHEMA_INVALID",
943
+ `resilience.repairHistory[${i}].startedAt must be a number (got ${typeof r.startedAt})`,
944
+ );
945
+ }
946
+ if (typeof r.endedAt !== "number") {
947
+ throw new StateFileError(
948
+ "STATE_SCHEMA_INVALID",
949
+ `resilience.repairHistory[${i}].endedAt must be a number (got ${typeof r.endedAt})`,
950
+ );
951
+ }
952
+ // repoId is optional — validate type only if present
953
+ if (r.repoId !== undefined && typeof r.repoId !== "string") {
954
+ throw new StateFileError(
955
+ "STATE_SCHEMA_INVALID",
956
+ `resilience.repairHistory[${i}].repoId must be a string when present (got ${typeof r.repoId})`,
957
+ );
958
+ }
959
+ }
960
+
961
+ // ── Validate v3 diagnostics section ──────────────────────────
962
+ // After upconversion, diagnostics must be a valid object with correct types.
963
+ if (!obj.diagnostics || typeof obj.diagnostics !== "object") {
964
+ throw new StateFileError(
965
+ "STATE_SCHEMA_INVALID",
966
+ `Missing or invalid "diagnostics" section (expected object, got ${typeof obj.diagnostics})`,
967
+ );
968
+ }
969
+ const diag = obj.diagnostics as Record<string, unknown>;
970
+ if (!diag.taskExits || typeof diag.taskExits !== "object" || Array.isArray(diag.taskExits)) {
971
+ throw new StateFileError(
972
+ "STATE_SCHEMA_INVALID",
973
+ `diagnostics.taskExits must be an object (got ${typeof diag.taskExits})`,
974
+ );
975
+ }
976
+ // Deep-validate taskExits entries
977
+ for (const [taskId, entry] of Object.entries(diag.taskExits as Record<string, unknown>)) {
978
+ if (!entry || typeof entry !== "object") {
979
+ throw new StateFileError(
980
+ "STATE_SCHEMA_INVALID",
981
+ `diagnostics.taskExits["${taskId}"] must be an object (got ${typeof entry})`,
982
+ );
983
+ }
984
+ const te = entry as Record<string, unknown>;
985
+ if (typeof te.classification !== "string") {
986
+ throw new StateFileError(
987
+ "STATE_SCHEMA_INVALID",
988
+ `diagnostics.taskExits["${taskId}"].classification must be a string (got ${typeof te.classification})`,
989
+ );
990
+ }
991
+ if (typeof te.cost !== "number") {
992
+ throw new StateFileError(
993
+ "STATE_SCHEMA_INVALID",
994
+ `diagnostics.taskExits["${taskId}"].cost must be a number (got ${typeof te.cost})`,
995
+ );
996
+ }
997
+ if (typeof te.durationSec !== "number") {
998
+ throw new StateFileError(
999
+ "STATE_SCHEMA_INVALID",
1000
+ `diagnostics.taskExits["${taskId}"].durationSec must be a number (got ${typeof te.durationSec})`,
1001
+ );
1002
+ }
1003
+ // retries is optional — validate type only if present
1004
+ if (te.retries !== undefined && typeof te.retries !== "number") {
1005
+ throw new StateFileError(
1006
+ "STATE_SCHEMA_INVALID",
1007
+ `diagnostics.taskExits["${taskId}"].retries must be a number when present (got ${typeof te.retries})`,
1008
+ );
1009
+ }
1010
+ }
1011
+ if (typeof diag.batchCost !== "number") {
1012
+ throw new StateFileError(
1013
+ "STATE_SCHEMA_INVALID",
1014
+ `diagnostics.batchCost must be a number (got ${typeof diag.batchCost})`,
1015
+ );
1016
+ }
1017
+
1018
+ // ── Validate exitDiagnostic on task records (optional) ───────
1019
+ for (let i = 0; i < tasks.length; i++) {
1020
+ const t = tasks[i] as Record<string, unknown>;
1021
+ if (t.exitDiagnostic !== undefined) {
1022
+ if (!t.exitDiagnostic || typeof t.exitDiagnostic !== "object") {
1023
+ throw new StateFileError(
1024
+ "STATE_SCHEMA_INVALID",
1025
+ `tasks[${i}].exitDiagnostic must be an object when present (got ${typeof t.exitDiagnostic})`,
1026
+ );
1027
+ }
1028
+ const ed = t.exitDiagnostic as Record<string, unknown>;
1029
+ if (typeof ed.classification !== "string") {
1030
+ throw new StateFileError(
1031
+ "STATE_SCHEMA_INVALID",
1032
+ `tasks[${i}].exitDiagnostic.classification must be a string (got ${typeof ed.classification})`,
1033
+ );
1034
+ }
1035
+ }
1036
+ // v4 optional fields: packetRepoId, packetTaskPath (string | undefined)
1037
+ if (t.packetRepoId !== undefined && typeof t.packetRepoId !== "string") {
1038
+ throw new StateFileError(
1039
+ "STATE_SCHEMA_INVALID",
1040
+ `tasks[${i}].packetRepoId is not a string (got ${typeof t.packetRepoId})`,
1041
+ );
1042
+ }
1043
+ if (t.packetTaskPath !== undefined && typeof t.packetTaskPath !== "string") {
1044
+ throw new StateFileError(
1045
+ "STATE_SCHEMA_INVALID",
1046
+ `tasks[${i}].packetTaskPath is not a string (got ${typeof t.packetTaskPath})`,
1047
+ );
1048
+ }
1049
+ // v4 optional field: segmentIds (string[] | undefined)
1050
+ if (t.segmentIds !== undefined) {
1051
+ if (!Array.isArray(t.segmentIds)) {
1052
+ throw new StateFileError(
1053
+ "STATE_SCHEMA_INVALID",
1054
+ `tasks[${i}].segmentIds is not an array (got ${typeof t.segmentIds})`,
1055
+ );
1056
+ }
1057
+ for (let j = 0; j < (t.segmentIds as unknown[]).length; j++) {
1058
+ if (typeof (t.segmentIds as unknown[])[j] !== "string") {
1059
+ throw new StateFileError(
1060
+ "STATE_SCHEMA_INVALID",
1061
+ `tasks[${i}].segmentIds[${j}] is not a string`,
1062
+ );
1063
+ }
1064
+ }
1065
+ }
1066
+ // v4 optional field: activeSegmentId (string | null | undefined)
1067
+ if (t.activeSegmentId !== undefined && t.activeSegmentId !== null && typeof t.activeSegmentId !== "string") {
1068
+ throw new StateFileError(
1069
+ "STATE_SCHEMA_INVALID",
1070
+ `tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`,
1071
+ );
1072
+ }
1073
+ }
1074
+
1075
+ // ── Validate v4 segments array ───────────────────────────────
1076
+ if (!Array.isArray(obj.segments)) {
1077
+ throw new StateFileError(
1078
+ "STATE_SCHEMA_INVALID",
1079
+ `Missing or invalid "segments" field (expected array, got ${typeof obj.segments})`,
1080
+ );
1081
+ }
1082
+ const segments = obj.segments as unknown[];
1083
+ for (let i = 0; i < segments.length; i++) {
1084
+ const s = segments[i] as Record<string, unknown>;
1085
+ if (!s || typeof s !== "object") {
1086
+ throw new StateFileError(
1087
+ "STATE_SCHEMA_INVALID",
1088
+ `segments[${i}] is not an object`,
1089
+ );
1090
+ }
1091
+ // Required string fields
1092
+ for (const field of ["segmentId", "taskId", "repoId", "laneId", "sessionName", "worktreePath", "branch", "exitReason"] as const) {
1093
+ if (typeof s[field] !== "string") {
1094
+ throw new StateFileError(
1095
+ "STATE_SCHEMA_INVALID",
1096
+ `segments[${i}].${field} is missing or not a string (got ${typeof s[field]})`,
1097
+ );
1098
+ }
1099
+ }
1100
+ // Required status field (same valid values as task status)
1101
+ if (typeof s.status !== "string" || !VALID_TASK_STATUSES.has(s.status)) {
1102
+ throw new StateFileError(
1103
+ "STATE_SCHEMA_INVALID",
1104
+ `segments[${i}].status is invalid: "${s.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`,
1105
+ );
1106
+ }
1107
+ // Nullable number fields: startedAt, endedAt
1108
+ if (s.startedAt !== null && typeof s.startedAt !== "number") {
1109
+ throw new StateFileError(
1110
+ "STATE_SCHEMA_INVALID",
1111
+ `segments[${i}].startedAt is not a number or null (got ${typeof s.startedAt})`,
1112
+ );
1113
+ }
1114
+ if (s.endedAt !== null && typeof s.endedAt !== "number") {
1115
+ throw new StateFileError(
1116
+ "STATE_SCHEMA_INVALID",
1117
+ `segments[${i}].endedAt is not a number or null (got ${typeof s.endedAt})`,
1118
+ );
1119
+ }
1120
+ // Required number: retries
1121
+ if (typeof s.retries !== "number") {
1122
+ throw new StateFileError(
1123
+ "STATE_SCHEMA_INVALID",
1124
+ `segments[${i}].retries is not a number (got ${typeof s.retries})`,
1125
+ );
1126
+ }
1127
+ // Required array: dependsOnSegmentIds
1128
+ if (!Array.isArray(s.dependsOnSegmentIds)) {
1129
+ throw new StateFileError(
1130
+ "STATE_SCHEMA_INVALID",
1131
+ `segments[${i}].dependsOnSegmentIds is not an array (got ${typeof s.dependsOnSegmentIds})`,
1132
+ );
1133
+ }
1134
+ for (let j = 0; j < (s.dependsOnSegmentIds as unknown[]).length; j++) {
1135
+ if (typeof (s.dependsOnSegmentIds as unknown[])[j] !== "string") {
1136
+ throw new StateFileError(
1137
+ "STATE_SCHEMA_INVALID",
1138
+ `segments[${i}].dependsOnSegmentIds[${j}] is not a string`,
1139
+ );
1140
+ }
1141
+ }
1142
+ if (s.expandedFrom !== undefined && typeof s.expandedFrom !== "string") {
1143
+ throw new StateFileError(
1144
+ "STATE_SCHEMA_INVALID",
1145
+ `segments[${i}].expandedFrom is not a string when present (got ${typeof s.expandedFrom})`,
1146
+ );
1147
+ }
1148
+ if (s.expansionRequestId !== undefined && typeof s.expansionRequestId !== "string") {
1149
+ throw new StateFileError(
1150
+ "STATE_SCHEMA_INVALID",
1151
+ `segments[${i}].expansionRequestId is not a string when present (got ${typeof s.expansionRequestId})`,
1152
+ );
1153
+ }
1154
+ // Optional exitDiagnostic
1155
+ if (s.exitDiagnostic !== undefined) {
1156
+ if (!s.exitDiagnostic || typeof s.exitDiagnostic !== "object" || Array.isArray(s.exitDiagnostic)) {
1157
+ throw new StateFileError(
1158
+ "STATE_SCHEMA_INVALID",
1159
+ `segments[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(s.exitDiagnostic) ? "array" : typeof s.exitDiagnostic})`,
1160
+ );
1161
+ }
1162
+ if (typeof (s.exitDiagnostic as Record<string, unknown>).classification !== "string") {
1163
+ throw new StateFileError(
1164
+ "STATE_SCHEMA_INVALID",
1165
+ `segments[${i}].exitDiagnostic.classification is not a string`,
1166
+ );
1167
+ }
1168
+ }
1169
+ }
1170
+
1171
+ // ── Capture unknown top-level fields for roundtrip preservation ──
1172
+ // Any fields not in the known schema are preserved so they survive
1173
+ // serialization. This protects against data loss from future schema
1174
+ // extensions or external tools writing additional fields.
1175
+ 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",
1182
+ "segments",
1183
+ "_extraFields",
1184
+ ]);
1185
+ const extraFields: Record<string, unknown> = {};
1186
+ for (const key of Object.keys(obj)) {
1187
+ if (!KNOWN_TOP_LEVEL_FIELDS.has(key)) {
1188
+ extraFields[key] = obj[key];
1189
+ }
1190
+ }
1191
+ if (Object.keys(extraFields).length > 0) {
1192
+ obj._extraFields = extraFields;
1193
+ }
1194
+
1195
+ return obj as unknown as PersistedBatchState;
1196
+ }
1197
+
1198
+ // ── Serialization ────────────────────────────────────────────────────
1199
+
1200
+ /**
1201
+ * Serialize runtime batch state to a PersistedBatchState JSON string.
1202
+ *
1203
+ * Pure function: extracts the serializable subset from OrchBatchRuntimeState
1204
+ * and its associated wave results, enriches with schema version and timestamps.
1205
+ *
1206
+ * @param state - Current runtime batch state
1207
+ * @param wavePlan - Wave plan (array of arrays of task IDs)
1208
+ * @param lanes - Currently allocated lanes (latest wave's lanes)
1209
+ * @param allTaskOutcomes - All task outcomes across completed waves + current
1210
+ * @returns JSON string (pretty-printed for debuggability)
1211
+ */
1212
+ export function serializeBatchState(
1213
+ state: OrchBatchRuntimeState,
1214
+ wavePlan: string[][],
1215
+ lanes: AllocatedLane[],
1216
+ allTaskOutcomes: LaneTaskOutcome[],
1217
+ ): string {
1218
+ const now = Date.now();
1219
+
1220
+ // Build lookup maps for fast per-task enrichment.
1221
+ const laneByTaskId = new Map<string, AllocatedLane>();
1222
+ for (const lane of lanes) {
1223
+ for (const task of lane.tasks) {
1224
+ laneByTaskId.set(task.taskId, lane);
1225
+ }
1226
+ }
1227
+
1228
+ // Latest outcome wins (allTaskOutcomes is append/replace ordered by time).
1229
+ const outcomeByTaskId = new Map<string, LaneTaskOutcome>();
1230
+ for (const outcome of allTaskOutcomes) {
1231
+ outcomeByTaskId.set(outcome.taskId, outcome);
1232
+ }
1233
+
1234
+ // Build full task registry from wave plan + any outcomes seen so far.
1235
+ const taskIdSet = new Set<string>();
1236
+ for (const wave of wavePlan) {
1237
+ for (const taskId of wave) taskIdSet.add(taskId);
1238
+ }
1239
+ for (const outcome of allTaskOutcomes) {
1240
+ taskIdSet.add(outcome.taskId);
1241
+ }
1242
+
1243
+ // 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 }>();
1245
+ for (const lane of lanes) {
1246
+ for (const allocTask of lane.tasks) {
1247
+ allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
1248
+ }
1249
+ }
1250
+
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);
1257
+
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
+ };
1269
+
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
+ }
1277
+
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
+ }
1285
+
1286
+ // TP-030 v3: Serialize exit diagnostic from task outcome
1287
+ if (outcome?.exitDiagnostic !== undefined) {
1288
+ record.exitDiagnostic = outcome.exitDiagnostic;
1289
+ }
1290
+
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
+ }
1304
+
1305
+ return record;
1306
+ });
1307
+
1308
+ // Build lane records
1309
+ const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => {
1310
+ const record: PersistedLaneRecord = {
1311
+ laneNumber: lane.laneNumber,
1312
+ laneId: lane.laneId,
1313
+ laneSessionId: lane.laneSessionId,
1314
+ worktreePath: lane.worktreePath,
1315
+ branch: lane.branch,
1316
+ taskIds: lane.tasks.map((t) => t.taskId),
1317
+ };
1318
+ if (lane.repoId !== undefined) {
1319
+ record.repoId = lane.repoId;
1320
+ }
1321
+ return record;
1322
+ });
1323
+
1324
+ // Build merge results from actual merge outcomes (accumulated on batchState).
1325
+ // MergeWaveResult.waveIndex is 1-based (from merge module); normalize to
1326
+ // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1").
1327
+ // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1,
1328
+ // 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
+ });
1349
+
1350
+ const persisted: PersistedBatchState = {
1351
+ schemaVersion: BATCH_STATE_SCHEMA_VERSION,
1352
+ phase: state.phase,
1353
+ batchId: state.batchId,
1354
+ baseBranch: state.baseBranch,
1355
+ orchBranch: state.orchBranch ?? "",
1356
+ mode: state.mode ?? "repo",
1357
+ startedAt: state.startedAt,
1358
+ updatedAt: now,
1359
+ endedAt: state.endedAt,
1360
+ currentWaveIndex: state.currentWaveIndex,
1361
+ totalWaves: state.totalWaves,
1362
+ // TP-166: Persist task-level wave metadata for correct display after resume
1363
+ ...(state.taskLevelWaveCount != null ? { taskLevelWaveCount: state.taskLevelWaveCount } : {}),
1364
+ ...(state.roundToTaskWave != null ? { roundToTaskWave: [...state.roundToTaskWave] } : {}),
1365
+ wavePlan,
1366
+ lanes: laneRecords,
1367
+ tasks: taskRecords,
1368
+ mergeResults,
1369
+ totalTasks: state.totalTasks,
1370
+ succeededTasks: state.succeededTasks,
1371
+ failedTasks: state.failedTasks,
1372
+ skippedTasks: state.skippedTasks,
1373
+ blockedTasks: state.blockedTasks,
1374
+ blockedTaskIds: [...state.blockedTaskIds],
1375
+ lastError: state.errors.length > 0
1376
+ ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] }
1377
+ : null,
1378
+ errors: [...state.errors],
1379
+ resilience: state.resilience ?? defaultResilienceState(),
1380
+ diagnostics: state.diagnostics ?? defaultBatchDiagnostics(),
1381
+ segments: state.segments ?? [],
1382
+ };
1383
+
1384
+ // Merge unknown fields from loaded state to preserve roundtrip fidelity.
1385
+ // Extra fields are placed at the end of the object (after known schema fields)
1386
+ // and will not overwrite any known field.
1387
+ if (state._extraFields) {
1388
+ const output = persisted as Record<string, unknown>;
1389
+ for (const [key, value] of Object.entries(state._extraFields)) {
1390
+ if (!(key in output)) {
1391
+ output[key] = value;
1392
+ }
1393
+ }
1394
+ }
1395
+
1396
+ return JSON.stringify(persisted, null, 2);
1397
+ }
1398
+
1399
+ // ── File Operations ──────────────────────────────────────────────────
1400
+
1401
+ /** Maximum retries for atomic write (Windows file locking). */
1402
+ export const STATE_WRITE_MAX_RETRIES = 3;
1403
+
1404
+ /** Delay between write retries (ms). */
1405
+ export const STATE_WRITE_RETRY_DELAY_MS = 500;
1406
+
1407
+ /**
1408
+ * Save batch state to `.pi/batch-state.json` with atomic write.
1409
+ *
1410
+ * Strategy: write to a temp file (`.pi/batch-state.json.tmp`), then
1411
+ * rename to the final path. This prevents partial writes from corrupting
1412
+ * the state file.
1413
+ *
1414
+ * On Windows, rename can fail if another process holds a handle on the
1415
+ * target file. We retry up to STATE_WRITE_MAX_RETRIES times with a
1416
+ * short delay.
1417
+ *
1418
+ * @param json - JSON string to write (from serializeBatchState)
1419
+ * @param repoRoot - Absolute path to the repository root
1420
+ * @throws StateFileError with STATE_FILE_IO_ERROR on failure
1421
+ */
1422
+ export function saveBatchState(json: string, repoRoot: string): void {
1423
+ const finalPath = batchStatePath(repoRoot);
1424
+ const tmpPath = `${finalPath}.tmp`;
1425
+ const dir = dirname(finalPath);
1426
+
1427
+ // Ensure .pi directory exists
1428
+ if (!existsSync(dir)) {
1429
+ try {
1430
+ mkdirSync(dir, { recursive: true });
1431
+ } catch (err: unknown) {
1432
+ throw new StateFileError(
1433
+ "STATE_FILE_IO_ERROR",
1434
+ `Failed to create directory "${dir}": ${(err as Error).message}`,
1435
+ );
1436
+ }
1437
+ }
1438
+
1439
+ // Write to temp file
1440
+ try {
1441
+ writeFileSync(tmpPath, json, "utf-8");
1442
+ } catch (err: unknown) {
1443
+ throw new StateFileError(
1444
+ "STATE_FILE_IO_ERROR",
1445
+ `Failed to write temp state file "${tmpPath}": ${(err as Error).message}`,
1446
+ );
1447
+ }
1448
+
1449
+ // Atomic rename with retry for Windows file locking
1450
+ let lastError: Error | null = null;
1451
+ for (let attempt = 1; attempt <= STATE_WRITE_MAX_RETRIES; attempt++) {
1452
+ try {
1453
+ renameSync(tmpPath, finalPath);
1454
+ return; // Success
1455
+ } catch (err: unknown) {
1456
+ lastError = err as Error;
1457
+ if (attempt < STATE_WRITE_MAX_RETRIES) {
1458
+ sleepSync(STATE_WRITE_RETRY_DELAY_MS);
1459
+ }
1460
+ }
1461
+ }
1462
+
1463
+ // All retries exhausted — clean up temp file if possible
1464
+ try { unlinkSync(tmpPath); } catch { /* ignore cleanup errors */ }
1465
+
1466
+ throw new StateFileError(
1467
+ "STATE_FILE_IO_ERROR",
1468
+ `Failed to atomically save state file "${finalPath}" after ` +
1469
+ `${STATE_WRITE_MAX_RETRIES} attempts: ${lastError?.message ?? "unknown error"}`,
1470
+ );
1471
+ }
1472
+
1473
+ /**
1474
+ * Load and validate batch state from `.pi/batch-state.json`.
1475
+ *
1476
+ * @param repoRoot - Absolute path to the repository root
1477
+ * @returns Validated PersistedBatchState, or null if file doesn't exist
1478
+ * @throws StateFileError with STATE_FILE_PARSE_ERROR if file contains invalid JSON
1479
+ * @throws StateFileError with STATE_SCHEMA_INVALID if JSON fails validation
1480
+ */
1481
+ export function loadBatchState(repoRoot: string): PersistedBatchState | null {
1482
+ const filePath = batchStatePath(repoRoot);
1483
+
1484
+ if (!existsSync(filePath)) {
1485
+ return null;
1486
+ }
1487
+
1488
+ let raw: string;
1489
+ try {
1490
+ raw = readFileSync(filePath, "utf-8");
1491
+ } catch (err: unknown) {
1492
+ throw new StateFileError(
1493
+ "STATE_FILE_IO_ERROR",
1494
+ `Failed to read state file "${filePath}": ${(err as Error).message}`,
1495
+ );
1496
+ }
1497
+
1498
+ let parsed: unknown;
1499
+ try {
1500
+ parsed = JSON.parse(raw);
1501
+ } catch (err: unknown) {
1502
+ throw new StateFileError(
1503
+ "STATE_FILE_PARSE_ERROR",
1504
+ `State file "${filePath}" contains invalid JSON: ${(err as Error).message}`,
1505
+ );
1506
+ }
1507
+
1508
+ return validatePersistedState(parsed);
1509
+ }
1510
+
1511
+ /**
1512
+ * Delete the batch state file. Idempotent: no error if file doesn't exist.
1513
+ *
1514
+ * @param repoRoot - Absolute path to the repository root
1515
+ * @throws StateFileError with STATE_FILE_IO_ERROR on unexpected deletion failure
1516
+ */
1517
+ export function deleteBatchState(repoRoot: string): void {
1518
+ const filePath = batchStatePath(repoRoot);
1519
+
1520
+ if (!existsSync(filePath)) {
1521
+ return; // Already gone — idempotent
1522
+ }
1523
+
1524
+ try {
1525
+ unlinkSync(filePath);
1526
+ } catch (err: unknown) {
1527
+ // If file was deleted between our check and unlink, that's fine
1528
+ if (!existsSync(filePath)) return;
1529
+ throw new StateFileError(
1530
+ "STATE_FILE_IO_ERROR",
1531
+ `Failed to delete state file "${filePath}": ${(err as Error).message}`,
1532
+ );
1533
+ }
1534
+ }
1535
+
1536
+
1537
+ // ── Orphan Detection (TS-009 Step 3) ─────────────────────────────────
1538
+
1539
+ /**
1540
+ * Status of the persisted batch state file.
1541
+ *
1542
+ * - "valid" — File exists, parsed, and validated successfully
1543
+ * - "missing" — File does not exist (normal for fresh start)
1544
+ * - "invalid" — File exists but has parse or schema errors
1545
+ * - "io-error" — File could not be read due to I/O error
1546
+ */
1547
+ export type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error";
1548
+
1549
+ /**
1550
+ * Recommended action based on orphan detection analysis.
1551
+ *
1552
+ * - "resume" — Orphan sessions + valid state, or no orphans + valid state with incomplete tasks: suggest /orch-resume
1553
+ * - "abort-orphans" — Orphan sessions without usable state: suggest /orch-abort
1554
+ * - "cleanup-stale" — No orphans + stale/valid/completed state: auto-delete and start fresh
1555
+ * - "paused-corrupt" — No orphans + corrupt/unreadable state file: do NOT auto-delete; notify user to inspect or manually remove
1556
+ * - "start-fresh" — No orphans, no state file: proceed normally
1557
+ */
1558
+ export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh";
1559
+
1560
+ /**
1561
+ * Result of orphan detection analysis.
1562
+ *
1563
+ * Machine-usable fields enable both automated handling and user notification.
1564
+ * The `userMessage` provides a human-readable summary for display.
1565
+ */
1566
+ export interface OrphanDetectionResult {
1567
+ /** TMUX sessions matching the orchestrator prefix that were found alive */
1568
+ orphanSessions: string[];
1569
+ /** Status of the persisted batch state file */
1570
+ stateStatus: OrphanStateStatus;
1571
+ /** Loaded and validated batch state (null if missing, invalid, or io-error) */
1572
+ loadedState: PersistedBatchState | null;
1573
+ /** Error message if state loading failed (null otherwise) */
1574
+ stateError: string | null;
1575
+ /** Deterministic recommended action */
1576
+ recommendedAction: OrphanRecommendedAction;
1577
+ /** Human-readable message for user notification */
1578
+ userMessage: string;
1579
+ }
1580
+
1581
+ /**
1582
+ * Parse TMUX `list-sessions -F "#{session_name}"` output.
1583
+ *
1584
+ * Filters session names by the given prefix (e.g., "orch" matches "orch-lane-1").
1585
+ * Handles empty output, blank lines, and whitespace-padded names gracefully.
1586
+ *
1587
+ * Pure function — no process or filesystem access.
1588
+ *
1589
+ * @param stdout - Raw stdout from `tmux list-sessions -F "#{session_name}"`
1590
+ * @param prefix - Session name prefix to filter by (e.g., "orch")
1591
+ * @returns Sorted array of matching session names
1592
+ */
1593
+ export function parseOrchSessionNames(stdout: string, prefix: string): string[] {
1594
+ if (!stdout || !stdout.trim()) return [];
1595
+
1596
+ const filterPrefix = `${prefix}-`;
1597
+
1598
+ return stdout
1599
+ .split("\n")
1600
+ .map(line => line.trim())
1601
+ .filter(name => name.length > 0 && name.startsWith(filterPrefix))
1602
+ .sort();
1603
+ }
1604
+
1605
+ /**
1606
+ * Analyze orchestrator startup state — pure deterministic decision logic.
1607
+ *
1608
+ * Given the current state of TMUX sessions, batch state file, and task
1609
+ * completion markers, returns a deterministic recommendation for what
1610
+ * the `/orch` command should do.
1611
+ *
1612
+ * Decision matrix:
1613
+ * | Orphans? | State Status | Done? | Action |
1614
+ * |----------|-------------|-------|-----------------|
1615
+ * | Yes | valid | — | resume |
1616
+ * | Yes | missing | — | abort-orphans |
1617
+ * | Yes | invalid | — | abort-orphans |
1618
+ * | Yes | io-error | — | abort-orphans |
1619
+ * | No | valid | all | cleanup-stale |
1620
+ * | No | valid | !all | resume |
1621
+ * | No | missing | — | start-fresh |
1622
+ * | No | invalid | — | paused-corrupt |
1623
+ * | No | io-error | — | paused-corrupt |
1624
+ *
1625
+ * Pure function — no process or filesystem access.
1626
+ *
1627
+ * @param orphanSessions - TMUX sessions matching the orch prefix
1628
+ * @param stateStatus - Status of the batch state file
1629
+ * @param loadedState - Validated batch state (null if unavailable)
1630
+ * @param stateError - Error message from state loading (null if no error)
1631
+ * @param doneTaskIds - Set of task IDs whose .DONE files were found
1632
+ * @returns OrphanDetectionResult with recommended action
1633
+ */
1634
+ export function analyzeOrchestratorStartupState(
1635
+ orphanSessions: string[],
1636
+ stateStatus: OrphanStateStatus,
1637
+ loadedState: PersistedBatchState | null,
1638
+ stateError: string | null,
1639
+ doneTaskIds: ReadonlySet<string>,
1640
+ ): OrphanDetectionResult {
1641
+ const hasOrphans = orphanSessions.length > 0;
1642
+ const sessionList = orphanSessions.join(", ");
1643
+
1644
+ // ── Orphan sessions exist ────────────────────────────────────
1645
+ if (hasOrphans) {
1646
+ if (stateStatus === "valid" && loadedState) {
1647
+ return {
1648
+ orphanSessions,
1649
+ stateStatus,
1650
+ loadedState,
1651
+ stateError,
1652
+ recommendedAction: "resume",
1653
+ userMessage:
1654
+ `🔄 Found ${orphanSessions.length} running orchestrator session(s): ${sessionList}\n` +
1655
+ ` Batch ${loadedState.batchId} (${loadedState.phase}) has persisted state.\n` +
1656
+ ` Use /orch-resume to continue, or /orch-abort to clean up.`,
1657
+ };
1658
+ }
1659
+
1660
+ // Orphans without usable state (missing, invalid, or io-error)
1661
+ const errorCtx = stateError ? `\n State error: ${stateError}` : "";
1662
+ return {
1663
+ orphanSessions,
1664
+ stateStatus,
1665
+ loadedState: null,
1666
+ stateError,
1667
+ recommendedAction: "abort-orphans",
1668
+ userMessage:
1669
+ `⚠️ Found ${orphanSessions.length} orphan orchestrator session(s): ${sessionList}\n` +
1670
+ ` No usable batch state file (status: ${stateStatus}).${errorCtx}\n` +
1671
+ ` Use /orch-abort to clean up before starting a new batch.`,
1672
+ };
1673
+ }
1674
+
1675
+ // ── No orphan sessions ───────────────────────────────────────
1676
+
1677
+ if (stateStatus === "missing") {
1678
+ return {
1679
+ orphanSessions: [],
1680
+ stateStatus,
1681
+ loadedState: null,
1682
+ stateError,
1683
+ recommendedAction: "start-fresh",
1684
+ userMessage: "", // No message needed for clean start
1685
+ };
1686
+ }
1687
+
1688
+ if (stateStatus === "valid" && loadedState) {
1689
+ // 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));
1692
+
1693
+ if (allDone) {
1694
+ return {
1695
+ orphanSessions: [],
1696
+ stateStatus,
1697
+ loadedState,
1698
+ stateError,
1699
+ recommendedAction: "cleanup-stale",
1700
+ userMessage:
1701
+ `🧹 Found stale batch state file from batch ${loadedState.batchId}.\n` +
1702
+ ` All ${allTaskIds.length} task(s) have .DONE files. Cleaning up state file.`,
1703
+ };
1704
+ }
1705
+
1706
+ // Not all tasks done — batch was interrupted (crashed orchestrator)
1707
+ const completedCount = allTaskIds.filter(id => doneTaskIds.has(id)).length;
1708
+
1709
+ // Only phases that resumeOrchBatch can actually handle should get "resume".
1710
+ // "failed" / "stopped" / "idle" / "planning" are non-resumable — if nothing
1711
+ // ran yet (completedCount === 0) the state file is pure noise; auto-clean it
1712
+ // so /orch can start fresh without forcing the user through /orch-abort first.
1713
+ const resumablePhases: OrchBatchPhase[] = ["paused", "executing", "merging"];
1714
+ const isResumable = resumablePhases.includes(loadedState.phase as OrchBatchPhase);
1715
+
1716
+ if (!isResumable && completedCount === 0) {
1717
+ return {
1718
+ orphanSessions: [],
1719
+ stateStatus,
1720
+ loadedState,
1721
+ stateError,
1722
+ recommendedAction: "cleanup-stale",
1723
+ userMessage:
1724
+ `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}, 0 tasks ran).\n` +
1725
+ ` Cleaning up stale state file so a fresh batch can start.`,
1726
+ };
1727
+ }
1728
+
1729
+ return {
1730
+ orphanSessions: [],
1731
+ stateStatus,
1732
+ loadedState,
1733
+ stateError,
1734
+ recommendedAction: isResumable ? "resume" : "cleanup-stale",
1735
+ userMessage: isResumable
1736
+ ? `🔄 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.`
1739
+ : `🧹 Found non-resumable batch state (${loadedState.batchId}, phase=${loadedState.phase}).\n` +
1740
+ ` ${completedCount}/${allTaskIds.length} task(s) completed. Cleaning up state file.`,
1741
+ };
1742
+ }
1743
+
1744
+ // Invalid or io-error state with no orphans — corrupt state.
1745
+ // Never auto-delete: enter paused-corrupt so the user can inspect the file
1746
+ // and decide whether to manually recover or remove it.
1747
+ return {
1748
+ orphanSessions: [],
1749
+ stateStatus,
1750
+ loadedState: null,
1751
+ stateError,
1752
+ recommendedAction: "paused-corrupt",
1753
+ userMessage:
1754
+ `⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` +
1755
+ (stateError ? ` Error: ${stateError}\n` : "") +
1756
+ ` The file has NOT been deleted. Inspect .pi/batch-state.json manually,\n` +
1757
+ ` then either fix it or delete it and run /orch again.`,
1758
+ };
1759
+ }
1760
+
1761
+ /**
1762
+ * Detect orphan orchestrator state and analyze startup recovery action.
1763
+ *
1764
+ * Runtime V2 no longer relies on TMUX session discovery. Startup decisions
1765
+ * are based on persisted batch state plus task .DONE markers.
1766
+ *
1767
+ * @param prefix - Legacy orchestrator session prefix (unused in Runtime V2)
1768
+ * @param repoRoot - Absolute path to the repository root
1769
+ * @returns OrphanDetectionResult with recommended action
1770
+ */
1771
+ export function detectOrphanSessions(prefix: string, repoRoot: string): OrphanDetectionResult {
1772
+ void prefix;
1773
+
1774
+ // Runtime V2 uses persisted state as the source of truth for orphan analysis.
1775
+ const orphanSessions: string[] = [];
1776
+
1777
+ // ── 1. Load batch state file ─────────────────────────────────
1778
+ let stateStatus: OrphanStateStatus = "missing";
1779
+ let loadedState: PersistedBatchState | null = null;
1780
+ let stateError: string | null = null;
1781
+
1782
+ try {
1783
+ loadedState = loadBatchState(repoRoot);
1784
+ stateStatus = loadedState ? "valid" : "missing";
1785
+ } catch (err: unknown) {
1786
+ if (err instanceof StateFileError) {
1787
+ switch (err.code) {
1788
+ case "STATE_FILE_PARSE_ERROR":
1789
+ case "STATE_SCHEMA_INVALID":
1790
+ stateStatus = "invalid";
1791
+ stateError = `[${err.code}] ${err.message}`;
1792
+ break;
1793
+ case "STATE_FILE_IO_ERROR":
1794
+ stateStatus = "io-error";
1795
+ stateError = `[${err.code}] ${err.message}`;
1796
+ break;
1797
+ }
1798
+ } else {
1799
+ stateStatus = "io-error";
1800
+ stateError = err instanceof Error ? err.message : String(err);
1801
+ }
1802
+ }
1803
+
1804
+ // ── 2. Check .DONE files for stale state detection ───────────
1805
+ const doneTaskIds = new Set<string>();
1806
+ if (loadedState && orphanSessions.length === 0) {
1807
+ // Only check .DONE files when we have state but no orphans
1808
+ // (stale state scenario — sessions finished while orchestrator was disconnected)
1809
+ for (const task of loadedState.tasks) {
1810
+ if (task.taskFolder && hasTaskDoneMarker(task.taskFolder)) {
1811
+ doneTaskIds.add(task.taskId);
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ // ── 3. Analyze and return ────────────────────────────────────
1817
+ return analyzeOrchestratorStartupState(
1818
+ orphanSessions,
1819
+ stateStatus,
1820
+ loadedState,
1821
+ stateError,
1822
+ doneTaskIds,
1823
+ );
1824
+ }
1825
+
1826
+
1827
+ // ── Batch History ────────────────────────────────────────────────────
1828
+
1829
+ /** Path to the batch history file. */
1830
+ function batchHistoryPath(repoRoot: string): string {
1831
+ return join(repoRoot, ".pi", "batch-history.json");
1832
+ }
1833
+
1834
+ /**
1835
+ * Load existing batch history entries from disk.
1836
+ * Returns empty array if file doesn't exist or is invalid.
1837
+ */
1838
+ export function loadBatchHistory(repoRoot: string): BatchHistorySummary[] {
1839
+ const filePath = batchHistoryPath(repoRoot);
1840
+ try {
1841
+ if (!existsSync(filePath)) return [];
1842
+ const raw = readFileSync(filePath, "utf-8");
1843
+ const data = JSON.parse(raw);
1844
+ if (!Array.isArray(data)) return [];
1845
+ return data;
1846
+ } catch {
1847
+ return [];
1848
+ }
1849
+ }
1850
+
1851
+ /**
1852
+ * Append a batch summary to history and trim to max entries.
1853
+ * Writes atomically via tmp+rename pattern.
1854
+ */
1855
+ export function saveBatchHistory(repoRoot: string, summary: BatchHistorySummary): void {
1856
+ const filePath = batchHistoryPath(repoRoot);
1857
+ try {
1858
+ const history = loadBatchHistory(repoRoot);
1859
+ // Upsert by batchId so resumed batches replace their earlier partial entry
1860
+ // instead of creating duplicates.
1861
+ const nextHistory = history.filter(entry => entry.batchId !== summary.batchId);
1862
+ // Prepend newest first
1863
+ nextHistory.unshift(summary);
1864
+ // Trim to max
1865
+ if (nextHistory.length > BATCH_HISTORY_MAX_ENTRIES) {
1866
+ nextHistory.length = BATCH_HISTORY_MAX_ENTRIES;
1867
+ }
1868
+ const dir = dirname(filePath);
1869
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1870
+ const tmpPath = filePath + ".tmp";
1871
+ writeFileSync(tmpPath, JSON.stringify(nextHistory, null, 2));
1872
+ renameSync(tmpPath, filePath);
1873
+ execLog("batch", "history", `saved batch summary (${nextHistory.length} entries)`);
1874
+ } catch (err) {
1875
+ execLog("batch", "history", `failed to save batch history: ${err}`);
1876
+ }
1877
+ }
1878
+
1879
+ /**
1880
+ * Update an existing batch history entry with the integration timestamp.
1881
+ *
1882
+ * Sets `integratedAt` on the matching entry (by batchId). If no entry
1883
+ * is found, this is a no-op — the batch may predate the history feature.
1884
+ *
1885
+ * @since TP-179
1886
+ */
1887
+ export function updateBatchHistoryIntegration(repoRoot: string, batchId: string, integratedAt: number): void {
1888
+ const filePath = batchHistoryPath(repoRoot);
1889
+ try {
1890
+ const history = loadBatchHistory(repoRoot);
1891
+ const entry = history.find(e => e.batchId === batchId);
1892
+ if (!entry) {
1893
+ execLog("batch", "history", `no history entry found for batchId=${batchId}, skipping integratedAt update`);
1894
+ return;
1895
+ }
1896
+ entry.integratedAt = integratedAt;
1897
+ const dir = dirname(filePath);
1898
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1899
+ const tmpPath = filePath + ".tmp";
1900
+ writeFileSync(tmpPath, JSON.stringify(history, null, 2));
1901
+ renameSync(tmpPath, filePath);
1902
+ execLog("batch", "history", `updated integratedAt for batchId=${batchId}`);
1903
+ } catch (err) {
1904
+ execLog("batch", "history", `failed to update integratedAt: ${err}`);
1905
+ }
1906
+ }
1907
+
1908
+
1909
+ // ── Tier 0 Supervisor Event Logging (TP-039 Step 2) ─────────────────
1910
+
1911
+ /**
1912
+ * Event types emitted by Tier 0 recovery actions.
1913
+ *
1914
+ * - `tier0_recovery_attempt` — A recovery action is being tried
1915
+ * - `tier0_recovery_success` — Recovery succeeded
1916
+ * - `tier0_recovery_exhausted` — Retry budget exhausted, escalation needed
1917
+ * - `tier0_escalation` — Escalation to supervisor (emitted alongside exhausted)
1918
+ *
1919
+ * @since TP-039
1920
+ */
1921
+ export type Tier0EventType =
1922
+ | "tier0_recovery_attempt"
1923
+ | "tier0_recovery_success"
1924
+ | "tier0_recovery_exhausted"
1925
+ | "tier0_escalation";
1926
+
1927
+ /**
1928
+ * Structured event written to `.pi/supervisor/events.jsonl`.
1929
+ *
1930
+ * Each event contains enough context for the supervisor agent (Tier 1)
1931
+ * to understand what happened and decide next actions.
1932
+ *
1933
+ * @since TP-039
1934
+ */
1935
+ export interface Tier0Event {
1936
+ /** ISO 8601 timestamp */
1937
+ timestamp: string;
1938
+ /** Event type */
1939
+ type: Tier0EventType;
1940
+ /** Batch identifier */
1941
+ batchId: string;
1942
+ /** Wave index (0-based) */
1943
+ waveIndex: number;
1944
+ /** Recovery pattern being applied */
1945
+ pattern: Tier0RecoveryPattern | "merge_timeout";
1946
+ /** Current attempt number (1-based) */
1947
+ attempt: number;
1948
+ /** Maximum attempts allowed */
1949
+ maxAttempts: number;
1950
+ /** Affected task ID (for task-scoped patterns like worker_crash) */
1951
+ taskId?: string;
1952
+ /** Lane number (for lane-scoped patterns) */
1953
+ laneNumber?: number;
1954
+ /** Repo ID (for workspace-mode attribution; null/undefined for repo-mode) */
1955
+ repoId?: string | null;
1956
+ /** Exit classification or error type */
1957
+ classification?: string;
1958
+ /** Error message (for exhausted events) */
1959
+ error?: string;
1960
+ /** Resolution description (for success events) */
1961
+ resolution?: string;
1962
+ /** Cooldown/timeout in milliseconds before retry (for attempt events) */
1963
+ cooldownMs?: number;
1964
+ /** Scope key used for retry counter tracking */
1965
+ scopeKey?: string;
1966
+ /** Affected task IDs (for escalation context in exhausted events) */
1967
+ affectedTaskIds?: string[];
1968
+ /** Suggested remediation (for exhausted events) */
1969
+ suggestion?: string;
1970
+ /** Typed escalation payload (present only on `tier0_escalation` events) */
1971
+ escalation?: EscalationContext;
1972
+ }
1973
+
1974
+ /**
1975
+ * Build the required base fields for a Tier 0 event.
1976
+ *
1977
+ * Ensures consistent field population across all emit sites so
1978
+ * supervisor consumers get a deterministic event shape.
1979
+ *
1980
+ * @since TP-039 R004
1981
+ */
1982
+ export function buildTier0EventBase(
1983
+ type: Tier0EventType,
1984
+ batchId: string,
1985
+ waveIndex: number,
1986
+ pattern: Tier0RecoveryPattern | "merge_timeout",
1987
+ attempt: number,
1988
+ maxAttempts: number,
1989
+ ): Pick<Tier0Event, "timestamp" | "type" | "batchId" | "waveIndex" | "pattern" | "attempt" | "maxAttempts"> {
1990
+ return {
1991
+ timestamp: new Date().toISOString(),
1992
+ type,
1993
+ batchId,
1994
+ waveIndex,
1995
+ pattern,
1996
+ attempt,
1997
+ maxAttempts,
1998
+ };
1999
+ }
2000
+
2001
+ /**
2002
+ * Emit a Tier 0 event to `.pi/supervisor/events.jsonl`.
2003
+ *
2004
+ * Best-effort: creates the directory if needed, appends the event as a
2005
+ * single JSONL line. Failures are logged but never crash the batch.
2006
+ *
2007
+ * @param stateRoot - Root directory for state files (workspace root or repo root)
2008
+ * @param event - The event to emit
2009
+ *
2010
+ * @since TP-039
2011
+ */
2012
+ export function emitTier0Event(stateRoot: string, event: Tier0Event): void {
2013
+ try {
2014
+ const supervisorDir = join(stateRoot, ".pi", "supervisor");
2015
+ if (!existsSync(supervisorDir)) {
2016
+ mkdirSync(supervisorDir, { recursive: true });
2017
+ }
2018
+ const eventsPath = join(supervisorDir, "events.jsonl");
2019
+ const line = JSON.stringify(event) + "\n";
2020
+ appendFileSync(eventsPath, line);
2021
+ } catch (err: unknown) {
2022
+ // Best-effort: log but don't crash the batch
2023
+ const msg = err instanceof Error ? err.message : String(err);
2024
+ execLog("batch", event.batchId, `tier0 event write failed: ${msg}`, {
2025
+ eventType: event.type,
2026
+ pattern: event.pattern,
2027
+ });
2028
+ }
2029
+ }
2030
+
2031
+
2032
+ // ── Engine Event Logging (TP-040) ───────────────────────────────────
2033
+
2034
+ /**
2035
+ * Emit an engine lifecycle event to `.pi/supervisor/events.jsonl`.
2036
+ *
2037
+ * Shares the same JSONL file as Tier 0 events for unified consumption
2038
+ * by the supervisor agent. Engine events cover batch lifecycle transitions
2039
+ * (wave start/end, task completion, merge phases, batch terminal states).
2040
+ *
2041
+ * Best-effort: creates the directory if needed, appends the event as a
2042
+ * single JSONL line. Failures are logged but never crash the batch.
2043
+ *
2044
+ * Also invokes the optional event callback for in-process consumers
2045
+ * (command handler, dashboard).
2046
+ *
2047
+ * @param stateRoot - Root directory for state files (workspace root or repo root)
2048
+ * @param event - The engine event to emit
2049
+ * @param callback - Optional in-process event callback
2050
+ *
2051
+ * @since TP-040
2052
+ */
2053
+ export function emitEngineEvent(
2054
+ stateRoot: string,
2055
+ event: EngineEvent,
2056
+ callback?: ((event: EngineEvent) => void) | null,
2057
+ ): void {
2058
+ // Write to JSONL file (same path as Tier 0 events)
2059
+ try {
2060
+ const supervisorDir = join(stateRoot, ".pi", "supervisor");
2061
+ if (!existsSync(supervisorDir)) {
2062
+ mkdirSync(supervisorDir, { recursive: true });
2063
+ }
2064
+ const eventsPath = join(supervisorDir, "events.jsonl");
2065
+ const line = JSON.stringify(event) + "\n";
2066
+ appendFileSync(eventsPath, line);
2067
+ } catch (err: unknown) {
2068
+ // Best-effort: log but don't crash the batch
2069
+ const msg = err instanceof Error ? err.message : String(err);
2070
+ execLog("batch", event.batchId, `engine event write failed: ${msg}`, {
2071
+ eventType: event.type,
2072
+ });
2073
+ }
2074
+
2075
+ // Invoke in-process callback
2076
+ if (callback) {
2077
+ try {
2078
+ callback(event);
2079
+ } catch (err: unknown) {
2080
+ const msg = err instanceof Error ? err.message : String(err);
2081
+ execLog("batch", event.batchId, `engine event callback failed: ${msg}`, {
2082
+ eventType: event.type,
2083
+ });
2084
+ }
2085
+ }
2086
+ }
2087
+