taskplane 0.5.12 → 0.6.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.
@@ -7,10 +7,11 @@ import { execSync } from "child_process";
7
7
  import { join, dirname, basename } from "path";
8
8
 
9
9
  import { execLog } from "./execution.ts";
10
- import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES } from "./types.ts";
10
+ import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics } from "./types.ts";
11
11
  import type { BatchHistorySummary } from "./types.ts";
12
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
+ import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
14
15
 
15
16
  // ── State Persistence Helper (TS-009 Step 2) ────────────────────────
16
17
 
@@ -66,7 +67,10 @@ export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOut
66
67
  prev.endTime !== next.endTime ||
67
68
  prev.exitReason !== next.exitReason ||
68
69
  prev.sessionName !== next.sessionName ||
69
- prev.doneFileFound !== next.doneFileFound;
70
+ prev.doneFileFound !== next.doneFileFound ||
71
+ prev.partialProgressCommits !== next.partialProgressCommits ||
72
+ prev.partialProgressBranch !== next.partialProgressBranch ||
73
+ prev.exitDiagnostic !== next.exitDiagnostic;
70
74
 
71
75
  if (changed) {
72
76
  outcomes[idx] = next;
@@ -74,6 +78,35 @@ export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOut
74
78
  return changed;
75
79
  }
76
80
 
81
+ /**
82
+ * Apply partial progress preservation results to task outcomes (TP-028).
83
+ *
84
+ * After `preserveFailedLaneProgress()` runs, call this to stamp each
85
+ * successfully-preserved task outcome with the saved branch name and
86
+ * commit count. This ensures the data flows into persistence and
87
+ * diagnostics via the normal outcome → serialization path.
88
+ *
89
+ * @param ppResult - Result from `preserveFailedLaneProgress()`
90
+ * @param outcomes - Mutable array of task outcomes to update in-place
91
+ * @returns Number of outcomes that were updated
92
+ */
93
+ export function applyPartialProgressToOutcomes(
94
+ ppResult: PreserveFailedLaneProgressResult,
95
+ outcomes: LaneTaskOutcome[],
96
+ ): number {
97
+ let updated = 0;
98
+ for (const r of ppResult.results) {
99
+ if (!r.saved || !r.savedBranch) continue;
100
+ const outcome = outcomes.find(o => o.taskId === r.taskId);
101
+ if (outcome) {
102
+ outcome.partialProgressCommits = r.commitCount;
103
+ outcome.partialProgressBranch = r.savedBranch;
104
+ updated++;
105
+ }
106
+ }
107
+ return updated;
108
+ }
109
+
77
110
  /**
78
111
  * Seed pending outcomes for all tasks in newly allocated lanes.
79
112
  *
@@ -130,6 +163,9 @@ export function syncTaskOutcomesFromMonitor(
130
163
  exitReason: existing?.exitReason || "Pending execution",
131
164
  sessionName: existing?.sessionName || lane.sessionName,
132
165
  doneFileFound: false,
166
+ partialProgressCommits: existing?.partialProgressCommits,
167
+ partialProgressBranch: existing?.partialProgressBranch,
168
+ exitDiagnostic: existing?.exitDiagnostic,
133
169
  }) || changed;
134
170
  }
135
171
 
@@ -144,6 +180,9 @@ export function syncTaskOutcomesFromMonitor(
144
180
  exitReason: existing?.exitReason || ".DONE file created by task-runner",
145
181
  sessionName: existing?.sessionName || lane.sessionName,
146
182
  doneFileFound: true,
183
+ partialProgressCommits: existing?.partialProgressCommits,
184
+ partialProgressBranch: existing?.partialProgressBranch,
185
+ exitDiagnostic: existing?.exitDiagnostic,
147
186
  }) || changed;
148
187
  }
149
188
 
@@ -158,6 +197,9 @@ export function syncTaskOutcomesFromMonitor(
158
197
  exitReason: existing?.exitReason || "Task failed or stalled",
159
198
  sessionName: existing?.sessionName || lane.sessionName,
160
199
  doneFileFound: false,
200
+ partialProgressCommits: existing?.partialProgressCommits,
201
+ partialProgressBranch: existing?.partialProgressBranch,
202
+ exitDiagnostic: existing?.exitDiagnostic,
161
203
  }) || changed;
162
204
  }
163
205
 
@@ -185,6 +227,9 @@ export function syncTaskOutcomesFromMonitor(
185
227
  exitReason: existing?.exitReason || (mappedStatus === "running" ? "Task in progress" : (snap.stallReason || "Task reached terminal state")),
186
228
  sessionName: existing?.sessionName || lane.sessionName,
187
229
  doneFileFound: snap.doneFileFound,
230
+ partialProgressCommits: existing?.partialProgressCommits,
231
+ partialProgressBranch: existing?.partialProgressBranch,
232
+ exitDiagnostic: existing?.exitDiagnostic,
188
233
  }) || changed;
189
234
  }
190
235
  }
@@ -298,26 +343,48 @@ export const VALID_PERSISTED_MERGE_STATUSES: ReadonlySet<string> = new Set([
298
343
  * @param obj - Parsed state object (mutated in-place)
299
344
  */
