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