taskplane 0.25.5 → 0.25.7
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 +10 -4
- package/dashboard/public/style.css +6 -5
- package/dashboard/server.cjs +84 -3
- package/extensions/taskplane/agent-bridge-extension.ts +11 -1
- package/extensions/taskplane/agent-host.ts +2 -2
- package/extensions/taskplane/config-loader.ts +5 -0
- package/extensions/taskplane/engine.ts +15 -3
- package/extensions/taskplane/execution.ts +157 -9
- package/extensions/taskplane/extension.ts +2 -1
- package/extensions/taskplane/lane-runner.ts +19 -0
- package/extensions/taskplane/merge.ts +91 -6
- package/extensions/taskplane/path-resolver.ts +7 -3
- package/extensions/taskplane/process-registry.ts +52 -0
- package/extensions/taskplane/resume.ts +4 -3
- package/extensions/taskplane/types.ts +57 -0
- package/package.json +1 -1
package/dashboard/public/app.js
CHANGED
|
@@ -79,7 +79,7 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
|
|
|
79
79
|
if (w) {
|
|
80
80
|
// Map V2 agent status to legacy dashboard status strings
|
|
81
81
|
if (w.status) {
|
|
82
|
-
const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: '
|
|
82
|
+
const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'done', timed_out: 'error', wrapping_up: 'running' };
|
|
83
83
|
base.workerStatus = statusMap[w.status] || w.status;
|
|
84
84
|
}
|
|
85
85
|
if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
|
|
@@ -1062,9 +1062,15 @@ function renderAgentsPanel(registry) {
|
|
|
1062
1062
|
let html = '<div class="agents-grid">';
|
|
1063
1063
|
|
|
1064
1064
|
for (const agent of agents) {
|
|
1065
|
+
const isCrash = ['crashed', 'timed_out'].includes(agent.status);
|
|
1065
1066
|
const isTerminal = ['exited', 'crashed', 'timed_out', 'killed'].includes(agent.status);
|
|
1066
|
-
const statusClass = isTerminal ? 'agent-terminal' : 'agent-live';
|
|
1067
|
-
const icon =
|
|
1067
|
+
const statusClass = isTerminal ? (isCrash ? 'agent-terminal agent-crashed' : 'agent-terminal') : 'agent-live';
|
|
1068
|
+
const icon = isCrash ? '\u{1F534}' : (isTerminal ? '\u26AA' : '\u{1F7E2}');
|
|
1069
|
+
// Display label: exited and killed both show as 'shutdown' — the mechanism is an
|
|
1070
|
+
// implementation detail. Only crashed/timed_out warrant a different label.
|
|
1071
|
+
const displayStatus = (agent.status === 'exited' || agent.status === 'killed') ? 'shutdown'
|
|
1072
|
+
: agent.status === 'timed_out' ? 'timed out'
|
|
1073
|
+
: agent.status;
|
|
1068
1074
|
const elapsed = agent.startedAt ? Math.round((Date.now() - agent.startedAt) / 1000) : 0;
|
|
1069
1075
|
const elapsedStr = elapsed > 0 ? formatDuration(elapsed * 1000) : '';
|
|
1070
1076
|
|
|
@@ -1074,7 +1080,7 @@ function renderAgentsPanel(registry) {
|
|
|
1074
1080
|
html += `<span class="agent-badge">${escapeHtml(agent.role)}</span>`;
|
|
1075
1081
|
if (agent.laneNumber != null) html += `<span class="agent-badge">lane ${agent.laneNumber}</span>`;
|
|
1076
1082
|
if (agent.taskId) html += `<span class="agent-badge">${escapeHtml(agent.taskId)}</span>`;
|
|
1077
|
-
html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(
|
|
1083
|
+
html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(displayStatus)}</span>`;
|
|
1078
1084
|
if (elapsedStr && !isTerminal) html += `<span class="agent-badge">${elapsedStr}</span>`;
|
|
1079
1085
|
html += `</div>`;
|
|
1080
1086
|
html += `</div>`;
|
|
@@ -817,8 +817,9 @@ body {
|
|
|
817
817
|
.worker-last-tool {
|
|
818
818
|
color: var(--text-muted);
|
|
819
819
|
max-width: 600px;
|
|
820
|
-
overflow:
|
|
821
|
-
|
|
820
|
+
overflow-wrap: break-word;
|
|
821
|
+
word-break: break-all;
|
|
822
|
+
white-space: normal;
|
|
822
823
|
}
|
|
823
824
|
|
|
824
825
|
/* ─── Reviewer Sub-Row ─────────────────────────────────────────────── */
|
|
@@ -1774,10 +1775,10 @@ body {
|
|
|
1774
1775
|
}
|
|
1775
1776
|
.agent-status-running { color: var(--green); }
|
|
1776
1777
|
.agent-status-spawning { color: var(--yellow); }
|
|
1777
|
-
.agent-status-exited { color: var(--text-faint); }
|
|
1778
|
+
.agent-status-exited { color: var(--text-faint); } /* displayed as 'shutdown' */
|
|
1779
|
+
.agent-status-killed { color: var(--text-faint); } /* displayed as 'shutdown' */
|
|
1778
1780
|
.agent-status-crashed { color: var(--red); }
|
|
1779
|
-
.agent-status-
|
|
1780
|
-
.agent-status-timed_out { color: var(--yellow); }
|
|
1781
|
+
.agent-status-timed_out { color: var(--red); }
|
|
1781
1782
|
|
|
1782
1783
|
/* TP-107: Messages Panel */
|
|
1783
1784
|
.messages-list {
|
package/dashboard/server.cjs
CHANGED
|
@@ -194,9 +194,21 @@ function parseStatusMd(taskFolder) {
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
function getActiveSessions() {
|
|
197
|
-
// Runtime V2
|
|
198
|
-
//
|
|
199
|
-
|
|
197
|
+
// Runtime V2: return active merger session names from the runtime registry
|
|
198
|
+
// so the dashboard merge pane can display live telemetry for running agents.
|
|
199
|
+
// Terminal statuses indicate the agent is no longer alive.
|
|
200
|
+
const TERMINAL_STATUSES = new Set(["exited", "killed", "crashed", "timed_out"]);
|
|
201
|
+
try {
|
|
202
|
+
const state = loadBatchState();
|
|
203
|
+
if (!state || !state.batchId) return [];
|
|
204
|
+
const registry = loadRuntimeRegistry(state.batchId);
|
|
205
|
+
if (!registry || !registry.agents) return [];
|
|
206
|
+
return Object.values(registry.agents)
|
|
207
|
+
.filter(a => a.role === "merger" && !TERMINAL_STATUSES.has(a.status))
|
|
208
|
+
.map(a => a.agentId);
|
|
209
|
+
} catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
function checkDoneFile(taskFolder) {
|
|
@@ -406,6 +418,33 @@ function loadRuntimeLaneSnapshots(batchId) {
|
|
|
406
418
|
return snapshots;
|
|
407
419
|
}
|
|
408
420
|
|
|
421
|
+
/**
|
|
422
|
+
* Load Runtime V2 merge agent snapshots for the current batch.
|
|
423
|
+
*
|
|
424
|
+
* Reads all `merge-N.json` files from `.pi/runtime/{batchId}/lanes/`.
|
|
425
|
+
* Returns a map of mergeNumber (string) → snapshot data.
|
|
426
|
+
*
|
|
427
|
+
* Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
|
|
428
|
+
*
|
|
429
|
+
* @since TP-164
|
|
430
|
+
*/
|
|
431
|
+
function loadRuntimeMergeSnapshots(batchId) {
|
|
432
|
+
if (!batchId) return {};
|
|
433
|
+
const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
|
|
434
|
+
const snapshots = {};
|
|
435
|
+
try {
|
|
436
|
+
if (!fs.existsSync(lanesDir)) return snapshots;
|
|
437
|
+
const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("merge-") && f.endsWith(".json"));
|
|
438
|
+
for (const file of files) {
|
|
439
|
+
try {
|
|
440
|
+
const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
|
|
441
|
+
if (data.mergeNumber != null) snapshots[data.mergeNumber] = data;
|
|
442
|
+
} catch { continue; }
|
|
443
|
+
}
|
|
444
|
+
} catch { /* dir missing */ }
|
|
445
|
+
return snapshots;
|
|
446
|
+
}
|
|
447
|
+
|
|
409
448
|
/**
|
|
410
449
|
* Load Runtime V2 agent events for a specific agent.
|
|
411
450
|
* Returns the last N events from the agent's events.jsonl.
|
|
@@ -1077,6 +1116,46 @@ function buildDashboardState() {
|
|
|
1077
1116
|
const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
|
|
1078
1117
|
const mailboxData = loadMailboxData(state.batchId);
|
|
1079
1118
|
|
|
1119
|
+
// TP-164: Load merge agent snapshots for live dashboard telemetry.
|
|
1120
|
+
const runtimeMergeSnapshots = loadRuntimeMergeSnapshots(state.batchId);
|
|
1121
|
+
|
|
1122
|
+
// TP-164: Inject merge snapshot telemetry into the telemetry map so
|
|
1123
|
+
// `telemetry[sessionName]` resolves for the merge pane.
|
|
1124
|
+
// Only inject when a JSONL-backed entry is absent or stale — the snapshot
|
|
1125
|
+
// provides real-time tool-call / cost / context data even before the
|
|
1126
|
+
// JSONL accumulator has accumulated enough events.
|
|
1127
|
+
for (const snap of Object.values(runtimeMergeSnapshots)) {
|
|
1128
|
+
const key = snap.sessionName;
|
|
1129
|
+
if (!key) continue;
|
|
1130
|
+
const agent = snap.agent;
|
|
1131
|
+
if (!agent) continue;
|
|
1132
|
+
// Only inject if there is no existing telemetry entry, or if the snapshot
|
|
1133
|
+
// is more recent than the latest accumulator update.
|
|
1134
|
+
const existing = telemetry[key];
|
|
1135
|
+
if (!existing || (snap.updatedAt && snap.updatedAt > (existing._updatedAt || 0))) {
|
|
1136
|
+
telemetry[key] = {
|
|
1137
|
+
inputTokens: agent.inputTokens || 0,
|
|
1138
|
+
outputTokens: agent.outputTokens || 0,
|
|
1139
|
+
cacheReadTokens: agent.cacheReadTokens || 0,
|
|
1140
|
+
cacheWriteTokens: agent.cacheWriteTokens || 0,
|
|
1141
|
+
cost: agent.costUsd || 0,
|
|
1142
|
+
toolCalls: agent.toolCalls || 0,
|
|
1143
|
+
lastTool: agent.lastTool || "",
|
|
1144
|
+
currentTool: snap.status === "running" ? (agent.lastTool || "") : "",
|
|
1145
|
+
contextPct: agent.contextPct || 0,
|
|
1146
|
+
// startedAt is not in the snapshot; compute from elapsed if possible.
|
|
1147
|
+
startedAt: agent.elapsedMs > 0 ? snap.updatedAt - agent.elapsedMs : snap.updatedAt,
|
|
1148
|
+
retries: 0,
|
|
1149
|
+
retryActive: false,
|
|
1150
|
+
lastRetryError: "",
|
|
1151
|
+
compactions: 0,
|
|
1152
|
+
latestTotalTokens: (agent.inputTokens || 0) + (agent.outputTokens || 0),
|
|
1153
|
+
_updatedAt: snap.updatedAt,
|
|
1154
|
+
_source: "merge-snapshot",
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1080
1159
|
// TP-115: Synthesize laneStates from V2 snapshots so the dashboard
|
|
1081
1160
|
// pipeline works without legacy lane-state-*.json sidecar files.
|
|
1082
1161
|
// V2 snapshots are authoritative when present.
|
|
@@ -1099,6 +1178,8 @@ function buildDashboardState() {
|
|
|
1099
1178
|
// Runtime V2 data (null/empty for legacy batches)
|
|
1100
1179
|
runtimeRegistry,
|
|
1101
1180
|
runtimeLaneSnapshots,
|
|
1181
|
+
// TP-164: Merge agent snapshots for live dashboard telemetry.
|
|
1182
|
+
runtimeMergeSnapshots,
|
|
1102
1183
|
mailbox: mailboxData,
|
|
1103
1184
|
batch: {
|
|
1104
1185
|
batchId: state.batchId,
|
|
@@ -429,12 +429,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
429
429
|
// Pre-clean stale reviewer state from prior interrupted review
|
|
430
430
|
removeReviewerState(taskFolder);
|
|
431
431
|
return new Promise((resolve) => {
|
|
432
|
+
// Read reviewer config from env vars set by lane-runner from runnerConfig.reviewer.
|
|
433
|
+
// Empty string means inherit from session default (no flag passed to pi CLI).
|
|
434
|
+
const reviewerModel = process.env.TASKPLANE_REVIEWER_MODEL || "";
|
|
435
|
+
const reviewerThinking = process.env.TASKPLANE_REVIEWER_THINKING || "";
|
|
436
|
+
// Fall back to the schema default reviewer tool list (read-only + bash/grep).
|
|
437
|
+
// Must match config-schema.ts reviewer.tools default to avoid capability expansion.
|
|
438
|
+
const reviewerTools = process.env.TASKPLANE_REVIEWER_TOOLS || "read,bash,grep,find,ls";
|
|
439
|
+
|
|
432
440
|
const cliPath = resolvePiCliPath();
|
|
433
441
|
const args = [
|
|
434
442
|
cliPath, "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills",
|
|
435
|
-
"--tools",
|
|
443
|
+
"--tools", reviewerTools,
|
|
436
444
|
"--system-prompt", systemPrompt,
|
|
437
445
|
];
|
|
446
|
+
if (reviewerModel) args.push("--model", reviewerModel);
|
|
447
|
+
if (reviewerThinking) args.push("--thinking", reviewerThinking);
|
|
438
448
|
const proc = nodeSpawn(process.execPath, args, {
|
|
439
449
|
shell: false,
|
|
440
450
|
cwd,
|
|
@@ -561,8 +561,8 @@ export function spawnAgent(
|
|
|
561
561
|
case "tool_execution_start": {
|
|
562
562
|
toolCalls++;
|
|
563
563
|
const toolName = event.toolName || "tool";
|
|
564
|
-
const argPreview = typeof event.args === "string" ? event.args.slice(0,
|
|
565
|
-
(event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0,
|
|
564
|
+
const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
|
|
565
|
+
(event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
|
|
566
566
|
lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
|
|
567
567
|
// TP-111: Bounded payload only — no raw args in durable event log
|
|
568
568
|
const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
|
|
@@ -1177,6 +1177,11 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
|
|
|
1177
1177
|
reference_docs: { ...config.taskRunner.referenceDocs },
|
|
1178
1178
|
...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
|
|
1179
1179
|
model_fallback: config.taskRunner.modelFallback ?? "inherit",
|
|
1180
|
+
reviewer: {
|
|
1181
|
+
model: config.taskRunner.reviewer.model,
|
|
1182
|
+
thinking: config.taskRunner.reviewer.thinking,
|
|
1183
|
+
tools: config.taskRunner.reviewer.tools,
|
|
1184
|
+
},
|
|
1180
1185
|
};
|
|
1181
1186
|
}
|
|
1182
1187
|
|
|
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "f
|
|
|
6
6
|
import { join, resolve } from "path";
|
|
7
7
|
|
|
8
8
|
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
9
|
-
import { computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents } from "./execution.ts";
|
|
9
|
+
import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents } from "./execution.ts";
|
|
10
10
|
import type { RuntimeBackend } from "./execution.ts";
|
|
11
11
|
import type { MonitorUpdateCallback } from "./execution.ts";
|
|
12
12
|
// classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
|
|
@@ -1307,7 +1307,7 @@ async function attemptWorkerCrashRetry(
|
|
|
1307
1307
|
retryPauseSignal,
|
|
1308
1308
|
wsRoot,
|
|
1309
1309
|
isWsMode,
|
|
1310
|
-
{ ORCH_BATCH_ID: batchState.batchId }, // TP-089: ensure mailbox works for retries
|
|
1310
|
+
{ ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) }, // TP-089: ensure mailbox works for retries
|
|
1311
1311
|
);
|
|
1312
1312
|
|
|
1313
1313
|
const retryOutcome = retryResult.tasks[0];
|
|
@@ -1566,7 +1566,7 @@ async function attemptModelFallbackRetry(
|
|
|
1566
1566
|
// Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
|
|
1567
1567
|
// the task-runner to use the session model instead of configured model.
|
|
1568
1568
|
// TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
|
|
1569
|
-
const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
|
|
1569
|
+
const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) };
|
|
1570
1570
|
const retryResult = await executeLaneV2(
|
|
1571
1571
|
retryLane,
|
|
1572
1572
|
orchConfig,
|
|
@@ -1706,6 +1706,7 @@ async function attemptStaleWorktreeRecovery(
|
|
|
1706
1706
|
runtimeBackend?: RuntimeBackend,
|
|
1707
1707
|
onSupervisorAlert?: SupervisorAlertCallback,
|
|
1708
1708
|
supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
|
|
1709
|
+
runnerConfig?: TaskRunnerConfig,
|
|
1709
1710
|
): Promise<WaveExecutionResult | null> {
|
|
1710
1711
|
// Only attempt recovery for ALLOC_WORKTREE_FAILED
|
|
1711
1712
|
if (!waveResult.allocationError || waveResult.allocationError.code !== "ALLOC_WORKTREE_FAILED") {
|
|
@@ -1808,6 +1809,11 @@ async function attemptStaleWorktreeRecovery(
|
|
|
1808
1809
|
runtimeBackend,
|
|
1809
1810
|
onSupervisorAlert,
|
|
1810
1811
|
supervisorAutonomy,
|
|
1812
|
+
{
|
|
1813
|
+
model: runnerConfig?.reviewer?.model || "",
|
|
1814
|
+
thinking: runnerConfig?.reviewer?.thinking || "",
|
|
1815
|
+
tools: runnerConfig?.reviewer?.tools || "",
|
|
1816
|
+
},
|
|
1811
1817
|
);
|
|
1812
1818
|
|
|
1813
1819
|
return retryResult;
|
|
@@ -2376,6 +2382,11 @@ export async function executeOrchBatch(
|
|
|
2376
2382
|
selectedBackend,
|
|
2377
2383
|
emitAlert,
|
|
2378
2384
|
supervisorAutonomy,
|
|
2385
|
+
{
|
|
2386
|
+
model: runnerConfig?.reviewer?.model || "",
|
|
2387
|
+
thinking: runnerConfig?.reviewer?.thinking || "",
|
|
2388
|
+
tools: runnerConfig?.reviewer?.tools || "",
|
|
2389
|
+
},
|
|
2379
2390
|
);
|
|
2380
2391
|
|
|
2381
2392
|
// ── TP-039: Tier 0 — Stale worktree recovery ────────────
|
|
@@ -2398,6 +2409,7 @@ export async function executeOrchBatch(
|
|
|
2398
2409
|
selectedBackend,
|
|
2399
2410
|
emitAlert,
|
|
2400
2411
|
supervisorAutonomy,
|
|
2412
|
+
runnerConfig,
|
|
2401
2413
|
);
|
|
2402
2414
|
if (retryResult) {
|
|
2403
2415
|
const staleRecovered = !retryResult.allocationError;
|
|
@@ -10,7 +10,7 @@ import { userInfo } from "os";
|
|
|
10
10
|
import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
|
|
11
11
|
import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig, ExecutionUnit, PacketPaths, RuntimeAgentId, RuntimeAgentRole, SupervisorAlertCallback } from "./types.ts";
|
|
12
12
|
import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
|
|
13
|
-
import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed } from "./process-registry.ts";
|
|
13
|
+
import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
|
|
14
14
|
import { allocateLanes } from "./waves.ts";
|
|
15
15
|
import { resolveOperatorId } from "./naming.ts";
|
|
16
16
|
import { runGit } from "./git.ts";
|
|
@@ -1138,11 +1138,15 @@ export async function monitorLanes(
|
|
|
1138
1138
|
if (registry) {
|
|
1139
1139
|
const orphans = detectOrphans(registry);
|
|
1140
1140
|
if (orphans.length > 0) {
|
|
1141
|
+
// Mark individual agent manifests as crashed
|
|
1141
1142
|
markOrphansCrashed(stateRootForRegistry ?? repoRoot, batchId, orphans);
|
|
1142
|
-
//
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1143
|
+
// Rebuild and write registry.json from the updated individual manifests.
|
|
1144
|
+
// markOrphansCrashed only updates per-agent files; registry.json is a
|
|
1145
|
+
// cached aggregate that must be explicitly rebuilt so readRegistrySnapshot()
|
|
1146
|
+
// and the dashboard see the crashed status within this poll cycle.
|
|
1147
|
+
const freshRegistry = buildRegistrySnapshot(stateRootForRegistry ?? repoRoot, batchId);
|
|
1148
|
+
writeRegistrySnapshot(stateRootForRegistry ?? repoRoot, freshRegistry);
|
|
1149
|
+
setV2LivenessRegistryCache(freshRegistry);
|
|
1146
1150
|
}
|
|
1147
1151
|
}
|
|
1148
1152
|
} catch {
|
|
@@ -1407,6 +1411,7 @@ export function ensureTaskFilesCommitted(
|
|
|
1407
1411
|
pending: Map<string, ParsedTask>,
|
|
1408
1412
|
repoRoot: string,
|
|
1409
1413
|
waveIndex: number,
|
|
1414
|
+
orchBranch?: string,
|
|
1410
1415
|
): void {
|
|
1411
1416
|
// Collect task folder paths for this wave
|
|
1412
1417
|
const foldersToCheck: { taskId: string; relPath: string }[] = [];
|
|
@@ -1473,6 +1478,121 @@ export function ensureTaskFilesCommitted(
|
|
|
1473
1478
|
folders: foldersToStage,
|
|
1474
1479
|
commit: commitResult.stdout.trim().split("\n")[0],
|
|
1475
1480
|
});
|
|
1481
|
+
|
|
1482
|
+
// Fast-forward (or merge) the orch branch to include the staging commit so
|
|
1483
|
+
// that worktrees—which branch from orchBranch—see the new task files and
|
|
1484
|
+
// workers can find their PROMPT.md / STATUS.md without an ENOENT crash.
|
|
1485
|
+
//
|
|
1486
|
+
// The orch branch was created from baseBranch before executeWave runs, so in
|
|
1487
|
+
// wave 1 it is always an ancestor of HEAD and a plain fast-forward applies.
|
|
1488
|
+
// In wave 2+ the orch branch may have advanced due to prior wave merges
|
|
1489
|
+
// (commits from worker worktrees merged back). In that case we create a merge
|
|
1490
|
+
// commit so the task files become visible without rewinding any wave history.
|
|
1491
|
+
//
|
|
1492
|
+
// Failure here is non-fatal: the commit already succeeded; if the ref update
|
|
1493
|
+
// fails the subsequent worktree allocation will produce a clear error.
|
|
1494
|
+
if (orchBranch) {
|
|
1495
|
+
try {
|
|
1496
|
+
const headRes = runGit(["rev-parse", "HEAD"], repoRoot);
|
|
1497
|
+
const orchTipRes = runGit(["rev-parse", `refs/heads/${orchBranch}`], repoRoot);
|
|
1498
|
+
|
|
1499
|
+
if (headRes.ok && orchTipRes.ok) {
|
|
1500
|
+
const newHead = headRes.stdout.trim();
|
|
1501
|
+
const orchTip = orchTipRes.stdout.trim();
|
|
1502
|
+
|
|
1503
|
+
// Check whether the orch branch tip is an ancestor of the new HEAD
|
|
1504
|
+
// (i.e., a fast-forward is safe and sufficient).
|
|
1505
|
+
const ancestorCheck = runGit(
|
|
1506
|
+
["merge-base", "--is-ancestor", orchTip, newHead],
|
|
1507
|
+
repoRoot,
|
|
1508
|
+
);
|
|
1509
|
+
|
|
1510
|
+
if (ancestorCheck.ok) {
|
|
1511
|
+
// FF case: orch branch is behind HEAD — move it forward.
|
|
1512
|
+
// Expected-old-sha semantics guard against concurrent ref moves.
|
|
1513
|
+
const ffResult = runGit(
|
|
1514
|
+
["update-ref", `refs/heads/${orchBranch}`, newHead, orchTip],
|
|
1515
|
+
repoRoot,
|
|
1516
|
+
);
|
|
1517
|
+
if (ffResult.ok) {
|
|
1518
|
+
execLog("wave", `W${waveIndex}`, `fast-forwarded orch branch to include staging commit`, {
|
|
1519
|
+
orchBranch,
|
|
1520
|
+
from: orchTip.slice(0, 8),
|
|
1521
|
+
to: newHead.slice(0, 8),
|
|
1522
|
+
});
|
|
1523
|
+
} else {
|
|
1524
|
+
execLog("wave", `W${waveIndex}`, `warning: failed to fast-forward orch branch (non-fatal)`, {
|
|
1525
|
+
orchBranch,
|
|
1526
|
+
error: ffResult.stderr,
|
|
1527
|
+
});
|
|
1528
|
+
}
|
|
1529
|
+
} else {
|
|
1530
|
+
// Non-FF case: orch branch has advanced due to prior wave merges.
|
|
1531
|
+
// Create a merge commit so the new task files become visible in
|
|
1532
|
+
// worktrees without discarding any accumulated wave history.
|
|
1533
|
+
// Requires git ≥ 2.38 for `merge-tree --write-tree`.
|
|
1534
|
+
const mergeTreeRes = runGit(
|
|
1535
|
+
["merge-tree", "--write-tree", orchTip, newHead],
|
|
1536
|
+
repoRoot,
|
|
1537
|
+
);
|
|
1538
|
+
if (mergeTreeRes.ok) {
|
|
1539
|
+
// First line of stdout is the merged tree SHA.
|
|
1540
|
+
// git merge-tree --write-tree exits 0 on clean merge, non-zero on conflicts.
|
|
1541
|
+
// Since it exited 0, the tree should be conflict-free, but validate
|
|
1542
|
+
// the SHA looks like a valid 40-hex OID before using it.
|
|
1543
|
+
const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0];
|
|
1544
|
+
if (!/^[0-9a-f]{40}$/i.test(mergedTree)) {
|
|
1545
|
+
execLog("wave", `W${waveIndex}`, `warning: merge-tree returned unexpected output (non-fatal)`, {
|
|
1546
|
+
orchBranch,
|
|
1547
|
+
output: mergedTree.slice(0, 60),
|
|
1548
|
+
});
|
|
1549
|
+
} else {
|
|
1550
|
+
const mergeCommitMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
|
|
1551
|
+
const commitTreeRes = runGit(
|
|
1552
|
+
["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeCommitMsg],
|
|
1553
|
+
repoRoot,
|
|
1554
|
+
);
|
|
1555
|
+
if (commitTreeRes.ok) {
|
|
1556
|
+
const mergeCommitSha = commitTreeRes.stdout.trim();
|
|
1557
|
+
const refUpdateRes = runGit(
|
|
1558
|
+
["update-ref", `refs/heads/${orchBranch}`, mergeCommitSha, orchTip],
|
|
1559
|
+
repoRoot,
|
|
1560
|
+
);
|
|
1561
|
+
if (refUpdateRes.ok) {
|
|
1562
|
+
execLog("wave", `W${waveIndex}`, `merged staging commit into orch branch (non-FF wave)`, {
|
|
1563
|
+
orchBranch,
|
|
1564
|
+
orchTip: orchTip.slice(0, 8),
|
|
1565
|
+
newHead: newHead.slice(0, 8),
|
|
1566
|
+
mergeCommit: mergeCommitSha.slice(0, 8),
|
|
1567
|
+
});
|
|
1568
|
+
} else {
|
|
1569
|
+
execLog("wave", `W${waveIndex}`, `warning: failed to update orch branch ref after merge-tree (non-fatal)`, {
|
|
1570
|
+
orchBranch,
|
|
1571
|
+
error: refUpdateRes.stderr,
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
} else {
|
|
1575
|
+
execLog("wave", `W${waveIndex}`, `warning: failed to create merge commit for orch branch (non-fatal)`, {
|
|
1576
|
+
orchBranch,
|
|
1577
|
+
error: commitTreeRes.stderr,
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
} // end valid tree SHA
|
|
1581
|
+
} else {
|
|
1582
|
+
execLog("wave", `W${waveIndex}`, `warning: failed to compute merge-tree for orch branch (non-fatal; requires git ≥ 2.38)`, {
|
|
1583
|
+
orchBranch,
|
|
1584
|
+
error: mergeTreeRes.stderr,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
} catch (refErr: unknown) {
|
|
1590
|
+
execLog("wave", `W${waveIndex}`, `warning: orch branch ref update threw unexpectedly (non-fatal)`, {
|
|
1591
|
+
orchBranch,
|
|
1592
|
+
error: refErr instanceof Error ? refErr.message : String(refErr),
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1476
1596
|
}
|
|
1477
1597
|
|
|
1478
1598
|
// ── Wave Execution Core ──────────────────────────────────────────────
|
|
@@ -1511,7 +1631,7 @@ export function ensureTaskFilesCommitted(
|
|
|
1511
1631
|
* @param batchId - Batch ID for naming
|
|
1512
1632
|
* @param pauseSignal - Shared pause signal (mutated by stop-wave policy)
|
|
1513
1633
|
* @param dependencyGraph - Dependency graph for computing transitive dependents
|
|
1514
|
-
* @param
|
|
1634
|
+
* @param orchBranch - Orch branch to base worktrees on (and to update after staging commits)
|
|
1515
1635
|
* @param onMonitorUpdate - Optional callback for dashboard updates during monitoring
|
|
1516
1636
|
* @param onLanesAllocated - Optional callback fired after lane allocation succeeds
|
|
1517
1637
|
* @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
|
|
@@ -1536,13 +1656,14 @@ export async function executeWave(
|
|
|
1536
1656
|
batchId: string,
|
|
1537
1657
|
pauseSignal: { paused: boolean },
|
|
1538
1658
|
dependencyGraph: DependencyGraph,
|
|
1539
|
-
|
|
1659
|
+
orchBranch: string,
|
|
1540
1660
|
onMonitorUpdate?: MonitorUpdateCallback,
|
|
1541
1661
|
onLanesAllocated?: (lanes: AllocatedLane[]) => void,
|
|
1542
1662
|
workspaceConfig?: WorkspaceConfig | null,
|
|
1543
1663
|
runtimeBackend?: RuntimeBackend,
|
|
1544
1664
|
onSupervisorAlert?: SupervisorAlertCallback,
|
|
1545
1665
|
supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
|
|
1666
|
+
reviewerConfig?: { model?: string; thinking?: string; tools?: string },
|
|
1546
1667
|
): Promise<WaveExecutionResult> {
|
|
1547
1668
|
const startedAt = Date.now();
|
|
1548
1669
|
const policy = config.failure.on_task_failure;
|
|
@@ -1557,8 +1678,10 @@ export async function executeWave(
|
|
|
1557
1678
|
// Task folders may contain untracked files (PROMPT.md, STATUS.md) that
|
|
1558
1679
|
// won't appear in worktrees unless committed. Stage and commit them now,
|
|
1559
1680
|
// before worktree creation, so workers can find their TASK_AUTOSTART paths.
|
|
1681
|
+
// Pass orchBranch so the staging commit is reflected in the orch branch
|
|
1682
|
+
// before worktrees are allocated from it.
|
|
1560
1683
|
try {
|
|
1561
|
-
ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex);
|
|
1684
|
+
ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex, orchBranch);
|
|
1562
1685
|
} catch (err: unknown) {
|
|
1563
1686
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1564
1687
|
execLog("wave", `W${waveIndex}`, `task file commit failed: ${errMsg}`);
|
|
@@ -1582,7 +1705,7 @@ export async function executeWave(
|
|
|
1582
1705
|
}
|
|
1583
1706
|
|
|
1584
1707
|
// ── Stage 1: Allocate lanes ──────────────────────────────────
|
|
1585
|
-
const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId,
|
|
1708
|
+
const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, orchBranch, workspaceConfig);
|
|
1586
1709
|
|
|
1587
1710
|
if (!allocResult.success) {
|
|
1588
1711
|
const errMsg = allocResult.error?.message || "Unknown allocation failure";
|
|
@@ -1647,6 +1770,7 @@ export async function executeWave(
|
|
|
1647
1770
|
executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
|
|
1648
1771
|
ORCH_BATCH_ID: batchId,
|
|
1649
1772
|
TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy,
|
|
1773
|
+
...buildReviewerEnv(reviewerConfig),
|
|
1650
1774
|
}, onSupervisorAlert),
|
|
1651
1775
|
);
|
|
1652
1776
|
|
|
@@ -2158,6 +2282,27 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
|
|
|
2158
2282
|
*
|
|
2159
2283
|
* @since TP-105
|
|
2160
2284
|
*/
|
|
2285
|
+
|
|
2286
|
+
/**
|
|
2287
|
+
* Build reviewer env vars from a TaskRunnerConfig or reviewer config object.
|
|
2288
|
+
* Used to ensure reviewer config is consistently passed to executeLaneV2
|
|
2289
|
+
* across all call sites (initial waves, resume, retries).
|
|
2290
|
+
*
|
|
2291
|
+
* Returns only the keys that have non-empty values, so that empty/inherit
|
|
2292
|
+
* config does not override inherited env vars from the parent process.
|
|
2293
|
+
*
|
|
2294
|
+
* @since TP-160
|
|
2295
|
+
*/
|
|
2296
|
+
export function buildReviewerEnv(
|
|
2297
|
+
reviewerConfig?: { model?: string; thinking?: string; tools?: string } | null,
|
|
2298
|
+
): Record<string, string> {
|
|
2299
|
+
const env: Record<string, string> = {};
|
|
2300
|
+
if (reviewerConfig?.model) env.TASKPLANE_REVIEWER_MODEL = reviewerConfig.model;
|
|
2301
|
+
if (reviewerConfig?.thinking) env.TASKPLANE_REVIEWER_THINKING = reviewerConfig.thinking;
|
|
2302
|
+
if (reviewerConfig?.tools) env.TASKPLANE_REVIEWER_TOOLS = reviewerConfig.tools;
|
|
2303
|
+
return env;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2161
2306
|
export async function executeLaneV2(
|
|
2162
2307
|
lane: AllocatedLane,
|
|
2163
2308
|
config: OrchestratorConfig,
|
|
@@ -2243,6 +2388,9 @@ export async function executeLaneV2(
|
|
|
2243
2388
|
workerTools: "read,write,edit,bash,grep,find,ls",
|
|
2244
2389
|
workerThinking: "",
|
|
2245
2390
|
workerSystemPrompt,
|
|
2391
|
+
reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
|
|
2392
|
+
reviewerThinking: extraEnvVars?.TASKPLANE_REVIEWER_THINKING || "",
|
|
2393
|
+
reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",
|
|
2246
2394
|
supervisorAutonomy,
|
|
2247
2395
|
projectName: config.project?.name || "project",
|
|
2248
2396
|
maxIterations: 20,
|
|
@@ -1920,8 +1920,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1920
1920
|
// "Discovery had fatal errors" on first /orch run after config creation.
|
|
1921
1921
|
// Skip if a batch is already active to avoid swapping config mid-run.
|
|
1922
1922
|
const _activePhase = orchBatchState.phase;
|
|
1923
|
+
// Treat paused as active — config must not change for a resumable batch
|
|
1923
1924
|
const _isActiveBatch = _activePhase === "executing" || _activePhase === "launching"
|
|
1924
|
-
|| _activePhase === "merging" || _activePhase === "planning";
|
|
1925
|
+
|| _activePhase === "merging" || _activePhase === "planning" || _activePhase === "paused";
|
|
1925
1926
|
if (!_isActiveBatch) {
|
|
1926
1927
|
try {
|
|
1927
1928
|
// Build everything into temporaries first, then commit atomically
|
|
@@ -92,6 +92,22 @@ export interface LaneRunnerConfig {
|
|
|
92
92
|
workerThinking: string;
|
|
93
93
|
/** Worker system prompt */
|
|
94
94
|
workerSystemPrompt: string;
|
|
95
|
+
/**
|
|
96
|
+
* Reviewer model (empty string = inherit session default).
|
|
97
|
+
* Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
|
|
98
|
+
* @since TP-160
|
|
99
|
+
*/
|
|
100
|
+
reviewerModel: string;
|
|
101
|
+
/**
|
|
102
|
+
* Reviewer thinking mode (empty string = inherit).
|
|
103
|
+
* @since TP-160
|
|
104
|
+
*/
|
|
105
|
+
reviewerThinking: string;
|
|
106
|
+
/**
|
|
107
|
+
* Reviewer tool allowlist (comma-separated).
|
|
108
|
+
* @since TP-160
|
|
109
|
+
*/
|
|
110
|
+
reviewerTools: string;
|
|
95
111
|
/** Supervisor autonomy level for bridge-tool guards. */
|
|
96
112
|
supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
|
|
97
113
|
/** Project name (for review request context) */
|
|
@@ -331,6 +347,9 @@ export async function executeTaskV2(
|
|
|
331
347
|
TASKPLANE_ACTIVE_SEGMENT_ID: segmentId ?? "",
|
|
332
348
|
TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
|
|
333
349
|
ORCH_BATCH_ID: config.batchId,
|
|
350
|
+
...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
|
|
351
|
+
...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
|
|
352
|
+
...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
|
|
334
353
|
},
|
|
335
354
|
};
|
|
336
355
|
|
|
@@ -10,9 +10,9 @@ import { join, dirname, resolve, relative } from "path";
|
|
|
10
10
|
import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
|
|
11
11
|
import { resolveOperatorId } from "./naming.ts";
|
|
12
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
|
-
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";
|
|
13
|
+
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase, RuntimeMergeSnapshot, RuntimeAgentTelemetrySnapshot } from "./types.ts";
|
|
14
14
|
import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
|
|
15
|
-
import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot } from "./process-registry.ts";
|
|
15
|
+
import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot, writeMergeSnapshot } 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";
|
|
@@ -20,7 +20,7 @@ import { emitEngineEvent } from "./persistence.ts";
|
|
|
20
20
|
import { loadOrchestratorConfig } from "./config.ts";
|
|
21
21
|
import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
|
|
22
22
|
import { spawnAgent } from "./agent-host.ts";
|
|
23
|
-
import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
|
|
23
|
+
import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
|
|
24
24
|
import type { RuntimeBackend } from "./execution.ts";
|
|
25
25
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
26
26
|
|
|
@@ -614,14 +614,69 @@ export async function spawnMergeAgentV2(
|
|
|
614
614
|
},
|
|
615
615
|
};
|
|
616
616
|
|
|
617
|
-
|
|
617
|
+
// Derive the 1-indexed merge number from the session name
|
|
618
|
+
// (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2).
|
|
619
|
+
const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/);
|
|
620
|
+
const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
|
|
621
|
+
const mergeStartedAt = Date.now();
|
|
622
|
+
const mergeStateRoot = stateRoot ?? repoRoot;
|
|
623
|
+
|
|
624
|
+
// Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
|
|
625
|
+
const buildAgentSnap = (tel: Partial<AgentHostResult>, status: RuntimeAgentTelemetrySnapshot["status"]): RuntimeAgentTelemetrySnapshot => ({
|
|
626
|
+
agentId: sessionName,
|
|
627
|
+
status,
|
|
628
|
+
elapsedMs: tel.durationMs ?? (Date.now() - mergeStartedAt),
|
|
629
|
+
toolCalls: tel.toolCalls ?? 0,
|
|
630
|
+
contextPct: tel.contextUsage?.percent ?? 0,
|
|
631
|
+
costUsd: tel.costUsd ?? 0,
|
|
632
|
+
lastTool: tel.lastTool ?? "",
|
|
633
|
+
inputTokens: tel.inputTokens ?? 0,
|
|
634
|
+
outputTokens: tel.outputTokens ?? 0,
|
|
635
|
+
cacheReadTokens: tel.cacheReadTokens ?? 0,
|
|
636
|
+
cacheWriteTokens: tel.cacheWriteTokens ?? 0,
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Telemetry callback: write a merge snapshot on every telemetry update.
|
|
640
|
+
// Non-fatal — snapshot writes must never interfere with merge execution.
|
|
641
|
+
const onMergeTelemetry: AgentTelemetryCallback = (tel) => {
|
|
642
|
+
try {
|
|
643
|
+
const snap: RuntimeMergeSnapshot = {
|
|
644
|
+
batchId: bid,
|
|
645
|
+
mergeNumber,
|
|
646
|
+
sessionName,
|
|
647
|
+
waveIndex: 0,
|
|
648
|
+
status: "running",
|
|
649
|
+
agent: buildAgentSnap(tel, "running"),
|
|
650
|
+
updatedAt: Date.now(),
|
|
651
|
+
};
|
|
652
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
653
|
+
} catch { /* non-fatal */ }
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry);
|
|
657
|
+
|
|
658
|
+
// Write an initial "running" snapshot immediately so the dashboard row
|
|
659
|
+
// appears even when the first telemetry event is delayed.
|
|
660
|
+
try {
|
|
661
|
+
const initialSnap: RuntimeMergeSnapshot = {
|
|
662
|
+
batchId: bid,
|
|
663
|
+
mergeNumber,
|
|
664
|
+
sessionName,
|
|
665
|
+
waveIndex: 0,
|
|
666
|
+
status: "running",
|
|
667
|
+
agent: buildAgentSnap({}, "running"),
|
|
668
|
+
updatedAt: Date.now(),
|
|
669
|
+
};
|
|
670
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
|
|
671
|
+
} catch { /* non-fatal */ }
|
|
618
672
|
|
|
619
673
|
// Store the kill handle for external cleanup (pause/abort).
|
|
620
674
|
// The promise runs in background — caller uses waitForMergeResult()
|
|
621
675
|
// to poll for the result file, same contract as the legacy session path.
|
|
622
|
-
activeMergeAgents.set(sessionName, { promise, kill, stateRoot:
|
|
676
|
+
activeMergeAgents.set(sessionName, { promise, kill, stateRoot: mergeStateRoot, batchId: bid });
|
|
623
677
|
|
|
624
|
-
// Fire-and-forget: the background promise handles exit logging
|
|
678
|
+
// Fire-and-forget: the background promise handles exit logging and
|
|
679
|
+
// writes a terminal snapshot ("complete" or "failed") when the agent exits.
|
|
625
680
|
promise.then(result => {
|
|
626
681
|
activeMergeAgents.delete(sessionName);
|
|
627
682
|
execLog("merge", sessionName, "merge agent exited (V2)", {
|
|
@@ -630,9 +685,39 @@ export async function spawnMergeAgentV2(
|
|
|
630
685
|
costUsd: result.costUsd,
|
|
631
686
|
killed: result.killed,
|
|
632
687
|
});
|
|
688
|
+
// Write terminal snapshot. Promise resolves for both successful and
|
|
689
|
+
// failed exits, so derive status from result fields rather than
|
|
690
|
+
// relying on .catch to handle failures.
|
|
691
|
+
const terminalStatus: RuntimeMergeSnapshot["status"] =
|
|
692
|
+
(result.killed || result.exitCode !== 0 || !result.agentEnded) ? "failed" : "complete";
|
|
693
|
+
try {
|
|
694
|
+
const snap: RuntimeMergeSnapshot = {
|
|
695
|
+
batchId: bid,
|
|
696
|
+
mergeNumber,
|
|
697
|
+
sessionName,
|
|
698
|
+
waveIndex: 0,
|
|
699
|
+
status: terminalStatus,
|
|
700
|
+
agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
|
|
701
|
+
updatedAt: Date.now(),
|
|
702
|
+
};
|
|
703
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
704
|
+
} catch { /* non-fatal */ }
|
|
633
705
|
}).catch(err => {
|
|
634
706
|
activeMergeAgents.delete(sessionName);
|
|
635
707
|
execLog("merge", sessionName, `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`);
|
|
708
|
+
// Write a failed terminal snapshot on unexpected rejection.
|
|
709
|
+
try {
|
|
710
|
+
const snap: RuntimeMergeSnapshot = {
|
|
711
|
+
batchId: bid,
|
|
712
|
+
mergeNumber,
|
|
713
|
+
sessionName,
|
|
714
|
+
waveIndex: 0,
|
|
715
|
+
status: "failed",
|
|
716
|
+
agent: buildAgentSnap({}, "crashed"),
|
|
717
|
+
updatedAt: Date.now(),
|
|
718
|
+
};
|
|
719
|
+
writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
|
|
720
|
+
} catch { /* non-fatal */ }
|
|
636
721
|
});
|
|
637
722
|
}
|
|
638
723
|
|
|
@@ -188,11 +188,15 @@ export function resolveTaskplanePackageFile(repoRoot: string, relPath: string):
|
|
|
188
188
|
candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", relPath));
|
|
189
189
|
candidates.push(join("/opt", "homebrew", "lib", "node_modules", "taskplane", relPath));
|
|
190
190
|
|
|
191
|
-
// 8. Peer of pi's package (look adjacent to pi's CLI entrypoint)
|
|
191
|
+
// 8. Peer of pi's package (look adjacent to pi's CLI entrypoint).
|
|
192
|
+
// pi is at: <npmRoot>/@mariozechner/pi-coding-agent/dist/cli.js
|
|
193
|
+
// so piPkgDir = <npmRoot>/@mariozechner/pi-coding-agent (resolve up 2 levels from cli.js)
|
|
194
|
+
// then go up TWO more levels to reach <npmRoot>, then into taskplane/
|
|
192
195
|
try {
|
|
193
196
|
const piPath = process.argv[1] || "";
|
|
194
|
-
const piPkgDir = resolve(piPath, "..", "..");
|
|
195
|
-
|
|
197
|
+
const piPkgDir = resolve(piPath, "..", ".."); // <npmRoot>/@mariozechner/pi-coding-agent
|
|
198
|
+
const npmRootFromPi = resolve(piPkgDir, "..", ".."); // <npmRoot>
|
|
199
|
+
candidates.push(join(npmRootFromPi, "taskplane", relPath));
|
|
196
200
|
} catch { /* ignore — process.argv[1] may be undefined in test contexts */ }
|
|
197
201
|
|
|
198
202
|
for (const candidate of candidates) {
|
|
@@ -30,12 +30,14 @@ import {
|
|
|
30
30
|
runtimeRegistryPath,
|
|
31
31
|
runtimeAgentEventsPath,
|
|
32
32
|
runtimeLaneSnapshotPath,
|
|
33
|
+
runtimeMergeSnapshotPath,
|
|
33
34
|
validateAgentManifest,
|
|
34
35
|
type RuntimeAgentId,
|
|
35
36
|
type RuntimeAgentManifest,
|
|
36
37
|
type RuntimeAgentRole,
|
|
37
38
|
type RuntimeAgentStatus,
|
|
38
39
|
type RuntimeRegistry,
|
|
40
|
+
type RuntimeMergeSnapshot,
|
|
39
41
|
type PacketPaths,
|
|
40
42
|
} from "./types.ts";
|
|
41
43
|
|
|
@@ -362,3 +364,53 @@ export function readLaneSnapshot(
|
|
|
362
364
|
return null;
|
|
363
365
|
}
|
|
364
366
|
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Write a V2 merge agent snapshot to disk (atomic rename).
|
|
370
|
+
*
|
|
371
|
+
* Stored in the `lanes/` directory alongside lane snapshots so the dashboard
|
|
372
|
+
* server picks it up with the same scan that reads lane-N.json files.
|
|
373
|
+
*
|
|
374
|
+
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
375
|
+
* @param batchId - Current batch identifier
|
|
376
|
+
* @param mergeNumber - 1-indexed merge agent number
|
|
377
|
+
* @param snapshot - Snapshot data to persist
|
|
378
|
+
*
|
|
379
|
+
* @since TP-164
|
|
380
|
+
*/
|
|
381
|
+
export function writeMergeSnapshot(
|
|
382
|
+
stateRoot: string,
|
|
383
|
+
batchId: string,
|
|
384
|
+
mergeNumber: number,
|
|
385
|
+
snapshot: RuntimeMergeSnapshot,
|
|
386
|
+
): void {
|
|
387
|
+
const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
388
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
389
|
+
const tmpPath = path + ".tmp";
|
|
390
|
+
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
|
|
391
|
+
renameSync(tmpPath, path);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Read a V2 merge agent snapshot from disk.
|
|
396
|
+
* Returns null if the file does not exist or is unreadable.
|
|
397
|
+
*
|
|
398
|
+
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
399
|
+
* @param batchId - Current batch identifier
|
|
400
|
+
* @param mergeNumber - 1-indexed merge agent number
|
|
401
|
+
*
|
|
402
|
+
* @since TP-164
|
|
403
|
+
*/
|
|
404
|
+
export function readMergeSnapshot(
|
|
405
|
+
stateRoot: string,
|
|
406
|
+
batchId: string,
|
|
407
|
+
mergeNumber: number,
|
|
408
|
+
): RuntimeMergeSnapshot | null {
|
|
409
|
+
try {
|
|
410
|
+
const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
|
|
411
|
+
if (!existsSync(p)) return null;
|
|
412
|
+
return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
@@ -8,7 +8,7 @@ import { join } from "path";
|
|
|
8
8
|
import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
|
|
9
9
|
import { runDiscovery } from "./discovery.ts";
|
|
10
10
|
import { executeOrchBatch } from "./engine.ts";
|
|
11
|
-
import { computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
|
|
11
|
+
import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
|
|
12
12
|
import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
|
|
13
13
|
import { selectRuntimeBackend } from "./engine.ts";
|
|
14
14
|
import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
|
|
@@ -1461,7 +1461,7 @@ export async function resumeOrchBatch(
|
|
|
1461
1461
|
const laneResult = await executeLaneV2(
|
|
1462
1462
|
lane, orchConfig, laneRepoRoot, batchState.pauseSignal,
|
|
1463
1463
|
workspaceRoot, !!workspaceConfig,
|
|
1464
|
-
{ ORCH_BATCH_ID: batchState.batchId },
|
|
1464
|
+
{ ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
|
|
1465
1465
|
emitAlert,
|
|
1466
1466
|
);
|
|
1467
1467
|
const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
|
|
@@ -1543,7 +1543,7 @@ export async function resumeOrchBatch(
|
|
|
1543
1543
|
const laneResult = await executeLaneV2(
|
|
1544
1544
|
lane, orchConfig, reExecRepoRoot, batchState.pauseSignal,
|
|
1545
1545
|
workspaceRoot, !!workspaceConfig,
|
|
1546
|
-
{ ORCH_BATCH_ID: batchState.batchId },
|
|
1546
|
+
{ ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
|
|
1547
1547
|
emitAlert,
|
|
1548
1548
|
);
|
|
1549
1549
|
const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
|
|
@@ -1994,6 +1994,7 @@ export async function resumeOrchBatch(
|
|
|
1994
1994
|
resumeBackend,
|
|
1995
1995
|
emitAlert,
|
|
1996
1996
|
supervisorAutonomy,
|
|
1997
|
+
runnerConfig.reviewer,
|
|
1997
1998
|
);
|
|
1998
1999
|
|
|
1999
2000
|
batchState.waveResults.push(waveResult);
|
|
@@ -291,6 +291,19 @@ export interface TaskRunnerConfig {
|
|
|
291
291
|
* @since TP-055
|
|
292
292
|
*/
|
|
293
293
|
model_fallback?: "inherit" | "fail";
|
|
294
|
+
/**
|
|
295
|
+
* Reviewer agent model/thinking/tools configuration.
|
|
296
|
+
* Threaded through to `spawnReviewer()` via env vars.
|
|
297
|
+
* @since TP-160
|
|
298
|
+
*/
|
|
299
|
+
reviewer?: {
|
|
300
|
+
/** Model string (empty = inherit session default) */
|
|
301
|
+
model: string;
|
|
302
|
+
/** Thinking mode ("on" | "off" | budget string, empty = inherit) */
|
|
303
|
+
thinking: string;
|
|
304
|
+
/** Comma-separated tool allowlist */
|
|
305
|
+
tools: string;
|
|
306
|
+
};
|
|
294
307
|
}
|
|
295
308
|
|
|
296
309
|
/** Result of a preflight check */
|
|
@@ -4062,6 +4075,50 @@ export function runtimeLaneSnapshotPath(stateRoot: string, batchId: string, lane
|
|
|
4062
4075
|
return `${stateRoot}/.pi/runtime/${batchId}/lanes/lane-${laneNumber}.json`;
|
|
4063
4076
|
}
|
|
4064
4077
|
|
|
4078
|
+
/**
|
|
4079
|
+
* Telemetry snapshot for a merge agent.
|
|
4080
|
+
*
|
|
4081
|
+
* Written to `.pi/runtime/{batchId}/lanes/merge-{mergeNumber}.json` alongside
|
|
4082
|
+
* lane snapshots so the dashboard can display live merge-phase telemetry.
|
|
4083
|
+
* Follows the same file-backed pattern as {@link RuntimeLaneSnapshot} but is
|
|
4084
|
+
* simpler — merge agents have no reviewer, progress tracking, or repoId.
|
|
4085
|
+
*
|
|
4086
|
+
* @since TP-164
|
|
4087
|
+
*/
|
|
4088
|
+
export interface RuntimeMergeSnapshot {
|
|
4089
|
+
/** Batch this merge agent belongs to */
|
|
4090
|
+
batchId: string;
|
|
4091
|
+
/** 1-indexed merge agent number (e.g. 1 for "orch-henry-merge-1") */
|
|
4092
|
+
mergeNumber: number;
|
|
4093
|
+
/** Stable agent session name (e.g. "orch-henry-merge-1") */
|
|
4094
|
+
sessionName: string;
|
|
4095
|
+
/** Wave index this merge agent is processing (0-indexed, 0 when unknown) */
|
|
4096
|
+
waveIndex: number;
|
|
4097
|
+
/** Merge agent lifecycle status */
|
|
4098
|
+
status: "running" | "complete" | "failed";
|
|
4099
|
+
/** Live telemetry snapshot for the merge agent (null when not yet started) */
|
|
4100
|
+
agent: RuntimeAgentTelemetrySnapshot | null;
|
|
4101
|
+
/** Epoch ms when this snapshot was last updated */
|
|
4102
|
+
updatedAt: number;
|
|
4103
|
+
}
|
|
4104
|
+
|
|
4105
|
+
/**
|
|
4106
|
+
* Resolve the path for a merge agent snapshot file.
|
|
4107
|
+
*
|
|
4108
|
+
* Snapshots are stored alongside lane snapshots in the `lanes/` directory so
|
|
4109
|
+
* the dashboard server's directory scan picks them up automatically.
|
|
4110
|
+
*
|
|
4111
|
+
* @param stateRoot - Repository root (where `.pi/` lives)
|
|
4112
|
+
* @param batchId - Current batch identifier
|
|
4113
|
+
* @param mergeNumber - 1-indexed merge agent number
|
|
4114
|
+
* @returns Absolute path to the merge snapshot JSON file
|
|
4115
|
+
*
|
|
4116
|
+
* @since TP-164
|
|
4117
|
+
*/
|
|
4118
|
+
export function runtimeMergeSnapshotPath(stateRoot: string, batchId: string, mergeNumber: number): string {
|
|
4119
|
+
return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`;
|
|
4120
|
+
}
|
|
4121
|
+
|
|
4065
4122
|
/**
|
|
4066
4123
|
* Resolve the path for the batch runtime registry.
|
|
4067
4124
|
*
|