taskplane 0.22.18 → 0.23.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.
@@ -18,6 +18,9 @@ import { ORCH_MESSAGES } from "./messages.ts";
18
18
  import { emitEngineEvent } from "./persistence.ts";
19
19
  import { loadOrchestratorConfig } from "./config.ts";
20
20
  import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
21
+ import { spawnAgent } from "./agent-host.ts";
22
+ import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
23
+ import type { RuntimeBackend } from "./execution.ts";
21
24
  import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
22
25
 
23
26
  // ── Merge Telemetry Helpers ───────────────────────────────────────────
@@ -702,6 +705,151 @@ export async function spawnMergeAgent(
702
705
  );
703
706
  }
704
707
 
708
+
709
+ /**
710
+ * Spawn a merge agent via Runtime V2 direct agent-host (no TMUX).
711
+ *
712
+ * Per Runtime V2 spec (02-runtime-process-model.md §8.3):
713
+ * "engine spawns merge host directly" — the merge agent runs as a direct
714
+ * child process via agent-host, with process registry tracking, normalized
715
+ * events, and deterministic exit classification.
716
+ *
717
+ * The merge agent receives the merge request as its prompt and writes
718
+ * a result JSON file. The caller polls for that result file (same contract
719
+ * as the legacy TMUX path via waitForMergeResult).
720
+ *
721
+ * @param sessionName - Stable agent ID (e.g., "orch-merge-1")
722
+ * @param repoRoot - Main repository root (merge happens here)
723
+ * @param mergeWorkDir - Working directory for the merge
724
+ * @param mergeRequestPath - Path to the merge request file
725
+ * @param config - Orchestrator config
726
+ * @param stateRoot - Root for state files / registry
727
+ * @param agentRoot - Root for agent prompts
728
+ * @param batchId - Current batch ID
729
+ * @returns Promise that resolves when the agent exits
730
+ *
731
+ * @since TP-108
732
+ */
733
+ export async function spawnMergeAgentV2(
734
+ sessionName: string,
735
+ repoRoot: string,
736
+ mergeWorkDir: string,
737
+ mergeRequestPath: string,
738
+ config: OrchestratorConfig,
739
+ stateRoot?: string,
740
+ agentRoot?: string,
741
+ batchId?: string,
742
+ ): Promise<AgentHostResult> {
743
+ execLog("merge", sessionName, "spawning merge agent via Runtime V2 (direct agent-host)", {
744
+ mergeWorkDir,
745
+ mergeRequestPath,
746
+ });
747
+
748
+ // Read the merge request as the agent prompt
749
+ const prompt = readFileSync(mergeRequestPath, "utf-8");
750
+
751
+ // Resolve merger system prompt
752
+ const systemPromptCandidates = [
753
+ agentRoot ? join(agentRoot, "task-merger.md") : "",
754
+ join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
755
+ ].filter(Boolean);
756
+ const systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
757
+ let systemPrompt: string | undefined;
758
+ if (systemPromptPath) {
759
+ try { systemPrompt = readFileSync(systemPromptPath, "utf-8"); } catch { /* use default */ }
760
+ }
761
+
762
+ // Resolve event/exit paths
763
+ const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
764
+ const bid = batchId || "unknown";
765
+ const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl");
766
+ const exitSummaryPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "exit-summary.json");
767
+
768
+ // Mailbox directory
769
+ let mailboxDir: string | null = null;
770
+ if (batchId) {
771
+ mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
772
+ mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
773
+ }
774
+
775
+ const opts: AgentHostOptions = {
776
+ agentId: sessionName,
777
+ role: "merger",
778
+ batchId: bid,
779
+ laneNumber: null,
780
+ taskId: null,
781
+ repoId: "default",
782
+ cwd: mergeWorkDir,
783
+ prompt,
784
+ systemPrompt,
785
+ model: config.merge.model || undefined,
786
+ tools: config.merge.tools || undefined,
787
+ mailboxDir,
788
+ eventsPath,
789
+ exitSummaryPath,
790
+ timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
791
+ stateRoot: stateRoot ?? repoRoot,
792
+ packet: null,
793
+ env: { ORCH_BATCH_ID: bid },
794
+ };
795
+
796
+ const { promise, kill } = spawnAgent(opts);
797
+
798
+ // Store the kill handle for external cleanup (pause/abort).
799
+ // The promise runs in background — caller uses waitForMergeResult()
800
+ // to poll for the result file, same contract as the TMUX path.
801
+ activeMergeAgents.set(sessionName, { promise, kill });
802
+
803
+ // Fire-and-forget: the background promise handles exit logging
804
+ promise.then(result => {
805
+ activeMergeAgents.delete(sessionName);
806
+ execLog("merge", sessionName, "merge agent exited (V2)", {
807
+ exitCode: result.exitCode,
808
+ durationMs: result.durationMs,
809
+ costUsd: result.costUsd,
810
+ killed: result.killed,
811
+ });
812
+ }).catch(err => {
813
+ activeMergeAgents.delete(sessionName);
814
+ execLog("merge", sessionName, `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`);
815
+ });
816
+ }
817
+
818
+ /** Active V2 merge agent handles for cleanup/abort. @since TP-108 */
819
+ const activeMergeAgents = new Map<string, { promise: Promise<AgentHostResult>; kill: () => void }>();
820
+
821
+ /**
822
+ * Kill a V2 merge agent if it's still running.
823
+ * Used by pause/abort/cleanup flows.
824
+ * @since TP-108
825
+ */
826
+ export function killMergeAgentV2(sessionName: string): boolean {
827
+ const handle = activeMergeAgents.get(sessionName);
828
+ if (handle) {
829
+ handle.kill();
830
+ activeMergeAgents.delete(sessionName);
831
+ return true;
832
+ }
833
+ return false;
834
+ }
835
+
836
+ /**
837
+ * Kill ALL active V2 merge agents. Used by abort flow to ensure
838
+ * no merge agents survive even when TMUX session list is empty.
839
+ * @returns Number of agents killed
840
+ * @since TP-108
841
+ */
842
+ export function killAllMergeAgentsV2(): number {
843
+ let killed = 0;
844
+ for (const [name, handle] of activeMergeAgents) {
845
+ handle.kill();
846
+ execLog("merge", name, "V2 merge agent killed by bulk abort");
847
+ killed++;
848
+ }
849
+ activeMergeAgents.clear();
850
+ return killed;
851
+ }
852
+
705
853
  /**
706
854
  * Re-read merge timeout from config on disk.
707
855
  *
@@ -751,13 +899,16 @@ export async function waitForMergeResult(
751
899
  resultPath: string,
752
900
  sessionName: string,
753
901
  timeoutMs: number = MERGE_TIMEOUT_MS,
902
+ runtimeBackend?: RuntimeBackend,
754
903
  ): Promise<MergeResult> {
755
904
  const startTime = Date.now();
756
905
  let sessionDiedAt: number | null = null;
906
+ const isV2 = runtimeBackend === "v2";
757
907
 
758
908
  execLog("merge", sessionName, "waiting for merge result", {
759
909
  resultPath,
760
910
  timeoutMs,
911
+ backend: isV2 ? "v2" : "legacy",
761
912
  });
762
913
 
763
914
  while (true) {
@@ -766,8 +917,6 @@ export async function waitForMergeResult(
766
917
  // Check timeout
767
918
  if (elapsed >= timeoutMs) {
768
919
  // TP-038: Check result file BEFORE killing the session.
769
- // The merge may have actually succeeded — the verification tests
770
- // just pushed past the timeout. Accept successful results without killing.
771
920
  if (existsSync(resultPath)) {
772
921
  try {
773
922
  const lateResult = await parseMergeResultAsync(resultPath);
@@ -777,33 +926,33 @@ export async function waitForMergeResult(
777
926
  elapsed,
778
927
  timeoutMs,
779
928
  });
780
- // Clean up session (agent may still be running post-write)
781
- if (await tmuxHasSessionAsync(sessionName)) {
929
+ // Clean up agent (may still be running post-write)
930
+ if (isV2) {
931
+ killMergeAgentV2(sessionName);
932
+ } else if (await tmuxHasSessionAsync(sessionName)) {
782
933
  await tmuxKillSessionAsync(sessionName);
783
934
  }
784
935
  return lateResult;
785
936
  }
786
- // Non-success result at timeout fall through to kill
787
- execLog("merge", sessionName, "merge result exists at timeout but non-success — killing session", {
937
+ execLog("merge", sessionName, "merge result exists at timeout but non-success killing", {
788
938
  status: lateResult.status,
789
- elapsed,
790
- timeoutMs,
791
939
  });
792
940
  } catch {
793
941
  // Result file unreadable — fall through to kill
794
942
  }
795
943
  }
796
944
 
797
- execLog("merge", sessionName, "merge timeout — killing session", {
798
- elapsed,
799
- timeoutMs,
800
- });
801
- await tmuxKillSessionAsync(sessionName);
945
+ execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
946
+ if (isV2) {
947
+ killMergeAgentV2(sessionName);
948
+ } else {
949
+ await tmuxKillSessionAsync(sessionName);
950
+ }
802
951
 
803
952
  throw new MergeError(
804
953
  "MERGE_TIMEOUT",
805
954
  `Merge agent '${sessionName}' did not produce a result within ` +
806
- `${Math.round(timeoutMs / 1000)}s. The session has been killed. ` +
955
+ `${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` +
807
956
  `Check the merge request and agent logs.`,
808
957
  );
809
958
  }
@@ -816,62 +965,54 @@ export async function waitForMergeResult(
816
965
  status: result.status,
817
966
  elapsed,
818
967
  });
819
- // Kill session if still alive (agent should exit, but ensure cleanup)
820
- if (await tmuxHasSessionAsync(sessionName)) {
968
+ // Clean up agent if still alive
969
+ if (isV2) {
970
+ killMergeAgentV2(sessionName);
971
+ } else if (await tmuxHasSessionAsync(sessionName)) {
821
972
  await tmuxKillSessionAsync(sessionName);
822
973
  }
823
974
  return result;
824
975
  } catch (err: unknown) {
825
- // File exists but invalid — might be partially written.
826
- // parseMergeResultAsync already retries, so if it throws, it's final.
827
976
  if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
828
- // Wait a bit and try once more (file might still be in flight)
829
977
  await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
830
978
  if (existsSync(resultPath)) {
831
- try {
832
- return await parseMergeResultAsync(resultPath);
833
- } catch {
834
- // Give up on this file
835
- }
979
+ try { return await parseMergeResultAsync(resultPath); } catch { /* give up */ }
836
980
  }
