taskplane 0.23.16 → 0.24.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/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 +1158 -1010
- 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 +100 -1006
- 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 +3 -3
- 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
|
-
});
|
|
356
|
-
|
|
357
|
-
proc.on("error", () => {
|
|
358
|
-
// Spawn failure — treat as non-zero exit
|
|
359
|
-
resolve({ status: 1, stdout: "" });
|
|
360
|
-
});
|
|
227
|
+
// ── Async File/Status Helpers (TP-070) ───────────────────────────────
|
|
361
228
|
|
|
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
|
-
}
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Build the tmux new-session command for spawning a lane.
|
|
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
|
-
}
|
|
268
|
+
// buildLaneEnvVars removed (TP-120 remediation: legacy lane-session env var path, dead code)
|
|
683
269
|
|
|
684
|
-
|
|
685
|
-
|
|
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
515
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Poll until a task completes (or fails).
|
|
1073
|
-
*
|
|
1074
|
-
* Completion detection logic:
|
|
1075
|
-
* 1. Check for .DONE file → task succeeded (highest priority)
|
|
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
|
|
@@ -1758,25 +853,31 @@ export async function resolveTaskMonitorState(
|
|
|
1758
853
|
runtimeBackend?: RuntimeBackend,
|
|
1759
854
|
v2Context?: { stateRoot: string; batchId: string; laneNumber: number },
|
|
1760
855
|
): Promise<TaskMonitorSnapshot> {
|
|
1761
|
-
// TP-115: Backend-aware liveness check.
|
|
856
|
+
// TP-115/TP-127: Backend-aware liveness check.
|
|
1762
857
|
// V2: read the lane snapshot file written by lane-runner every second.
|
|
1763
|
-
// Snapshot status is authoritative — no PID probing needed.
|
|
1764
858
|
// If snapshot doesn't exist yet, assume alive (lane-runner startup race).
|
|
1765
|
-
//
|
|
859
|
+
// If snapshot belongs to a different task, it's stale transition data from
|
|
860
|
+
// the previous wave/task and should be treated like startup grace (alive).
|
|
861
|
+
// Legacy: check lane-session liveness.
|
|
1766
862
|
let sessionAlive: boolean;
|
|
1767
863
|
if (runtimeBackend === "v2" && v2Context) {
|
|
1768
864
|
const snap = readLaneSnapshot(v2Context.stateRoot, v2Context.batchId, v2Context.laneNumber);
|
|
1769
|
-
if (snap == null) {
|
|
1770
|
-
// Snapshot not written yet
|
|
1771
|
-
// Assume alive
|
|
1772
|
-
|
|
865
|
+
if (snap == null || snap.taskId !== taskId) {
|
|
866
|
+
// Snapshot not written yet OR snapshot still points to a prior task.
|
|
867
|
+
// Assume alive initially, but if stale for >30s consult the registry
|
|
868
|
+
// to avoid indefinite false "running" if the lane-runner died.
|
|
869
|
+
const staleMs = snap?.updatedAt ? (now - snap.updatedAt) : 0;
|
|
870
|
+
if (staleMs > 30_000) {
|
|
871
|
+
// Snapshot hasn't been updated for 30s+ — check registry as fallback
|
|
872
|
+
sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
|
|
873
|
+
} else {
|
|
874
|
+
sessionAlive = true;
|
|
875
|
+
}
|
|
1773
876
|
} else {
|
|
1774
877
|
sessionAlive = snap.status === "running";
|
|
1775
878
|
}
|
|
1776
|
-
} else if (runtimeBackend === "v2") {
|
|
1777
|
-
sessionAlive = isV2AgentAlive(sessionName, runtimeBackend);
|
|
1778
879
|
} else {
|
|
1779
|
-
sessionAlive =
|
|
880
|
+
sessionAlive = isV2AgentAlive(sessionName, "v2");
|
|
1780
881
|
}
|
|
1781
882
|
const doneFileFound = await fileExistsAsync(donePath);
|
|
1782
883
|
|
|
@@ -1874,11 +975,7 @@ export async function resolveTaskMonitorState(
|
|
|
1874
975
|
stallMinutes,
|
|
1875
976
|
backend: runtimeBackend ?? "legacy",
|
|
1876
977
|
});
|
|
1877
|
-
|
|
1878
|
-
killV2LaneAgents(sessionName);
|
|
1879
|
-
} else {
|
|
1880
|
-
killLaneAndChildren(sessionName);
|
|
1881
|
-
}
|
|
978
|
+
killV2LaneAgents(sessionName);
|
|
1882
979
|
|
|
1883
980
|
return {
|
|
1884
981
|
taskId,
|
|
@@ -1952,10 +1049,10 @@ export type MonitorUpdateCallback = (state: MonitorState) => void;
|
|
|
1952
1049
|
* Monitor all lanes in a wave, polling for progress, completion, and stalls.
|
|
1953
1050
|
*
|
|
1954
1051
|
* This is the orchestrator's "air traffic control" — it does NOT attach
|
|
1955
|
-
* to
|
|
1052
|
+
* to lane sessions directly. It monitors via filesystem polling:
|
|
1956
1053
|
* - STATUS.md in each worktree for step/checkbox progress
|
|
1957
1054
|
* - .DONE files for task completion
|
|
1958
|
-
* -
|
|
1055
|
+
* - backend liveness probes for session state
|
|
1959
1056
|
* - STATUS.md mtime for stall detection
|
|
1960
1057
|
*
|
|
1961
1058
|
* The monitoring loop runs until all lanes reach terminal states
|
|
@@ -2091,7 +1188,7 @@ export async function monitorLanes(
|
|
|
2091
1188
|
const snapshot = await resolveTaskMonitorState(
|
|
2092
1189
|
task.taskId,
|
|
2093
1190
|
donePath,
|
|
2094
|
-
lane
|
|
1191
|
+
laneSessionIdOf(lane),
|
|
2095
1192
|
statusResult,
|
|
2096
1193
|
tracker,
|
|
2097
1194
|
stallTimeoutMs,
|
|
@@ -2141,14 +1238,12 @@ export async function monitorLanes(
|
|
|
2141
1238
|
}
|
|
2142
1239
|
|
|
2143
1240
|
// TP-112: Backend-aware lane liveness for snapshot
|
|
2144
|
-
const sessionAlive =
|
|
2145
|
-
? isV2AgentAlive(lane.tmuxSessionName, runtimeBackend)
|
|
2146
|
-
: await tmuxHasSessionAsync(lane.tmuxSessionName);
|
|
1241
|
+
const sessionAlive = isV2AgentAlive(laneSessionIdOf(lane), "v2");
|
|
2147
1242
|
|
|
2148
1243
|
laneSnapshots.push({
|
|
2149
1244
|
laneId: lane.laneId,
|
|
2150
1245
|
laneNumber: lane.laneNumber,
|
|
2151
|
-
sessionName: lane
|
|
1246
|
+
sessionName: laneSessionIdOf(lane),
|
|
2152
1247
|
sessionAlive,
|
|
2153
1248
|
currentTaskId,
|
|
2154
1249
|
currentTaskSnapshot,
|
|
@@ -2207,8 +1302,8 @@ export async function monitorLanes(
|
|
|
2207
1302
|
const laneSnapshots: LaneMonitorSnapshot[] = lanes.map(lane => ({
|
|
2208
1303
|
laneId: lane.laneId,
|
|
2209
1304
|
laneNumber: lane.laneNumber,
|
|
2210
|
-
sessionName: lane
|
|
2211
|
-
sessionAlive: false, // Best-effort during pause — don't block
|
|
1305
|
+
sessionName: laneSessionIdOf(lane),
|
|
1306
|
+
sessionAlive: false, // Best-effort during pause — don't block on extra liveness probes
|
|
2212
1307
|
currentTaskId: null,
|
|
2213
1308
|
currentTaskSnapshot: null,
|
|
2214
1309
|
completedTasks: [],
|
|
@@ -2388,11 +1483,11 @@ export function ensureTaskFilesCommitted(
|
|
|
2388
1483
|
* - **stop-wave**: On first failure, pauseSignal is set. In-flight tasks
|
|
2389
1484
|
* finish their current work, remaining tasks in lanes are skipped.
|
|
2390
1485
|
* No next wave is started (stoppedEarly=true).
|
|
2391
|
-
* - **stop-all**: On first failure, all
|
|
1486
|
+
* - **stop-all**: On first failure, all active lane sessions are killed immediately.
|
|
2392
1487
|
* Returns with aborted status.
|
|
2393
1488
|
*
|
|
2394
1489
|
* Concurrency model:
|
|
2395
|
-
* - Lane execution promises are NOT cancellable (
|
|
1490
|
+
* - Lane execution promises are NOT cancellable (lane sessions run externally)
|
|
2396
1491
|
* - stop-all kills sessions directly; executeLane() detects session death on next poll
|
|
2397
1492
|
* - Monitoring stops when all lanes reach terminal state or pauseSignal is set
|
|
2398
1493
|
*
|
|
@@ -2413,7 +1508,7 @@ export function ensureTaskFilesCommitted(
|
|
|
2413
1508
|
/**
|
|
2414
1509
|
* Runtime backend selector for lane execution.
|
|
2415
1510
|
*
|
|
2416
|
-
* - `"legacy"`:
|
|
1511
|
+
* - `"legacy"`: Session-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
|
|
2417
1512
|
* - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
|
|
2418
1513
|
*
|
|
2419
1514
|
* @since TP-105
|
|
@@ -2517,15 +1612,14 @@ export async function executeWave(
|
|
|
2517
1612
|
// configPath is .pi/taskplane-workspace.yaml → parent of parent is workspace root.
|
|
2518
1613
|
const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
|
|
2519
1614
|
const isWsMode = !!workspaceConfig;
|
|
2520
|
-
const backend =
|
|
2521
|
-
if (
|
|
2522
|
-
execLog("wave", `W${waveIndex}`,
|
|
1615
|
+
const backend: RuntimeBackend = "v2";
|
|
1616
|
+
if (runtimeBackend && runtimeBackend !== "v2") {
|
|
1617
|
+
execLog("wave", `W${waveIndex}`, `legacy runtime backend '${runtimeBackend}' requested but ignored; using Runtime V2`);
|
|
2523
1618
|
}
|
|
1619
|
+
execLog("wave", `W${waveIndex}`, "using Runtime V2 backend (executeLaneV2)");
|
|
2524
1620
|
|
|
2525
1621
|
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 }),
|
|
1622
|
+
executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, { ORCH_BATCH_ID: batchId }, onSupervisorAlert),
|
|
2529
1623
|
);
|
|
2530
1624
|
|
|
2531
1625
|
// Start monitoring as a sibling async loop
|
|
@@ -2578,7 +1672,7 @@ export async function executeWave(
|
|
|
2578
1672
|
startTime: null,
|
|
2579
1673
|
endTime: null,
|
|
2580
1674
|
exitReason: `Lane promise rejected: ${errMsg}`,
|
|
2581
|
-
sessionName: lanes[idx]
|
|
1675
|
+
sessionName: laneSessionIdOf(lanes[idx]),
|
|
2582
1676
|
doneFileFound: false,
|
|
2583
1677
|
laneNumber: lanes[idx].laneNumber,
|
|
2584
1678
|
})),
|
|
@@ -2695,7 +1789,7 @@ export async function executeWave(
|
|
|
2695
1789
|
* Execute lanes with stop-all failure policy.
|
|
2696
1790
|
*
|
|
2697
1791
|
* Starts all lanes, then monitors for the first failure.
|
|
2698
|
-
* On first failure: kills all
|
|
1792
|
+
* On first failure: kills all active lane sessions immediately and returns.
|
|
2699
1793
|
*
|
|
2700
1794
|
* Uses a race pattern: wraps each lane promise to signal on failure,
|
|
2701
1795
|
* then kills all sessions when first failure is detected.
|
|
@@ -2748,12 +1842,12 @@ export async function executeWithStopAll(
|
|
|
2748
1842
|
})[0];
|
|
2749
1843
|
|
|
2750
1844
|
execLog("wave", `W${waveIndex}`, `stop-all triggered by ${firstFailed?.taskId || "unknown"} in ${lanes[idx].laneId}`, {
|
|
2751
|
-
session: lanes[idx]
|
|
1845
|
+
session: laneSessionIdOf(lanes[idx]),
|
|
2752
1846
|
});
|
|
2753
1847
|
|
|
2754
1848
|
// Kill ALL lane sessions immediately
|
|
2755
1849
|
for (const lane of lanes) {
|
|
2756
|
-
|
|
1850
|
+
killV2LaneAgents(laneSessionIdOf(lane));
|
|
2757
1851
|
}
|
|
2758
1852
|
}
|
|
2759
1853
|
}
|
|
@@ -2767,7 +1861,7 @@ export async function executeWithStopAll(
|
|
|
2767
1861
|
pauseSignal.paused = true;
|
|
2768
1862
|
execLog("wave", `W${waveIndex}`, `stop-all triggered by lane error in ${lanes[idx].laneId}: ${errMsg}`);
|
|
2769
1863
|
for (const lane of lanes) {
|
|
2770
|
-
|
|
1864
|
+
killV2LaneAgents(laneSessionIdOf(lane));
|
|
2771
1865
|
}
|
|
2772
1866
|
}
|
|
2773
1867
|
|
|
@@ -2781,7 +1875,7 @@ export async function executeWithStopAll(
|
|
|
2781
1875
|
startTime: null,
|
|
2782
1876
|
endTime: null,
|
|
2783
1877
|
exitReason: `Lane aborted: ${errMsg}`,
|
|
2784
|
-
sessionName: lanes[idx]
|
|
1878
|
+
sessionName: laneSessionIdOf(lanes[idx]),
|
|
2785
1879
|
doneFileFound: false,
|
|
2786
1880
|
laneNumber: lanes[idx].laneNumber,
|
|
2787
1881
|
})),
|
|
@@ -2816,7 +1910,7 @@ export async function executeWithStopAll(
|
|
|
2816
1910
|
//
|
|
2817
1911
|
// They are additive — existing code paths continue to work.
|
|
2818
1912
|
// Runtime V2 consumers can start using these to avoid coupling to
|
|
2819
|
-
//
|
|
1913
|
+
// legacy lane-session naming, cwd-derived paths, or extension lifecycle assumptions.
|
|
2820
1914
|
// ────────────────────────────────────────────────────────────────────────────
|
|
2821
1915
|
|
|
2822
1916
|
/**
|
|
@@ -2893,11 +1987,11 @@ export function buildExecutionUnit(
|
|
|
2893
1987
|
/**
|
|
2894
1988
|
* Build a RuntimeAgentId for a lane's agent from existing naming.
|
|
2895
1989
|
*
|
|
2896
|
-
* Bridges the current
|
|
1990
|
+
* Bridges the current lane-session naming convention into a
|
|
2897
1991
|
* Runtime V2 stable agent ID. The output is compatible with
|
|
2898
1992
|
* existing supervisor tools and mailbox addressing.
|
|
2899
1993
|
*
|
|
2900
|
-
* @param lane - Allocated lane with
|
|
1994
|
+
* @param lane - Allocated lane with a lane session name
|
|
2901
1995
|
* @param role - Agent role
|
|
2902
1996
|
* @param mergeIndex - Merge wave index (only for merge agents)
|
|
2903
1997
|
* @returns Canonical agent ID
|
|
@@ -2909,18 +2003,18 @@ export function buildAgentIdFromLane(
|
|
|
2909
2003
|
role: RuntimeAgentRole,
|
|
2910
2004
|
mergeIndex?: number,
|
|
2911
2005
|
): RuntimeAgentId {
|
|
2912
|
-
// The current
|
|
2006
|
+
// The current laneSessionId is already in the right format
|
|
2913
2007
|
// (e.g., "orch-henrylach-lane-1"). We derive agent IDs from it
|
|
2914
2008
|
// by appending the role suffix, matching the existing convention.
|
|
2915
2009
|
if (role === "merger" && mergeIndex != null) {
|
|
2916
2010
|
// Merge agents use a different naming pattern
|
|
2917
|
-
const prefix = lane.
|
|
2011
|
+
const prefix = laneSessionIdOf(lane).replace(/-lane-\d+$/, "");
|
|
2918
2012
|
return `${prefix}-merge-${mergeIndex}`;
|
|
2919
2013
|
}
|
|
2920
2014
|
if (role === "lane-runner") {
|
|
2921
|
-
return lane
|
|
2015
|
+
return laneSessionIdOf(lane);
|
|
2922
2016
|
}
|
|
2923
|
-
return `${lane
|
|
2017
|
+
return `${laneSessionIdOf(lane)}-${role}`;
|
|
2924
2018
|
}
|
|
2925
2019
|
|
|
2926
2020
|
/**
|
|
@@ -3029,14 +2123,14 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
|
|
|
3029
2123
|
/**
|
|
3030
2124
|
* Execute a lane using the Runtime V2 headless backend.
|
|
3031
2125
|
*
|
|
3032
|
-
* This replaces the legacy
|
|
2126
|
+
* This replaces the legacy session-backed `executeLane()` for lanes that
|
|
3033
2127
|
* should run on the new direct-child architecture. It uses the
|
|
3034
2128
|
* lane-runner module which spawns workers via agent-host.ts instead
|
|
3035
|
-
* of
|
|
2129
|
+
* of terminal-session-backed workers.
|
|
3036
2130
|
*
|
|
3037
2131
|
* The function signature is deliberately close to the legacy
|
|
3038
2132
|
* `executeLane()` to minimize integration churn in the engine.
|
|
3039
|
-
* The key difference: no
|
|
2133
|
+
* The key difference: no legacy lane sessions are created.
|
|
3040
2134
|
*
|
|
3041
2135
|
* @since TP-105
|
|
3042
2136
|
*/
|
|
@@ -3059,10 +2153,10 @@ export async function executeLaneV2(
|
|
|
3059
2153
|
const batchId = config.orchestrator?.batchId || extraEnvVars?.ORCH_BATCH_ID || String(Date.now());
|
|
3060
2154
|
|
|
3061
2155
|
// Build agent ID prefix — must match the wave planner's naming (TP-115).
|
|
3062
|
-
// Uses resolveOperatorId() so agent registry keys align with
|
|
3063
|
-
const
|
|
2156
|
+
// Uses resolveOperatorId() so agent registry keys align with lane session IDs.
|
|
2157
|
+
const sessionPrefix = config.orchestrator?.sessionPrefix ?? "orch";
|
|
3064
2158
|
const opId = resolveOperatorId(config);
|
|
3065
|
-
const agentIdPrefix = `${
|
|
2159
|
+
const agentIdPrefix = `${sessionPrefix}-${opId}`;
|
|
3066
2160
|
|
|
3067
2161
|
// Load worker agent definition: compose base template + local project guidance.
|
|
3068
2162
|
// The base template (templates/agents/task-worker.md) contains critical behavioral
|