taskplane 0.8.0 → 0.8.1
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 +40 -2
- package/dashboard/server.cjs +12 -4
- package/extensions/taskplane/execution.ts +153 -7
- package/extensions/taskplane/merge.ts +90 -20
- package/package.json +1 -1
package/dashboard/public/app.js
CHANGED
|
@@ -589,6 +589,7 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
589
589
|
const mergeResults = batch?.mergeResults || [];
|
|
590
590
|
const tmuxSet = new Set(tmuxSessions || []);
|
|
591
591
|
const showRepos = knownRepos.length >= 2;
|
|
592
|
+
const telemetry = currentData?.telemetry || {};
|
|
592
593
|
|
|
593
594
|
// Check for active merge sessions (convention: orch-merge-*)
|
|
594
595
|
const mergeSessions = (tmuxSessions || []).filter(s => s.startsWith("orch-merge"));
|
|
@@ -599,7 +600,7 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
599
600
|
}
|
|
600
601
|
|
|
601
602
|
let html = '<table class="merge-table"><thead><tr>';
|
|
602
|
-
html += '<th>Wave</th><th>Status</th><th>Session</th><th>Attach</th><th>Details</th>';
|
|
603
|
+
html += '<th>Wave</th><th>Status</th><th>Session</th><th>Telemetry</th><th>Attach</th><th>Details</th>';
|
|
603
604
|
html += '</tr></thead><tbody>';
|
|
604
605
|
|
|
605
606
|
// Show merge results
|
|
@@ -620,10 +621,29 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
620
621
|
const sessionName = `orch-merge-w${mr.waveIndex + 1}`;
|
|
621
622
|
const alive = tmuxSet.has(sessionName);
|
|
622
623
|
|
|
624
|
+
// Look for merge telemetry data
|
|
625
|
+
const mergeTel = telemetry[sessionName] || telemetry[`orch-merge-${mr.waveIndex + 1}`] || null;
|
|
626
|
+
|
|
623
627
|
html += `<tr>`;
|
|
624
628
|
html += `<td style="font-family:var(--font-mono);">Wave ${mr.waveIndex + 1}</td>`;
|
|
625
629
|
html += `<td><span class="status-badge ${statusCls}">${mr.status}</span></td>`;
|
|
626
630
|
html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${alive ? escapeHtml(sessionName) : "—"}</td>`;
|
|
631
|
+
// Telemetry cell
|
|
632
|
+
html += `<td style="font-size:0.75rem;">`;
|
|
633
|
+
if (mergeTel) {
|
|
634
|
+
const totalTok = (mergeTel.inputTokens || 0) + (mergeTel.outputTokens || 0);
|
|
635
|
+
const cost = mergeTel.cost || 0;
|
|
636
|
+
if (totalTok > 0 || cost > 0) {
|
|
637
|
+
html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
|
|
638
|
+
if (cost > 0) html += ` · $${cost.toFixed(4)}`;
|
|
639
|
+
html += `</span>`;
|
|
640
|
+
} else {
|
|
641
|
+
html += '<span style="color:var(--text-faint);">—</span>';
|
|
642
|
+
}
|
|
643
|
+
} else {
|
|
644
|
+
html += '<span style="color:var(--text-faint);">—</span>';
|
|
645
|
+
}
|
|
646
|
+
html += `</td>`;
|
|
627
647
|
html += `<td>`;
|
|
628
648
|
if (alive) {
|
|
629
649
|
const cmd = `tmux attach -t ${sessionName}`;
|
|
@@ -652,7 +672,8 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
652
672
|
html += `<td>${repoBadgeHtml(rr.repoId)}</td>`;
|
|
653
673
|
html += `<td><span class="status-badge ${rrStatusCls}">${rr.status}</span></td>`;
|
|
654
674
|
html += `<td style="font-family:var(--font-mono);font-size:0.75rem;color:var(--text-faint);">${rrLanes}</td>`;
|
|
655
|
-
html += `<td></td>`;
|
|
675
|
+
html += `<td></td>`; /* telemetry placeholder */
|
|
676
|
+
html += `<td></td>`; /* attach placeholder */
|
|
656
677
|
html += `<td style="font-size:0.75rem;color:var(--text-faint);">${rrDetail}</td>`;
|
|
657
678
|
html += `</tr>`;
|
|
658
679
|
}
|
|
@@ -664,11 +685,28 @@ function renderMergeAgents(batch, tmuxSessions) {
|
|
|
664
685
|
const alreadyShown = mergeResults.some((mr) => `orch-merge-w${mr.waveIndex + 1}` === sess);
|
|
665
686
|
if (alreadyShown) continue;
|
|
666
687
|
|
|
688
|
+
const sessTel = telemetry[sess] || null;
|
|
667
689
|
const cmd = `tmux attach -t ${sess}`;
|
|
668
690
|
html += `<tr>`;
|
|
669
691
|
html += `<td style="font-family:var(--font-mono);">—</td>`;
|
|
670
692
|
html += `<td><span class="status-badge status-running"><span class="status-dot running"></span> merging</span></td>`;
|
|
671
693
|
html += `<td style="font-family:var(--font-mono);font-size:0.8rem;">${escapeHtml(sess)}</td>`;
|
|
694
|
+
// Telemetry cell for active merge session
|
|
695
|
+
html += `<td style="font-size:0.75rem;">`;
|
|
696
|
+
if (sessTel) {
|
|
697
|
+
const totalTok = (sessTel.inputTokens || 0) + (sessTel.outputTokens || 0);
|
|
698
|
+
const cost = sessTel.cost || 0;
|
|
699
|
+
if (totalTok > 0 || cost > 0) {
|
|
700
|
+
html += `<span style="color:var(--text-muted);">${totalTok > 0 ? totalTok.toLocaleString() + " tok" : ""}`;
|
|
701
|
+
if (cost > 0) html += ` · $${cost.toFixed(4)}`;
|
|
702
|
+
html += `</span>`;
|
|
703
|
+
} else {
|
|
704
|
+
html += '<span style="color:var(--text-faint);">—</span>';
|
|
705
|
+
}
|
|
706
|
+
} else {
|
|
707
|
+
html += '<span style="color:var(--text-faint);">—</span>';
|
|
708
|
+
}
|
|
709
|
+
html += `</td>`;
|
|
672
710
|
html += `<td><span class="tmux-cmd" data-tmux="${escapeHtml(sess)}" onclick="copyTmuxCmd('${escapeHtml(sess)}')" title="Click to copy">${escapeHtml(cmd)}</span></td>`;
|
|
673
711
|
html += `<td>—</td>`;
|
|
674
712
|
html += `</tr>`;
|
package/dashboard/server.cjs
CHANGED
|
@@ -239,7 +239,8 @@ const telemetryPrefixFiles = new Map();
|
|
|
239
239
|
/**
|
|
240
240
|
* Parse a telemetry JSONL filename to extract lane number and role.
|
|
241
241
|
* Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.jsonl
|
|
242
|
-
*
|
|
242
|
+
* Roles: worker, reviewer, merger
|
|
243
|
+
* Returns { laneNumber: number|null, role: string, mergeNumber: number|null } or null if unparseable.
|
|
243
244
|
*/
|
|
244
245
|
function parseTelemetryFilename(filename) {
|
|
245
246
|
// Remove .jsonl extension
|
|
@@ -248,13 +249,17 @@ function parseTelemetryFilename(filename) {
|
|
|
248
249
|
const lastDash = base.lastIndexOf("-");
|
|
249
250
|
if (lastDash < 0) return null;
|
|
250
251
|
const role = base.slice(lastDash + 1);
|
|
251
|
-
if (role !== "worker" && role !== "reviewer") return null;
|
|
252
|
+
if (role !== "worker" && role !== "reviewer" && role !== "merger") return null;
|
|
252
253
|
|
|
253
254
|
// Extract lane number from -lane-{N}- pattern
|
|
254
255
|
const laneMatch = base.match(/-lane-(\d+)-/);
|
|
255
256
|
const laneNumber = laneMatch ? parseInt(laneMatch[1], 10) : null;
|
|
256
257
|
|
|
257
|
-
|
|
258
|
+
// Extract merge number from -merge-{N}- pattern (merge agents)
|
|
259
|
+
const mergeMatch = base.match(/-merge-(\d+)-/);
|
|
260
|
+
const mergeNumber = mergeMatch ? parseInt(mergeMatch[1], 10) : null;
|
|
261
|
+
|
|
262
|
+
return { laneNumber, role, mergeNumber };
|
|
258
263
|
}
|
|
259
264
|
|
|
260
265
|
/**
|
|
@@ -375,7 +380,10 @@ function loadTelemetryData(batchState) {
|
|
|
375
380
|
|
|
376
381
|
// Determine the key (tmux prefix)
|
|
377
382
|
let prefix;
|
|
378
|
-
if (parsed.
|
|
383
|
+
if (parsed.role === "merger") {
|
|
384
|
+
// Merge agent — key by merge session number or generic "merge"
|
|
385
|
+
prefix = parsed.mergeNumber != null ? `orch-merge-${parsed.mergeNumber}` : "orch-merge";
|
|
386
|
+
} else if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
|
|
379
387
|
prefix = laneToPrefix[parsed.laneNumber];
|
|
380
388
|
} else if (parsed.laneNumber != null) {
|
|
381
389
|
// Lane number found but no batch-state mapping — use heuristic
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Lane execution, monitoring, wave execution loop
|
|
3
3
|
* @module orch/execution
|
|
4
4
|
*/
|
|
5
|
-
import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync } from "fs";
|
|
5
|
+
import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync } from "fs";
|
|
6
6
|
import { spawnSync } from "child_process";
|
|
7
7
|
import { join, dirname, resolve, relative, delimiter as pathDelimiter } from "path";
|
|
8
|
+
import { tmpdir, userInfo } from "os";
|
|
8
9
|
|
|
9
10
|
import { DONE_GRACE_MS, EXECUTION_POLL_INTERVAL_MS, ExecutionError, SESSION_SPAWN_RETRY_MAX } from "./types.ts";
|
|
10
11
|
import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult, LaneMonitorSnapshot, LaneTaskOutcome, LaneTaskStatus, MonitorState, MtimeTracker, OrchestratorConfig, ParsedTask, TaskMonitorSnapshot, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
@@ -58,6 +59,108 @@ function resolveTaskRunnerExtensionPath(repoRoot: string): string {
|
|
|
58
59
|
return localPath;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// ── RPC Wrapper Path Resolution ──────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Find the rpc-wrapper.mjs path for lane sessions.
|
|
66
|
+
*
|
|
67
|
+
* Resolution order mirrors resolveTaskRunnerExtensionPath:
|
|
68
|
+
* 1. Local project: {repoRoot}/bin/rpc-wrapper.mjs (for taskplane dev)
|
|
69
|
+
* 2. Global npm (Windows): {APPDATA}/npm/node_modules/taskplane/bin/rpc-wrapper.mjs
|
|
70
|
+
* 3. Global npm (Unix): /usr/local/lib/node_modules/taskplane/bin/rpc-wrapper.mjs
|
|
71
|
+
* 4. npm peer: resolve from pi's location
|
|
72
|
+
*
|
|
73
|
+
* @throws ExecutionError if rpc-wrapper.mjs cannot be found anywhere
|
|
74
|
+
*/
|
|
75
|
+
export function resolveRpcWrapperPath(repoRoot: string): string {
|
|
76
|
+
const wrapperFile = join("bin", "rpc-wrapper.mjs");
|
|
77
|
+
|
|
78
|
+
// 1. Local project (taskplane development)
|
|
79
|
+
const localPath = join(resolve(repoRoot), wrapperFile);
|
|
80
|
+
if (existsSync(localPath)) return localPath;
|
|
81
|
+
|
|
82
|
+
// 2. Global npm install paths
|
|
83
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
84
|
+
const candidates: string[] = [];
|
|
85
|
+
if (process.env.APPDATA) {
|
|
86
|
+
candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", wrapperFile));
|
|
87
|
+
}
|
|
88
|
+
if (home) {
|
|
89
|
+
candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane", wrapperFile));
|
|
90
|
+
candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane", wrapperFile));
|
|
91
|
+
}
|
|
92
|
+
candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", wrapperFile));
|
|
93
|
+
|
|
94
|
+
// 3. Peer of pi's package
|
|
95
|
+
try {
|
|
96
|
+
const piPath = process.argv[1] || "";
|
|
97
|
+
const piPkgDir = resolve(piPath, "..", "..");
|
|
98
|
+
candidates.push(join(piPkgDir, "..", "taskplane", wrapperFile));
|
|
99
|
+
} catch { /* ignore */ }
|
|
100
|
+
|
|
101
|
+
for (const candidate of candidates) {
|
|
102
|
+
if (existsSync(candidate)) return candidate;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Fallback: return the local path (will fail at spawn time with a clear error)
|
|
106
|
+
return localPath;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Telemetry Path Generation ────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate telemetry file paths for a lane session.
|
|
113
|
+
*
|
|
114
|
+
* Naming contract from resilience roadmap:
|
|
115
|
+
* .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
|
|
116
|
+
*
|
|
117
|
+
* @param sessionName - TMUX session name (e.g., "orch-lane-1")
|
|
118
|
+
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi or <repo>/.pi)
|
|
119
|
+
* @param taskId - Task identifier (e.g., "TP-049")
|
|
120
|
+
* @returns { sidecarPath, exitSummaryPath, telemetryDir }
|
|
121
|
+
*/
|
|
122
|
+
export function generateTelemetryPaths(
|
|
123
|
+
sessionName: string,
|
|
124
|
+
sidecarRoot: string,
|
|
125
|
+
taskId?: string,
|
|
126
|
+
): { sidecarPath: string; exitSummaryPath: string; telemetryDir: string } {
|
|
127
|
+
const telemetryTs = Date.now();
|
|
128
|
+
|
|
129
|
+
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
130
|
+
let opId = "op";
|
|
131
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
132
|
+
if (envOpId?.trim()) {
|
|
133
|
+
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
134
|
+
} else {
|
|
135
|
+
try {
|
|
136
|
+
const username = userInfo().username;
|
|
137
|
+
if (username?.trim()) {
|
|
138
|
+
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
139
|
+
}
|
|
140
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const batchId = String(telemetryTs);
|
|
144
|
+
const repoId = "default";
|
|
145
|
+
|
|
146
|
+
// Extract role from sessionName — lane sessions are "worker" role
|
|
147
|
+
const role = "worker";
|
|
148
|
+
const laneMatch = sessionName.match(/lane-(\d+)/);
|
|
149
|
+
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
150
|
+
|
|
151
|
+
// Include taskId when available
|
|
152
|
+
const taskIdSegment = taskId
|
|
153
|
+
? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
154
|
+
: "";
|
|
155
|
+
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
156
|
+
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
157
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
158
|
+
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
159
|
+
const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
|
|
160
|
+
|
|
161
|
+
return { sidecarPath, exitSummaryPath, telemetryDir };
|
|
162
|
+
}
|
|
163
|
+
|
|
61
164
|
// ── Execution Helpers ────────────────────────────────────────────────
|
|
62
165
|
|
|
63
166
|
/**
|
|
@@ -231,7 +334,12 @@ export function toTmuxPath(pathValue: string): string {
|
|
|
231
334
|
*
|
|
232
335
|
* Constructs a properly escaped command that:
|
|
233
336
|
* 1. Sets env vars (TASK_AUTOSTART, TASK_RUNNER_SPAWN_MODE, TASK_RUNNER_TMUX_PREFIX)
|
|
234
|
-
* 2. Runs `
|
|
337
|
+
* 2. Runs `node rpc-wrapper.mjs` to spawn pi with the task-runner extension,
|
|
338
|
+
* producing structured telemetry (sidecar JSONL + exit summary JSON).
|
|
339
|
+
*
|
|
340
|
+
* The RPC wrapper spawns pi in RPC mode with the task-runner extension loaded.
|
|
341
|
+
* The extension's TASK_AUTOSTART env var triggers task execution on init.
|
|
342
|
+
* A minimal prompt file is created to satisfy the wrapper's --prompt-file requirement.
|
|
235
343
|
*
|
|
236
344
|
* Shell escaping: env var values are single-quoted to prevent expansion.
|
|
237
345
|
* Path args are single-quoted to handle spaces and special characters.
|
|
@@ -241,6 +349,8 @@ export function toTmuxPath(pathValue: string): string {
|
|
|
241
349
|
* @param repoRoot - Absolute path to main repo (for extension absolute path)
|
|
242
350
|
* @param envVars - Environment variables to set
|
|
243
351
|
* @param laneLogPath - Optional path to write lane session stdout/stderr
|
|
352
|
+
* @param sidecarPath - Path for RPC telemetry sidecar JSONL file
|
|
353
|
+
* @param exitSummaryPath - Path for RPC telemetry exit summary JSON file
|
|
244
354
|
* @returns Array of arguments for spawnSync("tmux", args)
|
|
245
355
|
*/
|
|
246
356
|
export function buildTmuxSpawnArgs(
|
|
@@ -249,6 +359,8 @@ export function buildTmuxSpawnArgs(
|
|
|
249
359
|
repoRoot: string,
|
|
250
360
|
envVars: Record<string, string>,
|
|
251
361
|
laneLogPath?: string,
|
|
362
|
+
sidecarPath?: string,
|
|
363
|
+
exitSummaryPath?: string,
|
|
252
364
|
): string[] {
|
|
253
365
|
// Shell-quote a value for safe embedding in a command string.
|
|
254
366
|
// Wraps in single quotes, escaping any internal single quotes.
|
|
@@ -260,18 +372,44 @@ export function buildTmuxSpawnArgs(
|
|
|
260
372
|
};
|
|
261
373
|
|
|
262
374
|
// Build the command string that runs inside the TMUX session.
|
|
263
|
-
// Format: ENV_VAR1=value1 ENV_VAR2=value2 pi --no-session -e extensions/task-runner.ts
|
|
264
375
|
const envParts = Object.entries(envVars)
|
|
265
376
|
.map(([key, val]) => `${key}=${shellQuote(val)}`)
|
|
266
377
|
.join(" ");
|
|
267
378
|
|
|
268
379
|
const taskRunnerExtPath = resolveTaskRunnerExtensionPath(repoRoot);
|
|
269
|
-
|
|
380
|
+
|
|
381
|
+
let piCommand: string;
|
|
382
|
+
|
|
383
|
+
if (sidecarPath && exitSummaryPath) {
|
|
384
|
+
// ── RPC Wrapper mode: structured telemetry ──────────────
|
|
385
|
+
// Spawn `node rpc-wrapper.mjs` instead of `pi` directly.
|
|
386
|
+
// The wrapper runs pi in RPC mode, captures telemetry to
|
|
387
|
+
// sidecar JSONL, and writes exit summary on process exit.
|
|
388
|
+
const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
|
|
389
|
+
|
|
390
|
+
// Create a minimal prompt file for the RPC wrapper.
|
|
391
|
+
// The task-runner extension handles execution via TASK_AUTOSTART;
|
|
392
|
+
// this prompt satisfies the wrapper's --prompt-file requirement.
|
|
393
|
+
const promptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
394
|
+
const promptTmpFile = join(tmpdir(), `pi-lane-prompt-${promptId}.txt`);
|
|
395
|
+
writeFileSync(promptTmpFile, "Execute the task as configured by the task-runner extension.");
|
|
396
|
+
|
|
397
|
+
piCommand = [
|
|
398
|
+
envParts,
|
|
399
|
+
"node", shellQuote(rpcWrapperPath),
|
|
400
|
+
"--sidecar-path", shellQuote(sidecarPath),
|
|
401
|
+
"--exit-summary-path", shellQuote(exitSummaryPath),
|
|
402
|
+
"--prompt-file", shellQuote(promptTmpFile),
|
|
403
|
+
"--extensions", shellQuote(taskRunnerExtPath),
|
|
404
|
+
].filter(Boolean).join(" ");
|
|
405
|
+
} else {
|
|
406
|
+
// ── Legacy mode: direct pi spawn (no telemetry) ─────────
|
|
407
|
+
piCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
|
|
408
|
+
}
|
|
270
409
|
|
|
271
410
|
// NOTE: Do not redirect lane output here. Shell redirection has proven
|
|
272
411
|
// fragile across Windows + tmux environments and can prevent session spawn.
|
|
273
412
|
// Diagnostics use tmux pane capture + STATUS tail in pollUntilTaskComplete().
|
|
274
|
-
const piCommand = basePiCommand;
|
|
275
413
|
|
|
276
414
|
const tmuxWorktreePath = toTmuxPath(worktreePath);
|
|
277
415
|
const wrappedCommand = `cd ${shellQuote(tmuxWorktreePath)} && ${piCommand}`;
|
|
@@ -550,8 +688,16 @@ export function spawnLaneSession(
|
|
|
550
688
|
// Best effort — session can still run without log file setup
|
|
551
689
|
}
|
|
552
690
|
|
|
553
|
-
//
|
|
554
|
-
const
|
|
691
|
+
// Generate telemetry file paths for RPC wrapper sidecar
|
|
692
|
+
const sidecarRoot = join(workspaceRoot || repoRoot, ".pi");
|
|
693
|
+
const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId);
|
|
694
|
+
execLog(laneId, task.taskId, "telemetry paths generated", {
|
|
695
|
+
sidecar: telemetry.sidecarPath,
|
|
696
|
+
exitSummary: telemetry.exitSummaryPath,
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Build tmux args (with RPC wrapper telemetry)
|
|
700
|
+
const tmuxArgs = buildTmuxSpawnArgs(sessionName, lane.worktreePath, repoRoot, envVars, laneLogRelativePath, telemetry.sidecarPath, telemetry.exitSummaryPath);
|
|
555
701
|
|
|
556
702
|
// Clean up stale session if exists
|
|
557
703
|
if (tmuxHasSession(sessionName)) {
|
|
@@ -6,7 +6,7 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdi
|
|
|
6
6
|
import { execSync, spawnSync } from "child_process";
|
|
7
7
|
import { join, dirname, resolve, relative } from "path";
|
|
8
8
|
|
|
9
|
-
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
9
|
+
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
10
|
import { resolveOperatorId } from "./naming.ts";
|
|
11
11
|
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
|
|
12
12
|
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
|
|
@@ -18,6 +18,58 @@ import { loadOrchestratorConfig } from "./config.ts";
|
|
|
18
18
|
import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
|
|
19
19
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
20
20
|
|
|
21
|
+
// ── Merge Telemetry Helpers ───────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
import { userInfo } from "os";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generate telemetry file paths for a merge agent session.
|
|
27
|
+
*
|
|
28
|
+
* Naming: {opId}-{batchId}-{repoId}[-merge-w{N}-lane-{N}]-merger.{ext}
|
|
29
|
+
* Role is always "merger" to distinguish from worker/reviewer in the dashboard.
|
|
30
|
+
*
|
|
31
|
+
* @param sessionName - TMUX session name (e.g., "orch-merge-1")
|
|
32
|
+
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi)
|
|
33
|
+
* @returns { sidecarPath, exitSummaryPath }
|
|
34
|
+
*/
|
|
35
|
+
function generateMergeTelemetryPaths(
|
|
36
|
+
sessionName: string,
|
|
37
|
+
sidecarRoot: string,
|
|
38
|
+
): { sidecarPath: string; exitSummaryPath: string } {
|
|
39
|
+
const telemetryTs = Date.now();
|
|
40
|
+
|
|
41
|
+
// Resolve opId: same priority chain as execution.ts
|
|
42
|
+
let opId = "op";
|
|
43
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
44
|
+
if (envOpId?.trim()) {
|
|
45
|
+
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
46
|
+
} else {
|
|
47
|
+
try {
|
|
48
|
+
const username = userInfo().username;
|
|
49
|
+
if (username?.trim()) {
|
|
50
|
+
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
51
|
+
}
|
|
52
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const batchId = String(telemetryTs);
|
|
56
|
+
const repoId = "default";
|
|
57
|
+
|
|
58
|
+
// Extract merge-specific info from sessionName (e.g., "orch-merge-1")
|
|
59
|
+
const mergeMatch = sessionName.match(/merge-(\d+)/);
|
|
60
|
+
const mergeSuffix = mergeMatch ? `-merge-${mergeMatch[1]}` : "";
|
|
61
|
+
|
|
62
|
+
const role = "merger";
|
|
63
|
+
const telemetryBasename = `${opId}-${batchId}-${repoId}${mergeSuffix}-${role}`;
|
|
64
|
+
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
65
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
|
|
69
|
+
exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
21
73
|
// ── Merge Implementation ─────────────────────────────────────────────
|
|
22
74
|
|
|
23
75
|
/**
|
|
@@ -374,10 +426,10 @@ export async function spawnMergeAgent(
|
|
|
374
426
|
await sleepAsync(500);
|
|
375
427
|
}
|
|
376
428
|
|
|
377
|
-
// Build the
|
|
378
|
-
// Uses
|
|
379
|
-
//
|
|
380
|
-
// The merge request
|
|
429
|
+
// Build the merge agent command.
|
|
430
|
+
// Uses rpc-wrapper.mjs to produce structured telemetry (sidecar JSONL + exit summary).
|
|
431
|
+
// The merger agent definition is loaded via --system-prompt-file.
|
|
432
|
+
// The merge request is passed as the --prompt-file.
|
|
381
433
|
const shellQuote = (s: string): string => {
|
|
382
434
|
if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
|
|
383
435
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
@@ -385,25 +437,43 @@ export async function spawnMergeAgent(
|
|
|
385
437
|
return s;
|
|
386
438
|
};
|
|
387
439
|
|
|
388
|
-
//
|
|
389
|
-
|
|
440
|
+
// Generate telemetry paths for this merge session
|
|
441
|
+
// Naming: {opId}-{batchId}-{repoId}-merger.jsonl (or with wave/lane info from sessionName)
|
|
442
|
+
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
443
|
+
const telemetry = generateMergeTelemetryPaths(sessionName, sidecarRoot);
|
|
444
|
+
execLog("merge", sessionName, "telemetry paths generated", {
|
|
445
|
+
sidecar: telemetry.sidecarPath,
|
|
446
|
+
exitSummary: telemetry.exitSummaryPath,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// Resolve paths
|
|
450
|
+
const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
|
|
451
|
+
const systemPromptPath = agentRoot ? join(agentRoot, "task-merger.md") : join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md");
|
|
452
|
+
|
|
453
|
+
// Build RPC wrapper command
|
|
454
|
+
const wrapperParts = [
|
|
455
|
+
"TERM=xterm-256color",
|
|
456
|
+
"node", shellQuote(rpcWrapperPath),
|
|
457
|
+
"--sidecar-path", shellQuote(telemetry.sidecarPath),
|
|
458
|
+
"--exit-summary-path", shellQuote(telemetry.exitSummaryPath),
|
|
459
|
+
"--prompt-file", shellQuote(mergeRequestPath),
|
|
460
|
+
"--system-prompt-file", shellQuote(systemPromptPath),
|
|
461
|
+
];
|
|
390
462
|
|
|
391
|
-
//
|
|
392
|
-
|
|
463
|
+
// Add model args if specified
|
|
464
|
+
if (config.merge.model) {
|
|
465
|
+
wrapperParts.push("--model", shellQuote(config.merge.model));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Add tools override if specified
|
|
469
|
+
if (config.merge.tools) {
|
|
470
|
+
wrapperParts.push("--tools", shellQuote(config.merge.tools));
|
|
471
|
+
}
|
|
393
472
|
|
|
394
|
-
const piCommand =
|
|
395
|
-
"pi --no-session",
|
|
396
|
-
modelArgs,
|
|
397
|
-
toolsArgs,
|
|
398
|
-
`--append-system-prompt ${shellQuote(agentRoot ? join(agentRoot, "task-merger.md") : join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"))}`,
|
|
399
|
-
`@${shellQuote(mergeRequestPath)}`,
|
|
400
|
-
].filter(Boolean).join(" ");
|
|
473
|
+
const piCommand = wrapperParts.filter(Boolean).join(" ");
|
|
401
474
|
|
|
402
475
|
const tmuxMergeDir = toTmuxPath(mergeWorkDir);
|
|
403
|
-
|
|
404
|
-
// Force xterm-256color so pi can render and start execution.
|
|
405
|
-
// Same fix as buildTmuxSpawnArgs / buildLaneEnvVars.
|
|
406
|
-
const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && TERM=xterm-256color ${piCommand}`;
|
|
476
|
+
const wrappedCommand = `cd ${shellQuote(tmuxMergeDir)} && ${piCommand}`;
|
|
407
477
|
const tmuxArgs = [
|
|
408
478
|
"new-session", "-d",
|
|
409
479
|
"-s", sessionName,
|