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
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
|
|
7
7
|
import { parseDependencyReference } from "./discovery.ts";
|
|
8
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
8
9
|
import { AllocationError, getTaskDurationMinutes } from "./types.ts";
|
|
9
|
-
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorktreeInfo } from "./types.ts";
|
|
10
|
-
import {
|
|
10
|
+
import type { AllocatedLane, AllocatedTask, AllocateLanesResult, AllocationErrorCode, DependencyGraph, DiscoveryError, GraphValidationResult, LaneAssignment, OrchestratorConfig, ParsedTask, WaveAssignment, WaveComputationResult, WorkspaceConfig, WorktreeInfo } from "./types.ts";
|
|
11
|
+
import { getCurrentBranch } from "./git.ts";
|
|
12
|
+
import { ensureLaneWorktrees, removeAllWorktrees, removeWorktree } from "./worktree.ts";
|
|
11
13
|
|
|
12
14
|
// ── Dependency Graph Construction ────────────────────────────────────
|
|
13
15
|
|
|
@@ -402,6 +404,192 @@ export function applyFileScopeAffinity(
|
|
|
402
404
|
}
|
|
403
405
|
|
|
404
406
|
|
|
407
|
+
// ── Repo-Scoped Lane Helpers ─────────────────────────────────────────
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A group of tasks targeting the same repository.
|
|
411
|
+
*
|
|
412
|
+
* In repo mode: all tasks are in one group with `repoId` undefined.
|
|
413
|
+
* In workspace mode: tasks are grouped by `resolvedRepoId`.
|
|
414
|
+
*/
|
|
415
|
+
export interface RepoTaskGroup {
|
|
416
|
+
/** Repo ID (undefined for repo mode / tasks without resolvedRepoId) */
|
|
417
|
+
repoId: string | undefined;
|
|
418
|
+
/** Task IDs in this group (sorted alphabetically) */
|
|
419
|
+
taskIds: string[];
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Group wave tasks by their resolved repo ID.
|
|
424
|
+
*
|
|
425
|
+
* In workspace mode, tasks carry `resolvedRepoId` from the discovery/routing
|
|
426
|
+
* phase. This function groups them so each repo gets independent lane
|
|
427
|
+
* allocation (own affinity groups, own max_lanes budget).
|
|
428
|
+
*
|
|
429
|
+
* In repo mode, all tasks have `resolvedRepoId === undefined`, so they all
|
|
430
|
+
* land in a single group keyed by `""` (empty string). This preserves
|
|
431
|
+
* existing single-repo behavior exactly.
|
|
432
|
+
*
|
|
433
|
+
* Deterministic ordering guarantees:
|
|
434
|
+
* 1. Groups are sorted by repoId (undefined sorts first as empty string)
|
|
435
|
+
* 2. Task IDs within each group are sorted alphabetically
|
|
436
|
+
*
|
|
437
|
+
* @param waveTasks - Task IDs in this wave
|
|
438
|
+
* @param pending - Full pending task map (from discovery)
|
|
439
|
+
* @returns RepoTaskGroup[] sorted by repoId then by task IDs within group
|
|
440
|
+
*/
|
|
441
|
+
export function groupTasksByRepo(
|
|
442
|
+
waveTasks: string[],
|
|
443
|
+
pending: Map<string, ParsedTask>,
|
|
444
|
+
): RepoTaskGroup[] {
|
|
445
|
+
const groupMap = new Map<string, string[]>();
|
|
446
|
+
|
|
447
|
+
for (const taskId of waveTasks) {
|
|
448
|
+
const task = pending.get(taskId);
|
|
449
|
+
// Use resolvedRepoId or empty string as group key (undefined → "" for Map key)
|
|
450
|
+
const key = task?.resolvedRepoId ?? "";
|
|
451
|
+
const existing = groupMap.get(key) || [];
|
|
452
|
+
existing.push(taskId);
|
|
453
|
+
groupMap.set(key, existing);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Build sorted groups
|
|
457
|
+
const groups: RepoTaskGroup[] = [];
|
|
458
|
+
const sortedKeys = [...groupMap.keys()].sort();
|
|
459
|
+
for (const key of sortedKeys) {
|
|
460
|
+
const taskIds = groupMap.get(key)!;
|
|
461
|
+
taskIds.sort(); // Deterministic task order within group
|
|
462
|
+
groups.push({
|
|
463
|
+
repoId: key || undefined, // Convert "" back to undefined for repo mode
|
|
464
|
+
taskIds,
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return groups;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Generate a lane identifier string.
|
|
473
|
+
*
|
|
474
|
+
* - Repo mode (repoId undefined): `"lane-{N}"` — preserves legacy format
|
|
475
|
+
* - Workspace mode (repoId set): `"{repoId}/lane-{N}"` — collision-safe across repos
|
|
476
|
+
*
|
|
477
|
+
* The `laneLocalNumber` is the 1-indexed lane number within the repo group
|
|
478
|
+
* (NOT the global lane number). This gives operators clear per-repo context.
|
|
479
|
+
*
|
|
480
|
+
* @param laneLocalNumber - Lane number within the repo group (1-indexed)
|
|
481
|
+
* @param repoId - Repo identifier (undefined in repo mode)
|
|
482
|
+
*/
|
|
483
|
+
export function generateLaneId(laneLocalNumber: number, repoId?: string): string {
|
|
484
|
+
if (repoId) {
|
|
485
|
+
return `${repoId}/lane-${laneLocalNumber}`;
|
|
486
|
+
}
|
|
487
|
+
return `lane-${laneLocalNumber}`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Generate a TMUX session name for a lane.
|
|
492
|
+
*
|
|
493
|
+
* Includes the operator identifier (`opId`) for collision resistance
|
|
494
|
+
* across concurrent operators on the same machine.
|
|
495
|
+
*
|
|
496
|
+
* - Repo mode: `"{prefix}-{opId}-lane-{N}"` — operator-scoped
|
|
497
|
+
* - Workspace mode: `"{prefix}-{opId}-{repoId}-lane-{N}"` — operator + repo scoped
|
|
498
|
+
*
|
|
499
|
+
* TMUX session names must not contain periods or colons. Both `opId`
|
|
500
|
+
* and `repoId` are assumed to be sanitized identifiers (alphanumeric
|
|
501
|
+
* + hyphens only).
|
|
502
|
+
*
|
|
503
|
+
* @param tmuxPrefix - TMUX prefix from config (e.g., "orch")
|
|
504
|
+
* @param laneLocalNumber - Lane number within the repo group (1-indexed)
|
|
505
|
+
* @param opId - Operator identifier (sanitized, e.g., "henrylach")
|
|
506
|
+
* @param repoId - Repo identifier (undefined in repo mode)
|
|
507
|
+
*/
|
|
508
|
+
export function generateTmuxSessionName(tmuxPrefix: string, laneLocalNumber: number, opId: string, repoId?: string): string {
|
|
509
|
+
if (repoId) {
|
|
510
|
+
return `${tmuxPrefix}-${opId}-${repoId}-lane-${laneLocalNumber}`;
|
|
511
|
+
}
|
|
512
|
+
return `${tmuxPrefix}-${opId}-lane-${laneLocalNumber}`;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
// ── Repo-Scoped Worktree Resolution ─────────────────────────────────
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Resolve the repo root path for a given repo group.
|
|
520
|
+
*
|
|
521
|
+
* - Repo mode (repoId undefined): returns the passed `defaultRepoRoot`.
|
|
522
|
+
* - Workspace mode (repoId set): looks up `workspaceConfig.repos.get(repoId).path`.
|
|
523
|
+
* Falls back to `defaultRepoRoot` if repoId is not found in config (defensive).
|
|
524
|
+
*
|
|
525
|
+
* @param repoId - Repo identifier (undefined in repo mode)
|
|
526
|
+
* @param defaultRepoRoot - Default repo root (the single repoRoot in repo mode)
|
|
527
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
528
|
+
* @returns Absolute path to the repo root for this group
|
|
529
|
+
*/
|
|
530
|
+
export function resolveRepoRoot(
|
|
531
|
+
repoId: string | undefined,
|
|
532
|
+
defaultRepoRoot: string,
|
|
533
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
534
|
+
): string {
|
|
535
|
+
if (!repoId || !workspaceConfig) {
|
|
536
|
+
return defaultRepoRoot;
|
|
537
|
+
}
|
|
538
|
+
const repoConfig = workspaceConfig.repos.get(repoId);
|
|
539
|
+
if (!repoConfig) {
|
|
540
|
+
// Defensive fallback — discovery/routing should have caught this
|
|
541
|
+
return defaultRepoRoot;
|
|
542
|
+
}
|
|
543
|
+
return repoConfig.path;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the base branch for worktree creation in a given repo.
|
|
548
|
+
*
|
|
549
|
+
* Fallback chain (first non-empty wins):
|
|
550
|
+
* 1. `WorkspaceRepoConfig.defaultBranch` — explicit per-repo override from workspace config
|
|
551
|
+
* 2. Detected current branch via `getCurrentBranch(repoRoot)` — runtime detection
|
|
552
|
+
* 3. `batchBaseBranch` — the branch captured at batch start (ultimate fallback)
|
|
553
|
+
*
|
|
554
|
+
* In repo mode (repoId undefined), step 1 is skipped and step 2 uses
|
|
555
|
+
* the same repo root as the batch, so the result is equivalent to
|
|
556
|
+
* `batchBaseBranch` (which was itself detected from that repo).
|
|
557
|
+
*
|
|
558
|
+
* @param repoId - Repo identifier (undefined in repo mode)
|
|
559
|
+
* @param repoRoot - Absolute path to this repo's root
|
|
560
|
+
* @param batchBaseBranch - The base branch captured at batch start
|
|
561
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
562
|
+
* @returns Branch name to base worktrees on for this repo
|
|
563
|
+
*/
|
|
564
|
+
export function resolveBaseBranch(
|
|
565
|
+
repoId: string | undefined,
|
|
566
|
+
repoRoot: string,
|
|
567
|
+
batchBaseBranch: string,
|
|
568
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
569
|
+
): string {
|
|
570
|
+
// Step 1: Per-repo default branch from workspace config
|
|
571
|
+
if (repoId && workspaceConfig) {
|
|
572
|
+
const repoConfig = workspaceConfig.repos.get(repoId);
|
|
573
|
+
if (repoConfig?.defaultBranch) {
|
|
574
|
+
return repoConfig.defaultBranch;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Step 2: Detect current branch of this specific repo
|
|
579
|
+
// In repo mode this is the same repo as the batch, so it's equivalent to batchBaseBranch.
|
|
580
|
+
// In workspace mode this detects the actual HEAD of each repo independently.
|
|
581
|
+
if (repoId) {
|
|
582
|
+
const detected = getCurrentBranch(repoRoot);
|
|
583
|
+
if (detected) {
|
|
584
|
+
return detected;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Step 3: Ultimate fallback — batch-level base branch
|
|
589
|
+
return batchBaseBranch;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
|
|
405
593
|
// ── Lane Assignment ──────────────────────────────────────────────────
|
|
406
594
|
|
|
407
595
|
/**
|
|
@@ -635,38 +823,43 @@ export function validateAllocationInputs(
|
|
|
635
823
|
* Allocate lanes for a wave: assign tasks, create worktrees, return ready-to-execute lanes.
|
|
636
824
|
*
|
|
637
825
|
* This is the Phase 3 implementation from §5 of the design doc.
|
|
638
|
-
* It coordinates
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
*
|
|
646
|
-
* 2. **
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
*
|
|
651
|
-
*
|
|
652
|
-
*
|
|
653
|
-
* 3. **Worktree provisioning** — ensure one worktree per lane via
|
|
654
|
-
* `ensureLaneWorktrees()`.
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
826
|
+
* It coordinates four stages:
|
|
827
|
+
*
|
|
828
|
+
* 0. **Input validation** — config, tasks, strategy checks.
|
|
829
|
+
*
|
|
830
|
+
* 1. **Repo grouping** — tasks are grouped by `resolvedRepoId` via
|
|
831
|
+
* `groupTasksByRepo()`. In repo mode (no resolvedRepoId), all tasks
|
|
832
|
+
* go to a single group, preserving existing behavior exactly.
|
|
833
|
+
*
|
|
834
|
+
* 2. **Per-repo affinity grouping + strategy assignment** — for each repo
|
|
835
|
+
* group, `assignTasksToLanes()` runs independently with its own
|
|
836
|
+
* max_lanes budget. Lane numbers within each group are 1-indexed.
|
|
837
|
+
* Groups are processed in deterministic order (sorted by repoId).
|
|
838
|
+
* Global lane numbers are assigned sequentially across repo groups
|
|
839
|
+
* (repo A gets lanes 1..Na, repo B gets lanes Na+1..Na+Nb, etc.).
|
|
840
|
+
*
|
|
841
|
+
* 3. **Worktree provisioning** — ensure one worktree per global lane via
|
|
842
|
+
* `ensureLaneWorktrees()`. Existing lanes are reused across waves;
|
|
843
|
+
* missing lanes are created. If creating a missing lane fails,
|
|
844
|
+
* newly-created lanes in this call are rolled back.
|
|
845
|
+
*
|
|
846
|
+
* 4. **Build AllocatedLane[]** — each lane gets repo-aware `laneId` and
|
|
847
|
+
* `tmuxSessionName`. In workspace mode: `"api/lane-1"`, `"orch-api-lane-1"`.
|
|
848
|
+
* In repo mode: `"lane-1"`, `"orch-lane-1"` (unchanged).
|
|
658
849
|
*
|
|
659
850
|
* **Determinism guarantee:** Given the same `waveTasks`, `pending`, and `config`,
|
|
660
851
|
* this function always produces the same lane assignments and task ordering.
|
|
661
|
-
*
|
|
852
|
+
* Repo group order is sorted alphabetically by repoId. Lane assignment within
|
|
853
|
+
* each group uses the configured strategy deterministically.
|
|
662
854
|
*
|
|
663
|
-
* @param waveTasks
|
|
664
|
-
* @param pending
|
|
665
|
-
* @param config
|
|
666
|
-
* @param repoRoot
|
|
667
|
-
* @param batchId
|
|
668
|
-
* @param baseBranch
|
|
669
|
-
* @
|
|
855
|
+
* @param waveTasks - Task IDs in this wave (from topological sort)
|
|
856
|
+
* @param pending - Full pending task map (from discovery)
|
|
857
|
+
* @param config - Orchestrator configuration
|
|
858
|
+
* @param repoRoot - Absolute path to the main/default repository root
|
|
859
|
+
* @param batchId - Batch ID for branch/session naming (e.g., "20260308T111750")
|
|
860
|
+
* @param baseBranch - Branch to base worktrees on (captured at batch start)
|
|
861
|
+
* @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
|
|
862
|
+
* @returns - AllocateLanesResult with success flag and lane details
|
|
670
863
|
*/
|
|
671
864
|
export function allocateLanes(
|
|
672
865
|
waveTasks: string[],
|
|
@@ -675,6 +868,7 @@ export function allocateLanes(
|
|
|
675
868
|
repoRoot: string,
|
|
676
869
|
batchId: string,
|
|
677
870
|
baseBranch: string,
|
|
871
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
678
872
|
): AllocateLanesResult {
|
|
679
873
|
// ── Stage 0: Input validation ────────────────────────────────
|
|
680
874
|
const validationError = validateAllocationInputs(waveTasks, pending, config);
|
|
@@ -693,22 +887,65 @@ export function allocateLanes(
|
|
|
693
887
|
};
|
|
694
888
|
}
|
|
695
889
|
|
|
696
|
-
// ── Stage 1
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
//
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
890
|
+
// ── Stage 1: Group tasks by repo ─────────────────────────────
|
|
891
|
+
const repoGroups = groupTasksByRepo(waveTasks, pending);
|
|
892
|
+
|
|
893
|
+
// ── Stage 2: Per-repo affinity grouping + strategy assignment ─
|
|
894
|
+
// Each repo group gets independent lane assignment. Lane numbers
|
|
895
|
+
// within each group start at 1. We track a globalLaneOffset to
|
|
896
|
+
// produce globally unique lane numbers across all repo groups.
|
|
897
|
+
//
|
|
898
|
+
// The structure tracks: global lane number → { repoId, localLane, assignments }
|
|
899
|
+
const globalLaneEntries: Array<{
|
|
900
|
+
globalLane: number;
|
|
901
|
+
localLane: number;
|
|
902
|
+
repoId: string | undefined;
|
|
903
|
+
assignments: LaneAssignment[];
|
|
904
|
+
}> = [];
|
|
905
|
+
|
|
906
|
+
let globalLaneOffset = 0;
|
|
907
|
+
|
|
908
|
+
for (const group of repoGroups) {
|
|
909
|
+
const groupAssignments = assignTasksToLanes(
|
|
910
|
+
group.taskIds,
|
|
911
|
+
pending,
|
|
912
|
+
config.orchestrator.max_lanes,
|
|
913
|
+
config.assignment.strategy,
|
|
914
|
+
config.assignment.size_weights,
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
// Determine local lane numbers used in this group's assignment
|
|
918
|
+
const localLaneNumbers = new Set(groupAssignments.map((a) => a.lane));
|
|
919
|
+
const sortedLocalLanes = [...localLaneNumbers].sort((a, b) => a - b);
|
|
920
|
+
|
|
921
|
+
// Map local lane numbers to global lane numbers
|
|
922
|
+
const localToGlobal = new Map<number, number>();
|
|
923
|
+
for (let i = 0; i < sortedLocalLanes.length; i++) {
|
|
924
|
+
localToGlobal.set(sortedLocalLanes[i], globalLaneOffset + i + 1);
|
|
925
|
+
}
|
|
707
926
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
927
|
+
// Group assignments by local lane number
|
|
928
|
+
const byLocalLane = new Map<number, LaneAssignment[]>();
|
|
929
|
+
for (const a of groupAssignments) {
|
|
930
|
+
const existing = byLocalLane.get(a.lane) || [];
|
|
931
|
+
existing.push(a);
|
|
932
|
+
byLocalLane.set(a.lane, existing);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Produce global lane entries
|
|
936
|
+
for (const localLane of sortedLocalLanes) {
|
|
937
|
+
globalLaneEntries.push({
|
|
938
|
+
globalLane: localToGlobal.get(localLane)!,
|
|
939
|
+
localLane,
|
|
940
|
+
repoId: group.repoId,
|
|
941
|
+
assignments: byLocalLane.get(localLane) || [],
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
globalLaneOffset += sortedLocalLanes.length;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const laneCount = globalLaneEntries.length;
|
|
712
949
|
|
|
713
950
|
if (laneCount === 0) {
|
|
714
951
|
return {
|
|
@@ -724,69 +961,124 @@ export function allocateLanes(
|
|
|
724
961
|
};
|
|
725
962
|
}
|
|
726
963
|
|
|
727
|
-
// ── Stage 3: Ensure lane worktrees exist
|
|
728
|
-
|
|
964
|
+
// ── Stage 3: Ensure lane worktrees exist per repo group ──────
|
|
965
|
+
// In repo mode: all lanes use the single repoRoot/baseBranch (unchanged).
|
|
966
|
+
// In workspace mode: each repo group's lanes are created against that
|
|
967
|
+
// repo's root with its resolved base branch. Cross-repo rollback on
|
|
968
|
+
// partial failure ensures atomic wave provisioning.
|
|
969
|
+
//
|
|
970
|
+
// Group globalLaneEntries by repoId for per-repo worktree provisioning.
|
|
971
|
+
const repoLaneGroups = new Map<string, number[]>(); // key → global lane numbers
|
|
972
|
+
const repoIdForGroup = new Map<string, string | undefined>(); // key → repoId
|
|
973
|
+
for (const entry of globalLaneEntries) {
|
|
974
|
+
const key = entry.repoId ?? "";
|
|
975
|
+
const existing = repoLaneGroups.get(key) || [];
|
|
976
|
+
existing.push(entry.globalLane);
|
|
977
|
+
repoLaneGroups.set(key, existing);
|
|
978
|
+
repoIdForGroup.set(key, entry.repoId);
|
|
979
|
+
}
|
|
980
|
+
const sortedGroupKeys = [...repoLaneGroups.keys()].sort();
|
|
729
981
|
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
.join("\n");
|
|
734
|
-
const rollbackIssues = worktreeResult.rollbackErrors.length > 0
|
|
735
|
-
? "\nRollback issues:\n" +
|
|
736
|
-
worktreeResult.rollbackErrors
|
|
737
|
-
.map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
|
|
738
|
-
.join("\n")
|
|
739
|
-
: "";
|
|
982
|
+
// Track all worktrees created across all repo groups for cross-repo rollback
|
|
983
|
+
const allWorktrees = new Map<number, WorktreeInfo>(); // global lane → worktree
|
|
984
|
+
const createdGroupKeys: string[] = []; // groups that succeeded (for rollback tracking)
|
|
740
985
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
},
|
|
750
|
-
rolledBack: worktreeResult.rolledBack,
|
|
986
|
+
for (const groupKey of sortedGroupKeys) {
|
|
987
|
+
const groupLaneNumbers = repoLaneGroups.get(groupKey)!;
|
|
988
|
+
const groupRepoId = repoIdForGroup.get(groupKey);
|
|
989
|
+
const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
|
|
990
|
+
const groupBaseBranch = resolveBaseBranch(groupRepoId, groupRepoRoot, baseBranch, workspaceConfig);
|
|
991
|
+
|
|
992
|
+
const worktreeResult = ensureLaneWorktrees(
|
|
993
|
+
groupLaneNumbers,
|
|
751
994
|
batchId,
|
|
752
|
-
|
|
995
|
+
config,
|
|
996
|
+
groupRepoRoot,
|
|
997
|
+
groupBaseBranch,
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
if (!worktreeResult.success) {
|
|
1001
|
+
// ── Cross-repo rollback: remove worktrees from all previously-succeeded groups ─
|
|
1002
|
+
const rollbackErrors: string[] = [];
|
|
1003
|
+
for (const prevKey of createdGroupKeys) {
|
|
1004
|
+
const prevRepoId = repoIdForGroup.get(prevKey);
|
|
1005
|
+
const prevRepoRoot = resolveRepoRoot(prevRepoId, repoRoot, workspaceConfig);
|
|
1006
|
+
const prevLanes = repoLaneGroups.get(prevKey)!;
|
|
1007
|
+
for (const lane of prevLanes) {
|
|
1008
|
+
const wt = allWorktrees.get(lane);
|
|
1009
|
+
if (wt) {
|
|
1010
|
+
try {
|
|
1011
|
+
removeWorktree(wt, prevRepoRoot);
|
|
1012
|
+
} catch (rbErr: unknown) {
|
|
1013
|
+
rollbackErrors.push(
|
|
1014
|
+
`Lane ${lane} (repo ${prevRepoId ?? "default"}): ${rbErr instanceof Error ? rbErr.message : String(rbErr)}`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const failedLanes = worktreeResult.errors
|
|
1022
|
+
.map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
|
|
1023
|
+
.join("\n");
|
|
1024
|
+
const withinGroupRollbackIssues = worktreeResult.rollbackErrors.length > 0
|
|
1025
|
+
? "\nWithin-group rollback issues:\n" +
|
|
1026
|
+
worktreeResult.rollbackErrors
|
|
1027
|
+
.map((e) => ` Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
|
|
1028
|
+
.join("\n")
|
|
1029
|
+
: "";
|
|
1030
|
+
const crossRepoRollbackIssues = rollbackErrors.length > 0
|
|
1031
|
+
? "\nCross-repo rollback issues:\n" +
|
|
1032
|
+
rollbackErrors.map((e) => ` ${e}`).join("\n")
|
|
1033
|
+
: "";
|
|
1034
|
+
|
|
1035
|
+
return {
|
|
1036
|
+
success: false,
|
|
1037
|
+
lanes: [],
|
|
1038
|
+
laneCount: 0,
|
|
1039
|
+
error: {
|
|
1040
|
+
code: "ALLOC_WORKTREE_FAILED",
|
|
1041
|
+
message: `Failed to create worktrees for repo "${groupRepoId ?? "default"}" (${groupLaneNumbers.length} lane(s))`,
|
|
1042
|
+
details: failedLanes + withinGroupRollbackIssues + crossRepoRollbackIssues,
|
|
1043
|
+
},
|
|
1044
|
+
rolledBack: true,
|
|
1045
|
+
batchId,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Record successful worktrees
|
|
1050
|
+
for (const wt of worktreeResult.worktrees) {
|
|
1051
|
+
allWorktrees.set(wt.laneNumber, wt);
|
|
1052
|
+
}
|
|
1053
|
+
createdGroupKeys.push(groupKey);
|
|
753
1054
|
}
|
|
754
1055
|
|
|
755
1056
|
// ── Stage 4: Build AllocatedLane[] from assignments + worktrees ─
|
|
756
1057
|
const tmuxPrefix = config.orchestrator.tmux_prefix || "orch";
|
|
1058
|
+
const opId = resolveOperatorId(config);
|
|
757
1059
|
const strategy = config.assignment.strategy as AllocatedLane["strategy"];
|
|
758
1060
|
const sizeWeights = config.assignment.size_weights;
|
|
759
1061
|
|
|
760
|
-
// Build a worktree lookup by lane number
|
|
761
|
-
const worktreeByLane = new Map<number, WorktreeInfo>();
|
|
762
|
-
for (const wt of worktreeResult.worktrees) {
|
|
763
|
-
worktreeByLane.set(wt.laneNumber, wt);
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
// Group assignments by lane number and build AllocatedLane objects
|
|
767
|
-
const laneTaskMap = new Map<number, LaneAssignment[]>();
|
|
768
|
-
for (const assignment of laneAssignments) {
|
|
769
|
-
const existing = laneTaskMap.get(assignment.lane) || [];
|
|
770
|
-
existing.push(assignment);
|
|
771
|
-
laneTaskMap.set(assignment.lane, existing);
|
|
772
|
-
}
|
|
773
|
-
|
|
774
1062
|
const allocatedLanes: AllocatedLane[] = [];
|
|
775
1063
|
|
|
776
|
-
for (const
|
|
777
|
-
const wt =
|
|
1064
|
+
for (const entry of globalLaneEntries) {
|
|
1065
|
+
const wt = allWorktrees.get(entry.globalLane);
|
|
778
1066
|
if (!wt) {
|
|
779
1067
|
// This should never happen if ensureLaneWorktrees and assignTasksToLanes
|
|
780
|
-
// agree on lane numbers, but handle defensively
|
|
781
|
-
// Roll back all worktrees on this unexpected failure
|
|
782
|
-
|
|
1068
|
+
// agree on lane numbers, but handle defensively.
|
|
1069
|
+
// Roll back all worktrees across all repos on this unexpected failure.
|
|
1070
|
+
for (const groupKey of createdGroupKeys) {
|
|
1071
|
+
const groupRepoId = repoIdForGroup.get(groupKey);
|
|
1072
|
+
const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
|
|
1073
|
+
removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId);
|
|
1074
|
+
}
|
|
783
1075
|
return {
|
|
784
1076
|
success: false,
|
|
785
1077
|
lanes: [],
|
|
786
1078
|
laneCount: 0,
|
|
787
1079
|
error: {
|
|
788
1080
|
code: "ALLOC_WORKTREE_FAILED",
|
|
789
|
-
message: `No worktree found for lane ${
|
|
1081
|
+
message: `No worktree found for lane ${entry.globalLane} — lane count mismatch between assignment and worktree creation`,
|
|
790
1082
|
},
|
|
791
1083
|
rolledBack: true,
|
|
792
1084
|
batchId,
|
|
@@ -794,7 +1086,7 @@ export function allocateLanes(
|
|
|
794
1086
|
}
|
|
795
1087
|
|
|
796
1088
|
// Build ordered task list (preserve assignment order from assignTasksToLanes)
|
|
797
|
-
const allocatedTasks: AllocatedTask[] = assignments.map((a, idx) => ({
|
|
1089
|
+
const allocatedTasks: AllocatedTask[] = entry.assignments.map((a, idx) => ({
|
|
798
1090
|
taskId: a.taskId,
|
|
799
1091
|
order: idx,
|
|
800
1092
|
task: a.task,
|
|
@@ -811,19 +1103,20 @@ export function allocateLanes(
|
|
|
811
1103
|
);
|
|
812
1104
|
|
|
813
1105
|
allocatedLanes.push({
|
|
814
|
-
laneNumber:
|
|
815
|
-
laneId:
|
|
816
|
-
tmuxSessionName:
|
|
1106
|
+
laneNumber: entry.globalLane,
|
|
1107
|
+
laneId: generateLaneId(entry.localLane, entry.repoId),
|
|
1108
|
+
tmuxSessionName: generateTmuxSessionName(tmuxPrefix, entry.localLane, opId, entry.repoId),
|
|
817
1109
|
worktreePath: wt.path,
|
|
818
1110
|
branch: wt.branch,
|
|
819
1111
|
tasks: allocatedTasks,
|
|
820
1112
|
strategy,
|
|
821
1113
|
estimatedLoad,
|
|
822
1114
|
estimatedMinutes,
|
|
1115
|
+
repoId: entry.repoId,
|
|
823
1116
|
});
|
|
824
1117
|
}
|
|
825
1118
|
|
|
826
|
-
// Sort by lane number for deterministic output
|
|
1119
|
+
// Sort by global lane number for deterministic output
|
|
827
1120
|
allocatedLanes.sort((a, b) => a.laneNumber - b.laneNumber);
|
|
828
1121
|
|
|
829
1122
|
return {
|
|
@@ -891,4 +1184,3 @@ export function computeWaveAssignments(
|
|
|
891
1184
|
|
|
892
1185
|
return { waves: waveAssignments, errors };
|
|
893
1186
|
}
|
|
894
|
-
|
|
@@ -317,10 +317,27 @@ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | nu
|
|
|
317
317
|
);
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
// ── 12. routing.strict (optional boolean, default false) ─────
|
|
321
|
+
const rawStrict = rawRouting.strict;
|
|
322
|
+
if (rawStrict !== undefined) {
|
|
323
|
+
// null (from bare `strict:` or `strict: null` in YAML) is rejected
|
|
324
|
+
// to prevent fail-open: governance controls must be explicit.
|
|
325
|
+
if (rawStrict === null || typeof rawStrict !== "boolean") {
|
|
326
|
+
throw new WorkspaceConfigError(
|
|
327
|
+
"WORKSPACE_SCHEMA_INVALID",
|
|
328
|
+
`routing.strict must be a boolean (true/false)${rawStrict === null ? ", got null (use true or false explicitly)" : `, got ${typeof rawStrict}: ${JSON.stringify(rawStrict)}`}`,
|
|
329
|
+
undefined,
|
|
330
|
+
configFile,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const strict = rawStrict === true;
|
|
335
|
+
|
|
320
336
|
// ── Build routing config ─────────────────────────────────────
|
|
321
337
|
const routing: WorkspaceRoutingConfig = {
|
|
322
338
|
tasksRoot: tasksRootAbsolute,
|
|
323
339
|
defaultRepo: defaultRepoId,
|
|
340
|
+
...(strict ? { strict: true } : {}),
|
|
324
341
|
};
|
|
325
342
|
|
|
326
343
|
// ── Build and return WorkspaceConfig ─────────────────────────
|