300
345
  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;
346
+ if ((obj.schemaVersion as number) >= 2) return;
347
+ obj.schemaVersion = 2;
303
348
  if (!obj.baseBranch) obj.baseBranch = "";
304
349
  if (!obj.mode) obj.mode = "repo";
305
350
  // Task and lane records: v2 optional fields default to undefined (omitted)
306
351
  // which is already their state in v1 objects. No mutation needed.
307
352
  }
308
353
 
354
+ /**
355
+ * Upconvert a v2 state object to v3 by adding resilience and diagnostics
356
+ * sections with conservative defaults.
357
+ *
358
+ * Added fields:
359
+ * - `resilience`: default empty resilience state (no retries, no repairs)
360
+ * - `diagnostics`: default empty diagnostics (no task exits, zero batch cost)
361
+ *
362
+ * This function is idempotent: calling it on an already-v3 object is a no-op.
363
+ *
364
+ * @param obj - Parsed state object (mutated in-place)
365
+ */
366
+ export function upconvertV2toV3(obj: Record<string, unknown>): void {
367
+ if ((obj.schemaVersion as number) >= 3) return;
368
+ obj.schemaVersion = 3;
369
+ // Backfill v3 sections with conservative defaults only during genuine
370
+ // v1/v2→v3 migration. A native v3 file missing these sections is
371
+ // malformed and must be rejected by validation — not silently patched.
372
+ if (!obj.resilience) obj.resilience = defaultResilienceState();
373
+ if (!obj.diagnostics) obj.diagnostics = defaultBatchDiagnostics();
374
+ }
375
+
309
376
  /**
310
377
  * Validate a parsed JSON object as a PersistedBatchState.
311
378
  *
312
379
  * Checks:
313
- * 1. Schema version is 1 (auto-upconverted to v2) or 2 (current)
380
+ * 1. Schema version is 1 (auto-upconverted to v2→v3), 2 (upconverted to v3), or 3 (current)
314
381
  * 2. All required fields are present with correct types
315
382
  * 3. Enum fields contain valid values (phase, task statuses, merge statuses)
316
383
  * 4. Arrays contain valid sub-records
317
384
  * 5. v2 optional fields (repoId, resolvedRepoId, mode) are valid when present
318
385
  *
319
386
  * @param data - Parsed JSON (unknown type)
320
- * @returns Validated PersistedBatchState (always v2, even if input was v1)
387
+ * @returns Validated PersistedBatchState (always v3, even if input was v1/v2)
321
388
  * @throws StateFileError with STATE_SCHEMA_INVALID on any validation failure
322
389
  */
323
390
  export function validatePersistedState(data: unknown): PersistedBatchState {
@@ -337,12 +404,15 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
337
404
  `Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`,
338
405
  );
339
406
  }
340
- // Accept v1 (auto-upconvert) and v2 (current). Reject anything else.
341
- if (obj.schemaVersion !== 1 && obj.schemaVersion !== BATCH_STATE_SCHEMA_VERSION) {
407
+ // Accept v1 (auto-upconvert to v2→v3), v2 (upconvert to v3), and v3 (current).
408
+ // Reject anything else including future versions from newer runtimes.
409
+ const ACCEPTED_VERSIONS = [1, 2, BATCH_STATE_SCHEMA_VERSION];
410
+ if (!ACCEPTED_VERSIONS.includes(obj.schemaVersion as number)) {
342
411
  throw new StateFileError(
343
412
  "STATE_SCHEMA_INVALID",
344
413
  `Unsupported schema version ${obj.schemaVersion} (expected ${BATCH_STATE_SCHEMA_VERSION}). ` +
345
- `Delete .pi/batch-state.json and re-run the batch.`,
414
+ `Upgrade taskplane to a version that supports schema v${obj.schemaVersion}, ` +
415
+ `or delete .pi/batch-state.json and re-run the batch.`,
346
416
  );
347
417
  }
348
418
  const isV1 = obj.schemaVersion === 1;
@@ -518,6 +588,34 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
518
588
  `tasks[${i}].resolvedRepoId is not a string (got ${typeof t.resolvedRepoId})`,
519
589
  );
