taskplane 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,13 +24,18 @@ import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
24
24
  import { spawn, spawnSync } from "child_process";
25
25
  import {
26
26
  readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
27
- statSync, openSync, readSync, closeSync,
27
+ readdirSync, statSync, openSync, readSync, closeSync,
28
28
  } from "fs";
29
29
  import { tmpdir, userInfo } from "os";
30
30
  import { join, dirname, basename, resolve } from "path";
31
31
  import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
32
32
  import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
33
33
  import type { PointerResolution } from "./taskplane/types.ts";
34
+ import {
35
+ REVIEWER_SHUTDOWN_GRACE_MS,
36
+ REVIEWER_SIGNAL_PREFIX,
37
+ REVIEWER_SHUTDOWN_SIGNAL,
38
+ } from "./taskplane/types.ts";
34
39
  import { classifyExit } from "./taskplane/diagnostics.ts";
35
40
  import type { TaskExitDiagnostic, ExitSummary } from "./taskplane/diagnostics.ts";
36
41
  import {
@@ -143,6 +148,12 @@ interface TaskState {
143
148
  reviewerProc: any;
144
149
  reviewerTimer: any;
145
150
  reviewCounter: number;
151
+ /** TP-057: Persistent reviewer session — tracks the long-lived reviewer tmux session. */
152
+ persistentReviewerSession: string | null;
153
+ /** TP-057: Kill function for the persistent reviewer (to stop sidecar polling). */
154
+ persistentReviewerKill: (() => void) | null;
155
+ /** TP-057: Signal counter for the persistent reviewer (monotonically increasing). */
156
+ persistentReviewerSignalNum: number;
146
157
  totalIterations: number;
147
158
  stepStatuses: Map<number, StepInfo>;
148
159
  }
@@ -160,7 +171,9 @@ function freshState(): TaskState {
160
171
  reviewerElapsed: 0, reviewerLastTool: "", reviewerToolCount: 0,
161
172
  reviewerInputTokens: 0, reviewerOutputTokens: 0, reviewerCacheReadTokens: 0, reviewerCacheWriteTokens: 0,
162
173
  reviewerCostUsd: 0, reviewerContextPct: 0, reviewerProc: null, reviewerTimer: null,
163
- reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
174
+ reviewCounter: 0,
175
+ persistentReviewerSession: null, persistentReviewerKill: null, persistentReviewerSignalNum: 0,
176
+ totalIterations: 0, stepStatuses: new Map(),
164
177
  };
165
178
  }
166
179
 
@@ -633,6 +646,43 @@ function resolveRpcWrapperPath(): string {
633
646
  );
634
647
  }
635
648
 
649
+ /**
650
+ * Resolve the path to reviewer-extension.ts from the installed taskplane package.
651
+ * Mirrors resolveRpcWrapperPath() resolution strategy.
652
+ *
653
+ * @returns Absolute path to reviewer-extension.ts, or null if not found
654
+ * @since TP-057
655
+ */
656
+ function resolveReviewerExtensionPath(): string | null {
657
+ const extRelPath = join("extensions", "reviewer-extension.ts");
658
+
659
+ // 1. Package root
660
+ const root = findPackageRoot();
661
+ if (root) {
662
+ const p = join(root, extRelPath);
663
+ if (existsSync(p)) return p;
664
+ }
665
+
666
+ // 2. Extension-file-relative (dev scenario: task-runner.ts is sibling)
667
+ try {
668
+ const args = process.argv;
669
+ for (let i = 0; i < args.length - 1; i++) {
670
+ if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
671
+ const extPath = resolve(args[i + 1]);
672
+ const derivedRoot = resolve(extPath, "..", "..");
673
+ const p = join(derivedRoot, extRelPath);
674
+ if (existsSync(p)) return p;
675
+ }
676
+ }
677
+ } catch { /* ignore */ }
678
+
679
+ // 3. Development fallback: cwd
680
+ const cwdDev = join(process.cwd(), extRelPath);
681
+ if (existsSync(cwdDev)) return cwdDev;
682
+
683
+ return null;
684
+ }
685
+
636
686
  /**
637
687
  * Load an agent definition with prompt inheritance.
638
688
  *
@@ -1567,6 +1617,9 @@ function spawnAgentTmux(opts: {
1567
1617
  /** Optional extension paths to load in the spawned pi session (via rpc-wrapper --extensions).
1568
1618
  * When provided, --no-extensions is NOT passed to pi (would conflict). */
