taskplane 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@ import { join, dirname, basename } from "path";
9
9
  import { execLog } from "./execution.ts";
10
10
  import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES } from "./types.ts";
11
11
  import type { BatchHistorySummary } from "./types.ts";
12
- import type { AllocatedLane, DiscoveryResult, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedTaskRecord, TaskMonitorSnapshot } from "./types.ts";
12
+ import type { AllocatedLane, DiscoveryResult, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedTaskRecord, TaskMonitorSnapshot, WorkspaceMode } from "./types.ts";
13
13
  import { sleepSync } from "./worktree.ts";
14
14
 
15
15
  // ── State Persistence Helper (TS-009 Step 2) ────────────────────────
@@ -222,13 +222,20 @@ export function persistRuntimeState(
222
222
  try {
223
223
  const json = serializeBatchState(batchState, wavePlan, lanes, allTaskOutcomes);
224
224
 
225
- // Enrich task records with folder paths from discovery
225
+ // Enrich task records with folder paths and repo fields from discovery
226
226
  if (discovery) {
227
227
  const parsed = JSON.parse(json) as PersistedBatchState;
228
228
  for (const taskRecord of parsed.tasks) {
229
229
  const parsedTask = discovery.pending.get(taskRecord.taskId);
230
230
  if (parsedTask) {
231
231
  taskRecord.taskFolder = parsedTask.taskFolder;
232
+ // v2: Enrich repo fields for tasks not yet allocated (pending in future waves)
233
+ if (taskRecord.repoId === undefined && parsedTask.promptRepoId !== undefined) {
234
+ taskRecord.repoId = parsedTask.promptRepoId;
235
+ }
236
+ if (taskRecord.resolvedRepoId === undefined && parsedTask.resolvedRepoId !== undefined) {
237
+ taskRecord.resolvedRepoId = parsedTask.resolvedRepoId;
238
+ }
232
239
  }
233
240
  }
234
241
  const enrichedJson = JSON.stringify(parsed, null, 2);
@@ -271,17 +278,46 @@ export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet<string> = new Set([
271
278
  "succeeded", "failed", "partial",
272
279
  ]);
273
280
 
281
+ /**
282
+ * Upconvert a v1 state object to v2 in-memory.
283
+ *
284
+ * Applied automatically by `validatePersistedState()` when a v1 file is loaded.
285
+ * The on-disk file is NOT rewritten — upconversion is purely in-memory.
286
+ *
287
+ * v1→v2 field defaults:
288
+ * - `schemaVersion`: bumped from 1 → 2
289
+ * - `baseBranch`: defaults to "" (was already handled in v1 validation)
290
+ * - `mode`: defaults to "repo" (v1 was always single-repo)
291
+ * - `tasks[].repoId`: remains undefined (repo mode has no repo routing)
292
+ * - `tasks[].resolvedRepoId`: remains undefined (same reason)
293
+ * - `lanes[].repoId`: preserved if present (was already serialized in v1
294
+ * when workspace mode was partially implemented)
295
+ *
296
+ * This function is idempotent: calling it on an already-v2 object is a no-op.
297
+ *
298
+ * @param obj - Parsed state object (mutated in-place)
299
+ */
300
+ export function upconvertV1toV2(obj: Record<string, unknown>): void {
301
+ if ((obj.schemaVersion as number) >= BATCH_STATE_SCHEMA_VERSION) return;
302
+ obj.schemaVersion = BATCH_STATE_SCHEMA_VERSION;
303
+ if (!obj.baseBranch) obj.baseBranch = "";
304
+ if (!obj.mode) obj.mode = "repo";
305
+ // Task and lane records: v2 optional fields default to undefined (omitted)
306
+ // which is already their state in v1 objects. No mutation needed.
307
+ }
308
+
274
309
  /**
275
310
  * Validate a parsed JSON object as a PersistedBatchState.
276
311
  *
277
312
  * Checks:
278
- * 1. Schema version matches BATCH_STATE_SCHEMA_VERSION
313
+ * 1. Schema version is 1 (auto-upconverted to v2) or 2 (current)
279
314
  * 2. All required fields are present with correct types
280
315
  * 3. Enum fields contain valid values (phase, task statuses, merge statuses)
281
316
  * 4. Arrays contain valid sub-records
317
+ * 5. v2 optional fields (repoId, resolvedRepoId, mode) are valid when present
282
318
  *
283
319
  * @param data - Parsed JSON (unknown type)
284
- * @returns Validated PersistedBatchState
320
+ * @returns Validated PersistedBatchState (always v2, even if input was v1)
285
321
  * @throws StateFileError with STATE_SCHEMA_INVALID on any validation failure
286
322
  */
287
323
  export function validatePersistedState(data: unknown): PersistedBatchState {
@@ -301,13 +337,15 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
301
337
  `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`,
302
338
  );
303
339
  }
304
- if (obj.schemaVersion !== BATCH_STATE_SCHEMA_VERSION) {
340
+ // Accept v1 (auto-upconvert) and v2 (current). Reject anything else.
341
+ if (obj.schemaVersion !== 1 && obj.schemaVersion !== BATCH_STATE_SCHEMA_VERSION) {
305
342
  throw new StateFileError(
306
343
  "STATE_SCHEMA_INVALID",
307
344
  `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). ` +
308
345
  `Delete .pi/batch-state.json and re-run the batch.`,
309
346
  );
310
347
  }
348
+ const isV1 = obj.schemaVersion === 1;
311
349
 
312
350
  // ── Required string fields ───────────────────────────────────
313
351
  for (const field of ["phase", "batchId"] as const) {
@@ -328,6 +366,27 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
328
366
  );
329
367
  }
330
368
 
369
+ // ── v2: mode field ───────────────────────────────────────────
370
+ // mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
371
+ if (!isV1 && obj.mode === undefined) {
372
+ throw new StateFileError(
373
+ "STATE_SCHEMA_INVALID",
374
+ `Missing required "mode" field in schema v2 (expected "repo" or "workspace")`,
375
+ );
376
+ }
377
+ if (obj.mode !== undefined && typeof obj.mode !== "string") {
378
+ throw new StateFileError(
379
+ "STATE_SCHEMA_INVALID",
380
+ `Invalid "mode" field (expected string, got ${typeof obj.mode})`,
381
+ );
382
+ }
383
+ if (obj.mode !== undefined && obj.mode !== "repo" && obj.mode !== "workspace") {
384
+ throw new StateFileError(
385
+ "STATE_SCHEMA_INVALID",
386
+ `Invalid "mode" value "${obj.mode}" (expected "repo" or "workspace")`,
387
+ );
388
+ }
389
+
331
390
  // ── Phase enum validation ────────────────────────────────────
332
391
  if (!VALID_BATCH_PHASES.has(obj.phase as string)) {
333
392
  throw new StateFileError(
@@ -434,6 +493,19 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
434
493
  `tasks[${i}].doneFileFound is missing or not a boolean`,
435
494
  );
436
495
  }
496
+ // v2 optional fields: repoId, resolvedRepoId (string | undefined)
497
+ if (t.repoId !== undefined && typeof t.repoId !== "string") {
498
+ throw new StateFileError(
499
+ "STATE_SCHEMA_INVALID",
500
+ `tasks[${i}].repoId is not a string (got ${typeof t.repoId})`,
501
+ );
502
+ }
503
+ if (t.resolvedRepoId !== undefined && typeof t.resolvedRepoId !== "string") {
504
+ throw new StateFileError(
505
+ "STATE_SCHEMA_INVALID",
506
+ `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`,
507
+ );
508
+ }
437
509
  }
438
510
 
439
511
  // ── Validate lane records ────────────────────────────────────
@@ -466,6 +538,13 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
466
538
  `lanes[${i}].taskIds is missing or not an array`,
467
539
  );
