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