taskplane 0.5.12 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,12 +23,28 @@ import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
23
23
  import { spawn, spawnSync } from "child_process";
24
24
  import {
25
25
  readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
26
+ statSync, openSync, readSync, closeSync,
26
27
  } from "fs";
27
- import { tmpdir } from "os";
28
+ import { tmpdir, userInfo } from "os";
28
29
  import { join, dirname, basename, resolve } from "path";
29
30
  import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
30
31
  import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
31
32
  import type { PointerResolution } from "./taskplane/types.ts";
33
+ import { classifyExit } from "./taskplane/diagnostics.ts";
34
+ import type { TaskExitDiagnostic, ExitSummary } from "./taskplane/diagnostics.ts";
35
+ import {
36
+ generateQualityGatePrompt,
37
+ generateFeedbackMd,
38
+ buildFixAgentPrompt,
39
+ readAndEvaluateVerdict,
40
+ VERDICT_FILENAME,
41
+ FEEDBACK_FILENAME,
42
+ applyStatusReconciliation,
43
+ type QualityGateContext,
44
+ type QualityGateResult,
45
+ type ReviewVerdict,
46
+ type VerdictEvaluation,
47
+ } from "./taskplane/quality-gate.ts";
32
48
 
33
49
 
34
50
  // ── Types ────────────────────────────────────────────────────────────
@@ -56,6 +72,13 @@ interface TaskConfig {
56
72
  no_progress_limit: number;
57
73
  max_worker_minutes?: number;
58
74
  };
75
+ quality_gate: {
76
+ enabled: boolean;
77
+ review_model: string;
78
+ max_review_cycles: number;
79
+ max_fix_cycles: number;
80
+ pass_threshold: "no_critical" | "no_important" | "all_clear";
81
+ };
59
82
  }
60
83
 
