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
package/bin/taskplane.mjs
CHANGED
|
@@ -234,7 +234,7 @@ orchestrator:
|
|
|
234
234
|
worktree_prefix: "${vars.worktree_prefix}"
|
|
235
235
|
batch_id_format: "timestamp"
|
|
236
236
|
spawn_mode: "${vars.spawn_mode}"
|
|
237
|
-
|
|
237
|
+
session_prefix: "${vars.session_prefix}"
|
|
238
238
|
|
|
239
239
|
dependencies:
|
|
240
240
|
source: "prompt"
|
|
@@ -315,7 +315,7 @@ function generateProjectConfig(vars) {
|
|
|
315
315
|
worktreePrefix: vars.worktree_prefix,
|
|
316
316
|
batchIdFormat: "timestamp",
|
|
317
317
|
spawnMode: vars.spawn_mode,
|
|
318
|
-
|
|
318
|
+
sessionPrefix: vars.session_prefix,
|
|
319
319
|
operatorId: "",
|
|
320
320
|
},
|
|
321
321
|
dependencies: { source: "prompt", cache: true },
|
|
@@ -993,23 +993,6 @@ function detectInitMode(dir) {
|
|
|
993
993
|
};
|
|
994
994
|
}
|
|
995
995
|
|
|
996
|
-
// ─── tmux / spawn mode detection ────────────────────────────────────────────
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Detect whether tmux is available and determine the default spawn_mode.
|
|
1000
|
-
*
|
|
1001
|
-
* Reusable for both repo mode (Step 3) and workspace mode (Step 4) init.
|
|
1002
|
-
*
|
|
1003
|
-
* @returns {{ spawnMode: string, hasTmux: boolean }}
|
|
1004
|
-
*/
|
|
1005
|
-
function detectSpawnMode() {
|
|
1006
|
-
const hasTmux = commandExists("tmux");
|
|
1007
|
-
return {
|
|
1008
|
-
spawnMode: hasTmux ? "tmux" : "subprocess",
|
|
1009
|
-
hasTmux,
|
|
1010
|
-
};
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
996
|
// ─── init ───────────────────────────────────────────────────────────────────
|
|
1014
997
|
|
|
1015
998
|
async function cmdInit(args) {
|
|
@@ -1308,14 +1291,8 @@ async function cmdInit(args) {
|
|
|
1308
1291
|
vars = await getInteractiveVars(projectRoot, tasksRootOverride);
|
|
1309
1292
|
}
|
|
1310
1293
|
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
vars.spawn_mode = spawnMode;
|
|
1314
|
-
|
|
1315
|
-
if (preset !== "runner-only" && !hasTmux) {
|
|
1316
|
-
console.log(` ${WARN} tmux not found. Using subprocess mode.`);
|
|
1317
|
-
console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
|
|
1318
|
-
}
|
|
1294
|
+
// Runtime V2 is subprocess-only.
|
|
1295
|
+
vars.spawn_mode = "subprocess";
|
|
1319
1296
|
|
|
1320
1297
|
const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
|
|
1321
1298
|
|
|
@@ -1530,18 +1507,8 @@ async function cmdInit(args) {
|
|
|
1530
1507
|
vars = await getInteractiveVars(projectRoot, tasksRootOverride);
|
|
1531
1508
|
}
|
|
1532
1509
|
|
|
1533
|
-
//
|
|
1534
|
-
|
|
1535
|
-
// Runs for all init modes (repo and workspace) per spec.
|
|
1536
|
-
// Silent when tmux is found; shows guidance when missing.
|
|
1537
|
-
// Skipped for runner-only preset (no orchestrator config generated).
|
|
1538
|
-
const { spawnMode, hasTmux } = detectSpawnMode();
|
|
1539
|
-
vars.spawn_mode = spawnMode;
|
|
1540
|
-
|
|
1541
|
-
if (preset !== "runner-only" && !hasTmux) {
|
|
1542
|
-
console.log(` ${WARN} tmux not found. Using subprocess mode.`);
|
|
1543
|
-
console.log(` Run ${c.cyan}taskplane install-tmux${c.reset} for full orchestrator support.\n`);
|
|
1544
|
-
}
|
|
1510
|
+
// Runtime V2 is subprocess-only.
|
|
1511
|
+
vars.spawn_mode = "subprocess";
|
|
1545
1512
|
|
|
1546
1513
|
const exampleTemplateDirs = noExamples ? [] : listExampleTaskTemplates();
|
|
1547
1514
|
|
|
@@ -1672,7 +1639,7 @@ function getPresetVars(preset, projectRoot, tasksRootOverride = null) {
|
|
|
1672
1639
|
project_name: dirName,
|
|
1673
1640
|
max_lanes: 3,
|
|
1674
1641
|
worktree_prefix: `${slug}-wt`,
|
|
1675
|
-
|
|
1642
|
+
session_prefix: `${slug}-orch`,
|
|
1676
1643
|
tasks_root: tasksRootOverride || "taskplane-tasks",
|
|
1677
1644
|
default_area: "general",
|
|
1678
1645
|
default_prefix: "TP",
|
|
@@ -1699,7 +1666,7 @@ async function getInteractiveVars(projectRoot, tasksRootOverride = null) {
|
|
|
1699
1666
|
project_name,
|
|
1700
1667
|
max_lanes,
|
|
1701
1668
|
worktree_prefix: `${slug}-wt`,
|
|
1702
|
-
|
|
1669
|
+
session_prefix: `${slug}-orch`,
|
|
1703
1670
|
tasks_root,
|
|
1704
1671
|
default_area,
|
|
1705
1672
|
default_prefix,
|
package/dashboard/public/app.js
CHANGED
|
@@ -160,19 +160,18 @@ function showCopyToast(text) {
|
|
|
160
160
|
toastTimer = setTimeout(() => toastEl.classList.remove("visible"), 2000);
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
showCopyToast(cmd);
|
|
163
|
+
function copySessionId(sessionName) {
|
|
164
|
+
navigator.clipboard.writeText(sessionName).then(() => {
|
|
165
|
+
showCopyToast(`session ${sessionName}`);
|
|
167
166
|
// Flash the button
|
|
168
|
-
const btn = document.querySelector(`[data-
|
|
167
|
+
const btn = document.querySelector(`[data-session="${sessionName}"]`);
|
|
169
168
|
if (btn) {
|
|
170
169
|
btn.classList.add("copied");
|
|
171
170
|
setTimeout(() => btn.classList.remove("copied"), 1500);
|
|
172
171
|
}
|
|
173
172
|
}).catch(() => {
|
|
174
173
|
// Fallback: select the text
|
|
175
|
-
const btn = document.querySelector(`[data-
|
|
174
|
+
const btn = document.querySelector(`[data-session="${sessionName}"]`);
|
|
176
175
|
if (btn) {
|
|
177
176
|
const range = document.createRange();
|
|
178
177
|
range.selectNodeContents(btn);
|
|
@@ -517,30 +516,31 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
517
516
|
}
|
|
518
517
|
|
|
519
518
|
// TP-107: check V2 registry for liveness first, fall back to tmux
|
|
519
|
+
const laneSessionId = lane.laneSessionId;
|
|
520
520
|
const v2Alive = isLaneAliveV2(lane.laneNumber);
|
|
521
|
-
const alive = v2Alive !== null ? v2Alive : tmuxSet.has(
|
|
522
|
-
const
|
|
521
|
+
const alive = v2Alive !== null ? v2Alive : tmuxSet.has(laneSessionId);
|
|
522
|
+
const sessionChip = `session: ${laneSessionId}`;
|
|
523
523
|
|
|
524
524
|
// Lane header
|
|
525
525
|
html += `<div class="lane-group">`;
|
|
526
526
|
html += `<div class="lane-header">`;
|
|
527
527
|
html += ` <span class="lane-num">${lane.laneNumber}</span>`;
|
|
528
528
|
html += ` <div class="lane-meta">`;
|
|
529
|
-
html += ` <span class="lane-session">${escapeHtml(
|
|
529
|
+
html += ` <span class="lane-session">${escapeHtml(laneSessionId || "—")}</span>`;
|
|
530
530
|
html += ` <span class="lane-branch">${escapeHtml(lane.branch || "—")}</span>`;
|
|
531
531
|
if (showRepos && lane.repoId) {
|
|
532
532
|
html += ` ${repoBadgeHtml(lane.repoId, "repo-badge-lane")}`;
|
|
533
533
|
}
|
|
534
534
|
html += ` </div>`;
|
|
535
535
|
html += ` <div class="lane-right">`;
|
|
536
|
-
html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "
|
|
537
|
-
// View button: shows conversation stream
|
|
538
|
-
const isViewingConv = viewerMode === 'conversation' && viewerTarget ===
|
|
539
|
-
html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(
|
|
536
|
+
html += ` <span class="tmux-dot ${alive ? "alive" : "dead"}" title="${alive ? "session alive" : "session not active"}"></span>`;
|
|
537
|
+
// View button: shows conversation stream when available
|
|
538
|
+
const isViewingConv = viewerMode === 'conversation' && viewerTarget === laneSessionId;
|
|
539
|
+
html += ` <button class="tmux-view-btn${isViewingConv ? ' active' : ''}" onclick="viewConversation('${escapeHtml(laneSessionId)}')" title="View worker conversation">👁 View</button>`;
|
|
540
540
|
if (alive) {
|
|
541
|
-
html += ` <span class="tmux-cmd" data-
|
|
541
|
+
html += ` <span class="tmux-cmd" data-session="${escapeHtml(laneSessionId)}" onclick="copySessionId('${escapeHtml(laneSessionId)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
|
|
542
542
|
} else {
|
|
543
|
-
html += ` <span class="tmux-cmd dead-session">${escapeHtml(
|
|
543
|
+
html += ` <span class="tmux-cmd dead-session">${escapeHtml(sessionChip)}</span>`;
|
|
544
544
|
}
|
|
545
545
|
html += ` </div>`;
|
|
546
546
|
html += `</div>`;
|
|
@@ -555,9 +555,9 @@ function renderLanesTasks(batch, tmuxSessions) {
|
|
|
555
555
|
// Get lane state and telemetry for worker stats
|
|
556
556
|
// TP-107: V2 lane snapshots take precedence when present
|
|
557
557
|
const v2snap = v2Snapshots[lane.laneNumber] || null;
|
|
558
|
-
const legacyLs = laneStates[
|
|
558
|
+
const legacyLs = laneStates[laneSessionId] || null;
|
|
559
559
|
const ls = v2snap ? mergeV2LaneSnapshot(legacyLs, v2snap) : legacyLs;
|
|
560
|
-
const tel = telemetry[
|
|
560
|
+
const tel = telemetry[laneSessionId] || null;
|
|
561
561
|
|
|
562
562
|
for (const task of laneTasks) {
|
|
563
563
|
// Repo filtering at task level
|
|
@@ -768,8 +768,8 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
768
768
|
// Extract the prefix-opId part from the first lane and use it to construct merge names.
|
|
769
769
|
const lanes = batch?.lanes || [];
|
|
770
770
|
let mergePrefix = "orch-merge"; // fallback for legacy/unknown patterns
|
|
771
|
-
if (lanes.length > 0 && lanes[0].
|
|
772
|
-
const laneName = lanes[0].
|
|
771
|
+
if (lanes.length > 0 && lanes[0].laneSessionId) {
|
|
772
|
+
const laneName = lanes[0].laneSessionId;
|
|
773
773
|
const laneMatch = laneName.match(/^(.+)-lane-\d+$/);
|
|
774
774
|
if (laneMatch) {
|
|
775
775
|
mergePrefix = laneMatch[1] + "-merge";
|
|
@@ -784,7 +784,7 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
784
784
|
}
|
|
785
785
|
|
|
786
786
|
let html = '<table class="merge-table"><thead><tr>';
|
|
787
|
-
html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>
|
|
787
|
+
html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Session ID</th><th>Details</th>';
|
|
788
788
|
html += '</tr></thead><tbody>';
|
|
789
789
|
|
|
790
790
|
// Track sessions shown in wave result rows so we don't duplicate them below
|
|
@@ -855,8 +855,8 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
855
855
|
html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(mergeTel, effectiveAlive)}</td>`;
|
|
856
856
|
html += `<td>`;
|
|
857
857
|
if (effectiveAlive) {
|
|
858
|
-
const
|
|
859
|
-
html += `<span class="tmux-cmd" data-
|
|
858
|
+
const sessionChip = `session: ${effectiveSession}`;
|
|
859
|
+
html += `<span class="tmux-cmd" data-session="${escapeHtml(effectiveSession)}" onclick="copySessionId('${escapeHtml(effectiveSession)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span>`;
|
|
860
860
|
} else {
|
|
861
861
|
html += '<span class="merge-no-data">—</span>';
|
|
862
862
|
}
|
|
@@ -894,14 +894,14 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
894
894
|
if (shownSessions.has(sess)) continue;
|
|
895
895
|
|
|
896
896
|
const sessTel = telemetry[sess] || null;
|
|
897
|
-
const
|
|
897
|
+
const sessionChip = `session: ${sess}`;
|
|
898
898
|
html += `<tr>`;
|
|
899
899
|
html += `<td class="merge-wave-cell">—</td>`;
|
|
900
900
|
html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
|
|
901
901
|
html += `<td class="merge-session-cell">${escapeHtml(sess)}</td>`;
|
|
902
902
|
// Full telemetry cell for active merge session
|
|
903
903
|
html += `<td class="merge-telemetry-cell">${mergeTelemetryHtml(sessTel, true)}</td>`;
|
|
904
|
-
html += `<td><span class="tmux-cmd" data-
|
|
904
|
+
html += `<td><span class="tmux-cmd" data-session="${escapeHtml(sess)}" onclick="copySessionId('${escapeHtml(sess)}')" title="Copy session ID">${escapeHtml(sessionChip)}</span></td>`;
|
|
905
905
|
html += `<td>—</td>`;
|
|
906
906
|
html += `</tr>`;
|
|
907
907
|
}
|
package/dashboard/server.cjs
CHANGED
|
@@ -68,10 +68,31 @@ Options:
|
|
|
68
68
|
|
|
69
69
|
// ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
|
|
70
70
|
|
|
71
|
+
function normalizeBatchStateIngress(state) {
|
|
72
|
+
if (!state || typeof state !== "object" || !Array.isArray(state.lanes)) {
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const lane of state.lanes) {
|
|
77
|
+
if (!lane || typeof lane !== "object") continue;
|
|
78
|
+
const laneSessionId = typeof lane.laneSessionId === "string"
|
|
79
|
+
? lane.laneSessionId
|
|
80
|
+
: (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
|
|
81
|
+
if (laneSessionId) {
|
|
82
|
+
lane.laneSessionId = laneSessionId;
|
|
83
|
+
}
|
|
84
|
+
if ("tmuxSessionName" in lane) {
|
|
85
|
+
delete lane.tmuxSessionName;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return state;
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
function loadBatchState() {
|
|
72
93
|
try {
|
|
73
94
|
const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
|
|
74
|
-
return JSON.parse(raw);
|
|
95
|
+
return normalizeBatchStateIngress(JSON.parse(raw));
|
|
75
96
|
} catch {
|
|
76
97
|
return null;
|
|
77
98
|
}
|
|
@@ -344,13 +365,13 @@ function tailJsonlFile(filePath) {
|
|
|
344
365
|
|
|
345
366
|
/**
|
|
346
367
|
* Load and accumulate telemetry from .pi/telemetry/*.jsonl files.
|
|
347
|
-
* Returns telemetry keyed by
|
|
368
|
+
* Returns telemetry keyed by session prefix (e.g., "orch-lane-1").
|
|
348
369
|
*
|
|
349
|
-
* Uses batch-state lanes to map lane numbers →
|
|
370
|
+
* Uses batch-state lanes to map lane numbers → session prefixes.
|
|
350
371
|
* For standalone /task mode (no lane number in filename), data is keyed as "standalone".
|
|
351
372
|
*
|
|
352
373
|
* @param {object|null} batchState - The batch state from batch-state.json
|
|
353
|
-
* @returns {object} Map of
|
|
374
|
+
* @returns {object} Map of sessionPrefix → accumulated telemetry
|
|
354
375
|
*/
|
|
355
376
|
|
|
356
377
|
// ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
|
|
@@ -521,8 +542,9 @@ function loadTelemetryData(batchState) {
|
|
|
521
542
|
const laneToPrefix = {};
|
|
522
543
|
if (batchState && batchState.lanes) {
|
|
523
544
|
for (const lane of batchState.lanes) {
|
|
524
|
-
|
|
525
|
-
|
|
545
|
+
const laneSessionId = lane.laneSessionId;
|
|
546
|
+
if (lane.laneNumber != null && laneSessionId) {
|
|
547
|
+
laneToPrefix[lane.laneNumber] = laneSessionId;
|
|
526
548
|
}
|
|
527
549
|
}
|
|
528
550
|
}
|
|
@@ -1022,7 +1044,7 @@ function buildDashboardState() {
|
|
|
1022
1044
|
for (const [laneNum, snap] of Object.entries(runtimeLaneSnapshots)) {
|
|
1023
1045
|
// Find the matching lane record to get the session name key
|
|
1024
1046
|
const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
|
|
1025
|
-
const key = laneRec ? laneRec.
|
|
1047
|
+
const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
|
|
1026
1048
|
if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
|
|
1027
1049
|
const w = snap.worker || {};
|
|
1028
1050
|
const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
} from "fs";
|
|
29
29
|
import { tmpdir, userInfo } from "os";
|
|
30
30
|
import { join, dirname, basename, resolve } from "path";
|
|
31
|
-
import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
|
|
31
|
+
import { ConfigLoadError, loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
|
|
32
32
|
import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
|
|
33
33
|
import type { PointerResolution } from "./taskplane/types.ts";
|
|
34
34
|
import {
|
|
@@ -292,8 +292,12 @@ export function loadConfig(cwd: string): TaskConfig {
|
|
|
292
292
|
const pointer = resolveTaskRunnerPointer();
|
|
293
293
|
const unified = loadProjectConfig(cwd, pointer?.configRoot);
|
|
294
294
|
return toTaskConfig(unified);
|
|
295
|
-
} catch {
|
|
296
|
-
|
|
295
|
+
} catch (err: unknown) {
|
|
296
|
+
if (err instanceof ConfigLoadError && err.code === "CONFIG_LEGACY_FIELD") {
|
|
297
|
+
// Hard-fail deprecated TMUX-era config/prefs with migration guidance.
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
// For malformed/unreadable config, preserve historical fallback behavior.
|
|
297
301
|
return { ...DEFAULT_CONFIG };
|
|
298
302
|
}
|
|
299
303
|
}
|
|
@@ -3,12 +3,11 @@
|
|
|
3
3
|
* @module orch/abort
|
|
4
4
|
*/
|
|
5
5
|
import { writeFileSync, existsSync } from "fs";
|
|
6
|
-
import { execSync } from "child_process";
|
|
7
6
|
import { join } from "path";
|
|
8
7
|
|
|
9
|
-
import { execLog,
|
|
8
|
+
import { execLog, killV2LaneAgents, resolveCanonicalTaskPaths } from "./execution.ts";
|
|
10
9
|
import { killMergeAgentV2, killAllMergeAgentsV2 } from "./merge.ts";
|
|
11
|
-
import { deleteBatchState,
|
|
10
|
+
import { deleteBatchState, persistRuntimeState } from "./persistence.ts";
|
|
12
11
|
import type { AbortActionStep, AbortErrorCode, AbortLaneResult, AbortMode, AbortResult, AbortTargetSession, AllocatedLane, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord } from "./types.ts";
|
|
13
12
|
|
|
14
13
|
// ── Abort Pure Functions ─────────────────────────────────────────────
|
|
@@ -51,11 +50,11 @@ export function selectAbortTargetSessions(
|
|
|
51
50
|
});
|
|
52
51
|
|
|
53
52
|
// Build lookup from persisted lane records for workspace-aware laneId resolution.
|
|
54
|
-
// Keyed by
|
|
53
|
+
// Keyed by lane session ID for direct session-to-lane mapping.
|
|
55
54
|
const persistedLaneLookup = new Map<string, PersistedLaneRecord>();
|
|
56
55
|
if (persistedState?.lanes) {
|
|
57
56
|
for (const lane of persistedState.lanes) {
|
|
58
|
-
persistedLaneLookup.set(lane.
|
|
57
|
+
persistedLaneLookup.set(lane.laneSessionId, lane);
|
|
59
58
|
}
|
|
60
59
|
}
|
|
61
60
|
|
|
@@ -82,7 +81,7 @@ export function selectAbortTargetSessions(
|
|
|
82
81
|
const runtimeLookup = new Map<string, { laneId: string; taskId: string | null; worktreePath: string; taskFolder: string | null }>();
|
|
83
82
|
for (const lane of runtimeLanes) {
|
|
84
83
|
const currentTask = lane.tasks.length > 0 ? lane.tasks[0] : null;
|
|
85
|
-
runtimeLookup.set(lane.
|
|
84
|
+
runtimeLookup.set(lane.laneSessionId, {
|
|
86
85
|
laneId: lane.laneId,
|
|
87
86
|
taskId: currentTask?.taskId || null,
|
|
88
87
|
worktreePath: lane.worktreePath,
|
|
@@ -143,6 +142,47 @@ export function planAbortActions(
|
|
|
143
142
|
];
|
|
144
143
|
}
|
|
145
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Discover abort target session names from Runtime V2 state sources.
|
|
147
|
+
*
|
|
148
|
+
* Sources (deduped):
|
|
149
|
+
* - in-memory runtime lanes (`batchState.currentLanes`)
|
|
150
|
+
* - persisted lane records (`persistedState.lanes`)
|
|
151
|
+
* - persisted task records (`persistedState.tasks[].sessionName`)
|
|
152
|
+
*/
|
|
153
|
+
export function discoverAbortSessionNames(
|
|
154
|
+
prefix: string,
|
|
155
|
+
persistedState: PersistedBatchState | null,
|
|
156
|
+
runtimeLanes: AllocatedLane[],
|
|
157
|
+
): string[] {
|
|
158
|
+
const names = new Set<string>();
|
|
159
|
+
const prefixWithDash = `${prefix}-`;
|
|
160
|
+
const add = (name: string | null | undefined) => {
|
|
161
|
+
if (!name) return;
|
|
162
|
+
const trimmed = name.trim();
|
|
163
|
+
if (!trimmed || !trimmed.startsWith(prefixWithDash)) return;
|
|
164
|
+
names.add(trimmed);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
for (const lane of runtimeLanes) {
|
|
168
|
+
add(lane.laneSessionId);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (persistedState?.lanes) {
|
|
172
|
+
for (const lane of persistedState.lanes) {
|
|
173
|
+
add(lane.laneSessionId);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (persistedState?.tasks) {
|
|
178
|
+
for (const task of persistedState.tasks) {
|
|
179
|
+
add(task.sessionName);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return [...names];
|
|
184
|
+
}
|
|
185
|
+
|
|
146
186
|
|
|
147
187
|
// ── Abort Orchestration Functions ────────────────────────────────────
|
|
148
188
|
|
|
@@ -198,14 +238,15 @@ export function writeWrapUpFiles(
|
|
|
198
238
|
}
|
|
199
239
|
|
|
200
240
|
/**
|
|
201
|
-
* Wait for
|
|
241
|
+
* Wait for graceful shutdown window to elapse.
|
|
202
242
|
*
|
|
203
|
-
*
|
|
204
|
-
* grace
|
|
243
|
+
* Runtime V2 no longer relies on TMUX session liveness as an abort signal.
|
|
244
|
+
* We keep this grace window so workers can observe `.task-wrap-up` and exit
|
|
245
|
+
* naturally before forced cleanup.
|
|
205
246
|
*
|
|
206
|
-
* @param sessionNames - Session names
|
|
247
|
+
* @param sessionNames - Session names being tracked for abort
|
|
207
248
|
* @param gracePeriodMs - Maximum time to wait
|
|
208
|
-
* @param pollIntervalMs - Polling
|
|
249
|
+
* @param pollIntervalMs - Polling cadence for the grace wait loop
|
|
209
250
|
* @returns Object with exited and remaining session names
|
|
210
251
|
*/
|
|
211
252
|
export async function waitForSessionExit(
|
|
@@ -213,69 +254,52 @@ export async function waitForSessionExit(
|
|
|
213
254
|
gracePeriodMs: number,
|
|
214
255
|
pollIntervalMs: number,
|
|
215
256
|
): Promise<{ exited: string[]; remaining: string[] }> {
|
|
257
|
+
if (sessionNames.length === 0 || gracePeriodMs <= 0) {
|
|
258
|
+
return { exited: [], remaining: [...sessionNames] };
|
|
259
|
+
}
|
|
260
|
+
|
|
216
261
|
const deadline = Date.now() + gracePeriodMs;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
while (Date.now() < deadline && remaining.size > 0) {
|
|
221
|
-
for (const name of [...remaining]) {
|
|
222
|
-
if (!tmuxHasSession(name)) {
|
|
223
|
-
remaining.delete(name);
|
|
224
|
-
exited.push(name);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
if (remaining.size === 0) break;
|
|
228
|
-
await new Promise(r => setTimeout(r, pollIntervalMs));
|
|
262
|
+
while (Date.now() < deadline) {
|
|
263
|
+
const sleepMs = Math.max(1, Math.min(pollIntervalMs, deadline - Date.now()));
|
|
264
|
+
await new Promise(r => setTimeout(r, sleepMs));
|
|
229
265
|
}
|
|
230
266
|
|
|
231
|
-
return { exited, remaining: [...
|
|
267
|
+
return { exited: [], remaining: [...sessionNames] };
|
|
232
268
|
}
|
|
233
269
|
|
|
234
270
|
/**
|
|
235
|
-
* Kill orchestrator
|
|
271
|
+
* Kill orchestrator Runtime V2 agents.
|
|
236
272
|
*
|
|
237
|
-
* Kills
|
|
238
|
-
*
|
|
273
|
+
* Kills lane worker/reviewer agents and merge agents by process handle.
|
|
274
|
+
* Session names are normalized to base lane/merge IDs so child suffixes do
|
|
275
|
+
* not trigger duplicate cleanup attempts.
|
|
239
276
|
*
|
|
240
277
|
* @param sessionNames - Session names to kill
|
|
241
278
|
* @returns Per-session kill results
|
|
242
279
|
*/
|
|
243
280
|
export function killOrchSessions(
|
|
244
281
|
sessionNames: string[],
|
|
282
|
+
options?: { stateRoot?: string; batchId?: string },
|
|
245
283
|
): Array<{ sessionName: string; killed: boolean; error: string | null }> {
|
|
246
284
|
const results: Array<{ sessionName: string; killed: boolean; error: string | null }> = [];
|
|
285
|
+
const killedBaseSessions = new Set<string>();
|
|
286
|
+
|
|
287
|
+
for (const name of sessionNames) {
|
|
288
|
+
const baseSessionName = name.replace(/-(worker|reviewer)$/, "");
|
|
289
|
+
if (!killedBaseSessions.has(baseSessionName)) {
|
|
290
|
+
killV2LaneAgents(baseSessionName, {
|
|
291
|
+
stateRoot: options?.stateRoot,
|
|
292
|
+
batchId: options?.batchId,
|
|
293
|
+
logContext: "abort",
|
|
294
|
+
});
|
|
295
|
+
killMergeAgentV2(baseSessionName);
|
|
296
|
+
killedBaseSessions.add(baseSessionName);
|
|
297
|
+
}
|
|
247
298
|
|
|
248
|
-
// Group into base sessions (lane/merge) and child sessions
|
|
249
|
-
const baseSessionNames = sessionNames.filter(name =>
|
|
250
|
-
!name.endsWith("-worker") && !name.endsWith("-reviewer"),
|
|
251
|
-
);
|
|
252
|
-
const childSessionNames = sessionNames.filter(name =>
|
|
253
|
-
name.endsWith("-worker") || name.endsWith("-reviewer"),
|
|
254
|
-
);
|
|
255
|
-
|
|
256
|
-
// Kill explicitly-targeted child sessions first.
|
|
257
|
-
for (const name of childSessionNames) {
|
|
258
|
-
const killed = tmuxKillSession(name);
|
|
259
|
-
results.push({
|
|
260
|
-
sessionName: name,
|
|
261
|
-
killed,
|
|
262
|
-
error: killed ? null : `Session '${name}' still alive after kill attempt`,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Then kill base sessions (and defensively kill their children).
|
|
267
|
-
for (const name of baseSessionNames) {
|
|
268
|
-
// Best-effort child cleanup even if not explicitly targeted.
|
|
269
|
-
tmuxKillSession(`${name}-worker`);
|
|
270
|
-
tmuxKillSession(`${name}-reviewer`);
|
|
271
|
-
// TP-108: Also kill V2 merge agents (no-op if not V2)
|
|
272
|
-
killMergeAgentV2(name);
|
|
273
|
-
|
|
274
|
-
const killed = tmuxKillSession(name);
|
|
275
299
|
results.push({
|
|
276
300
|
sessionName: name,
|
|
277
|
-
killed,
|
|
278
|
-
error:
|
|
301
|
+
killed: true,
|
|
302
|
+
error: null,
|
|
279
303
|
});
|
|
280
304
|
}
|
|
281
305
|
|
|
@@ -296,7 +320,7 @@ export function killOrchSessions(
|
|
|
296
320
|
* Non-goal: does NOT delete worktrees/branches (preserved for inspection).
|
|
297
321
|
*
|
|
298
322
|
* @param mode - Abort mode (graceful or hard)
|
|
299
|
-
* @param prefix -
|
|
323
|
+
* @param prefix - orchestrator session prefix (e.g., "orch")
|
|
300
324
|
* @param repoRoot - Repository root path
|
|
301
325
|
* @param batchState - Current batch runtime state (mutated: phase set to stopped)
|
|
302
326
|
* @param persistedState - Loaded persisted state (for session enrichment)
|
|
@@ -342,28 +366,10 @@ export async function executeAbort(
|
|
|
342
366
|
execLog("abort", batchState.batchId, `killed ${v2MergeKilled} V2 merge agent(s)`);
|
|
343
367
|
}
|
|
344
368
|
|
|
345
|
-
// Step 3:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
(() => {
|
|
350
|
-
try {
|
|
351
|
-
return execSync('tmux list-sessions -F "#{session_name}"', {
|
|
352
|
-
encoding: "utf-8",
|
|
353
|
-
timeout: 5000,
|
|
354
|
-
});
|
|
355
|
-
} catch {
|
|
356
|
-
return "";
|
|
357
|
-
}
|
|
358
|
-
})(),
|
|
359
|
-
prefix,
|
|
360
|
-
);
|
|
361
|
-
} catch (err) {
|
|
362
|
-
errors.push({
|
|
363
|
-
code: "ABORT_TMUX_LIST_FAILED",
|
|
364
|
-
message: err instanceof Error ? err.message : String(err),
|
|
365
|
-
});
|
|
366
|
-
allSessionNames = [];
|
|
369
|
+
// Step 3: Discover target sessions from Runtime V2 state sources.
|
|
370
|
+
const allSessionNames = discoverAbortSessionNames(prefix, persistedState, batchState.currentLanes);
|
|
371
|
+
if (allSessionNames.length === 0) {
|
|
372
|
+
execLog("abort", batchState.batchId, `No abort targets discovered for prefix "${prefix}" from runtime/persisted state.`);
|
|
367
373
|
}
|
|
368
374
|
|
|
369
375
|
// Step 4: Select and enrich target sessions
|
|
@@ -400,7 +406,10 @@ export async function executeAbort(
|
|
|
400
406
|
// Step 5c: Force-kill remaining sessions
|
|
401
407
|
const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
|
|
402
408
|
if (waitResult.remaining.length > 0) {
|
|
403
|
-
const killResults = killOrchSessions(waitResult.remaining
|
|
409
|
+
const killResults = killOrchSessions(waitResult.remaining, {
|
|
410
|
+
stateRoot: repoRoot,
|
|
411
|
+
batchId: batchState.batchId,
|
|
412
|
+
});
|
|
404
413
|
for (const kr of killResults) {
|
|
405
414
|
killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
|
|
406
415
|
}
|
|
@@ -434,7 +443,10 @@ export async function executeAbort(
|
|
|
434
443
|
} else {
|
|
435
444
|
// Hard mode: kill all immediately
|
|
436
445
|
const allTargetNames = targets.map(t => t.sessionName);
|
|
437
|
-
const killResults = killOrchSessions(allTargetNames
|
|
446
|
+
const killResults = killOrchSessions(allTargetNames, {
|
|
447
|
+
stateRoot: repoRoot,
|
|
448
|
+
batchId: batchState.batchId,
|
|
449
|
+
});
|
|
438
450
|
const killResultBySession = new Map<string, { killed: boolean; error: string | null }>();
|
|
439
451
|
for (const kr of killResults) {
|
|
440
452
|
killResultBySession.set(kr.sessionName, { killed: kr.killed, error: kr.error });
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent Host — Direct-child Pi agent hosting for Runtime V2
|
|
3
3
|
*
|
|
4
|
-
* Spawns `pi --mode rpc` as a direct child process (no
|
|
4
|
+
* Spawns `pi --mode rpc` as a direct child process (no terminal multiplexer, no shell),
|
|
5
5
|
* parses RPC JSONL events, normalizes them into RuntimeAgentEvents,
|
|
6
6
|
* manages mailbox delivery, and produces exit summaries.
|
|
7
7
|
*
|
|
8
|
-
* This replaces the
|
|
9
|
-
* task-runner.ts + rpc-wrapper.mjs as a TMUX session command) with
|
|
8
|
+
* This replaces the legacy terminal-session hosting path with
|
|
10
9
|
* a programmatic parent-child model where the caller has full process
|
|
11
10
|
* ownership.
|
|
12
11
|
*
|
|
13
12
|
* Key differences from the legacy path:
|
|
14
|
-
* 1. No
|
|
13
|
+
* 1. No terminal-session backend — `spawn()` with `shell: false`
|
|
15
14
|
* 2. No sidecar tailing — events flow directly to the caller via callbacks
|
|
16
15
|
* 3. No PID-file orphan guessing — caller owns the process handle
|
|
17
16
|
* 4. Registry integration — manifests updated on status transitions
|
|
@@ -301,7 +300,7 @@ export function spawnAgent(
|
|
|
301
300
|
piArgs.push("--no-skills");
|
|
302
301
|
if (opts.thinking) piArgs.push("--thinking", opts.thinking);
|
|
303
302
|
|
|
304
|
-
// Spawn directly — no shell, no
|
|
303
|
+
// Spawn directly — no shell, no terminal multiplexer
|
|
305
304
|
const proc = spawn(process.execPath, piArgs, {
|
|
306
305
|
shell: false,
|
|
307
306
|
cwd: opts.cwd,
|