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