taskplane 0.5.12 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1111 -30
- 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,298 @@ 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
|
+
|
|
994
1409
|
// ── TMUX Agent Spawner ───────────────────────────────────────────────
|
|
995
1410
|
|
|
996
1411
|
/**
|
|
@@ -1011,7 +1426,9 @@ function spawnAgent(opts: {
|
|
|
1011
1426
|
* - Session creation failure (throws after cleanup)
|
|
1012
1427
|
*
|
|
1013
1428
|
* Parity with spawnAgent():
|
|
1014
|
-
* - Return shape:
|
|
1429
|
+
* - Return shape: extended — { promise, kill, sidecarPath, exitSummaryPath }
|
|
1430
|
+
* (promise and kill are drop-in compatible; sidecarPath and exitSummaryPath
|
|
1431
|
+
* are additions for RPC telemetry consumption in Steps 2/3)
|
|
1015
1432
|
* - Promise result: identical fields — { output, exitCode, elapsed, killed }
|
|
1016
1433
|
* - Kill semantics: sets killed=true, terminates session, cleans temp files
|
|
1017
1434
|
* - Elapsed calc: Date.now() - startTime (same pattern)
|
|
@@ -1019,6 +1436,15 @@ function spawnAgent(opts: {
|
|
|
1019
1436
|
* - output: always "" (no JSON stream in TMUX mode)
|
|
1020
1437
|
* - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
|
|
1021
1438
|
*
|
|
1439
|
+
* RPC Wrapper Integration (TP-026):
|
|
1440
|
+
* Instead of spawning `pi -p` directly, this function now spawns `rpc-wrapper.mjs`
|
|
1441
|
+
* which runs pi in RPC mode and produces:
|
|
1442
|
+
* - Sidecar JSONL file with real-time telemetry (tokens, cost, tool calls, retries)
|
|
1443
|
+
* - Exit summary JSON with structured exit data for classification
|
|
1444
|
+
*
|
|
1445
|
+
* The telemetry file paths are returned alongside the promise/kill handles so that
|
|
1446
|
+
* Steps 2 (sidecar tailing) and 3 (exit diagnostic) can read them.
|
|
1447
|
+
*
|
|
1022
1448
|
* @param opts.sessionName — TMUX session name (e.g., "orch-lane-1-worker")
|
|
1023
1449
|
* @param opts.cwd — Working directory for the TMUX session
|
|
1024
1450
|
* @param opts.systemPrompt — System prompt content (written to temp file)
|
|
@@ -1026,6 +1452,7 @@ function spawnAgent(opts: {
|
|
|
1026
1452
|
* @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
|
|
1027
1453
|
* @param opts.tools — Comma-separated tool list
|
|
1028
1454
|
* @param opts.thinking — Thinking mode ("off", "on", etc.)
|
|
1455
|
+
* @param opts.taskId — Optional task ID for telemetry filename enrichment (e.g., "TP-026")
|
|
1029
1456
|
*/
|
|
1030
1457
|
function spawnAgentTmux(opts: {
|
|
1031
1458
|
sessionName: string;
|
|
@@ -1035,7 +1462,17 @@ function spawnAgentTmux(opts: {
|
|
|
1035
1462
|
model: string;
|
|
1036
1463
|
tools: string;
|
|
1037
1464
|
thinking: string;
|
|
1038
|
-
|
|
1465
|
+
taskId?: string;
|
|
1466
|
+
/** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
|
|
1467
|
+
* Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
|
|
1468
|
+
* with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
|
|
1469
|
+
onTelemetry?: (delta: SidecarTelemetryDelta) => void;
|
|
1470
|
+
}): {
|
|
1471
|
+
promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
1472
|
+
kill: () => void;
|
|
1473
|
+
sidecarPath: string;
|
|
1474
|
+
exitSummaryPath: string;
|
|
1475
|
+
} {
|
|
1039
1476
|
|
|
1040
1477
|
// ── Preflight: verify tmux is available ──────────────────────────
|
|
1041
1478
|
const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
|
|
@@ -1047,10 +1484,59 @@ function spawnAgentTmux(opts: {
|
|
|
1047
1484
|
);
|
|
1048
1485
|
}
|
|
1049
1486
|
|
|
1487
|
+
// ── Generate telemetry file paths ───────────────────────────────
|
|
1488
|
+
// Naming contract from resilience roadmap:
|
|
1489
|
+
// .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
|
|
1490
|
+
//
|
|
1491
|
+
// In standalone /task mode (no orchestrator):
|
|
1492
|
+
// opId → TASKPLANE_OPERATOR_ID env, or OS username, or "op"
|
|
1493
|
+
// batchId → timestamp (no batch concept in standalone mode)
|
|
1494
|
+
// repoId → "default" (single-repo mode)
|
|
1495
|
+
// taskId → from opts.taskId when provided (e.g., "tp-026"), omitted if absent
|
|
1496
|
+
// lane → omitted (no lanes)
|
|
1497
|
+
// role → derived from sessionName suffix (worker/reviewer)
|
|
1498
|
+
//
|
|
1499
|
+
// getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
|
|
1500
|
+
const telemetryTs = Date.now();
|
|
1501
|
+
|
|
1502
|
+
// Resolve opId: same priority chain as naming.ts resolveOperatorId()
|
|
1503
|
+
let opId = "op";
|
|
1504
|
+
const envOpId = process.env.TASKPLANE_OPERATOR_ID;
|
|
1505
|
+
if (envOpId?.trim()) {
|
|
1506
|
+
opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1507
|
+
} else {
|
|
1508
|
+
try {
|
|
1509
|
+
const username = userInfo().username;
|
|
1510
|
+
if (username?.trim()) {
|
|
1511
|
+
opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
|
|
1512
|
+
}
|
|
1513
|
+
} catch { /* userInfo() can throw on some platforms */ }
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
const batchId = String(telemetryTs);
|
|
1517
|
+
const repoId = "default";
|
|
1518
|
+
|
|
1519
|
+
// Extract role (worker/reviewer) from sessionName, and optional lane component
|
|
1520
|
+
// sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
|
|
1521
|
+
const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
|
|
1522
|
+
const laneMatch = opts.sessionName.match(/lane-(\d+)/);
|
|
1523
|
+
const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
|
|
1524
|
+
|
|
1525
|
+
// Include taskId when available — sanitize to filesystem-safe characters.
|
|
1526
|
+
// Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
|
|
1527
|
+
const taskIdSegment = opts.taskId
|
|
1528
|
+
? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
|
|
1529
|
+
: "";
|
|
1530
|
+
const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
|
|
1531
|
+
const telemetryDir = join(getSidecarDir(), "telemetry");
|
|
1532
|
+
if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
|
|
1533
|
+
const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
|
|
1534
|
+
const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
|
|
1535
|
+
|
|
1050
1536
|
// ── Write prompts to temp files ─────────────────────────────────
|
|
1051
1537
|
// Same pattern as spawnAgent() — avoids shell escaping issues with
|
|
1052
1538
|
// backticks, quotes, and special characters in markdown content.
|
|
1053
|
-
const id = `${
|
|
1539
|
+
const id = `${telemetryTs}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1054
1540
|
const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
|
|
1055
1541
|
const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
|
|
1056
1542
|
writeFileSync(sysTmpFile, opts.systemPrompt);
|
|
@@ -1061,10 +1547,14 @@ function spawnAgentTmux(opts: {
|
|
|
1061
1547
|
try { unlinkSync(promptTmpFile); } catch {}
|
|
1062
1548
|
};
|
|
1063
1549
|
|
|
1064
|
-
// ── Build
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1550
|
+
// ── Build RPC Wrapper command ────────────────────────────────────
|
|
1551
|
+
// Spawns `node rpc-wrapper.mjs` instead of `pi -p`. The wrapper runs
|
|
1552
|
+
// pi in RPC mode, captures telemetry to the sidecar JSONL, and writes
|
|
1553
|
+
// a structured exit summary JSON on process exit.
|
|
1554
|
+
//
|
|
1555
|
+
// Shell quoting: use quoteArg() for all path arguments — same quoting
|
|
1556
|
+
// guarantees as the previous `pi -p` command since both execute as a
|
|
1557
|
+
// single shell string via tmux new-session.
|
|
1068
1558
|
const quoteArg = (s: string): string => {
|
|
1069
1559
|
// If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
|
|
1070
1560
|
// Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
|
|
@@ -1074,17 +1564,24 @@ function spawnAgentTmux(opts: {
|
|
|
1074
1564
|
return s;
|
|
1075
1565
|
};
|
|
1076
1566
|
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1567
|
+
// Resolve rpc-wrapper.mjs path from the installed package
|
|
1568
|
+
const rpcWrapperPath = resolveRpcWrapperPath();
|
|
1569
|
+
|
|
1570
|
+
const wrapperArgs = [
|
|
1571
|
+
"node", quoteArg(rpcWrapperPath),
|
|
1572
|
+
"--sidecar-path", quoteArg(sidecarPath),
|
|
1573
|
+
"--exit-summary-path", quoteArg(exitSummaryPath),
|
|
1081
1574
|
"--model", quoteArg(opts.model),
|
|
1575
|
+
"--system-prompt-file", quoteArg(sysTmpFile),
|
|
1576
|
+
"--prompt-file", quoteArg(promptTmpFile),
|
|
1082
1577
|
"--tools", quoteArg(opts.tools),
|
|
1578
|
+
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1579
|
+
// Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
|
|
1580
|
+
"--",
|
|
1083
1581
|
"--thinking", quoteArg(opts.thinking),
|
|
1084
|
-
"--
|
|
1085
|
-
`@${quoteArg(promptTmpFile)}`,
|
|
1582
|
+
"--no-extensions", "--no-skills",
|
|
1086
1583
|
];
|
|
1087
|
-
const
|
|
1584
|
+
const wrapperCommand = wrapperArgs.join(" ");
|
|
1088
1585
|
|
|
1089
1586
|
// ── Handle stale session ─────────────────────────────────────────
|
|
1090
1587
|
// Session names are fixed per role (e.g., "orch-lane-1-worker").
|
|
@@ -1101,7 +1598,7 @@ function spawnAgentTmux(opts: {
|
|
|
1101
1598
|
// Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
|
|
1102
1599
|
// force xterm-256color.
|
|
1103
1600
|
const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
|
|
1104
|
-
const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${
|
|
1601
|
+
const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${wrapperCommand}`;
|
|
1105
1602
|
const createResult = spawnSync("tmux", [
|
|
1106
1603
|
"new-session", "-d",
|
|
1107
1604
|
"-s", opts.sessionName,
|
|
@@ -1124,14 +1621,32 @@ function spawnAgentTmux(opts: {
|
|
|
1124
1621
|
// ── Poll until session ends ─────────────────────────────────────
|
|
1125
1622
|
let killed = false;
|
|
1126
1623
|
const startTime = Date.now();
|
|
1624
|
+
const tailState = createSidecarTailState();
|
|
1127
1625
|
|
|
1128
1626
|
const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
|
|
1129
1627
|
try {
|
|
1130
1628
|
while (true) {
|
|
1131
1629
|
await new Promise(r => setTimeout(r, 2000));
|
|
1630
|
+
|
|
1631
|
+
// Tail sidecar JSONL for telemetry updates on each tick
|
|
1632
|
+
if (opts.onTelemetry) {
|
|
1633
|
+
const delta = tailSidecarJsonl(sidecarPath, tailState);
|
|
1634
|
+
// Call back whenever events were parsed (including retry state transitions)
|
|
1635
|
+
if (delta.hadEvents) {
|
|
1636
|
+
opts.onTelemetry(delta);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1132
1640
|
const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
|
|
1133
1641
|
if (result.status !== 0) {
|
|
1134
1642
|
// Session no longer exists — Pi exited, TMUX closed
|
|
1643
|
+
// Final tail to catch any events written since last tick
|
|
1644
|
+
if (opts.onTelemetry) {
|
|
1645
|
+
const finalDelta = tailSidecarJsonl(sidecarPath, tailState);
|
|
1646
|
+
if (finalDelta.hadEvents) {
|
|
1647
|
+
opts.onTelemetry(finalDelta);
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1135
1650
|
break;
|
|
1136
1651
|
}
|
|
1137
1652
|
}
|
|
@@ -1174,7 +1689,7 @@ function spawnAgentTmux(opts: {
|
|
|
1174
1689
|
console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
|
|
1175
1690
|
};
|
|
1176
1691
|
|
|
1177
|
-
return { promise, kill };
|
|
1692
|
+
return { promise, kill, sidecarPath, exitSummaryPath };
|
|
1178
1693
|
}
|
|
1179
1694
|
|
|
1180
1695
|
// ── Display Helpers ──────────────────────────────────────────────────
|
|
@@ -1403,11 +1918,127 @@ export default function (pi: ExtensionAPI) {
|
|
|
1403
1918
|
if (state.phase === "error" || state.phase === "paused") return;
|
|
1404
1919
|
}
|
|
1405
1920
|
|
|
1406
|
-
// All done
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1921
|
+
// All steps done — run quality gate if enabled, then create .DONE
|
|
1922
|
+
if (config.quality_gate.enabled) {
|
|
1923
|
+
// ── Quality Gate Enabled ─────────────────────────────────
|
|
1924
|
+
// Run structured review cycles with remediation. .DONE only
|
|
1925
|
+
// created after PASS verdict — never delete/recreate.
|
|
1926
|
+
const maxReviewCycles = config.quality_gate.max_review_cycles;
|
|
1927
|
+
const maxFixCycles = config.quality_gate.max_fix_cycles;
|
|
1928
|
+
let reviewCycle = 0;
|
|
1929
|
+
let fixCyclesUsed = 0;
|
|
1930
|
+
let gatePassed = false;
|
|
1931
|
+
let lastVerdict: ReviewVerdict | null = null;
|
|
1932
|
+
|
|
1933
|
+
const gateContext: QualityGateContext = {
|
|
1934
|
+
taskFolder: task.taskFolder,
|
|
1935
|
+
promptPath: task.promptPath,
|
|
1936
|
+
taskId: task.taskId,
|
|
1937
|
+
projectName: config.project.name,
|
|
1938
|
+
passThreshold: config.quality_gate.pass_threshold,
|
|
1939
|
+
};
|
|
1940
|
+
|
|
1941
|
+
logExecution(statusPath, "Quality gate", `Enabled (threshold: ${config.quality_gate.pass_threshold}, max reviews: ${maxReviewCycles}, max fixes: ${maxFixCycles})`);
|
|
1942
|
+
|
|
1943
|
+
while (reviewCycle < maxReviewCycles) {
|
|
1944
|
+
reviewCycle++;
|
|
1945
|
+
const result = await doQualityGateReview(ctx, reviewCycle);
|
|
1946
|
+
lastVerdict = result.verdict;
|
|
1947
|
+
|
|
1948
|
+
if (result.passed) {
|
|
1949
|
+
gatePassed = true;
|
|
1950
|
+
break;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// NEEDS_FIXES — check if we can still do a fix cycle
|
|
1954
|
+
if (reviewCycle >= maxReviewCycles) {
|
|
1955
|
+
// No more review cycles left — terminal failure
|
|
1956
|
+
logExecution(statusPath, "Quality gate", `Max review cycles (${maxReviewCycles}) exhausted — no more reviews allowed`);
|
|
1957
|
+
break;
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
if (fixCyclesUsed >= maxFixCycles) {
|
|
1961
|
+
// No more fix cycles allowed
|
|
1962
|
+
logExecution(statusPath, "Quality gate", `Max fix cycles (${maxFixCycles}) exhausted — cannot remediate`);
|
|
1963
|
+
break;
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// ── Remediation: write feedback, spawn fix agent ─────
|
|
1967
|
+
fixCyclesUsed++;
|
|
1968
|
+
|
|
1969
|
+
// Write REVIEW_FEEDBACK.md with blocking findings
|
|
1970
|
+
const feedbackContent = generateFeedbackMd(result.verdict, reviewCycle, maxReviewCycles, config.quality_gate.pass_threshold);
|
|
1971
|
+
const feedbackPath = join(task.taskFolder, FEEDBACK_FILENAME);
|
|
1972
|
+
try {
|
|
1973
|
+
writeFileSync(feedbackPath, feedbackContent);
|
|
1974
|
+
logExecution(statusPath, "Quality gate", `Wrote ${FEEDBACK_FILENAME} (fix cycle ${fixCyclesUsed}/${maxFixCycles})`);
|
|
1975
|
+
} catch (err: any) {
|
|
1976
|
+
logExecution(statusPath, "Quality gate", `Failed to write ${FEEDBACK_FILENAME}: ${err?.message} — skipping remediation`);
|
|
1977
|
+
break;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Build fix agent prompt
|
|
1981
|
+
const fixPrompt = buildFixAgentPrompt(gateContext, feedbackContent, fixCyclesUsed);
|
|
1982
|
+
|
|
1983
|
+
// Spawn fix agent (reuses worker spawn pattern)
|
|
1984
|
+
const fixResult = await doQualityGateFixAgent(ctx, fixPrompt, fixCyclesUsed);
|
|
1985
|
+
|
|
1986
|
+
if (fixResult.timedOut) {
|
|
1987
|
+
// Fix agent hit wall-clock timeout — budget consumed deterministically
|
|
1988
|
+
logExecution(statusPath, "Quality gate", `Fix agent timed out (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — budget consumed, proceeding to re-review`);
|
|
1989
|
+
} else if (fixResult.exitCode !== 0) {
|
|
1990
|
+
// Fix agent abnormal exit — consumes fix budget, log and continue to re-review
|
|
1991
|
+
logExecution(statusPath, "Quality gate", `Fix agent exited with code ${fixResult.exitCode} (cycle ${fixCyclesUsed}) — budget consumed, proceeding to re-review`);
|
|
1992
|
+
} else {
|
|
1993
|
+
logExecution(statusPath, "Quality gate", `Fix agent completed (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — proceeding to re-review`);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Loop back to the top for re-review
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
if (gatePassed) {
|
|
2000
|
+
// PASS → create .DONE
|
|
2001
|
+
const donePath = join(task.taskFolder, ".DONE");
|
|
2002
|
+
writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\nQuality gate: PASS (cycle ${reviewCycle})\n`);
|
|
2003
|
+
updateStatusField(statusPath, "Status", "✅ Complete");
|
|
2004
|
+
logExecution(statusPath, "Task complete", `.DONE created (quality gate PASS, cycle ${reviewCycle})`);
|
|
2005
|
+
} else {
|
|
2006
|
+
// Gate failed — do NOT create .DONE
|
|
2007
|
+
// Persist blocking findings summary for operator visibility
|
|
2008
|
+
if (lastVerdict) {
|
|
2009
|
+
const criticals = lastVerdict.findings.filter(f => f.severity === "critical");
|
|
2010
|
+
const importants = lastVerdict.findings.filter(f => f.severity === "important");
|
|
2011
|
+
const suggestions = lastVerdict.findings.filter(f => f.severity === "suggestion");
|
|
2012
|
+
const summaryParts = [
|
|
2013
|
+
criticals.length > 0 ? `${criticals.length} critical` : "",
|
|
2014
|
+
importants.length > 0 ? `${importants.length} important` : "",
|
|
2015
|
+
// Include suggestion counts when they are blocking (all_clear threshold)
|
|
2016
|
+
(config.quality_gate.pass_threshold === "all_clear" && suggestions.length > 0)
|
|
2017
|
+
? `${suggestions.length} suggestion` : "",
|
|
2018
|
+
].filter(Boolean);
|
|
2019
|
+
const findingsSummary = summaryParts.join(", ");
|
|
2020
|
+
logExecution(statusPath, "Quality gate failed",
|
|
2021
|
+
`${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). ` +
|
|
2022
|
+
`Blocking findings: ${findingsSummary || "none extracted"}. ` +
|
|
2023
|
+
`Summary: ${lastVerdict.summary}`);
|
|
2024
|
+
} else {
|
|
2025
|
+
logExecution(statusPath, "Quality gate failed", `Task did not pass after ${reviewCycle} review cycle(s)`);
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
state.phase = "error";
|
|
2029
|
+
updateStatusField(statusPath, "Status", "❌ Quality gate failed");
|
|
2030
|
+
ctx.ui.notify(`❌ Quality gate failed after ${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). .DONE not created.`, "error");
|
|
2031
|
+
updateWidgets();
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
} else {
|
|
2035
|
+
// ── Quality Gate Disabled (default) ──────────────────────
|
|
2036
|
+
// Unchanged behavior — create .DONE immediately.
|
|
2037
|
+
const donePath = join(task.taskFolder, ".DONE");
|
|
2038
|
+
writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
|
|
2039
|
+
updateStatusField(statusPath, "Status", "✅ Complete");
|
|
2040
|
+
logExecution(statusPath, "Task complete", ".DONE created");
|
|
2041
|
+
}
|
|
1411
2042
|
|
|
1412
2043
|
// Auto-archive: move task folder to tasks/archive/.
|
|
1413
2044
|
// In orchestrated runs, do NOT archive here — the orchestrator polls
|
|
@@ -1591,6 +2222,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1591
2222
|
state.workerContextPct = 0;
|
|
1592
2223
|
state.workerLastTool = "";
|
|
1593
2224
|
state.workerToolCount = 0;
|
|
2225
|
+
state.workerRetryActive = false;
|
|
2226
|
+
state.workerRetryCount = 0;
|
|
2227
|
+
state.workerLastRetryError = "";
|
|
1594
2228
|
updateWidgets();
|
|
1595
2229
|
|
|
1596
2230
|
const startTime = Date.now();
|
|
@@ -1604,12 +2238,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1604
2238
|
let kill: () => void;
|
|
1605
2239
|
let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
|
|
1606
2240
|
let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
|
|
2241
|
+
// Track why the session was killed for exit classification.
|
|
2242
|
+
// "timer" = wall-clock timeout, "context" = context % limit, "user" = manual kill.
|
|
2243
|
+
let killReason: "timer" | "context" | "user" | null = null;
|
|
2244
|
+
// Exit summary path — set only in tmux mode (rpc-wrapper produces this file).
|
|
2245
|
+
let exitSummaryPath: string | null = null;
|
|
1607
2246
|
|
|
1608
2247
|
if (spawnMode === "tmux") {
|
|
1609
2248
|
// ── TMUX mode ────────────────────────────────────────
|
|
1610
|
-
//
|
|
1611
|
-
//
|
|
2249
|
+
// Sidecar JSONL provides telemetry parity: tokens, cost, context%,
|
|
2250
|
+
// tool calls, and retry events — same signals as subprocess mode.
|
|
2251
|
+
// Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
|
|
1612
2252
|
const sessionName = `${getTmuxPrefix()}-worker`;
|
|
2253
|
+
const contextWindow = config.context.worker_context_window;
|
|
2254
|
+
const warnPct = config.context.warn_percent;
|
|
2255
|
+
const killPct = config.context.kill_percent;
|
|
2256
|
+
|
|
1613
2257
|
const spawned = spawnAgentTmux({
|
|
1614
2258
|
sessionName,
|
|
1615
2259
|
cwd: ctx.cwd,
|
|
@@ -1618,12 +2262,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
1618
2262
|
model,
|
|
1619
2263
|
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
1620
2264
|
thinking: config.worker.thinking || "off",
|
|
2265
|
+
taskId: task.taskId,
|
|
2266
|
+
onTelemetry: (delta) => {
|
|
2267
|
+
// Accumulate tokens and cost (same as subprocess onTokenUpdate)
|
|
2268
|
+
state.workerInputTokens += delta.inputTokens;
|
|
2269
|
+
state.workerOutputTokens += delta.outputTokens;
|
|
2270
|
+
state.workerCacheReadTokens += delta.cacheReadTokens;
|
|
2271
|
+
state.workerCacheWriteTokens += delta.cacheWriteTokens;
|
|
2272
|
+
state.workerCostUsd += delta.cost;
|
|
2273
|
+
|
|
2274
|
+
// Tool tracking (same as subprocess onToolCall)
|
|
2275
|
+
state.workerToolCount += delta.toolCalls;
|
|
2276
|
+
if (delta.lastTool) {
|
|
2277
|
+
state.workerLastTool = delta.lastTool;
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
// Retry tracking
|
|
2281
|
+
state.workerRetryCount += delta.retriesStarted;
|
|
2282
|
+
state.workerRetryActive = delta.retryActive;
|
|
2283
|
+
if (delta.lastRetryError) {
|
|
2284
|
+
state.workerLastRetryError = delta.lastRetryError;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// Context % (same as subprocess onContextPct)
|
|
2288
|
+
// totalTokens is cumulative from the most recent message_end
|
|
2289
|
+
if (delta.latestTotalTokens > 0 && contextWindow > 0) {
|
|
2290
|
+
const pct = (delta.latestTotalTokens / contextWindow) * 100;
|
|
2291
|
+
state.workerContextPct = pct;
|
|
2292
|
+
if (pct >= warnPct) {
|
|
2293
|
+
writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
|
|
2294
|
+
}
|
|
2295
|
+
if (pct >= killPct && state.workerStatus === "running") {
|
|
2296
|
+
console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
|
|
2297
|
+
killReason = "context";
|
|
2298
|
+
spawned.kill();
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
updateWidgets();
|
|
2303
|
+
},
|
|
1621
2304
|
});
|
|
1622
2305
|
promise = spawned.promise;
|
|
1623
2306
|
kill = spawned.kill;
|
|
2307
|
+
exitSummaryPath = spawned.exitSummaryPath;
|
|
1624
2308
|
|
|
1625
2309
|
// Wall-clock timeout: write wrap-up file at 80% of limit,
|
|
1626
|
-
// hard kill at 100%.
|
|
2310
|
+
// hard kill at 100%. Context-% based wrap-up/kill is also active
|
|
2311
|
+
// via sidecar telemetry (above), providing dual safety nets.
|
|
1627
2312
|
const maxMinutes = getMaxWorkerMinutes(config);
|
|
1628
2313
|
const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
|
|
1629
2314
|
const killMs = maxMinutes * 60_000;
|
|
@@ -1643,6 +2328,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1643
2328
|
wallClockKillTimer = setTimeout(() => {
|
|
1644
2329
|
if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
|
|
1645
2330
|
console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
|
|
2331
|
+
killReason = "timer";
|
|
1646
2332
|
kill();
|
|
1647
2333
|
}
|
|
1648
2334
|
}, killMs);
|
|
@@ -1718,12 +2404,57 @@ export default function (pi: ExtensionAPI) {
|
|
|
1718
2404
|
|
|
1719
2405
|
clearWrapUpSignals();
|
|
1720
2406
|
|
|
1721
|
-
//
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
2407
|
+
// ── Exit Diagnostic (tmux mode only) ─────────────────────
|
|
2408
|
+
// Read the exit summary JSON written by rpc-wrapper.mjs, classify
|
|
2409
|
+
// the exit, and build a structured diagnostic for persistence.
|
|
2410
|
+
// Subprocess mode doesn't produce exit summaries (it uses JSON
|
|
2411
|
+
// event stream directly), so this path is tmux-only.
|
|
2412
|
+
if (spawnMode === "tmux" && exitSummaryPath) {
|
|
2413
|
+
const exitSummary = readExitSummary(exitSummaryPath);
|
|
2414
|
+
const donePath = join(task.taskFolder, ".DONE");
|
|
2415
|
+
const doneFileFound = existsSync(donePath);
|
|
2416
|
+
|
|
2417
|
+
// Determine userKilled: killed is true but not by timer or context
|
|
2418
|
+
const userKilled = result.killed && killReason === null;
|
|
2419
|
+
|
|
2420
|
+
const diagnostic = buildExitDiagnostic({
|
|
2421
|
+
exitSummary,
|
|
2422
|
+
doneFileFound,
|
|
2423
|
+
timerKilled: killReason === "timer",
|
|
2424
|
+
contextKilled: killReason === "context",
|
|
2425
|
+
userKilled,
|
|
2426
|
+
contextPct: state.workerContextPct,
|
|
2427
|
+
durationSec: Math.round(state.workerElapsed / 1000),
|
|
2428
|
+
repoId: process.env.TASKPLANE_REPO_ID || "default",
|
|
2429
|
+
lastKnownStep: state.currentStep || null,
|
|
2430
|
+
lastKnownCheckbox: null, // Not parsed in task-runner; available via STATUS.md
|
|
2431
|
+
partialProgressCommits: 0, // Computed by orchestrator after commit
|
|
2432
|
+
partialProgressBranch: null,
|
|
2433
|
+
});
|
|
2434
|
+
|
|
2435
|
+
// Store diagnostic on state for lane-state sidecar and logging
|
|
2436
|
+
state.workerExitDiagnostic = diagnostic;
|
|
2437
|
+
|
|
2438
|
+
console.error(`[task-runner] exit diagnostic: ${diagnostic.classification}` +
|
|
2439
|
+
(diagnostic.exitCode !== null ? ` (exit ${diagnostic.exitCode})` : "") +
|
|
2440
|
+
(exitSummary ? `` : " (no exit summary)"));
|
|
2441
|
+
|
|
2442
|
+
// Log telemetry file paths for operator visibility (files preserved for dashboard)
|
|
2443
|
+
const sidecarPath = exitSummaryPath.replace(/-exit\.json$/, ".jsonl");
|
|
2444
|
+
console.error(`[task-runner] telemetry files preserved:` +
|
|
2445
|
+
`\n sidecar: ${sidecarPath}` +
|
|
2446
|
+
`\n exit summary: ${exitSummaryPath}`);
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Log with telemetry detail — both subprocess and TMUX now have context%
|
|
2450
|
+
const killedMsg = result.killed
|
|
2451
|
+
? (spawnMode === "tmux"
|
|
2452
|
+
? `killed (${killReason === "context" ? "context limit" : killReason === "timer" ? "wall-clock timeout" : "user"})`
|
|
2453
|
+
: "killed (context limit)")
|
|
2454
|
+
: "";
|
|
2455
|
+
const statusMsg = killedMsg || (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
|
|
1725
2456
|
logExecution(statusPath, `Worker iter ${state.totalIterations}`,
|
|
1726
|
-
`${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s${
|
|
2457
|
+
`${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s, ctx: ${Math.round(state.workerContextPct)}%, tools: ${state.workerToolCount}`);
|
|
1727
2458
|
|
|
1728
2459
|
updateWidgets();
|
|
1729
2460
|
}
|
|
@@ -1783,6 +2514,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1783
2514
|
model: reviewerModel,
|
|
1784
2515
|
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
1785
2516
|
thinking: config.reviewer.thinking || "on",
|
|
2517
|
+
taskId: state.task?.taskId,
|
|
1786
2518
|
});
|
|
1787
2519
|
reviewPromise = spawned.promise;
|
|
1788
2520
|
state.reviewerProc = { kill: spawned.kill };
|
|
@@ -1833,6 +2565,355 @@ export default function (pi: ExtensionAPI) {
|
|
|
1833
2565
|
return verdict;
|
|
1834
2566
|
}
|
|
1835
2567
|
|
|
2568
|
+
// ── Quality Gate ─────────────────────────────────────────────────
|
|
2569
|
+
|
|
2570
|
+
/**
|
|
2571
|
+
* Run a single quality gate review cycle.
|
|
2572
|
+
*
|
|
2573
|
+
* Spawns a review agent with a structured prompt that includes task evidence
|
|
2574
|
+
* (PROMPT.md, STATUS.md, git diff, file list). The agent writes a JSON verdict
|
|
2575
|
+
* to REVIEW_VERDICT.json in the task folder. This function reads/parses that
|
|
2576
|
+
* file and applies verdict rules.
|
|
2577
|
+
*
|
|
2578
|
+
* Fail-open on all error paths:
|
|
2579
|
+
* - Agent crash / non-zero exit → synthetic PASS
|
|
2580
|
+
* - Missing verdict file → synthetic PASS
|
|
2581
|
+
* - Malformed JSON → synthetic PASS
|
|
2582
|
+
*
|
|
2583
|
+
* @param ctx - Extension context
|
|
2584
|
+
* @param cycleNum - Current review cycle number (1-based)
|
|
2585
|
+
* @returns Quality gate result with pass/fail, verdict, and evaluation
|
|
2586
|
+
*/
|
|
2587
|
+
async function doQualityGateReview(ctx: ExtensionContext, cycleNum: number): Promise<QualityGateResult> {
|
|
2588
|
+
if (!state.task || !state.config) {
|
|
2589
|
+
return {
|
|
2590
|
+
passed: true, skipped: true, cyclesUsed: cycleNum,
|
|
2591
|
+
verdict: { verdict: "PASS", confidence: "low", summary: "No task/config — skipped", findings: [], statusReconciliation: [] },
|
|
2592
|
+
evaluation: { pass: true, failReasons: [] },
|
|
2593
|
+
};
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
const task = state.task;
|
|
2597
|
+
const config = state.config;
|
|
2598
|
+
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2599
|
+
|
|
2600
|
+
// Delete any previous verdict file so we can detect agent failure
|
|
2601
|
+
const verdictPath = join(task.taskFolder, VERDICT_FILENAME);
|
|
2602
|
+
try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch { /* ignore */ }
|
|
2603
|
+
|
|
2604
|
+
// Build the quality gate context and prompt
|
|
2605
|
+
const gateContext: QualityGateContext = {
|
|
2606
|
+
taskFolder: task.taskFolder,
|
|
2607
|
+
promptPath: task.promptPath,
|
|
2608
|
+
taskId: task.taskId,
|
|
2609
|
+
projectName: config.project.name,
|
|
2610
|
+
passThreshold: config.quality_gate.pass_threshold,
|
|
2611
|
+
};
|
|
2612
|
+
|
|
2613
|
+
const prompt = generateQualityGatePrompt(gateContext, ctx.cwd);
|
|
2614
|
+
|
|
2615
|
+
// Determine review model with fallback chain:
|
|
2616
|
+
// quality_gate.review_model → reviewer.model → agent def → default
|
|
2617
|
+
const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
|
|
2618
|
+
const reviewModel = config.quality_gate.review_model
|
|
2619
|
+
|| config.reviewer.model
|
|
2620
|
+
|| reviewerDef?.model
|
|
2621
|
+
|| "openai/gpt-5.3-codex";
|
|
2622
|
+
|
|
2623
|
+
const reviewerPrompt = reviewerDef?.systemPrompt
|
|
2624
|
+
|| "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
|
|
2625
|
+
const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2626
|
+
|
|
2627
|
+
// Update UI state
|
|
2628
|
+
state.reviewerStatus = "running";
|
|
2629
|
+
state.reviewerType = `quality-gate cycle ${cycleNum}`;
|
|
2630
|
+
state.reviewerElapsed = 0;
|
|
2631
|
+
state.reviewerLastTool = "";
|
|
2632
|
+
updateWidgets();
|
|
2633
|
+
|
|
2634
|
+
const startTime = Date.now();
|
|
2635
|
+
state.reviewerTimer = setInterval(() => {
|
|
2636
|
+
state.reviewerElapsed = Date.now() - startTime;
|
|
2637
|
+
updateWidgets();
|
|
2638
|
+
}, 1000);
|
|
2639
|
+
|
|
2640
|
+
logExecution(statusPath, `Quality gate`, `Starting review cycle ${cycleNum}`);
|
|
2641
|
+
|
|
2642
|
+
const spawnMode = getSpawnMode(config);
|
|
2643
|
+
let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
2644
|
+
|
|
2645
|
+
try {
|
|
2646
|
+
if (spawnMode === "tmux") {
|
|
2647
|
+
const sessionName = `${getTmuxPrefix()}-qg-reviewer`;
|
|
2648
|
+
const spawned = spawnAgentTmux({
|
|
2649
|
+
sessionName,
|
|
2650
|
+
cwd: ctx.cwd,
|
|
2651
|
+
systemPrompt,
|
|
2652
|
+
prompt,
|
|
2653
|
+
model: reviewModel,
|
|
2654
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2655
|
+
thinking: config.reviewer.thinking || "on",
|
|
2656
|
+
taskId: task.taskId,
|
|
2657
|
+
});
|
|
2658
|
+
reviewPromise = spawned.promise;
|
|
2659
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
2660
|
+
} else {
|
|
2661
|
+
const spawned = spawnAgent({
|
|
2662
|
+
model: reviewModel,
|
|
2663
|
+
tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
|
|
2664
|
+
thinking: config.reviewer.thinking || "on",
|
|
2665
|
+
systemPrompt,
|
|
2666
|
+
prompt,
|
|
2667
|
+
onToolCall: (toolName, args) => {
|
|
2668
|
+
const path = args?.path || args?.command || "";
|
|
2669
|
+
const shortPath = typeof path === "string" && path.length > 40
|
|
2670
|
+
? "..." + path.slice(-37) : path;
|
|
2671
|
+
state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2672
|
+
updateWidgets();
|
|
2673
|
+
},
|
|
2674
|
+
});
|
|
2675
|
+
reviewPromise = spawned.promise;
|
|
2676
|
+
state.reviewerProc = { kill: spawned.kill };
|
|
2677
|
+
}
|
|
2678
|
+
|
|
2679
|
+
const result = await reviewPromise;
|
|
2680
|
+
|
|
2681
|
+
clearInterval(state.reviewerTimer);
|
|
2682
|
+
state.reviewerElapsed = Date.now() - startTime;
|
|
2683
|
+
state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
|
|
2684
|
+
state.reviewerProc = null;
|
|
2685
|
+
updateWidgets();
|
|
2686
|
+
|
|
2687
|
+
// If agent exited non-zero, fail-open
|
|
2688
|
+
if (result.exitCode !== 0) {
|
|
2689
|
+
logExecution(statusPath, `Quality gate`, `Review agent exited with code ${result.exitCode} — fail-open → PASS`);
|
|
2690
|
+
ctx.ui.notify(`Quality gate: review agent error (exit ${result.exitCode}) — fail-open PASS`, "warning");
|
|
2691
|
+
return {
|
|
2692
|
+
passed: true, skipped: false, cyclesUsed: cycleNum,
|
|
2693
|
+
verdict: { verdict: "PASS", confidence: "low", summary: `Review agent exited with code ${result.exitCode} — fail-open`, findings: [], statusReconciliation: [] },
|
|
2694
|
+
evaluation: { pass: true, failReasons: [] },
|
|
2695
|
+
};
|
|
2696
|
+
}
|
|
2697
|
+
} catch (err: any) {
|
|
2698
|
+
// Agent crash — fail-open
|
|
2699
|
+
clearInterval(state.reviewerTimer);
|
|
2700
|
+
state.reviewerStatus = "error";
|
|
2701
|
+
state.reviewerProc = null;
|
|
2702
|
+
updateWidgets();
|
|
2703
|
+
|
|
2704
|
+
logExecution(statusPath, `Quality gate`, `Review agent crashed: ${err?.message || err} — fail-open → PASS`);
|
|
2705
|
+
ctx.ui.notify(`Quality gate: review agent crashed — fail-open PASS`, "warning");
|
|
2706
|
+
return {
|
|
2707
|
+
passed: true, skipped: false, cyclesUsed: cycleNum,
|
|
2708
|
+
verdict: { verdict: "PASS", confidence: "low", summary: `Review agent crashed — fail-open`, findings: [], statusReconciliation: [] },
|
|
2709
|
+
evaluation: { pass: true, failReasons: [] },
|
|
2710
|
+
};
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// Read and evaluate the verdict file
|
|
2714
|
+
const { verdict, evaluation } = readAndEvaluateVerdict(
|
|
2715
|
+
task.taskFolder,
|
|
2716
|
+
config.quality_gate.pass_threshold,
|
|
2717
|
+
);
|
|
2718
|
+
|
|
2719
|
+
// Apply STATUS.md reconciliation if verdict has entries
|
|
2720
|
+
if (verdict.statusReconciliation.length > 0) {
|
|
2721
|
+
const reconResult = applyStatusReconciliation(statusPath, verdict.statusReconciliation);
|
|
2722
|
+
if (reconResult.changed > 0 || reconResult.unmatched > 0) {
|
|
2723
|
+
logExecution(statusPath, `Reconciliation`,
|
|
2724
|
+
`${reconResult.changed} changed, ${reconResult.alreadyCorrect} already correct, ${reconResult.unmatched} unmatched`);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
const passed = evaluation.pass;
|
|
2729
|
+
const verdictLabel = passed ? "PASS" : "NEEDS_FIXES";
|
|
2730
|
+
const findingsSummary = verdict.findings.length > 0
|
|
2731
|
+
? ` (${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)`
|
|
2732
|
+
: "";
|
|
2733
|
+
|
|
2734
|
+
logExecution(statusPath, `Quality gate`, `Cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`);
|
|
2735
|
+
ctx.ui.notify(
|
|
2736
|
+
`Quality gate cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`,
|
|
2737
|
+
passed ? "success" : "warning",
|
|
2738
|
+
);
|
|
2739
|
+
|
|
2740
|
+
return {
|
|
2741
|
+
passed,
|
|
2742
|
+
skipped: false,
|
|
2743
|
+
cyclesUsed: cycleNum,
|
|
2744
|
+
verdict,
|
|
2745
|
+
evaluation,
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
// ── Quality Gate Fix Agent ───────────────────────────────────────
|
|
2750
|
+
|
|
2751
|
+
/** Default wall-clock timeout for fix agents (15 minutes). */
|
|
2752
|
+
const FIX_AGENT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* Spawn a fix agent to address quality gate findings.
|
|
2756
|
+
*
|
|
2757
|
+
* Reuses the worker spawn pattern (subprocess or tmux). The fix agent
|
|
2758
|
+
* receives REVIEW_FEEDBACK.md content and makes targeted code fixes.
|
|
2759
|
+
*
|
|
2760
|
+
* Handles abnormal exits deterministically:
|
|
2761
|
+
* - Agent crash → returns non-zero exit code (caller consumes fix budget)
|
|
2762
|
+
* - Agent timeout → kills agent, returns non-zero (caller consumes fix budget)
|
|
2763
|
+
* - Agent exits normally but makes no changes → still returns 0 (re-review will catch)
|
|
2764
|
+
*
|
|
2765
|
+
* Wall-clock timeout: 15 minutes (or getMaxWorkerMinutes if configured).
|
|
2766
|
+
* This prevents a hung fix agent from stalling the task permanently.
|
|
2767
|
+
*
|
|
2768
|
+
* @param ctx - Extension context
|
|
2769
|
+
* @param fixPrompt - Prompt for the fix agent (includes REVIEW_FEEDBACK.md)
|
|
2770
|
+
* @param fixCycleNum - Current fix cycle number (1-based)
|
|
2771
|
+
* @returns Exit code, elapsed time, and whether timeout was hit
|
|
2772
|
+
*/
|
|
2773
|
+
async function doQualityGateFixAgent(
|
|
2774
|
+
ctx: ExtensionContext,
|
|
2775
|
+
fixPrompt: string,
|
|
2776
|
+
fixCycleNum: number,
|
|
2777
|
+
): Promise<{ exitCode: number; elapsed: number; timedOut: boolean }> {
|
|
2778
|
+
if (!state.task || !state.config) {
|
|
2779
|
+
return { exitCode: 1, elapsed: 0, timedOut: false };
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
const task = state.task;
|
|
2783
|
+
const config = state.config;
|
|
2784
|
+
const statusPath = join(task.taskFolder, "STATUS.md");
|
|
2785
|
+
|
|
2786
|
+
// Use worker model and tools for fix agent (it needs to edit code)
|
|
2787
|
+
const workerDef = loadAgentDef(ctx.cwd, "task-worker");
|
|
2788
|
+
const fixModel = config.worker.model
|
|
2789
|
+
|| workerDef?.model
|
|
2790
|
+
|| "anthropic/claude-sonnet-4-20250514";
|
|
2791
|
+
|
|
2792
|
+
const basePrompt = workerDef?.systemPrompt
|
|
2793
|
+
|| "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
|
|
2794
|
+
const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
|
|
2795
|
+
|
|
2796
|
+
// Wall-clock timeout: use half of worker limit (fix agents should be quick),
|
|
2797
|
+
// with a floor of 15 minutes.
|
|
2798
|
+
const workerMinutes = getMaxWorkerMinutes(config);
|
|
2799
|
+
const timeoutMs = Math.max(FIX_AGENT_TIMEOUT_MS, Math.floor(workerMinutes / 2) * 60 * 1000);
|
|
2800
|
+
|
|
2801
|
+
// Update UI state
|
|
2802
|
+
state.workerStatus = "running";
|
|
2803
|
+
state.workerElapsed = 0;
|
|
2804
|
+
state.workerContextPct = 0;
|
|
2805
|
+
state.workerLastTool = "";
|
|
2806
|
+
state.workerToolCount = 0;
|
|
2807
|
+
state.workerRetryActive = false;
|
|
2808
|
+
state.workerRetryCount = 0;
|
|
2809
|
+
state.workerLastRetryError = "";
|
|
2810
|
+
updateWidgets();
|
|
2811
|
+
|
|
2812
|
+
const startTime = Date.now();
|
|
2813
|
+
state.workerTimer = setInterval(() => {
|
|
2814
|
+
state.workerElapsed = Date.now() - startTime;
|
|
2815
|
+
updateWidgets();
|
|
2816
|
+
}, 1000);
|
|
2817
|
+
|
|
2818
|
+
logExecution(statusPath, "Quality gate", `Starting fix agent (cycle ${fixCycleNum}, timeout: ${Math.round(timeoutMs / 60000)}min)`);
|
|
2819
|
+
|
|
2820
|
+
const spawnMode = getSpawnMode(config);
|
|
2821
|
+
let fixPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
|
|
2822
|
+
let killFn: (() => void) | null = null;
|
|
2823
|
+
let tmuxExitSummaryPath: string | null = null;
|
|
2824
|
+
|
|
2825
|
+
try {
|
|
2826
|
+
if (spawnMode === "tmux") {
|
|
2827
|
+
const sessionName = `${getTmuxPrefix()}-qg-fix`;
|
|
2828
|
+
const spawned = spawnAgentTmux({
|
|
2829
|
+
sessionName,
|
|
2830
|
+
cwd: ctx.cwd,
|
|
2831
|
+
systemPrompt,
|
|
2832
|
+
prompt: fixPrompt,
|
|
2833
|
+
model: fixModel,
|
|
2834
|
+
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2835
|
+
thinking: config.worker.thinking || "off",
|
|
2836
|
+
taskId: task.taskId,
|
|
2837
|
+
});
|
|
2838
|
+
fixPromise = spawned.promise;
|
|
2839
|
+
killFn = spawned.kill;
|
|
2840
|
+
tmuxExitSummaryPath = spawned.exitSummaryPath;
|
|
2841
|
+
state.workerProc = { kill: spawned.kill };
|
|
2842
|
+
} else {
|
|
2843
|
+
const spawned = spawnAgent({
|
|
2844
|
+
model: fixModel,
|
|
2845
|
+
tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
|
|
2846
|
+
thinking: config.worker.thinking || "off",
|
|
2847
|
+
systemPrompt,
|
|
2848
|
+
prompt: fixPrompt,
|
|
2849
|
+
onToolCall: (toolName, args) => {
|
|
2850
|
+
state.workerToolCount++;
|
|
2851
|
+
const path = args?.path || args?.command || "";
|
|
2852
|
+
const shortPath = typeof path === "string" && path.length > 80
|
|
2853
|
+
? "..." + path.slice(-77) : path;
|
|
2854
|
+
state.workerLastTool = `${toolName} ${shortPath}`.trim();
|
|
2855
|
+
updateWidgets();
|
|
2856
|
+
},
|
|
2857
|
+
});
|
|
2858
|
+
fixPromise = spawned.promise;
|
|
2859
|
+
killFn = spawned.kill;
|
|
2860
|
+
state.workerProc = { kill: spawned.kill };
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Race the agent against a wall-clock timeout
|
|
2864
|
+
let timedOut = false;
|
|
2865
|
+
const timeoutPromise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
|
|
2866
|
+
const timer = setTimeout(() => {
|
|
2867
|
+
timedOut = true;
|
|
2868
|
+
logExecution(statusPath, "Quality gate", `Fix agent wall-clock timeout (${Math.round(timeoutMs / 60000)}min) — killing agent`);
|
|
2869
|
+
if (killFn) killFn();
|
|
2870
|
+
// Resolve after a brief delay to allow kill to take effect
|
|
2871
|
+
setTimeout(() => {
|
|
2872
|
+
resolve({ output: "timeout", exitCode: 1, elapsed: Date.now() - startTime, killed: true });
|
|
2873
|
+
}, 5000);
|
|
2874
|
+
}, timeoutMs);
|
|
2875
|
+
// Clean up timer if agent finishes first
|
|
2876
|
+
fixPromise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
|
|
2877
|
+
});
|
|
2878
|
+
|
|
2879
|
+
const result = await Promise.race([fixPromise, timeoutPromise]);
|
|
2880
|
+
|
|
2881
|
+
// ── TMUX exit classification ─────────────────────────
|
|
2882
|
+
// spawnAgentTmux always reports exitCode: 0 on session end.
|
|
2883
|
+
// Read the exit summary written by rpc-wrapper to get the
|
|
2884
|
+
// real Pi process exit code (same pattern as worker flow).
|
|
2885
|
+
let effectiveExitCode = result.exitCode;
|
|
2886
|
+
if (spawnMode === "tmux" && tmuxExitSummaryPath && !timedOut) {
|
|
2887
|
+
const exitSummary = readExitSummary(tmuxExitSummaryPath);
|
|
2888
|
+
if (exitSummary && typeof exitSummary.exitCode === "number") {
|
|
2889
|
+
effectiveExitCode = exitSummary.exitCode;
|
|
2890
|
+
if (effectiveExitCode !== 0) {
|
|
2891
|
+
console.error(`[task-runner] qg-fix: tmux exit summary reports exit code ${effectiveExitCode}`);
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
// If no exit summary exists, keep the tmux-reported code (0).
|
|
2895
|
+
// This is fail-open: missing exit summary ≠ crash.
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
clearInterval(state.workerTimer);
|
|
2899
|
+
state.workerElapsed = Date.now() - startTime;
|
|
2900
|
+
state.workerStatus = (effectiveExitCode === 0 && !timedOut) ? "done" : "error";
|
|
2901
|
+
state.workerProc = null;
|
|
2902
|
+
updateWidgets();
|
|
2903
|
+
|
|
2904
|
+
return { exitCode: timedOut ? 1 : effectiveExitCode, elapsed: Date.now() - startTime, timedOut };
|
|
2905
|
+
} catch (err: any) {
|
|
2906
|
+
// Fix agent crashed — return non-zero to consume fix budget
|
|
2907
|
+
clearInterval(state.workerTimer);
|
|
2908
|
+
state.workerStatus = "error";
|
|
2909
|
+
state.workerProc = null;
|
|
2910
|
+
updateWidgets();
|
|
2911
|
+
|
|
2912
|
+
logExecution(statusPath, "Quality gate", `Fix agent crashed: ${err?.message || err} — fix cycle ${fixCycleNum} consumed`);
|
|
2913
|
+
return { exitCode: 1, elapsed: Date.now() - startTime, timedOut: false };
|
|
2914
|
+
}
|
|
2915
|
+
}
|
|
2916
|
+
|
|
1836
2917
|
// ── Commands ─────────────────────────────────────────────────────
|
|
1837
2918
|
|
|
1838
2919
|
// ── Shared Task Initialization ───────────────────────────────────
|