837
981
  }
838
- // If still failing, continue polling (agent might rewrite)
839
982
  }
840
983
  }
841
984
 
842
- // Check session liveness — async to avoid blocking
843
- const sessionAlive = await tmuxHasSessionAsync(sessionName);
985
+ // Check agent liveness — backend-aware
986
+ let agentAlive: boolean;
987
+ if (isV2) {
988
+ // V2: check activeMergeAgents map (process handle)
989
+ agentAlive = activeMergeAgents.has(sessionName);
990
+ } else {
991
+ // Legacy: check TMUX session
992
+ agentAlive = await tmuxHasSessionAsync(sessionName);
993
+ }
844
994
 
845
- if (!sessionAlive) {
995
+ if (!agentAlive) {
846
996
  if (sessionDiedAt === null) {
847
- // First detection of session death — start grace period
848
997
  sessionDiedAt = Date.now();
849
- execLog("merge", sessionName, "session exited — starting grace period", {
998
+ execLog("merge", sessionName, "agent exited — starting grace period", {
850
999
  graceMs: MERGE_RESULT_GRACE_MS,
851
1000
  });
852
1001
  } else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
853
- // Grace period expired — no result file
854
- // One final check
1002
+ // Grace period expired — one final check
855
1003
  if (existsSync(resultPath)) {
856
- try {
857
- return await parseMergeResultAsync(resultPath);
858
- } catch {
859
- // Fall through to session died error
860
- }
1004
+ try { return await parseMergeResultAsync(resultPath); } catch { /* fall through */ }
861
1005
  }
862
1006
 
863
1007
  throw new MergeError(
864
1008
  "MERGE_SESSION_DIED",
865
- `Merge agent session '${sessionName}' exited without writing ` +
1009
+ `Merge agent '${sessionName}' exited without writing ` +
866
1010
  `a result file to '${resultPath}'. The merge may have crashed. ` +
867
- `Check the session output: tmux capture-pane is unavailable ` +
868
- `after session exit.`,
1011
+ `Check agent logs for diagnostics.`,
869
1012
  );
870
1013
  }
871
- // Within grace period — continue polling
872
1014
  }
873
1015
 
874
- // Poll interval
875
1016
  await sleepAsync(MERGE_POLL_INTERVAL_MS);
876
1017
  }
877
1018
  }
