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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, existsSync, statSync, unlinkSync, mkdirSync, writeFileSync, copyFileSync } from "fs";
|
|
6
6
|
import { access as fsAccess, readFile as fsReadFile, stat as fsStat } from "fs/promises";
|
|
7
|
-
import { spawnSync
|
|
7
|
+
import { spawnSync } from "child_process";
|
|
8
8
|
import { join, dirname, basename, resolve, relative, delimiter as pathDelimiter } from "path";
|
|
9
9
|
import { userInfo } from "os";
|
|
10
10
|
|
|
@@ -117,81 +117,15 @@ function resolveTaskRunnerExtensionPath(repoRoot: string): string {
|
|
|
117
117
|
* Find the rpc-wrapper.mjs path for lane sessions.
|
|
118
118
|
* @see resolveTaskplanePackageFile for resolution order
|
|
119
119
|
*/
|
|
120
|
-
|
|
121
|
-
return resolveTaskplanePackageFile(repoRoot, join("bin", "rpc-wrapper.mjs"));
|
|
122
|
-
}
|
|
120
|
+
// resolveRpcWrapperPath removed (TP-120 remediation: legacy session-backend dead code)
|
|
123
121
|
|
|
124
122
|
// ── Telemetry Helpers ────────────────────────────────────────────────
|
|
125
123
|
|
|
126
|
-
|
|
127
|
-
* Resolve the operator ID for telemetry filenames.
|
|
128
|
-
*
|
|
129
|
-
* Priority: TASKPLANE_OPERATOR_ID env → OS username → "op" fallback.
|
|
130
|
-
* Shared by lane and merge telemetry path generators to avoid divergence.
|
|
131
|
-
*/
|
|
132
|
-
export function resolveTelemOpId(): string {
|
|
133
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
134
|
-
if (envOpId?.trim()) {
|
|
135
|
-
return envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
136
|
-
}
|
|
137
|
-
try {
|
|
138
|
-
const username = userInfo().username;
|
|
139
|
-
if (username?.trim()) {
|
|
140
|
-
return username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
141
|
-
}
|
|
142
|
-
} catch { /* userInfo() can throw on some platforms */ }
|
|
143
|
-
return "op";
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Sanitize a string for use in telemetry filenames.
|
|
148
|
-
*/
|
|
149
|
-
function sanitizeForFilename(s: string, maxLen: number = 30): string {
|
|
150
|
-
return s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen);
|
|
151
|
-
}
|
|
124
|
+
// resolveTelemOpId removed (TP-120 remediation: only consumer was generateTelemetryPaths)
|
|
152
125
|
|
|
153
|
-
//
|
|
126
|
+
// sanitizeForFilename + generateTelemetryPaths removed (TP-120 remediation: legacy telemetry dead code)
|
|
154
127
|
|
|
155
|
-
|
|
156
|
-
* Generate telemetry file paths for a lane session.
|
|
157
|
-
*
|
|
158
|
-
* Naming contract from resilience roadmap:
|
|
159
|
-
* .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
|
|
160
|
-
*
|
|
161
|
-
* @param sessionName - TMUX session name (e.g., "orch-lane-1")
|
|
162
|
-
* @param sidecarRoot - Root dir for sidecar files (e.g., <workspace>/.pi or <repo>/.pi)
|
|
163
|
-
* @param taskId - Task identifier (e.g., "TP-049")
|
|
164
|
-
* @param batchId - Actual batch ID from batch state (falls back to timestamp)
|
|
165
|
-
* @param repoId - Repo ID for workspace mode (falls back to "default")
|
|
166
|
-
* @returns { sidecarPath, exitSummaryPath, telemetryDir }
|
|
167
|
-
*/
|
|
168
|
-
export function generateTelemetryPaths(
|
|
169
|
-
sessionName: string,
|
|
170
|
-
sidecarRoot: string,
|
|
171
|
-
taskId?: string,
|
|
172
|
-
batchId?: string,
|
|
173
|
-
repoId?: string,
|
|
174
|
-
): { sidecarPath: string; exitSummaryPath: string; telemetryDir: string } {
|
|
175
|
-
const opId = resolveTelemOpId();
|
|
176
|
-
const effectiveBatchId = batchId || String(Date.now());
|
|
177
|
-
const effectiveRepoId = repoId || "default";
|
|
178
|
-
|
|
179
|
-
// Lane sessions are the task-runner orchestration layer, NOT the worker agent.
|
|
180
|
-
// Use "lane" role to avoid filename collisions with worker sidecar files.
|
|
181
|
-
const role = "lane";
|
|
182
|
-
const laneMatch = sessionName.match(/lane-(\d+)/);
|
|
183
|
-
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
184
|
-
|
|
185
|
-
// Include taskId when available
|
|
186
|
-
const taskIdSegment = taskId ? `-${sanitizeForFilename(taskId)}` : "";
|
|
187
|
-
const telemetryBasename = `${opId}-${effectiveBatchId}-${effectiveRepoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
188
|
-
const telemetryDir = join(sidecarRoot, "telemetry");
|
|
189
|
-
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
190
|
-
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
191
|
-
const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
|
|
192
|
-
|
|
193
|
-
return { sidecarPath, exitSummaryPath, telemetryDir };
|
|
194
|
-
}
|
|
128
|
+
// generateTelemetryPaths removed (TP-120 remediation: legacy telemetry sidecar dead code)
|
|
195
129
|
|
|
196
130
|
// ── Execution Helpers ────────────────────────────────────────────────
|
|
197
131
|
|
|
@@ -220,55 +154,6 @@ export function execLog(
|
|
|
220
154
|
}
|
|
221
155
|
}
|
|
222
156
|
|
|
223
|
-
/**
|
|
224
|
-
* Check if a TMUX session exists (is alive).
|
|
225
|
-
*
|
|
226
|
-
* @param sessionName - TMUX session name to check
|
|
227
|
-
* @returns true if session exists
|
|
228
|
-
*/
|
|
229
|
-
export function tmuxHasSession(sessionName: string): boolean {
|
|
230
|
-
const result = spawnSync("tmux", ["has-session", "-t", sessionName]);
|
|
231
|
-
return result.status === 0;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Kill a TMUX session if it exists.
|
|
236
|
-
*
|
|
237
|
-
* Idempotent: returns true if session was killed or was already absent.
|
|
238
|
-
*
|
|
239
|
-
* @param sessionName - TMUX session name to kill
|
|
240
|
-
* @returns true if session is now absent
|
|
241
|
-
*/
|
|
242
|
-
export function tmuxKillSession(sessionName: string): boolean {
|
|
243
|
-
// Check liveness first so we can distinguish "already gone" from "kill failed".
|
|
244
|
-
const wasAlive = tmuxHasSession(sessionName);
|
|
245
|
-
if (!wasAlive) {
|
|
246
|
-
return true; // Already absent
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
spawnSync("tmux", ["kill-session", "-t", sessionName]);
|
|
250
|
-
|
|
251
|
-
// Consider success only if the session is now absent.
|
|
252
|
-
return !tmuxHasSession(sessionName);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Kill a lane session and its child sessions (worker, reviewer).
|
|
257
|
-
*
|
|
258
|
-
* Child session names follow the convention:
|
|
259
|
-
* - `{sessionName}-worker`
|
|
260
|
-
* - `{sessionName}-reviewer`
|
|
261
|
-
*
|
|
262
|
-
* @param sessionName - Base lane session name (e.g., "orch-lane-1")
|
|
263
|
-
*/
|
|
264
|
-
export function killLaneAndChildren(sessionName: string): void {
|
|
265
|
-
// Kill children first (they depend on the parent context)
|
|
266
|
-
tmuxKillSession(`${sessionName}-worker`);
|
|
267
|
-
tmuxKillSession(`${sessionName}-reviewer`);
|
|
268
|
-
// Then kill the parent lane session
|
|
269
|
-
tmuxKillSession(sessionName);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
157
|
/**
|
|
273
158
|
* TP-112: Check if a V2 agent is alive via process registry.
|
|
274
159
|
* Returns true if the agent's PID is running and status is non-terminal.
|
|
@@ -308,120 +193,39 @@ export function setV2LivenessRegistryCache(registry: import("./process-registry.
|
|
|
308
193
|
|
|
309
194
|
/**
|
|
310
195
|
* TP-112: Kill V2 lane agents (worker + reviewer) by PID from the registry.
|
|
311
|
-
*
|
|
196
|
+
*
|
|
197
|
+
* Uses the monitor cache when available for hot-path polling, and can
|
|
198
|
+
* optionally read a fresh registry snapshot for cleanup flows outside monitor.
|
|
199
|
+
*
|
|
312
200
|
* @since TP-112
|
|
313
201
|
*/
|
|
314
|
-
export function killV2LaneAgents(
|
|
315
|
-
|
|
316
|
-
|
|
202
|
+
export function killV2LaneAgents(
|
|
203
|
+
sessionName: string,
|
|
204
|
+
options?: { stateRoot?: string; batchId?: string; logContext?: string },
|
|
205
|
+
): void {
|
|
206
|
+
const registry = _v2LivenessRegistryCache ?? (
|
|
207
|
+
options?.stateRoot && options?.batchId
|
|
208
|
+
? readRegistrySnapshot(options.stateRoot, options.batchId)
|
|
209
|
+
: null
|
|
210
|
+
);
|
|
211
|
+
if (!registry) return;
|
|
212
|
+
|
|
213
|
+
const agents = registry.agents;
|
|
214
|
+
const logContext = options?.logContext ?? "monitor";
|
|
317
215
|
for (const suffix of ["-worker", "-reviewer", ""]) {
|
|
318
216
|
const key = `${sessionName}${suffix}`;
|
|
319
217
|
const manifest = agents[key];
|
|
320
218
|
if (manifest && !isTerminalStatus(manifest.status) && isProcessAlive(manifest.pid)) {
|
|
321
219
|
try {
|
|
322
220
|
process.kill(manifest.pid, "SIGTERM");
|
|
323
|
-
execLog(
|
|
221
|
+
execLog(logContext, key, `killed V2 agent (PID ${manifest.pid})`);
|
|
324
222
|
} catch { /* already dead */ }
|
|
325
223
|
}
|
|
326
224
|
}
|
|
327
225
|
}
|
|
328
226
|
|
|
329
|
-
// ── Async
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
* Run a tmux command asynchronously, without blocking the event loop.
|
|
333
|
-
*
|
|
334
|
-
* Wraps `child_process.spawn` in a promise. The process is spawned and
|
|
335
|
-
* stdout is collected incrementally; the promise resolves when the process
|
|
336
|
-
* exits.
|
|
337
|
-
*
|
|
338
|
-
* @param args - Arguments to pass to the `tmux` command
|
|
339
|
-
* @param timeoutMs - Optional timeout in milliseconds (default: 5000)
|
|
340
|
-
* @returns Promise resolving to `{ status, stdout }` where status is the exit code (0 = success)
|
|
341
|
-
*
|
|
342
|
-
* @since TP-070
|
|
343
|
-
*/
|
|
344
|
-
export function tmuxAsync(args: string[], timeoutMs: number = 5_000): Promise<{ status: number; stdout: string }> {
|
|
345
|
-
return new Promise((resolve) => {
|
|
346
|
-
const proc = spawn("tmux", args, {
|
|
347
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
348
|
-
timeout: timeoutMs,
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
let stdout = "";
|
|
352
|
-
|
|
353
|
-
proc.stdout.on("data", (chunk: Buffer) => {
|
|
354
|
-
stdout += chunk.toString("utf-8");
|
|
355
|
-
});
|
|
227
|
+
// ── Async File/Status Helpers (TP-070) ───────────────────────────────
|
|
356
228
|
|
|
357
|
-
proc.on("error", () => {
|
|
358
|
-
// Spawn failure — treat as non-zero exit
|
|
359
|
-
resolve({ status: 1, stdout: "" });
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
proc.on("close", (code) => {
|
|
363
|
-
resolve({ status: code ?? 1, stdout });
|
|
364
|
-
});
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* Async version of tmuxHasSession — checks if a TMUX session exists
|
|
370
|
-
* without blocking the event loop.
|
|
371
|
-
*
|
|
372
|
-
* @param sessionName - TMUX session name to check
|
|
373
|
-
* @returns Promise resolving to true if session exists
|
|
374
|
-
*
|
|
375
|
-
* @since TP-070
|
|
376
|
-
*/
|
|
377
|
-
export async function tmuxHasSessionAsync(sessionName: string): Promise<boolean> {
|
|
378
|
-
const result = await tmuxAsync(["has-session", "-t", sessionName]);
|
|
379
|
-
return result.status === 0;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/**
|
|
383
|
-
* Async version of tmuxKillSession — kills a TMUX session without
|
|
384
|
-
* blocking the event loop.
|
|
385
|
-
*
|
|
386
|
-
* Idempotent: resolves to true if session was killed or was already absent.
|
|
387
|
-
*
|
|
388
|
-
* @param sessionName - TMUX session name to kill
|
|
389
|
-
* @returns Promise resolving to true if session is now absent
|
|
390
|
-
*
|
|
391
|
-
* @since TP-070
|
|
392
|
-
*/
|
|
393
|
-
export async function tmuxKillSessionAsync(sessionName: string): Promise<boolean> {
|
|
394
|
-
const wasAlive = await tmuxHasSessionAsync(sessionName);
|
|
395
|
-
if (!wasAlive) return true;
|
|
396
|
-
|
|
397
|
-
await tmuxAsync(["kill-session", "-t", sessionName]);
|
|
398
|
-
return !(await tmuxHasSessionAsync(sessionName));
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Async version of captureTmuxPaneTail — captures tail output from a live
|
|
403
|
-
* TMUX pane without blocking the event loop.
|
|
404
|
-
*
|
|
405
|
-
* @param sessionName - TMUX session name
|
|
406
|
-
* @param maxLines - Maximum number of lines to return
|
|
407
|
-
* @param maxChars - Maximum character count
|
|
408
|
-
* @returns Promise resolving to captured text (empty string on failure)
|
|
409
|
-
*
|
|
410
|
-
* @since TP-070
|
|
411
|
-
*/
|
|
412
|
-
export async function captureTmuxPaneTailAsync(
|
|
413
|
-
sessionName: string,
|
|
414
|
-
maxLines: number = 40,
|
|
415
|
-
maxChars: number = 1200,
|
|
416
|
-
): Promise<string> {
|
|
417
|
-
const result = await tmuxAsync(["capture-pane", "-p", "-t", sessionName], 3000);
|
|
418
|
-
if (result.status !== 0) return "";
|
|
419
|
-
const raw = (result.stdout || "").replace(/\r\n/g, "\n").trim();
|
|
420
|
-
if (!raw) return "";
|
|
421
|
-
const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
|
|
422
|
-
if (!tail) return "";
|
|
423
|
-
return tail.length > maxChars ? tail.slice(-maxChars) : tail;
|
|
424
|
-
}
|
|
425
229
|
|
|
426
230
|
/**
|
|
427
231
|
* Async version of readTaskStatusTail — reads STATUS.md tail without
|
|
@@ -456,239 +260,15 @@ export async function readTaskStatusTailAsync(
|
|
|
456
260
|
}
|
|
457
261
|
|
|
458
262
|
/**
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* These env vars tell the task-runner extension inside the TMUX session
|
|
462
|
-
* how to behave:
|
|
463
|
-
* - TASK_AUTOSTART: relative path to PROMPT.md from worktree root
|
|
464
|
-
* - TASK_RUNNER_SPAWN_MODE: "tmux" for TMUX-based worker/reviewer spawning
|
|
465
|
-
* - TASK_RUNNER_TMUX_PREFIX: prefix for worker/reviewer session names
|
|
466
|
-
*
|
|
467
|
-
* @param lane - The allocated lane (provides session name and worktree path)
|
|
468
|
-
* @param taskId - Task ID for logging
|
|
469
|
-
* @param promptPath - Absolute path to the task's PROMPT.md in the main repo
|
|
470
|
-
* @param repoRoot - Absolute path to the main repository root
|
|
471
|
-
* @returns Map of env var name → value
|
|
472
|
-
*/
|
|
473
|
-
export function buildLaneEnvVars(
|
|
474
|
-
lane: AllocatedLane,
|
|
475
|
-
promptPath: string,
|
|
476
|
-
repoRoot: string,
|
|
477
|
-
workspaceRoot?: string,
|
|
478
|
-
): Record<string, string> {
|
|
479
|
-
// TASK_AUTOSTART: resolve the prompt path for the lane session.
|
|
480
|
-
//
|
|
481
|
-
// In workspace mode, tasks may live in a different repo than the lane's
|
|
482
|
-
// worktree (e.g., task PROMPT.md in shared-libs, worker runs in api-service).
|
|
483
|
-
// Always use the absolute path — task-runner's resolve(cwd, autoPath) handles
|
|
484
|
-
// absolute paths correctly, and this avoids broken relative paths when the
|
|
485
|
-
// task folder is outside the lane's repo.
|
|
486
|
-
//
|
|
487
|
-
// In repo mode (no workspace), we still use relative paths from repoRoot
|
|
488
|
-
// because the worktree mirrors the repo structure and the task folder is
|
|
489
|
-
// inside the repo.
|
|
490
|
-
const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
|
|
491
|
-
const promptNorm = resolve(promptPath).replace(/\\/g, "/");
|
|
492
|
-
|
|
493
|
-
let relativePath: string;
|
|
494
|
-
if (workspaceRoot) {
|
|
495
|
-
// Workspace mode: use worktree-relative path when the task folder is
|
|
496
|
-
// inside the lane's repo. This ensures STATUS.md, .DONE, and git commits
|
|
497
|
-
// all operate in the worktree (not the original source directory).
|
|
498
|
-
if (promptNorm.startsWith(repoRootNorm + "/")) {
|
|
499
|
-
relativePath = promptNorm.slice(repoRootNorm.length + 1);
|
|
500
|
-
} else {
|
|
501
|
-
// Cross-repo: task files live in a different repo than the worker's
|
|
502
|
-
// worktree. Copy the task folder into the worktree so STATUS.md,
|
|
503
|
-
// .DONE, and git commits all happen locally.
|
|
504
|
-
const taskFolder = dirname(resolve(promptPath));
|
|
505
|
-
const taskDirName = basename(taskFolder);
|
|
506
|
-
const localTaskDir = join(lane.worktreePath, ".taskplane-tasks", taskDirName);
|
|
507
|
-
mkdirSync(localTaskDir, { recursive: true });
|
|
508
|
-
// Copy PROMPT.md and STATUS.md into the local task dir
|
|
509
|
-
for (const file of ["PROMPT.md", "STATUS.md"]) {
|
|
510
|
-
const src = join(taskFolder, file);
|
|
511
|
-
const dst = join(localTaskDir, file);
|
|
512
|
-
if (existsSync(src) && !existsSync(dst)) {
|
|
513
|
-
copyFileSync(src, dst);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
// Create .reviews dir if it exists in source
|
|
517
|
-
const reviewsDir = join(taskFolder, ".reviews");
|
|
518
|
-
if (existsSync(reviewsDir)) {
|
|
519
|
-
mkdirSync(join(localTaskDir, ".reviews"), { recursive: true });
|
|
520
|
-
}
|
|
521
|
-
relativePath = join(".taskplane-tasks", taskDirName, "PROMPT.md");
|
|
522
|
-
}
|
|
523
|
-
} else if (promptNorm.startsWith(repoRootNorm + "/")) {
|
|
524
|
-
// Repo mode: relative path from repo root (mirrors into worktree)
|
|
525
|
-
relativePath = promptNorm.slice(repoRootNorm.length + 1);
|
|
526
|
-
} else {
|
|
527
|
-
// Fallback: absolute path
|
|
528
|
-
relativePath = resolve(promptPath);
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const nodePathEntries: string[] = [join(repoRoot, "node_modules")];
|
|
532
|
-
if (process.env.NODE_PATH) {
|
|
533
|
-
nodePathEntries.push(...process.env.NODE_PATH.split(pathDelimiter).filter(Boolean));
|
|
534
|
-
}
|
|
535
|
-
const nodePath = [...new Set(nodePathEntries)].join(pathDelimiter);
|
|
536
|
-
|
|
537
|
-
const vars: Record<string, string> = {
|
|
538
|
-
TASK_AUTOSTART: relativePath,
|
|
539
|
-
TASK_RUNNER_SPAWN_MODE: "tmux",
|
|
540
|
-
TASK_RUNNER_TMUX_PREFIX: lane.tmuxSessionName,
|
|
541
|
-
ORCH_SIDECAR_DIR: join(workspaceRoot || repoRoot, ".pi"),
|
|
542
|
-
NODE_PATH: nodePath,
|
|
543
|
-
// Pi's TUI (ink/react) hangs silently with TERM=tmux-256color (tmux default).
|
|
544
|
-
// Force xterm-256color so pi can render and start execution.
|
|
545
|
-
TERM: "xterm-256color",
|
|
546
|
-
};
|
|
547
|
-
|
|
548
|
-
// In workspace mode, the worktree cwd is inside a repo — not the workspace root.
|
|
549
|
-
// The task-runner needs TASKPLANE_WORKSPACE_ROOT to find .pi/ config
|
|
550
|
-
// and resolve task area paths from the correct base directory.
|
|
551
|
-
// Always set when workspaceRoot is provided (workspace mode), regardless of
|
|
552
|
-
// whether it equals repoRoot (it often does — cwd is the workspace root).
|
|
553
|
-
if (workspaceRoot) {
|
|
554
|
-
vars.TASKPLANE_WORKSPACE_ROOT = workspaceRoot;
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
return vars;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* Convert a Windows absolute path to a tmux-friendly POSIX-style path.
|
|
263
|
+
* Legacy lane environment-variable helper removed in TP-120.
|
|
562
264
|
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
* path resolution failures.
|
|
265
|
+
* Runtime V2 lane execution now runs through lane-runner/agent-host and no
|
|
266
|
+
* longer injects task-runner autostart/session env vars from this module.
|
|
566
267
|
*/
|
|
567
|
-
|
|
568
|
-
const normalized = resolve(pathValue).replace(/\\/g, "/");
|
|
569
|
-
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
|
|
570
|
-
if (driveMatch) {
|
|
571
|
-
return `/${driveMatch[1].toLowerCase()}/${driveMatch[2]}`;
|
|
572
|
-
}
|
|
573
|
-
return normalized;
|
|
574
|
-
}
|
|
268
|
+
// buildLaneEnvVars removed (TP-120 remediation: legacy lane-session env var path, dead code)
|
|
575
269
|
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
*
|
|
579
|
-
* Constructs a properly escaped command that:
|
|
580
|
-
* 1. Sets env vars (TASK_AUTOSTART, TASK_RUNNER_SPAWN_MODE, TASK_RUNNER_TMUX_PREFIX)
|
|
581
|
-
* 2. Runs `node rpc-wrapper.mjs` to spawn pi with the task-runner extension,
|
|
582
|
-
* producing structured telemetry (sidecar JSONL + exit summary JSON).
|
|
583
|
-
*
|
|
584
|
-
* The RPC wrapper spawns pi in RPC mode with the task-runner extension loaded.
|
|
585
|
-
* The extension's TASK_AUTOSTART env var triggers task execution on init.
|
|
586
|
-
* A minimal prompt file is created to satisfy the wrapper's --prompt-file requirement.
|
|
587
|
-
*
|
|
588
|
-
* Shell escaping: env var values are single-quoted to prevent expansion.
|
|
589
|
-
* Path args are single-quoted to handle spaces and special characters.
|
|
590
|
-
*
|
|
591
|
-
* @param sessionName - TMUX session name (e.g., "orch-lane-1")
|
|
592
|
-
* @param worktreePath - Absolute path to the lane worktree
|
|
593
|
-
* @param repoRoot - Absolute path to main repo (for extension absolute path)
|
|
594
|
-
* @param envVars - Environment variables to set
|
|
595
|
-
* @param laneLogPath - Optional path to write lane session stdout/stderr
|
|
596
|
-
* @param sidecarPath - Path for RPC telemetry sidecar JSONL file
|
|
597
|
-
* @param exitSummaryPath - Path for RPC telemetry exit summary JSON file
|
|
598
|
-
* @returns Array of arguments for spawnSync("tmux", args)
|
|
599
|
-
*/
|
|
600
|
-
export function buildTmuxSpawnArgs(
|
|
601
|
-
sessionName: string,
|
|
602
|
-
worktreePath: string,
|
|
603
|
-
repoRoot: string,
|
|
604
|
-
envVars: Record<string, string>,
|
|
605
|
-
laneLogPath?: string,
|
|
606
|
-
sidecarPath?: string,
|
|
607
|
-
exitSummaryPath?: string,
|
|
608
|
-
): string[] {
|
|
609
|
-
// Shell-quote a value for safe embedding in a command string.
|
|
610
|
-
// Wraps in single quotes, escaping any internal single quotes.
|
|
611
|
-
const shellQuote = (s: string): string => {
|
|
612
|
-
if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
|
|
613
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
614
|
-
}
|
|
615
|
-
return s;
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
// Build the command string that runs inside the TMUX session.
|
|
619
|
-
const envParts = Object.entries(envVars)
|
|
620
|
-
.map(([key, val]) => `${key}=${shellQuote(val)}`)
|
|
621
|
-
.join(" ");
|
|
622
|
-
|
|
623
|
-
const taskRunnerExtPath = resolveTaskRunnerExtensionPath(repoRoot);
|
|
624
|
-
|
|
625
|
-
let piCommand: string;
|
|
626
|
-
|
|
627
|
-
if (sidecarPath && exitSummaryPath) {
|
|
628
|
-
// ── RPC Wrapper mode: structured telemetry ──────────────
|
|
629
|
-
// Spawn `node rpc-wrapper.mjs` instead of `pi` directly.
|
|
630
|
-
// The wrapper runs pi in RPC mode, captures telemetry to
|
|
631
|
-
// sidecar JSONL, and writes exit summary on process exit.
|
|
632
|
-
const rpcWrapperPath = resolveRpcWrapperPath(repoRoot);
|
|
633
|
-
|
|
634
|
-
// Create a minimal prompt file for the RPC wrapper.
|
|
635
|
-
// The task-runner extension handles execution via TASK_AUTOSTART;
|
|
636
|
-
// this prompt satisfies the wrapper's --prompt-file requirement.
|
|
637
|
-
// Written to the sidecar dir (not tmpdir) so it's co-located with
|
|
638
|
-
// telemetry artifacts and cleaned up with them after the batch.
|
|
639
|
-
const promptId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
640
|
-
const promptDir = dirname(sidecarPath);
|
|
641
|
-
if (!existsSync(promptDir)) mkdirSync(promptDir, { recursive: true });
|
|
642
|
-
const promptTmpFile = join(promptDir, `lane-prompt-${promptId}.txt`);
|
|
643
|
-
writeFileSync(promptTmpFile, "Execute the task as configured by the task-runner extension.");
|
|
644
|
-
|
|
645
|
-
piCommand = [
|
|
646
|
-
envParts,
|
|
647
|
-
"node", shellQuote(rpcWrapperPath),
|
|
648
|
-
"--sidecar-path", shellQuote(sidecarPath),
|
|
649
|
-
"--exit-summary-path", shellQuote(exitSummaryPath),
|
|
650
|
-
"--prompt-file", shellQuote(promptTmpFile),
|
|
651
|
-
"--extensions", shellQuote(taskRunnerExtPath),
|
|
652
|
-
// Prevent pi from auto-discovering extensions from the worktree CWD.
|
|
653
|
-
// Without this, pi loads BOTH the explicit -e extension AND any
|
|
654
|
-
// extensions/ in the worktree, causing duplicate tool registration
|
|
655
|
-
// and unpredictable behavior (two copies of task-runner compete).
|
|
656
|
-
"--", "--no-extensions",
|
|
657
|
-
].filter(Boolean).join(" ");
|
|
658
|
-
} else {
|
|
659
|
-
// ── Legacy mode: direct pi spawn (no telemetry) ─────────
|
|
660
|
-
piCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// TP-095: Capture lane session stderr to a log file (#339).
|
|
664
|
-
// When the lane session (rpc-wrapper → pi → task-runner) dies, stderr is
|
|
665
|
-
// lost to tmux scrollback. Redirect stderr to a persistent log file
|
|
666
|
-
// co-located with telemetry so the supervisor can diagnose lane deaths.
|
|
667
|
-
//
|
|
668
|
-
// We append stderr to a file using `2>>`. This captures all stderr output
|
|
669
|
-
// from rpc-wrapper (which includes pi stderr forwarding, progress display,
|
|
670
|
-
// and crash diagnostics). The tmux pane loses live stderr visibility, but
|
|
671
|
-
// the dashboard provides live monitoring and the file preserves everything
|
|
672
|
-
// for post-mortem analysis.
|
|
673
|
-
//
|
|
674
|
-
// Appended to piCommand (not the tmux shell wrapper) to target the
|
|
675
|
-
// node/rpc-wrapper process specifically. This avoids the fragile shell
|
|
676
|
-
// redirection issues that previously caused spawn failures on Windows.
|
|
677
|
-
if (sidecarPath) {
|
|
678
|
-
// Derive stderr log path from sidecar path:
|
|
679
|
-
// .pi/telemetry/{basename}.jsonl → .pi/telemetry/{basename}-stderr.log
|
|
680
|
-
const stderrLogPath = sidecarPath.replace(/\.jsonl$/, "-stderr.log");
|
|
681
|
-
piCommand = `${piCommand} 2>> ${shellQuote(stderrLogPath)}`;
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
const tmuxWorktreePath = toTmuxPath(worktreePath);
|
|
685
|
-
const wrappedCommand = `cd ${shellQuote(tmuxWorktreePath)} && ${piCommand}`;
|
|
686
|
-
|
|
687
|
-
return [
|
|
688
|
-
"new-session", "-d",
|
|
689
|
-
"-s", sessionName,
|
|
690
|
-
wrappedCommand,
|
|
691
|
-
];
|
|
270
|
+
function laneSessionIdOf(lane: Pick<AllocatedLane, "laneSessionId">): string {
|
|
271
|
+
return lane.laneSessionId;
|
|
692
272
|
}
|
|
693
273
|
|
|
694
274
|
/**
|
|
@@ -701,11 +281,11 @@ export function resolveLaneLogPath(
|
|
|
701
281
|
lane: AllocatedLane,
|
|
702
282
|
task: AllocatedTask,
|
|
703
283
|
): string {
|
|
704
|
-
return join(lane.worktreePath, ".pi", "orch-logs", `${lane
|
|
284
|
+
return join(lane.worktreePath, ".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`);
|
|
705
285
|
}
|
|
706
286
|
|
|
707
287
|
/**
|
|
708
|
-
* Relative lane log path used
|
|
288
|
+
* Relative lane log path used by the legacy shell-spawn path.
|
|
709
289
|
*
|
|
710
290
|
* Relative paths avoid Windows drive-letter parsing issues in shell redirection.
|
|
711
291
|
*/
|
|
@@ -713,7 +293,7 @@ export function resolveLaneLogRelativePath(
|
|
|
713
293
|
lane: AllocatedLane,
|
|
714
294
|
task: AllocatedTask,
|
|
715
295
|
): string {
|
|
716
|
-
return join(".pi", "orch-logs", `${lane
|
|
296
|
+
return join(".pi", "orch-logs", `${laneSessionIdOf(lane)}-${task.taskId}.log`).replace(/\\/g, "/");
|
|
717
297
|
}
|
|
718
298
|
|
|
719
299
|
/**
|
|
@@ -779,28 +359,6 @@ export async function fileExistsAsync(filePath: string): Promise<boolean> {
|
|
|
779
359
|
}
|
|
780
360
|
}
|
|
781
361
|
|
|
782
|
-
/**
|
|
783
|
-
* Capture tail output from a live TMUX pane for diagnostics.
|
|
784
|
-
*
|
|
785
|
-
* Works even when lane log redirection is disabled (Windows-safe fallback).
|
|
786
|
-
*/
|
|
787
|
-
export function captureTmuxPaneTail(
|
|
788
|
-
sessionName: string,
|
|
789
|
-
maxLines: number = 40,
|
|
790
|
-
maxChars: number = 1200,
|
|
791
|
-
): string {
|
|
792
|
-
const result = spawnSync("tmux", ["capture-pane", "-p", "-t", sessionName], {
|
|
793
|
-
encoding: "utf-8",
|
|
794
|
-
timeout: 3000,
|
|
795
|
-
});
|
|
796
|
-
if (result.status !== 0) return "";
|
|
797
|
-
const raw = (result.stdout || "").replace(/\r\n/g, "\n").trim();
|
|
798
|
-
if (!raw) return "";
|
|
799
|
-
const tail = raw.split("\n").slice(-maxLines).join("\n").trim();
|
|
800
|
-
if (!tail) return "";
|
|
801
|
-
return tail.length > maxChars ? tail.slice(-maxChars) : tail;
|
|
802
|
-
}
|
|
803
|
-
|
|
804
362
|
/**
|
|
805
363
|
* Read a tail snippet from task STATUS.md for failure diagnostics.
|
|
806
364
|
*/
|
|
@@ -954,306 +512,26 @@ export function resolveTaskDonePath(
|
|
|
954
512
|
return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode).donePath;
|
|
955
513
|
}
|
|
956
514
|
|
|
957
|
-
/**
|
|
958
|
-
* Spawn a TMUX session for a task in a lane.
|
|
959
|
-
*
|
|
960
|
-
* Handles:
|
|
961
|
-
* - Stale session cleanup (kill if session name already exists)
|
|
962
|
-
* - Retry on transient spawn failures (up to SESSION_SPAWN_RETRY_MAX)
|
|
963
|
-
* - Structured logging
|
|
964
|
-
*
|
|
965
|
-
* @param lane - Allocated lane with worktree and session info
|
|
966
|
-
* @param task - Task to execute
|
|
967
|
-
* @param config - Orchestrator configuration
|
|
968
|
-
* @param repoRoot - Main repository root
|
|
969
|
-
* @throws ExecutionError if spawn fails after retries
|
|
970
|
-
*/
|
|
971
|
-
export function spawnLaneSession(
|
|
972
|
-
lane: AllocatedLane,
|
|
973
|
-
task: AllocatedTask,
|
|
974
|
-
config: OrchestratorConfig,
|
|
975
|
-
repoRoot: string,
|
|
976
|
-
workspaceRoot?: string,
|
|
977
|
-
extraEnvVars?: Record<string, string>,
|
|
978
|
-
): void {
|
|
979
|
-
const sessionName = lane.tmuxSessionName;
|
|
980
|
-
const laneId = lane.laneId;
|
|
981
|
-
|
|
982
|
-
execLog(laneId, task.taskId, "preparing to spawn TMUX session", {
|
|
983
|
-
session: sessionName,
|
|
984
|
-
worktree: lane.worktreePath,
|
|
985
|
-
worktreeTmuxPath: toTmuxPath(lane.worktreePath),
|
|
986
|
-
logPath: resolveLaneLogPath(lane, task),
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
// Pre-check: worktree exists
|
|
990
|
-
if (!existsSync(lane.worktreePath)) {
|
|
991
|
-
throw new ExecutionError(
|
|
992
|
-
"EXEC_WORKTREE_MISSING",
|
|
993
|
-
`Worktree path does not exist: ${lane.worktreePath}`,
|
|
994
|
-
laneId,
|
|
995
|
-
task.taskId,
|
|
996
|
-
);
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Build env vars
|
|
1000
|
-
const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
|
|
1001
|
-
// ORCH_BATCH_ID is passed via extraEnvVars from executeWave → executeLane → spawnLaneSession.
|
|
1002
|
-
// The task-runner reads it to include batchId in lane-state JSON for dashboard filtering.
|
|
1003
|
-
if (extraEnvVars) {
|
|
1004
|
-
Object.assign(envVars, extraEnvVars);
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
// Prepare per-task lane log path for post-mortem diagnostics
|
|
1008
|
-
const laneLogPath = resolveLaneLogPath(lane, task);
|
|
1009
|
-
const laneLogRelativePath = resolveLaneLogRelativePath(lane, task);
|
|
1010
|
-
try {
|
|
1011
|
-
mkdirSync(dirname(laneLogPath), { recursive: true });
|
|
1012
|
-
if (existsSync(laneLogPath)) {
|
|
1013
|
-
unlinkSync(laneLogPath); // fresh log per task attempt
|
|
1014
|
-
}
|
|
1015
|
-
} catch {
|
|
1016
|
-
// Best effort — session can still run without log file setup
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
// Generate telemetry file paths for RPC wrapper sidecar
|
|
1020
|
-
const sidecarRoot = join(workspaceRoot || repoRoot, ".pi");
|
|
1021
|
-
const telemetry = generateTelemetryPaths(sessionName, sidecarRoot, task.taskId, config.orchestrator?.batchId, lane.repoId);
|
|
1022
|
-
execLog(laneId, task.taskId, "telemetry paths generated", {
|
|
1023
|
-
sidecar: telemetry.sidecarPath,
|
|
1024
|
-
exitSummary: telemetry.exitSummaryPath,
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
// Build tmux args (with RPC wrapper telemetry)
|
|
1028
|
-
const tmuxArgs = buildTmuxSpawnArgs(sessionName, lane.worktreePath, repoRoot, envVars, laneLogRelativePath, telemetry.sidecarPath, telemetry.exitSummaryPath);
|
|
1029
|
-
|
|
1030
|
-
// Clean up stale session if exists
|
|
1031
|
-
if (tmuxHasSession(sessionName)) {
|
|
1032
|
-
execLog(laneId, task.taskId, "killing stale TMUX session", { session: sessionName });
|
|
1033
|
-
killLaneAndChildren(sessionName);
|
|
1034
|
-
// Brief pause to let tmux clean up
|
|
1035
|
-
spawnSync("sleep", ["0.5"], { shell: true, timeout: 3000 });
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
// Attempt to spawn with retry
|
|
1039
|
-
let lastError = "";
|
|
1040
|
-
for (let attempt = 1; attempt <= SESSION_SPAWN_RETRY_MAX + 1; attempt++) {
|
|
1041
|
-
const result = spawnSync("tmux", tmuxArgs);
|
|
1042
|
-
|
|
1043
|
-
if (result.status === 0) {
|
|
1044
|
-
execLog(laneId, task.taskId, "TMUX session spawned successfully", {
|
|
1045
|
-
session: sessionName,
|
|
1046
|
-
attempt,
|
|
1047
|
-
});
|
|
1048
|
-
return;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
lastError = result.stderr?.toString().trim() || "unknown spawn error";
|
|
1052
|
-
execLog(laneId, task.taskId, `spawn attempt ${attempt} failed: ${lastError}`, {
|
|
1053
|
-
session: sessionName,
|
|
1054
|
-
});
|
|
1055
|
-
|
|
1056
|
-
if (attempt <= SESSION_SPAWN_RETRY_MAX) {
|
|
1057
|
-
// Wait before retry (1s, 2s)
|
|
1058
|
-
const delayMs = attempt * 1000;
|
|
1059
|
-
spawnSync("sleep", [`${delayMs / 1000}`], { shell: true, timeout: delayMs + 2000 });
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
throw new ExecutionError(
|
|
1064
|
-
"EXEC_SPAWN_FAILED",
|
|
1065
|
-
`Failed to create TMUX session '${sessionName}' after ${SESSION_SPAWN_RETRY_MAX + 1} attempts. Last error: ${lastError}`,
|
|
1066
|
-
laneId,
|
|
1067
|
-
task.taskId,
|
|
1068
|
-
);
|
|
1069
|
-
}
|
|
1070
515
|
|
|
1071
|
-
|
|
1072
|
-
*
|
|
1073
|
-
*
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
* 2. Check TMUX session liveness via `tmux has-session`
|
|
1077
|
-
* 3. If session exits without .DONE → wait DONE_GRACE_MS (slow disk flush)
|
|
1078
|
-
* 4. After grace period, if still no .DONE → task failed
|
|
1079
|
-
*
|
|
1080
|
-
* Terminal-state precedence: .DONE found at any point = success,
|
|
1081
|
-
* regardless of session state.
|
|
1082
|
-
*
|
|
1083
|
-
* @param lane - Allocated lane
|
|
1084
|
-
* @param task - Task being executed
|
|
1085
|
-
* @param config - Orchestrator configuration
|
|
1086
|
-
* @param repoRoot - Main repository root
|
|
1087
|
-
* @param pauseSignal - Checked each poll cycle; if true, returns early with "skipped"
|
|
1088
|
-
* @returns LaneTaskStatus indicating the final state
|
|
516
|
+
/*
|
|
517
|
+
* Removed in TP-120 while decommissioning the legacy session backend.
|
|
518
|
+
*
|
|
519
|
+
* `pollUntilTaskComplete` remains as a test-compatibility stub only.
|
|
520
|
+
* Runtime V2 completion detection now lives in lane-runner + agent-host.
|
|
1089
521
|
*/
|
|
522
|
+
// pollUntilTaskComplete function body removed — was ~170 lines of legacy .DONE polling.
|
|
523
|
+
// @ts-ignore — export kept as stub for test compatibility
|
|
1090
524
|
export async function pollUntilTaskComplete(
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
525
|
+
_lane: AllocatedLane,
|
|
526
|
+
_task: AllocatedTask,
|
|
527
|
+
_config: OrchestratorConfig,
|
|
528
|
+
_repoRoot: string,
|
|
529
|
+
_pauseSignal: { paused: boolean },
|
|
530
|
+
_isWorkspaceMode?: boolean,
|
|
1097
531
|
): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
|
|
1098
|
-
|
|
1099
|
-
const laneId = lane.laneId;
|
|
1100
|
-
const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot, isWorkspaceMode);
|
|
1101
|
-
const donePath = resolved.donePath;
|
|
1102
|
-
const statusPath = resolved.statusPath;
|
|
1103
|
-
const laneLogPath = resolveLaneLogPath(lane, task);
|
|
1104
|
-
|
|
1105
|
-
execLog(laneId, task.taskId, "polling for completion", {
|
|
1106
|
-
session: sessionName,
|
|
1107
|
-
donePath,
|
|
1108
|
-
statusPath,
|
|
1109
|
-
logPath: laneLogPath,
|
|
1110
|
-
});
|
|
1111
|
-
|
|
1112
|
-
let lastPaneTail = "";
|
|
1113
|
-
|
|
1114
|
-
// Abort signal file path — checked each poll cycle.
|
|
1115
|
-
// Any process can create this file to trigger abort (belt-and-suspenders
|
|
1116
|
-
// alongside the in-memory pauseSignal, since /orch-abort may not be able
|
|
1117
|
-
// to run concurrently with the /orch command handler).
|
|
1118
|
-
const abortSignalFile = join(repoRoot, ".pi", "orch-abort-signal");
|
|
1119
|
-
|
|
1120
|
-
// Main polling loop
|
|
1121
|
-
while (true) {
|
|
1122
|
-
// Check pause signal
|
|
1123
|
-
if (pauseSignal.paused) {
|
|
1124
|
-
execLog(laneId, task.taskId, "pause signal detected during poll");
|
|
1125
|
-
// Don't kill the session — let the current task-runner checkpoint
|
|
1126
|
-
// The calling code will handle marking as skipped
|
|
1127
|
-
return {
|
|
1128
|
-
status: "skipped",
|
|
1129
|
-
exitReason: "Paused by user (/orch-pause)",
|
|
1130
|
-
doneFileFound: false,
|
|
1131
|
-
};
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
// Check file-based abort signal (TP-070: async)
|
|
1135
|
-
if (await fileExistsAsync(abortSignalFile)) {
|
|
1136
|
-
execLog(laneId, task.taskId, "abort signal file detected — killing session and aborting");
|
|
1137
|
-
await tmuxKillSessionAsync(sessionName);
|
|
1138
|
-
// Also kill child sessions (worker, reviewer)
|
|
1139
|
-
await tmuxKillSessionAsync(`${sessionName}-worker`);
|
|
1140
|
-
await tmuxKillSessionAsync(`${sessionName}-reviewer`);
|
|
1141
|
-
return {
|
|
1142
|
-
status: "failed",
|
|
1143
|
-
exitReason: "Aborted by signal file (.pi/orch-abort-signal)",
|
|
1144
|
-
doneFileFound: false,
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
// Capture live pane output for diagnostics (best effort) — async to avoid blocking.
|
|
1149
|
-
const paneTail = await captureTmuxPaneTailAsync(sessionName);
|
|
1150
|
-
if (paneTail) {
|
|
1151
|
-
lastPaneTail = paneTail;
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
// Priority 1: Check for .DONE file (TP-070: async)
|
|
1155
|
-
if (await fileExistsAsync(donePath)) {
|
|
1156
|
-
execLog(laneId, task.taskId, ".DONE file found — task succeeded", {
|
|
1157
|
-
session: sessionName,
|
|
1158
|
-
});
|
|
1159
|
-
return {
|
|
1160
|
-
status: "succeeded",
|
|
1161
|
-
exitReason: ".DONE file created by task-runner",
|
|
1162
|
-
doneFileFound: true,
|
|
1163
|
-
};
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
// Priority 2: Check if TMUX session is still alive — async to avoid blocking
|
|
1167
|
-
if (!(await tmuxHasSessionAsync(sessionName))) {
|
|
1168
|
-
// Session exited — start grace period for .DONE file
|
|
1169
|
-
execLog(laneId, task.taskId, "TMUX session exited, entering grace period", {
|
|
1170
|
-
session: sessionName,
|
|
1171
|
-
graceMs: DONE_GRACE_MS,
|
|
1172
|
-
});
|
|
1173
|
-
|
|
1174
|
-
// Grace period: poll .DONE file at short intervals
|
|
1175
|
-
const graceStart = Date.now();
|
|
1176
|
-
while (Date.now() - graceStart < DONE_GRACE_MS) {
|
|
1177
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
1178
|
-
|
|
1179
|
-
if (await fileExistsAsync(donePath)) {
|
|
1180
|
-
execLog(laneId, task.taskId, ".DONE file found during grace period — task succeeded", {
|
|
1181
|
-
session: sessionName,
|
|
1182
|
-
});
|
|
1183
|
-
return {
|
|
1184
|
-
status: "succeeded",
|
|
1185
|
-
exitReason: ".DONE file created (found during grace period)",
|
|
1186
|
-
doneFileFound: true,
|
|
1187
|
-
};
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
// Grace period expired — last resort: check the lane BRANCH for .DONE.
|
|
1192
|
-
// The worker may have committed .DONE before the session exited, but
|
|
1193
|
-
// the worktree filesystem doesn't reflect it (stale checkout, race).
|
|
1194
|
-
// This handles the common case where the worker completes all work,
|
|
1195
|
-
// commits .DONE, and then the session exits before the poll detects it.
|
|
1196
|
-
{
|
|
1197
|
-
const relDonePath = donePath.startsWith(lane.worktreePath)
|
|
1198
|
-
? donePath.slice(lane.worktreePath.length).replace(/^[\\/]+/, "").replace(/\\/g, "/")
|
|
1199
|
-
: null;
|
|
1200
|
-
if (relDonePath) {
|
|
1201
|
-
const gitResult = runGit(
|
|
1202
|
-
["show", `${lane.branch}:${relDonePath}`],
|
|
1203
|
-
lane.worktreePath,
|
|
1204
|
-
);
|
|
1205
|
-
if (gitResult.ok) {
|
|
1206
|
-
execLog(laneId, task.taskId, ".DONE found on lane branch (not in worktree) — task succeeded", {
|
|
1207
|
-
session: sessionName,
|
|
1208
|
-
branch: lane.branch,
|
|
1209
|
-
});
|
|
1210
|
-
return {
|
|
1211
|
-
status: "succeeded",
|
|
1212
|
-
exitReason: ".DONE committed to lane branch (found via git show after grace period)",
|
|
1213
|
-
doneFileFound: true,
|
|
1214
|
-
};
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
// Truly failed — no .DONE on filesystem or branch
|
|
1220
|
-
const logTail = await readLaneLogTailAsync(laneLogPath);
|
|
1221
|
-
execLog(laneId, task.taskId, "grace period expired, no .DONE on filesystem or branch — task failed", {
|
|
1222
|
-
session: sessionName,
|
|
1223
|
-
logPath: laneLogPath,
|
|
1224
|
-
});
|
|
1225
|
-
if (logTail) {
|
|
1226
|
-
execLog(laneId, task.taskId, `lane session output (tail):\n${logTail}`);
|
|
1227
|
-
}
|
|
1228
|
-
const statusTail = await readTaskStatusTailAsync(statusPath);
|
|
1229
|
-
const hasLogFile = await fileExistsAsync(laneLogPath);
|
|
1230
|
-
const outputForHint = logTail || lastPaneTail || statusTail;
|
|
1231
|
-
const logHint = outputForHint
|
|
1232
|
-
? ` Last output: ${outputForHint.replace(/\s+/g, " ").slice(-300)}`
|
|
1233
|
-
: "";
|
|
1234
|
-
const logLocation = hasLogFile ? ` Lane log: ${laneLogPath}.` : "";
|
|
1235
|
-
if (!logTail && lastPaneTail) {
|
|
1236
|
-
execLog(laneId, task.taskId, `lane session output from TMUX pane (tail):\n${lastPaneTail}`);
|
|
1237
|
-
}
|
|
1238
|
-
if (statusTail) {
|
|
1239
|
-
execLog(laneId, task.taskId, `task STATUS tail:\n${statusTail}`);
|
|
1240
|
-
}
|
|
1241
|
-
return {
|
|
1242
|
-
status: "failed",
|
|
1243
|
-
exitReason:
|
|
1244
|
-
`TMUX session '${sessionName}' exited without creating .DONE file ` +
|
|
1245
|
-
`(grace period ${DONE_GRACE_MS}ms expired).` +
|
|
1246
|
-
`${logLocation}${logHint}`,
|
|
1247
|
-
doneFileFound: false,
|
|
1248
|
-
};
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Session alive, no .DONE yet — keep polling
|
|
1252
|
-
await new Promise((r) => setTimeout(r, EXECUTION_POLL_INTERVAL_MS));
|
|
1253
|
-
}
|
|
532
|
+
return { status: "failed", exitReason: "Legacy pollUntilTaskComplete removed — use V2 lane-runner", doneFileFound: false };
|
|
1254
533
|
}
|
|
1255
534
|
|
|
1256
|
-
|
|
1257
535
|
// ── Post-Task Commit ─────────────────────────────────────────────────
|
|
1258
536
|
|
|
1259
537
|
/**
|
|
@@ -1310,189 +588,6 @@ function commitTaskArtifacts(
|
|
|
1310
588
|
}
|
|
1311
589
|
|
|
1312
590
|
|
|
1313
|
-
/**
|
|
1314
|
-
* Execute all tasks in a lane sequentially.
|
|
1315
|
-
*
|
|
1316
|
-
* For each task in the lane (in order):
|
|
1317
|
-
* 1. Spawn a TMUX session with TASK_AUTOSTART pointing to the task's PROMPT.md
|
|
1318
|
-
* 2. Poll until the task completes (or fails)
|
|
1319
|
-
* 3. Commit any uncommitted task artifacts (.DONE, STATUS.md) to the lane branch
|
|
1320
|
-
* 4. Record the outcome
|
|
1321
|
-
* 5. If the task failed, skip remaining tasks in the lane
|
|
1322
|
-
*
|
|
1323
|
-
* The lane reuses the same worktree and TMUX session name across tasks.
|
|
1324
|
-
* Each new task gets a fresh TMUX session (the previous one has exited).
|
|
1325
|
-
*
|
|
1326
|
-
* Cleanup policy:
|
|
1327
|
-
* - On success: session exits naturally, no cleanup needed
|
|
1328
|
-
* - On failure: session may have exited already; if alive, leave for debugging
|
|
1329
|
-
* - On pause: stop after current task, mark remaining as skipped
|
|
1330
|
-
* - On stall: handled by Step 3 (monitoring) — this function just polls
|
|
1331
|
-
*
|
|
1332
|
-
* @param lane - Fully allocated lane from Step 1
|
|
1333
|
-
* @param config - Orchestrator configuration
|
|
1334
|
-
* @param repoRoot - Main repository root
|
|
1335
|
-
* @param pauseSignal - Shared signal for pause/abort (checked between tasks)
|
|
1336
|
-
* @returns LaneExecutionResult with per-task outcomes
|
|
1337
|
-
*/
|
|
1338
|
-
export async function executeLane(
|
|
1339
|
-
lane: AllocatedLane,
|
|
1340
|
-
config: OrchestratorConfig,
|
|
1341
|
-
repoRoot: string,
|
|
1342
|
-
pauseSignal: { paused: boolean },
|
|
1343
|
-
workspaceRoot?: string,
|
|
1344
|
-
isWorkspaceMode?: boolean,
|
|
1345
|
-
extraEnvVars?: Record<string, string>,
|
|
1346
|
-
): Promise<LaneExecutionResult> {
|
|
1347
|
-
const laneId = lane.laneId;
|
|
1348
|
-
const laneStartTime = Date.now();
|
|
1349
|
-
const outcomes: LaneTaskOutcome[] = [];
|
|
1350
|
-
let shouldSkipRemaining = false;
|
|
1351
|
-
|
|
1352
|
-
execLog(laneId, "LANE", `starting execution of ${lane.tasks.length} task(s)`, {
|
|
1353
|
-
worktree: lane.worktreePath,
|
|
1354
|
-
session: lane.tmuxSessionName,
|
|
1355
|
-
});
|
|
1356
|
-
|
|
1357
|
-
for (const task of lane.tasks) {
|
|
1358
|
-
// Check if remaining tasks should be skipped (prior failure or pause)
|
|
1359
|
-
if (shouldSkipRemaining || pauseSignal.paused) {
|
|
1360
|
-
const reason = pauseSignal.paused
|
|
1361
|
-
? "Skipped due to pause signal"
|
|
1362
|
-
: "Skipped due to prior task failure in lane";
|
|
1363
|
-
execLog(laneId, task.taskId, reason);
|
|
1364
|
-
outcomes.push({
|
|
1365
|
-
taskId: task.taskId,
|
|
1366
|
-
status: "skipped",
|
|
1367
|
-
startTime: null,
|
|
1368
|
-
endTime: null,
|
|
1369
|
-
exitReason: reason,
|
|
1370
|
-
sessionName: lane.tmuxSessionName,
|
|
1371
|
-
doneFileFound: false,
|
|
1372
|
-
laneNumber: lane.laneNumber,
|
|
1373
|
-
});
|
|
1374
|
-
continue;
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
// Execute this task
|
|
1378
|
-
const taskStartTime = Date.now();
|
|
1379
|
-
let taskOutcome: LaneTaskOutcome;
|
|
1380
|
-
|
|
1381
|
-
try {
|
|
1382
|
-
// Spawn TMUX session
|
|
1383
|
-
spawnLaneSession(lane, task, config, repoRoot, workspaceRoot, extraEnvVars);
|
|
1384
|
-
|
|
1385
|
-
// Poll until completion
|
|
1386
|
-
const pollResult = await pollUntilTaskComplete(
|
|
1387
|
-
lane,
|
|
1388
|
-
task,
|
|
1389
|
-
config,
|
|
1390
|
-
repoRoot,
|
|
1391
|
-
pauseSignal,
|
|
1392
|
-
isWorkspaceMode,
|
|
1393
|
-
);
|
|
1394
|
-
|
|
1395
|
-
taskOutcome = {
|
|
1396
|
-
taskId: task.taskId,
|
|
1397
|
-
status: pollResult.status,
|
|
1398
|
-
startTime: taskStartTime,
|
|
1399
|
-
endTime: Date.now(),
|
|
1400
|
-
exitReason: pollResult.exitReason,
|
|
1401
|
-
sessionName: lane.tmuxSessionName,
|
|
1402
|
-
doneFileFound: pollResult.doneFileFound,
|
|
1403
|
-
laneNumber: lane.laneNumber,
|
|
1404
|
-
};
|
|
1405
|
-
|
|
1406
|
-
// After task succeeds, commit any uncommitted artifacts (.DONE, final
|
|
1407
|
-
// STATUS.md update) to the lane branch so they survive the merge.
|
|
1408
|
-
// The task-runner writes .DONE via writeFileSync but never commits it.
|
|
1409
|
-
if (pollResult.status === "succeeded") {
|
|
1410
|
-
commitTaskArtifacts(lane, task, laneId);
|
|
1411
|
-
|
|
1412
|
-
// Reset worktree to clean state for the next task on this lane.
|
|
1413
|
-
// Without this, the next worker sees the previous task's modified
|
|
1414
|
-
// files and can get confused about which task it's working on.
|
|
1415
|
-
if (lane.tasks.indexOf(task) < lane.tasks.length - 1) {
|
|
1416
|
-
execLog(laneId, task.taskId, "resetting worktree for next task");
|
|
1417
|
-
const resetResult = runGit(["checkout", "--", "."], lane.worktreePath);
|
|
1418
|
-
const cleanResult = runGit(["clean", "-fd"], lane.worktreePath);
|
|
1419
|
-
if (!resetResult.ok || !cleanResult.ok) {
|
|
1420
|
-
execLog(laneId, task.taskId, "worktree reset warning", {
|
|
1421
|
-
resetOk: resetResult.ok,
|
|
1422
|
-
cleanOk: cleanResult.ok,
|
|
1423
|
-
resetErr: resetResult.stderr,
|
|
1424
|
-
cleanErr: cleanResult.stderr,
|
|
1425
|
-
});
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
// If task failed or was paused, skip remaining tasks
|
|
1431
|
-
if (pollResult.status === "failed" || pollResult.status === "stalled") {
|
|
1432
|
-
shouldSkipRemaining = true;
|
|
1433
|
-
}
|
|
1434
|
-
if (pollResult.status === "skipped") {
|
|
1435
|
-
// Pause was signaled during poll — mark remaining as skipped too
|
|
1436
|
-
shouldSkipRemaining = true;
|
|
1437
|
-
}
|
|
1438
|
-
} catch (err: unknown) {
|
|
1439
|
-
// Spawn or polling error
|
|
1440
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1441
|
-
execLog(laneId, task.taskId, `execution error: ${errMsg}`);
|
|
1442
|
-
|
|
1443
|
-
taskOutcome = {
|
|
1444
|
-
taskId: task.taskId,
|
|
1445
|
-
status: "failed",
|
|
1446
|
-
startTime: taskStartTime,
|
|
1447
|
-
endTime: Date.now(),
|
|
1448
|
-
exitReason: errMsg,
|
|
1449
|
-
sessionName: lane.tmuxSessionName,
|
|
1450
|
-
doneFileFound: false,
|
|
1451
|
-
laneNumber: lane.laneNumber,
|
|
1452
|
-
};
|
|
1453
|
-
|
|
1454
|
-
shouldSkipRemaining = true;
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
const elapsed = Math.round(((taskOutcome.endTime || Date.now()) - taskStartTime) / 1000);
|
|
1458
|
-
execLog(laneId, task.taskId, `task ${taskOutcome.status}`, {
|
|
1459
|
-
elapsed: `${elapsed}s`,
|
|
1460
|
-
doneFile: taskOutcome.doneFileFound,
|
|
1461
|
-
});
|
|
1462
|
-
|
|
1463
|
-
outcomes.push(taskOutcome);
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
const laneEndTime = Date.now();
|
|
1467
|
-
const succeededCount = outcomes.filter((o) => o.status === "succeeded").length;
|
|
1468
|
-
const failedCount = outcomes.filter((o) => o.status === "failed" || o.status === "stalled").length;
|
|
1469
|
-
|
|
1470
|
-
let overallStatus: LaneExecutionResult["overallStatus"];
|
|
1471
|
-
if (failedCount === 0 && succeededCount === lane.tasks.length) {
|
|
1472
|
-
overallStatus = "succeeded";
|
|
1473
|
-
} else if (failedCount > 0 && succeededCount > 0) {
|
|
1474
|
-
overallStatus = "partial";
|
|
1475
|
-
} else {
|
|
1476
|
-
overallStatus = "failed";
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
const totalElapsed = Math.round((laneEndTime - laneStartTime) / 1000);
|
|
1480
|
-
execLog(laneId, "LANE", `execution complete: ${overallStatus}`, {
|
|
1481
|
-
succeeded: succeededCount,
|
|
1482
|
-
failed: failedCount,
|
|
1483
|
-
skipped: outcomes.filter((o) => o.status === "skipped").length,
|
|
1484
|
-
elapsed: `${totalElapsed}s`,
|
|
1485
|
-
});
|
|
1486
|
-
|
|
1487
|
-
return {
|
|
1488
|
-
laneNumber: lane.laneNumber,
|
|
1489
|
-
laneId: lane.laneId,
|
|
1490
|
-
tasks: outcomes,
|
|
1491
|
-
overallStatus,
|
|
1492
|
-
startTime: laneStartTime,
|
|
1493
|
-
endTime: laneEndTime,
|
|
1494
|
-
};
|
|
1495
|
-
}
|
|
1496
591
|
|
|
1497
592
|
|
|
1498
593
|
// ── STATUS.md Parsing for Worktree ───────────────────────────────────
|
|
@@ -1733,7 +828,7 @@ export async function parseWorktreeStatusMdAsync(
|
|
|
1733
828
|
* State-resolution precedence (deterministic):
|
|
1734
829
|
* 1. `.DONE` file found → "succeeded" (highest priority, always wins)
|
|
1735
830
|
* 2. Stall timeout reached (mtime unchanged for stall_timeout AND session alive) → "stalled"
|
|
1736
|
-
* 3.
|
|
831
|
+
* 3. Lane session ended without .DONE → "failed"
|
|
1737
832
|
* 4. Session alive + recent mtime (within stall_timeout) → "running"
|
|
1738
833
|
* 5. Session alive + stale mtime but within startup grace → "running" (with no stall timer yet)
|
|
1739
834
|
* 6. Session alive + no STATUS.md yet but within startup grace → "running"
|
|
@@ -1741,7 +836,7 @@ export async function parseWorktreeStatusMdAsync(
|
|
|
1741
836
|
*
|
|
1742
837
|
* @param taskId - Task identifier
|
|
1743
838
|
* @param donePath - Absolute path to the .DONE file in the worktree
|
|
1744
|
-
* @param sessionName -
|
|
839
|
+
* @param sessionName - Lane session name for this lane
|
|
1745
840
|
* @param statusResult - Parsed STATUS.md result (may be null)
|
|
1746
841
|
* @param tracker - Mtime tracker for stall detection
|
|
1747
842
|
* @param stallTimeoutMs - Stall timeout in milliseconds
|
|
@@ -1762,7 +857,7 @@ export async function resolveTaskMonitorState(
|
|
|
1762
857
|
// V2: read the lane snapshot file written by lane-runner every second.
|
|
1763
858
|
// Snapshot status is authoritative — no PID probing needed.
|
|
1764
859
|
// If snapshot doesn't exist yet, assume alive (lane-runner startup race).
|
|
1765
|
-
// Legacy: check
|
|
860
|
+
// Legacy: check lane-session liveness.
|
|
1766
861
|
let sessionAlive: boolean;
|
|
1767
862
|
if (runtimeBackend === "v2" && v2Context) {
|
|
1768
863
|
const snap = readLaneSnapshot(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber);
|
|
@@ -1773,10 +868,8 @@ export async function resolveTaskMonitorState(
|
|
|
1773
868
|
} else {
|
|
1774
869
|
sessionAlive = snap.status === "running";
|
|
1775
870
|
}
|
|
1776
|
-
} else if (runtimeBackend === "v2") {
|
|
1777
|
-
sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
|
|
1778
871
|
} else {
|
|
1779
|
-
sessionAlive =
|
|
872
|
+
sessionAlive = isV2AgentAlive(sessionName, "v2");
|
|
1780
873
|
}
|
|
1781
874
|
const doneFileFound = await fileExistsAsync(donePath);
|
|
1782
875
|
|
|
@@ -1874,11 +967,7 @@ export async function resolveTaskMonitorState(
|
|
|
1874
967
|
stallMinutes,
|
|
1875
968
|
backend: runtimeBackend ?? "legacy",
|
|
1876
969
|
});
|
|
1877
|
-
|
|
1878
|
-
killV2LaneAgents(sessionName);
|
|
1879
|
-
} else {
|
|
1880
|
-
killLaneAndChildren(sessionName);
|
|
1881
|
-
}
|
|
970
|
+
killV2LaneAgents(sessionName);
|
|
1882
971
|
|
|
1883
972
|
return {
|
|
1884
973
|
taskId,
|
|
@@ -1952,10 +1041,10 @@ export type MonitorUpdateCallback = (state: MonitorState) => void;
|
|
|
1952
1041
|
* Monitor all lanes in a wave, polling for progress, completion, and stalls.
|
|
1953
1042
|
*
|
|
1954
1043
|
* This is the orchestrator's "air traffic control" — it does NOT attach
|
|
1955
|
-
* to
|
|
1044
|
+
* to lane sessions directly. It monitors via filesystem polling:
|
|
1956
1045
|
* - STATUS.md in each worktree for step/checkbox progress
|
|
1957
1046
|
* - .DONE files for task completion
|
|
1958
|
-
* -
|
|
1047
|
+
* - backend liveness probes for session state
|
|
1959
1048
|
* - STATUS.md mtime for stall detection
|
|
1960
1049
|
*
|
|
1961
1050
|
* The monitoring loop runs until all lanes reach terminal states
|
|
@@ -2091,7 +1180,7 @@ export async function monitorLanes(
|
|
|
2091
1180
|
const snapshot = await resolveTaskMonitorState(
|
|
2092
1181
|
task.taskId,
|
|
2093
1182
|
donePath,
|
|
2094
|
-
lane
|
|
1183
|
+
laneSessionIdOf(lane),
|
|
2095
1184
|
statusResult,
|
|
2096
1185
|
tracker,
|
|
2097
1186
|
stallTimeoutMs,
|
|
@@ -2141,14 +1230,12 @@ export async function monitorLanes(
|
|
|
2141
1230
|
}
|
|
2142
1231
|
|
|
2143
1232
|
// TP-112: Backend-aware lane liveness for snapshot
|
|
2144
|
-
const sessionAlive =
|
|
2145
|
-
? isV2AgentAlive(lane.tmuxSessionName, runtimeBackend)
|
|
2146
|
-
: await tmuxHasSessionAsync(lane.tmuxSessionName);
|
|
1233
|
+
const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
|
|
2147
1234
|
|
|
2148
1235
|
laneSnapshots.push({
|
|
2149
1236
|
laneId: lane.laneId,
|
|
2150
1237
|
laneNumber: lane.laneNumber,
|
|
2151
|
-
sessionName: lane
|
|
1238
|
+
sessionName: laneSessionIdOf(lane),
|
|
2152
1239
|
sessionAlive,
|
|
2153
1240
|
currentTaskId,
|
|
2154
1241
|
currentTaskSnapshot,
|
|
@@ -2207,8 +1294,8 @@ export async function monitorLanes(
|
|
|
2207
1294
|
const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({
|
|
2208
1295
|
laneId: lane.laneId,
|
|
2209
1296
|
laneNumber: lane.laneNumber,
|
|
2210
|
-
sessionName: lane
|
|
2211
|
-
sessionAlive: false, // Best-effort during pause — don't block
|
|
1297
|
+
sessionName: laneSessionIdOf(lane),
|
|
1298
|
+
sessionAlive: false, // Best-effort during pause — don't block on extra liveness probes
|
|
2212
1299
|
currentTaskId: null,
|
|
2213
1300
|
currentTaskSnapshot: null,
|
|
2214
1301
|
completedTasks: [],
|
|
@@ -2388,11 +1475,11 @@ export function ensureTaskFilesCommitted(
|
|
|
2388
1475
|
* - **stop-wave**: On first failure, pauseSignal is set. In-flight tasks
|
|
2389
1476
|
* finish their current work, remaining tasks in lanes are skipped.
|
|
2390
1477
|
* No next wave is started (stoppedEarly=true).
|
|
2391
|
-
* - **stop-all**: On first failure, all
|
|
1478
|
+
* - **stop-all**: On first failure, all active lane sessions are killed immediately.
|
|
2392
1479
|
* Returns with aborted status.
|
|
2393
1480
|
*
|
|
2394
1481
|
* Concurrency model:
|
|
2395
|
-
* - Lane execution promises are NOT cancellable (
|
|
1482
|
+
* - Lane execution promises are NOT cancellable (lane sessions run externally)
|
|
2396
1483
|
* - stop-all kills sessions directly; executeLane() detects session death on next poll
|
|
2397
1484
|
* - Monitoring stops when all lanes reach terminal state or pauseSignal is set
|
|
2398
1485
|
*
|
|
@@ -2413,7 +1500,7 @@ export function ensureTaskFilesCommitted(
|
|
|
2413
1500
|
/**
|
|
2414
1501
|
* Runtime backend selector for lane execution.
|
|
2415
1502
|
*
|
|
2416
|
-
* - `"legacy"`:
|
|
1503
|
+
* - `"legacy"`: Session-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
|
|
2417
1504
|
* - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
|
|
2418
1505
|
*
|
|
2419
1506
|
* @since TP-105
|
|
@@ -2517,15 +1604,14 @@ export async function executeWave(
|
|
|
2517
1604
|
// configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
|
|
2518
1605
|
const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
|
|
2519
1606
|
const isWsMode = !!workspaceConfig;
|
|
2520
|
-
const backend =
|
|
2521
|
-
if (
|
|
2522
|
-
execLog("wave", `W${waveIndex}`,
|
|
1607
|
+
const backend: RuntimeBackend = "v2";
|
|
1608
|
+
if (runtimeBackend && runtimeBackend !== "v2") {
|
|
1609
|
+
execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`);
|
|
2523
1610
|
}
|
|
1611
|
+
execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
|
|
2524
1612
|
|
|
2525
1613
|
const lanePromises = lanes.map(lane =>
|
|
2526
|
-
|
|
2527
|
-
? executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert)
|
|
2528
|
-
: executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }),
|
|
1614
|
+
executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert),
|
|
2529
1615
|
);
|
|
2530
1616
|
|
|
2531
1617
|
// Start monitoring as a sibling async loop
|
|
@@ -2578,7 +1664,7 @@ export async function executeWave(
|
|
|
2578
1664
|
startTime: null,
|
|
2579
1665
|
endTime: null,
|
|
2580
1666
|
exitReason: `Lane promise rejected: ${errMsg}`,
|
|
2581
|
-
sessionName: lanes[idx]
|
|
1667
|
+
sessionName: laneSessionIdOf(lanes[idx]),
|
|
2582
1668
|
doneFileFound: false,
|
|
2583
1669
|
laneNumber: lanes[idx].laneNumber,
|
|
2584
1670
|
})),
|
|
@@ -2695,7 +1781,7 @@ export async function executeWave(
|
|
|
2695
1781
|
* Execute lanes with stop-all failure policy.
|
|
2696
1782
|
*
|
|
2697
1783
|
* Starts all lanes, then monitors for the first failure.
|
|
2698
|
-
* On first failure: kills all
|
|
1784
|
+
* On first failure: kills all active lane sessions immediately and returns.
|
|
2699
1785
|
*
|
|
2700
1786
|
* Uses a race pattern: wraps each lane promise to signal on failure,
|
|
2701
1787
|
* then kills all sessions when first failure is detected.
|
|
@@ -2748,12 +1834,12 @@ export async function executeWithStopAll(
|
|
|
2748
1834
|
})[0];
|
|
2749
1835
|
|
|
2750
1836
|
execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, {
|
|
2751
|
-
session: lanes[idx]
|
|
1837
|
+
session: laneSessionIdOf(lanes[idx]),
|
|
2752
1838
|
});
|
|
2753
1839
|
|
|
2754
1840
|
// Kill ALL lane sessions immediately
|
|
2755
1841
|
for (const lane of lanes) {
|
|
2756
|
-
|
|
1842
|
+
killV2LaneAgents(laneSessionIdOf(lane));
|
|
2757
1843
|
}
|
|
2758
1844
|
}
|
|
2759
1845
|
}
|
|
@@ -2767,7 +1853,7 @@ export async function executeWithStopAll(
|
|
|
2767
1853
|
pauseSignal.paused = true;
|
|
2768
1854
|
execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
|
|
2769
1855
|
for (const lane of lanes) {
|
|
2770
|
-
|
|
1856
|
+
killV2LaneAgents(laneSessionIdOf(lane));
|
|
2771
1857
|
}
|
|
2772
1858
|
}
|
|
2773
1859
|
|
|
@@ -2781,7 +1867,7 @@ export async function executeWithStopAll(
|
|
|
2781
1867
|
startTime: null,
|
|
2782
1868
|
endTime: null,
|
|
2783
1869
|
exitReason: `Lane aborted: ${errMsg}`,
|
|
2784
|
-
sessionName: lanes[idx]
|
|
1870
|
+
sessionName: laneSessionIdOf(lanes[idx]),
|
|
2785
1871
|
doneFileFound: false,
|
|
2786
1872
|
laneNumber: lanes[idx].laneNumber,
|
|
2787
1873
|
})),
|
|
@@ -2816,7 +1902,7 @@ export async function executeWithStopAll(
|
|
|
2816
1902
|
//
|
|
2817
1903
|
// They are additive — existing code paths continue to work.
|
|
2818
1904
|
// Runtime V2 consumers can start using these to avoid coupling to
|
|
2819
|
-
//
|
|
1905
|
+
// legacy lane-session naming, cwd-derived paths, or extension lifecycle assumptions.
|
|
2820
1906
|
// ────────────────────────────────────────────────────────────────────────────
|
|
2821
1907
|
|
|
2822
1908
|
/**
|
|
@@ -2893,11 +1979,11 @@ export function buildExecutionUnit(
|
|
|
2893
1979
|
/**
|
|
2894
1980
|
* Build a RuntimeAgentId for a lane's agent from existing naming.
|
|
2895
1981
|
*
|
|
2896
|
-
* Bridges the current
|
|
1982
|
+
* Bridges the current lane-session naming convention into a
|
|
2897
1983
|
* Runtime V2 stable agent ID. The output is compatible with
|
|
2898
1984
|
* existing supervisor tools and mailbox addressing.
|
|
2899
1985
|
*
|
|
2900
|
-
* @param lane - Allocated lane with
|
|
1986
|
+
* @param lane - Allocated lane with a lane session name
|
|
2901
1987
|
* @param role - Agent role
|
|
2902
1988
|
* @param mergeIndex - Merge wave index (only for merge agents)
|
|
2903
1989
|
* @returns Canonical agent ID
|
|
@@ -2909,18 +1995,18 @@ export function buildAgentIdFromLane(
|
|
|
2909
1995
|
role: RuntimeAgentRole,
|
|
2910
1996
|
mergeIndex?: number,
|
|
2911
1997
|
): RuntimeAgentId {
|
|
2912
|
-
// The current
|
|
1998
|
+
// The current laneSessionId is already in the right format
|
|
2913
1999
|
// (e.g., "orch-henrylach-lane-1"). We derive agent IDs from it
|
|
2914
2000
|
// by appending the role suffix, matching the existing convention.
|
|
2915
2001
|
if (role === "merger" && mergeIndex != null) {
|
|
2916
2002
|
// Merge agents use a different naming pattern
|
|
2917
|
-
const prefix = lane.
|
|
2003
|
+
const prefix = laneSessionIdOf(lane).replace(/-lane-\d+$/, "");
|
|
2918
2004
|
return `${prefix}-merge-${mergeIndex}`;
|
|
2919
2005
|
}
|
|
2920
2006
|
if (role === "lane-runner") {
|
|
2921
|
-
return lane
|
|
2007
|
+
return laneSessionIdOf(lane);
|
|
2922
2008
|
}
|
|
2923
|
-
return `${lane
|
|
2009
|
+
return `${laneSessionIdOf(lane)}-${role}`;
|
|
2924
2010
|
}
|
|
2925
2011
|
|
|
2926
2012
|
/**
|
|
@@ -3029,14 +2115,14 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
|
|
|
3029
2115
|
/**
|
|
3030
2116
|
* Execute a lane using the Runtime V2 headless backend.
|
|
3031
2117
|
*
|
|
3032
|
-
* This replaces the legacy
|
|
2118
|
+
* This replaces the legacy session-backed `executeLane()` for lanes that
|
|
3033
2119
|
* should run on the new direct-child architecture. It uses the
|
|
3034
2120
|
* lane-runner module which spawns workers via agent-host.ts instead
|
|
3035
|
-
* of
|
|
2121
|
+
* of terminal-session-backed workers.
|
|
3036
2122
|
*
|
|
3037
2123
|
* The function signature is deliberately close to the legacy
|
|
3038
2124
|
* `executeLane()` to minimize integration churn in the engine.
|
|
3039
|
-
* The key difference: no
|
|
2125
|
+
* The key difference: no legacy lane sessions are created.
|
|
3040
2126
|
*
|
|
3041
2127
|
* @since TP-105
|
|
3042
2128
|
*/
|
|
@@ -3059,10 +2145,10 @@ export async function executeLaneV2(
|
|
|
3059
2145
|
const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now());
|
|
3060
2146
|
|
|
3061
2147
|
// Build agent ID prefix — must match the wave planner's naming (TP-115).
|
|
3062
|
-
// Uses resolveOperatorId() so agent registry keys align with
|
|
3063
|
-
const
|
|
2148
|
+
// Uses resolveOperatorId() so agent registry keys align with lane session IDs.
|
|
2149
|
+
const sessionPrefix = config.orchestrator?.sessionPrefix ?? "orch";
|
|
3064
2150
|
const opId = resolveOperatorId(config);
|
|
3065
|
-
const agentIdPrefix = `${
|
|
2151
|
+
const agentIdPrefix = `${sessionPrefix}-${opId}`;
|
|
3066
2152
|
|
|
3067
2153
|
// Load worker agent definition: compose base template + local project guidance.
|
|
3068
2154
|
// The base template (templates/agents/task-worker.md) contains critical behavioral
|