taskplane 0.23.16 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/taskplane.mjs +8 -41
- package/dashboard/public/app.js +24 -24
- package/dashboard/server.cjs +29 -7
- package/extensions/task-runner.ts +7 -3
- package/extensions/taskplane/abort.ts +93 -81
- package/extensions/taskplane/agent-host.ts +4 -5
- package/extensions/taskplane/config-loader.ts +86 -11
- package/extensions/taskplane/config-schema.ts +13 -13
- package/extensions/taskplane/diagnostic-reports.ts +1 -1
- package/extensions/taskplane/diagnostics.ts +3 -3
- package/extensions/taskplane/engine.ts +37 -16
- package/extensions/taskplane/execution.ts +86 -1000
- package/extensions/taskplane/extension.ts +60 -189
- package/extensions/taskplane/formatting.ts +5 -5
- package/extensions/taskplane/merge.ts +63 -371
- package/extensions/taskplane/messages.ts +1 -1
- package/extensions/taskplane/naming.ts +4 -4
- package/extensions/taskplane/persistence.ts +53 -26
- package/extensions/taskplane/process-registry.ts +2 -2
- package/extensions/taskplane/resume.ts +65 -130
- package/extensions/taskplane/sessions.ts +57 -92
- package/extensions/taskplane/settings-tui.ts +4 -4
- package/extensions/taskplane/tmux-compat.ts +37 -0
- package/extensions/taskplane/types.ts +43 -43
- package/extensions/taskplane/waves.ts +12 -10
- package/extensions/taskplane/worktree.ts +8 -66
- package/package.json +1 -1
- package/templates/config/task-orchestrator.yaml +3 -4
|
@@ -7,12 +7,12 @@ import { readFile as fsReadFile } from "fs/promises";
|
|
|
7
7
|
import { execSync, spawnSync } from "child_process";
|
|
8
8
|
import { join, dirname, resolve, relative } from "path";
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
|
|
11
11
|
import { resolveOperatorId } from "./naming.ts";
|
|
12
|
-
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_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS,
|
|
12
|
+
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_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
|
|
13
13
|
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
|
|
14
14
|
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
15
|
-
import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
|
|
15
|
+
import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot } from "./process-registry.ts";
|
|
16
16
|
import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
|
|
17
17
|
import { getCurrentBranch, runGit } from "./git.ts";
|
|
18
18
|
import { ORCH_MESSAGES } from "./messages.ts";
|
|
@@ -24,47 +24,7 @@ import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
|
|
|
24
24
|
import type { RuntimeBackend } from "./execution.ts";
|
|
25
25
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
26
26
|
|
|
27
|
-
// ── Merge Telemetry Helpers ───────────────────────────────────────────
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* Generate telemetry file paths for a merge agent session.
|
|
31
|
-
*
|
|
32
|
-
* Uses the shared resolveTelemOpId() from execution.ts to avoid
|
|
33
|
-
* opId resolution divergence.
|
|
34
|
-
*
|
|
35
|
-
* Naming: {opId}-{batchId}-{repoId}[-merge-{N}]-merger.{ext}
|
|
36
|
-
* Role is always "merger" to distinguish from worker/reviewer in the dashboard.
|
|
37
|
-
*
|
|
38
|
-
* @param sessionName - TMUX session name (e.g., "orch-merge-1")
|
|
39
|
-
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi)
|
|
40
|
-
* @param batchId - Actual batch ID from batch state (falls back to timestamp)
|
|
41
|
-
* @param repoId - Repo ID for workspace mode (falls back to "default")
|
|
42
|
-
* @returns { sidecarPath, exitSummaryPath }
|
|
43
|
-
*/
|
|
44
|
-
function generateMergeTelemetryPaths(
|
|
45
|
-
sessionName: string,
|
|
46
|
-
sidecarRoot: string,
|
|
47
|
-
batchId?: string,
|
|
48
|
-
repoId?: string,
|
|
49
|
-
): { sidecarPath: string; exitSummaryPath: string } {
|
|
50
|
-
const opId = resolveTelemOpId();
|
|
51
|
-
const effectiveBatchId = batchId || String(Date.now());
|
|
52
|
-
const effectiveRepoId = repoId || "default";
|
|
53
|
-
|
|
54
|
-
// Extract merge-specific info from sessionName (e.g., "orch-merge-1")
|
|
55
|
-
const mergeMatch = sessionName.match(/merge-(\d+)/);
|
|
56
|
-
const mergeSuffix = mergeMatch ? `-merge-${mergeMatch[1]}` : "";
|
|
57
|
-
|
|
58
|
-
const role = "merger";
|
|
59
|
-
const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${mergeSuffix}-${role}`;
|
|
60
|
-
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
61
|
-
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
|
|
65
|
-
exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
28
|
|
|
69
29
|
// ── Merge Implementation ─────────────────────────────────────────────
|
|
70
30
|
|
|
@@ -562,153 +522,10 @@ export function buildMergeRequest(
|
|
|
562
522
|
return lines.join("\n");
|
|
563
523
|
}
|
|
564
524
|
|
|
565
|
-
/**
|
|
566
|
-
* Spawn a TMUX session for the merge agent.
|
|
567
|
-
*
|
|
568
|
-
* Creates a TMUX session in the main repo directory (not a worktree)
|
|
569
|
-
* that runs pi with the task-merger agent definition and the merge request.
|
|
570
|
-
*
|
|
571
|
-
* Handles:
|
|
572
|
-
* - Stale session cleanup
|
|
573
|
-
* - Retry on transient spawn failures
|
|
574
|
-
* - Structured logging
|
|
575
|
-
*
|
|
576
|
-
* @param sessionName - TMUX session name (e.g., "orch-merge-1")
|
|
577
|
-
* @param repoRoot - Main repository root (merge happens here)
|
|
578
|
-
* @param mergeRequestPath - Path to the merge request temp file
|
|
579
|
-
* @param config - Orchestrator config (for model, tools)
|
|
580
|
-
* @param stateRoot - Root for state files (batch state, merge results). Stays at workspace root.
|
|
581
|
-
* @param agentRoot - Root for agent prompts. When pointer is resolved, this is the config repo's agent dir. Falls back to `<stateRoot>/.pi/agents/` or `<repoRoot>/.pi/agents/`.
|
|
582
|
-
* @throws MergeError if spawn fails after retries
|
|
583
|
-
*/
|
|
584
|
-
export async function spawnMergeAgent(
|
|
585
|
-
sessionName: string,
|
|
586
|
-
repoRoot: string,
|
|
587
|
-
mergeWorkDir: string,
|
|
588
|
-
mergeRequestPath: string,
|
|
589
|
-
config: OrchestratorConfig,
|
|
590
|
-
stateRoot?: string,
|
|
591
|
-
agentRoot?: string,
|
|
592
|
-
batchId?: string,
|
|
593
|
-
): Promise<void> {
|
|
594
|
-
execLog("merge", sessionName, "preparing to spawn merge agent", {
|
|
595
|
-
mergeWorkDir,
|
|
596
|
-
mergeRequestPath,
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
// Clean up stale session if exists
|
|
600
|
-
if (tmuxHasSession(sessionName)) {
|
|
601
|
-
execLog("merge", sessionName, "killing stale merge session");
|
|
602
|
-
tmuxKillSession(sessionName);
|
|
603
|
-
await sleepAsync(500);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// Build the merge agent command.
|
|
607
|
-
// Uses rpc-wrapper.mjs to produce structured telemetry (sidecar JSONL + exit summary).
|
|
608
|
-
// The merger agent definition is loaded via --system-prompt-file.
|
|
609
|
-
// The merge request is passed as the --prompt-file.
|
|
610
|
-
const shellQuote = (s: string): string => {
|
|
611
|
-
if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
|
|
612
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
613
|
-
}
|
|
614
|
-
return s;
|
|
615
|
-
};
|
|
616
|
-
|
|
617
|
-
// Generate telemetry paths for this merge session
|
|
618
|
-
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
619
|
-
const telemetry = generateMergeTelemetryPaths(sessionName, sidecarRoot);
|
|
620
|
-
execLog("merge", sessionName, "telemetry paths generated", {
|
|
621
|
-
sidecar: telemetry.sidecarPath,
|
|
622
|
-
exitSummary: telemetry.exitSummaryPath,
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
// Resolve paths
|
|
626
|
-
const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
|
|
627
|
-
|
|
628
|
-
// Resolve merger agent definition — check existence and fall back gracefully.
|
|
629
|
-
// Fresh projects that haven't run `taskplane init` may not have .pi/agents/task-merger.md.
|
|
630
|
-
const systemPromptCandidates = [
|
|
631
|
-
agentRoot ? join(agentRoot, "task-merger.md") : "",
|
|
632
|
-
join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
|
|
633
|
-
].filter(Boolean);
|
|
634
|
-
let systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
|
|
635
|
-
if (!systemPromptPath) {
|
|
636
|
-
execLog("merge", sessionName, "WARNING: merger agent definition not found — merge agent will use default system prompt", {
|
|
637
|
-
candidates: systemPromptCandidates,
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// Build RPC wrapper command
|
|
642
|
-
const wrapperParts = [
|
|
643
|
-
"TERM=xterm-256color",
|
|
644
|
-
"node", shellQuote(rpcWrapperPath),
|
|
645
|
-
"--sidecar-path", shellQuote(telemetry.sidecarPath),
|
|
646
|
-
"--exit-summary-path", shellQuote(telemetry.exitSummaryPath),
|
|
647
|
-
"--prompt-file", shellQuote(mergeRequestPath),
|
|
648
|
-
];
|
|
649
|
-
|
|
650
|
-
// Only pass --system-prompt-file when the file exists (fresh projects
|
|
651
|
-
// may not have .pi/agents/task-merger.md — rpc-wrapper would crash).
|
|
652
|
-
if (systemPromptPath) {
|
|
653
|
-
wrapperParts.push("--system-prompt-file", shellQuote(systemPromptPath));
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Add model args if specified
|
|
657
|
-
if (config.merge.model) {
|
|
658
|
-
wrapperParts.push("--model", shellQuote(config.merge.model));
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
// Add tools override if specified
|
|
662
|
-
if (config.merge.tools) {
|
|
663
|
-
wrapperParts.push("--tools", shellQuote(config.merge.tools));
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// TP-089: Agent mailbox steering — pass --mailbox-dir when batchId is available.
|
|
667
|
-
if (batchId) {
|
|
668
|
-
const mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
|
|
669
|
-
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
670
|
-
wrapperParts.push("--mailbox-dir", shellQuote(mailboxDir));
|
|
671
|
-
execLog("merge", sessionName, "mailbox enabled", { mailboxDir });
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
const piCommand = wrapperParts.filter(Boolean).join(" ");
|
|
675
|
-
|
|
676
|
-
const tmuxMergeDir = toTmuxPath(mergeWorkDir);
|
|
677
|
-
const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && ${piCommand}`;
|
|
678
|
-
const tmuxArgs = [
|
|
679
|
-
"new-session", "-d",
|
|
680
|
-
"-s", sessionName,
|
|
681
|
-
wrappedCommand,
|
|
682
|
-
];
|
|
683
|
-
|
|
684
|
-
// Attempt to spawn with retry
|
|
685
|
-
let lastError = "";
|
|
686
|
-
for (let attempt = 1; attempt <= MERGE_SPAWN_RETRY_MAX + 1; attempt++) {
|
|
687
|
-
const result = spawnSync("tmux", tmuxArgs);
|
|
688
|
-
|
|
689
|
-
if (result.status === 0) {
|
|
690
|
-
execLog("merge", sessionName, "merge agent session spawned", { attempt });
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
lastError = result.stderr?.toString().trim() || "unknown spawn error";
|
|
695
|
-
execLog("merge", sessionName, `merge spawn attempt ${attempt} failed: ${lastError}`);
|
|
696
|
-
|
|
697
|
-
if (attempt <= MERGE_SPAWN_RETRY_MAX) {
|
|
698
|
-
await sleepAsync(attempt * 1000);
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
throw new MergeError(
|
|
703
|
-
"MERGE_SPAWN_FAILED",
|
|
704
|
-
`Failed to create merge TMUX session '${sessionName}' after ` +
|
|
705
|
-
`${MERGE_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
525
|
|
|
709
526
|
|
|
710
527
|
/**
|
|
711
|
-
* Spawn a merge agent via Runtime V2 direct agent-host (no
|
|
528
|
+
* Spawn a merge agent via Runtime V2 direct agent-host (no terminal multiplexer).
|
|
712
529
|
*
|
|
713
530
|
* Per Runtime V2 spec (02-runtime-process-model.md §8.3):
|
|
714
531
|
* "engine spawns merge host directly" — the merge agent runs as a direct
|
|
@@ -717,7 +534,7 @@ export async function spawnMergeAgent(
|
|
|
717
534
|
*
|
|
718
535
|
* The merge agent receives the merge request as its prompt and writes
|
|
719
536
|
* a result JSON file. The caller polls for that result file (same contract
|
|
720
|
-
* as the legacy
|
|
537
|
+
* as the legacy session-backed path via waitForMergeResult).
|
|
721
538
|
*
|
|
722
539
|
* @param sessionName - Stable agent ID (e.g., "orch-merge-1")
|
|
723
540
|
* @param repoRoot - Main repository root (merge happens here)
|
|
@@ -798,7 +615,7 @@ export async function spawnMergeAgentV2(
|
|
|
798
615
|
|
|
799
616
|
// Store the kill handle for external cleanup (pause/abort).
|
|
800
617
|
// The promise runs in background — caller uses waitForMergeResult()
|
|
801
|
-
// to poll for the result file, same contract as the
|
|
618
|
+
// to poll for the result file, same contract as the legacy session path.
|
|
802
619
|
activeMergeAgents.set(sessionName, { promise, kill, stateRoot: stateRoot ?? repoRoot, batchId: bid });
|
|
803
620
|
|
|
804
621
|
// Fire-and-forget: the background promise handles exit logging
|
|
@@ -849,7 +666,7 @@ export function killMergeAgentV2(sessionName: string, cleanExit?: boolean): bool
|
|
|
849
666
|
|
|
850
667
|
/**
|
|
851
668
|
* Kill ALL active V2 merge agents. Used by abort flow to ensure
|
|
852
|
-
* no merge agents survive even when
|
|
669
|
+
* no merge agents survive even when the legacy session list is empty.
|
|
853
670
|
* @returns Number of agents killed
|
|
854
671
|
* @since TP-108
|
|
855
672
|
*/
|
|
@@ -896,7 +713,7 @@ const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED
|
|
|
896
713
|
*
|
|
897
714
|
* Polling loop with timeout and session liveness detection:
|
|
898
715
|
* 1. Check if result file exists → parse and return
|
|
899
|
-
* 2. Check if
|
|
716
|
+
* 2. Check if the merge agent session is still alive
|
|
900
717
|
* 3. If session died without result → grace period → check again → fail
|
|
901
718
|
* 4. If timeout exceeded → check result before killing:
|
|
902
719
|
* a. If result exists with SUCCESS/CONFLICT_RESOLVED: accept it
|
|
@@ -904,7 +721,7 @@ const SUCCESSFUL_MERGE_STATUSES = new Set<string>(["SUCCESS", "CONFLICT_RESOLVED
|
|
|
904
721
|
* b. If result missing or non-success: kill session → fail
|
|
905
722
|
*
|
|
906
723
|
* @param resultPath - Path to the expected result JSON file
|
|
907
|
-
* @param sessionName -
|
|
724
|
+
* @param sessionName - Merge session name for liveness checking
|
|
908
725
|
* @param timeoutMs - Maximum wait time (default: MERGE_TIMEOUT_MS)
|
|
909
726
|
* @returns Validated MergeResult
|
|
910
727
|
* @throws MergeError on timeout, session death, or invalid result
|
|
@@ -941,11 +758,7 @@ export async function waitForMergeResult(
|
|
|
941
758
|
timeoutMs,
|
|
942
759
|
});
|
|
943
760
|
// Clean up agent (may still be running post-write)
|
|
944
|
-
|
|
945
|
-
killMergeAgentV2(sessionName, true);
|
|
946
|
-
} else if (await tmuxHasSessionAsync(sessionName)) {
|
|
947
|
-
await tmuxKillSessionAsync(sessionName);
|
|
948
|
-
}
|
|
761
|
+
killMergeAgentV2(sessionName, true);
|
|
949
762
|
return lateResult;
|
|
950
763
|
}
|
|
951
764
|
execLog("merge", sessionName, "merge result exists at timeout but non-success — killing", {
|
|
@@ -957,11 +770,7 @@ export async function waitForMergeResult(
|
|
|
957
770
|
}
|
|
958
771
|
|
|
959
772
|
execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
|
|
960
|
-
|
|
961
|
-
killMergeAgentV2(sessionName);
|
|
962
|
-
} else {
|
|
963
|
-
await tmuxKillSessionAsync(sessionName);
|
|
964
|
-
}
|
|
773
|
+
killMergeAgentV2(sessionName);
|
|
965
774
|
|
|
966
775
|
throw new MergeError(
|
|
967
776
|
"MERGE_TIMEOUT",
|
|
@@ -980,11 +789,7 @@ export async function waitForMergeResult(
|
|
|
980
789
|
elapsed,
|
|
981
790
|
});
|
|
982
791
|
// Clean up agent if still alive
|
|
983
|
-
|
|
984
|
-
killMergeAgentV2(sessionName, true);
|
|
985
|
-
} else if (await tmuxHasSessionAsync(sessionName)) {
|
|
986
|
-
await tmuxKillSessionAsync(sessionName);
|
|
987
|
-
}
|
|
792
|
+
killMergeAgentV2(sessionName, true);
|
|
988
793
|
return result;
|
|
989
794
|
} catch (err: unknown) {
|
|
990
795
|
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
@@ -997,14 +802,8 @@ export async function waitForMergeResult(
|
|
|
997
802
|
}
|
|
998
803
|
|
|
999
804
|
// Check agent liveness — backend-aware
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
// V2: check activeMergeAgents map (process handle)
|
|
1003
|
-
agentAlive = activeMergeAgents.has(sessionName);
|
|
1004
|
-
} else {
|
|
1005
|
-
// Legacy: check TMUX session
|
|
1006
|
-
agentAlive = await tmuxHasSessionAsync(sessionName);
|
|
1007
|
-
}
|
|
805
|
+
// Runtime V2: check active merge agent handle map (process-owned).
|
|
806
|
+
const agentAlive = activeMergeAgents.has(sessionName);
|
|
1008
807
|
|
|
1009
808
|
if (!agentAlive) {
|
|
1010
809
|
if (sessionDiedAt === null) {
|
|
@@ -1330,7 +1129,7 @@ function runPostMergeVerification(
|
|
|
1330
1129
|
* 3. For each lane, sequentially:
|
|
1331
1130
|
* a. Build merge request content
|
|
1332
1131
|
* b. Write merge request to temp file
|
|
1333
|
-
* c. Spawn merge agent
|
|
1132
|
+
* c. Spawn merge agent session (in main repo)
|
|
1334
1133
|
* d. Wait for merge result
|
|
1335
1134
|
* e. Handle result (continue, log, or pause)
|
|
1336
1135
|
* 4. Return MergeWaveResult
|
|
@@ -1370,7 +1169,7 @@ export async function mergeWave(
|
|
|
1370
1169
|
runtimeBackend?: RuntimeBackend,
|
|
1371
1170
|
): Promise<MergeWaveResult> {
|
|
1372
1171
|
const startTime = Date.now();
|
|
1373
|
-
const
|
|
1172
|
+
const sessionPrefix = config.orchestrator.sessionPrefix;
|
|
1374
1173
|
const opId = resolveOperatorId(config);
|
|
1375
1174
|
const targetBranch = baseBranch;
|
|
1376
1175
|
const laneResults: MergeLaneResult[] = [];
|
|
@@ -1581,7 +1380,7 @@ export async function mergeWave(
|
|
|
1581
1380
|
for (const lane of orderedLanes) {
|
|
1582
1381
|
const laneStart = Date.now();
|
|
1583
1382
|
const txnStartedAt = new Date().toISOString();
|
|
1584
|
-
const sessionName = `${
|
|
1383
|
+
const sessionName = `${sessionPrefix}-${opId}-merge-${lane.laneNumber}`;
|
|
1585
1384
|
const resultFileName = `merge-result-w${waveIndex}-lane${lane.laneNumber}-${opId}-${batchId}.json`;
|
|
1586
1385
|
const piDir = stateRoot ?? repoRoot;
|
|
1587
1386
|
const resultFilePath = join(piDir, ".pi", resultFileName);
|
|
@@ -1675,25 +1474,13 @@ export async function mergeWave(
|
|
|
1675
1474
|
try { unlinkSync(resultFilePath); } catch { /* best effort */ }
|
|
1676
1475
|
}
|
|
1677
1476
|
|
|
1678
|
-
// Re-spawn merge agent for the retry
|
|
1679
|
-
//
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1683
|
-
} else {
|
|
1684
|
-
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1685
|
-
}
|
|
1686
|
-
// TP-056: Re-register with health monitor after respawn (legacy only)
|
|
1687
|
-
if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1477
|
+
// Re-spawn merge agent for the retry.
|
|
1478
|
+
// Kill previous V2 agent handle to prevent orphan/duplicate.
|
|
1479
|
+
killMergeAgentV2(sessionName);
|
|
1480
|
+
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1688
1481
|
} else {
|
|
1689
|
-
// First attempt: spawn merge agent
|
|
1690
|
-
|
|
1691
|
-
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1692
|
-
} else {
|
|
1693
|
-
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1694
|
-
}
|
|
1695
|
-
// TP-056: Register session with health monitor (legacy only — V2 uses process handle)
|
|
1696
|
-
if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1482
|
+
// First attempt: spawn merge agent (Runtime V2)
|
|
1483
|
+
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1697
1484
|
}
|
|
1698
1485
|
|
|
1699
1486
|
try {
|
|
@@ -1956,12 +1743,8 @@ export async function mergeWave(
|
|
|
1956
1743
|
// Best effort
|
|
1957
1744
|
}
|
|
1958
1745
|
|
|
1959
|
-
// Kill merge agent if still alive
|
|
1960
|
-
|
|
1961
|
-
killMergeAgentV2(sessionName);
|
|
1962
|
-
} else if (tmuxHasSession(sessionName)) {
|
|
1963
|
-
tmuxKillSession(sessionName);
|
|
1964
|
-
}
|
|
1746
|
+
// Kill merge agent if still alive.
|
|
1747
|
+
killMergeAgentV2(sessionName);
|
|
1965
1748
|
|
|
1966
1749
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1967
1750
|
const errCode = err instanceof MergeError ? err.code : "UNKNOWN";
|
|
@@ -2735,81 +2518,13 @@ export function attemptAutoIntegration(
|
|
|
2735
2518
|
// ── Merge Health Monitor (TP-056) ────────────────────────────────────
|
|
2736
2519
|
|
|
2737
2520
|
/**
|
|
2738
|
-
*
|
|
2739
|
-
*
|
|
2740
|
-
* Uses `tmux capture-pane` with `-p` (stdout) and `-S -N` (last N lines).
|
|
2741
|
-
* Returns null if the session doesn't exist or capture fails.
|
|
2521
|
+
* Classify merge-session health from Runtime V2 liveness and result-file state.
|
|
2742
2522
|
*
|
|
2743
|
-
*
|
|
2744
|
-
*
|
|
2745
|
-
* @returns Captured text, or null on failure
|
|
2523
|
+
* Without legacy pane capture, warning/stuck are time-based heuristics from
|
|
2524
|
+
* the session registration timestamp (`lastActivityAt`).
|
|
2746
2525
|
*
|
|
2747
|
-
* @
|
|
2748
|
-
*/
|
|
2749
|
-
export function captureMergePaneOutput(
|
|
2750
|
-
sessionName: string,
|
|
2751
|
-
lines: number = MERGE_HEALTH_CAPTURE_LINES,
|
|
2752
|
-
): string | null {
|
|
2753
|
-
try {
|
|
2754
|
-
const result = spawnSync("tmux", [
|
|
2755
|
-
"capture-pane",
|
|
2756
|
-
"-t", sessionName,
|
|
2757
|
-
"-p", // print to stdout
|
|
2758
|
-
"-S", `-${lines}`, // last N lines
|
|
2759
|
-
], { encoding: "utf-8", timeout: 5_000 });
|
|
2760
|
-
|
|
2761
|
-
if (result.status !== 0) {
|
|
2762
|
-
return null;
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
|
-
return result.stdout ?? null;
|
|
2766
|
-
} catch {
|
|
2767
|
-
return null;
|
|
2768
|
-
}
|
|
2769
|
-
}
|
|
2770
|
-
|
|
2771
|
-
/**
|
|
2772
|
-
* Async version of captureMergePaneOutput — captures pane output
|
|
2773
|
-
* without blocking the event loop.
|
|
2774
|
-
*
|
|
2775
|
-
* @param sessionName - TMUX session name
|
|
2776
|
-
* @param lines - Number of lines to capture from the bottom
|
|
2777
|
-
* @returns Promise resolving to captured text, or null on failure
|
|
2778
|
-
*
|
|
2779
|
-
* @since TP-070
|
|
2780
|
-
*/
|
|
2781
|
-
export async function captureMergePaneOutputAsync(
|
|
2782
|
-
sessionName: string,
|
|
2783
|
-
lines: number = MERGE_HEALTH_CAPTURE_LINES,
|
|
2784
|
-
): Promise<string | null> {
|
|
2785
|
-
try {
|
|
2786
|
-
const result = await tmuxAsync([
|
|
2787
|
-
"capture-pane",
|
|
2788
|
-
"-t", sessionName,
|
|
2789
|
-
"-p",
|
|
2790
|
-
"-S", `-${lines}`,
|
|
2791
|
-
], 5_000);
|
|
2792
|
-
|
|
2793
|
-
if (result.status !== 0) {
|
|
2794
|
-
return null;
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
return result.stdout || null;
|
|
2798
|
-
} catch {
|
|
2799
|
-
return null;
|
|
2800
|
-
}
|
|
2801
|
-
}
|
|
2802
|
-
|
|
2803
|
-
/**
|
|
2804
|
-
* Classify the health of a merge session based on session liveness
|
|
2805
|
-
* and pane output activity.
|
|
2806
|
-
*
|
|
2807
|
-
* Pure function — no side effects. Takes the current state and produces
|
|
2808
|
-
* a health classification.
|
|
2809
|
-
*
|
|
2810
|
-
* @param sessionAlive - Whether the tmux session is alive
|
|
2526
|
+
* @param sessionAlive - Whether the Runtime V2 merge agent is alive
|
|
2811
2527
|
* @param hasResultFile - Whether the merge result file exists
|
|
2812
|
-
* @param currentOutput - Current pane capture (null if session dead or capture failed)
|
|
2813
2528
|
* @param healthState - Tracked health state for this session
|
|
2814
2529
|
* @param now - Current epoch ms
|
|
2815
2530
|
* @returns Updated health status
|
|
@@ -2819,40 +2534,24 @@ export async function captureMergePaneOutputAsync(
|
|
|
2819
2534
|
export function classifyMergeHealth(
|
|
2820
2535
|
sessionAlive: boolean,
|
|
2821
2536
|
hasResultFile: boolean,
|
|
2822
|
-
currentOutput: string | null,
|
|
2823
2537
|
healthState: MergeSessionHealthState,
|
|
2824
2538
|
now: number,
|
|
2825
2539
|
): MergeHealthStatus {
|
|
2826
|
-
// Dead session with no result file → immediate detection
|
|
2827
2540
|
if (!sessionAlive && !hasResultFile) {
|
|
2828
2541
|
return "dead";
|
|
2829
2542
|
}
|
|
2830
2543
|
|
|
2831
|
-
// Session dead but result file exists → merge completed, healthy
|
|
2832
2544
|
if (!sessionAlive && hasResultFile) {
|
|
2833
2545
|
return "healthy";
|
|
2834
2546
|
}
|
|
2835
2547
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
const outputChanged = currentOutput !== null
|
|
2839
|
-
&& (lastContent === null || currentOutput !== lastContent);
|
|
2840
|
-
|
|
2841
|
-
if (outputChanged) {
|
|
2842
|
-
return "healthy";
|
|
2843
|
-
}
|
|
2844
|
-
|
|
2845
|
-
// No output change — compute stale duration
|
|
2846
|
-
const staleDuration = now - healthState.lastActivityAt;
|
|
2847
|
-
|
|
2848
|
-
if (staleDuration >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
|
|
2548
|
+
const elapsedMs = now - healthState.lastActivityAt;
|
|
2549
|
+
if (elapsedMs >= MERGE_HEALTH_STUCK_THRESHOLD_MS) {
|
|
2849
2550
|
return "stuck";
|
|
2850
2551
|
}
|
|
2851
|
-
|
|
2852
|
-
if (staleDuration >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
|
|
2552
|
+
if (elapsedMs >= MERGE_HEALTH_WARNING_THRESHOLD_MS) {
|
|
2853
2553
|
return "warning";
|
|
2854
2554
|
}
|
|
2855
|
-
|
|
2856
2555
|
return "healthy";
|
|
2857
2556
|
}
|
|
2858
2557
|
|
|
@@ -2917,7 +2616,7 @@ export class MergeHealthMonitor {
|
|
|
2917
2616
|
/**
|
|
2918
2617
|
* Register a merge session for monitoring.
|
|
2919
2618
|
*
|
|
2920
|
-
* @param sessionName -
|
|
2619
|
+
* @param sessionName - Merge session name
|
|
2921
2620
|
* @param laneNumber - Lane number the session belongs to
|
|
2922
2621
|
* @param resultPath - Path to the expected merge result file
|
|
2923
2622
|
*/
|
|
@@ -2998,51 +2697,44 @@ export class MergeHealthMonitor {
|
|
|
2998
2697
|
* Run a single poll cycle across all monitored sessions.
|
|
2999
2698
|
*
|
|
3000
2699
|
* Exposed as public for testing — normally called by the interval timer.
|
|
3001
|
-
* Async (TP-070) — uses non-blocking tmux calls to avoid event loop stalls.
|
|
3002
2700
|
*/
|
|
3003
2701
|
async poll(): Promise<void> {
|
|
3004
2702
|
const now = Date.now();
|
|
3005
2703
|
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
// Capture pane output for activity detection — async to avoid blocking
|
|
3012
|
-
const currentOutput = sessionAlive
|
|
3013
|
-
? await captureMergePaneOutputAsync(sessionName)
|
|
3014
|
-
: null;
|
|
3015
|
-
|
|
3016
|
-
// Classify health
|
|
3017
|
-
const newStatus = classifyMergeHealth(
|
|
3018
|
-
sessionAlive,
|
|
3019
|
-
hasResultFile,
|
|
3020
|
-
currentOutput,
|
|
3021
|
-
state,
|
|
3022
|
-
now,
|
|
3023
|
-
);
|
|
2704
|
+
try {
|
|
2705
|
+
setV2LivenessRegistryCache(readRegistrySnapshot(this.stateRoot, this.batchId));
|
|
2706
|
+
} catch {
|
|
2707
|
+
setV2LivenessRegistryCache(null);
|
|
2708
|
+
}
|
|
3024
2709
|
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
2710
|
+
try {
|
|
2711
|
+
for (const [sessionName, state] of this.sessions) {
|
|
2712
|
+
const sessionAlive = isV2AgentAlive(sessionName, "v2");
|
|
2713
|
+
const resultPath = this._resultPaths.get(sessionName) ?? "";
|
|
2714
|
+
const hasResultFile = resultPath ? existsSync(resultPath) : false;
|
|
2715
|
+
|
|
2716
|
+
const newStatus = classifyMergeHealth(
|
|
2717
|
+
sessionAlive,
|
|
2718
|
+
hasResultFile,
|
|
2719
|
+
state,
|
|
2720
|
+
now,
|
|
2721
|
+
);
|
|
3032
2722
|
|
|
3033
|
-
|
|
3034
|
-
state.status = newStatus;
|
|
2723
|
+
state.status = newStatus;
|
|
3035
2724
|
|
|
3036
|
-
|
|
3037
|
-
|
|
2725
|
+
// Emit events based on status transitions
|
|
2726
|
+
this._emitHealthEvents(state, now);
|
|
3038
2727
|
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
2728
|
+
// Signal dead session for early exit
|
|
2729
|
+
if (newStatus === "dead" && !state.deadEmitted) {
|
|
2730
|
+
state.deadEmitted = true;
|
|
2731
|
+
if (this._onDeadSession) {
|
|
2732
|
+
this._onDeadSession(sessionName, state.laneNumber);
|
|
2733
|
+
}
|
|
3044
2734
|
}
|
|
3045
2735
|
}
|
|
2736
|
+
} finally {
|
|
2737
|
+
setV2LivenessRegistryCache(null);
|
|
3046
2738
|
}
|
|
3047
2739
|
}
|
|
3048
2740
|
|
|
@@ -3061,7 +2753,7 @@ export class MergeHealthMonitor {
|
|
|
3061
2753
|
sessionName: state.sessionName,
|
|
3062
2754
|
healthStatus: "warning",
|
|
3063
2755
|
stalledMinutes,
|
|
3064
|
-
reason: `Merge agent on lane ${state.laneNumber} may be stalled (
|
|
2756
|
+
reason: `Merge agent on lane ${state.laneNumber} may be stalled (${stalledMinutes} min without completion)`,
|
|
3065
2757
|
};
|
|
3066
2758
|
emitEngineEvent(this.stateRoot, event);
|
|
3067
2759
|
execLog("merge-health", state.sessionName, `⚠️ merge session possibly stalled`, {
|
|
@@ -3093,7 +2785,7 @@ export class MergeHealthMonitor {
|
|
|
3093
2785
|
sessionName: state.sessionName,
|
|
3094
2786
|
healthStatus: "stuck",
|
|
3095
2787
|
stalledMinutes,
|
|
3096
|
-
reason: `Merge agent on lane ${state.laneNumber} appears stuck (
|
|
2788
|
+
reason: `Merge agent on lane ${state.laneNumber} appears stuck (${stalledMinutes} min without completion). Consider killing and retrying.`,
|
|
3097
2789
|
};
|
|
3098
2790
|
emitEngineEvent(this.stateRoot, event);
|
|
3099
2791
|
execLog("merge-health", state.sessionName, `🔒 merge session stuck`, {
|
|
@@ -78,7 +78,7 @@ export const ORCH_MESSAGES = {
|
|
|
78
78
|
`⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
|
|
79
79
|
|
|
80
80
|
// /orch-sessions
|
|
81
|
-
sessionsNone: () => "No orchestrator
|
|
81
|
+
sessionsNone: () => "No active orchestrator sessions found.",
|
|
82
82
|
sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
|
|
83
83
|
|
|
84
84
|
// /orch orphan detection
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Naming contract helpers for team-scale collision resistance.
|
|
3
3
|
*
|
|
4
|
-
* Provides deterministic, human-readable identifiers for
|
|
4
|
+
* Provides deterministic, human-readable identifiers for lane session IDs,
|
|
5
5
|
* worktree directories, git branches, and merge artifacts. All naming
|
|
6
6
|
* components are sanitized for safe use in filesystem paths, git refs,
|
|
7
|
-
* and
|
|
7
|
+
* and tmux-compatible session IDs.
|
|
8
8
|
*
|
|
9
9
|
* @module orch/naming
|
|
10
10
|
*/
|
|
@@ -25,7 +25,7 @@ import type { OrchestratorConfig } from "./types.ts";
|
|
|
25
25
|
* - Trim leading/trailing hyphens
|
|
26
26
|
* - Truncate to `maxLen` characters
|
|
27
27
|
*
|
|
28
|
-
* Safe for use in:
|
|
28
|
+
* Safe for use in: lane session IDs, git branch refs, filesystem paths.
|
|
29
29
|
*
|
|
30
30
|
* @param raw - Raw input string
|
|
31
31
|
* @param maxLen - Maximum length (default: 16)
|
|
@@ -99,7 +99,7 @@ export function resolveOperatorId(
|
|
|
99
99
|
* Derive a repo slug from the repository root directory name.
|
|
100
100
|
*
|
|
101
101
|
* Provides cross-repo disambiguation when multiple repos share the
|
|
102
|
-
* same machine. Used in
|
|
102
|
+
* same machine. Used in lane session IDs and worktree paths where
|
|
103
103
|
* names must be globally unique on the machine.
|
|
104
104
|
*
|
|
105
105
|
* @param repoRoot - Absolute path to the repository root
|