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
|
@@ -281,6 +281,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
281
|
"info",
|
|
282
282
|
);
|
|
283
283
|
}
|
|
284
|
+
const hasStrictErrors = fatalErrors.some(
|
|
285
|
+
(e) => e.code === "TASK_ROUTING_STRICT",
|
|
286
|
+
);
|
|
287
|
+
if (hasStrictErrors) {
|
|
288
|
+
ctx.ui.notify(
|
|
289
|
+
"π‘ Strict routing is enabled (routing.strict: true). Every task must declare an explicit execution target.\n" +
|
|
290
|
+
" Add a `## Execution Target` section with `Repo: <id>` to each task's PROMPT.md.\n" +
|
|
291
|
+
" To disable strict routing, set `routing.strict: false` in workspace config.",
|
|
292
|
+
"info",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
284
295
|
return;
|
|
285
296
|
}
|
|
286
297
|
|
|
@@ -7,8 +7,10 @@ import { spawnSync } from "child_process";
|
|
|
7
7
|
import { join } from "path";
|
|
8
8
|
|
|
9
9
|
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
|
+
import { resolveOperatorId } from "./naming.ts";
|
|
10
11
|
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
11
|
-
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, WaveExecutionResult } from "./types.ts";
|
|
12
|
+
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
13
|
+
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
12
14
|
import { sleepSync } from "./worktree.ts";
|
|
13
15
|
|
|
14
16
|
// ββ Merge Implementation βββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -493,6 +495,7 @@ export function mergeWave(
|
|
|
493
495
|
): MergeWaveResult {
|
|
494
496
|
const startTime = Date.now();
|
|
495
497
|
const tmuxPrefix = config.orchestrator.tmux_prefix;
|
|
498
|
+
const opId = resolveOperatorId(config);
|
|
496
499
|
const targetBranch = baseBranch;
|
|
497
500
|
const laneResults: MergeLaneResult[] = [];
|
|
498
501
|
|
|
@@ -543,8 +546,9 @@ export function mergeWave(
|
|
|
543
546
|
// ββ Create isolated merge worktree ββββββββββββββββββββββββββββββ
|
|
544
547
|
// Merging in a dedicated worktree prevents dirty-worktree failures
|
|
545
548
|
// caused by user edits or orchestrator-generated files in the main repo.
|
|
546
|
-
|
|
547
|
-
const
|
|
549
|
+
// Include opId to prevent collisions between concurrent operators.
|
|
550
|
+
const tempBranch = `_merge-temp-${opId}-${batchId}`;
|
|
551
|
+
const mergeWorkDir = join(repoRoot, ".worktrees", `merge-workspace-${opId}`);
|
|
548
552
|
|
|
549
553
|
// Clean up stale merge worktree/branch from prior failed attempt
|
|
550
554
|
try {
|
|
@@ -592,10 +596,10 @@ export function mergeWave(
|
|
|
592
596
|
|
|
593
597
|
for (const lane of orderedLanes) {
|
|
594
598
|
const laneStart = Date.now();
|
|
595
|
-
const sessionName = `${tmuxPrefix}-merge-${lane.laneNumber}`;
|
|
596
|
-
const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${batchId}.json`;
|
|
599
|
+
const sessionName = `${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`;
|
|
600
|
+
const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
|
|
597
601
|
const resultFilePath = join(repoRoot, ".pi", resultFileName);
|
|
598
|
-
const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${batchId}.txt`;
|
|
602
|
+
const requestFileName = `merge-request-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.txt`;
|
|
599
603
|
const requestFilePath = join(repoRoot, ".pi", requestFileName);
|
|
600
604
|
|
|
601
605
|
execLog("merge", sessionName, `starting merge for lane ${lane.laneNumber}`, {
|
|
@@ -647,6 +651,7 @@ export function mergeWave(
|
|
|
647
651
|
result: mergeResult,
|
|
648
652
|
error: null,
|
|
649
653
|
durationMs: Date.now() - laneStart,
|
|
654
|
+
repoId: lane.repoId,
|
|
650
655
|
});
|
|
651
656
|
|
|
652
657
|
// Handle merge outcome
|
|
@@ -715,6 +720,7 @@ export function mergeWave(
|
|
|
715
720
|
result: null,
|
|
716
721
|
error: errMsg,
|
|
717
722
|
durationMs: Date.now() - laneStart,
|
|
723
|
+
repoId: lane.repoId,
|
|
718
724
|
});
|
|
719
725
|
|
|
720
726
|
failedLane = lane.laneNumber;
|
|
@@ -795,3 +801,241 @@ export function mergeWave(
|
|
|
795
801
|
};
|
|
796
802
|
}
|
|
797
803
|
|
|
804
|
+
|
|
805
|
+
// ββ Repo-Scoped Merge ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Group mergeable lanes by their `repoId`.
|
|
809
|
+
*
|
|
810
|
+
* Returns groups sorted deterministically by repoId (undefined/repo-mode
|
|
811
|
+
* group sorts first as empty string). Lanes within each group preserve
|
|
812
|
+
* the input order.
|
|
813
|
+
*
|
|
814
|
+
* @param lanes - Lanes to group (already filtered for mergeability)
|
|
815
|
+
* @returns Array of { repoId, lanes } groups in deterministic order
|
|
816
|
+
*/
|
|
817
|
+
export function groupLanesByRepo(
|
|
818
|
+
lanes: AllocatedLane[],
|
|
819
|
+
): Array<{ repoId: string | undefined; lanes: AllocatedLane[] }> {
|
|
820
|
+
const groupMap = new Map<string, AllocatedLane[]>();
|
|
821
|
+
|
|
822
|
+
for (const lane of lanes) {
|
|
823
|
+
const key = lane.repoId ?? "";
|
|
824
|
+
const existing = groupMap.get(key) || [];
|
|
825
|
+
existing.push(lane);
|
|
826
|
+
groupMap.set(key, existing);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const sortedKeys = [...groupMap.keys()].sort();
|
|
830
|
+
return sortedKeys.map(key => ({
|
|
831
|
+
repoId: key || undefined,
|
|
832
|
+
lanes: groupMap.get(key)!,
|
|
833
|
+
}));
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Merge a wave's lanes partitioned by repository.
|
|
838
|
+
*
|
|
839
|
+
* In repo mode (all lanes have repoId=undefined), this produces a single
|
|
840
|
+
* repo group and delegates to `mergeWave()` exactly once β a no-op
|
|
841
|
+
* regression case that preserves existing behavior.
|
|
842
|
+
*
|
|
843
|
+
* In workspace mode, lanes are grouped by `repoId`. Each repo group gets:
|
|
844
|
+
* - Its own repo root (via `resolveRepoRoot()`)
|
|
845
|
+
* - Its own base branch (via `resolveBaseBranch()`)
|
|
846
|
+
* - An independent `mergeWave()` call with those repo-scoped parameters
|
|
847
|
+
*
|
|
848
|
+
* Repo groups are processed in deterministic order (sorted by repoId).
|
|
849
|
+
* Per-repo results are aggregated into a single `MergeWaveResult` for
|
|
850
|
+
* the existing wave-level failure policy handling in `engine.ts`.
|
|
851
|
+
*
|
|
852
|
+
* Failure semantics:
|
|
853
|
+
* - A failure in one repo does NOT stop merging in other repos.
|
|
854
|
+
* - The aggregate status is "succeeded" only if all repos succeeded.
|
|
855
|
+
* - If any repo failed and any succeeded, status is "partial".
|
|
856
|
+
* - `repoResults` field carries per-repo attribution for downstream
|
|
857
|
+
* reporting (Step 1 will use this for explicit partial-success summaries).
|
|
858
|
+
*
|
|
859
|
+
* @param completedLanes - Lanes that completed execution (from wave result)
|
|
860
|
+
* @param waveResult - The wave execution result (for lane status filtering)
|
|
861
|
+
* @param waveIndex - Wave number (1-indexed)
|
|
862
|
+
* @param config - Orchestrator configuration
|
|
863
|
+
* @param repoRoot - Default repository root (used in repo mode)
|
|
864
|
+
* @param batchId - Batch ID for session naming
|
|
865
|
+
* @param baseBranch - Default branch to merge into (captured at batch start)
|
|
866
|
+
* @param workspaceConfig - Workspace configuration (null in repo mode)
|
|
867
|
+
* @returns MergeWaveResult with per-lane and per-repo outcomes
|
|
868
|
+
*/
|
|
869
|
+
export function mergeWaveByRepo(
|
|
870
|
+
completedLanes: AllocatedLane[],
|
|
871
|
+
waveResult: WaveExecutionResult,
|
|
872
|
+
waveIndex: number,
|
|
873
|
+
config: OrchestratorConfig,
|
|
874
|
+
repoRoot: string,
|
|
875
|
+
batchId: string,
|
|
876
|
+
baseBranch: string,
|
|
877
|
+
workspaceConfig?: WorkspaceConfig | null,
|
|
878
|
+
): MergeWaveResult {
|
|
879
|
+
const startTime = Date.now();
|
|
880
|
+
|
|
881
|
+
// Build lane outcome lookup for merge eligibility (same logic as mergeWave).
|
|
882
|
+
const laneOutcomeByNumber = new Map<number, LaneExecutionResult>();
|
|
883
|
+
for (const laneOutcome of waveResult.laneResults) {
|
|
884
|
+
laneOutcomeByNumber.set(laneOutcome.laneNumber, laneOutcome);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// Filter to mergeable lanes (same criteria as mergeWave).
|
|
888
|
+
const mergeableLanes = completedLanes.filter(lane => {
|
|
889
|
+
const outcome = laneOutcomeByNumber.get(lane.laneNumber);
|
|
890
|
+
if (!outcome) return false;
|
|
891
|
+
const hasSucceeded = outcome.tasks.some(t => t.status === "succeeded");
|
|
892
|
+
const hasHardFailure = outcome.tasks.some(
|
|
893
|
+
t => t.status === "failed" || t.status === "stalled",
|
|
894
|
+
);
|
|
895
|
+
return hasSucceeded && !hasHardFailure;
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
if (mergeableLanes.length === 0) {
|
|
899
|
+
execLog("merge", `W${waveIndex}`, "no mergeable lanes (all failed or empty)");
|
|
900
|
+
return {
|
|
901
|
+
waveIndex,
|
|
902
|
+
status: "succeeded",
|
|
903
|
+
laneResults: [],
|
|
904
|
+
failedLane: null,
|
|
905
|
+
failureReason: null,
|
|
906
|
+
totalDurationMs: Date.now() - startTime,
|
|
907
|
+
repoResults: [],
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Group lanes by repo
|
|
912
|
+
const repoGroups = groupLanesByRepo(mergeableLanes);
|
|
913
|
+
|
|
914
|
+
execLog("merge", `W${waveIndex}`, `merging across ${repoGroups.length} repo group(s)`, {
|
|
915
|
+
repos: repoGroups.map(g => g.repoId ?? "(default)").join(", "),
|
|
916
|
+
totalLanes: mergeableLanes.length,
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// In repo mode (single group with repoId=undefined), delegate directly
|
|
920
|
+
// to mergeWave() for zero-overhead backward compatibility.
|
|
921
|
+
if (repoGroups.length === 1 && repoGroups[0].repoId === undefined) {
|
|
922
|
+
const result = mergeWave(
|
|
923
|
+
completedLanes,
|
|
924
|
+
waveResult,
|
|
925
|
+
waveIndex,
|
|
926
|
+
config,
|
|
927
|
+
repoRoot,
|
|
928
|
+
batchId,
|
|
929
|
+
baseBranch,
|
|
930
|
+
);
|
|
931
|
+
// Attach empty repoResults for consistent shape
|
|
932
|
+
return { ...result, repoResults: [] };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ββ Workspace mode: per-repo merge loops βββββββββββββββββββββ
|
|
936
|
+
const allLaneResults: MergeLaneResult[] = [];
|
|
937
|
+
const repoOutcomes: RepoMergeOutcome[] = [];
|
|
938
|
+
let firstFailedLane: number | null = null;
|
|
939
|
+
let firstFailureReason: string | null = null;
|
|
940
|
+
// Track repo-level failures independently of lane-level failures.
|
|
941
|
+
// mergeWave() can return status="failed" with failedLane=null for
|
|
942
|
+
// pre-lane setup errors (temp branch creation, worktree creation).
|
|
943
|
+
// We must detect these to avoid misclassifying the aggregate as "succeeded".
|
|
944
|
+
let anyRepoFailed = false;
|
|
945
|
+
|
|
946
|
+
for (const group of repoGroups) {
|
|
947
|
+
const groupRepoRoot = resolveRepoRoot(group.repoId, repoRoot, workspaceConfig);
|
|
948
|
+
const groupBaseBranch = resolveBaseBranch(group.repoId, groupRepoRoot, baseBranch, workspaceConfig);
|
|
949
|
+
|
|
950
|
+
execLog("merge", `W${waveIndex}`, `merging repo group: ${group.repoId ?? "(default)"}`, {
|
|
951
|
+
repoRoot: groupRepoRoot,
|
|
952
|
+
baseBranch: groupBaseBranch,
|
|
953
|
+
laneCount: group.lanes.length,
|
|
954
|
+
lanes: group.lanes.map(l => l.laneNumber).join(","),
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// Build a filtered WaveExecutionResult containing only this group's lanes.
|
|
958
|
+
const groupLaneNumbers = new Set(group.lanes.map(l => l.laneNumber));
|
|
959
|
+
const filteredWaveResult: WaveExecutionResult = {
|
|
960
|
+
...waveResult,
|
|
961
|
+
laneResults: waveResult.laneResults.filter(lr => groupLaneNumbers.has(lr.laneNumber)),
|
|
962
|
+
allocatedLanes: waveResult.allocatedLanes.filter(l => groupLaneNumbers.has(l.laneNumber)),
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
const groupResult = mergeWave(
|
|
966
|
+
group.lanes,
|
|
967
|
+
filteredWaveResult,
|
|
968
|
+
waveIndex,
|
|
969
|
+
config,
|
|
970
|
+
groupRepoRoot,
|
|
971
|
+
batchId,
|
|
972
|
+
groupBaseBranch,
|
|
973
|
+
);
|
|
974
|
+
|
|
975
|
+
// Accumulate lane results
|
|
976
|
+
allLaneResults.push(...groupResult.laneResults);
|
|
977
|
+
|
|
978
|
+
// Build per-repo outcome
|
|
979
|
+
const repoOutcome: RepoMergeOutcome = {
|
|
980
|
+
repoId: group.repoId,
|
|
981
|
+
status: groupResult.status,
|
|
982
|
+
laneResults: groupResult.laneResults,
|
|
983
|
+
failedLane: groupResult.failedLane,
|
|
984
|
+
failureReason: groupResult.failureReason,
|
|
985
|
+
};
|
|
986
|
+
repoOutcomes.push(repoOutcome);
|
|
987
|
+
|
|
988
|
+
// Track failures across repos (but continue to merge other repos).
|
|
989
|
+
// Check groupResult.status (not just failedLane) to catch setup failures
|
|
990
|
+
// where mergeWave() returns status="failed" with failedLane=null
|
|
991
|
+
// (e.g., temp branch creation or worktree creation failure).
|
|
992
|
+
if (groupResult.status !== "succeeded") {
|
|
993
|
+
anyRepoFailed = true;
|
|
994
|
+
|
|
995
|
+
if (firstFailureReason === null) {
|
|
996
|
+
firstFailedLane = groupResult.failedLane;
|
|
997
|
+
firstFailureReason = groupResult.failureReason
|
|
998
|
+
? `[repo:${group.repoId ?? "default"}] ${groupResult.failureReason}`
|
|
999
|
+
: `[repo:${group.repoId ?? "default"}] Merge failed (setup error)`;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ββ Aggregate status βββββββββββββββββββββββββββββββββββββββββ
|
|
1005
|
+
// Use both lane-level and repo-level evidence for correct classification:
|
|
1006
|
+
// - anyLaneSucceeded: at least one lane merged successfully across all repos
|
|
1007
|
+
// - anyRepoFailed: at least one repo had a non-succeeded status (includes
|
|
1008
|
+
// both lane-level failures AND repo setup failures with failedLane=null)
|
|
1009
|
+
const anyLaneSucceeded = allLaneResults.some(
|
|
1010
|
+
r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
let status: MergeWaveResult["status"];
|
|
1014
|
+
if (!anyRepoFailed) {
|
|
1015
|
+
status = "succeeded";
|
|
1016
|
+
} else if (anyLaneSucceeded) {
|
|
1017
|
+
status = "partial";
|
|
1018
|
+
} else {
|
|
1019
|
+
status = "failed";
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const totalDurationMs = Date.now() - startTime;
|
|
1023
|
+
|
|
1024
|
+
execLog("merge", `W${waveIndex}`, `repo-scoped wave merge complete: ${status}`, {
|
|
1025
|
+
repoCount: repoOutcomes.length,
|
|
1026
|
+
repoStatuses: repoOutcomes.map(r => `${r.repoId ?? "default"}:${r.status}`).join(", "),
|
|
1027
|
+
mergedLanes: allLaneResults.filter(r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED").length,
|
|
1028
|
+
duration: `${Math.round(totalDurationMs / 1000)}s`,
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
waveIndex,
|
|
1033
|
+
status,
|
|
1034
|
+
laneResults: allLaneResults,
|
|
1035
|
+
failedLane: firstFailedLane,
|
|
1036
|
+
failureReason: firstFailureReason,
|
|
1037
|
+
totalDurationMs,
|
|
1038
|
+
repoResults: repoOutcomes,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* User-facing message templates (ORCH_MESSAGES)
|
|
3
3
|
* @module orch/messages
|
|
4
4
|
*/
|
|
5
|
-
import type { AbortMode } from "./types.ts";
|
|
5
|
+
import type { AbortMode, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome } from "./types.ts";
|
|
6
6
|
|
|
7
7
|
// ββ Message Templates ββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
8
8
|
|
|
@@ -19,7 +19,7 @@ export const ORCH_MESSAGES = {
|
|
|
19
19
|
orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
|
|
20
20
|
`β
Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
|
|
21
21
|
orchMergeStart: (waveNum: number, laneCount: number) =>
|
|
22
|
-
`π [Wave ${waveNum}] Merging ${laneCount} lane(s) into
|
|
22
|
+
`π [Wave ${waveNum}] Merging ${laneCount} lane(s) into target branch...`,
|
|
23
23
|
orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
|
|
24
24
|
` β
Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
|
|
25
25
|
orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
|
|
@@ -35,7 +35,7 @@ export const ORCH_MESSAGES = {
|
|
|
35
35
|
orchMergePlaceholder: (waveNum: number) =>
|
|
36
36
|
`π [Wave ${waveNum}] Merge: placeholder β Step 3 (TS-008) will replace with mergeWave()`,
|
|
37
37
|
orchWorktreeReset: (waveNum: number, lanes: number) =>
|
|
38
|
-
`π Resetting ${lanes} worktree(s) to
|
|
38
|
+
`π Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`,
|
|
39
39
|
orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => {
|
|
40
40
|
const lines = [`\nπ Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
|
|
41
41
|
if (failed > 0 || blocked > 0) {
|
|
@@ -117,9 +117,213 @@ export const ORCH_MESSAGES = {
|
|
|
117
117
|
`No active batch to abort. Use /orch <areas|all> to start a batch.`,
|
|
118
118
|
abortComplete: (mode: AbortMode, sessionsKilled: number) =>
|
|
119
119
|
`π Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
|
|
120
|
+
// /orch merge β repo-scoped partial summary (TP-005 Step 1)
|
|
121
|
+
orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
|
|
122
|
+
`β οΈ [Wave ${waveNum}] Merge partially succeeded β repo outcomes diverged:\n${repoLines.join("\n")}`,
|
|
120
123
|
} as const;
|
|
121
124
|
|
|
122
125
|
|
|
126
|
+
// ββ Repo-Scoped Merge Summary (TP-005) ββββββββββββββββββββββββββββββ
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Status emoji for repo merge outcome.
|
|
130
|
+
*/
|
|
131
|
+
function repoStatusIcon(status: RepoMergeOutcome["status"]): string {
|
|
132
|
+
switch (status) {
|
|
133
|
+
case "succeeded": return "β
";
|
|
134
|
+
case "partial": return "β οΈ";
|
|
135
|
+
case "failed": return "β";
|
|
136
|
+
default: return "β";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format a repo-divergence summary for a partial merge wave result.
|
|
142
|
+
*
|
|
143
|
+
* Returns null if:
|
|
144
|
+
* - repoResults is empty or undefined (mono-repo mode)
|
|
145
|
+
* - all repos have the same status (no divergence)
|
|
146
|
+
* - there is only one repo group (divergence is meaningless)
|
|
147
|
+
*
|
|
148
|
+
* When the partial result is caused by mixed-outcome lanes within
|
|
149
|
+
* a single repo (not repo divergence), this returns null to avoid
|
|
150
|
+
* misleading "cross-repo divergence" messaging.
|
|
151
|
+
*
|
|
152
|
+
* The returned string is a complete, ready-to-emit message.
|
|
153
|
+
*
|
|
154
|
+
* @param mergeResult - The MergeWaveResult with status "partial"
|
|
155
|
+
* @returns Formatted summary string, or null if no repo-divergence summary applies
|
|
156
|
+
*/
|
|
157
|
+
export function formatRepoMergeSummary(mergeResult: MergeWaveResult): string | null {
|
|
158
|
+
const repoResults = mergeResult.repoResults;
|
|
159
|
+
|
|
160
|
+
// No repo attribution β mono-repo mode, no summary
|
|
161
|
+
if (!repoResults || repoResults.length === 0) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Single repo group β divergence is meaningless (partial is lane-level)
|
|
166
|
+
if (repoResults.length < 2) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Check for actual divergence: are there different statuses across repos?
|
|
171
|
+
const statuses = new Set(repoResults.map(r => r.status));
|
|
172
|
+
if (statuses.size < 2) {
|
|
173
|
+
// All repos have the same status (e.g., all "partial") β
|
|
174
|
+
// the partial is from within-repo lane failures, not cross-repo divergence
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Build per-repo summary lines (sorted by repoId, which repoResults already is)
|
|
179
|
+
const repoLines = repoResults.map(r => {
|
|
180
|
+
const repoLabel = r.repoId ?? "(default)";
|
|
181
|
+
const icon = repoStatusIcon(r.status);
|
|
182
|
+
const mergedCount = r.laneResults.filter(
|
|
183
|
+
lr => lr.result?.status === "SUCCESS" || lr.result?.status === "CONFLICT_RESOLVED",
|
|
184
|
+
).length;
|
|
185
|
+
const totalCount = r.laneResults.length;
|
|
186
|
+
let detail = `${mergedCount}/${totalCount} lane(s) merged`;
|
|
187
|
+
if (r.failureReason) {
|
|
188
|
+
detail += ` β ${r.failureReason.slice(0, 150)}`;
|
|
189
|
+
}
|
|
190
|
+
return ` ${icon} ${repoLabel}: ${detail}`;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return ORCH_MESSAGES.orchMergePartialRepoSummary(mergeResult.waveIndex, repoLines);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
// ββ Merge Failure Policy Application (TP-005 Step 2) βββββββββββββββββ
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Result of applying the merge failure policy.
|
|
201
|
+
*
|
|
202
|
+
* Pure function output β callers use this to perform state mutations
|
|
203
|
+
* and notifications consistently. Ensures engine.ts and resume.ts
|
|
204
|
+
* apply identical pause/abort transitions.
|
|
205
|
+
*/
|
|
206
|
+
export interface MergeFailurePolicyResult {
|
|
207
|
+
/** The applied policy: "pause" or "abort". */
|
|
208
|
+
policy: "pause" | "abort";
|
|
209
|
+
/** Target phase for batchState.phase. */
|
|
210
|
+
targetPhase: "paused" | "stopped";
|
|
211
|
+
/** Error message to push to batchState.errors. */
|
|
212
|
+
errorMessage: string;
|
|
213
|
+
/** Persistence trigger label. */
|
|
214
|
+
persistTrigger: "merge-failure-pause" | "merge-failure-abort";
|
|
215
|
+
/** User-facing notification message. */
|
|
216
|
+
notifyMessage: string;
|
|
217
|
+
/** Notification level for onNotify. */
|
|
218
|
+
notifyLevel: "error";
|
|
219
|
+
/** Comma-separated failed lane identifiers for logging. */
|
|
220
|
+
failedLaneIds: string;
|
|
221
|
+
/** Structured log details for execLog. */
|
|
222
|
+
logDetails: {
|
|
223
|
+
failedLane: number;
|
|
224
|
+
failedLaneIds: string;
|
|
225
|
+
reason: string;
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Compute the merge failure policy application result.
|
|
231
|
+
*
|
|
232
|
+
* This is a **pure function** β it computes all outputs deterministically
|
|
233
|
+
* from the merge result and config, without performing any side effects.
|
|
234
|
+
*
|
|
235
|
+
* Both engine.ts and resume.ts MUST use this function to guarantee
|
|
236
|
+
* identical failure attribution, phase transitions, error messages,
|
|
237
|
+
* and notifications on repo-scoped merge failures.
|
|
238
|
+
*
|
|
239
|
+
* Failure attribution rules (priority chain):
|
|
240
|
+
* 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
|
|
241
|
+
* β formatted as `lane-<N>` (comma-separated).
|
|
242
|
+
* 2. Fallback: if no lane-level failures but `mergeResult.failedLane`
|
|
243
|
+
* is non-null, uses `lane-<N>` as the identifier.
|
|
244
|
+
* 3. Repo-level: if no lane-level failures and failedLane is null
|
|
245
|
+
* (repo setup failure), uses `repo:<repoId>` from repoResults
|
|
246
|
+
* entries with non-succeeded status. Sorted deterministically.
|
|
247
|
+
* - The failure reason is truncated to 200 chars for notifications and
|
|
248
|
+
* logged in full in batchState.errors.
|
|
249
|
+
*
|
|
250
|
+
* @param mergeResult - The merge wave result with status "failed" or "partial"
|
|
251
|
+
* @param waveIndex - 0-based wave index (displayed as 1-indexed)
|
|
252
|
+
* @param config - Orchestrator configuration (for on_merge_failure policy)
|
|
253
|
+
* @returns Policy result object for callers to apply
|
|
254
|
+
*/
|
|
255
|
+
export function computeMergeFailurePolicy(
|
|
256
|
+
mergeResult: MergeWaveResult,
|
|
257
|
+
waveIndex: number,
|
|
258
|
+
config: OrchestratorConfig,
|
|
259
|
+
): MergeFailurePolicyResult {
|
|
260
|
+
const waveNum = waveIndex + 1;
|
|
261
|
+
const mergeFailurePolicy = config.failure.on_merge_failure;
|
|
262
|
+
|
|
263
|
+
// Build failed lane identifiers from lane results.
|
|
264
|
+
// Priority chain:
|
|
265
|
+
// 1. Lane-level: lanes with CONFLICT_UNRESOLVED, BUILD_FAILURE, or error
|
|
266
|
+
// 2. Fallback: failedLane from mergeResult (single lane ID)
|
|
267
|
+
// 3. Repo-level: repos with non-succeeded status from repoResults
|
|
268
|
+
// (catches setup failures where failedLane=null and no lane results)
|
|
269
|
+
let failedLaneIds = mergeResult.laneResults
|
|
270
|
+
.filter(r => r.result?.status === "CONFLICT_UNRESOLVED" || r.result?.status === "BUILD_FAILURE" || r.error)
|
|
271
|
+
.map(r => `lane-${r.laneNumber}`)
|
|
272
|
+
.join(", ");
|
|
273
|
+
if (!failedLaneIds && mergeResult.failedLane !== null) {
|
|
274
|
+
failedLaneIds = `lane-${mergeResult.failedLane}`;
|
|
275
|
+
}
|
|
276
|
+
if (!failedLaneIds && mergeResult.repoResults && mergeResult.repoResults.length > 0) {
|
|
277
|
+
// Repo-level fallback for setup failures (no lane results, failedLane=null).
|
|
278
|
+
// Uses sorted repoResults order for determinism.
|
|
279
|
+
failedLaneIds = mergeResult.repoResults
|
|
280
|
+
.filter(r => r.status !== "succeeded")
|
|
281
|
+
.map(r => `repo:${r.repoId ?? "default"}`)
|
|
282
|
+
.join(", ");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const reason = mergeResult.failureReason || "unknown";
|
|
286
|
+
const reasonTruncated = reason.slice(0, 200);
|
|
287
|
+
|
|
288
|
+
const logDetails = {
|
|
289
|
+
failedLane: mergeResult.failedLane ?? 0,
|
|
290
|
+
failedLaneIds,
|
|
291
|
+
reason: reasonTruncated,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const errorMessage =
|
|
295
|
+
`Merge failed at wave ${waveNum}: ${reason}. ` +
|
|
296
|
+
(mergeFailurePolicy === "pause"
|
|
297
|
+
? `Batch paused. Resolve conflicts and use /orch-resume to continue.`
|
|
298
|
+
: `Batch aborted by on_merge_failure policy.`);
|
|
299
|
+
|
|
300
|
+
const laneDetail = failedLaneIds ? ` (${failedLaneIds})` : "";
|
|
301
|
+
|
|
302
|
+
let notifyMessage: string;
|
|
303
|
+
if (mergeFailurePolicy === "pause") {
|
|
304
|
+
notifyMessage =
|
|
305
|
+
`βΈοΈ Batch paused due to merge failure at wave ${waveNum}${laneDetail}. ` +
|
|
306
|
+
`Reason: ${reasonTruncated}. ` +
|
|
307
|
+
`Resolve conflicts and resume.`;
|
|
308
|
+
} else {
|
|
309
|
+
notifyMessage =
|
|
310
|
+
`β Batch aborted due to merge failure at wave ${waveNum}${laneDetail}. ` +
|
|
311
|
+
`Reason: ${reasonTruncated}.`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
policy: mergeFailurePolicy,
|
|
316
|
+
targetPhase: mergeFailurePolicy === "pause" ? "paused" : "stopped",
|
|
317
|
+
errorMessage,
|
|
318
|
+
persistTrigger: mergeFailurePolicy === "pause" ? "merge-failure-pause" : "merge-failure-abort",
|
|
319
|
+
notifyMessage,
|
|
320
|
+
notifyLevel: "error",
|
|
321
|
+
failedLaneIds,
|
|
322
|
+
logDetails,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
|
|
123
327
|
// ββ Resume ORCH_MESSAGES βββββββββββββββββββββββββββββββββββββββββββββ
|
|
124
328
|
|
|
125
329
|
// Note: These are added via extension to the ORCH_MESSAGES object below.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming contract helpers for team-scale collision resistance.
|
|
3
|
+
*
|
|
4
|
+
* Provides deterministic, human-readable identifiers for TMUX sessions,
|
|
5
|
+
* worktree directories, git branches, and merge artifacts. All naming
|
|
6
|
+
* components are sanitized for safe use in filesystem paths, git refs,
|
|
7
|
+
* and TMUX session names.
|
|
8
|
+
*
|
|
9
|
+
* @module orch/naming
|
|
10
|
+
*/
|
|
11
|
+
import { basename, resolve } from "path";
|
|
12
|
+
import { userInfo } from "os";
|
|
13
|
+
|
|
14
|
+
import type { OrchestratorConfig } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
// ββ Sanitization βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Sanitize a raw string into a safe naming component.
|
|
20
|
+
*
|
|
21
|
+
* Rules:
|
|
22
|
+
* - Lowercase
|
|
23
|
+
* - Replace non-alphanumeric characters (except hyphens) with hyphens
|
|
24
|
+
* - Collapse consecutive hyphens
|
|
25
|
+
* - Trim leading/trailing hyphens
|
|
26
|
+
* - Truncate to `maxLen` characters
|
|
27
|
+
*
|
|
28
|
+
* Safe for use in: TMUX session names, git branch refs, filesystem paths.
|
|
29
|
+
*
|
|
30
|
+
* @param raw - Raw input string
|
|
31
|
+
* @param maxLen - Maximum length (default: 16)
|
|
32
|
+
* @returns Sanitized string, or empty string if input sanitizes to nothing
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeNameComponent(raw: string, maxLen: number = 16): string {
|
|
35
|
+
return raw
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
38
|
+
.replace(/-+/g, "-")
|
|
39
|
+
.replace(/^-+|-+$/g, "")
|
|
40
|
+
.slice(0, maxLen);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ββ Operator ID ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the operator identifier from available sources.
|
|
47
|
+
*
|
|
48
|
+
* Resolution order (first non-empty wins):
|
|
49
|
+
* 1. `TASKPLANE_OPERATOR_ID` environment variable
|
|
50
|
+
* 2. `operator_id` field in OrchestratorConfig
|
|
51
|
+
* 3. Current OS username via `os.userInfo().username`
|
|
52
|
+
* 4. Fallback: `"op"`
|
|
53
|
+
*
|
|
54
|
+
* The resolved value is sanitized and truncated to 12 characters.
|
|
55
|
+
*
|
|
56
|
+
* @param config - Orchestrator configuration (may contain operator_id)
|
|
57
|
+
* @param env - Environment variables (defaults to process.env)
|
|
58
|
+
* @returns Sanitized operator identifier (never empty)
|
|
59
|
+
*/
|
|
60
|
+
export function resolveOperatorId(
|
|
61
|
+
config: OrchestratorConfig,
|
|
62
|
+
env: Record<string, string | undefined> = process.env,
|
|
63
|
+
): string {
|
|
64
|
+
const FALLBACK = "op";
|
|
65
|
+
const MAX_LEN = 12;
|
|
66
|
+
|
|
67
|
+
// 1. Environment variable
|
|
68
|
+
const envValue = env.TASKPLANE_OPERATOR_ID;
|
|
69
|
+
if (envValue && envValue.trim()) {
|
|
70
|
+
const sanitized = sanitizeNameComponent(envValue.trim(), MAX_LEN);
|
|
71
|
+
if (sanitized) return sanitized;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. Config field
|
|
75
|
+
const configValue = config.orchestrator.operator_id;
|
|
76
|
+
if (configValue && configValue.trim()) {
|
|
77
|
+
const sanitized = sanitizeNameComponent(configValue.trim(), MAX_LEN);
|
|
78
|
+
if (sanitized) return sanitized;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. OS username
|
|
82
|
+
try {
|
|
83
|
+
const username = userInfo().username;
|
|
84
|
+
if (username && username.trim()) {
|
|
85
|
+
const sanitized = sanitizeNameComponent(username.trim(), MAX_LEN);
|
|
86
|
+
if (sanitized) return sanitized;
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// userInfo() can throw on some platforms
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 4. Fallback
|
|
93
|
+
return FALLBACK;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ββ Repo Slug ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Derive a repo slug from the repository root directory name.
|
|
100
|
+
*
|
|
101
|
+
* Provides cross-repo disambiguation when multiple repos share the
|
|
102
|
+
* same machine. Used in TMUX session names and worktree paths where
|
|
103
|
+
* names must be globally unique on the machine.
|
|
104
|
+
*
|
|
105
|
+
* @param repoRoot - Absolute path to the repository root
|
|
106
|
+
* @returns Sanitized repo slug (never empty; falls back to "repo")
|
|
107
|
+
*/
|
|
108
|
+
export function resolveRepoSlug(repoRoot: string): string {
|
|
109
|
+
const FALLBACK = "repo";
|
|
110
|
+
const MAX_LEN = 16;
|
|
111
|
+
|
|
112
|
+
const dirName = basename(resolve(repoRoot));
|
|
113
|
+
if (!dirName) return FALLBACK;
|
|
114
|
+
|
|
115
|
+
const sanitized = sanitizeNameComponent(dirName, MAX_LEN);
|
|
116
|
+
return sanitized || FALLBACK;
|
|
117
|
+
}
|