1569
1619
  extensions?: string[];
1620
+ /** Optional extra environment variables to set in the spawned tmux session.
1621
+ * Injected as `KEY=VALUE` prefixes in the shell command. @since TP-057 */
1622
+ env?: Record<string, string>;
1570
1623
  /** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
1571
1624
  * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1572
1625
  * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
@@ -1710,7 +1763,11 @@ function spawnAgentTmux(opts: {
1710
1763
  // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
1711
1764
  // force xterm-256color.
1712
1765
  const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
1713
- const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${wrapperCommand}`;
1766
+ // Build extra env var prefix (TP-057: e.g., REVIEWER_SIGNAL_DIR for persistent reviewer)
1767
+ const extraEnv = opts.env
1768
+ ? Object.entries(opts.env).map(([k, v]) => `${k}=${quoteArg(v)}`).join(" ") + " "
1769
+ : "";
1770
+ const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && ${extraEnv}TERM=xterm-256color ${wrapperCommand}`;
1714
1771
  const createResult = spawnSync("tmux", [
1715
1772
  "new-session", "-d",
1716
1773
  "-s", opts.sessionName,
@@ -2017,6 +2074,80 @@ export default function (pi: ExtensionAPI) {
2017
2074
  state.reviewerTimer = null;
2018
2075
  }
2019
2076
 
2077
+ /**
2078
+ * TP-057: Remove stale signal and shutdown files from .reviews/ directory.
2079
+ * Called before spawning a new persistent reviewer to prevent the reviewer
2080
+ * from consuming old signals or immediately seeing a stale shutdown marker.
2081
+ */
2082
+ function cleanStaleReviewerSignals(reviewsDir: string): void {
2083
+ try {
2084
+ const files = readdirSync(reviewsDir);
2085
+ for (const f of files) {
2086
+ if (f.startsWith(REVIEWER_SIGNAL_PREFIX) || f === REVIEWER_SHUTDOWN_SIGNAL) {
2087
+ try { unlinkSync(join(reviewsDir, f)); } catch {}
2088
+ }
2089
+ }
2090
+ } catch {
2091
+ // Directory may not exist yet — not an error
2092
+ }
2093
+ }
2094
+
2095
+ /**
2096
+ * TP-057: Shut down the persistent reviewer session cleanly.
2097
+ * Writes shutdown signal, waits for clean exit within grace period,
2098
+ * then force-kills the session if still alive.
2099
+ *
2100
+ * Called from all executeTask exit paths (success, pause, error, stall)
2101
+ * to prevent orphan tmux sessions.
2102
+ *
2103
+ * @param reason - Why the reviewer is being shut down (for logging)
2104
+ */
2105
+ async function shutdownPersistentReviewer(reason: string): Promise<void> {
2106
+ if (!state.persistentReviewerSession) return;
2107
+
2108
+ const sessionName = state.persistentReviewerSession;
2109
+ console.error(`[task-runner] persistent reviewer: shutting down (${reason})`);
2110
+
2111
+ // Write shutdown signal so the reviewer exits cleanly
2112
+ if (state.task) {
2113
+ const reviewsDir = join(state.task.taskFolder, ".reviews");
2114
+ const shutdownPath = join(reviewsDir, REVIEWER_SHUTDOWN_SIGNAL);
2115
+ try {
2116
+ if (!existsSync(reviewsDir)) mkdirSync(reviewsDir, { recursive: true });
2117
+ writeFileSync(shutdownPath, "shutdown");
2118
+ } catch (err: any) {
2119
+ console.error(`[task-runner] persistent reviewer: failed to write shutdown signal: ${err?.message}`);
2120
+ }
2121
+ }
2122
+
2123
+ // Poll for session death within grace period
2124
+ const graceStart = Date.now();
2125
+ while (Date.now() - graceStart < REVIEWER_SHUTDOWN_GRACE_MS) {
2126
+ const alive = spawnSync("tmux", ["has-session", "-t", sessionName]);
2127
+ if (alive.status !== 0) break;
2128
+ await new Promise(r => setTimeout(r, 1000));
2129
+ }
2130
+
2131
+ // Force kill if still alive after grace period
2132
+ const finalCheck = spawnSync("tmux", ["has-session", "-t", sessionName]);
2133
+ if (finalCheck.status === 0) {
2134
+ console.error(`[task-runner] persistent reviewer: killing session after grace period`);
2135
+ spawnSync("tmux", ["kill-session", "-t", sessionName]);
2136
+ }
2137
+
2138
+ // Reset state
2139
+ state.persistentReviewerSession = null;
2140
+ state.persistentReviewerKill = null;
2141
+ state.persistentReviewerSignalNum = 0;
2142
+ clearReviewerState();
2143
+ writeLaneState(state);
2144
+
2145
+ if (state.task) {
2146
+ const statusPath = join(state.task.taskFolder, "STATUS.md");
2147
+ logExecution(statusPath, "Persistent reviewer", `Shutdown complete (${reason})`);
2148
+ }
2149
+ }
2150
+
2020
2151
  if (isOrchestratedMode()) {
2021
2152
  pi.registerTool({
2022
2153
  name: "review_step",
@@ -2080,9 +2211,6 @@ export default function (pi: ExtensionAPI) {
2080
2211
  const outputPath = join(reviewsDir, `R${num}-${reviewType}-step${stepNum}.md`);
2081
2212
 
2082
2213
  // Resolve step baseline commit for code reviews.
2083
- // The worker should pass the pre-step HEAD SHA as `baseline` so the
2084
- // reviewer sees only this step's changes (not cumulative diff).
2085
- // Falls back to undefined (full diff) if baseline is not provided.
2086
2214
  const stepBaselineCommit: string | undefined =
2087
2215
  reviewType === "code" ? (baseline || undefined) : undefined;
2088
2216
 
@@ -2118,12 +2246,15 @@ export default function (pi: ExtensionAPI) {
2118
2246
  state.reviewerElapsed = 0;
2119
2247
  state.reviewerLastTool = "";
2120
2248
  state.reviewerToolCount = 0;
2121
- state.reviewerInputTokens = 0;
2122
- state.reviewerOutputTokens = 0;
2123
- state.reviewerCacheReadTokens = 0;
2124
- state.reviewerCacheWriteTokens = 0;
2125
- state.reviewerCostUsd = 0;
2126
- state.reviewerContextPct = 0;
2249
+ // Don't reset cumulative token counts for persistent reviewer — they accumulate
2250
+ if (!state.persistentReviewerSession) {
2251
+ state.reviewerInputTokens = 0;
2252
+ state.reviewerOutputTokens = 0;
2253
+ state.reviewerCacheReadTokens = 0;
2254
+ state.reviewerCacheWriteTokens = 0;
2255
+ state.reviewerCostUsd = 0;
2256
+ state.reviewerContextPct = 0;
2257
+ }
2127
2258
  updateWidgets();
2128
2259
 
2129
2260
  const startTime = Date.now();
@@ -2132,23 +2263,54 @@ export default function (pi: ExtensionAPI) {
2132
2263
  updateWidgets();
2133
2264
  }, 1000);
2134
2265
 
2135
- // Read the request file content as the prompt
2136
- const promptContent = readFileSync(requestPath, "utf-8");
2137
-
2138
2266
  // Resolve context window for reviewer context% calculation
2139
2267
  const { contextWindow } = resolveContextWindow(config, ctx);
2140
2268
 
2141
- try {
2142
- // Spawn reviewer via spawnAgentTmux with onTelemetry for live metrics
2269
+ // ── TP-057: Persistent Reviewer Session ─────────────────
2270
+ // On the first review_step call, spawn a persistent reviewer
2271
+ // that stays alive via the wait_for_review tool. On subsequent
2272
+ // calls, reuse the existing session by writing signal files.
2273
+ // Fall back to fresh-spawn if the persistent session dies.
2274
+
2275
+ /**
2276
+ * Check if the persistent reviewer tmux session is still alive.
2277
+ */
2278
+ function isPersistentReviewerAlive(): boolean {
2279
+ if (!state.persistentReviewerSession) return false;
2280
+ const result = spawnSync("tmux", ["has-session", "-t", state.persistentReviewerSession]);
2281
+ return result.status === 0;
2282
+ }
2283
+
2284
+ /**
2285
+ * Spawn a persistent reviewer session with the reviewer-extension
2286
+ * loaded, so the reviewer can use wait_for_review to receive requests.
2287
+ */
2288
+ function spawnPersistentReviewer(): void {
2289
+ const reviewerExtPath = resolveReviewerExtensionPath();
2290
+ if (!reviewerExtPath) {
2291
+ throw new Error("Cannot find reviewer-extension.ts. Ensure taskplane is installed correctly.");
2292
+ }
2293
+
2294
+ // Clean stale signal/shutdown files before spawning
2295
+ cleanStaleReviewerSignals(reviewsDir);
2296
+
2297
+ // Initial prompt tells the reviewer to call wait_for_review
2298
+ const initialPrompt =
2299
+ "You are a persistent reviewer for this task. " +
2300
+ "Call the `wait_for_review` tool now to receive your first review request. " +
2301
+ "After writing each review, call `wait_for_review` again for the next one.";
2302
+
2143
2303
  const spawned = spawnAgentTmux({
2144
2304
  sessionName,
2145
2305
  cwd: ctx.cwd,
2146
2306
  systemPrompt,
2147
- prompt: promptContent,
2307
+ prompt: initialPrompt,
2148
2308
  model: reviewerModel,
2149
2309
  tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2150
2310
  thinking: config.reviewer.thinking || "on",
2151
2311
  taskId: task.taskId,
2312
+ extensions: [reviewerExtPath],
2313
+ env: { REVIEWER_SIGNAL_DIR: reviewsDir },
2152
2314
  onTelemetry: (delta) => {
2153
2315
  // Accumulate tokens and cost
2154
2316
  state.reviewerInputTokens += delta.inputTokens;
@@ -2173,27 +2335,96 @@ export default function (pi: ExtensionAPI) {
2173
2335
  },
2174
2336
  });
2175
2337
 
2338
+ // Store persistent session state
2339
+ state.persistentReviewerSession = sessionName;
2340
+ state.persistentReviewerKill = spawned.kill;
2341
+ state.persistentReviewerSignalNum = 0;
2176
2342
  state.reviewerProc = { kill: spawned.kill };
2177
2343
 
2178
- // Await reviewer completion
2179
- const result = await spawned.promise;
2344
+ // Don't await spawned.promise — the session stays alive across reviews.
2345
+ // Handle session death via isPersistentReviewerAlive() checks.
2346
+ spawned.promise.then(() => {
2347
+ // Session ended (reviewer exited or was killed)
2348
+ console.error(`[task-runner] persistent reviewer session '${sessionName}' ended`);
2349
+ }).catch((err: any) => {
2350
+ console.error(`[task-runner] persistent reviewer session error: ${err?.message || err}`);
2351
+ });
2352
+ }
2353
+
2354
+ /**
2355
+ * Write signal file to notify the persistent reviewer of a new request.
2356
+ * Returns the signal number used.
2357
+ */
2358
+ function signalPersistentReviewer(): number {
2359
+ state.persistentReviewerSignalNum++;
2360
+ const sigNum = String(state.persistentReviewerSignalNum).padStart(3, "0");
2361
+ const signalPath = join(reviewsDir, `${REVIEWER_SIGNAL_PREFIX}${sigNum}`);
2362
+ // Write the request filename so the reviewer can find it
2363
+ // (signal num and review counter may diverge after respawns)
2364
+ writeFileSync(signalPath, `request-R${num}.md`);
2365
+ return state.persistentReviewerSignalNum;
2366
+ }
2367
+
2368
+ /**
2369
+ * Poll for the verdict file to appear (written by the reviewer).
2370
+ * Same pattern as the original review_step handler.
2371
+ */
2372
+ async function pollForVerdict(): Promise<string> {
2373
+ const verdictTimeout = 30 * 60 * 1000; // 30 minutes
2374
+ const pollStart = Date.now();
2375
+ while (Date.now() - pollStart < verdictTimeout) {
2376
+ if (existsSync(outputPath)) {
2377
+ return readFileSync(outputPath, "utf-8");
2378
+ }
2379
+ // Also check if persistent reviewer died while we're waiting
2380
+ if (state.persistentReviewerSession && !isPersistentReviewerAlive()) {
2381
+ throw new Error("Persistent reviewer session died while waiting for verdict");
2382
+ }
2383
+ await new Promise(r => setTimeout(r, 2000));
2384
+ }
2385
+ throw new Error("Reviewer verdict timeout — no output file after 30 minutes");
2386
+ }
2387
+
2388
+ try {
2389
+ // ── Persistent reviewer: spawn or reuse ─────────────
2390
+ const needsSpawn = !state.persistentReviewerSession || !isPersistentReviewerAlive();
2391
+
2392
+ if (needsSpawn && state.persistentReviewerSession) {
2393
+ // Session was previously active but died — log fallback
2394
+ console.error(`[task-runner] persistent reviewer session dead — respawning`);
2395
+ logExecution(statusPath, `Reviewer R${num}`,
2396
+ `persistent reviewer dead — respawning for ${reviewType} review`);
2397
+ state.persistentReviewerSession = null;
2398
+ state.persistentReviewerKill = null;
2399
+ state.persistentReviewerSignalNum = 0;
2400
+ }
2401
+
2402
+ if (needsSpawn) {
2403
+ spawnPersistentReviewer();
2404
+ // Give the reviewer a moment to start and call wait_for_review
2405
+ await new Promise(r => setTimeout(r, 5000));
2406
+ }
2180
2407
 
2181
- clearInterval(state.reviewerTimer);
2408
+ // Signal the reviewer with the new request
2409
+ signalPersistentReviewer();
2410
+
2411
+ // Poll for the verdict file
2412
+ const reviewContent = await pollForVerdict();
2413
+
2414
+ // Stop the per-review timer
2415
+ if (state.reviewerTimer) clearInterval(state.reviewerTimer);
2182
2416
  state.reviewerElapsed = Date.now() - startTime;
2183
- state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2184
- state.reviewerProc = null;
2417
+ state.reviewerStatus = "done";
2185
2418
  writeLaneState(state);
2186
2419
  updateWidgets();
2187
2420
 
2188
2421
  // Extract verdict from review output
2189
2422
  let verdict = "UNKNOWN";
2190
2423
  let reviseDetails = "";
2191
- if (existsSync(outputPath)) {
2192
- const review = readFileSync(outputPath, "utf-8");
2193
- verdict = extractVerdict(review);
2424
+ if (reviewContent) {
2425
+ verdict = extractVerdict(reviewContent);
2194
2426
  if (verdict === "REVISE") {
2195
- // Extract a brief summary from the review for the worker
2196
- const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2427
+ const summaryMatch = reviewContent.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2197
2428
  reviseDetails = summaryMatch
2198
2429
  ? summaryMatch[1].trim().slice(0, 500)
2199
2430
  : "See review file for details.";
@@ -2211,8 +2442,12 @@ export default function (pi: ExtensionAPI) {
2211
2442
  `${reviewType} Step ${stepNum}: ${verdict}`);
2212
2443
  updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
2213
2444
 
2214
- // Clear reviewer state for dashboard
2215
- clearReviewerState();
2445
+ // Set reviewer to idle (NOT clear — persistent session stays alive)
2446
+ state.reviewerStatus = "idle";
2447
+ state.reviewerType = "";
2448
+ state.reviewerStep = 0;
2449
+ if (state.reviewerTimer) clearInterval(state.reviewerTimer);
2450
+ state.reviewerTimer = null;
2216
2451
  writeLaneState(state);
2217
2452
  updateWidgets();
2218
2453
 
@@ -2233,20 +2468,116 @@ export default function (pi: ExtensionAPI) {
2233
2468
  details: undefined,
2234
2469
  };
2235
2470
  } catch (err: any) {
2236
- // Reviewer crashed
2237
- clearInterval(state.reviewerTimer);
2238
- clearReviewerState();
2239
- state.reviewerStatus = "error";
2240
- writeLaneState(state);
2241
- updateWidgets();
2242
-
2471
+ // ── Fallback: kill persistent session, try fresh spawn ──
2472
+ console.error(`[task-runner] persistent reviewer error: ${err?.message || err}`);
2243
2473
  logExecution(statusPath, `Reviewer R${num}`,
2244
- `${reviewType} reviewreviewer crashed: ${err?.message || err}`);
2474
+ `persistent reviewer failed falling back to fresh spawn: ${err?.message || err}`);
2245
2475
 
2246
- return {
2247
- content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer error: ${err?.message || err}` }],
2248
- details: undefined,
2249
- };
2476
+ // Kill the dead/broken persistent session
2477
+ if (state.persistentReviewerKill) {
2478
+ try { state.persistentReviewerKill(); } catch {}
2479
+ }
2480
+ state.persistentReviewerSession = null;
2481
+ state.persistentReviewerKill = null;
2482
+ state.persistentReviewerSignalNum = 0;
2483
+
2484
+ // ── Fresh spawn fallback (original behavior) ────────
2485
+ try {
2486
+ const promptContent = readFileSync(requestPath, "utf-8");
2487
+ const spawned = spawnAgentTmux({
2488
+ sessionName,
2489
+ cwd: ctx.cwd,
2490
+ systemPrompt,
2491
+ prompt: promptContent,
2492
+ model: reviewerModel,
2493
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2494
+ thinking: config.reviewer.thinking || "on",
2495
+ taskId: task.taskId,
2496
+ onTelemetry: (delta) => {
2497
+ state.reviewerInputTokens += delta.inputTokens;
2498
+ state.reviewerOutputTokens += delta.outputTokens;
2499
+ state.reviewerCacheReadTokens += delta.cacheReadTokens;
2500
+ state.reviewerCacheWriteTokens += delta.cacheWriteTokens;
2501
+ state.reviewerCostUsd += delta.cost;
2502
+ state.reviewerToolCount += delta.toolCalls;
2503
+ if (delta.lastTool) state.reviewerLastTool = delta.lastTool;
2504
+ if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2505
+ state.reviewerContextPct = (delta.latestTotalTokens / contextWindow) * 100;
2506
+ }
2507
+ writeLaneState(state);
2508
+ updateWidgets();
2509
+ },
2510
+ });
2511
+
2512
+ state.reviewerProc = { kill: spawned.kill };
2513
+ const result = await spawned.promise;
2514
+
2515
+ clearInterval(state.reviewerTimer);
2516
+ state.reviewerElapsed = Date.now() - startTime;
2517
+ state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2518
+ state.reviewerProc = null;
2519
+ writeLaneState(state);
2520
+ updateWidgets();
2521
+
2522
+ // Extract verdict from fallback review
2523
+ let verdict = "UNKNOWN";
2524
+ let reviseDetails = "";
2525
+ if (existsSync(outputPath)) {
2526
+ const review = readFileSync(outputPath, "utf-8");
2527
+ verdict = extractVerdict(review);
2528
+ if (verdict === "REVISE") {
2529
+ const summaryMatch = review.match(/###?\s*Summary[:\s]*([\s\S]*?)(?=###|$)/i);
2530
+ reviseDetails = summaryMatch
2531
+ ? summaryMatch[1].trim().slice(0, 500)
2532
+ : "See review file for details.";
2533
+ }
2534
+ } else {
2535
+ verdict = "UNAVAILABLE";
2536
+ logExecution(statusPath, `Reviewer R${num}`,
2537
+ `${reviewType} review — fallback reviewer did not produce output`);
2538
+ }
2539
+
2540
+ logReview(statusPath, `R${num}`, reviewType, stepNum, verdict,
2541
+ `.reviews/R${num}-${reviewType}-step${stepNum}.md`);
2542
+ logExecution(statusPath, `Review R${num}`,
2543
+ `${reviewType} Step ${stepNum}: ${verdict} (fallback)`);
2544
+ updateStatusField(statusPath, "Review Counter", `${state.reviewCounter}`);
2545
+
2546
+ clearReviewerState();
2547
+ writeLaneState(state);
2548
+ updateWidgets();
2549
+
2550
+ let resultText: string;
2551
+ if (verdict === "APPROVE") {
2552
+ resultText = "APPROVE";
2553
+ } else if (verdict === "REVISE") {
2554
+ resultText = `REVISE: ${reviseDetails}\n\nFull review: .reviews/R${num}-${reviewType}-step${stepNum}.md`;
2555
+ } else if (verdict === "RETHINK") {
2556
+ resultText = `RETHINK — reconsider your approach. See .reviews/R${num}-${reviewType}-step${stepNum}.md`;
2557
+ } else {
2558
+ resultText = `UNAVAILABLE — reviewer did not produce a usable verdict.`;
2559
+ }
2560
+
2561
+ return {
2562
+ content: [{ type: "text" as const, text: resultText }],
2563
+ details: undefined,
2564
+ };
2565
+ } catch (fallbackErr: any) {
2566
+ // Both persistent and fallback failed
2567
+ clearInterval(state.reviewerTimer);
2568
+ clearReviewerState();
2569
+ state.reviewerStatus = "error";
2570
+ writeLaneState(state);
2571
+ updateWidgets();
2572
+
2573
+ logExecution(statusPath, `Reviewer R${num}`,
2574
+ `${reviewType} review — both persistent and fallback failed: ${fallbackErr?.message || fallbackErr}`);
2575
+
2576
+ return {
2577
+ content: [{ type: "text" as const, text: `UNAVAILABLE — reviewer error: ${fallbackErr?.message || fallbackErr}` }],
2578
+ details: undefined,
2579
+ };
2580
+ }
2250
2581
  }
2251
2582
  },
2252
2583
  });
@@ -2298,6 +2629,7 @@ export default function (pi: ExtensionAPI) {
2298
2629
  if (state.phase === "paused") {
2299
2630
  logExecution(statusPath, "Paused", `User paused at iteration ${iter + 1}`);
2300
2631
  ctx.ui.notify(`Task paused at iteration ${iter + 1}`, "info");
2632
+ await shutdownPersistentReviewer("task paused");
2301
2633
  return;
2302
2634
  }
2303
2635
 
@@ -2329,7 +2661,10 @@ export default function (pi: ExtensionAPI) {
2329
2661
 
2330
2662
  await runWorker(remainingSteps, ctx);
2331
2663
 
2332
- if (state.phase === "error") return;
2664
+ if (state.phase === "error") {
2665
+ await shutdownPersistentReviewer("worker error");
2666
+ return;
2667
+ }
2333
2668
 
2334
2669
  // ── Post-worker: determine which steps were newly completed ──
2335
2670
  const afterStatus = parseStatusMd(readFileSync(statusPath, "utf-8"));
@@ -2345,6 +2680,7 @@ export default function (pi: ExtensionAPI) {
2345
2680
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
2346
2681
  ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
2347
2682
  state.phase = "error";
2683
+ await shutdownPersistentReviewer("task stalled");
2348
2684
  return;
2349
2685
  }
2350
2686
  } else {
@@ -2409,10 +2745,14 @@ export default function (pi: ExtensionAPI) {
2409
2745
  logExecution(statusPath, "Task incomplete", `Max iterations (${config.context.max_worker_iterations}) reached with incomplete steps: ${incomplete}`);
2410
2746
  ctx.ui.notify(`⚠️ Task incomplete — max iterations reached. Incomplete: ${incomplete}`, "error");
2411
2747
  state.phase = "error";
2748
+ await shutdownPersistentReviewer("max iterations reached");
2412
2749
  return;
2413
2750
  }
2414
2751
  }
2415
2752
 
2753
+ // ── TP-057: Shutdown persistent reviewer ────────────────────────
2754
+ await shutdownPersistentReviewer("task complete");
2755
+
2416
2756
  // All steps done — run quality gate if enabled, then create .DONE
2417
2757
  if (config.quality_gate.enabled) {
2418
2758
  // ── Quality Gate Enabled ─────────────────────────────────
@@ -3545,6 +3885,11 @@ export default function (pi: ExtensionAPI) {
3545
3885
  // Kill any running subprocesses
3546
3886
  if (state.workerProc) try { state.workerProc.kill(); } catch {}
3547
3887
  if (state.reviewerProc) try { state.reviewerProc.kill(); } catch {}
3888
+ // TP-057: Kill persistent reviewer session if alive
3889
+ if (state.persistentReviewerKill) try { state.persistentReviewerKill(); } catch {}
3890
+ state.persistentReviewerSession = null;
3891
+ state.persistentReviewerKill = null;
3892
+ state.persistentReviewerSignalNum = 0;
3548
3893
  if (state.workerTimer) clearInterval(state.workerTimer);
3549
3894
  if (state.reviewerTimer) clearInterval(state.reviewerTimer);
3550
3895
 
@@ -11,7 +11,7 @@ import type { MonitorUpdateCallback } from "./execution.ts";
11
11
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
12
12
  // from the diagnostic-reports pipeline (populated by assembleDiagnosticInput).
13
13
  import { getCurrentBranch, runGit } from "./git.ts";
14
- import { mergeWaveByRepo } from "./merge.ts";
14
+ import { mergeWaveByRepo, MergeHealthMonitor } from "./merge.ts";
15
15
  import { applyMergeRetryLoop, computeCleanupGatePolicy, computeMergeFailurePolicy, extractFailedRepoId, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
16
16
  import type { CleanupGateRepoFailure } from "./messages.ts";
17
17
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
@@ -1358,19 +1358,41 @@ export async function executeOrchBatch(
1358
1358
  laneCount: mergeableLaneCount,
1359
1359
  }, onEngineEvent);
1360
1360
 
1361
- mergeResult = await mergeWaveByRepo(
1362
- waveResult.allocatedLanes,
1363
- waveResult,
1364
- waveIdx + 1,
1365
- orchConfig,
1366
- repoRoot,
1367
- batchState.batchId,
1368
- batchState.orchBranch,
1369
- workspaceConfig,
1361
+ // TP-056: Start merge health monitor during merge phase
1362
+ const mergeHealthMonitor = new MergeHealthMonitor({
1370
1363
  stateRoot,
1371
- agentRoot,
1372
- runnerConfig.testing_commands,
1373
- );
1364
+ batchId: batchState.batchId,
1365
+ waveIndex: waveIdx,
1366
+ phase: batchState.phase,
1367
+ onDeadSession: (sessionName, laneNumber) => {
1368
+ execLog("batch", batchState.batchId, `merge health monitor detected dead session`, {
1369
+ sessionName,
1370
+ laneNumber,
1371
+ waveIndex: waveIdx,
1372
+ });
1373
+ },
1374
+ });
1375
+ mergeHealthMonitor.start();
1376
+
1377
+ try {
1378
+ mergeResult = await mergeWaveByRepo(
1379
+ waveResult.allocatedLanes,
1380
+ waveResult,
1381
+ waveIdx + 1,
1382
+ orchConfig,
1383
+ repoRoot,
1384
+ batchState.batchId,
1385
+ batchState.orchBranch,
1386
+ workspaceConfig,
1387
+ stateRoot,
1388
+ agentRoot,
1389
+ runnerConfig.testing_commands,
1390
+ mergeHealthMonitor,
1391
+ );
1392
+ } finally {
1393
+ // TP-056: Always stop the health monitor when merge phase ends
1394
+ mergeHealthMonitor.stop();
1395
+ }
1374
1396
  allMergeResults.push(mergeResult);
1375
1397
  batchState.mergeResults.push(mergeResult);
1376
1398