520
590
  }
591
+ // TP-028 optional fields: partialProgressCommits (number | undefined), partialProgressBranch (string | undefined)
592
+ if (t.partialProgressCommits !== undefined && typeof t.partialProgressCommits !== "number") {
593
+ throw new StateFileError(
594
+ "STATE_SCHEMA_INVALID",
595
+ `tasks[${i}].partialProgressCommits is not a number (got ${typeof t.partialProgressCommits})`,
596
+ );
597
+ }
598
+ if (t.partialProgressBranch !== undefined && typeof t.partialProgressBranch !== "string") {
599
+ throw new StateFileError(
600
+ "STATE_SCHEMA_INVALID",
601
+ `tasks[${i}].partialProgressBranch is not a string (got ${typeof t.partialProgressBranch})`,
602
+ );
603
+ }
604
+ // TP-026 optional field: exitDiagnostic (object with classification string | undefined)
605
+ if (t.exitDiagnostic !== undefined) {
606
+ if (typeof t.exitDiagnostic !== "object" || t.exitDiagnostic === null || Array.isArray(t.exitDiagnostic)) {
607
+ throw new StateFileError(
608
+ "STATE_SCHEMA_INVALID",
609
+ `tasks[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(t.exitDiagnostic) ? "array" : typeof t.exitDiagnostic})`,
610
+ );
611
+ }
612
+ if (typeof (t.exitDiagnostic as any).classification !== "string") {
613
+ throw new StateFileError(
614
+ "STATE_SCHEMA_INVALID",
615
+ `tasks[${i}].exitDiagnostic.classification is not a string (got ${typeof (t.exitDiagnostic as any).classification})`,
616
+ );
617
+ }
618
+ }
521
619
  }
522
620
 
523
621
  // ── Validate lane records ────────────────────────────────────
@@ -650,10 +748,204 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
650
748
  }
651
749
  }
652
750
 
653
- // ── v1→v2 upconversion ───────────────────────────────────────
654
- // Apply defaults for fields that may be absent in v1 state files.
751
+ // ── v1→v2→v3 upconversion ────────────────────────────────────
752
+ // Apply defaults for fields that may be absent in older state files.
655
753
  // The on-disk file is NOT rewritten; upconversion is in-memory only.
754
+ // Chain: v1→v2 then v2→v3 (each is idempotent / no-op if already at target).
656
755
  upconvertV1toV2(obj);
