taskplane 0.24.3 → 0.24.4
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.
|
@@ -88,7 +88,7 @@ interface TaskConfig {
|
|
|
88
88
|
model: string;
|
|
89
89
|
tools: string;
|
|
90
90
|
thinking: string;
|
|
91
|
-
spawn_mode?: "subprocess"
|
|
91
|
+
spawn_mode?: "subprocess";
|
|
92
92
|
};
|
|
93
93
|
reviewer: { model: string; tools: string; thinking: string };
|
|
94
94
|
context: {
|
|
@@ -152,7 +152,7 @@ interface TaskState {
|
|
|
152
152
|
workerRetryActive: boolean;
|
|
153
153
|
workerRetryCount: number;
|
|
154
154
|
workerLastRetryError: string;
|
|
155
|
-
/** Structured exit diagnostic from the most recent
|
|
155
|
+
/** Structured exit diagnostic from the most recent worker iteration (reserved for compatibility). */
|
|
156
156
|
workerExitDiagnostic: TaskExitDiagnostic | null;
|
|
157
157
|
reviewerStatus: "idle" | "running" | "done" | "error";
|
|
158
158
|
reviewerType: string;
|
|
@@ -170,13 +170,13 @@ interface TaskState {
|
|
|
170
170
|
reviewerProc: any;
|
|
171
171
|
reviewerTimer: any;
|
|
172
172
|
reviewCounter: number;
|
|
173
|
-
/**
|
|
173
|
+
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
174
174
|
persistentReviewerSession: string | null;
|
|
175
|
-
/**
|
|
175
|
+
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
176
176
|
persistentReviewerKill: (() => void) | null;
|
|
177
|
-
/**
|
|
177
|
+
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
178
178
|
persistentReviewerSignalNum: number;
|
|
179
|
-
/**
|
|
179
|
+
/** Reserved for compatibility with legacy lane-state payloads. */
|
|
180
180
|
reviewerRespawnCount: number;
|
|
181
181
|
totalIterations: number;
|
|
182
182
|
stepStatuses: Map<number, StepInfo>;
|
|
@@ -302,54 +302,32 @@ export function loadConfig(cwd: string): TaskConfig {
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
// ──
|
|
305
|
+
// ── Runtime Mode Helpers ─────────────────────────────────────────────
|
|
306
306
|
|
|
307
307
|
/**
|
|
308
|
-
*
|
|
309
|
-
* (existing behavior) or as TMUX sessions (parallel orchestrator mode).
|
|
308
|
+
* Detect whether this runner is executing under /orch orchestration.
|
|
310
309
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
310
|
+
* Runtime V2 exposes ORCH_BATCH_ID for lane workers. We also keep
|
|
311
|
+
* TASK_RUNNER_TMUX_PREFIX as a legacy signal so older launchers are still
|
|
312
|
+
* treated as orchestrated mode during migration.
|
|
313
313
|
*/
|
|
314
|
-
function
|
|
315
|
-
|
|
316
|
-
if (envMode === "tmux" || envMode === "subprocess") return envMode;
|
|
317
|
-
if (config.worker.spawn_mode === "tmux" || config.worker.spawn_mode === "subprocess") {
|
|
318
|
-
return config.worker.spawn_mode;
|
|
319
|
-
}
|
|
320
|
-
return "subprocess";
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Returns the TMUX session name prefix for worker/reviewer sessions.
|
|
325
|
-
* The orchestrator sets TASK_RUNNER_TMUX_PREFIX per-lane (e.g., "orch-lane-1").
|
|
326
|
-
* Worker sessions become "{prefix}-worker", reviewer sessions "{prefix}-reviewer".
|
|
327
|
-
*/
|
|
328
|
-
function getTmuxPrefix(): string {
|
|
329
|
-
return process.env.TASK_RUNNER_TMUX_PREFIX || "task";
|
|
314
|
+
function isOrchestratedMode(): boolean {
|
|
315
|
+
return !!process.env.ORCH_BATCH_ID || !!process.env.TASK_RUNNER_TMUX_PREFIX;
|
|
330
316
|
}
|
|
331
317
|
|
|
332
318
|
/**
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* TASK_RUNNER_TMUX_PREFIX is only ever set by the orchestrator (via execution.ts
|
|
336
|
-
* buildLaneEnv). Its presence — regardless of value — indicates orchestrated mode.
|
|
337
|
-
* The prefix can be any user-configured value (e.g., "orch-lane-1", "penster-lane-1").
|
|
338
|
-
*
|
|
339
|
-
* When true, certain worker behaviors are suppressed — most notably, workers
|
|
340
|
-
* must NOT archive task folders because the orchestrator polls for .DONE files
|
|
341
|
-
* at the original path.
|
|
319
|
+
* Returns the lane/session prefix used for sidecar filenames.
|
|
342
320
|
*/
|
|
343
|
-
function
|
|
344
|
-
return
|
|
321
|
+
function getLanePrefix(): string {
|
|
322
|
+
return process.env.TASKPLANE_LANE_PREFIX
|
|
323
|
+
|| process.env.TASK_RUNNER_TMUX_PREFIX
|
|
324
|
+
|| "task";
|
|
345
325
|
}
|
|
346
326
|
|
|
347
327
|
/**
|
|
348
|
-
* Returns
|
|
349
|
-
* Used instead of context-% based kill (no JSON stream in TMUX mode).
|
|
328
|
+
* Returns worker wall-clock timeout in minutes.
|
|
350
329
|
*
|
|
351
330
|
* Resolution order: env var → config → default 30 minutes.
|
|
352
|
-
* Reviewers do NOT use this timeout — they run to session completion.
|
|
353
331
|
*/
|
|
354
332
|
function getMaxWorkerMinutes(config: TaskConfig): number {
|
|
355
333
|
const envVal = process.env.TASK_RUNNER_MAX_WORKER_MINUTES;
|
|
@@ -436,7 +414,7 @@ function getSidecarDir(): string {
|
|
|
436
414
|
*/
|
|
437
415
|
function writeLaneState(state: TaskState): void {
|
|
438
416
|
if (!isOrchestratedMode()) return;
|
|
439
|
-
const prefix =
|
|
417
|
+
const prefix = getLanePrefix(); // e.g., "orch-lane-1"
|
|
440
418
|
const filePath = join(getSidecarDir(), `lane-state-${prefix}.json`);
|
|
441
419
|
try {
|
|
442
420
|
const data = {
|
|
@@ -489,7 +467,7 @@ function writeLaneState(state: TaskState): void {
|
|
|
489
467
|
*/
|
|
490
468
|
function writeContextSnapshot(state: TaskState, contextWindow: number): void {
|
|
491
469
|
const batchId = process.env.ORCH_BATCH_ID || "standalone";
|
|
492
|
-
const sessionName = isOrchestratedMode() ? `${
|
|
470
|
+
const sessionName = isOrchestratedMode() ? `${getLanePrefix()}-worker` : "task-worker";
|
|
493
471
|
try {
|
|
494
472
|
const dir = join(getSidecarDir(), "context-snapshots", batchId);
|
|
495
473
|
mkdirSync(dir, { recursive: true });
|
|
@@ -1337,679 +1315,14 @@ export const _resolveContextWindow = resolveContextWindow;
|
|
|
1337
1315
|
export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
|
|
1338
1316
|
export type { SidecarTailState, SidecarTelemetryDelta };
|
|
1339
1317
|
|
|
1340
|
-
// ── Stable Sidecar Path Generation (TP-097) ─────────────────────────
|
|
1341
|
-
|
|
1342
|
-
/**
|
|
1343
|
-
* Generate a deterministic telemetry basename for sidecar/exit-summary files.
|
|
1344
|
-
*
|
|
1345
|
-
* The basename is stable per session (not per spawn attempt). When called
|
|
1346
|
-
* once before the iteration loop and reused, it ensures that:
|
|
1347
|
-
* - Crash recovery writes to the same sidecar file (tailing resumes)
|
|
1348
|
-
* - Exit summaries overwrite the same file (latest wins)
|
|
1349
|
-
*
|
|
1350
|
-
* Naming contract:
|
|
1351
|
-
* {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
|
|
1352
|
-
*
|
|
1353
|
-
* @param sessionName — TMUX session name (e.g., "orch-lane-1-worker")
|
|
1354
|
-
* @param taskId — Optional task ID for enrichment (e.g., "TP-097")
|
|
1355
|
-
* @returns Object with sidecarPath and exitSummaryPath
|
|
1356
|
-
*/
|
|
1357
|
-
function generateStableSidecarPaths(sessionName: string, taskId?: string): {
|
|
1358
|
-
sidecarPath: string;
|
|
1359
|
-
exitSummaryPath: string;
|
|
1360
|
-
} {
|
|
1361
|
-
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
1362
|
-
let opId = "op";
|
|
1363
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1364
|
-
if (envOpId?.trim()) {
|
|
1365
|
-
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1366
|
-
} else {
|
|
1367
|
-
try {
|
|
1368
|
-
const username = userInfo().username;
|
|
1369
|
-
if (username?.trim()) {
|
|
1370
|
-
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1371
|
-
}
|
|
1372
|
-
} catch { /* userInfo() can throw on some platforms */ }
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
// Use ORCH_BATCH_ID if available (orchestrated mode), otherwise fallback to timestamp
|
|
1376
|
-
const batchId = process.env.ORCH_BATCH_ID || String(Date.now());
|
|
1377
|
-
const repoId = process.env.TASKPLANE_REPO_ID || "default";
|
|
1378
|
-
|
|
1379
|
-
// Extract role (worker/reviewer) from sessionName, and optional lane component
|
|
1380
|
-
const role = sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1381
|
-
const laneMatch = sessionName.match(/lane-(\d+)/);
|
|
1382
|
-
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1383
|
-
|
|
1384
|
-
// Include taskId when available — sanitize to filesystem-safe characters
|
|
1385
|
-
const taskIdSegment = taskId
|
|
1386
|
-
? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1387
|
-
: "";
|
|
1388
|
-
|
|
1389
|
-
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1390
|
-
const telemetryDir = join(getSidecarDir(), "telemetry");
|
|
1391
|
-
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1392
|
-
|
|
1393
|
-
return {
|
|
1394
|
-
sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
|
|
1395
|
-
exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
/** Expose for testing. */
|
|
1400
|
-
export const _generateStableSidecarPaths = generateStableSidecarPaths;
|
|
1401
|
-
|
|
1402
|
-
// ── Exit Summary & Diagnostic ────────────────────────────────────────
|
|
1403
|
-
|
|
1404
|
-
/**
|
|
1405
|
-
* Read the exit summary JSON file written by rpc-wrapper.mjs.
|
|
1406
|
-
*
|
|
1407
|
-
* Returns null if the file is missing (session vanished) or malformed
|
|
1408
|
-
* (wrapper crashed mid-write). Logs a warning on parse failure but
|
|
1409
|
-
* never throws — the caller should treat null as "session_vanished".
|
|
1410
|
-
*/
|
|
1411
|
-
function readExitSummary(exitSummaryPath: string): ExitSummary | null {
|
|
1412
|
-
try {
|
|
1413
|
-
if (!existsSync(exitSummaryPath)) {
|
|
1414
|
-
return null;
|
|
1415
|
-
}
|
|
1416
|
-
const raw = readFileSync(exitSummaryPath, "utf-8").trim();
|
|
1417
|
-
if (!raw) return null;
|
|
1418
|
-
const parsed = JSON.parse(raw);
|
|
1419
|
-
// Minimal shape validation: must be a plain object (not array, not null)
|
|
1420
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1421
|
-
console.error(`[task-runner] exit summary is not a plain object: ${exitSummaryPath}`);
|
|
1422
|
-
return null;
|
|
1423
|
-
}
|
|
1424
|
-
return parsed as ExitSummary;
|
|
1425
|
-
} catch (err: any) {
|
|
1426
|
-
console.error(`[task-runner] failed to read exit summary: ${err.message}`);
|
|
1427
|
-
return null;
|
|
1428
|
-
}
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* Input parameters for `buildExitDiagnostic()`.
|
|
1433
|
-
*
|
|
1434
|
-
* Bridges the task-runner's runtime state into `classifyExit()` input
|
|
1435
|
-
* and populates the full `TaskExitDiagnostic` with progress metadata.
|
|
1436
|
-
*/
|
|
1437
|
-
interface BuildExitDiagnosticInput {
|
|
1438
|
-
/** Exit summary from rpc-wrapper.mjs (null if file missing) */
|
|
1439
|
-
exitSummary: ExitSummary | null;
|
|
1440
|
-
/** Whether .DONE file was found */
|
|
1441
|
-
doneFileFound: boolean;
|
|
1442
|
-
/** Whether the wall-clock timer killed the session */
|
|
1443
|
-
timerKilled: boolean;
|
|
1444
|
-
/** Whether the context-limit kill was triggered */
|
|
1445
|
-
contextKilled: boolean;
|
|
1446
|
-
/** Whether the user manually killed the session */
|
|
1447
|
-
userKilled: boolean;
|
|
1448
|
-
/** Estimated context utilization % from sidecar tailing (0-100) */
|
|
1449
|
-
contextPct: number;
|
|
1450
|
-
/** Wall-clock duration in seconds */
|
|
1451
|
-
durationSec: number;
|
|
1452
|
-
/** Repo identifier ("default" in repo mode, repo key in workspace mode) */
|
|
1453
|
-
repoId: string;
|
|
1454
|
-
/** Last known step number from STATUS.md (null if not parsed) */
|
|
1455
|
-
lastKnownStep: number | null;
|
|
1456
|
-
/** Last known checkbox text from STATUS.md (null if not parsed) */
|
|
1457
|
-
lastKnownCheckbox: string | null;
|
|
1458
|
-
/** Number of commits representing partial progress (0 if none) */
|
|
1459
|
-
partialProgressCommits: number;
|
|
1460
|
-
/** Branch name holding partial progress (null if no branch) */
|
|
1461
|
-
partialProgressBranch: string | null;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* Build a structured `TaskExitDiagnostic` from task-runner runtime state.
|
|
1466
|
-
*
|
|
1467
|
-
* Calls `classifyExit()` with the appropriate signal mapping, then
|
|
1468
|
-
* enriches the result with progress metadata (commits, step, repo).
|
|
1469
|
-
*
|
|
1470
|
-
* Signal mapping:
|
|
1471
|
-
* - `stallDetected` in ExitClassificationInput ← not directly available in
|
|
1472
|
-
* task-runner's tmux mode (stall detection is orchestrator-level), so
|
|
1473
|
-
* always false here. Stall classification may still occur via orchestrator.
|
|
1474
|
-
* - `contextKilled` ← when the task-runner explicitly kills the session
|
|
1475
|
-
* due to context limit. Passed to classifyExit() so it can produce
|
|
1476
|
-
* `context_overflow` even when exit summary is missing or lacks
|
|
1477
|
-
* compaction events (e.g., wrapper crashed before writing summary).
|
|
1478
|
-
*/
|
|
1479
|
-
function buildExitDiagnostic(input: BuildExitDiagnosticInput): TaskExitDiagnostic {
|
|
1480
|
-
const classification = classifyExit({
|
|
1481
|
-
exitSummary: input.exitSummary,
|
|
1482
|
-
doneFileFound: input.doneFileFound,
|
|
1483
|
-
timerKilled: input.timerKilled,
|
|
1484
|
-
contextKilled: input.contextKilled,
|
|
1485
|
-
stallDetected: false, // Stall detection is orchestrator-level, not available in /task mode
|
|
1486
|
-
userKilled: input.userKilled,
|
|
1487
|
-
contextPct: input.contextPct,
|
|
1488
|
-
});
|
|
1489
|
-
|
|
1490
|
-
return {
|
|
1491
|
-
classification,
|
|
1492
|
-
exitCode: input.exitSummary?.exitCode ?? null,
|
|
1493
|
-
errorMessage: input.exitSummary?.error ?? null,
|
|
1494
|
-
tokensUsed: input.exitSummary?.tokens ?? null,
|
|
1495
|
-
contextPct: input.contextPct,
|
|
1496
|
-
partialProgressCommits: input.partialProgressCommits,
|
|
1497
|
-
partialProgressBranch: input.partialProgressBranch,
|
|
1498
|
-
durationSec: input.durationSec,
|
|
1499
|
-
lastKnownStep: input.lastKnownStep,
|
|
1500
|
-
lastKnownCheckbox: input.lastKnownCheckbox,
|
|
1501
|
-
repoId: input.repoId,
|
|
1502
|
-
};
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
/** Expose exit summary/diagnostic helpers for testing. */
|
|
1506
|
-
export const _readExitSummary = readExitSummary;
|
|
1507
|
-
export const _buildExitDiagnostic = buildExitDiagnostic;
|
|
1508
|
-
export type { BuildExitDiagnosticInput };
|
|
1509
|
-
|
|
1510
1318
|
/**
|
|
1511
1319
|
* Determine whether a step is "low-risk" and should skip reviews.
|
|
1512
1320
|
* Low-risk steps: Step 0 (Preflight) and the final step (Delivery/Docs).
|
|
1513
|
-
*
|
|
1514
|
-
* @param stepNumber The 0-based step number being evaluated
|
|
1515
|
-
* @param totalSteps Total number of steps in the task
|
|
1516
|
-
* @returns true if the step should skip plan and code reviews
|
|
1517
1321
|
*/
|
|
1518
1322
|
export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
|
|
1519
1323
|
return coreIsLowRiskStep(stepNumber, totalSteps);
|
|
1520
1324
|
}
|
|
1521
1325
|
|
|
1522
|
-
// ── TMUX Agent Spawner ───────────────────────────────────────────────
|
|
1523
|
-
|
|
1524
|
-
/**
|
|
1525
|
-
* Synchronous sleep helper for tmux spawn stabilization checks.
|
|
1526
|
-
*
|
|
1527
|
-
* Uses Atomics.wait for cross-platform blocking delays without relying on
|
|
1528
|
-
* shell `sleep` availability (important on Windows environments).
|
|
1529
|
-
*/
|
|
1530
|
-
function sleepSyncMs(ms: number): void {
|
|
1531
|
-
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
1532
|
-
try {
|
|
1533
|
-
const arr = new Int32Array(new SharedArrayBuffer(4));
|
|
1534
|
-
Atomics.wait(arr, 0, 0, Math.floor(ms));
|
|
1535
|
-
} catch {
|
|
1536
|
-
const start = Date.now();
|
|
1537
|
-
while (Date.now() - start < ms) {
|
|
1538
|
-
// Busy-wait fallback (rare path)
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
/**
|
|
1544
|
-
* Spawns a Pi agent in a named TMUX session instead of a headless subprocess.
|
|
1545
|
-
* Returns the same interface shape as `spawnAgent()` for drop-in compatibility.
|
|
1546
|
-
*
|
|
1547
|
-
* Differences from subprocess mode:
|
|
1548
|
-
* - No JSON event stream → no onToolCall/onContextPct callbacks
|
|
1549
|
-
* - No captured output → output is always ""
|
|
1550
|
-
* - Completion detected via `tmux has-session` polling (2s interval)
|
|
1551
|
-
* - Kill via `tmux kill-session`
|
|
1552
|
-
* - User can `tmux attach -t {sessionName}` for full visibility
|
|
1553
|
-
*
|
|
1554
|
-
* Temp files are cleaned up on all exit paths:
|
|
1555
|
-
* - Normal completion (session ends, polling detects it)
|
|
1556
|
-
* - Kill (explicit kill-session call)
|
|
1557
|
-
* - TMUX not installed (throws with actionable message)
|
|
1558
|
-
* - Session creation failure (throws after cleanup)
|
|
1559
|
-
*
|
|
1560
|
-
* Parity with spawnAgent():
|
|
1561
|
-
* - Return shape: extended — { promise, kill, sidecarPath, exitSummaryPath }
|
|
1562
|
-
* (promise and kill are drop-in compatible; sidecarPath and exitSummaryPath
|
|
1563
|
-
* are additions for RPC telemetry consumption in Steps 2/3)
|
|
1564
|
-
* - Promise result: identical fields — { output, exitCode, elapsed, killed }
|
|
1565
|
-
* - Kill semantics: sets killed=true, terminates session, cleans temp files
|
|
1566
|
-
* - Elapsed calc: Date.now() - startTime (same pattern)
|
|
1567
|
-
* - Cleanup: synchronous on all paths (more deterministic than spawnAgent's 1s setTimeout)
|
|
1568
|
-
* - output: always "" (no JSON stream in TMUX mode)
|
|
1569
|
-
* - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
|
|
1570
|
-
*
|
|
1571
|
-
* RPC Wrapper Integration (TP-026):
|
|
1572
|
-
* Instead of spawning `pi -p` directly, this function now spawns `rpc-wrapper.mjs`
|
|
1573
|
-
* which runs pi in RPC mode and produces:
|
|
1574
|
-
* - Sidecar JSONL file with real-time telemetry (tokens, cost, tool calls, retries)
|
|
1575
|
-
* - Exit summary JSON with structured exit data for classification
|
|
1576
|
-
*
|
|
1577
|
-
* The telemetry file paths are returned alongside the promise/kill handles so that
|
|
1578
|
-
* Steps 2 (sidecar tailing) and 3 (exit diagnostic) can read them.
|
|
1579
|
-
*
|
|
1580
|
-
* @param opts.sessionName — TMUX session name (e.g., "orch-lane-1-worker")
|
|
1581
|
-
* @param opts.cwd — Working directory for the TMUX session
|
|
1582
|
-
* @param opts.systemPrompt — System prompt content (written to temp file)
|
|
1583
|
-
* @param opts.prompt — User prompt content (written to temp file)
|
|
1584
|
-
* @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
|
|
1585
|
-
* @param opts.tools — Comma-separated tool list
|
|
1586
|
-
* @param opts.thinking — Thinking mode ("off", "on", etc.)
|
|
1587
|
-
* @param opts.taskId — Optional task ID for telemetry filename enrichment (e.g., "TP-026")
|
|
1588
|
-
*/
|
|
1589
|
-
function spawnAgentTmux(opts: {
|
|
1590
|
-
sessionName: string;
|
|
1591
|
-
cwd: string;
|
|
1592
|
-
systemPrompt: string;
|
|
1593
|
-
prompt: string;
|
|
1594
|
-
model: string;
|
|
1595
|
-
tools: string;
|
|
1596
|
-
thinking: string;
|
|
1597
|
-
taskId?: string;
|
|
1598
|
-
/** Optional extension paths to load in the spawned pi session (via rpc-wrapper --extensions).
|
|
1599
|
-
* When provided, --no-extensions is NOT passed to pi (would conflict). */
|
|
1600
|
-
extensions?: string[];
|
|
1601
|
-
/** Optional extra environment variables to set in the spawned tmux session.
|
|
1602
|
-
* Injected as `KEY=VALUE` prefixes in the shell command. @since TP-057 */
|
|
1603
|
-
env?: Record<string, string>;
|
|
1604
|
-
/** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
|
|
1605
|
-
* Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
|
|
1606
|
-
* with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
|
|
1607
|
-
onTelemetry?: (delta: SidecarTelemetryDelta) => void;
|
|
1608
|
-
/** TP-090: Path to .steering-pending JSONL flag file for STATUS.md annotation.
|
|
1609
|
-
* Only set for worker sessions (not reviewer/merger). */
|
|
1610
|
-
steeringPendingPath?: string;
|
|
1611
|
-
/** TP-097: Caller-provided sidecar path for stable identity across iterations.
|
|
1612
|
-
* When provided, spawnAgentTmux() skips internal path generation and uses this path.
|
|
1613
|
-
* The caller (runWorker) generates this ONCE before the iteration loop. */
|
|
1614
|
-
sidecarPath?: string;
|
|
1615
|
-
/** TP-097: Caller-provided exit summary path (paired with sidecarPath). */
|
|
1616
|
-
exitSummaryPath?: string;
|
|
1617
|
-
/** TP-097: Caller-provided tail state for resuming sidecar tailing across iterations.
|
|
1618
|
-
* When provided, the poll loop reuses this state instead of creating a fresh one.
|
|
1619
|
-
* This ensures tailing resumes from the last byte position after crash recovery. */
|
|
1620
|
-
tailState?: SidecarTailState;
|
|
1621
|
-
}): {
|
|
1622
|
-
promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
1623
|
-
kill: () => void;
|
|
1624
|
-
sidecarPath: string;
|
|
1625
|
-
exitSummaryPath: string;
|
|
1626
|
-
} {
|
|
1627
|
-
|
|
1628
|
-
// ── Preflight: verify tmux is available ──────────────────────────
|
|
1629
|
-
const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
|
|
1630
|
-
if (tmuxCheck.status !== 0 && tmuxCheck.status !== null) {
|
|
1631
|
-
throw new Error(
|
|
1632
|
-
"tmux is not installed or not in PATH. " +
|
|
1633
|
-
"Install tmux to use TMUX spawn mode, or set TASK_RUNNER_SPAWN_MODE=subprocess. " +
|
|
1634
|
-
`(tmux -V exited with code ${tmuxCheck.status})`
|
|
1635
|
-
);
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
// ── Resolve telemetry file paths ───────────────────────────────
|
|
1639
|
-
// TP-097: When the caller provides sidecarPath + exitSummaryPath, reuse them
|
|
1640
|
-
// for stable identity across crash recovery iterations. Otherwise, generate
|
|
1641
|
-
// paths internally (backward compatible for reviewer/quality-gate/standalone).
|
|
1642
|
-
let sidecarPath: string;
|
|
1643
|
-
let exitSummaryPath: string;
|
|
1644
|
-
if (opts.sidecarPath && opts.exitSummaryPath) {
|
|
1645
|
-
// TP-097: Caller provided stable paths (worker iteration flow)
|
|
1646
|
-
sidecarPath = opts.sidecarPath;
|
|
1647
|
-
exitSummaryPath = opts.exitSummaryPath;
|
|
1648
|
-
} else {
|
|
1649
|
-
// Internal generation: use unique per-spawn paths (Date.now-based batchId)
|
|
1650
|
-
// to prevent reviewer/QG sessions from replaying old telemetry on respawn.
|
|
1651
|
-
// Only the worker iteration flow uses ORCH_BATCH_ID for stable identity.
|
|
1652
|
-
const telemetryTs = Date.now();
|
|
1653
|
-
let opId = "op";
|
|
1654
|
-
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1655
|
-
if (envOpId?.trim()) {
|
|
1656
|
-
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1657
|
-
} else {
|
|
1658
|
-
try {
|
|
1659
|
-
const username = userInfo().username;
|
|
1660
|
-
if (username?.trim()) {
|
|
1661
|
-
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1662
|
-
}
|
|
1663
|
-
} catch { /* userInfo() can throw on some platforms */ }
|
|
1664
|
-
}
|
|
1665
|
-
const batchId = String(telemetryTs);
|
|
1666
|
-
const repoId = "default";
|
|
1667
|
-
const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1668
|
-
const laneMatch = opts.sessionName.match(/lane-(\d+)/);
|
|
1669
|
-
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1670
|
-
const taskIdSegment = opts.taskId
|
|
1671
|
-
? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1672
|
-
: "";
|
|
1673
|
-
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1674
|
-
const internalTelemetryDir = join(getSidecarDir(), "telemetry");
|
|
1675
|
-
if (!existsSync(internalTelemetryDir)) mkdirSync(internalTelemetryDir, { recursive: true });
|
|
1676
|
-
sidecarPath = join(internalTelemetryDir, `${telemetryBasename}.jsonl`);
|
|
1677
|
-
exitSummaryPath = join(internalTelemetryDir, `${telemetryBasename}-exit.json`);
|
|
1678
|
-
}
|
|
1679
|
-
// Ensure telemetry directory exists
|
|
1680
|
-
const telemetryDir = dirname(sidecarPath);
|
|
1681
|
-
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1682
|
-
|
|
1683
|
-
// ── Write prompts to temp files ─────────────────────────────────
|
|
1684
|
-
// Same pattern as spawnAgent() — avoids shell escaping issues with
|
|
1685
|
-
// backticks, quotes, and special characters in markdown content.
|
|
1686
|
-
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1687
|
-
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
|
|
1688
|
-
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
|
|
1689
|
-
writeFileSync(sysTmpFile, opts.systemPrompt);
|
|
1690
|
-
writeFileSync(promptTmpFile, opts.prompt);
|
|
1691
|
-
|
|
1692
|
-
const cleanupTmp = () => {
|
|
1693
|
-
try { unlinkSync(sysTmpFile); } catch {}
|
|
1694
|
-
try { unlinkSync(promptTmpFile); } catch {}
|
|
1695
|
-
};
|
|
1696
|
-
|
|
1697
|
-
// ── Build RPC Wrapper command ────────────────────────────────────
|
|
1698
|
-
// Spawns `node rpc-wrapper.mjs` instead of `pi -p`. The wrapper runs
|
|
1699
|
-
// pi in RPC mode, captures telemetry to the sidecar JSONL, and writes
|
|
1700
|
-
// a structured exit summary JSON on process exit.
|
|
1701
|
-
//
|
|
1702
|
-
// Shell quoting: use quoteArg() for all path arguments — same quoting
|
|
1703
|
-
// guarantees as the previous `pi -p` command since both execute as a
|
|
1704
|
-
// single shell string via tmux new-session.
|
|
1705
|
-
const quoteArg = (s: string): string => {
|
|
1706
|
-
// If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
|
|
1707
|
-
// Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
|
|
1708
|
-
if (/[\s"'`$\\!&|;()<>{}#*?~]/.test(s)) {
|
|
1709
|
-
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
1710
|
-
}
|
|
1711
|
-
return s;
|
|
1712
|
-
};
|
|
1713
|
-
|
|
1714
|
-
// Resolve rpc-wrapper.mjs path from the installed package
|
|
1715
|
-
const rpcWrapperPath = resolveRpcWrapperPath();
|
|
1716
|
-
|
|
1717
|
-
const wrapperArgs = [
|
|
1718
|
-
"node", quoteArg(rpcWrapperPath),
|
|
1719
|
-
"--sidecar-path", quoteArg(sidecarPath),
|
|
1720
|
-
"--exit-summary-path", quoteArg(exitSummaryPath),
|
|
1721
|
-
"--model", quoteArg(opts.model),
|
|
1722
|
-
"--system-prompt-file", quoteArg(sysTmpFile),
|
|
1723
|
-
"--prompt-file", quoteArg(promptTmpFile),
|
|
1724
|
-
"--tools", quoteArg(opts.tools),
|
|
1725
|
-
];
|
|
1726
|
-
// When extensions are provided, pass them to rpc-wrapper (which translates to `pi -e`)
|
|
1727
|
-
// and do NOT pass --no-extensions (would conflict).
|
|
1728
|
-
if (opts.extensions && opts.extensions.length > 0) {
|
|
1729
|
-
wrapperArgs.push("--extensions", quoteArg(opts.extensions.join(",")));
|
|
1730
|
-
}
|
|
1731
|
-
// TP-089: Agent mailbox steering — construct mailbox dir when in orchestrator mode.
|
|
1732
|
-
// ORCH_BATCH_ID is set by execution.ts for all lane spawns (including retries).
|
|
1733
|
-
// getSidecarDir() returns the .pi/ directory path (already includes .pi/).
|
|
1734
|
-
const orchBatchId = process.env.ORCH_BATCH_ID;
|
|
1735
|
-
if (orchBatchId) {
|
|
1736
|
-
const mailboxDir = join(getSidecarDir(), "mailbox", orchBatchId, opts.sessionName);
|
|
1737
|
-
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
1738
|
-
wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
|
|
1739
|
-
}
|
|
1740
|
-
// TP-090: Pass steering-pending path to rpc-wrapper (worker-only).
|
|
1741
|
-
if (opts.steeringPendingPath) {
|
|
1742
|
-
wrapperArgs.push("--steering-pending-path", quoteArg(opts.steeringPendingPath));
|
|
1743
|
-
}
|
|
1744
|
-
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1745
|
-
// Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
|
|
1746
|
-
wrapperArgs.push("--");
|
|
1747
|
-
wrapperArgs.push("--thinking", quoteArg(opts.thinking));
|
|
1748
|
-
if (!opts.extensions || opts.extensions.length === 0) {
|
|
1749
|
-
wrapperArgs.push("--no-extensions");
|
|
1750
|
-
}
|
|
1751
|
-
wrapperArgs.push("--no-skills");
|
|
1752
|
-
const wrapperCommand = wrapperArgs.join(" ");
|
|
1753
|
-
|
|
1754
|
-
// ── Handle stale session ─────────────────────────────────────────
|
|
1755
|
-
// Session names are fixed per role (e.g., "orch-lane-1-worker").
|
|
1756
|
-
// If a stale session from a previous iteration exists, kill it first.
|
|
1757
|
-
const staleCheck = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
|
|
1758
|
-
if (staleCheck.status === 0) {
|
|
1759
|
-
console.error(`[task-runner] tmux: killing stale session '${opts.sessionName}'`);
|
|
1760
|
-
spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1763
|
-
// ── Create TMUX session ─────────────────────────────────────────
|
|
1764
|
-
// Use `cd <path> && TERM=xterm-256color <cmd>` wrapper instead of tmux `-c`
|
|
1765
|
-
// because `-c` with Windows paths silently fails in MSYS2/Git Bash tmux.
|
|
1766
|
-
// Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
|
|
1767
|
-
// force xterm-256color.
|
|
1768
|
-
const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
|
|
1769
|
-
// Build extra env var prefix (TP-057: e.g., REVIEWER_SIGNAL_DIR for persistent reviewer)
|
|
1770
|
-
const extraEnv = opts.env
|
|
1771
|
-
? Object.entries(opts.env).map(([k, v]) => `${k}=${quoteArg(v)}`).join(" ") + " "
|
|
1772
|
-
: "";
|
|
1773
|
-
const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && ${extraEnv}TERM=xterm-256color ${wrapperCommand}`;
|
|
1774
|
-
const createResult = spawnSync("tmux", [
|
|
1775
|
-
"new-session", "-d",
|
|
1776
|
-
"-s", opts.sessionName,
|
|
1777
|
-
wrappedCommand,
|
|
1778
|
-
]);
|
|
1779
|
-
|
|
1780
|
-
if (createResult.status !== 0) {
|
|
1781
|
-
cleanupTmp();
|
|
1782
|
-
const stderr = createResult.stderr?.toString().trim() || "unknown error";
|
|
1783
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' creation failed: ${stderr}`);
|
|
1784
|
-
throw new Error(
|
|
1785
|
-
`Failed to create TMUX session '${opts.sessionName}': ${stderr}. ` +
|
|
1786
|
-
`Verify tmux is running and the session name is valid.`
|
|
1787
|
-
);
|
|
1788
|
-
}
|
|
1789
|
-
|
|
1790
|
-
// ── TP-095: Post-spawn verification with retry (#335) ──────────
|
|
1791
|
-
// On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
|
|
1792
|
-
// Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
|
|
1793
|
-
// Verify the session is alive after a brief delay, and retry if it died.
|
|
1794
|
-
// TP-097: Increased from 300→500ms and 2→5 retries for reliability (#335)
|
|
1795
|
-
const SPAWN_VERIFY_DELAY_MS = 500;
|
|
1796
|
-
const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
|
|
1797
|
-
const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
|
|
1798
|
-
const SPAWN_MAX_RETRIES = 5;
|
|
1799
|
-
|
|
1800
|
-
const verifySessionAlive = (): boolean => {
|
|
1801
|
-
for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
|
|
1802
|
-
const check = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
|
|
1803
|
-
if (check.status === 0) return true;
|
|
1804
|
-
if (poll < SPAWN_VERIFY_POLL_ATTEMPTS - 1) {
|
|
1805
|
-
sleepSyncMs(SPAWN_VERIFY_POLL_INTERVAL_MS);
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
return false;
|
|
1809
|
-
};
|
|
1810
|
-
|
|
1811
|
-
// Wait briefly for session to stabilize, then verify
|
|
1812
|
-
sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
|
|
1813
|
-
|
|
1814
|
-
// Derive the stderr log path for diagnostic messages (mirrors execution.ts convention)
|
|
1815
|
-
const stderrLogHint = `${sidecarPath.replace(/\.jsonl$/, "-stderr.log")}`;
|
|
1816
|
-
|
|
1817
|
-
let spawnRetries = 0;
|
|
1818
|
-
while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
|
|
1819
|
-
spawnRetries++;
|
|
1820
|
-
// TP-097: Log stderr from the failed session for diagnostics
|
|
1821
|
-
let failedStderr = "";
|
|
1822
|
-
try {
|
|
1823
|
-
if (existsSync(stderrLogHint)) {
|
|
1824
|
-
const raw = readFileSync(stderrLogHint, "utf-8");
|
|
1825
|
-
// Take last 500 chars to capture the most recent error
|
|
1826
|
-
failedStderr = raw.length > 500 ? "..." + raw.slice(-500) : raw;
|
|
1827
|
-
}
|
|
1828
|
-
} catch { /* best effort */ }
|
|
1829
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup — retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}).${failedStderr ? ` Last stderr: ${failedStderr.trim().slice(0, 200)}` : ""} Stderr log: ${stderrLogHint}`);
|
|
1830
|
-
|
|
1831
|
-
// Brief delay before retry (increases with each attempt: 500ms, 1000ms, 1500ms, ...)
|
|
1832
|
-
const retryDelay = spawnRetries * 500;
|
|
1833
|
-
sleepSyncMs(retryDelay);
|
|
1834
|
-
|
|
1835
|
-
// Kill any remnant and re-create
|
|
1836
|
-
spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
|
|
1837
|
-
|
|
1838
|
-
const retryResult = spawnSync("tmux", [
|
|
1839
|
-
"new-session", "-d",
|
|
1840
|
-
"-s", opts.sessionName,
|
|
1841
|
-
wrappedCommand,
|
|
1842
|
-
]);
|
|
1843
|
-
|
|
1844
|
-
if (retryResult.status !== 0) {
|
|
1845
|
-
const retryStderr = retryResult.stderr?.toString().trim() || "unknown error";
|
|
1846
|
-
console.error(`[task-runner] tmux: retry ${spawnRetries} session creation failed: ${retryStderr}`);
|
|
1847
|
-
continue;
|
|
1848
|
-
}
|
|
1849
|
-
|
|
1850
|
-
// Wait for the retried session to stabilize
|
|
1851
|
-
sleepSyncMs(SPAWN_VERIFY_DELAY_MS);
|
|
1852
|
-
}
|
|
1853
|
-
|
|
1854
|
-
if (spawnRetries > 0) {
|
|
1855
|
-
const finalAlive = verifySessionAlive();
|
|
1856
|
-
if (!finalAlive) {
|
|
1857
|
-
cleanupTmp();
|
|
1858
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' failed after ${SPAWN_MAX_RETRIES} retries. Stderr log: ${stderrLogHint}`);
|
|
1859
|
-
throw new Error(
|
|
1860
|
-
`TMUX session '${opts.sessionName}' died on startup after ${SPAWN_MAX_RETRIES} retries. ` +
|
|
1861
|
-
`Stderr log: ${stderrLogHint}`
|
|
1862
|
-
);
|
|
1863
|
-
}
|
|
1864
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' alive after ${spawnRetries} retry(ies)`);
|
|
1865
|
-
}
|
|
1866
|
-
|
|
1867
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' created (cwd: ${opts.cwd})`);
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
// ── Poll until session ends ─────────────────────────────────────
|
|
1871
|
-
let killed = false;
|
|
1872
|
-
const startTime = Date.now();
|
|
1873
|
-
// TP-097: Reuse caller-provided tailState for cross-iteration tailing resume.
|
|
1874
|
-
// When the caller (runWorker) passes tailState, byte offset is preserved
|
|
1875
|
-
// across iterations so tailing resumes from the last position after crash recovery.
|
|
1876
|
-
const tailState = opts.tailState ?? createSidecarTailState();
|
|
1877
|
-
|
|
1878
|
-
const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
|
|
1879
|
-
try {
|
|
1880
|
-
while (true) {
|
|
1881
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
1882
|
-
|
|
1883
|
-
// Tail sidecar JSONL for telemetry updates on each tick
|
|
1884
|
-
if (opts.onTelemetry) {
|
|
1885
|
-
const delta = tailSidecarJsonl(sidecarPath, tailState);
|
|
1886
|
-
// Call back whenever events were parsed (including retry state transitions)
|
|
1887
|
-
if (delta.hadEvents) {
|
|
1888
|
-
opts.onTelemetry(delta);
|
|
1889
|
-
}
|
|
1890
|
-
}
|
|
1891
|
-
|
|
1892
|
-
const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
|
|
1893
|
-
if (result.status !== 0) {
|
|
1894
|
-
// Session no longer exists — Pi exited, TMUX closed
|
|
1895
|
-
// Final tail to catch any events written since last tick
|
|
1896
|
-
if (opts.onTelemetry) {
|
|
1897
|
-
const finalDelta = tailSidecarJsonl(sidecarPath, tailState);
|
|
1898
|
-
if (finalDelta.hadEvents) {
|
|
1899
|
-
opts.onTelemetry(finalDelta);
|
|
1900
|
-
}
|
|
1901
|
-
}
|
|
1902
|
-
break;
|
|
1903
|
-
}
|
|
1904
|
-
}
|
|
1905
|
-
} catch (pollErr: any) {
|
|
1906
|
-
// Polling failure — clean up and report
|
|
1907
|
-
console.error(`[task-runner] tmux: polling error for '${opts.sessionName}': ${pollErr?.message || pollErr}`);
|
|
1908
|
-
cleanupTmp();
|
|
1909
|
-
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (poll-fail)`);
|
|
1910
|
-
return {
|
|
1911
|
-
output: `Polling error: ${pollErr?.message || pollErr}`,
|
|
1912
|
-
exitCode: 1,
|
|
1913
|
-
elapsed: Date.now() - startTime,
|
|
1914
|
-
killed: false,
|
|
1915
|
-
};
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
// Normal completion — clean up temp files and orphan processes
|
|
1919
|
-
const elapsed = Date.now() - startTime;
|
|
1920
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
|
|
1921
|
-
cleanupTmp();
|
|
1922
|
-
// TP-097: Clean up orphan rpc-wrapper/pi processes after session ends
|
|
1923
|
-
cleanupOrphanProcesses(sidecarPath);
|
|
1924
|
-
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
|
|
1925
|
-
return {
|
|
1926
|
-
output: "", // No captured output in TMUX mode
|
|
1927
|
-
exitCode: 0, // TMUX session exit is best-effort success
|
|
1928
|
-
elapsed,
|
|
1929
|
-
killed,
|
|
1930
|
-
};
|
|
1931
|
-
})();
|
|
1932
|
-
|
|
1933
|
-
// ── Kill function ───────────────────────────────────────────────
|
|
1934
|
-
const kill = () => {
|
|
1935
|
-
killed = true;
|
|
1936
|
-
console.error(`[task-runner] tmux: killing session '${opts.sessionName}'`);
|
|
1937
|
-
const killResult = spawnSync("tmux", ["kill-session", "-t", opts.sessionName]);
|
|
1938
|
-
if (killResult.status !== 0) {
|
|
1939
|
-
// Session may have already exited — not an error
|
|
1940
|
-
console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
|
|
1941
|
-
}
|
|
1942
|
-
// TP-097: Clean up orphan rpc-wrapper/pi processes on explicit kill
|
|
1943
|
-
cleanupOrphanProcesses(sidecarPath);
|
|
1944
|
-
cleanupTmp();
|
|
1945
|
-
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
|
|
1946
|
-
};
|
|
1947
|
-
|
|
1948
|
-
return { promise, kill, sidecarPath, exitSummaryPath };
|
|
1949
|
-
}
|
|
1950
|
-
|
|
1951
|
-
// ── Orphan Process Cleanup (TP-097) ───────────────────────────────────
|
|
1952
|
-
|
|
1953
|
-
/**
|
|
1954
|
-
* Read the PID file written by rpc-wrapper.mjs and kill orphan processes.
|
|
1955
|
-
*
|
|
1956
|
-
* The PID file is at `{sidecarPath}.pid` and contains JSON with wrapperPid
|
|
1957
|
-
* and childPid fields. After a tmux session ends, the rpc-wrapper child
|
|
1958
|
-
* process may still be alive (e.g., if the tmux session was killed externally
|
|
1959
|
-
* or the wrapper didn't get a clean shutdown signal).
|
|
1960
|
-
*
|
|
1961
|
-
* Best-effort: failures are logged but never throw.
|
|
1962
|
-
*
|
|
1963
|
-
* @param sidecarPath - Path to the sidecar JSONL file (PID file is at sidecarPath + ".pid")
|
|
1964
|
-
*/
|
|
1965
|
-
function cleanupOrphanProcesses(sidecarPath: string): void {
|
|
1966
|
-
const pidFilePath = sidecarPath + ".pid";
|
|
1967
|
-
try {
|
|
1968
|
-
if (!existsSync(pidFilePath)) return;
|
|
1969
|
-
|
|
1970
|
-
const raw = readFileSync(pidFilePath, "utf-8").trim();
|
|
1971
|
-
if (!raw) return;
|
|
1972
|
-
|
|
1973
|
-
const pidData = JSON.parse(raw);
|
|
1974
|
-
const pidsToCheck = new Set<number>();
|
|
1975
|
-
if (typeof pidData.childPid === "number" && pidData.childPid > 0) {
|
|
1976
|
-
pidsToCheck.add(pidData.childPid);
|
|
1977
|
-
}
|
|
1978
|
-
if (typeof pidData.wrapperPid === "number" && pidData.wrapperPid > 0) {
|
|
1979
|
-
pidsToCheck.add(pidData.wrapperPid);
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
// Safety: never kill ourselves or PID 1 (init)
|
|
1983
|
-
const selfPid = process.pid;
|
|
1984
|
-
pidsToCheck.delete(selfPid);
|
|
1985
|
-
pidsToCheck.delete(1);
|
|
1986
|
-
|
|
1987
|
-
for (const pid of pidsToCheck) {
|
|
1988
|
-
try {
|
|
1989
|
-
// Check if process is still alive (signal 0 = no-op, just check existence)
|
|
1990
|
-
process.kill(pid, 0);
|
|
1991
|
-
// Process is alive — send SIGTERM
|
|
1992
|
-
console.error(`[task-runner] TP-097: killing orphan process PID ${pid}`);
|
|
1993
|
-
try {
|
|
1994
|
-
process.kill(pid, "SIGTERM");
|
|
1995
|
-
} catch (killErr: any) {
|
|
1996
|
-
console.error(`[task-runner] TP-097: failed to kill PID ${pid}: ${killErr?.message}`);
|
|
1997
|
-
}
|
|
1998
|
-
} catch {
|
|
1999
|
-
// Process already dead — expected path
|
|
2000
|
-
}
|
|
2001
|
-
}
|
|
2002
|
-
|
|
2003
|
-
// Clean up the PID file
|
|
2004
|
-
try { unlinkSync(pidFilePath); } catch {}
|
|
2005
|
-
} catch (err: any) {
|
|
2006
|
-
console.error(`[task-runner] TP-097: orphan cleanup error: ${err?.message}`);
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
/** Expose for testing. */
|
|
2011
|
-
export const _cleanupOrphanProcesses = cleanupOrphanProcesses;
|
|
2012
|
-
|
|
2013
1326
|
// ── Display Helpers ──────────────────────────────────────────────────
|
|
2014
1327
|
|
|
2015
1328
|
function displayName(name: string): string {
|
|
@@ -2200,13 +1513,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2200
1513
|
|
|
2201
1514
|
// ── review_step Tool (orchestrated mode only) ───────────────────
|
|
2202
1515
|
|
|
2203
|
-
/** Per-step code review cycle counter. Reset
|
|
1516
|
+
/** Per-step code review cycle counter. Reset after code review completion. */
|
|
2204
1517
|
const stepCodeReviewCounts = new Map<number, number>();
|
|
2205
1518
|
|
|
2206
|
-
/**
|
|
2207
|
-
* Reset reviewer telemetry fields on state to idle/zero.
|
|
2208
|
-
* Called after a review completes to clear dashboard metrics.
|
|
2209
|
-
*/
|
|
2210
1519
|
function clearReviewerState(): void {
|
|
2211
1520
|
state.reviewerStatus = "idle";
|
|
2212
1521
|
state.reviewerType = "";
|
|
@@ -2226,77 +1535,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
2226
1535
|
state.reviewerTimer = null;
|
|
2227
1536
|
}
|
|
2228
1537
|
|
|
2229
|
-
/**
|
|
2230
|
-
* TP-057: Remove stale signal and shutdown files from .reviews/ directory.
|
|
2231
|
-
* Called before spawning a new persistent reviewer to prevent the reviewer
|
|
2232
|
-
* from consuming old signals or immediately seeing a stale shutdown marker.
|
|
2233
|
-
*/
|
|
2234
|
-
function cleanStaleReviewerSignals(reviewsDir: string): void {
|
|
2235
|
-
try {
|
|
2236
|
-
const files = readdirSync(reviewsDir);
|
|
2237
|
-
for (const f of files) {
|
|
2238
|
-
if (f.startsWith(REVIEWER_SIGNAL_PREFIX) || f === REVIEWER_SHUTDOWN_SIGNAL) {
|
|
2239
|
-
try { unlinkSync(join(reviewsDir, f)); } catch {}
|
|
2240
|
-
}
|
|
2241
|
-
}
|
|
2242
|
-
} catch {
|
|
2243
|
-
// Directory may not exist yet — not an error
|
|
2244
|
-
}
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
/**
|
|
2248
|
-
* TP-057: Shut down the persistent reviewer session cleanly.
|
|
2249
|
-
* Writes shutdown signal, waits for clean exit within grace period,
|
|
2250
|
-
* then force-kills the session if still alive.
|
|
2251
|
-
*
|
|
2252
|
-
* Called from all executeTask exit paths (success, pause, error, stall)
|
|
2253
|
-
* to prevent orphan tmux sessions.
|
|
2254
|
-
*
|
|
2255
|
-
* @param reason - Why the reviewer is being shut down (for logging)
|
|
2256
|
-
*/
|
|
2257
1538
|
async function shutdownPersistentReviewer(reason: string): Promise<void> {
|
|
2258
|
-
if (!state.persistentReviewerSession) return;
|
|
2259
|
-
|
|
2260
|
-
const sessionName = state.persistentReviewerSession;
|
|
2261
|
-
console.error(`[task-runner] persistent reviewer: shutting down (${reason})`);
|
|
2262
|
-
|
|
2263
|
-
// Write shutdown signal so the reviewer exits cleanly
|
|
2264
|
-
if (state.task) {
|
|
2265
|
-
const reviewsDir = join(state.task.taskFolder, ".reviews");
|
|
2266
|
-
const shutdownPath = join(reviewsDir, REVIEWER_SHUTDOWN_SIGNAL);
|
|
2267
|
-
try {
|
|
2268
|
-
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2269
|
-
writeFileSync(shutdownPath, "shutdown");
|
|
2270
|
-
} catch (err: any) {
|
|
2271
|
-
console.error(`[task-runner] persistent reviewer: failed to write shutdown signal: ${err?.message}`);
|
|
2272
|
-
}
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
// Poll for session death within grace period
|
|
2276
|
-
const graceStart = Date.now();
|
|
2277
|
-
while (Date.now() - graceStart < REVIEWER_SHUTDOWN_GRACE_MS) {
|
|
2278
|
-
const alive = spawnSync("tmux", ["has-session", "-t", sessionName]);
|
|
2279
|
-
if (alive.status !== 0) break;
|
|
2280
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
2281
|
-
}
|
|
2282
|
-
|
|
2283
|
-
// Force kill if still alive after grace period
|
|
2284
|
-
const finalCheck = spawnSync("tmux", ["has-session", "-t", sessionName]);
|
|
2285
|
-
if (finalCheck.status === 0) {
|
|
2286
|
-
console.error(`[task-runner] persistent reviewer: killing session after grace period`);
|
|
2287
|
-
spawnSync("tmux", ["kill-session", "-t", sessionName]);
|
|
2288
|
-
}
|
|
2289
|
-
|
|
2290
|
-
// Reset state
|
|
2291
1539
|
state.persistentReviewerSession = null;
|
|
2292
1540
|
state.persistentReviewerKill = null;
|
|
2293
1541
|
state.persistentReviewerSignalNum = 0;
|
|
1542
|
+
state.reviewerRespawnCount = 0;
|
|
2294
1543
|
clearReviewerState();
|
|
2295
1544
|
writeLaneState(state);
|
|
2296
|
-
|
|
2297
1545
|
if (state.task) {
|
|
2298
1546
|
const statusPath = join(state.task.taskFolder, "STATUS.md");
|
|
2299
|
-
logExecution(statusPath, "
|
|
1547
|
+
logExecution(statusPath, "Reviewer cleanup", `No persistent reviewer active (${reason})`);
|
|
2300
1548
|
}
|
|
2301
1549
|
}
|
|
2302
1550
|
|
|
@@ -2313,7 +1561,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2313
1561
|
"Call review_step at step boundaries based on the task's Review Level (from STATUS.md header).",
|
|
2314
1562
|
"Review Level 0: skip all reviews. Level 1: plan review before implementing. Level 2: plan + code review. Level 3: plan + code + test review.",
|
|
2315
1563
|
"Skip reviews for Step 0 (Preflight) and the final documentation/delivery step.",
|
|
2316
|
-
"For code reviews: before starting a step, capture the current HEAD commit with `git rev-parse HEAD` and pass it as the `baseline` parameter.
|
|
1564
|
+
"For code reviews: before starting a step, capture the current HEAD commit with `git rev-parse HEAD` and pass it as the `baseline` parameter.",
|
|
2317
1565
|
"On REVISE: read the review file in .reviews/ for detailed feedback, address the issues, commit fixes, then proceed.",
|
|
2318
1566
|
"On RETHINK: reconsider your plan approach, adjust, then implement.",
|
|
2319
1567
|
"On UNAVAILABLE: reviewer failed — proceed with caution.",
|
|
@@ -2325,19 +1573,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2325
1573
|
{ description: 'Review type: "plan" or "code"' },
|
|
2326
1574
|
),
|
|
2327
1575
|
baseline: Type.Optional(Type.String({
|
|
2328
|
-
description: "Git commit SHA to use as the diff baseline for code reviews.
|
|
2329
|
-
"Capture HEAD before starting a step and pass it here so the reviewer " +
|
|
2330
|
-
"sees only that step's changes. If omitted, the reviewer sees the full diff against HEAD.",
|
|
1576
|
+
description: "Git commit SHA to use as the diff baseline for code reviews.",
|
|
2331
1577
|
})),
|
|
2332
1578
|
}),
|
|
2333
1579
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
2334
1580
|
const { step: stepNum, type: reviewType, baseline } = params;
|
|
2335
|
-
|
|
2336
1581
|
if (!state.task || !state.config) {
|
|
2337
|
-
return {
|
|
2338
|
-
content: [{ type: "text" as const, text: "UNAVAILABLE — no task loaded" }],
|
|
2339
|
-
details: undefined,
|
|
2340
|
-
};
|
|
1582
|
+
return { content: [{ type: "text" as const, text: "UNAVAILABLE — no task loaded" }], details: undefined };
|
|
2341
1583
|
}
|
|
2342
1584
|
|
|
2343
1585
|
const task = state.task;
|
|
@@ -2346,22 +1588,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
2346
1588
|
const reviewsDir = join(task.taskFolder, ".reviews");
|
|
2347
1589
|
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2348
1590
|
|
|
2349
|
-
// Per-step code review cycle limit
|
|
2350
1591
|
if (reviewType === "code") {
|
|
2351
1592
|
const codeCount = (stepCodeReviewCounts.get(stepNum) || 0) + 1;
|
|
2352
1593
|
stepCodeReviewCounts.set(stepNum, codeCount);
|
|
2353
1594
|
const maxCycles = config.context.max_review_cycles || 2;
|
|
2354
1595
|
if (codeCount > maxCycles) {
|
|
2355
|
-
logExecution(statusPath,
|
|
2356
|
-
`Step ${stepNum} code review cycle limit reached (${codeCount}/${maxCycles}) — auto-approving`);
|
|
2357
|
-
// Kill reviewer to free context for next step
|
|
2358
|
-
if (state.persistentReviewerKill) {
|
|
2359
|
-
try { state.persistentReviewerKill(); } catch {}
|
|
2360
|
-
}
|
|
2361
|
-
state.persistentReviewerSession = null;
|
|
2362
|
-
state.persistentReviewerKill = null;
|
|
2363
|
-
state.persistentReviewerSignalNum = 0;
|
|
2364
|
-
state.reviewerRespawnCount = 0;
|
|
1596
|
+
logExecution(statusPath, "Skip code review", `Step ${stepNum} code review cycle limit reached (${codeCount}/${maxCycles}) — auto-approving`);
|
|
2365
1597
|
stepCodeReviewCounts.delete(stepNum);
|
|
2366
1598
|
return {
|
|
2367
1599
|
content: [{ type: "text" as const, text: `APPROVE — Code review cycle limit reached (${maxCycles}). Auto-approved to prevent context exhaustion.` }],
|
|
@@ -2370,7 +1602,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2370
1602
|
}
|
|
2371
1603
|
}
|
|
2372
1604
|
|
|
2373
|
-
// Low-risk step check (safety net — worker template also skips)
|
|
2374
1605
|
if (isLowRiskStep(stepNum, task.steps.length)) {
|
|
2375
1606
|
const label = stepNum === 0 ? "Preflight" : "final step";
|
|
2376
1607
|
logExecution(statusPath, `Skip ${reviewType} review`, `Step ${stepNum} (${label}) — low-risk`);
|
|
@@ -2380,57 +1611,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
2380
1611
|
};
|
|
2381
1612
|
}
|
|
2382
1613
|
|
|
2383
|
-
// Increment review counter
|
|
2384
1614
|
state.reviewCounter++;
|
|
2385
1615
|
const num = String(state.reviewCounter).padStart(3, "0");
|
|
2386
1616
|
const requestPath = join(reviewsDir, `request-R${num}.md`);
|
|
2387
1617
|
const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
|
|
2388
|
-
|
|
2389
|
-
// Resolve step baseline commit for code reviews.
|
|
2390
|
-
const stepBaselineCommit: string | undefined =
|
|
2391
|
-
reviewType === "code" ? (baseline || undefined) : undefined;
|
|
2392
|
-
|
|
2393
|
-
// Find step info for the name
|
|
1618
|
+
const stepBaselineCommit: string | undefined = reviewType === "code" ? (baseline || undefined) : undefined;
|
|
2394
1619
|
const stepInfo = task.steps.find(s => s.number === stepNum);
|
|
2395
1620
|
const stepName = stepInfo?.name || `Step ${stepNum}`;
|
|
2396
|
-
|
|
2397
|
-
// Generate review request
|
|
2398
|
-
const request = generateReviewRequest(
|
|
2399
|
-
reviewType, stepNum, stepName, task, config, outputPath, stepBaselineCommit,
|
|
2400
|
-
);
|
|
1621
|
+
const request = generateReviewRequest(reviewType, stepNum, stepName, task, config, outputPath, stepBaselineCommit);
|
|
2401
1622
|
writeFileSync(requestPath, request);
|
|
2402
1623
|
|
|
2403
|
-
// Load reviewer agent definition
|
|
2404
1624
|
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
2405
|
-
// TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
|
|
2406
1625
|
const reviewerModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
2407
1626
|
const reviewerModel = reviewerModelFallback
|
|
2408
1627
|
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
2409
|
-
: (config.reviewer.model
|
|
2410
|
-
|
|
2411
|
-
|| (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
|
|
2412
|
-
const reviewerPrompt = reviewerDef?.systemPrompt
|
|
2413
|
-
|| "You are a code reviewer. Read the request and write your review to the specified output file.";
|
|
1628
|
+
: (config.reviewer.model || reviewerDef?.model || (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
|
|
1629
|
+
const reviewerPrompt = reviewerDef?.systemPrompt || "You are a code reviewer. Read the request and write your review to the specified output file.";
|
|
2414
1630
|
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
1631
|
+
const promptContent = readFileSync(requestPath, "utf-8");
|
|
2415
1632
|
|
|
2416
|
-
// Update state for dashboard visibility
|
|
2417
|
-
const sessionName = `${getTmuxPrefix()}-reviewer`;
|
|
2418
1633
|
state.reviewerStatus = "running";
|
|
2419
1634
|
state.reviewerType = `${reviewType} review`;
|
|
2420
1635
|
state.reviewerStep = stepNum;
|
|
2421
|
-
state.reviewerSessionName =
|
|
1636
|
+
state.reviewerSessionName = "reviewer-subprocess";
|
|
2422
1637
|
state.reviewerElapsed = 0;
|
|
2423
1638
|
state.reviewerLastTool = "";
|
|
2424
1639
|
state.reviewerToolCount = 0;
|
|
2425
|
-
// Don't reset cumulative token counts for persistent reviewer — they accumulate
|
|
2426
|
-
if (!state.persistentReviewerSession) {
|
|
2427
|
-
state.reviewerInputTokens = 0;
|
|
2428
|
-
state.reviewerOutputTokens = 0;
|
|
2429
|
-
state.reviewerCacheReadTokens = 0;
|
|
2430
|
-
state.reviewerCacheWriteTokens = 0;
|
|
2431
|
-
state.reviewerCostUsd = 0;
|
|
2432
|
-
state.reviewerContextPct = 0;
|
|
2433
|
-
}
|
|
2434
1640
|
updateWidgets();
|
|
2435
1641
|
|
|
2436
1642
|
const startTime = Date.now();
|
|
@@ -2439,339 +1645,55 @@ export default function (pi: ExtensionAPI) {
|
|
|
2439
1645
|
updateWidgets();
|
|
2440
1646
|
}, 1000);
|
|
2441
1647
|
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
// Clean stale signal/shutdown files before spawning
|
|
2471
|
-
cleanStaleReviewerSignals(reviewsDir);
|
|
2472
|
-
|
|
2473
|
-
// Initial prompt tells the reviewer to call wait_for_review
|
|
2474
|
-
const initialPrompt =
|
|
2475
|
-
"You are a persistent reviewer for this task. " +
|
|
2476
|
-
"Use the `wait_for_review` tool now to receive your first review request. " +
|
|
2477
|
-
"IMPORTANT: `wait_for_review` is a REGISTERED EXTENSION TOOL — call it " +
|
|
2478
|
-
"the same way you call `read`, `write`, `edit`, or `grep`. " +
|
|
2479
|
-
"Do NOT run it via `bash` or any shell command. " +
|
|
2480
|
-
"After writing each review, use `wait_for_review` again for the next one.";
|
|
2481
|
-
|
|
2482
|
-
const spawned = spawnAgentTmux({
|
|
2483
|
-
sessionName,
|
|
2484
|
-
cwd: ctx.cwd,
|
|
2485
|
-
systemPrompt,
|
|
2486
|
-
prompt: initialPrompt,
|
|
2487
|
-
model: reviewerModel,
|
|
2488
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2489
|
-
thinking: config.reviewer.thinking || "on",
|
|
2490
|
-
taskId: task.taskId,
|
|
2491
|
-
extensions: [reviewerExtPath],
|
|
2492
|
-
env: { REVIEWER_SIGNAL_DIR: reviewsDir },
|
|
2493
|
-
onTelemetry: (delta) => {
|
|
2494
|
-
// Accumulate tokens and cost
|
|
2495
|
-
state.reviewerInputTokens += delta.inputTokens;
|
|
2496
|
-
state.reviewerOutputTokens += delta.outputTokens;
|
|
2497
|
-
state.reviewerCacheReadTokens += delta.cacheReadTokens;
|
|
2498
|
-
state.reviewerCacheWriteTokens += delta.cacheWriteTokens;
|
|
2499
|
-
state.reviewerCostUsd += delta.cost;
|
|
2500
|
-
|
|
2501
|
-
// Tool tracking
|
|
2502
|
-
state.reviewerToolCount += delta.toolCalls;
|
|
2503
|
-
if (delta.lastTool) {
|
|
2504
|
-
state.reviewerLastTool = delta.lastTool;
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2508
|
-
if (delta.contextUsage) {
|
|
2509
|
-
state.reviewerContextPct = delta.contextUsage.percent;
|
|
2510
|
-
}
|
|
2511
|
-
|
|
2512
|
-
writeLaneState(state);
|
|
2513
|
-
updateWidgets();
|
|
2514
|
-
},
|
|
2515
|
-
});
|
|
2516
|
-
|
|
2517
|
-
// Store persistent session state
|
|
2518
|
-
state.persistentReviewerSession = sessionName;
|
|
2519
|
-
state.persistentReviewerKill = spawned.kill;
|
|
2520
|
-
state.persistentReviewerSignalNum = 0;
|
|
2521
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
2522
|
-
|
|
2523
|
-
// Don't await spawned.promise — the session stays alive across reviews.
|
|
2524
|
-
// Handle session death via isPersistentReviewerAlive() checks.
|
|
2525
|
-
spawned.promise.then(() => {
|
|
2526
|
-
// Session ended (reviewer exited or was killed)
|
|
2527
|
-
console.error(`[task-runner] persistent reviewer session '${sessionName}' ended`);
|
|
2528
|
-
}).catch((err: any) => {
|
|
2529
|
-
console.error(`[task-runner] persistent reviewer session error: ${err?.message || err}`);
|
|
2530
|
-
});
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
/**
|
|
2534
|
-
* Write signal file to notify the persistent reviewer of a new request.
|
|
2535
|
-
* Returns the signal number used.
|
|
2536
|
-
*/
|
|
2537
|
-
function signalPersistentReviewer(): number {
|
|
2538
|
-
state.persistentReviewerSignalNum++;
|
|
2539
|
-
const sigNum = String(state.persistentReviewerSignalNum).padStart(3, "0");
|
|
2540
|
-
const signalPath = join(reviewsDir, `${REVIEWER_SIGNAL_PREFIX}${sigNum}`);
|
|
2541
|
-
// Write the request filename so the reviewer can find it
|
|
2542
|
-
// (signal num and review counter may diverge after respawns)
|
|
2543
|
-
writeFileSync(signalPath, `request-R${num}.md`);
|
|
2544
|
-
return state.persistentReviewerSignalNum;
|
|
2545
|
-
}
|
|
2546
|
-
|
|
2547
|
-
/**
|
|
2548
|
-
* Poll for the verdict file to appear (written by the reviewer).
|
|
2549
|
-
* Same pattern as the original review_step handler.
|
|
2550
|
-
*
|
|
2551
|
-
* Early-exit detection (TP-068): If the reviewer exits within 30s
|
|
2552
|
-
* of spawn without producing a verdict, it likely failed to use the
|
|
2553
|
-
* wait_for_review tool correctly (e.g., called it via bash). This
|
|
2554
|
-
* triggers a faster fallback instead of waiting 30 minutes.
|
|
2555
|
-
*/
|
|
2556
|
-
async function pollForVerdict(spawnTime?: number): Promise<string> {
|
|
2557
|
-
const EARLY_EXIT_THRESHOLD_MS = 30_000; // 30 seconds
|
|
2558
|
-
const verdictTimeout = 30 * 60 * 1000; // 30 minutes
|
|
2559
|
-
const pollStart = Date.now();
|
|
2560
|
-
while (Date.now() - pollStart < verdictTimeout) {
|
|
2561
|
-
if (existsSync(outputPath)) {
|
|
2562
|
-
return readFileSync(outputPath, "utf-8");
|
|
2563
|
-
}
|
|
2564
|
-
// Also check if persistent reviewer died while we're waiting
|
|
2565
|
-
if (state.persistentReviewerSession && !isPersistentReviewerAlive()) {
|
|
2566
|
-
// TP-068: Detect early exit as tool compatibility failure
|
|
2567
|
-
if (spawnTime && (Date.now() - spawnTime) < EARLY_EXIT_THRESHOLD_MS) {
|
|
2568
|
-
throw new Error(
|
|
2569
|
-
"Persistent reviewer exited within 30s of spawn without producing a verdict — " +
|
|
2570
|
-
"wait_for_review tool may not be supported by this model (e.g., called via bash instead of as a registered tool)"
|
|
2571
|
-
);
|
|
2572
|
-
}
|
|
2573
|
-
throw new Error("Persistent reviewer session died while waiting for verdict");
|
|
2574
|
-
}
|
|
2575
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
2576
|
-
}
|
|
2577
|
-
throw new Error("Reviewer verdict timeout — no output file after 30 minutes");
|
|
2578
|
-
}
|
|
1648
|
+
const spawned = spawnAgent({
|
|
1649
|
+
model: reviewerModel,
|
|
1650
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
1651
|
+
thinking: config.reviewer.thinking || "on",
|
|
1652
|
+
systemPrompt,
|
|
1653
|
+
prompt: promptContent,
|
|
1654
|
+
onToolCall: (toolName, args) => {
|
|
1655
|
+
state.reviewerToolCount++;
|
|
1656
|
+
const path = args?.path || args?.command || "";
|
|
1657
|
+
const shortPath = typeof path === "string" && path.length > 60 ? "..." + path.slice(-57) : path;
|
|
1658
|
+
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
1659
|
+
updateWidgets();
|
|
1660
|
+
},
|
|
1661
|
+
onTokenUpdate: (tokens) => {
|
|
1662
|
+
state.reviewerInputTokens += tokens.input;
|
|
1663
|
+
state.reviewerOutputTokens += tokens.output;
|
|
1664
|
+
state.reviewerCacheReadTokens += tokens.cacheRead;
|
|
1665
|
+
state.reviewerCacheWriteTokens += tokens.cacheWrite;
|
|
1666
|
+
state.reviewerCostUsd += tokens.cost;
|
|
1667
|
+
updateWidgets();
|
|
1668
|
+
},
|
|
1669
|
+
onContextPct: (pct) => {
|
|
1670
|
+
state.reviewerContextPct = pct;
|
|
1671
|
+
updateWidgets();
|
|
1672
|
+
},
|
|
1673
|
+
});
|
|
1674
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
2579
1675
|
|
|
2580
1676
|
try {
|
|
2581
|
-
|
|
2582
|
-
const
|
|
2583
|
-
|
|
2584
|
-
if (needsSpawn && state.persistentReviewerSession) {
|
|
2585
|
-
// Session was previously active but died — log fallback
|
|
2586
|
-
state.reviewerRespawnCount++;
|
|
2587
|
-
const MAX_REVIEWER_RESPAWNS = 3;
|
|
2588
|
-
if (state.reviewerRespawnCount > MAX_REVIEWER_RESPAWNS) {
|
|
2589
|
-
console.error(`[task-runner] reviewer respawn limit (${MAX_REVIEWER_RESPAWNS}) exceeded — skipping review`);
|
|
2590
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2591
|
-
`reviewer respawn limit exceeded (${state.reviewerRespawnCount}/${MAX_REVIEWER_RESPAWNS}) — skipping review`);
|
|
2592
|
-
state.persistentReviewerSession = null;
|
|
2593
|
-
state.persistentReviewerKill = null;
|
|
2594
|
-
state.persistentReviewerSignalNum = 0;
|
|
2595
|
-
return {
|
|
2596
|
-
content: [{ type: "text" as const, text: `⚠️ Reviewer respawn limit exceeded (${MAX_REVIEWER_RESPAWNS}). Review skipped — proceeding without review.` }],
|
|
2597
|
-
details: undefined,
|
|
2598
|
-
};
|
|
2599
|
-
}
|
|
2600
|
-
console.error(`[task-runner] persistent reviewer session dead — respawning (${state.reviewerRespawnCount}/${MAX_REVIEWER_RESPAWNS})`);
|
|
2601
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2602
|
-
`persistent reviewer dead — respawning for ${reviewType} review (${state.reviewerRespawnCount}/${MAX_REVIEWER_RESPAWNS})`);
|
|
2603
|
-
state.persistentReviewerSession = null;
|
|
2604
|
-
state.persistentReviewerKill = null;
|
|
2605
|
-
state.persistentReviewerSignalNum = 0;
|
|
2606
|
-
}
|
|
2607
|
-
|
|
2608
|
-
// Track spawn time for early-exit detection (TP-068)
|
|
2609
|
-
let spawnTime: number | undefined;
|
|
2610
|
-
if (needsSpawn) {
|
|
2611
|
-
spawnTime = Date.now();
|
|
2612
|
-
spawnPersistentReviewer();
|
|
2613
|
-
// Give the reviewer a moment to start and call wait_for_review
|
|
2614
|
-
await new Promise(r => setTimeout(r, 5000));
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
// Signal the reviewer with the new request
|
|
2618
|
-
signalPersistentReviewer();
|
|
2619
|
-
|
|
2620
|
-
// Poll for the verdict file (pass spawnTime for early-exit detection)
|
|
2621
|
-
const reviewContent = await pollForVerdict(spawnTime);
|
|
2622
|
-
|
|
2623
|
-
// Stop the per-review timer
|
|
2624
|
-
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
2625
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2626
|
-
state.reviewerStatus = "done";
|
|
2627
|
-
writeLaneState(state);
|
|
2628
|
-
updateWidgets();
|
|
2629
|
-
|
|
2630
|
-
// Extract verdict and build result
|
|
1677
|
+
await spawned.promise;
|
|
1678
|
+
const reviewContent = existsSync(outputPath) ? readFileSync(outputPath, "utf-8") : null;
|
|
2631
1679
|
const { resultText, verdict } = processReviewVerdict(
|
|
2632
1680
|
reviewContent, statusPath, num, reviewType, stepNum, state.reviewCounter,
|
|
2633
1681
|
);
|
|
2634
|
-
|
|
2635
|
-
// After code review APPROVE: kill the persistent reviewer to free context.
|
|
2636
|
-
// The reviewer persists through plan+code for one step, and through
|
|
2637
|
-
// REVISE→fix→re-review cycles (it knows what it asked to be fixed).
|
|
2638
|
-
// Only kill on APPROVE — a REVISE means the worker needs to fix and
|
|
2639
|
-
// re-submit, and the same reviewer should evaluate the follow-up.
|
|
2640
1682
|
if (reviewType === "code" && (verdict === "APPROVE" || verdict === "UNAVAILABLE")) {
|
|
2641
|
-
console.error(`[task-runner] code review ${verdict} for step ${stepNum} — killing reviewer for fresh context on next step`);
|
|
2642
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2643
|
-
`code review ${verdict} — killing persistent reviewer (step ${stepNum} cycle done)`);
|
|
2644
|
-
if (state.persistentReviewerKill) {
|
|
2645
|
-
try { state.persistentReviewerKill(); } catch {}
|
|
2646
|
-
}
|
|
2647
|
-
state.persistentReviewerSession = null;
|
|
2648
|
-
state.persistentReviewerKill = null;
|
|
2649
|
-
state.persistentReviewerSignalNum = 0;
|
|
2650
|
-
state.reviewerRespawnCount = 0;
|
|
2651
1683
|
stepCodeReviewCounts.delete(stepNum);
|
|
2652
1684
|
}
|
|
2653
|
-
|
|
2654
|
-
state.reviewerStatus = "idle";
|
|
2655
|
-
state.reviewerType = "";
|
|
2656
|
-
state.reviewerStep = 0;
|
|
2657
|
-
if (state.reviewerTimer) clearInterval(state.reviewerTimer);
|
|
2658
|
-
state.reviewerTimer = null;
|
|
1685
|
+
clearReviewerState();
|
|
2659
1686
|
writeLaneState(state);
|
|
2660
1687
|
updateWidgets();
|
|
2661
|
-
|
|
2662
|
-
return {
|
|
2663
|
-
content: [{ type: "text" as const, text: resultText }],
|
|
2664
|
-
details: undefined,
|
|
2665
|
-
};
|
|
1688
|
+
return { content: [{ type: "text" as const, text: resultText }], details: undefined };
|
|
2666
1689
|
} catch (err: any) {
|
|
2667
|
-
|
|
2668
|
-
state.
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
if (state.persistentReviewerKill) {
|
|
2675
|
-
try { state.persistentReviewerKill(); } catch {}
|
|
2676
|
-
}
|
|
2677
|
-
state.persistentReviewerSession = null;
|
|
2678
|
-
state.persistentReviewerKill = null;
|
|
2679
|
-
state.persistentReviewerSignalNum = 0;
|
|
2680
|
-
|
|
2681
|
-
// Circuit breaker — skip review if we've exhausted respawns
|
|
2682
|
-
if (state.reviewerRespawnCount > 3) {
|
|
2683
|
-
console.error(`[task-runner] reviewer respawn limit exceeded — skipping review`);
|
|
2684
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2685
|
-
`reviewer respawn limit exceeded — review skipped`);
|
|
2686
|
-
return {
|
|
2687
|
-
content: [{ type: "text" as const, text: `⚠️ Reviewer respawn limit exceeded. Review skipped — proceeding without review.` }],
|
|
2688
|
-
details: undefined,
|
|
2689
|
-
};
|
|
2690
|
-
}
|
|
2691
|
-
|
|
2692
|
-
// ── Fresh spawn fallback (original behavior) ────────
|
|
2693
|
-
try {
|
|
2694
|
-
const promptContent = readFileSync(requestPath, "utf-8");
|
|
2695
|
-
const spawned = spawnAgentTmux({
|
|
2696
|
-
sessionName,
|
|
2697
|
-
cwd: ctx.cwd,
|
|
2698
|
-
systemPrompt,
|
|
2699
|
-
prompt: promptContent,
|
|
2700
|
-
model: reviewerModel,
|
|
2701
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2702
|
-
thinking: config.reviewer.thinking || "on",
|
|
2703
|
-
taskId: task.taskId,
|
|
2704
|
-
onTelemetry: (delta) => {
|
|
2705
|
-
state.reviewerInputTokens += delta.inputTokens;
|
|
2706
|
-
state.reviewerOutputTokens += delta.outputTokens;
|
|
2707
|
-
state.reviewerCacheReadTokens += delta.cacheReadTokens;
|
|
2708
|
-
state.reviewerCacheWriteTokens += delta.cacheWriteTokens;
|
|
2709
|
-
state.reviewerCostUsd += delta.cost;
|
|
2710
|
-
state.reviewerToolCount += delta.toolCalls;
|
|
2711
|
-
if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
|
|
2712
|
-
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
2713
|
-
if (delta.contextUsage) {
|
|
2714
|
-
state.reviewerContextPct = delta.contextUsage.percent;
|
|
2715
|
-
}
|
|
2716
|
-
writeLaneState(state);
|
|
2717
|
-
updateWidgets();
|
|
2718
|
-
},
|
|
2719
|
-
});
|
|
2720
|
-
|
|
2721
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
2722
|
-
const result = await spawned.promise;
|
|
2723
|
-
|
|
2724
|
-
clearInterval(state.reviewerTimer);
|
|
2725
|
-
state.reviewerElapsed = Date.now() - startTime;
|
|
2726
|
-
state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
|
|
2727
|
-
state.reviewerProc = null;
|
|
2728
|
-
writeLaneState(state);
|
|
2729
|
-
updateWidgets();
|
|
2730
|
-
|
|
2731
|
-
// Extract verdict from fallback review
|
|
2732
|
-
const fallbackContent = existsSync(outputPath)
|
|
2733
|
-
? readFileSync(outputPath, "utf-8")
|
|
2734
|
-
: null;
|
|
2735
|
-
const { resultText } = processReviewVerdict(
|
|
2736
|
-
fallbackContent, statusPath, num, reviewType, stepNum, state.reviewCounter, "fallback",
|
|
2737
|
-
);
|
|
2738
|
-
|
|
2739
|
-
// Reset respawn counter on successful fallback review
|
|
2740
|
-
state.reviewerRespawnCount = 0;
|
|
2741
|
-
|
|
2742
|
-
clearReviewerState();
|
|
2743
|
-
writeLaneState(state);
|
|
2744
|
-
updateWidgets();
|
|
2745
|
-
|
|
2746
|
-
return {
|
|
2747
|
-
content: [{ type: "text" as const, text: resultText }],
|
|
2748
|
-
details: undefined,
|
|
2749
|
-
};
|
|
2750
|
-
} catch (fallbackErr: any) {
|
|
2751
|
-
// Both persistent and fallback failed — TP-068: clear logging
|
|
2752
|
-
clearInterval(state.reviewerTimer);
|
|
2753
|
-
clearReviewerState();
|
|
2754
|
-
state.reviewerStatus = "error";
|
|
2755
|
-
writeLaneState(state);
|
|
2756
|
-
updateWidgets();
|
|
2757
|
-
|
|
2758
|
-
const skipMsg = `⚠️ Reviews skipped for Step ${stepNum} — reviewer model could not process ${reviewType} review request. Both persistent and fallback modes failed.`;
|
|
2759
|
-
console.error(`[task-runner] ${skipMsg}`);
|
|
2760
|
-
logExecution(statusPath, `Reviewer R${num}`,
|
|
2761
|
-
`${skipMsg} Error: ${fallbackErr?.message || fallbackErr}`);
|
|
2762
|
-
|
|
2763
|
-
// TP-068: Ensure shutdown signal is written even on double failure
|
|
2764
|
-
try {
|
|
2765
|
-
const shutdownPath = join(reviewsDir, REVIEWER_SHUTDOWN_SIGNAL);
|
|
2766
|
-
if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
|
|
2767
|
-
writeFileSync(shutdownPath, "shutdown");
|
|
2768
|
-
} catch {}
|
|
2769
|
-
|
|
2770
|
-
return {
|
|
2771
|
-
content: [{ type: "text" as const, text: `UNAVAILABLE — ${skipMsg}` }],
|
|
2772
|
-
details: undefined,
|
|
2773
|
-
};
|
|
2774
|
-
}
|
|
1690
|
+
clearReviewerState();
|
|
1691
|
+
state.reviewerStatus = "error";
|
|
1692
|
+
writeLaneState(state);
|
|
1693
|
+
updateWidgets();
|
|
1694
|
+
const msg = `UNAVAILABLE — reviewer failed: ${err?.message || err}`;
|
|
1695
|
+
logExecution(statusPath, `Reviewer R${num}`, msg);
|
|
1696
|
+
return { content: [{ type: "text" as const, text: msg }], details: undefined };
|
|
2775
1697
|
}
|
|
2776
1698
|
},
|
|
2777
1699
|
});
|
|
@@ -2838,20 +1760,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
2838
1760
|
return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
|
|
2839
1761
|
}
|
|
2840
1762
|
|
|
2841
|
-
// ── TP-097: Generate stable sidecar paths ONCE before the iteration loop ──
|
|
2842
|
-
// These paths are reused across all worker iterations so that:
|
|
2843
|
-
// 1. After crash recovery, the new worker writes to the SAME sidecar file
|
|
2844
|
-
// 2. tailState preserves byte offset, so tailing resumes from last position
|
|
2845
|
-
// 3. Exit summary overwrites the same file (latest iteration wins)
|
|
2846
|
-
const spawnMode = getSpawnMode(config);
|
|
2847
|
-
let workerStableSidecar: { sidecarPath: string; exitSummaryPath: string } | null = null;
|
|
2848
|
-
let workerTailState: SidecarTailState | null = null;
|
|
2849
|
-
if (spawnMode === "tmux") {
|
|
2850
|
-
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
2851
|
-
workerStableSidecar = generateStableSidecarPaths(sessionName, task.taskId);
|
|
2852
|
-
workerTailState = createSidecarTailState();
|
|
2853
|
-
console.error(`[task-runner] TP-097: stable sidecar path: ${workerStableSidecar.sidecarPath}`);
|
|
2854
|
-
}
|
|
2855
1763
|
|
|
2856
1764
|
let noProgressCount = 0;
|
|
2857
1765
|
for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
|
|
@@ -2911,7 +1819,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2911
1819
|
writeLaneState(state);
|
|
2912
1820
|
}
|
|
2913
1821
|
|
|
2914
|
-
await runWorker(remainingSteps, ctx
|
|
1822
|
+
await runWorker(remainingSteps, ctx);
|
|
2915
1823
|
|
|
2916
1824
|
// Write context % snapshot at iteration boundary (TP-094)
|
|
2917
1825
|
const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
|
|
@@ -3236,14 +2144,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3236
2144
|
|
|
3237
2145
|
// ── Worker ───────────────────────────────────────────────────────
|
|
3238
2146
|
|
|
3239
|
-
/** Pre-generated sidecar paths for stable identity across iterations (TP-097). */
|
|
3240
|
-
type StableSidecarPaths = { sidecarPath: string; exitSummaryPath: string };
|
|
3241
2147
|
|
|
3242
2148
|
async function runWorker(
|
|
3243
2149
|
remainingSteps: StepInfo[],
|
|
3244
2150
|
ctx: ExtensionContext,
|
|
3245
|
-
stableSidecar?: StableSidecarPaths | null,
|
|
3246
|
-
sharedTailState?: SidecarTailState | null,
|
|
3247
2151
|
): Promise<void> {
|
|
3248
2152
|
if (!state.task || !state.config) return;
|
|
3249
2153
|
|
|
@@ -3267,9 +2171,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3267
2171
|
const basePrompt = workerDef?.systemPrompt || "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
|
|
3268
2172
|
const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
3269
2173
|
|
|
3270
|
-
// TP-055: When TASKPLANE_MODEL_FALLBACK=1 is set, skip configured model
|
|
3271
|
-
// and fall back to the session model. This is set by the orchestrator's
|
|
3272
|
-
// model fallback retry when the configured model becomes unavailable.
|
|
3273
2174
|
const modelFallbackActive = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
3274
2175
|
const model = modelFallbackActive
|
|
3275
2176
|
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
@@ -3277,11 +2178,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3277
2178
|
|| workerDef?.model
|
|
3278
2179
|
|| (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514"));
|
|
3279
2180
|
|
|
3280
|
-
// ── Lean worker prompt: pass file paths, not content ──────────
|
|
3281
|
-
// The worker reads PROMPT.md and STATUS.md itself using the read tool.
|
|
3282
|
-
// This keeps the initial prompt small (~500 chars) instead of embedding
|
|
3283
|
-
// 50K+ of compiled content that exceeds Windows command line limits
|
|
3284
|
-
// and wastes initial context window capacity.
|
|
3285
2181
|
const promptLines = [
|
|
3286
2182
|
`Read your task instructions at: ${task.promptPath}`,
|
|
3287
2183
|
`Read your execution state at: ${statusPath}`,
|
|
@@ -3317,10 +2213,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3317
2213
|
state.workerElapsed = 0;
|
|
3318
2214
|
state.workerContextPct = 0;
|
|
3319
2215
|
state.workerLastTool = "";
|
|
3320
|
-
// TP-095: Don't reset workerToolCount — accumulate across iterations (#334).
|
|
3321
|
-
// Previous behavior zeroed the counter on each iteration, losing totals
|
|
3322
|
-
// when a worker crashed and restarted. Token/cost counters already
|
|
3323
|
-
// accumulate via += in onTelemetry and were never reset here.
|
|
3324
2216
|
state.workerRetryActive = false;
|
|
3325
2217
|
state.workerRetryCount = 0;
|
|
3326
2218
|
state.workerLastRetryError = "";
|
|
@@ -3332,248 +2224,69 @@ export default function (pi: ExtensionAPI) {
|
|
|
3332
2224
|
updateWidgets();
|
|
3333
2225
|
}, 1000);
|
|
3334
2226
|
|
|
3335
|
-
const spawnMode = getSpawnMode(config);
|
|
3336
|
-
let promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
3337
|
-
let kill: () => void;
|
|
3338
|
-
let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3339
|
-
let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
|
|
3340
|
-
// Track why the session was killed for exit classification.
|
|
3341
|
-
// "timer" = wall-clock timeout, "context" = context % limit, "user" = manual kill.
|
|
3342
|
-
let killReason: "timer" | "context" | "user" | null = null;
|
|
3343
|
-
// Exit summary path — set only in tmux mode (rpc-wrapper produces this file).
|
|
3344
|
-
let exitSummaryPath: string | null = null;
|
|
3345
|
-
|
|
3346
|
-
// Resolve context window: explicit config → model registry → 200K fallback
|
|
3347
2227
|
const { contextWindow, source: contextWindowSource } = resolveContextWindow(config, ctx);
|
|
3348
2228
|
const warnPct = config.context.warn_percent;
|
|
3349
2229
|
const killPct = config.context.kill_percent;
|
|
3350
2230
|
console.error(`[task-runner] worker context window: ${contextWindow} (${contextWindowSource})`);
|
|
3351
|
-
// One-shot warning when pi doesn't provide authoritative contextUsage (TP-094)
|
|
3352
|
-
let warnedNoContextUsage = false;
|
|
3353
|
-
|
|
3354
|
-
if (spawnMode === "tmux") {
|
|
3355
|
-
// ── TMUX mode ────────────────────────────────────────
|
|
3356
|
-
// Sidecar JSONL provides telemetry parity: tokens, cost, context%,
|
|
3357
|
-
// tool calls, and retry events — same signals as subprocess mode.
|
|
3358
|
-
// Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
|
|
3359
|
-
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
3360
|
-
|
|
3361
|
-
// TP-090: Construct .steering-pending path for worker-only STATUS.md annotation.
|
|
3362
|
-
// Only set when running under orchestrator (mailbox is orch-only).
|
|
3363
|
-
const steeringPendingPath = isOrchestratedMode()
|
|
3364
|
-
? join(task.taskFolder, ".steering-pending")
|
|
3365
|
-
: undefined;
|
|
3366
|
-
|
|
3367
|
-
const spawned = spawnAgentTmux({
|
|
3368
|
-
sessionName,
|
|
3369
|
-
cwd: ctx.cwd,
|
|
3370
|
-
systemPrompt,
|
|
3371
|
-
prompt,
|
|
3372
|
-
model,
|
|
3373
|
-
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
3374
|
-
thinking: config.worker.thinking || "off",
|
|
3375
|
-
taskId: task.taskId,
|
|
3376
|
-
steeringPendingPath,
|
|
3377
|
-
// TP-097: Pass stable sidecar paths and shared tailState for cross-iteration identity
|
|
3378
|
-
sidecarPath: stableSidecar?.sidecarPath,
|
|
3379
|
-
exitSummaryPath: stableSidecar?.exitSummaryPath,
|
|
3380
|
-
tailState: sharedTailState ?? undefined,
|
|
3381
|
-
onTelemetry: (delta) => {
|
|
3382
|
-
// Accumulate tokens and cost (same as subprocess onTokenUpdate)
|
|
3383
|
-
state.workerInputTokens += delta.inputTokens;
|
|
3384
|
-
state.workerOutputTokens += delta.outputTokens;
|
|
3385
|
-
state.workerCacheReadTokens += delta.cacheReadTokens;
|
|
3386
|
-
state.workerCacheWriteTokens += delta.cacheWriteTokens;
|
|
3387
|
-
state.workerCostUsd += delta.cost;
|
|
3388
|
-
|
|
3389
|
-
// Tool tracking (same as subprocess onToolCall)
|
|
3390
|
-
state.workerToolCount += delta.toolCalls;
|
|
3391
|
-
if (delta.lastTool) {
|
|
3392
|
-
state.workerLastTool = delta.lastTool;
|
|
3393
|
-
}
|
|
3394
2231
|
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
state.workerRetryActive = delta.retryActive;
|
|
3398
|
-
if (delta.lastRetryError) {
|
|
3399
|
-
state.workerLastRetryError = delta.lastRetryError;
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
// Context % — authoritative contextUsage only (pi ≥ 0.63.0, TP-094)
|
|
3403
|
-
// Manual token-based fallback removed: avoids false thresholds on older pi.
|
|
3404
|
-
if (delta.contextUsage) {
|
|
3405
|
-
const pct = delta.contextUsage.percent;
|
|
3406
|
-
if (pct > 0) {
|
|
3407
|
-
state.workerContextPct = pct;
|
|
3408
|
-
if (pct >= warnPct) {
|
|
3409
|
-
writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
|
|
3410
|
-
}
|
|
3411
|
-
if (pct >= killPct && state.workerStatus === "running") {
|
|
3412
|
-
console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
|
|
3413
|
-
killReason = "context";
|
|
3414
|
-
spawned.kill();
|
|
3415
|
-
}
|
|
3416
|
-
}
|
|
3417
|
-
} else if (delta.sawStatsResponseWithoutContextUsage && !warnedNoContextUsage) {
|
|
3418
|
-
// One-shot warning: pi responded to get_session_stats but omitted contextUsage (older pi)
|
|
3419
|
-
warnedNoContextUsage = true;
|
|
3420
|
-
console.error(`[task-runner] warning: pi did not provide contextUsage — context pressure thresholds disabled`);
|
|
3421
|
-
}
|
|
2232
|
+
const conversationPrefix = isOrchestratedMode() ? getLanePrefix() : null;
|
|
2233
|
+
if (conversationPrefix) clearConversationLog(conversationPrefix);
|
|
3422
2234
|
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
) {
|
|
3444
|
-
writeWrapUpSignal(`Wrap up (wall-clock ${maxMinutes}min limit)`);
|
|
2235
|
+
const spawned = spawnAgent({
|
|
2236
|
+
model,
|
|
2237
|
+
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2238
|
+
thinking: config.worker.thinking || "off",
|
|
2239
|
+
systemPrompt,
|
|
2240
|
+
prompt,
|
|
2241
|
+
contextWindow,
|
|
2242
|
+
warnPct,
|
|
2243
|
+
killPct,
|
|
2244
|
+
wrapUpFile,
|
|
2245
|
+
onToolCall: (toolName, args) => {
|
|
2246
|
+
state.workerToolCount++;
|
|
2247
|
+
const path = args?.path || args?.command || "";
|
|
2248
|
+
const shortPath = typeof path === "string" && path.length > 80
|
|
2249
|
+
? "..." + path.slice(-77) : path;
|
|
2250
|
+
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2251
|
+
if (conversationPrefix) {
|
|
2252
|
+
appendConversationEvent(conversationPrefix, {
|
|
2253
|
+
type: "tool_call", toolName, args, timestamp: Date.now(),
|
|
2254
|
+
});
|
|
3445
2255
|
}
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
2256
|
+
updateWidgets();
|
|
2257
|
+
},
|
|
2258
|
+
onTokenUpdate: (tokens) => {
|
|
2259
|
+
state.workerInputTokens += tokens.input;
|
|
2260
|
+
state.workerOutputTokens += tokens.output;
|
|
2261
|
+
state.workerCacheReadTokens += tokens.cacheRead;
|
|
2262
|
+
state.workerCacheWriteTokens += tokens.cacheWrite;
|
|
2263
|
+
state.workerCostUsd += tokens.cost;
|
|
2264
|
+
updateWidgets();
|
|
2265
|
+
},
|
|
2266
|
+
onContextPct: (pct) => {
|
|
2267
|
+
state.workerContextPct = pct;
|
|
2268
|
+
if (pct >= warnPct) {
|
|
2269
|
+
writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
|
|
3454
2270
|
}
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
const spawned = spawnAgent({
|
|
3463
|
-
model,
|
|
3464
|
-
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
3465
|
-
thinking: config.worker.thinking || "off",
|
|
3466
|
-
systemPrompt,
|
|
3467
|
-
prompt,
|
|
3468
|
-
contextWindow,
|
|
3469
|
-
warnPct,
|
|
3470
|
-
killPct,
|
|
3471
|
-
wrapUpFile,
|
|
3472
|
-
onToolCall: (toolName, args) => {
|
|
3473
|
-
state.workerToolCount++;
|
|
3474
|
-
// Build a short summary of what the tool is doing
|
|
3475
|
-
const path = args?.path || args?.command || "";
|
|
3476
|
-
const shortPath = typeof path === "string" && path.length > 80
|
|
3477
|
-
? "..." + path.slice(-77) : path;
|
|
3478
|
-
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
3479
|
-
if (conversationPrefix) {
|
|
3480
|
-
appendConversationEvent(conversationPrefix, {
|
|
3481
|
-
type: "tool_call", toolName, args, timestamp: Date.now(),
|
|
3482
|
-
});
|
|
3483
|
-
}
|
|
3484
|
-
updateWidgets();
|
|
3485
|
-
},
|
|
3486
|
-
onTokenUpdate: (tokens) => {
|
|
3487
|
-
// Accumulate across turns — each message_end reports per-turn values.
|
|
3488
|
-
// Anthropic's `input` is only uncached new tokens; cacheRead holds
|
|
3489
|
-
// the bulk of input processing. We sum all four independently so the
|
|
3490
|
-
// dashboard can show the full picture.
|
|
3491
|
-
state.workerInputTokens += tokens.input;
|
|
3492
|
-
state.workerOutputTokens += tokens.output;
|
|
3493
|
-
state.workerCacheReadTokens += tokens.cacheRead;
|
|
3494
|
-
state.workerCacheWriteTokens += tokens.cacheWrite;
|
|
3495
|
-
state.workerCostUsd += tokens.cost;
|
|
3496
|
-
updateWidgets();
|
|
3497
|
-
},
|
|
3498
|
-
onContextPct: (pct) => {
|
|
3499
|
-
state.workerContextPct = pct;
|
|
3500
|
-
if (pct >= warnPct) {
|
|
3501
|
-
writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
|
|
3502
|
-
}
|
|
3503
|
-
updateWidgets();
|
|
3504
|
-
},
|
|
3505
|
-
onJsonEvent: conversationPrefix
|
|
3506
|
-
? (event: Record<string, unknown>) => appendConversationEvent(conversationPrefix, event)
|
|
3507
|
-
: undefined,
|
|
3508
|
-
});
|
|
3509
|
-
promise = spawned.promise;
|
|
3510
|
-
kill = spawned.kill;
|
|
3511
|
-
}
|
|
3512
|
-
|
|
3513
|
-
state.workerProc = { kill };
|
|
3514
|
-
|
|
3515
|
-
const result = await promise;
|
|
2271
|
+
updateWidgets();
|
|
2272
|
+
},
|
|
2273
|
+
onJsonEvent: conversationPrefix
|
|
2274
|
+
? (event: Record<string, unknown>) => appendConversationEvent(conversationPrefix, event)
|
|
2275
|
+
: undefined,
|
|
2276
|
+
});
|
|
3516
2277
|
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
if (wallClockKillTimer) clearTimeout(wallClockKillTimer);
|
|
2278
|
+
state.workerProc = { kill: spawned.kill };
|
|
2279
|
+
const result = await spawned.promise;
|
|
3520
2280
|
|
|
3521
2281
|
clearInterval(state.workerTimer);
|
|
3522
2282
|
state.workerElapsed = Date.now() - startTime;
|
|
3523
2283
|
state.workerStatus = result.killed ? "killed" : (result.exitCode === 0 ? "done" : "error");
|
|
3524
2284
|
state.workerProc = null;
|
|
3525
|
-
|
|
3526
2285
|
clearWrapUpSignals();
|
|
3527
2286
|
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
// Subprocess mode doesn't produce exit summaries (it uses JSON
|
|
3532
|
-
// event stream directly), so this path is tmux-only.
|
|
3533
|
-
if (spawnMode === "tmux" && exitSummaryPath) {
|
|
3534
|
-
const exitSummary = readExitSummary(exitSummaryPath);
|
|
3535
|
-
const donePath = join(task.taskFolder, ".DONE");
|
|
3536
|
-
const doneFileFound = existsSync(donePath);
|
|
3537
|
-
|
|
3538
|
-
// Determine userKilled: killed is true but not by timer or context
|
|
3539
|
-
const userKilled = result.killed && killReason === null;
|
|
3540
|
-
|
|
3541
|
-
const diagnostic = buildExitDiagnostic({
|
|
3542
|
-
exitSummary,
|
|
3543
|
-
doneFileFound,
|
|
3544
|
-
timerKilled: killReason === "timer",
|
|
3545
|
-
contextKilled: killReason === "context",
|
|
3546
|
-
userKilled,
|
|
3547
|
-
contextPct: state.workerContextPct,
|
|
3548
|
-
durationSec: Math.round(state.workerElapsed / 1000),
|
|
3549
|
-
repoId: process.env.TASKPLANE_REPO_ID || "default",
|
|
3550
|
-
lastKnownStep: state.currentStep || null,
|
|
3551
|
-
lastKnownCheckbox: null, // Not parsed in task-runner; available via STATUS.md
|
|
3552
|
-
partialProgressCommits: 0, // Computed by orchestrator after commit
|
|
3553
|
-
partialProgressBranch: null,
|
|
3554
|
-
});
|
|
3555
|
-
|
|
3556
|
-
// Store diagnostic on state for lane-state sidecar and logging
|
|
3557
|
-
state.workerExitDiagnostic = diagnostic;
|
|
3558
|
-
|
|
3559
|
-
console.error(`[task-runner] exit diagnostic: ${diagnostic.classification}` +
|
|
3560
|
-
(diagnostic.exitCode !== null ? ` (exit ${diagnostic.exitCode})` : "") +
|
|
3561
|
-
(exitSummary ? `` : " (no exit summary)"));
|
|
3562
|
-
|
|
3563
|
-
// Log telemetry file paths for operator visibility (files preserved for dashboard)
|
|
3564
|
-
const sidecarPath = exitSummaryPath.replace(/-exit\.json$/, ".jsonl");
|
|
3565
|
-
console.error(`[task-runner] telemetry files preserved:` +
|
|
3566
|
-
`\n sidecar: ${sidecarPath}` +
|
|
3567
|
-
`\n exit summary: ${exitSummaryPath}`);
|
|
3568
|
-
}
|
|
3569
|
-
|
|
3570
|
-
// Log with telemetry detail — both subprocess and TMUX now have context%
|
|
3571
|
-
const killedMsg = result.killed
|
|
3572
|
-
? (spawnMode === "tmux"
|
|
3573
|
-
? `killed (${killReason === "context" ? "context limit" : killReason === "timer" ? "wall-clock timeout" : "user"})`
|
|
3574
|
-
: "killed (context limit)")
|
|
3575
|
-
: "";
|
|
3576
|
-
const statusMsg = killedMsg || (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
|
|
2287
|
+
const statusMsg = result.killed
|
|
2288
|
+
? "killed (context limit)"
|
|
2289
|
+
: (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
|
|
3577
2290
|
logExecution(statusPath, `Worker iter ${state.totalIterations}`,
|
|
3578
2291
|
`${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s, ctx: ${Math.round(state.workerContextPct)}%, tools: ${state.workerToolCount}`);
|
|
3579
2292
|
|
|
@@ -3600,7 +2313,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3600
2313
|
writeFileSync(requestPath, request);
|
|
3601
2314
|
|
|
3602
2315
|
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
3603
|
-
// TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
|
|
3604
2316
|
const reviewerModelFallback2 = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
3605
2317
|
const reviewerModel = reviewerModelFallback2
|
|
3606
2318
|
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
@@ -3620,50 +2332,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
3620
2332
|
updateWidgets();
|
|
3621
2333
|
}, 1000);
|
|
3622
2334
|
|
|
3623
|
-
// Read the request file content as the prompt
|
|
3624
2335
|
const promptContent = readFileSync(requestPath, "utf-8");
|
|
2336
|
+
const spawned = spawnAgent({
|
|
2337
|
+
model: reviewerModel,
|
|
2338
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2339
|
+
thinking: config.reviewer.thinking || "on",
|
|
2340
|
+
systemPrompt,
|
|
2341
|
+
prompt: promptContent,
|
|
2342
|
+
onToolCall: (toolName, args) => {
|
|
2343
|
+
const path = args?.path || args?.command || "";
|
|
2344
|
+
const shortPath = typeof path === "string" && path.length > 40
|
|
2345
|
+
? "..." + path.slice(-37) : path;
|
|
2346
|
+
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2347
|
+
updateWidgets();
|
|
2348
|
+
},
|
|
2349
|
+
});
|
|
2350
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
3625
2351
|
|
|
3626
|
-
const
|
|
3627
|
-
let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
3628
|
-
|
|
3629
|
-
if (spawnMode === "tmux") {
|
|
3630
|
-
// ── TMUX mode ────────────────────────────────────────
|
|
3631
|
-
// No JSON stream → no onToolCall callback.
|
|
3632
|
-
// No timeout — reviewer runs to session completion.
|
|
3633
|
-
const sessionName = `${getTmuxPrefix()}-reviewer`;
|
|
3634
|
-
const spawned = spawnAgentTmux({
|
|
3635
|
-
sessionName,
|
|
3636
|
-
cwd: ctx.cwd,
|
|
3637
|
-
systemPrompt,
|
|
3638
|
-
prompt: promptContent,
|
|
3639
|
-
model: reviewerModel,
|
|
3640
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
3641
|
-
thinking: config.reviewer.thinking || "on",
|
|
3642
|
-
taskId: state.task?.taskId,
|
|
3643
|
-
});
|
|
3644
|
-
reviewPromise = spawned.promise;
|
|
3645
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
3646
|
-
} else {
|
|
3647
|
-
// ── Subprocess mode (default, unchanged) ─────────────
|
|
3648
|
-
const spawned = spawnAgent({
|
|
3649
|
-
model: reviewerModel,
|
|
3650
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
3651
|
-
thinking: config.reviewer.thinking || "on",
|
|
3652
|
-
systemPrompt,
|
|
3653
|
-
prompt: promptContent,
|
|
3654
|
-
onToolCall: (toolName, args) => {
|
|
3655
|
-
const path = args?.path || args?.command || "";
|
|
3656
|
-
const shortPath = typeof path === "string" && path.length > 40
|
|
3657
|
-
? "..." + path.slice(-37) : path;
|
|
3658
|
-
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
3659
|
-
updateWidgets();
|
|
3660
|
-
},
|
|
3661
|
-
});
|
|
3662
|
-
reviewPromise = spawned.promise;
|
|
3663
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
3664
|
-
}
|
|
3665
|
-
|
|
3666
|
-
const result = await reviewPromise;
|
|
2352
|
+
const result = await spawned.promise;
|
|
3667
2353
|
|
|
3668
2354
|
clearInterval(state.reviewerTimer);
|
|
3669
2355
|
state.reviewerElapsed = Date.now() - startTime;
|
|
@@ -3671,7 +2357,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3671
2357
|
state.reviewerProc = null;
|
|
3672
2358
|
updateWidgets();
|
|
3673
2359
|
|
|
3674
|
-
// Read verdict
|
|
3675
2360
|
let verdict = "UNKNOWN";
|
|
3676
2361
|
if (existsSync(outputPath)) {
|
|
3677
2362
|
const review = readFileSync(outputPath, "utf-8");
|
|
@@ -3686,7 +2371,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3686
2371
|
updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
|
|
3687
2372
|
|
|
3688
2373
|
ctx.ui.notify(`Review R${num} (${type} Step ${step.number}): ${verdict}`, verdict === "APPROVE" ? "success" : "warning");
|
|
3689
|
-
|
|
3690
2374
|
return verdict;
|
|
3691
2375
|
}
|
|
3692
2376
|
|
|
@@ -3722,11 +2406,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3722
2406
|
const config = state.config;
|
|
3723
2407
|
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
3724
2408
|
|
|
3725
|
-
// Delete any previous verdict file so we can detect agent failure
|
|
3726
2409
|
const verdictPath = join(task.taskFolder, VERDICT_FILENAME);
|
|
3727
|
-
try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch {
|
|
2410
|
+
try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch {}
|
|
3728
2411
|
|
|
3729
|
-
// Build the quality gate context and prompt
|
|
3730
2412
|
const gateContext: QualityGateContext = {
|
|
3731
2413
|
taskFolder: task.taskFolder,
|
|
3732
2414
|
promptPath: task.promptPath,
|
|
@@ -3736,11 +2418,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3736
2418
|
};
|
|
3737
2419
|
|
|
3738
2420
|
const prompt = generateQualityGatePrompt(gateContext, ctx.cwd);
|
|
3739
|
-
|
|
3740
|
-
// Determine review model with fallback chain:
|
|
3741
|
-
// quality_gate.review_model → reviewer.model → agent def → default
|
|
3742
2421
|
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
3743
|
-
// TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
|
|
3744
2422
|
const qgModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
3745
2423
|
const reviewModel = qgModelFallback
|
|
3746
2424
|
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
@@ -3753,7 +2431,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3753
2431
|
|| "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
|
|
3754
2432
|
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
3755
2433
|
|
|
3756
|
-
// Update UI state
|
|
3757
2434
|
state.reviewerStatus = "running";
|
|
3758
2435
|
state.reviewerType = `quality-gate cycle ${cycleNum}`;
|
|
3759
2436
|
state.reviewerElapsed = 0;
|
|
@@ -3768,44 +2445,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
3768
2445
|
|
|
3769
2446
|
logExecution(statusPath, `Quality gate`, `Starting review cycle ${cycleNum}`);
|
|
3770
2447
|
|
|
3771
|
-
const spawnMode = getSpawnMode(config);
|
|
3772
|
-
let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
3773
|
-
|
|
3774
2448
|
try {
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
const spawned = spawnAgent({
|
|
3791
|
-
model: reviewModel,
|
|
3792
|
-
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
3793
|
-
thinking: config.reviewer.thinking || "on",
|
|
3794
|
-
systemPrompt,
|
|
3795
|
-
prompt,
|
|
3796
|
-
onToolCall: (toolName, args) => {
|
|
3797
|
-
const path = args?.path || args?.command || "";
|
|
3798
|
-
const shortPath = typeof path === "string" && path.length > 40
|
|
3799
|
-
? "..." + path.slice(-37) : path;
|
|
3800
|
-
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
3801
|
-
updateWidgets();
|
|
3802
|
-
},
|
|
3803
|
-
});
|
|
3804
|
-
reviewPromise = spawned.promise;
|
|
3805
|
-
state.reviewerProc = { kill: spawned.kill };
|
|
3806
|
-
}
|
|
2449
|
+
const spawned = spawnAgent({
|
|
2450
|
+
model: reviewModel,
|
|
2451
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2452
|
+
thinking: config.reviewer.thinking || "on",
|
|
2453
|
+
systemPrompt,
|
|
2454
|
+
prompt,
|
|
2455
|
+
onToolCall: (toolName, args) => {
|
|
2456
|
+
const path = args?.path || args?.command || "";
|
|
2457
|
+
const shortPath = typeof path === "string" && path.length > 40
|
|
2458
|
+
? "..." + path.slice(-37) : path;
|
|
2459
|
+
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2460
|
+
updateWidgets();
|
|
2461
|
+
},
|
|
2462
|
+
});
|
|
2463
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
3807
2464
|
|
|
3808
|
-
const result = await
|
|
2465
|
+
const result = await spawned.promise;
|
|
3809
2466
|
|
|
3810
2467
|
clearInterval(state.reviewerTimer);
|
|
3811
2468
|
state.reviewerElapsed = Date.now() - startTime;
|
|
@@ -3813,7 +2470,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3813
2470
|
state.reviewerProc = null;
|
|
3814
2471
|
updateWidgets();
|
|
3815
2472
|
|
|
3816
|
-
// If agent exited non-zero, fail-open
|
|
3817
2473
|
if (result.exitCode !== 0) {
|
|
3818
2474
|
logExecution(statusPath, `Quality gate`, `Review agent exited with code ${result.exitCode} — fail-open → PASS`);
|
|
3819
2475
|
ctx.ui.notify(`Quality gate: review agent error (exit ${result.exitCode}) — fail-open PASS`, "warning");
|
|
@@ -3824,12 +2480,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3824
2480
|
};
|
|
3825
2481
|
}
|
|
3826
2482
|
} catch (err: any) {
|
|
3827
|
-
// Agent crash — fail-open
|
|
3828
2483
|
clearInterval(state.reviewerTimer);
|
|
3829
2484
|
state.reviewerStatus = "error";
|
|
3830
2485
|
state.reviewerProc = null;
|
|
3831
2486
|
updateWidgets();
|
|
3832
|
-
|
|
3833
2487
|
logExecution(statusPath, `Quality gate`, `Review agent crashed: ${err?.message || err} — fail-open → PASS`);
|
|
3834
2488
|
ctx.ui.notify(`Quality gate: review agent crashed — fail-open PASS`, "warning");
|
|
3835
2489
|
return {
|
|
@@ -3839,13 +2493,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3839
2493
|
};
|
|
3840
2494
|
}
|
|
3841
2495
|
|
|
3842
|
-
// Read and evaluate the verdict file
|
|
3843
2496
|
const { verdict, evaluation } = readAndEvaluateVerdict(
|
|
3844
2497
|
task.taskFolder,
|
|
3845
2498
|
config.quality_gate.pass_threshold,
|
|
3846
2499
|
);
|
|
3847
2500
|
|
|
3848
|
-
// Apply STATUS.md reconciliation if verdict has entries
|
|
3849
2501
|
if (verdict.statusReconciliation.length > 0) {
|
|
3850
2502
|
const reconResult = applyStatusReconciliation(statusPath, verdict.statusReconciliation);
|
|
3851
2503
|
if (reconResult.changed > 0 || reconResult.unmatched > 0) {
|
|
@@ -3883,7 +2535,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3883
2535
|
/**
|
|
3884
2536
|
* Spawn a fix agent to address quality gate findings.
|
|
3885
2537
|
*
|
|
3886
|
-
* Reuses the worker spawn pattern
|
|
2538
|
+
* Reuses the standard worker subprocess spawn pattern. The fix agent
|
|
3887
2539
|
* receives REVIEW_FEEDBACK.md content and makes targeted code fixes.
|
|
3888
2540
|
*
|
|
3889
2541
|
* Handles abnormal exits deterministically:
|
|
@@ -3912,26 +2564,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
3912
2564
|
const config = state.config;
|
|
3913
2565
|
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
3914
2566
|
|
|
3915
|
-
// Use worker model and tools for fix agent (it needs to edit code)
|
|
3916
2567
|
const workerDef = loadAgentDef(ctx.cwd, "task-worker");
|
|
3917
|
-
// TP-055: model fallback — use session model when TASKPLANE_MODEL_FALLBACK=1
|
|
3918
2568
|
const fixModelFallback = process.env.TASKPLANE_MODEL_FALLBACK === "1";
|
|
3919
2569
|
const fixModel = fixModelFallback
|
|
3920
2570
|
? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "anthropic/claude-sonnet-4-20250514")
|
|
3921
|
-
: (config.worker.model
|
|
3922
|
-
|| workerDef?.model
|
|
3923
|
-
|| "anthropic/claude-sonnet-4-20250514");
|
|
2571
|
+
: (config.worker.model || workerDef?.model || "anthropic/claude-sonnet-4-20250514");
|
|
3924
2572
|
|
|
3925
2573
|
const basePrompt = workerDef?.systemPrompt
|
|
3926
2574
|
|| "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
|
|
3927
2575
|
const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
3928
2576
|
|
|
3929
|
-
// Wall-clock timeout: use half of worker limit (fix agents should be quick),
|
|
3930
|
-
// with a floor of 15 minutes.
|
|
3931
2577
|
const workerMinutes = getMaxWorkerMinutes(config);
|
|
3932
2578
|
const timeoutMs = Math.max(FIX_AGENT_TIMEOUT_MS, Math.floor(workerMinutes / 2) * 60 * 1000);
|
|
3933
2579
|
|
|
3934
|
-
// Update UI state
|
|
3935
2580
|
state.workerStatus = "running";
|
|
3936
2581
|
state.workerElapsed = 0;
|
|
3937
2582
|
state.workerContextPct = 0;
|
|
@@ -3950,98 +2595,54 @@ export default function (pi: ExtensionAPI) {
|
|
|
3950
2595
|
|
|
3951
2596
|
logExecution(statusPath, "Quality gate", `Starting fix agent (cycle ${fixCycleNum}, timeout: ${Math.round(timeoutMs / 60000)}min)`);
|
|
3952
2597
|
|
|
3953
|
-
const spawnMode = getSpawnMode(config);
|
|
3954
|
-
let fixPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
3955
2598
|
let killFn: (() => void) | null = null;
|
|
3956
|
-
let tmuxExitSummaryPath: string | null = null;
|
|
3957
2599
|
|
|
3958
2600
|
try {
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
const spawned = spawnAgent({
|
|
3977
|
-
model: fixModel,
|
|
3978
|
-
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
3979
|
-
thinking: config.worker.thinking || "off",
|
|
3980
|
-
systemPrompt,
|
|
3981
|
-
prompt: fixPrompt,
|
|
3982
|
-
onToolCall: (toolName, args) => {
|
|
3983
|
-
state.workerToolCount++;
|
|
3984
|
-
const path = args?.path || args?.command || "";
|
|
3985
|
-
const shortPath = typeof path === "string" && path.length > 80
|
|
3986
|
-
? "..." + path.slice(-77) : path;
|
|
3987
|
-
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
3988
|
-
updateWidgets();
|
|
3989
|
-
},
|
|
3990
|
-
});
|
|
3991
|
-
fixPromise = spawned.promise;
|
|
3992
|
-
killFn = spawned.kill;
|
|
3993
|
-
state.workerProc = { kill: spawned.kill };
|
|
3994
|
-
}
|
|
2601
|
+
const spawned = spawnAgent({
|
|
2602
|
+
model: fixModel,
|
|
2603
|
+
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2604
|
+
thinking: config.worker.thinking || "off",
|
|
2605
|
+
systemPrompt,
|
|
2606
|
+
prompt: fixPrompt,
|
|
2607
|
+
onToolCall: (toolName, args) => {
|
|
2608
|
+
state.workerToolCount++;
|
|
2609
|
+
const path = args?.path || args?.command || "";
|
|
2610
|
+
const shortPath = typeof path === "string" && path.length > 80
|
|
2611
|
+
? "..." + path.slice(-77) : path;
|
|
2612
|
+
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2613
|
+
updateWidgets();
|
|
2614
|
+
},
|
|
2615
|
+
});
|
|
2616
|
+
killFn = spawned.kill;
|
|
2617
|
+
state.workerProc = { kill: spawned.kill };
|
|
3995
2618
|
|
|
3996
|
-
// Race the agent against a wall-clock timeout
|
|
3997
2619
|
let timedOut = false;
|
|
3998
2620
|
const timeoutPromise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
|
|
3999
2621
|
const timer = setTimeout(() => {
|
|
4000
2622
|
timedOut = true;
|
|
4001
2623
|
logExecution(statusPath, "Quality gate", `Fix agent wall-clock timeout (${Math.round(timeoutMs / 60000)}min) — killing agent`);
|
|
4002
2624
|
if (killFn) killFn();
|
|
4003
|
-
// Resolve after a brief delay to allow kill to take effect
|
|
4004
2625
|
setTimeout(() => {
|
|
4005
2626
|
resolve({ output: "timeout", exitCode: 1, elapsed: Date.now() - startTime, killed: true });
|
|
4006
2627
|
}, 5000);
|
|
4007
2628
|
}, timeoutMs);
|
|
4008
|
-
|
|
4009
|
-
fixPromise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
|
|
2629
|
+
spawned.promise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
|
|
4010
2630
|
});
|
|
4011
2631
|
|
|
4012
|
-
const result = await Promise.race([
|
|
4013
|
-
|
|
4014
|
-
// ── TMUX exit classification ─────────────────────────
|
|
4015
|
-
// spawnAgentTmux always reports exitCode: 0 on session end.
|
|
4016
|
-
// Read the exit summary written by rpc-wrapper to get the
|
|
4017
|
-
// real Pi process exit code (same pattern as worker flow).
|
|
4018
|
-
let effectiveExitCode = result.exitCode;
|
|
4019
|
-
if (spawnMode === "tmux" && tmuxExitSummaryPath && !timedOut) {
|
|
4020
|
-
const exitSummary = readExitSummary(tmuxExitSummaryPath);
|
|
4021
|
-
if (exitSummary && typeof exitSummary.exitCode === "number") {
|
|
4022
|
-
effectiveExitCode = exitSummary.exitCode;
|
|
4023
|
-
if (effectiveExitCode !== 0) {
|
|
4024
|
-
console.error(`[task-runner] qg-fix: tmux exit summary reports exit code ${effectiveExitCode}`);
|
|
4025
|
-
}
|
|
4026
|
-
}
|
|
4027
|
-
// If no exit summary exists, keep the tmux-reported code (0).
|
|
4028
|
-
// This is fail-open: missing exit summary ≠ crash.
|
|
4029
|
-
}
|
|
2632
|
+
const result = await Promise.race([spawned.promise, timeoutPromise]);
|
|
4030
2633
|
|
|
4031
2634
|
clearInterval(state.workerTimer);
|
|
4032
2635
|
state.workerElapsed = Date.now() - startTime;
|
|
4033
|
-
state.workerStatus = (
|
|
2636
|
+
state.workerStatus = (result.exitCode === 0 && !timedOut) ? "done" : "error";
|
|
4034
2637
|
state.workerProc = null;
|
|
4035
2638
|
updateWidgets();
|
|
4036
2639
|
|
|
4037
|
-
return { exitCode: timedOut ? 1 :
|
|
2640
|
+
return { exitCode: timedOut ? 1 : result.exitCode, elapsed: Date.now() - startTime, timedOut };
|
|
4038
2641
|
} catch (err: any) {
|
|
4039
|
-
// Fix agent crashed — return non-zero to consume fix budget
|
|
4040
2642
|
clearInterval(state.workerTimer);
|
|
4041
2643
|
state.workerStatus = "error";
|
|
4042
2644
|
state.workerProc = null;
|
|
4043
2645
|
updateWidgets();
|
|
4044
|
-
|
|
4045
2646
|
logExecution(statusPath, "Quality gate", `Fix agent crashed: ${err?.message || err} — fix cycle ${fixCycleNum} consumed`);
|
|
4046
2647
|
return { exitCode: 1, elapsed: Date.now() - startTime, timedOut: false };
|
|
4047
2648
|
}
|
|
@@ -4267,8 +2868,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
4267
2868
|
} else if (process.env.TASK_AUTOSTART) {
|
|
4268
2869
|
// ── TASK_AUTOSTART ────────────────────────────────────────
|
|
4269
2870
|
// When set, automatically start a task as if the user typed
|
|
4270
|
-
// `/task <path>`. Used by the
|
|
4271
|
-
// workers
|
|
2871
|
+
// `/task <path>`. Used by the orchestrator to launch
|
|
2872
|
+
// workers automatically without manual command entry timing issues.
|
|
4272
2873
|
const autoPath = process.env.TASK_AUTOSTART;
|
|
4273
2874
|
const fullPath = resolve(ctx.cwd, autoPath);
|
|
4274
2875
|
if (!existsSync(fullPath)) {
|