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.
- package/README.md +1 -1
- package/bin/rpc-wrapper.mjs +777 -0
- package/dashboard/public/app.js +45 -6
- package/dashboard/public/style.css +31 -0
- package/dashboard/server.cjs +326 -1
- package/extensions/task-runner.ts +1111 -30
- package/extensions/taskplane/config-loader.ts +31 -0
- package/extensions/taskplane/config-schema.ts +88 -0
- package/extensions/taskplane/diagnostic-reports.ts +463 -0
- package/extensions/taskplane/diagnostics.ts +323 -0
- package/extensions/taskplane/engine.ts +407 -62
- package/extensions/taskplane/extension.ts +259 -8
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +786 -66
- package/extensions/taskplane/messages.ts +594 -2
- package/extensions/taskplane/persistence.ts +342 -19
- package/extensions/taskplane/quality-gate.ts +1033 -0
- package/extensions/taskplane/resume.ts +505 -36
- package/extensions/taskplane/supervisor-primer.md +664 -0
- package/extensions/taskplane/types.ts +534 -6
- package/extensions/taskplane/verification.ts +537 -0
- package/extensions/taskplane/worktree.ts +329 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -2
- package/templates/agents/task-reviewer.md +54 -2
- package/templates/agents/task-worker.md +8 -4
|
@@ -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) >=
|
|
302
|
-
obj.schemaVersion =
|
|
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
|
|
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
|
|
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
|
|
341
|
-
|
|
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
|
-
`
|
|
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
|
|
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/
|
|
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 | — |
|
|
1040
|
-
* | No | io-error | — |
|
|
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 —
|
|
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: "
|
|
1489
|
+
recommendedAction: "paused-corrupt",
|
|
1168
1490
|
userMessage:
|
|
1169
|
-
|
|
1491
|
+
`⚠️ Batch state file is corrupt or unreadable (${stateStatus}).\n` +
|
|
1170
1492
|
(stateError ? ` Error: ${stateError}\n` : "") +
|
|
1171
|
-
`
|
|
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
|
|