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.
- package/README.md +1 -1
- package/bin/rpc-wrapper.mjs +777 -0
- package/dashboard/public/app.js +45 -6
- package/dashboard/public/style.css +31 -0
- package/dashboard/server.cjs +326 -1
- package/extensions/task-runner.ts +1147 -37
- package/extensions/taskplane/config-loader.ts +31 -0
- package/extensions/taskplane/config-schema.ts +88 -0
- package/extensions/taskplane/diagnostic-reports.ts +463 -0
- package/extensions/taskplane/diagnostics.ts +323 -0
- package/extensions/taskplane/engine.ts +407 -62
- package/extensions/taskplane/extension.ts +259 -8
- package/extensions/taskplane/index.ts +1 -0
- package/extensions/taskplane/merge.ts +786 -66
- package/extensions/taskplane/messages.ts +594 -2
- package/extensions/taskplane/persistence.ts +342 -19
- package/extensions/taskplane/quality-gate.ts +1033 -0
- package/extensions/taskplane/resume.ts +505 -36
- package/extensions/taskplane/supervisor-primer.md +664 -0
- package/extensions/taskplane/types.ts +534 -6
- package/extensions/taskplane/verification.ts +537 -0
- package/extensions/taskplane/worktree.ts +329 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -2
- package/templates/agents/task-reviewer.md +54 -2
- package/templates/agents/task-worker.md +8 -4
|
@@ -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:
|
|
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
|
-
|
|
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 = `${
|
|
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
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
-
"--
|
|
1085
|
-
`@${quoteArg(promptTmpFile)}`,
|
|
1596
|
+
"--no-extensions", "--no-skills",
|
|
1086
1597
|
];
|
|
1087
|
-
const
|
|
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 ${
|
|
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
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
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
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
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
|
-
//
|
|
1611
|
-
//
|
|
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%.
|
|
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
|
-
//
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
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${
|
|
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 ───────────────────────────────────
|