468
540
  }
541
+ // v2 optional field: repoId (string | undefined)
542
+ if (l.repoId !== undefined && typeof l.repoId !== "string") {
543
+ throw new StateFileError(
544
+ "STATE_SCHEMA_INVALID",
545
+ `lanes[${i}].repoId is not a string (got ${typeof l.repoId})`,
546
+ );
547
+ }
469
548
  }
470
549
 
471
550
  // ── Validate merge results ───────────────────────────────────
@@ -490,6 +569,36 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
490
569
  `mergeResults[${i}].status is invalid: "${m.status}" (expected one of: ${[...VALID_PERSISTED_MERGE_STATUSES].join(", ")})`,
491
570
  );
492
571
  }
572
+ // v2 optional field: repoResults (array | undefined)
573
+ if (m.repoResults !== undefined) {
574
+ if (!Array.isArray(m.repoResults)) {
575
+ throw new StateFileError(
576
+ "STATE_SCHEMA_INVALID",
577
+ `mergeResults[${i}].repoResults is not an array (got ${typeof m.repoResults})`,
578
+ );
579
+ }
580
+ for (let j = 0; j < (m.repoResults as unknown[]).length; j++) {
581
+ const rr = (m.repoResults as unknown[])[j] as Record<string, unknown>;
582
+ if (!rr || typeof rr !== "object") {
583
+ throw new StateFileError(
584
+ "STATE_SCHEMA_INVALID",
585
+ `mergeResults[${i}].repoResults[${j}] is not an object`,
586
+ );
587
+ }
588
+ if (typeof rr.status !== "string" || !VALID_PERSISTED_MERGE_STATUSES.has(rr.status)) {
589
+ throw new StateFileError(
590
+ "STATE_SCHEMA_INVALID",
591
+ `mergeResults[${i}].repoResults[${j}].status is invalid: "${rr.status}"`,
592
+ );
593
+ }
594
+ if (!Array.isArray(rr.laneNumbers)) {
595
+ throw new StateFileError(
596
+ "STATE_SCHEMA_INVALID",
597
+ `mergeResults[${i}].repoResults[${j}].laneNumbers is not an array`,
598
+ );
599
+ }
600
+ }
601
+ }
493
602
  }