61
84
  interface StepInfo {
@@ -98,6 +121,11 @@ interface TaskState {
98
121
  workerCostUsd: number;
99
122
  workerProc: any;
100
123
  workerTimer: any;
124
+ workerRetryActive: boolean;
125
+ workerRetryCount: number;
126
+ workerLastRetryError: string;
127
+ /** Structured exit diagnostic from the most recent tmux worker iteration (null in subprocess mode or before first completion). */
128
+ workerExitDiagnostic: TaskExitDiagnostic | null;
101
129
  reviewerStatus: "idle" | "running" | "done" | "error";
102
130
  reviewerType: string;
103
131
  reviewerElapsed: number;
@@ -116,6 +144,8 @@ function freshState(): TaskState {
116
144
  workerContextPct: 0, workerLastTool: "", workerToolCount: 0,
117
145
  workerInputTokens: 0, workerOutputTokens: 0, workerCacheReadTokens: 0, workerCacheWriteTokens: 0, workerCostUsd: 0,
118
146
  workerProc: null, workerTimer: null,
147
+ workerRetryActive: false, workerRetryCount: 0, workerLastRetryError: "",
148
+ workerExitDiagnostic: null,
119
149
  reviewerStatus: "idle", reviewerType: "", reviewerElapsed: 0,
120
150
  reviewerLastTool: "", reviewerProc: null, reviewerTimer: null,
121
151
  reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
@@ -137,6 +167,13 @@ const DEFAULT_CONFIG: TaskConfig = {
137
167
  worker_context_window: 200000, warn_percent: 70, kill_percent: 85,
138
168
  max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
139
169
  },
170
+ quality_gate: {
171
+ enabled: false,
172
+ review_model: "",
173
+ max_review_cycles: 2,
174
+ max_fix_cycles: 1,
175
+ pass_threshold: "no_critical",
176
+ },
140
177
  };
141
178
 
142
179
  // ── Pointer Resolution (Workspace Mode) ──────────────────────────────
@@ -326,6 +363,10 @@ function writeLaneState(state: TaskState): void {
326
363
  workerCacheReadTokens: state.workerCacheReadTokens,
327
364
  workerCacheWriteTokens: state.workerCacheWriteTokens,
328
365
  workerCostUsd: state.workerCostUsd,
366
+ workerRetryActive: state.workerRetryActive,
367
+ workerRetryCount: state.workerRetryCount,
368
+ workerLastRetryError: state.workerLastRetryError,
369
+ workerExitDiagnostic: state.workerExitDiagnostic || undefined,
329
370
  reviewerStatus: state.reviewerStatus || "idle",
330
371
  timestamp: Date.now(),
331
372
  };
@@ -446,6 +487,88 @@ function resolveBaseAgentPath(name: string): string {
446
487
  return join(root, "templates", "agents", `${name}.md`);
447
488
  }
448
489
 
490
+ /**
491
+ * Resolve the path to rpc-wrapper.mjs from the installed taskplane package.
492
+ *
493
+ * Resolution strategy (first match wins):
494
+ * 1. Package root via findPackageRoot() (covers global npm, workspace, pi peer)
495
+ * 2. Project-local node_modules/taskplane (for non-workspace local installs)
496
+ * 3. Global npm paths (explicit fallback for layouts findPackageRoot may miss)
497
+ * 4. Extension-file-relative: derive package root from the `-e` arg that loaded
498
+ * this extension (handles dev scenarios where cwd differs from checkout)
499
+ * 5. Development fallback: cwd/bin/rpc-wrapper.mjs (running from taskplane repo)
500
+ *
501
+ * @returns Absolute path to rpc-wrapper.mjs
502
+ * @throws Error if rpc-wrapper.mjs cannot be found
503
+ */
504
+ function resolveRpcWrapperPath(): string {
505
+ const wrapperRelPath = join("bin", "rpc-wrapper.mjs");
506
+ const searched: string[] = [];
507
+
508
+ const tryPath = (dir: string): string | null => {
509
+ const p = join(dir, wrapperRelPath);
510
+ searched.push(p);
511
+ return existsSync(p) ? p : null;
512
+ };
513
+
514
+ // 1. Package root (installed npm package — covers global, workspace, peer)
515
+ const root = findPackageRoot();
516
+ if (root) {
517
+ const found = tryPath(root);
518
+ if (found) return found;
519
+ }
520
+
521
+ // 2. Project-local node_modules (non-workspace local installs)
522
+ const cwdLocal = join(process.cwd(), "node_modules", "taskplane");
523
+ if (existsSync(cwdLocal)) {
524
+ const found = tryPath(cwdLocal);
525
+ if (found) return found;
526
+ }
527
+
528
+ // 3. Global npm paths (explicit check for layouts findPackageRoot may miss)
529
+ const home = process.env.HOME || process.env.USERPROFILE || "";
530
+ const globalCandidates: string[] = [];
531
+ if (process.env.APPDATA) {
532
+ globalCandidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane"));
533
+ }
534
+ if (home) {
535
+ globalCandidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
536
+ globalCandidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
537
+ }
538
+ globalCandidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
539
+ for (const dir of globalCandidates) {
540
+ const found = tryPath(dir);
541
+ if (found) return found;
542
+ }
543
+
544
+ // 4. Extension-file-relative: derive package root from the -e argument
545
+ // that loaded this file. This covers dev scenarios where the extension
546
+ // is loaded from a local checkout but cwd is a different directory
547
+ // (e.g., a worktree or integration test working directory).
548
+ // This file lives at <package-root>/extensions/task-runner.ts, so walk up two levels.
549
+ try {
550
+ const args = process.argv;
551
+ for (let i = 0; i < args.length - 1; i++) {
552
+ if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
553
+ const extPath = resolve(args[i + 1]);
554
+ const derivedRoot = resolve(extPath, "..", "..");
555
+ const found = tryPath(derivedRoot);
556
+ if (found) return found;
557
+ }
558
+ }
559
+ } catch { /* ignore argv parsing errors */ }
560
+
561
+ // 5. Development fallback: running from the taskplane repo directly
562
+ const cwdDev = process.cwd();
563
+ const devFound = tryPath(cwdDev);
564
+ if (devFound) return devFound;
565
+
566
+ throw new Error(
567
+ "Cannot find rpc-wrapper.mjs. Ensure taskplane is installed correctly. " +
568
+ `Searched: ${searched.join(", ")}`
569
+ );
570
+ }
571
+
449
572
  /**
450
573
  * Load an agent definition with prompt inheritance.
451
574
  *
@@ -991,6 +1114,312 @@ function spawnAgent(opts: {
991
1114
  return { promise, kill: () => killFn() };
992
1115
  }
993
1116
 
1117
+ // ── Sidecar JSONL Tailing ────────────────────────────────────────────
1118
+
1119
+ /**
1120
+ * Mutable state for incremental byte-offset sidecar JSONL reading.
1121
+ * One instance per sidecar file, persists across poll ticks within a session.
1122
+ */
1123
+ interface SidecarTailState {
1124
+ /** Byte offset of the next unread position in the sidecar file */
1125
+ offset: number;
1126
+ /** Partial trailing line from the last read (incomplete JSONL line) */
1127
+ partial: string;
1128
+ /** Whether a retry is currently active (persisted across ticks) */
1129
+ retryActive: boolean;
1130
+ }
1131
+
1132
+ function createSidecarTailState(): SidecarTailState {
1133
+ return { offset: 0, partial: "", retryActive: false };
1134
+ }
1135
+
1136
+ /**
1137
+ * Parsed telemetry accumulated from sidecar JSONL events.
1138
+ * Returned by tailSidecarJsonl() on each tick.
1139
+ */
1140
+ interface SidecarTelemetryDelta {
1141
+ /** Per-turn input tokens (sum of new message_end events in this tick) */
1142
+ inputTokens: number;
1143
+ outputTokens: number;
1144
+ cacheReadTokens: number;
1145
+ cacheWriteTokens: number;
1146
+ /** Incremental cost from new message_end events */
1147
+ cost: number;
1148
+ /** Most recent totalTokens from message_end usage (cumulative, for context %) */
1149
+ latestTotalTokens: number;
1150
+ /** Tool calls observed in this tick */
1151
+ toolCalls: number;
1152
+ /** Last tool description from tool_execution_start */
1153
+ lastTool: string;
1154
+ /** Whether a retry is currently active (persisted across ticks via SidecarTailState) */
1155
+ retryActive: boolean;
1156
+ /** Total retries started in this tick */
1157
+ retriesStarted: number;
1158
+ /** Error message from the most recent auto_retry_start */
1159
+ lastRetryError: string;
1160
+ /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1161
+ hadEvents: boolean;
1162
+ }
1163
+
1164
+ /**
1165
+ * Incrementally read new lines from a sidecar JSONL file and parse telemetry events.
1166
+ *
1167
+ * O(new) per call — only reads bytes after the previous offset. Handles:
1168
+ * - File not yet created (returns zero delta)
1169
+ * - Empty reads (no new data since last tick)
1170
+ * - Partial trailing lines (buffered for next call)
1171
+ * - Malformed JSON lines (skipped with stderr warning, does not break iteration)
1172
+ *
1173
+ * The caller (poll loop) accumulates the returned deltas into TaskState.
1174
+ */
1175
+ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): SidecarTelemetryDelta {
1176
+ const delta: SidecarTelemetryDelta = {
1177
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1178
+ cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1179
+ retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1180
+ hadEvents: false,
1181
+ };
1182
+
1183
+ // Gracefully handle missing file (wrapper hasn't written yet)
1184
+ let fileSize: number;
1185
+ try {
1186
+ fileSize = statSync(filePath).size;
1187
+ } catch {
1188
+ return delta; // File doesn't exist yet — no-op
1189
+ }
1190
+
1191
+ if (fileSize <= tailState.offset) {
1192
+ return delta; // No new data
1193
+ }
1194
+
1195
+ // Read new bytes from offset to end of file
1196
+ const bytesToRead = fileSize - tailState.offset;
1197
+ const buf = Buffer.alloc(bytesToRead);
1198
+ let fd: number;
1199
+ try {
1200
+ fd = openSync(filePath, "r");
1201
+ } catch {
1202
+ return delta; // File became inaccessible between stat and open
1203
+ }
1204
+ try {
1205
+ readSync(fd, buf, 0, bytesToRead, tailState.offset);
1206
+ } catch {
1207
+ closeSync(fd);
1208
+ return delta; // Read error — try again next tick
1209
+ }
1210
+ closeSync(fd);
1211
+ tailState.offset = fileSize;
1212
+
1213
+ // Split into lines, preserving any partial trailing line
1214
+ const chunk = tailState.partial + buf.toString("utf-8");
1215
+ const lines = chunk.split("\n");
1216
+ // Last element is either "" (if chunk ended with \n) or a partial line
1217
+ tailState.partial = lines.pop() || "";
1218
+
1219
+ for (const line of lines) {
1220
+ const trimmed = line.trim();
1221
+ if (!trimmed) continue;
1222
+
1223
+ let event: any;
1224
+ try {
1225
+ event = JSON.parse(trimmed);
1226
+ } catch {
1227
+ // Malformed JSON — skip silently (concurrent write race, truncated line)
1228
+ continue;
1229
+ }
1230
+
1231
+ if (!event || !event.type) continue;
1232
+
1233
+ delta.hadEvents = true;
1234
+
1235
+ switch (event.type) {
1236
+ case "message_end": {
1237
+ const usage = event.message?.usage;
1238
+ if (usage) {
1239
+ delta.inputTokens += usage.input || 0;
1240
+ delta.outputTokens += usage.output || 0;
1241
+ delta.cacheReadTokens += usage.cacheRead || 0;
1242
+ delta.cacheWriteTokens += usage.cacheWrite || 0;
1243
+ if (usage.cost) {
1244
+ delta.cost += typeof usage.cost === "object"
1245
+ ? (usage.cost.total || 0)
1246
+ : (typeof usage.cost === "number" ? usage.cost : 0);
1247
+ }
1248
+ // totalTokens is cumulative (grows each turn) — use latest value
1249
+ const totalTokens = usage.totalTokens
1250
+ || ((usage.input || 0) + (usage.output || 0));
1251
+ if (totalTokens > delta.latestTotalTokens) {
1252
+ delta.latestTotalTokens = totalTokens;
1253
+ }
1254
+ }
1255
+ break;
1256
+ }
1257
+
1258
+ case "tool_execution_start": {
1259
+ delta.toolCalls++;
1260
+ const toolDesc = event.toolName || "unknown";
1261
+ let argPreview = "";
1262
+ if (event.args) {
1263
+ if (typeof event.args === "string") {
1264
+ argPreview = event.args.slice(0, 80);
1265
+ } else if (typeof event.args === "object") {
1266
+ const firstVal = Object.values(event.args)[0];
1267
+ if (typeof firstVal === "string") {
1268
+ argPreview = (firstVal as string).slice(0, 80);
1269
+ }
1270
+ }
1271
+ }
1272
+ delta.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
1273
+ break;
1274
+ }
1275
+
1276
+ case "auto_retry_start": {
1277
+ delta.retriesStarted++;
1278
+ delta.lastRetryError = event.errorMessage || event.error || "unknown";
1279
+ tailState.retryActive = true;
1280
+ break;
1281
+ }
1282
+
1283
+ case "auto_retry_end": {
1284
+ tailState.retryActive = false;
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // Reflect persisted retry state into the delta for the caller
1291
+ delta.retryActive = tailState.retryActive;
1292
+ return delta;
1293
+ }
1294
+
1295
+ /** Expose sidecar tailing internals for testing (not part of public API). */
1296
+ export const _tailSidecarJsonl = tailSidecarJsonl;
1297
+ export const _createSidecarTailState = createSidecarTailState;
1298
+ export const _getSidecarDir = getSidecarDir;
1299
+ export type { SidecarTailState, SidecarTelemetryDelta };
1300
+
1301
+ // ── Exit Summary & Diagnostic ────────────────────────────────────────
1302
+
1303
+ /**
1304
+ * Read the exit summary JSON file written by rpc-wrapper.mjs.
1305
+ *
1306
+ * Returns null if the file is missing (session vanished) or malformed
1307
+ * (wrapper crashed mid-write). Logs a warning on parse failure but
1308
+ * never throws — the caller should treat null as "session_vanished".
1309
+ */
1310
+ function readExitSummary(exitSummaryPath: string): ExitSummary | null {
1311
+ try {
1312
+ if (!existsSync(exitSummaryPath)) {
1313
+ return null;
1314
+ }
1315
+ const raw = readFileSync(exitSummaryPath, "utf-8").trim();
1316
+ if (!raw) return null;
1317
+ const parsed = JSON.parse(raw);
1318
+ // Minimal shape validation: must be a plain object (not array, not null)
1319
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1320
+ console.error(`[task-runner] exit summary is not a plain object: ${exitSummaryPath}`);
1321
+ return null;
1322
+ }
1323
+ return parsed as ExitSummary;
1324
+ } catch (err: any) {
1325
+ console.error(`[task-runner] failed to read exit summary: ${err.message}`);
1326
+ return null;
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * Input parameters for `buildExitDiagnostic()`.
1332
+ *
1333
+ * Bridges the task-runner's runtime state into `classifyExit()` input
1334
+ * and populates the full `TaskExitDiagnostic` with progress metadata.
1335
+ */
1336
+ interface BuildExitDiagnosticInput {
1337
+ /** Exit summary from rpc-wrapper.mjs (null if file missing) */
1338
+ exitSummary: ExitSummary | null;
1339
+ /** Whether .DONE file was found */
1340
+ doneFileFound: boolean;
1341
+ /** Whether the wall-clock timer killed the session */
1342
+ timerKilled: boolean;
1343
+ /** Whether the context-limit kill was triggered */
1344
+ contextKilled: boolean;
1345
+ /** Whether the user manually killed the session */
1346
+ userKilled: boolean;
1347
+ /** Estimated context utilization % from sidecar tailing (0-100) */
1348
+ contextPct: number;
1349
+ /** Wall-clock duration in seconds */
1350
+ durationSec: number;
1351
+ /** Repo identifier ("default" in repo mode, repo key in workspace mode) */
1352
+ repoId: string;
1353
+ /** Last known step number from STATUS.md (null if not parsed) */
1354
+ lastKnownStep: number | null;
1355
+ /** Last known checkbox text from STATUS.md (null if not parsed) */
1356
+ lastKnownCheckbox: string | null;
1357
+ /** Number of commits representing partial progress (0 if none) */
1358
+ partialProgressCommits: number;
1359
+ /** Branch name holding partial progress (null if no branch) */
1360
+ partialProgressBranch: string | null;
1361
+ }
1362
+
1363
+ /**
1364
+ * Build a structured `TaskExitDiagnostic` from task-runner runtime state.
1365
+ *
1366
+ * Calls `classifyExit()` with the appropriate signal mapping, then
1367
+ * enriches the result with progress metadata (commits, step, repo).
1368
+ *
1369
+ * Signal mapping:
1370
+ * - `stallDetected` in ExitClassificationInput ← not directly available in
1371
+ * task-runner's tmux mode (stall detection is orchestrator-level), so
1372
+ * always false here. Stall classification may still occur via orchestrator.
1373
+ * - `contextKilled` ← when the task-runner explicitly kills the session
1374
+ * due to context limit. Passed to classifyExit() so it can produce
1375
+ * `context_overflow` even when exit summary is missing or lacks
1376
+ * compaction events (e.g., wrapper crashed before writing summary).
1377
+ */
1378
+ function buildExitDiagnostic(input: BuildExitDiagnosticInput): TaskExitDiagnostic {
1379
+ const classification = classifyExit({
1380
+ exitSummary: input.exitSummary,
1381
+ doneFileFound: input.doneFileFound,
1382
+ timerKilled: input.timerKilled,
1383
+ contextKilled: input.contextKilled,
1384
+ stallDetected: false, // Stall detection is orchestrator-level, not available in /task mode
1385
+ userKilled: input.userKilled,
1386
+ contextPct: input.contextPct,
1387
+ });
1388
+
1389
+ return {
1390
+ classification,
1391
+ exitCode: input.exitSummary?.exitCode ?? null,
1392
+ errorMessage: input.exitSummary?.error ?? null,
1393
+ tokensUsed: input.exitSummary?.tokens ?? null,
1394
+ contextPct: input.contextPct,
1395
+ partialProgressCommits: input.partialProgressCommits,
1396
+ partialProgressBranch: input.partialProgressBranch,
1397
+ durationSec: input.durationSec,
1398
+ lastKnownStep: input.lastKnownStep,
1399
+ lastKnownCheckbox: input.lastKnownCheckbox,
1400
+ repoId: input.repoId,
1401
+ };
1402
+ }
1403
+
1404
+ /** Expose exit summary/diagnostic helpers for testing. */
1405
+ export const _readExitSummary = readExitSummary;
1406
+ export const _buildExitDiagnostic = buildExitDiagnostic;
1407
+ export type { BuildExitDiagnosticInput };
1408
+
1409
+ /**
1410
+ * Determine whether a step is "low-risk" and should skip reviews.
1411
+ * Low-risk steps: Step 0 (Preflight) and the final step (Delivery/Docs).
1412
+ *
1413
+ * @param stepNumber The 0-based step number being evaluated
1414
+ * @param totalSteps Total number of steps in the task
1415
+ * @returns true if the step should skip plan and code reviews
1416
+ */
1417
+ export function isLowRiskStep(stepNumber: number, totalSteps: number): boolean {
1418
+ if (totalSteps <= 0) return false;
1419
+ const lastStepIndex = totalSteps - 1;
1420
+ return stepNumber === 0 || stepNumber === lastStepIndex;
1421
+ }
1422
+
994
1423
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
995
1424
 
996
1425
  /**
@@ -1011,7 +1440,9 @@ function spawnAgent(opts: {
1011
1440
  * - Session creation failure (throws after cleanup)
1012
1441
  *
1013
1442
  * Parity with spawnAgent():
1014
- * - Return shape: identical — { promise, kill }
1443
+ * - Return shape: extended — { promise, kill, sidecarPath, exitSummaryPath }
1444
+ * (promise and kill are drop-in compatible; sidecarPath and exitSummaryPath
1445
+ * are additions for RPC telemetry consumption in Steps 2/3)
1015
1446
  * - Promise result: identical fields — { output, exitCode, elapsed, killed }
1016
1447
  * - Kill semantics: sets killed=true, terminates session, cleans temp files
1017
1448
  * - Elapsed calc: Date.now() - startTime (same pattern)
@@ -1019,6 +1450,15 @@ function spawnAgent(opts: {
1019
1450
  * - output: always "" (no JSON stream in TMUX mode)
1020
1451
  * - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
1021
1452
  *
1453
+ * RPC Wrapper Integration (TP-026):
1454
+ * Instead of spawning `pi -p` directly, this function now spawns `rpc-wrapper.mjs`
1455
+ * which runs pi in RPC mode and produces:
1456
+ * - Sidecar JSONL file with real-time telemetry (tokens, cost, tool calls, retries)
1457
+ * - Exit summary JSON with structured exit data for classification
1458
+ *
1459
+ * The telemetry file paths are returned alongside the promise/kill handles so that
1460
+ * Steps 2 (sidecar tailing) and 3 (exit diagnostic) can read them.
1461
+ *
1022
1462
  * @param opts.sessionName — TMUX session name (e.g., "orch-lane-1-worker")
1023
1463
  * @param opts.cwd — Working directory for the TMUX session
1024
1464
  * @param opts.systemPrompt — System prompt content (written to temp file)
@@ -1026,6 +1466,7 @@ function spawnAgent(opts: {
1026
1466
  * @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
1027
1467
  * @param opts.tools — Comma-separated tool list
1028
1468
  * @param opts.thinking — Thinking mode ("off", "on", etc.)
1469
+ * @param opts.taskId — Optional task ID for telemetry filename enrichment (e.g., "TP-026")
1029
1470
  */
1030
1471
  function spawnAgentTmux(opts: {
1031
1472
  sessionName: string;
@@ -1035,7 +1476,17 @@ function spawnAgentTmux(opts: {
1035
1476
  model: string;
1036
1477
  tools: string;
1037
1478
  thinking: string;
1038
- }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
1479
+ taskId?: string;
1480
+ /** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
1481
+ * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1482
+ * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
1483
+ onTelemetry?: (delta: SidecarTelemetryDelta) => void;
1484
+ }): {
1485
+ promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1486
+ kill: () => void;
1487
+ sidecarPath: string;
1488
+ exitSummaryPath: string;
1489
+ } {
1039
1490
 
1040
1491
  // ── Preflight: verify tmux is available ──────────────────────────
1041
1492
  const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
@@ -1047,10 +1498,59 @@ function spawnAgentTmux(opts: {
1047
1498
  );
1048
1499
  }
1049
1500
 
1501
+ // ── Generate telemetry file paths ───────────────────────────────
1502
+ // Naming contract from resilience roadmap:
1503
+ // .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
1504
+ //
1505
+ // In standalone /task mode (no orchestrator):
1506
+ // opId → TASKPLANE_OPERATOR_ID env, or OS username, or "op"
1507
+ // batchId → timestamp (no batch concept in standalone mode)
1508
+ // repoId → "default" (single-repo mode)
1509
+ // taskId → from opts.taskId when provided (e.g., "tp-026"), omitted if absent
1510
+ // lane → omitted (no lanes)
1511
+ // role → derived from sessionName suffix (worker/reviewer)
1512
+ //
1513
+ // getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
1514
+ const telemetryTs = Date.now();
1515
+
1516
+ // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1517
+ let opId = "op";
1518
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1519
+ if (envOpId?.trim()) {
1520
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1521
+ } else {
1522
+ try {
1523
+ const username = userInfo().username;
1524
+ if (username?.trim()) {
1525
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1526
+ }
1527
+ } catch { /* userInfo() can throw on some platforms */ }
1528
+ }
1529
+
1530
+ const batchId = String(telemetryTs);
1531
+ const repoId = "default";
1532
+
1533
+ // Extract role (worker/reviewer) from sessionName, and optional lane component
1534
+ // sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
1535
+ const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1536
+ const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1537
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1538
+
1539
+ // Include taskId when available — sanitize to filesystem-safe characters.
1540
+ // Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1541
+ const taskIdSegment = opts.taskId
1542
+ ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1543
+ : "";
1544
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1545
+ const telemetryDir = join(getSidecarDir(), "telemetry");
1546
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1547
+ const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
1548
+ const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
1549
+
1050
1550
  // ── Write prompts to temp files ─────────────────────────────────
1051
1551
  // Same pattern as spawnAgent() — avoids shell escaping issues with
1052
1552
  // backticks, quotes, and special characters in markdown content.
1053
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1553
+ const id = `${telemetryTs}-${Math.random().toString(36).slice(2, 8)}`;
1054
1554
  const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
1055
1555
  const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
1056
1556
  writeFileSync(sysTmpFile, opts.systemPrompt);
@@ -1061,10 +1561,14 @@ function spawnAgentTmux(opts: {
1061
1561
  try { unlinkSync(promptTmpFile); } catch {}
1062
1562
  };
1063
1563
 
1064
- // ── Build Pi command ─────────────────────────────────────────────
1065
- // Use an array of arguments and quote each one individually to handle
1066
- // paths with spaces (Windows paths, temp dir, etc.). The command is
1067
- // passed as a single string to tmux new-session, so we shell-quote it.
1564
+ // ── Build RPC Wrapper command ────────────────────────────────────
1565
+ // Spawns `node rpc-wrapper.mjs` instead of `pi -p`. The wrapper runs
1566
+ // pi in RPC mode, captures telemetry to the sidecar JSONL, and writes
1567
+ // a structured exit summary JSON on process exit.
1568
+ //
1569
+ // Shell quoting: use quoteArg() for all path arguments — same quoting
1570
+ // guarantees as the previous `pi -p` command since both execute as a
1571
+ // single shell string via tmux new-session.
1068
1572
  const quoteArg = (s: string): string => {
1069
1573
  // If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
1070
1574
  // Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
@@ -1074,17 +1578,24 @@ function spawnAgentTmux(opts: {
1074
1578
  return s;
1075
1579
  };
1076
1580
 
1077
- const piArgs = [
1078
- "pi",
1079
- "-p", // Non-interactive: process prompt and exit (without this, pi waits for more input)
1080
- "--no-session", "--no-extensions", "--no-skills",
1581
+ // Resolve rpc-wrapper.mjs path from the installed package
1582
+ const rpcWrapperPath = resolveRpcWrapperPath();
1583
+
1584
+ const wrapperArgs = [
1585
+ "node", quoteArg(rpcWrapperPath),
1586
+ "--sidecar-path", quoteArg(sidecarPath),
1587
+ "--exit-summary-path", quoteArg(exitSummaryPath),
1081
1588
  "--model", quoteArg(opts.model),
1589
+ "--system-prompt-file", quoteArg(sysTmpFile),
1590
+ "--prompt-file", quoteArg(promptTmpFile),
1082
1591
  "--tools", quoteArg(opts.tools),
1592
+ // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1593
+ // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1594
+ "--",
1083
1595
  "--thinking", quoteArg(opts.thinking),
1084
- "--append-system-prompt", quoteArg(sysTmpFile),
1085
- `@${quoteArg(promptTmpFile)}`,
1596
+ "--no-extensions", "--no-skills",
1086
1597
  ];
1087
- const piCommand = piArgs.join(" ");
1598
+ const wrapperCommand = wrapperArgs.join(" ");
1088
1599
 
1089
1600
  // ── Handle stale session ─────────────────────────────────────────
1090
1601
  // Session names are fixed per role (e.g., "orch-lane-1-worker").
@@ -1101,7 +1612,7 @@ function spawnAgentTmux(opts: {
1101
1612
  // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
1102
1613
  // force xterm-256color.
1103
1614
  const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
1104
- const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${piCommand}`;
1615
+ const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${wrapperCommand}`;
1105
1616
  const createResult = spawnSync("tmux", [
1106
1617
  "new-session", "-d",
1107
1618
  "-s", opts.sessionName,
@@ -1124,14 +1635,32 @@ function spawnAgentTmux(opts: {
1124
1635
  // ── Poll until session ends ─────────────────────────────────────
1125
1636
  let killed = false;
1126
1637
  const startTime = Date.now();
1638
+ const tailState = createSidecarTailState();
1127
1639
 
1128
1640
  const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
1129
1641
  try {
1130
1642
  while (true) {
1131
1643
  await new Promise(r => setTimeout(r, 2000));
1644
+
1645
+ // Tail sidecar JSONL for telemetry updates on each tick
1646
+ if (opts.onTelemetry) {
1647
+ const delta = tailSidecarJsonl(sidecarPath, tailState);
1648
+ // Call back whenever events were parsed (including retry state transitions)
1649
+ if (delta.hadEvents) {
1650
+ opts.onTelemetry(delta);
1651
+ }
1652
+ }
1653
+
1132
1654
  const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
1133
1655
  if (result.status !== 0) {
1134
1656
  // Session no longer exists — Pi exited, TMUX closed
1657
+ // Final tail to catch any events written since last tick
1658
+ if (opts.onTelemetry) {
1659
+ const finalDelta = tailSidecarJsonl(sidecarPath, tailState);
1660
+ if (finalDelta.hadEvents) {
1661
+ opts.onTelemetry(finalDelta);
1662
+ }
1663
+ }
1135
1664
  break;
1136
1665
  }
1137
1666
  }
@@ -1174,7 +1703,7 @@ function spawnAgentTmux(opts: {
1174
1703
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
1175
1704
  };
1176
1705
 
1177
- return { promise, kill };
1706
+ return { promise, kill, sidecarPath, exitSummaryPath };
1178
1707
  }
1179
1708
 
1180
1709
  // ── Display Helpers ──────────────────────────────────────────────────
@@ -1403,11 +1932,127 @@ export default function (pi: ExtensionAPI) {
1403
1932
  if (state.phase === "error" || state.phase === "paused") return;
1404
1933
  }
1405
1934
 
1406
- // All done
1407
- const donePath = join(task.taskFolder, ".DONE");
1408
- writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
1409
- updateStatusField(statusPath, "Status", "✅ Complete");
1410
- logExecution(statusPath, "Task complete", ".DONE created");
1935
+ // All steps done — run quality gate if enabled, then create .DONE
1936
+ if (config.quality_gate.enabled) {
1937
+ // ── Quality Gate Enabled ─────────────────────────────────
1938
+ // Run structured review cycles with remediation. .DONE only
1939
+ // created after PASS verdict — never delete/recreate.
1940
+ const maxReviewCycles = config.quality_gate.max_review_cycles;
1941
+ const maxFixCycles = config.quality_gate.max_fix_cycles;
1942
+ let reviewCycle = 0;
1943
+ let fixCyclesUsed = 0;
1944
+ let gatePassed = false;
1945
+ let lastVerdict: ReviewVerdict | null = null;
1946
+
1947
+ const gateContext: QualityGateContext = {
1948
+ taskFolder: task.taskFolder,
1949
+ promptPath: task.promptPath,
1950
+ taskId: task.taskId,
1951
+ projectName: config.project.name,
1952
+ passThreshold: config.quality_gate.pass_threshold,
1953
+ };
1954
+
1955
+ logExecution(statusPath, "Quality gate", `Enabled (threshold: ${config.quality_gate.pass_threshold}, max reviews: ${maxReviewCycles}, max fixes: ${maxFixCycles})`);
1956
+
1957
+ while (reviewCycle < maxReviewCycles) {
1958
+ reviewCycle++;
1959
+ const result = await doQualityGateReview(ctx, reviewCycle);
1960
+ lastVerdict = result.verdict;
1961
+
1962
+ if (result.passed) {
1963
+ gatePassed = true;
1964
+ break;
1965
+ }
1966
+
1967
+ // NEEDS_FIXES — check if we can still do a fix cycle
1968
+ if (reviewCycle >= maxReviewCycles) {
1969
+ // No more review cycles left — terminal failure
1970
+ logExecution(statusPath, "Quality gate", `Max review cycles (${maxReviewCycles}) exhausted — no more reviews allowed`);
1971
+ break;
1972
+ }
1973
+
1974
+ if (fixCyclesUsed >= maxFixCycles) {
1975
+ // No more fix cycles allowed
1976
+ logExecution(statusPath, "Quality gate", `Max fix cycles (${maxFixCycles}) exhausted — cannot remediate`);
1977
+ break;
1978
+ }
1979
+
1980
+ // ── Remediation: write feedback, spawn fix agent ─────
1981
+ fixCyclesUsed++;
1982
+
1983
+ // Write REVIEW_FEEDBACK.md with blocking findings
1984
+ const feedbackContent = generateFeedbackMd(result.verdict, reviewCycle, maxReviewCycles, config.quality_gate.pass_threshold);
1985
+ const feedbackPath = join(task.taskFolder, FEEDBACK_FILENAME);
1986
+ try {
1987
+ writeFileSync(feedbackPath, feedbackContent);
1988
+ logExecution(statusPath, "Quality gate", `Wrote ${FEEDBACK_FILENAME} (fix cycle ${fixCyclesUsed}/${maxFixCycles})`);
1989
+ } catch (err: any) {
1990
+ logExecution(statusPath, "Quality gate", `Failed to write ${FEEDBACK_FILENAME}: ${err?.message} — skipping remediation`);
1991
+ break;
1992
+ }
1993
+
1994
+ // Build fix agent prompt
1995
+ const fixPrompt = buildFixAgentPrompt(gateContext, feedbackContent, fixCyclesUsed);
1996
+
1997
+ // Spawn fix agent (reuses worker spawn pattern)
1998
+ const fixResult = await doQualityGateFixAgent(ctx, fixPrompt, fixCyclesUsed);
1999
+
2000
+ if (fixResult.timedOut) {
2001
+ // Fix agent hit wall-clock timeout — budget consumed deterministically
2002
+ logExecution(statusPath, "Quality gate", `Fix agent timed out (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — budget consumed, proceeding to re-review`);
2003
+ } else if (fixResult.exitCode !== 0) {
2004
+ // Fix agent abnormal exit — consumes fix budget, log and continue to re-review
2005
+ logExecution(statusPath, "Quality gate", `Fix agent exited with code ${fixResult.exitCode} (cycle ${fixCyclesUsed}) — budget consumed, proceeding to re-review`);
2006
+ } else {
2007
+ logExecution(statusPath, "Quality gate", `Fix agent completed (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — proceeding to re-review`);
2008
+ }
2009
+
2010
+ // Loop back to the top for re-review
2011
+ }
2012
+
2013
+ if (gatePassed) {
2014
+ // PASS → create .DONE
2015
+ const donePath = join(task.taskFolder, ".DONE");
2016
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\nQuality gate: PASS (cycle ${reviewCycle})\n`);
2017
+ updateStatusField(statusPath, "Status", "✅ Complete");
2018
+ logExecution(statusPath, "Task complete", `.DONE created (quality gate PASS, cycle ${reviewCycle})`);
2019
+ } else {
2020
+ // Gate failed — do NOT create .DONE
2021
+ // Persist blocking findings summary for operator visibility
2022
+ if (lastVerdict) {
2023
+ const criticals = lastVerdict.findings.filter(f => f.severity === "critical");
2024
+ const importants = lastVerdict.findings.filter(f => f.severity === "important");
2025
+ const suggestions = lastVerdict.findings.filter(f => f.severity === "suggestion");
2026
+ const summaryParts = [
2027
+ criticals.length > 0 ? `${criticals.length} critical` : "",
2028
+ importants.length > 0 ? `${importants.length} important` : "",
2029
+ // Include suggestion counts when they are blocking (all_clear threshold)
2030
+ (config.quality_gate.pass_threshold === "all_clear" && suggestions.length > 0)
2031
+ ? `${suggestions.length} suggestion` : "",
2032
+ ].filter(Boolean);
2033
+ const findingsSummary = summaryParts.join(", ");
2034
+ logExecution(statusPath, "Quality gate failed",
2035
+ `${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). ` +
2036
+ `Blocking findings: ${findingsSummary || "none extracted"}. ` +
2037
+ `Summary: ${lastVerdict.summary}`);
2038
+ } else {
2039
+ logExecution(statusPath, "Quality gate failed", `Task did not pass after ${reviewCycle} review cycle(s)`);
2040
+ }
2041
+
2042
+ state.phase = "error";
2043
+ updateStatusField(statusPath, "Status", "❌ Quality gate failed");
2044
+ ctx.ui.notify(`❌ Quality gate failed after ${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). .DONE not created.`, "error");
2045
+ updateWidgets();
2046
+ return;
2047
+ }
2048
+ } else {
2049
+ // ── Quality Gate Disabled (default) ──────────────────────
2050
+ // Unchanged behavior — create .DONE immediately.
2051
+ const donePath = join(task.taskFolder, ".DONE");
2052
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
2053
+ updateStatusField(statusPath, "Status", "✅ Complete");
2054
+ logExecution(statusPath, "Task complete", ".DONE created");
2055
+ }
1411
2056
 
1412
2057
  // Auto-archive: move task folder to tasks/archive/.
1413
2058
  // In orchestrated runs, do NOT archive here — the orchestrator polls
@@ -1450,11 +2095,20 @@ export default function (pi: ExtensionAPI) {
1450
2095
  logExecution(statusPath, `Step ${step.number} started`, step.name);
1451
2096
  updateWidgets();
1452
2097
 
2098
+ // Skip reviews for low-risk steps (Step 0 / Preflight and final step / Delivery)
2099
+ const _isLowRiskStep = isLowRiskStep(step.number, task.steps.length);
2100
+
1453
2101
  // Plan review (level ≥ 1)
1454
2102
  if (task.reviewLevel >= 1) {
1455
- const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
1456
- if (verdict === "RETHINK") {
1457
- ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2103
+ if (_isLowRiskStep) {
2104
+ const label = step.number === 0 ? "Preflight" : "final step";
2105
+ logExecution(statusPath, `Skip plan review`, `Step ${step.number} (${label}) low-risk`);
2106
+ ctx.ui.notify(`⏭️ Skipping plan review for Step ${step.number} (${label})`, "info");
2107
+ } else {
2108
+ const verdict = await doReview("plan", step, ctx, stepBaselineCommit);
2109
+ if (verdict === "RETHINK") {
2110
+ ctx.ui.notify(`Reviewer: RETHINK on Step ${step.number} plan. Proceeding with caution.`, "warning");
2111
+ }
1458
2112
  }
1459
2113
  }
1460
2114
 
@@ -1504,10 +2158,16 @@ export default function (pi: ExtensionAPI) {
1504
2158
 
1505
2159
  // Code review (level ≥ 2)
1506
2160
  if (task.reviewLevel >= 2 && state.phase === "running") {
1507
- const verdict = await doReview("code", step, ctx, stepBaselineCommit);
1508
- if (verdict === "REVISE") {
1509
- ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
1510
- await runWorker(step, ctx); // One more pass to address issues
2161
+ if (_isLowRiskStep) {
2162
+ const label = step.number === 0 ? "Preflight" : "final step";
2163
+ logExecution(statusPath, `Skip code review`, `Step ${step.number} (${label}) low-risk`);
2164
+ ctx.ui.notify(`⏭️ Skipping code review for Step ${step.number} (${label})`, "info");
2165
+ } else {
2166
+ const verdict = await doReview("code", step, ctx, stepBaselineCommit);
2167
+ if (verdict === "REVISE") {
2168
+ ctx.ui.notify(`Reviewer: REVISE on Step ${step.number}. Running worker to fix...`, "warning");
2169
+ await runWorker(step, ctx); // One more pass to address issues
2170
+ }
1511
2171
  }
1512
2172
  }
1513
2173
 
@@ -1591,6 +2251,9 @@ export default function (pi: ExtensionAPI) {
1591
2251
  state.workerContextPct = 0;
1592
2252
  state.workerLastTool = "";
1593
2253
  state.workerToolCount = 0;
2254
+ state.workerRetryActive = false;
2255
+ state.workerRetryCount = 0;
2256
+ state.workerLastRetryError = "";
1594
2257
  updateWidgets();
1595
2258
 
1596
2259
  const startTime = Date.now();
@@ -1604,12 +2267,22 @@ export default function (pi: ExtensionAPI) {
1604
2267
  let kill: () => void;
1605
2268
  let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
1606
2269
  let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
2270
+ // Track why the session was killed for exit classification.
2271
+ // "timer" = wall-clock timeout, "context" = context % limit, "user" = manual kill.
2272
+ let killReason: "timer" | "context" | "user" | null = null;
2273
+ // Exit summary path — set only in tmux mode (rpc-wrapper produces this file).
2274
+ let exitSummaryPath: string | null = null;
1607
2275
 
1608
2276
  if (spawnMode === "tmux") {
1609
2277
  // ── TMUX mode ────────────────────────────────────────
1610
- // No JSON stream no onToolCall/onContextPct callbacks.
1611
- // Kill via wall-clock timeout instead of context-%.
2278
+ // Sidecar JSONL provides telemetry parity: tokens, cost, context%,
2279
+ // tool calls, and retry events same signals as subprocess mode.
2280
+ // Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
1612
2281
  const sessionName = `${getTmuxPrefix()}-worker`;
2282
+ const contextWindow = config.context.worker_context_window;
2283
+ const warnPct = config.context.warn_percent;
2284
+ const killPct = config.context.kill_percent;
2285
+
1613
2286
  const spawned = spawnAgentTmux({
1614
2287
  sessionName,
1615
2288
  cwd: ctx.cwd,
@@ -1618,12 +2291,53 @@ export default function (pi: ExtensionAPI) {
1618
2291
  model,
1619
2292
  tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1620
2293
  thinking: config.worker.thinking || "off",
2294
+ taskId: task.taskId,
2295
+ onTelemetry: (delta) => {
2296
+ // Accumulate tokens and cost (same as subprocess onTokenUpdate)
2297
+ state.workerInputTokens += delta.inputTokens;
2298
+ state.workerOutputTokens += delta.outputTokens;
2299
+ state.workerCacheReadTokens += delta.cacheReadTokens;
2300
+ state.workerCacheWriteTokens += delta.cacheWriteTokens;
2301
+ state.workerCostUsd += delta.cost;
2302
+
2303
+ // Tool tracking (same as subprocess onToolCall)
2304
+ state.workerToolCount += delta.toolCalls;
2305
+ if (delta.lastTool) {
2306
+ state.workerLastTool = delta.lastTool;
2307
+ }
2308
+
2309
+ // Retry tracking
2310
+ state.workerRetryCount += delta.retriesStarted;
2311
+ state.workerRetryActive = delta.retryActive;
2312
+ if (delta.lastRetryError) {
2313
+ state.workerLastRetryError = delta.lastRetryError;
2314
+ }
2315
+
2316
+ // Context % (same as subprocess onContextPct)
2317
+ // totalTokens is cumulative from the most recent message_end
2318
+ if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2319
+ const pct = (delta.latestTotalTokens / contextWindow) * 100;
2320
+ state.workerContextPct = pct;
2321
+ if (pct >= warnPct) {
2322
+ writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
2323
+ }
2324
+ if (pct >= killPct && state.workerStatus === "running") {
2325
+ console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
2326
+ killReason = "context";
2327
+ spawned.kill();
2328
+ }
2329
+ }
2330
+
2331
+ updateWidgets();
2332
+ },
1621
2333
  });
1622
2334
  promise = spawned.promise;
1623
2335
  kill = spawned.kill;
2336
+ exitSummaryPath = spawned.exitSummaryPath;
1624
2337
 
1625
2338
  // Wall-clock timeout: write wrap-up file at 80% of limit,
1626
- // hard kill at 100%. No context telemetry in TMUX mode.
2339
+ // hard kill at 100%. Context-% based wrap-up/kill is also active
2340
+ // via sidecar telemetry (above), providing dual safety nets.
1627
2341
  const maxMinutes = getMaxWorkerMinutes(config);
1628
2342
  const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
1629
2343
  const killMs = maxMinutes * 60_000;
@@ -1643,6 +2357,7 @@ export default function (pi: ExtensionAPI) {
1643
2357
  wallClockKillTimer = setTimeout(() => {
1644
2358
  if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
1645
2359
  console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
2360
+ killReason = "timer";
1646
2361
  kill();
1647
2362
  }
1648
2363
  }, killMs);
@@ -1718,12 +2433,57 @@ export default function (pi: ExtensionAPI) {
1718
2433
 
1719
2434
  clearWrapUpSignals();
1720
2435
 
1721
- // Log with mode-appropriate detail: subprocess has context%, TMUX does not
1722
- const killedMsg = spawnMode === "tmux" ? "killed (wall-clock timeout)" : "killed (context limit)";
1723
- const statusMsg = result.killed ? killedMsg : (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1724
- const ctxDetail = spawnMode === "tmux" ? "" : `, ctx: ${Math.round(state.workerContextPct)}%`;
2436
+ // ── Exit Diagnostic (tmux mode only) ─────────────────────
2437
+ // Read the exit summary JSON written by rpc-wrapper.mjs, classify
2438
+ // the exit, and build a structured diagnostic for persistence.
2439
+ // Subprocess mode doesn't produce exit summaries (it uses JSON
2440
+ // event stream directly), so this path is tmux-only.
2441
+ if (spawnMode === "tmux" && exitSummaryPath) {
2442
+ const exitSummary = readExitSummary(exitSummaryPath);
2443
+ const donePath = join(task.taskFolder, ".DONE");
2444
+ const doneFileFound = existsSync(donePath);
2445
+
2446
+ // Determine userKilled: killed is true but not by timer or context
2447
+ const userKilled = result.killed && killReason === null;
2448
+
2449
+ const diagnostic = buildExitDiagnostic({
2450
+ exitSummary,
2451
+ doneFileFound,
2452
+ timerKilled: killReason === "timer",
2453
+ contextKilled: killReason === "context",
2454
+ userKilled,
2455
+ contextPct: state.workerContextPct,
2456
+ durationSec: Math.round(state.workerElapsed / 1000),
2457
+ repoId: process.env.TASKPLANE_REPO_ID || "default",
2458
+ lastKnownStep: state.currentStep || null,
2459
+ lastKnownCheckbox: null, // Not parsed in task-runner; available via STATUS.md
2460
+ partialProgressCommits: 0, // Computed by orchestrator after commit
2461
+ partialProgressBranch: null,
2462
+ });
2463
+
2464
+ // Store diagnostic on state for lane-state sidecar and logging
2465
+ state.workerExitDiagnostic = diagnostic;
2466
+
2467
+ console.error(`[task-runner] exit diagnostic: ${diagnostic.classification}` +
2468
+ (diagnostic.exitCode !== null ? ` (exit ${diagnostic.exitCode})` : "") +
2469
+ (exitSummary ? `` : " (no exit summary)"));
2470
+
2471
+ // Log telemetry file paths for operator visibility (files preserved for dashboard)
2472
+ const sidecarPath = exitSummaryPath.replace(/-exit\.json$/, ".jsonl");
2473
+ console.error(`[task-runner] telemetry files preserved:` +
2474
+ `\n sidecar: ${sidecarPath}` +
2475
+ `\n exit summary: ${exitSummaryPath}`);
2476
+ }
2477
+
2478
+ // Log with telemetry detail — both subprocess and TMUX now have context%
2479
+ const killedMsg = result.killed
2480
+ ? (spawnMode === "tmux"
2481
+ ? `killed (${killReason === "context" ? "context limit" : killReason === "timer" ? "wall-clock timeout" : "user"})`
2482
+ : "killed (context limit)")
2483
+ : "";
2484
+ const statusMsg = killedMsg || (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1725
2485
  logExecution(statusPath, `Worker iter ${state.totalIterations}`,
1726
- `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s${ctxDetail}, tools: ${state.workerToolCount}`);
2486
+ `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s, ctx: ${Math.round(state.workerContextPct)}%, tools: ${state.workerToolCount}`);
1727
2487
 
1728
2488
  updateWidgets();
1729
2489
  }
@@ -1783,6 +2543,7 @@ export default function (pi: ExtensionAPI) {
1783
2543
  model: reviewerModel,
1784
2544
  tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1785
2545
  thinking: config.reviewer.thinking || "on",
2546
+ taskId: state.task?.taskId,
1786
2547
  });
1787
2548
  reviewPromise = spawned.promise;
1788
2549
  state.reviewerProc = { kill: spawned.kill };
@@ -1833,6 +2594,355 @@ export default function (pi: ExtensionAPI) {
1833
2594
  return verdict;
1834
2595
  }
1835
2596
 
2597
+ // ── Quality Gate ─────────────────────────────────────────────────
2598
+
2599
+ /**
2600
+ * Run a single quality gate review cycle.
2601
+ *
2602
+ * Spawns a review agent with a structured prompt that includes task evidence
2603
+ * (PROMPT.md, STATUS.md, git diff, file list). The agent writes a JSON verdict
2604
+ * to REVIEW_VERDICT.json in the task folder. This function reads/parses that
2605
+ * file and applies verdict rules.
2606
+ *
2607
+ * Fail-open on all error paths:
2608
+ * - Agent crash / non-zero exit → synthetic PASS
2609
+ * - Missing verdict file → synthetic PASS
2610
+ * - Malformed JSON → synthetic PASS
2611
+ *
2612
+ * @param ctx - Extension context
2613
+ * @param cycleNum - Current review cycle number (1-based)
2614
+ * @returns Quality gate result with pass/fail, verdict, and evaluation
2615
+ */
2616
+ async function doQualityGateReview(ctx: ExtensionContext, cycleNum: number): Promise<QualityGateResult> {
2617
+ if (!state.task || !state.config) {
2618
+ return {
2619
+ passed: true, skipped: true, cyclesUsed: cycleNum,
2620
+ verdict: { verdict: "PASS", confidence: "low", summary: "No task/config — skipped", findings: [], statusReconciliation: [] },
2621
+ evaluation: { pass: true, failReasons: [] },
2622
+ };
2623
+ }
2624
+
2625
+ const task = state.task;
2626
+ const config = state.config;
2627
+ const statusPath = join(task.taskFolder, "STATUS.md");
2628
+
2629
+ // Delete any previous verdict file so we can detect agent failure
2630
+ const verdictPath = join(task.taskFolder, VERDICT_FILENAME);
2631
+ try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch { /* ignore */ }
2632
+
2633
+ // Build the quality gate context and prompt
2634
+ const gateContext: QualityGateContext = {
2635
+ taskFolder: task.taskFolder,
2636
+ promptPath: task.promptPath,
2637
+ taskId: task.taskId,
2638
+ projectName: config.project.name,
2639
+ passThreshold: config.quality_gate.pass_threshold,
2640
+ };
2641
+
2642
+ const prompt = generateQualityGatePrompt(gateContext, ctx.cwd);
2643
+
2644
+ // Determine review model with fallback chain:
2645
+ // quality_gate.review_model → reviewer.model → agent def → default
2646
+ const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
2647
+ const reviewModel = config.quality_gate.review_model
2648
+ || config.reviewer.model
2649
+ || reviewerDef?.model
2650
+ || "openai/gpt-5.3-codex";
2651
+
2652
+ const reviewerPrompt = reviewerDef?.systemPrompt
2653
+ || "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
2654
+ const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2655
+
2656
+ // Update UI state
2657
+ state.reviewerStatus = "running";
2658
+ state.reviewerType = `quality-gate cycle ${cycleNum}`;
2659
+ state.reviewerElapsed = 0;
2660
+ state.reviewerLastTool = "";
2661
+ updateWidgets();
2662
+
2663
+ const startTime = Date.now();
2664
+ state.reviewerTimer = setInterval(() => {
2665
+ state.reviewerElapsed = Date.now() - startTime;
2666
+ updateWidgets();
2667
+ }, 1000);
2668
+
2669
+ logExecution(statusPath, `Quality gate`, `Starting review cycle ${cycleNum}`);
2670
+
2671
+ const spawnMode = getSpawnMode(config);
2672
+ let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
2673
+
2674
+ try {
2675
+ if (spawnMode === "tmux") {
2676
+ const sessionName = `${getTmuxPrefix()}-qg-reviewer`;
2677
+ const spawned = spawnAgentTmux({
2678
+ sessionName,
2679
+ cwd: ctx.cwd,
2680
+ systemPrompt,
2681
+ prompt,
2682
+ model: reviewModel,
2683
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2684
+ thinking: config.reviewer.thinking || "on",
2685
+ taskId: task.taskId,
2686
+ });
2687
+ reviewPromise = spawned.promise;
2688
+ state.reviewerProc = { kill: spawned.kill };
2689
+ } else {
2690
+ const spawned = spawnAgent({
2691
+ model: reviewModel,
2692
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2693
+ thinking: config.reviewer.thinking || "on",
2694
+ systemPrompt,
2695
+ prompt,
2696
+ onToolCall: (toolName, args) => {
2697
+ const path = args?.path || args?.command || "";
2698
+ const shortPath = typeof path === "string" && path.length > 40
2699
+ ? "..." + path.slice(-37) : path;
2700
+ state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
2701
+ updateWidgets();
2702
+ },
2703
+ });
2704
+ reviewPromise = spawned.promise;
2705
+ state.reviewerProc = { kill: spawned.kill };
2706
+ }
2707
+
2708
+ const result = await reviewPromise;
2709
+
2710
+ clearInterval(state.reviewerTimer);
2711
+ state.reviewerElapsed = Date.now() - startTime;
2712
+ state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2713
+ state.reviewerProc = null;
2714
+ updateWidgets();
2715
+
2716
+ // If agent exited non-zero, fail-open
2717
+ if (result.exitCode !== 0) {
2718
+ logExecution(statusPath, `Quality gate`, `Review agent exited with code ${result.exitCode} — fail-open → PASS`);
2719
+ ctx.ui.notify(`Quality gate: review agent error (exit ${result.exitCode}) — fail-open PASS`, "warning");
2720
+ return {
2721
+ passed: true, skipped: false, cyclesUsed: cycleNum,
2722
+ verdict: { verdict: "PASS", confidence: "low", summary: `Review agent exited with code ${result.exitCode} — fail-open`, findings: [], statusReconciliation: [] },
2723
+ evaluation: { pass: true, failReasons: [] },
2724
+ };
2725
+ }
2726
+ } catch (err: any) {
2727
+ // Agent crash — fail-open
2728
+ clearInterval(state.reviewerTimer);
2729
+ state.reviewerStatus = "error";
2730
+ state.reviewerProc = null;
2731
+ updateWidgets();
2732
+
2733
+ logExecution(statusPath, `Quality gate`, `Review agent crashed: ${err?.message || err} — fail-open → PASS`);
2734
+ ctx.ui.notify(`Quality gate: review agent crashed — fail-open PASS`, "warning");
2735
+ return {
2736
+ passed: true, skipped: false, cyclesUsed: cycleNum,
2737
+ verdict: { verdict: "PASS", confidence: "low", summary: `Review agent crashed — fail-open`, findings: [], statusReconciliation: [] },
2738
+ evaluation: { pass: true, failReasons: [] },
2739
+ };
2740
+ }
2741
+
2742
+ // Read and evaluate the verdict file
2743
+ const { verdict, evaluation } = readAndEvaluateVerdict(
2744
+ task.taskFolder,
2745
+ config.quality_gate.pass_threshold,
2746
+ );
2747
+
2748
+ // Apply STATUS.md reconciliation if verdict has entries
2749
+ if (verdict.statusReconciliation.length > 0) {
2750
+ const reconResult = applyStatusReconciliation(statusPath, verdict.statusReconciliation);
2751
+ if (reconResult.changed > 0 || reconResult.unmatched > 0) {
2752
+ logExecution(statusPath, `Reconciliation`,
2753
+ `${reconResult.changed} changed, ${reconResult.alreadyCorrect} already correct, ${reconResult.unmatched} unmatched`);
2754
+ }
2755
+ }
2756
+
2757
+ const passed = evaluation.pass;
2758
+ const verdictLabel = passed ? "PASS" : "NEEDS_FIXES";
2759
+ const findingsSummary = verdict.findings.length > 0
2760
+ ? ` (${verdict.findings.length} findings: ${verdict.findings.filter(f => f.severity === "critical").length}C/${verdict.findings.filter(f => f.severity === "important").length}I/${verdict.findings.filter(f => f.severity === "suggestion").length}S)`
2761
+ : "";
2762
+
2763
+ logExecution(statusPath, `Quality gate`, `Cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`);
2764
+ ctx.ui.notify(
2765
+ `Quality gate cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`,
2766
+ passed ? "success" : "warning",
2767
+ );
2768
+
2769
+ return {
2770
+ passed,
2771
+ skipped: false,
2772
+ cyclesUsed: cycleNum,
2773
+ verdict,
2774
+ evaluation,
2775
+ };
2776
+ }
2777
+
2778
+ // ── Quality Gate Fix Agent ───────────────────────────────────────
2779
+
2780
+ /** Default wall-clock timeout for fix agents (15 minutes). */
2781
+ const FIX_AGENT_TIMEOUT_MS = 15 * 60 * 1000;
2782
+
2783
+ /**
2784
+ * Spawn a fix agent to address quality gate findings.
2785
+ *
2786
+ * Reuses the worker spawn pattern (subprocess or tmux). The fix agent
2787
+ * receives REVIEW_FEEDBACK.md content and makes targeted code fixes.
2788
+ *
2789
+ * Handles abnormal exits deterministically:
2790
+ * - Agent crash → returns non-zero exit code (caller consumes fix budget)
2791
+ * - Agent timeout → kills agent, returns non-zero (caller consumes fix budget)
2792
+ * - Agent exits normally but makes no changes → still returns 0 (re-review will catch)
2793
+ *
2794
+ * Wall-clock timeout: 15 minutes (or getMaxWorkerMinutes if configured).
2795
+ * This prevents a hung fix agent from stalling the task permanently.
2796
+ *
2797
+ * @param ctx - Extension context
2798
+ * @param fixPrompt - Prompt for the fix agent (includes REVIEW_FEEDBACK.md)
2799
+ * @param fixCycleNum - Current fix cycle number (1-based)
2800
+ * @returns Exit code, elapsed time, and whether timeout was hit
2801
+ */
2802
+ async function doQualityGateFixAgent(
2803
+ ctx: ExtensionContext,
2804
+ fixPrompt: string,
2805
+ fixCycleNum: number,
2806
+ ): Promise<{ exitCode: number; elapsed: number; timedOut: boolean }> {
2807
+ if (!state.task || !state.config) {
2808
+ return { exitCode: 1, elapsed: 0, timedOut: false };
2809
+ }
2810
+
2811
+ const task = state.task;
2812
+ const config = state.config;
2813
+ const statusPath = join(task.taskFolder, "STATUS.md");
2814
+
2815
+ // Use worker model and tools for fix agent (it needs to edit code)
2816
+ const workerDef = loadAgentDef(ctx.cwd, "task-worker");
2817
+ const fixModel = config.worker.model
2818
+ || workerDef?.model
2819
+ || "anthropic/claude-sonnet-4-20250514";
2820
+
2821
+ const basePrompt = workerDef?.systemPrompt
2822
+ || "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
2823
+ const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2824
+
2825
+ // Wall-clock timeout: use half of worker limit (fix agents should be quick),
2826
+ // with a floor of 15 minutes.
2827
+ const workerMinutes = getMaxWorkerMinutes(config);
2828
+ const timeoutMs = Math.max(FIX_AGENT_TIMEOUT_MS, Math.floor(workerMinutes / 2) * 60 * 1000);
2829
+
2830
+ // Update UI state
2831
+ state.workerStatus = "running";
2832
+ state.workerElapsed = 0;
2833
+ state.workerContextPct = 0;
2834
+ state.workerLastTool = "";
2835
+ state.workerToolCount = 0;
2836
+ state.workerRetryActive = false;
2837
+ state.workerRetryCount = 0;
2838
+ state.workerLastRetryError = "";
2839
+ updateWidgets();
2840
+
2841
+ const startTime = Date.now();
2842
+ state.workerTimer = setInterval(() => {
2843
+ state.workerElapsed = Date.now() - startTime;
2844
+ updateWidgets();
2845
+ }, 1000);
2846
+
2847
+ logExecution(statusPath, "Quality gate", `Starting fix agent (cycle ${fixCycleNum}, timeout: ${Math.round(timeoutMs / 60000)}min)`);
2848
+
2849
+ const spawnMode = getSpawnMode(config);
2850
+ let fixPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
2851
+ let killFn: (() => void) | null = null;
2852
+ let tmuxExitSummaryPath: string | null = null;
2853
+
2854
+ try {
2855
+ if (spawnMode === "tmux") {
2856
+ const sessionName = `${getTmuxPrefix()}-qg-fix`;
2857
+ const spawned = spawnAgentTmux({
2858
+ sessionName,
2859
+ cwd: ctx.cwd,
2860
+ systemPrompt,
2861
+ prompt: fixPrompt,
2862
+ model: fixModel,
2863
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
2864
+ thinking: config.worker.thinking || "off",
2865
+ taskId: task.taskId,
2866
+ });
2867
+ fixPromise = spawned.promise;
2868
+ killFn = spawned.kill;
2869
+ tmuxExitSummaryPath = spawned.exitSummaryPath;
2870
+ state.workerProc = { kill: spawned.kill };
2871
+ } else {
2872
+ const spawned = spawnAgent({
2873
+ model: fixModel,
2874
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
2875
+ thinking: config.worker.thinking || "off",
2876
+ systemPrompt,
2877
+ prompt: fixPrompt,
2878
+ onToolCall: (toolName, args) => {
2879
+ state.workerToolCount++;
2880
+ const path = args?.path || args?.command || "";
2881
+ const shortPath = typeof path === "string" && path.length > 80
2882
+ ? "..." + path.slice(-77) : path;
2883
+ state.workerLastTool = `${toolName} ${shortPath}`.trim();
2884
+ updateWidgets();
2885
+ },
2886
+ });
2887
+ fixPromise = spawned.promise;
2888
+ killFn = spawned.kill;
2889
+ state.workerProc = { kill: spawned.kill };
2890
+ }
2891
+
2892
+ // Race the agent against a wall-clock timeout
2893
+ let timedOut = false;
2894
+ const timeoutPromise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
2895
+ const timer = setTimeout(() => {
2896
+ timedOut = true;
2897
+ logExecution(statusPath, "Quality gate", `Fix agent wall-clock timeout (${Math.round(timeoutMs / 60000)}min) — killing agent`);
2898
+ if (killFn) killFn();
2899
+ // Resolve after a brief delay to allow kill to take effect
2900
+ setTimeout(() => {
2901
+ resolve({ output: "timeout", exitCode: 1, elapsed: Date.now() - startTime, killed: true });
2902
+ }, 5000);
2903
+ }, timeoutMs);
2904
+ // Clean up timer if agent finishes first
2905
+ fixPromise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
2906
+ });
2907
+
2908
+ const result = await Promise.race([fixPromise, timeoutPromise]);
2909
+
2910
+ // ── TMUX exit classification ─────────────────────────
2911
+ // spawnAgentTmux always reports exitCode: 0 on session end.
2912
+ // Read the exit summary written by rpc-wrapper to get the
2913
+ // real Pi process exit code (same pattern as worker flow).
2914
+ let effectiveExitCode = result.exitCode;
2915
+ if (spawnMode === "tmux" && tmuxExitSummaryPath && !timedOut) {
2916
+ const exitSummary = readExitSummary(tmuxExitSummaryPath);
2917
+ if (exitSummary && typeof exitSummary.exitCode === "number") {
2918
+ effectiveExitCode = exitSummary.exitCode;
2919
+ if (effectiveExitCode !== 0) {
2920
+ console.error(`[task-runner] qg-fix: tmux exit summary reports exit code ${effectiveExitCode}`);
2921
+ }
2922
+ }
2923
+ // If no exit summary exists, keep the tmux-reported code (0).
2924
+ // This is fail-open: missing exit summary ≠ crash.
2925
+ }
2926
+
2927
+ clearInterval(state.workerTimer);
2928
+ state.workerElapsed = Date.now() - startTime;
2929
+ state.workerStatus = (effectiveExitCode === 0 && !timedOut) ? "done" : "error";
2930
+ state.workerProc = null;
2931
+ updateWidgets();
2932
+
2933
+ return { exitCode: timedOut ? 1 : effectiveExitCode, elapsed: Date.now() - startTime, timedOut };
2934
+ } catch (err: any) {
2935
+ // Fix agent crashed — return non-zero to consume fix budget
2936
+ clearInterval(state.workerTimer);
2937
+ state.workerStatus = "error";
2938
+ state.workerProc = null;
2939
+ updateWidgets();
2940
+
2941
+ logExecution(statusPath, "Quality gate", `Fix agent crashed: ${err?.message || err} — fix cycle ${fixCycleNum} consumed`);
2942
+ return { exitCode: 1, elapsed: Date.now() - startTime, timedOut: false };
2943
+ }
2944
+ }
2945
+
1836
2946
  // ── Commands ─────────────────────────────────────────────────────
1837
2947
 
1838
2948
  // ── Shared Task Initialization ───────────────────────────────────