@@ -1212,6 +1353,7 @@ export async function mergeWave(
1212
1353
  repoId?: string,
1213
1354
  healthMonitor?: MergeHealthMonitor | null,
1214
1355
  forceMixedOutcome?: boolean,
1356
+ runtimeBackend?: RuntimeBackend,
1215
1357
  ): Promise<MergeWaveResult> {
1216
1358
  const startTime = Date.now();
1217
1359
  const tmuxPrefix = config.orchestrator.tmux_prefix;
@@ -1520,18 +1662,28 @@ export async function mergeWave(
1520
1662
  }
1521
1663
 
1522
1664
  // Re-spawn merge agent for the retry
1523
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1524
- // TP-056: Re-register with health monitor after respawn
1525
- if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1665
+ // TP-108: Kill previous V2 agent to prevent orphan/duplicate
1666
+ if (runtimeBackend === "v2") {
1667
+ killMergeAgentV2(sessionName);
1668
+ await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1669
+ } else {
1670
+ await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1671
+ }
1672
+ // TP-056: Re-register with health monitor after respawn (legacy only)
1673
+ if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1526
1674
  } else {
1527
1675
  // First attempt: spawn merge agent
1528
- await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1529
- // TP-056: Register session with health monitor
1530
- if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1676
+ if (runtimeBackend === "v2") {
1677
+ await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1678
+ } else {
1679
+ await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
1680
+ }
1681
+ // TP-056: Register session with health monitor (legacy only — V2 uses process handle)
1682
+ if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
1531
1683
  }