494
603
 
495
604
  // ── Validate lastError ───────────────────────────────────────
@@ -529,10 +638,10 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
529
638
  }
530
639
  }
531
640
 
532
- // Default baseBranch for backward compatibility with older state files
533
- if (!obj.baseBranch) {
534
- (obj as any).baseBranch = "";
535
- }
641
+ // ── v1→v2 upconversion ───────────────────────────────────────
642
+ // Apply defaults for fields that may be absent in v1 state files.
643
+ // The on-disk file is NOT rewritten; upconversion is in-memory only.
644
+ upconvertV1toV2(obj);
536
645
 
537
646
  return obj as unknown as PersistedBatchState;
538
647
  }
@@ -582,13 +691,22 @@ export function serializeBatchState(
582
691
  taskIdSet.add(outcome.taskId);
583
692
  }
584
693
 
694
+ // Build a lookup from taskId → AllocatedTask (which holds the ParsedTask with repo fields).
695
+ const allocatedTaskByTaskId = new Map<string, { allocatedTask: import("./types.ts").AllocatedTask; lane: AllocatedLane }>();
696
+ for (const lane of lanes) {
697
+ for (const allocTask of lane.tasks) {
698
+ allocatedTaskByTaskId.set(allocTask.taskId, { allocatedTask: allocTask, lane });
699
+ }
700
+ }
701
+
585
702
  const taskRecords: PersistedTaskRecord[] = [...taskIdSet]
586
703
  .sort()