756
+ upconvertV2toV3(obj);
757
+
758
+ // ── Validate v3 resilience section ───────────────────────────
759
+ // After upconversion, resilience must be a valid object with correct types.
760
+ if (!obj.resilience || typeof obj.resilience !== "object") {
761
+ throw new StateFileError(
762
+ "STATE_SCHEMA_INVALID",
763
+ `Missing or invalid "resilience" section (expected object, got ${typeof obj.resilience})`,
764
+ );
765
+ }
766
+ const res = obj.resilience as Record<string, unknown>;
767
+ if (typeof res.resumeForced !== "boolean") {
768
+ throw new StateFileError(
769
+ "STATE_SCHEMA_INVALID",
770
+ `resilience.resumeForced must be a boolean (got ${typeof res.resumeForced})`,
771
+ );
772
+ }
773
+ if (!res.retryCountByScope || typeof res.retryCountByScope !== "object" || Array.isArray(res.retryCountByScope)) {
774
+ throw new StateFileError(
775
+ "STATE_SCHEMA_INVALID",
776
+ `resilience.retryCountByScope must be an object (got ${typeof res.retryCountByScope})`,
777
+ );
778
+ }
779
+ // Deep-validate retryCountByScope: all values must be numbers
780
+ for (const [scope, count] of Object.entries(res.retryCountByScope as Record<string, unknown>)) {
781
+ if (typeof count !== "number") {
782
+ throw new StateFileError(
783
+ "STATE_SCHEMA_INVALID",
784
+ `resilience.retryCountByScope["${scope}"] must be a number (got ${typeof count})`,
785
+ );
786
+ }
787
+ }
788
+ if (res.lastFailureClass !== null && typeof res.lastFailureClass !== "string") {
789
+ throw new StateFileError(
790
+ "STATE_SCHEMA_INVALID",
791
+ `resilience.lastFailureClass must be a string or null (got ${typeof res.lastFailureClass})`,
792
+ );
793
+ }
794
+ if (!Array.isArray(res.repairHistory)) {
795
+ throw new StateFileError(
796
+ "STATE_SCHEMA_INVALID",
797
+ `resilience.repairHistory must be an array (got ${typeof res.repairHistory})`,
798
+ );
799
+ }
800
+ // Deep-validate repairHistory entries
801
+ for (let i = 0; i < (res.repairHistory as unknown[]).length; i++) {
802
+ const rec = (res.repairHistory as unknown[])[i];
803
+ if (!rec || typeof rec !== "object") {
804
+ throw new StateFileError(
805
+ "STATE_SCHEMA_INVALID",
806
+ `resilience.repairHistory[${i}] must be an object (got ${typeof rec})`,
807
+ );
808
+ }
809
+ const r = rec as Record<string, unknown>;
810
+ if (typeof r.id !== "string") {
811
+ throw new StateFileError(
812
+ "STATE_SCHEMA_INVALID",
813
+ `resilience.repairHistory[${i}].id must be a string (got ${typeof r.id})`,
814
+ );
815
+ }
816
+ if (typeof r.strategy !== "string") {
817
+ throw new StateFileError(
818
+ "STATE_SCHEMA_INVALID",
819
+ `resilience.repairHistory[${i}].strategy must be a string (got ${typeof r.strategy})`,
820
+ );
821
+ }
822
+ const VALID_REPAIR_STATUSES = new Set(["succeeded", "failed", "skipped"]);
823
+ if (typeof r.status !== "string" || !VALID_REPAIR_STATUSES.has(r.status)) {
824
+ throw new StateFileError(
825
+ "STATE_SCHEMA_INVALID",
826
+ `resilience.repairHistory[${i}].status must be "succeeded"|"failed"|"skipped" (got ${JSON.stringify(r.status)})`,
827
+ );
828
+ }
829
+ if (typeof r.startedAt !== "number") {
830
+ throw new StateFileError(
831
+ "STATE_SCHEMA_INVALID",
832
+ `resilience.repairHistory[${i}].startedAt must be a number (got ${typeof r.startedAt})`,
833
+ );
834
+ }
835
+ if (typeof r.endedAt !== "number") {
836
+ throw new StateFileError(
837
+ "STATE_SCHEMA_INVALID",
838
+ `resilience.repairHistory[${i}].endedAt must be a number (got ${typeof r.endedAt})`,
839
+ );
840
+ }
841
+ // repoId is optional — validate type only if present
842
+ if (r.repoId !== undefined && typeof r.repoId !== "string") {
843
+ throw new StateFileError(
844
+ "STATE_SCHEMA_INVALID",
845
+ `resilience.repairHistory[${i}].repoId must be a string when present (got ${typeof r.repoId})`,
846
+ );
847
+ }
848
+ }
849
+
850
+ // ── Validate v3 diagnostics section ──────────────────────────
851
+ // After upconversion, diagnostics must be a valid object with correct types.
852
+ if (!obj.diagnostics || typeof obj.diagnostics !== "object") {
853
+ throw new StateFileError(
854
+ "STATE_SCHEMA_INVALID",
855
+ `Missing or invalid "diagnostics" section (expected object, got ${typeof obj.diagnostics})`,
856
+ );
857
+ }
858
+ const diag = obj.diagnostics as Record<string, unknown>;
859
+ if (!diag.taskExits || typeof diag.taskExits !== "object" || Array.isArray(diag.taskExits)) {
860
+ throw new StateFileError(
861
+ "STATE_SCHEMA_INVALID",
862
+ `diagnostics.taskExits must be an object (got ${typeof diag.taskExits})`,
863
+ );
864
+ }
865
+ // Deep-validate taskExits entries
866
+ for (const [taskId, entry] of Object.entries(diag.taskExits as Record<string, unknown>)) {
867
+ if (!entry || typeof entry !== "object") {
868
+ throw new StateFileError(
869
+ "STATE_SCHEMA_INVALID",
870
+ `diagnostics.taskExits["${taskId}"] must be an object (got ${typeof entry})`,
871
+ );
872
+ }
873
+ const te = entry as Record<string, unknown>;
874
+ if (typeof te.classification !== "string") {
875
+ throw new StateFileError(
876
+ "STATE_SCHEMA_INVALID",
877
+ `diagnostics.taskExits["${taskId}"].classification must be a string (got ${typeof te.classification})`,
878
+ );
879
+ }
880
+ if (typeof te.cost !== "number") {
881
+ throw new StateFileError(
882
+ "STATE_SCHEMA_INVALID",
883
+ `diagnostics.taskExits["${taskId}"].cost must be a number (got ${typeof te.cost})`,
884
+ );
885
+ }
886
+ if (typeof te.durationSec !== "number") {
887
+ throw new StateFileError(
888
+ "STATE_SCHEMA_INVALID",
889
+ `diagnostics.taskExits["${taskId}"].durationSec must be a number (got ${typeof te.durationSec})`,
890
+ );
891
+ }
892
+ // retries is optional — validate type only if present
893
+ if (te.retries !== undefined && typeof te.retries !== "number") {
894
+ throw new StateFileError(
895
+ "STATE_SCHEMA_INVALID",
896
+ `diagnostics.taskExits["${taskId}"].retries must be a number when present (got ${typeof te.retries})`,
897
+ );
898
+ }
899
+ }
900
+ if (typeof diag.batchCost !== "number") {
901
+ throw new StateFileError(
902
+ "STATE_SCHEMA_INVALID",
903
+ `diagnostics.batchCost must be a number (got ${typeof diag.batchCost})`,
904
+ );
905
+ }
906
+
907
+ // ── Validate exitDiagnostic on task records (optional) ───────
908
+ for (let i = 0; i < tasks.length; i++) {
909
+ const t = tasks[i] as Record<string, unknown>;
910
+ if (t.exitDiagnostic !== undefined) {
911
+ if (!t.exitDiagnostic || typeof t.exitDiagnostic !== "object") {
912
+ throw new StateFileError(
913
+ "STATE_SCHEMA_INVALID",
914
+ `tasks[${i}].exitDiagnostic must be an object when present (got ${typeof t.exitDiagnostic})`,
915
+ );
916
+ }
917
+ const ed = t.exitDiagnostic as Record<string, unknown>;
918
+ if (typeof ed.classification !== "string") {
919
+ throw new StateFileError(
920
+ "STATE_SCHEMA_INVALID",
921
+ `tasks[${i}].exitDiagnostic.classification must be a string (got ${typeof ed.classification})`,
922
+ );
923
+ }
924
+ }
925
+ }
926
+
927
+ // ── Capture unknown top-level fields for roundtrip preservation ──
928
+ // Any fields not in the known schema are preserved so they survive
929
+ // serialization. This protects against data loss from future schema
930
+ // extensions or external tools writing additional fields.
931
+ const KNOWN_TOP_LEVEL_FIELDS = new Set([
932
+ "schemaVersion", "phase", "batchId", "baseBranch", "orchBranch", "mode",
933
+ "startedAt", "updatedAt", "endedAt", "currentWaveIndex", "totalWaves",
934
+ "wavePlan", "lanes", "tasks", "mergeResults",
935
+ "totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
936
+ "blockedTaskIds", "lastError", "errors",
937
+ "resilience", "diagnostics",
938
+ "_extraFields",
939
+ ]);
940
+ const extraFields: Record<string, unknown> = {};
941
+ for (const key of Object.keys(obj)) {
942
+ if (!KNOWN_TOP_LEVEL_FIELDS.has(key)) {
943
+ extraFields[key] = obj[key];
944
+ }
945
+ }
946
+ if (Object.keys(extraFields).length > 0) {
947
+ obj._extraFields = extraFields;
948
+ }
657
949
 