1532
1684
 
1533
1685
  try {
1534
- mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs);
1686
+ mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs, runtimeBackend);
1535
1687
  // TP-056: Deregister session from health monitor on completion
1536
1688
  if (healthMonitor) healthMonitor.removeSession(sessionName);
1537
1689
  lastTimeoutError = null;
@@ -1790,8 +1942,10 @@ export async function mergeWave(
1790
1942
  // Best effort
1791
1943
  }
1792
1944
 
1793
- // Kill merge session if still alive
1794
- if (tmuxHasSession(sessionName)) {
1945
+ // Kill merge agent if still alive (backend-aware)
1946
+ if (runtimeBackend === "v2") {
1947
+ killMergeAgentV2(sessionName);
1948
+ } else if (tmuxHasSession(sessionName)) {
1795
1949
  tmuxKillSession(sessionName);
1796
1950
  }
1797
1951
 
@@ -2229,6 +2383,7 @@ export async function mergeWaveByRepo(
2229
2383
  testingCommands?: Record<string, string>,
2230
2384
  healthMonitor?: MergeHealthMonitor | null,
2231
2385
  forceMixedOutcome?: boolean,
2386
+ runtimeBackend?: RuntimeBackend,
2232
2387
  ): Promise<MergeWaveResult> {
2233
2388
  const startTime = Date.now();
2234
2389
 
@@ -2289,6 +2444,7 @@ export async function mergeWaveByRepo(
2289
2444
  undefined, // repoId
2290
2445
  healthMonitor,
2291
2446
  forceMixedOutcome,
2447
+ runtimeBackend,
2292
2448
  );
2293
2449
  // Attach empty repoResults for consistent shape
2294
2450
  return { ...result, repoResults: [] };
@@ -2347,6 +2503,7 @@ export async function mergeWaveByRepo(
2347
2503
  group.repoId,
2348
2504
  healthMonitor,
2349
2505
  forceMixedOutcome,
2506
+ runtimeBackend,
2350
2507
  );
2351
2508
 
2352
2509
  // Accumulate lane results