taskplane 0.24.2 → 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" | "tmux";
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 tmux worker iteration (null in subprocess mode or before first completion). */
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
- /** TP-057: Persistent reviewer session tracks the long-lived reviewer tmux session. */
173
+ /** Reserved for compatibility with legacy lane-state payloads. */
174
174
  persistentReviewerSession: string | null;
175
- /** TP-057: Kill function for the persistent reviewer (to stop sidecar polling). */
175
+ /** Reserved for compatibility with legacy lane-state payloads. */
176
176
  persistentReviewerKill: (() => void) | null;
177
- /** TP-057: Signal counter for the persistent reviewer (monotonically increasing). */
177
+ /** Reserved for compatibility with legacy lane-state payloads. */
178
178
  persistentReviewerSignalNum: number;
179
- /** Reviewer respawn counter circuit breaker to prevent infinite respawn loops. */
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
- // ── Spawn Mode Resolution ────────────────────────────────────────────
305
+ // ── Runtime Mode Helpers ─────────────────────────────────────────────
306
306
 
307
307
  /**
308
- * Determines whether workers/reviewers spawn as headless subprocesses
309
- * (existing behavior) or as TMUX sessions (parallel orchestrator mode).
308
+ * Detect whether this runner is executing under /orch orchestration.
310
309
  *
311
- * Resolution order: env var config default "subprocess".
312
- * The orchestrator sets TASK_RUNNER_SPAWN_MODE=tmux per-lane.
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 getSpawnMode(config: TaskConfig): "subprocess" | "tmux" {
315
- const envMode = process.env.TASK_RUNNER_SPAWN_MODE;
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
- * Detects whether this task runner is executing inside the parallel orchestrator.
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 isOrchestratedMode(): boolean {
344
- return !!process.env.TASK_RUNNER_TMUX_PREFIX;
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 the wall-clock timeout for TMUX worker sessions in minutes.
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 = getTmuxPrefix(); // e.g., "orch-lane-1"
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() ? `${getTmuxPrefix()}-worker` : "task-worker";
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 when reviewer is killed after APPROVE. */
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, "Persistent reviewer", `Shutdown complete (${reason})`);
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. This lets the reviewer see only that step's changes.",
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, `Skip code review`,
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
- || reviewerDef?.model
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 = sessionName;
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
- // Resolve context window for reviewer context% calculation
2443
- const { contextWindow } = resolveContextWindow(config, ctx);
2444
-
2445
- // ── TP-057: Persistent Reviewer Session ─────────────────
2446
- // On the first review_step call, spawn a persistent reviewer
2447
- // that stays alive via the wait_for_review tool. On subsequent
2448
- // calls, reuse the existing session by writing signal files.
2449
- // Fall back to fresh-spawn if the persistent session dies.
2450
-
2451
- /**
2452
- * Check if the persistent reviewer tmux session is still alive.
2453
- */
2454
- function isPersistentReviewerAlive(): boolean {
2455
- if (!state.persistentReviewerSession) return false;
2456
- const result = spawnSync("tmux", ["has-session", "-t", state.persistentReviewerSession]);
2457
- return result.status === 0;
2458
- }
2459
-
2460
- /**
2461
- * Spawn a persistent reviewer session with the reviewer-extension
2462
- * loaded, so the reviewer can use wait_for_review to receive requests.
2463
- */
2464
- function spawnPersistentReviewer(): void {
2465
- const reviewerExtPath = resolveReviewerExtensionPath();
2466
- if (!reviewerExtPath) {
2467
- throw new Error("Cannot find reviewer-extension.ts. Ensure taskplane is installed correctly.");
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
- // ── Persistent reviewer: spawn or reuse ─────────────
2582
- const needsSpawn = !state.persistentReviewerSession || !isPersistentReviewerAlive();
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
- // ── Fallback: kill persistent session, try fresh spawn ──
2668
- state.reviewerRespawnCount++;
2669
- console.error(`[task-runner] persistent reviewer error (${state.reviewerRespawnCount}/3): ${err?.message || err}`);
2670
- logExecution(statusPath, `Reviewer R${num}`,
2671
- `persistent reviewer failedfalling back to fresh spawn: ${err?.message || err}`);
2672
-
2673
- // Kill the dead/broken persistent session
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, workerStableSidecar, workerTailState);
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
- // Retry tracking
3396
- state.workerRetryCount += delta.retriesStarted;
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
- updateWidgets();
3424
- },
3425
- });
3426
- promise = spawned.promise;
3427
- kill = spawned.kill;
3428
- exitSummaryPath = spawned.exitSummaryPath;
3429
-
3430
- // Wall-clock timeout: write wrap-up file at 80% of limit,
3431
- // hard kill at 100%. Context-% based wrap-up/kill is also active
3432
- // via sidecar telemetry (above), providing dual safety nets.
3433
- const maxMinutes = getMaxWorkerMinutes(config);
3434
- const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
3435
- const killMs = maxMinutes * 60_000;
3436
- const iterationMarker = state.totalIterations;
3437
-
3438
- // Wrap-up warning at 80% of wall-clock limit
3439
- wallClockWarnTimer = setTimeout(() => {
3440
- if (
3441
- state.workerStatus === "running" &&
3442
- state.totalIterations === iterationMarker
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
- }, warnMs);
3447
-
3448
- // Hard kill at 100% of wall-clock limit
3449
- wallClockKillTimer = setTimeout(() => {
3450
- if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
3451
- console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
3452
- killReason = "timer";
3453
- kill();
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
- }, killMs);
3456
- } else {
3457
- // ── Subprocess mode (default, unchanged) ─────────────
3458
- // In orchestrated mode, tee conversation events to JSONL for web dashboard
3459
- const conversationPrefix = isOrchestratedMode() ? getTmuxPrefix() : null;
3460
- if (conversationPrefix) clearConversationLog(conversationPrefix);
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
- // Clean up wall-clock timers if they haven't fired yet
3518
- if (wallClockWarnTimer) clearTimeout(wallClockWarnTimer);
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
- // ── Exit Diagnostic (tmux mode only) ─────────────────────
3529
- // Read the exit summary JSON written by rpc-wrapper.mjs, classify
3530
- // the exit, and build a structured diagnostic for persistence.
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 spawnMode = getSpawnMode(config);
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 { /* ignore */ }
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
- if (spawnMode === "tmux") {
3776
- const sessionName = `${getTmuxPrefix()}-qg-reviewer`;
3777
- const spawned = spawnAgentTmux({
3778
- sessionName,
3779
- cwd: ctx.cwd,
3780
- systemPrompt,
3781
- prompt,
3782
- model: reviewModel,
3783
- tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
3784
- thinking: config.reviewer.thinking || "on",
3785
- taskId: task.taskId,
3786
- });
3787
- reviewPromise = spawned.promise;
3788
- state.reviewerProc = { kill: spawned.kill };
3789
- } else {
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 reviewPromise;
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 (subprocess or tmux). The fix agent
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
- if (spawnMode === "tmux") {
3960
- const sessionName = `${getTmuxPrefix()}-qg-fix`;
3961
- const spawned = spawnAgentTmux({
3962
- sessionName,
3963
- cwd: ctx.cwd,
3964
- systemPrompt,
3965
- prompt: fixPrompt,
3966
- model: fixModel,
3967
- tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
3968
- thinking: config.worker.thinking || "off",
3969
- taskId: task.taskId,
3970
- });
3971
- fixPromise = spawned.promise;
3972
- killFn = spawned.kill;
3973
- tmuxExitSummaryPath = spawned.exitSummaryPath;
3974
- state.workerProc = { kill: spawned.kill };
3975
- } else {
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
- // Clean up timer if agent finishes first
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([fixPromise, timeoutPromise]);
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 = (effectiveExitCode === 0 && !timedOut) ? "done" : "error";
2636
+ state.workerStatus = (result.exitCode === 0 && !timedOut) ? "done" : "error";
4034
2637
  state.workerProc = null;
4035
2638
  updateWidgets();
4036
2639
 
4037
- return { exitCode: timedOut ? 1 : effectiveExitCode, elapsed: Date.now() - startTime, timedOut };
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 parallel orchestrator to launch
4271
- // workers in TMUX sessions without send-keys timing issues.
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)) {