taskplane 0.1.17 → 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.
@@ -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 { ensureLaneWorktrees, removeAllWorktrees } from "./worktree.ts";
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 three stages:
639
- *
640
- * 1. **Affinity grouping** — tasks with overlapping file scope are grouped
641
- * together using `applyFileScopeAffinity()`. Overlap is detected from
642
- * PROMPT.md's `## File Scope` section (parsed during discovery). Affinity
643
- * groups have priority: they are assigned before independent tasks.
644
- * Tie-breaking is deterministic (alphabetical by first task ID in group).
645
- *
646
- * 2. **Strategy assignment** — groups are distributed across lanes using
647
- * the configured strategy via `assignTasksToLanes()`:
648
- * - `affinity-first`: multi-task groups first (heaviest→lightest), then
649
- * single tasks via load-balanced fill
650
- * - `round-robin`: sequential assignment by group index mod lane count
651
- * - `load-balanced`: heaviest group lightest lane, repeated
652
- *
653
- * 3. **Worktree provisioning** — ensure one worktree per lane via
654
- * `ensureLaneWorktrees()`.
655
- * Existing lanes are reused across waves; missing lanes are created.
656
- * If creating a missing lane fails, newly-created lanes in this call are
657
- * rolled back.
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
- * This makes debugging and retry behavior predictable.
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 - Task IDs in this wave (from topological sort)
664
- * @param pending - Full pending task map (from discovery)
665
- * @param config - Orchestrator configuration
666
- * @param repoRoot - Absolute path to the main repository root
667
- * @param batchId - Batch ID for branch/session naming (e.g., "20260308T111750")
668
- * @param baseBranch - Branch to base worktrees on (captured at batch start)
669
- * @returns - AllocateLanesResult with success flag and lane details
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+2: Affinity grouping + strategy assignment ───────
697
- // assignTasksToLanes() internally calls applyFileScopeAffinity()
698
- // and applies the configured strategy. It returns LaneAssignment[]
699
- // with deterministic ordering.
700
- const laneAssignments = assignTasksToLanes(
701
- waveTasks,
702
- pending,
703
- config.orchestrator.max_lanes,
704
- config.assignment.strategy,
705
- config.assignment.size_weights,
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
- // Determine actual lane count from assignments
709
- const laneNumbers = new Set(laneAssignments.map((a) => a.lane));
710
- const sortedLaneNumbers = [...laneNumbers].sort((a, b) => a - b);
711
- const laneCount = laneNumbers.size;
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 (reuse across waves + create missing) ─
728
- const worktreeResult = ensureLaneWorktrees(sortedLaneNumbers, batchId, config, repoRoot, baseBranch);
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
- if (!worktreeResult.success) {
731
- const failedLanes = worktreeResult.errors
732
- .map((e) => `Lane ${e.laneNumber}: [${e.code}] ${e.message}`)
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
- return {
742
- success: false,
743
- lanes: [],
744
- laneCount: 0,
745
- error: {
746
- code: "ALLOC_WORKTREE_FAILED",
747
- message: `Failed to create worktrees for ${laneCount} lane(s)`,
748
- details: failedLanes + rollbackIssues,
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 [laneNum, assignments] of laneTaskMap) {
777
- const wt = worktreeByLane.get(laneNum);
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
- removeAllWorktrees(config.orchestrator.worktree_prefix, repoRoot);
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 ${laneNum} — lane count mismatch between assignment and worktree creation`,
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: laneNum,
815
- laneId: `lane-${laneNum}`,
816
- tmuxSessionName: `${tmuxPrefix}-lane-${laneNum}`,
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 ─────────────────────────