658
950
  return obj as unknown as PersistedBatchState;
659
951
  }
@@ -738,6 +1030,19 @@ export function serializeBatchState(
738
1030
  record.resolvedRepoId = allocated.allocatedTask.task.resolvedRepoId;
739
1031
  }
740
1032
 
1033
+ // TP-028: Serialize partial progress fields from task outcome
1034
+ if (outcome?.partialProgressCommits !== undefined) {
1035
+ record.partialProgressCommits = outcome.partialProgressCommits;
1036
+ }
1037
+ if (outcome?.partialProgressBranch !== undefined) {
1038
+ record.partialProgressBranch = outcome.partialProgressBranch;
1039
+ }
1040
+
1041
+ // TP-030 v3: Serialize exit diagnostic from task outcome
1042
+ if (outcome?.exitDiagnostic !== undefined) {
1043
+ record.exitDiagnostic = outcome.exitDiagnostic;
1044
+ }
1045
+
741
1046
  return record;
742
1047
  });
743
1048
 
@@ -809,8 +1114,22 @@ export function serializeBatchState(
809
1114
  ? { code: "BATCH_ERROR", message: state.errors[state.errors.length - 1] }
810
1115
  : null,
811
1116
  errors: [...state.errors],
1117
+ resilience: state.resilience ?? defaultResilienceState(),
1118
+ diagnostics: state.diagnostics ?? defaultBatchDiagnostics(),
812
1119
  };