587
704
  .map((taskId) => {
588
705
  const lane = laneByTaskId.get(taskId);
589
706
  const outcome = outcomeByTaskId.get(taskId);
707
+ const allocated = allocatedTaskByTaskId.get(taskId);
590
708
 
591
- return {
709
+ const record: PersistedTaskRecord = {
592
710
  taskId,
593
711
  laneNumber: lane?.laneNumber ?? 0,
594
712
  sessionName: outcome?.sessionName || lane?.tmuxSessionName || "",
@@ -599,34 +717,66 @@ export function serializeBatchState(
599
717
  doneFileFound: outcome?.doneFileFound ?? false,
600
718
  exitReason: outcome?.exitReason ?? "",
601
719
  };
720
+
721
+ // v2: Serialize repo-aware fields from the ParsedTask
722
+ if (allocated?.allocatedTask.task?.promptRepoId !== undefined) {
723
+ record.repoId = allocated.allocatedTask.task.promptRepoId;
724
+ }
725
+ if (allocated?.allocatedTask.task?.resolvedRepoId !== undefined) {
726
+ record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
727
+ }
728
+
729
+ return record;
602
730
  });
603
731
 
604
732
  // Build lane records
605
- const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => ({
606
- laneNumber: lane.laneNumber,
607
- laneId: lane.laneId,
608
- tmuxSessionName: lane.tmuxSessionName,
609
- worktreePath: lane.worktreePath,
610
- branch: lane.branch,
611
- taskIds: lane.tasks.map((t) => t.taskId),
612
- }));
733
+ const laneRecords: PersistedLaneRecord[] = lanes.map((lane) => {
734
+ const record: PersistedLaneRecord = {
735
+ laneNumber: lane.laneNumber,
736
+ laneId: lane.laneId,
737
+ tmuxSessionName: lane.tmuxSessionName,
738
+ worktreePath: lane.worktreePath,
739
+ branch: lane.branch,
740
+ taskIds: lane.tasks.map((t) => t.taskId),
741
+ };
742
+ if (lane.repoId !== undefined) {
743
+ record.repoId = lane.repoId;
744
+ }
745
+ return record;
746
+ });
613
747
 
614
748
  // Build merge results from actual merge outcomes (accumulated on batchState).
615
749
  // MergeWaveResult.waveIndex is 1-based (from merge module); normalize to
616
750
  // 0-based for PersistedMergeResult (dashboard renders as "Wave N+1").
751
+ // Clamp to 0 minimum: resume re-exec merges use sentinel waveIndex -1,
752
+ // which would produce -2 without clamping.
617
753
  const mergeResults: PersistedMergeResult[] = (state.mergeResults || [])
618
- .map((mr) => ({
619
- waveIndex: mr.waveIndex - 1,
620
- status: mr.status,
621
- failedLane: mr.failedLane,
622
- failureReason: mr.failureReason,
623
- }));
754
+ .map((mr) => {
755
+ const record: PersistedMergeResult = {
756
+ waveIndex: Math.max(0, mr.waveIndex - 1),
757
+ status: mr.status,
758
+ failedLane: mr.failedLane,
759
+ failureReason: mr.failureReason,
760
+ };
761
+ // v2 (TP-009): Serialize per-repo merge outcomes when available (workspace mode).
762
+ if (mr.repoResults && mr.repoResults.length > 0) {
763
+ record.repoResults = mr.repoResults.map((rr) => ({
764
+ repoId: rr.repoId,
765
+ status: rr.status,
766
+ laneNumbers: rr.laneResults.map((lr) => lr.laneNumber),
767
+ failedLane: rr.failedLane,
768
+ failureReason: rr.failureReason,
769
+ }));
770
+ }
771
+ return record;
772
+ });
624
773
 
625
774
  const persisted: PersistedBatchState = {
626
775
  schemaVersion: BATCH_STATE_SCHEMA_VERSION,
627
776
  phase: state.phase,
628
777
  batchId: state.batchId,
629
778
  baseBranch: state.baseBranch,
779
+ mode: state.mode ?? "repo",
630
780
  startedAt: state.startedAt,
631
781
  updatedAt: now,
632
782
  endedAt: state.endedAt,