813
1120
 
1121
+ // Merge unknown fields from loaded state to preserve roundtrip fidelity.
1122
+ // Extra fields are placed at the end of the object (after known schema fields)
1123
+ // and will not overwrite any known field.
1124
+ if (state._extraFields) {
1125
+ const output = persisted as Record<string, unknown>;
1126
+ for (const [key, value] of Object.entries(state._extraFields)) {
1127
+ if (!(key in output)) {
1128
+ output[key] = value;
1129
+ }
1130
+ }
1131
+ }
1132
+
814
1133
  return JSON.stringify(persisted, null, 2);
815
1134
  }
816
1135
 
@@ -969,10 +1288,11 @@ export type OrphanStateStatus = "valid" | "missing" | "invalid" | "io-error";
969
1288
  *
970
1289
  * - "resume" — Orphan sessions + valid state, or no orphans + valid state with incomplete tasks: suggest /orch-resume
971
1290
  * - "abort-orphans" — Orphan sessions without usable state: suggest /orch-abort
972
- * - "cleanup-stale" — No orphans + stale/invalid state file: auto-delete and start fresh
1291
+ * - "cleanup-stale" — No orphans + stale/valid/completed state: auto-delete and start fresh
1292
+ * - "paused-corrupt" — No orphans + corrupt/unreadable state file: do NOT auto-delete; notify user to inspect or manually remove
973
1293
  * - "start-fresh" — No orphans, no state file: proceed normally
974
1294
  */
975
- export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "start-fresh";
1295
+ export type OrphanRecommendedAction = "resume" | "abort-orphans" | "cleanup-stale" | "paused-corrupt" | "start-fresh";
976
1296
 
977
1297
  /**
978
1298
  * Result of orphan detection analysis.
@@ -1036,8 +1356,8 @@ export function parseOrchSessionNames(stdout: string, prefix: string): string[]
1036
1356
  * | No | valid | all | cleanup-stale |
1037
1357
  * | No | valid | !all | resume |
1038
1358
  * | No | missing | — | start-fresh |
1039
- * | No | invalid | — | cleanup-stale |
1040
- * | No | io-error | — | cleanup-stale |
1359
+ * | No | invalid | — | paused-corrupt |
1360
+ * | No | io-error | — | paused-corrupt |
1041
1361
  *
1042
1362
  * Pure function — no process or filesystem access.
1043
1363
  *
@@ -1158,17 +1478,20 @@ export function analyzeOrchestratorStartupState(
1158
1478
  };
1159
1479
  }
1160
1480
 
1161
- // Invalid or io-error state with no orphans — safe to clean up
1481
+ // Invalid or io-error state with no orphans — corrupt state.
1482
+ // Never auto-delete: enter paused-corrupt so the user can inspect the file
1483
+ // and decide whether to manually recover or remove it.
1162
1484
  return {
1163
1485
  orphanSessions: [],
1164
1486
  stateStatus,
1165
1487
  loadedState: null,
1166
1488
  stateError,
1167
- recommendedAction: "cleanup-stale",
1489
+ recommendedAction: "paused-corrupt",
1168
1490
  userMessage:
1169
- `🧹 Found unusable batch state file (${stateStatus}).\n` +
1491
+ `⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` +
1170
1492
  (stateError ? ` Error: ${stateError}\n` : "") +
1171
- ` Cleaning up state file before starting fresh.`,
1493
+ ` The file has NOT been deleted. Inspect .pi/batch-state.json manually,\n` +
1494
+ ` then either fix it or delete it and run /orch again.`,
1172
1495
  };
1173
1496
  }
1174
1497