taskplane 0.30.5 → 0.30.6
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/extensions/taskplane/agent-bridge-extension.ts +66 -8
- package/extensions/taskplane/agent-host.ts +170 -9
- package/extensions/taskplane/config-loader.ts +9 -0
- package/extensions/taskplane/config-schema.ts +47 -1
- package/extensions/taskplane/diagnostic-reports.ts +109 -6
- package/extensions/taskplane/diagnostics.ts +3 -0
- package/extensions/taskplane/engine-identity.ts +401 -0
- package/extensions/taskplane/engine-worker.ts +59 -3
- package/extensions/taskplane/engine.ts +137 -17
- package/extensions/taskplane/execution.ts +89 -8
- package/extensions/taskplane/extension.ts +1130 -96
- package/extensions/taskplane/git.ts +74 -0
- package/extensions/taskplane/lane-runner.ts +971 -95
- package/extensions/taskplane/process-registry.ts +7 -2
- package/extensions/taskplane/resume.ts +559 -23
- package/extensions/taskplane/review-analysis.ts +450 -0
- package/extensions/taskplane/segment-recovery.ts +192 -0
- package/extensions/taskplane/supervisor-primer.md +182 -2
- package/extensions/taskplane/supervisor.ts +225 -17
- package/extensions/taskplane/types.ts +142 -4
- package/extensions/taskplane/worktree.ts +143 -2
- package/package.json +1 -1
|
@@ -23,13 +23,22 @@
|
|
|
23
23
|
|
|
24
24
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
25
25
|
import { Type } from "@mariozechner/pi-ai";
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
writeFileSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
readdirSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
mkdirSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
} from "fs";
|
|
27
35
|
import { join, dirname } from "path";
|
|
28
36
|
import { spawn as nodeSpawn } from "child_process";
|
|
29
37
|
import { resolvePiCliPath, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
|
|
30
38
|
import { loadPiSettingsPackages, filterExcludedExtensions } from "./settings-loader.ts";
|
|
31
39
|
import { randomBytes } from "crypto";
|
|
32
40
|
import { buildExpansionRequestId, type SegmentExpansionRequest } from "./types.ts";
|
|
41
|
+
import { latestReviewFilesPerGate, parseReviewVerdict } from "./review-analysis.ts";
|
|
33
42
|
|
|
34
43
|
/**
|
|
35
44
|
* Resolve the outbox directory from environment variables.
|
|
@@ -174,6 +183,28 @@ function writeSegmentExpansionRequest(request: SegmentExpansionRequest): string
|
|
|
174
183
|
* @param stepNum the step number being reviewed
|
|
175
184
|
* @returns true iff the step is marked Complete in STATUS.md
|
|
176
185
|
*/
|
|
186
|
+
/**
|
|
187
|
+
* #629: does the LATEST review file for `{type}-step{N}` read REVISE/RETHINK?
|
|
188
|
+
* Used to exempt a remediation re-review from the TP-186 complete-step guard.
|
|
189
|
+
* Fail-closed: unreadable dir/file → false (guard stays in force).
|
|
190
|
+
*/
|
|
191
|
+
export function hasOutstandingNonApproveReview(
|
|
192
|
+
reviewsDir: string,
|
|
193
|
+
reviewType: string,
|
|
194
|
+
stepNum: number,
|
|
195
|
+
): boolean {
|
|
196
|
+
try {
|
|
197
|
+
if (!existsSync(reviewsDir)) return false;
|
|
198
|
+
const latest = latestReviewFilesPerGate(readdirSync(reviewsDir));
|
|
199
|
+
const filename = latest.get(`${reviewType.toLowerCase()}-step${stepNum}`);
|
|
200
|
+
if (!filename) return false;
|
|
201
|
+
const verdict = parseReviewVerdict(readFileSync(join(reviewsDir, filename), "utf-8"));
|
|
202
|
+
return verdict === "REVISE" || verdict === "RETHINK";
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
177
208
|
export function isStepMarkedComplete(statusPath: string, stepNum: number): boolean {
|
|
178
209
|
let content: string;
|
|
179
210
|
try {
|
|
@@ -822,7 +853,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
822
853
|
// violated the Order of Operations contract; the only safe path
|
|
823
854
|
// is to revert STATUS first, then re-call review_step. Plan
|
|
824
855
|
// reviews are exempt because they fire BEFORE implementation.
|
|
825
|
-
|
|
856
|
+
//
|
|
857
|
+
// #629 exemption: when the step is Complete but the LATEST review for
|
|
858
|
+
// this gate is REVISE/RETHINK, the runtime's finalize gate has refused
|
|
859
|
+
// (or will refuse) the task and spawned a remediation iteration whose
|
|
860
|
+
// whole purpose is to re-run this review. That state IS the anomaly
|
|
861
|
+
// being repaired — a re-review is the correct action, not an order
|
|
862
|
+
// violation. Refusing here would make the documented remedy unreachable.
|
|
863
|
+
if (
|
|
864
|
+
reviewType !== "plan" &&
|
|
865
|
+
isStepMarkedComplete(statusPath, stepNum) &&
|
|
866
|
+
!hasOutstandingNonApproveReview(reviewsDir, reviewType, stepNum)
|
|
867
|
+
) {
|
|
826
868
|
const taskIdMatch = statusPath.match(/[\\/]([A-Z]{2,}-\d+)[^\\/]*[\\/]STATUS\.md$/);
|
|
827
869
|
const taskId = taskIdMatch ? taskIdMatch[1] : "<TASK-ID>";
|
|
828
870
|
const refusal = [
|
|
@@ -944,13 +986,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
944
986
|
// Read review output and extract verdict
|
|
945
987
|
if (existsSync(outputPath)) {
|
|
946
988
|
const reviewContent = readFileSync(outputPath, "utf-8");
|
|
947
|
-
|
|
948
|
-
|
|
989
|
+
// #624 (severity upgrade): robust, FAIL-CLOSED verdict extraction. The
|
|
990
|
+
// old regex ('###?\s*Verdict[:\s]*…') missed common reviewer format
|
|
991
|
+
// variants, and its fallback checked the substring "approve" FIRST — so
|
|
992
|
+
// a REVISE review whose body merely contained "approve" (e.g. "cannot
|
|
993
|
+
// approve") was returned to the worker as APPROVE. Workers then marked
|
|
994
|
+
// steps complete and advanced past unaddressed findings. The review
|
|
995
|
+
// gate must NEVER fail open: APPROVE is only ever taken from an
|
|
996
|
+
// explicit Verdict line (parseReviewVerdict handles heading/bold/plain/
|
|
997
|
+
// dash/bracket variants and skips the template placeholder). The body
|
|
998
|
+
// fallback below may only produce fail-closed guesses (REVISE/RETHINK).
|
|
999
|
+
const parsedVerdict = parseReviewVerdict(reviewContent);
|
|
1000
|
+
let verdict: string = parsedVerdict ?? "UNKNOWN";
|
|
949
1001
|
if (verdict === "UNKNOWN") {
|
|
950
1002
|
const lower = reviewContent.toLowerCase();
|
|
951
|
-
if (lower
|
|
952
|
-
else if (
|
|
953
|
-
|
|
1003
|
+
if (/\brevise\b/.test(lower) || lower.includes("changes requested")) verdict = "REVISE";
|
|
1004
|
+
else if (/\brethink\b/.test(lower)) verdict = "RETHINK";
|
|
1005
|
+
// NO approve fallback — an approval must be explicit.
|
|
954
1006
|
}
|
|
955
1007
|
|
|
956
1008
|
// Log review in STATUS.md execution log
|
|
@@ -986,7 +1038,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
986
1038
|
} else {
|
|
987
1039
|
return {
|
|
988
1040
|
content: [
|
|
989
|
-
{
|
|
1041
|
+
{
|
|
1042
|
+
type: "text" as const,
|
|
1043
|
+
text:
|
|
1044
|
+
`Review complete (verdict unclear). Read ${reviewFile} and follow its ` +
|
|
1045
|
+
`Verdict line — do NOT treat this as an approval or mark the step complete ` +
|
|
1046
|
+
`without an explicit APPROVE.`,
|
|
1047
|
+
},
|
|
990
1048
|
],
|
|
991
1049
|
details: undefined,
|
|
992
1050
|
};
|
|
@@ -40,6 +40,7 @@ import type {
|
|
|
40
40
|
RuntimeAgentEventType,
|
|
41
41
|
RuntimeAgentManifest,
|
|
42
42
|
PacketPaths,
|
|
43
|
+
ReviewDisposition,
|
|
43
44
|
} from "./types.ts";
|
|
44
45
|
|
|
45
46
|
import {
|
|
@@ -144,6 +145,65 @@ export function buildWorkerToolsAllowlist(userTools: string | undefined | null):
|
|
|
144
145
|
return Array.from(merged).join(",");
|
|
145
146
|
}
|
|
146
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Normalize the freeform text a `review_step` tool call returns into a
|
|
150
|
+
* {@link ReviewDisposition}. The tool returns clean leading tokens
|
|
151
|
+
* (`APPROVE`, `REVISE: …`, `RETHINK — …`, `UNAVAILABLE — …`, `REFUSED: …`),
|
|
152
|
+
* but this parser is defensive: it matches a leading verdict token
|
|
153
|
+
* case-insensitively and falls back to a substring scan, returning `UNKNOWN`
|
|
154
|
+
* when nothing recognizable is present.
|
|
155
|
+
*
|
|
156
|
+
* Pure and side-effect-free so it can be unit-tested directly.
|
|
157
|
+
*
|
|
158
|
+
* @since review-boundary notifications
|
|
159
|
+
*/
|
|
160
|
+
/**
|
|
161
|
+
* Extract the review file path from a review_step tool return. The tool appends
|
|
162
|
+
* "Full review: .reviews/R{NNN}-{type}-step{N}.md" (REVISE) or
|
|
163
|
+
* "See .reviews/R{NNN}-{type}-step{N}.md" (RETHINK). Returns undefined when no
|
|
164
|
+
* such reference is present (e.g. APPROVE returns just "APPROVE").
|
|
165
|
+
*/
|
|
166
|
+
export function extractReviewPath(resultText: string | undefined | null): string | undefined {
|
|
167
|
+
if (!resultText || typeof resultText !== "string") return undefined;
|
|
168
|
+
const m = resultText.match(/(?:Full review:|See)\s+(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
|
|
169
|
+
if (m) return m[1];
|
|
170
|
+
const m2 = resultText.match(/(\S*R\d+-[a-z]+-step\d+\.md)\b/i);
|
|
171
|
+
return m2 ? m2[1] : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function normalizeReviewDisposition(
|
|
175
|
+
resultText: string | undefined | null,
|
|
176
|
+
): ReviewDisposition {
|
|
177
|
+
if (!resultText || typeof resultText !== "string") return "UNKNOWN";
|
|
178
|
+
const text = resultText.trim();
|
|
179
|
+
if (text.length === 0) return "UNKNOWN";
|
|
180
|
+
// Leading-token match (the tool's canonical output shape).
|
|
181
|
+
const lead = text.toUpperCase();
|
|
182
|
+
if (/^APPROVE\b/.test(lead)) return "APPROVE";
|
|
183
|
+
if (/^REVISE\b/.test(lead)) return "REVISE";
|
|
184
|
+
if (/^RETHINK\b/.test(lead)) return "RETHINK";
|
|
185
|
+
if (/^REFUSED\b/.test(lead)) return "REFUSED";
|
|
186
|
+
if (/^UNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
|
|
187
|
+
// Defensive fallback: scan for the token anywhere, most-specific first, using
|
|
188
|
+
// word boundaries so substrings inside other words don't false-match.
|
|
189
|
+
// REFUSED and UNAVAILABLE are checked before REVISE/RETHINK because their
|
|
190
|
+
// bodies may quote a verdict word.
|
|
191
|
+
if (/\bREFUSED\b/.test(lead)) return "REFUSED";
|
|
192
|
+
if (/\bUNAVAILABLE\b/.test(lead)) return "UNAVAILABLE";
|
|
193
|
+
if (/\bRETHINK\b/.test(lead)) return "RETHINK";
|
|
194
|
+
if (/\bREVISE\b/.test(lead) || /\bCHANGES REQUESTED\b/.test(lead)) return "REVISE";
|
|
195
|
+
// Approve only on a clean, non-negated APPROVE token. Reject explicit
|
|
196
|
+
// negations ("do not approve", "not approved", "disapprove", "unapproved").
|
|
197
|
+
if (
|
|
198
|
+
/\bAPPROVE\b/.test(lead) &&
|
|
199
|
+
!/\bDO NOT APPROVE\b/.test(lead) &&
|
|
200
|
+
!/\bNOT APPROVE\b/.test(lead)
|
|
201
|
+
) {
|
|
202
|
+
return "APPROVE";
|
|
203
|
+
}
|
|
204
|
+
return "UNKNOWN";
|
|
205
|
+
}
|
|
206
|
+
|
|
147
207
|
// ── Conversation Payload Helpers (TP-111) ───────────────────────────────
|
|
148
208
|
|
|
149
209
|
/** Maximum characters for conversation event text payloads. */
|
|
@@ -181,6 +241,37 @@ function extractAssistantText(message: Record<string, unknown>): string {
|
|
|
181
241
|
return "";
|
|
182
242
|
}
|
|
183
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Extract text from a Pi RPC `tool_execution_end` result, which — like message
|
|
246
|
+
* content — may be a plain string, an array of `{type:"text",text}` blocks, or
|
|
247
|
+
* an object with a `content` field (the shape tool handlers return). The old
|
|
248
|
+
* `typeof event.result === "string" ? ... : String(event.output)` extraction
|
|
249
|
+
* silently produced an empty/garbage string for structured results, which made
|
|
250
|
+
* review_step verdicts unparseable and mis-fired "Reviewer unavailable" (#624).
|
|
251
|
+
*/
|
|
252
|
+
export function extractToolResultText(event: { result?: unknown; output?: unknown }): string {
|
|
253
|
+
const fromValue = (val: unknown): string | null => {
|
|
254
|
+
if (typeof val === "string") return val;
|
|
255
|
+
if (Array.isArray(val)) {
|
|
256
|
+
const texts = val
|
|
257
|
+
.filter(
|
|
258
|
+
(b: unknown): b is { type: string; text: string } =>
|
|
259
|
+
typeof b === "object" &&
|
|
260
|
+
b !== null &&
|
|
261
|
+
(b as { type?: unknown }).type === "text" &&
|
|
262
|
+
typeof (b as { text?: unknown }).text === "string",
|
|
263
|
+
)
|
|
264
|
+
.map((b) => b.text);
|
|
265
|
+
if (texts.length > 0) return texts.join("\n");
|
|
266
|
+
}
|
|
267
|
+
if (val && typeof val === "object" && "content" in val) {
|
|
268
|
+
return extractAssistantText(val as Record<string, unknown>);
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
};
|
|
272
|
+
return fromValue(event.result) ?? fromValue(event.output) ?? "";
|
|
273
|
+
}
|
|
274
|
+
|
|
184
275
|
// ── Types ────────────────────────────────────────────────────────────
|
|
185
276
|
|
|
186
277
|
/**
|
|
@@ -250,6 +341,11 @@ export interface AgentHostOptions {
|
|
|
250
341
|
* @since TP-172
|
|
251
342
|
*/
|
|
252
343
|
maxExitInterceptions?: number;
|
|
344
|
+
/**
|
|
345
|
+
* Upper bound (ms) for one onPrematureExit intercept before the host stops
|
|
346
|
+
* waiting. Must exceed the lane's supervisor-reply window; default 120s.
|
|
347
|
+
*/
|
|
348
|
+
exitInterceptSafetyMs?: number;
|
|
253
349
|
}
|
|
254
350
|
|
|
255
351
|
/**
|
|
@@ -396,6 +492,14 @@ export function spawnAgent(
|
|
|
396
492
|
let lastTool = "",
|
|
397
493
|
error: string | null = null;
|
|
398
494
|
let contextUsage: AgentHostResult["contextUsage"] = null;
|
|
495
|
+
/**
|
|
496
|
+
* In-flight review_step boundary state (review-boundary notifications). Set at
|
|
497
|
+
* tool_execution_start so the end event and any crash-abort can carry the
|
|
498
|
+
* step/reviewType identity (tool_execution_end does not include the original
|
|
499
|
+
* args). review_step calls are sequential within a worker (the worker blocks
|
|
500
|
+
* on the verdict), so a single pending slot is sufficient.
|
|
501
|
+
*/
|
|
502
|
+
let pendingReview: { step?: number; reviewType?: string } | null = null;
|
|
399
503
|
let stderrBuffer = "";
|
|
400
504
|
const STDERR_MAX = 2048;
|
|
401
505
|
/** Last assistant message text captured from message_end events (TP-172) */
|
|
@@ -589,7 +693,10 @@ export function spawnAgent(
|
|
|
589
693
|
try {
|
|
590
694
|
appendFileSync(
|
|
591
695
|
opts.steeringPendingPath,
|
|
592
|
-
|
|
696
|
+
// #630: `type` lets the lane-runner tell an acknowledgement (info)
|
|
697
|
+
// from a ruling/instruction (steer) for hold bookkeeping.
|
|
698
|
+
JSON.stringify({ ts: msg.timestamp, content: msg.content, id: msg.id, type: msg.type }) +
|
|
699
|
+
"\n",
|
|
593
700
|
"utf-8",
|
|
594
701
|
);
|
|
595
702
|
} catch {
|
|
@@ -671,6 +778,19 @@ export function spawnAgent(
|
|
|
671
778
|
: exitCode === 0 && agentEnded
|
|
672
779
|
? "agent_exited"
|
|
673
780
|
: "agent_crashed";
|
|
781
|
+
// Review-boundary: if the worker died mid-review, close the dangling
|
|
782
|
+
// review_started so the supervisor doesn't see an orphaned "review
|
|
783
|
+
// starting" with no end. Emitted as review_failed (aborted) before the
|
|
784
|
+
// terminal exit event.
|
|
785
|
+
if (pendingReview) {
|
|
786
|
+
emitEvent("review_failed", {
|
|
787
|
+
step: pendingReview.step,
|
|
788
|
+
reviewType: pendingReview.reviewType,
|
|
789
|
+
disposition: "UNKNOWN",
|
|
790
|
+
summary: `review aborted (${exitEventType})`,
|
|
791
|
+
});
|
|
792
|
+
pendingReview = null;
|
|
793
|
+
}
|
|
674
794
|
emitEvent(exitEventType, { exitCode, signal, durationMs: result.durationMs, timedOut });
|
|
675
795
|
|
|
676
796
|
// Registry integration: update manifest to terminal status
|
|
@@ -781,17 +901,58 @@ export function spawnAgent(
|
|
|
781
901
|
// TP-111: Bounded payload only — no raw args in durable event log
|
|
782
902
|
const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
|
|
783
903
|
emitEvent("tool_call", { tool: toolName, path: toolPath, argsPreview: argPreview });
|
|
904
|
+
// Review-boundary notification: the review START. review_step spawns a
|
|
905
|
+
// reviewer; surfacing this lets the supervisor track review activity
|
|
906
|
+
// live (see lane-runner onEvent bridge + supervisor tailer).
|
|
907
|
+
if (toolName === "review_step") {
|
|
908
|
+
const reviewStep =
|
|
909
|
+
event.args && typeof event.args === "object"
|
|
910
|
+
? (event.args as { step?: unknown }).step
|
|
911
|
+
: undefined;
|
|
912
|
+
const reviewType =
|
|
913
|
+
event.args && typeof event.args === "object"
|
|
914
|
+
? (event.args as { type?: unknown }).type
|
|
915
|
+
: undefined;
|
|
916
|
+
const stepNum = typeof reviewStep === "number" ? reviewStep : undefined;
|
|
917
|
+
const rType = typeof reviewType === "string" ? reviewType : undefined;
|
|
918
|
+
// Remember identity so the end/abort events can carry step+reviewType
|
|
919
|
+
// (tool_execution_end omits args).
|
|
920
|
+
pendingReview = { step: stepNum, reviewType: rType };
|
|
921
|
+
emitEvent("review_requested", { step: stepNum, reviewType: rType });
|
|
922
|
+
}
|
|
784
923
|
break;
|
|
785
924
|
}
|
|
786
925
|
case "tool_execution_end": {
|
|
787
|
-
//
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
: event.output
|
|
792
|
-
? String(event.output).slice(0, 200)
|
|
793
|
-
: "";
|
|
926
|
+
// #624: extract robustly — tool results are often structured content
|
|
927
|
+
// arrays, not plain strings; the old extraction produced "" for those.
|
|
928
|
+
const fullResult = extractToolResultText(event);
|
|
929
|
+
const toolResultSummary = fullResult.slice(0, 200);
|
|
794
930
|
emitEvent("tool_result", { tool: event.toolName, summary: toolResultSummary });
|
|
931
|
+
// Review-boundary notification: the review END. Normalize the reviewer
|
|
932
|
+
// verdict and emit review_completed (APPROVE/REVISE/RETHINK/REFUSED) or
|
|
933
|
+
// review_failed. Only a GENUINE UNAVAILABLE (reviewer subprocess failed
|
|
934
|
+
// / produced no output) is the "broken reviewer" signal. A parse miss
|
|
935
|
+
// (UNKNOWN) must NOT masquerade as a broken reviewer (#624) — emit it as
|
|
936
|
+
// review_completed; lane-runner authoritatively resolves the verdict from
|
|
937
|
+
// the review file on disk.
|
|
938
|
+
if (event.toolName === "review_step") {
|
|
939
|
+
const disposition = normalizeReviewDisposition(fullResult);
|
|
940
|
+
const reviewFields = {
|
|
941
|
+
step: pendingReview?.step,
|
|
942
|
+
reviewType: pendingReview?.reviewType,
|
|
943
|
+
disposition,
|
|
944
|
+
summary: toolResultSummary,
|
|
945
|
+
// review_step embeds the review file path in its REVISE/RETHINK
|
|
946
|
+
// return; surfacing it lets lane-runner read the EXACT review file.
|
|
947
|
+
reviewPath: extractReviewPath(fullResult),
|
|
948
|
+
};
|
|
949
|
+
if (disposition === "UNAVAILABLE") {
|
|
950
|
+
emitEvent("review_failed", reviewFields);
|
|
951
|
+
} else {
|
|
952
|
+
emitEvent("review_completed", reviewFields);
|
|
953
|
+
}
|
|
954
|
+
pendingReview = null;
|
|
955
|
+
}
|
|
795
956
|
break;
|
|
796
957
|
}
|
|
797
958
|
case "auto_retry_start": {
|
|
@@ -841,7 +1002,7 @@ export function spawnAgent(
|
|
|
841
1002
|
const shouldIntercept = opts.onPrematureExit && exitInterceptionCount < maxExitInterceptions;
|
|
842
1003
|
if (shouldIntercept) {
|
|
843
1004
|
exitInterceptionCount++;
|
|
844
|
-
const INTERCEPTION_TIMEOUT_MS = 120_000; //
|
|
1005
|
+
const INTERCEPTION_TIMEOUT_MS = opts.exitInterceptSafetyMs ?? 120_000; // safety timeout (> lane window)
|
|
845
1006
|
// Wrap in Promise.resolve().then() to catch synchronous throws
|
|
846
1007
|
const interceptPromise = Promise.resolve().then(() =>
|
|
847
1008
|
opts.onPrematureExit!(lastAssistantMessage),
|
|
@@ -1268,6 +1268,9 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
|
|
|
1268
1268
|
thinking: config.taskRunner.worker.thinking,
|
|
1269
1269
|
tools: config.taskRunner.worker.tools,
|
|
1270
1270
|
excludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
|
|
1271
|
+
...(typeof config.taskRunner.worker.exitInterceptTimeoutSec === "number"
|
|
1272
|
+
? { exitInterceptTimeoutSec: config.taskRunner.worker.exitInterceptTimeoutSec }
|
|
1273
|
+
: {}),
|
|
1271
1274
|
},
|
|
1272
1275
|
model_fallback: config.taskRunner.modelFallback ?? "inherit",
|
|
1273
1276
|
reviewer: {
|
|
@@ -1275,6 +1278,12 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
|
|
|
1275
1278
|
thinking: config.taskRunner.reviewer.thinking,
|
|
1276
1279
|
tools: config.taskRunner.reviewer.tools,
|
|
1277
1280
|
excludeExtensions: [...(config.taskRunner.reviewer.excludeExtensions ?? [])],
|
|
1281
|
+
...(config.taskRunner.reviewer.severityLabels
|
|
1282
|
+
? { severityLabels: [...config.taskRunner.reviewer.severityLabels] }
|
|
1283
|
+
: {}),
|
|
1284
|
+
...(config.taskRunner.reviewer.spiral
|
|
1285
|
+
? { spiral: { ...config.taskRunner.reviewer.spiral } }
|
|
1286
|
+
: {}),
|
|
1278
1287
|
},
|
|
1279
1288
|
workerExcludeExtensions: [...(config.taskRunner.worker.excludeExtensions ?? [])],
|
|
1280
1289
|
};
|
|
@@ -118,9 +118,34 @@ export interface WorkerConfig {
|
|
|
118
118
|
spawnMode?: "subprocess";
|
|
119
119
|
/** Package specifiers to exclude from extension forwarding for worker agents (exact match). @since TP-180 */
|
|
120
120
|
excludeExtensions?: string[];
|
|
121
|
+
/**
|
|
122
|
+
* How long (seconds) the lane waits for a supervisor reply when it intercepts a
|
|
123
|
+
* worker's premature exit, before letting the session close. Default 60. Raise
|
|
124
|
+
* it when the supervisor is often inside long tool calls (a blocking
|
|
125
|
+
* `--wait` mid-tool-call cannot answer in 60s — penster feedback #3, item 5).
|
|
126
|
+
* Bounded to 15..1800.
|
|
127
|
+
*/
|
|
128
|
+
exitInterceptTimeoutSec?: number;
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
/** Reviewer agent configuration */
|
|
132
|
+
/**
|
|
133
|
+
* Revision-spiral detection tuning (review-boundary supervisor notifications).
|
|
134
|
+
* When a step is reviewed repeatedly without converging, the supervisor is
|
|
135
|
+
* escalated so it can adjudicate / steer the worker. Detection is notify-based,
|
|
136
|
+
* not a hard cap on revisions.
|
|
137
|
+
*/
|
|
138
|
+
export interface ReviewSpiralConfig {
|
|
139
|
+
/** Master switch for spiral escalation (per-boundary notifications are always on). */
|
|
140
|
+
enabled: boolean;
|
|
141
|
+
/** Consecutive non-APPROVE reviews on the SAME step before the first escalation. */
|
|
142
|
+
threshold: number;
|
|
143
|
+
/** Minimum further non-APPROVE reviews between re-escalations (anti-spam spacing). */
|
|
144
|
+
cooldownReviews: number;
|
|
145
|
+
/** Whether UNAVAILABLE reviews count toward the spiral (default false: broken-reviewer signal, not a spiral). */
|
|
146
|
+
treatUnavailableAsNonApprove: boolean;
|
|
147
|
+
}
|
|
148
|
+
|
|
124
149
|
export interface ReviewerConfig {
|
|
125
150
|
/** Reviewer model (empty = inherit session model) */
|
|
126
151
|
model: string;
|
|
@@ -130,6 +155,15 @@ export interface ReviewerConfig {
|
|
|
130
155
|
thinking: string;
|
|
131
156
|
/** Package specifiers to exclude from extension forwarding for reviewer agents (exact match). @since TP-180 */
|
|
132
157
|
excludeExtensions?: string[];
|
|
158
|
+
/**
|
|
159
|
+
* Ordered severity vocabulary (highest severity first) used to bucket review
|
|
160
|
+
* findings for spiral-vs-converging analysis. Core default is generic
|
|
161
|
+
* (critical/important/minor); projects whose reviewer emits a different scheme
|
|
162
|
+
* (e.g. P0/P1/P2) override this. Never hardcode a project vocabulary in core.
|
|
163
|
+
*/
|
|
164
|
+
severityLabels: string[];
|
|
165
|
+
/** Revision-spiral detection tuning. */
|
|
166
|
+
spiral: ReviewSpiralConfig;
|
|
133
167
|
}
|
|
134
168
|
|
|
135
169
|
/** Context/resource limits for task execution */
|
|
@@ -598,7 +632,19 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
|
|
|
598
632
|
// bridge tools are appended at the lane-runner spawn site by
|
|
599
633
|
// `buildWorkerToolsAllowlist()`, not here.
|
|
600
634
|
worker: { model: "", tools: DEFAULT_WORKER_USER_TOOLS, thinking: "", excludeExtensions: [] },
|
|
601
|
-
reviewer: {
|
|
635
|
+
reviewer: {
|
|
636
|
+
model: "",
|
|
637
|
+
tools: "read,bash,grep,find,ls",
|
|
638
|
+
thinking: "on",
|
|
639
|
+
excludeExtensions: [],
|
|
640
|
+
severityLabels: ["critical", "important", "minor"],
|
|
641
|
+
spiral: {
|
|
642
|
+
enabled: true,
|
|
643
|
+
threshold: 3,
|
|
644
|
+
cooldownReviews: 2,
|
|
645
|
+
treatUnavailableAsNonApprove: false,
|
|
646
|
+
},
|
|
647
|
+
},
|
|
602
648
|
context: {
|
|
603
649
|
workerContextWindow: 0,
|
|
604
650
|
warnPercent: 85,
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* @module orch/diagnostic-reports
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
14
14
|
import { join } from "path";
|
|
15
15
|
|
|
16
16
|
import { execLog } from "./execution.ts";
|
|
@@ -92,6 +92,12 @@ export interface DiagnosticReportInput {
|
|
|
92
92
|
totalTasks: number;
|
|
93
93
|
/** State root path where `.pi/` lives */
|
|
94
94
|
stateRoot: string;
|
|
95
|
+
/**
|
|
96
|
+
* #629: per-task cost (USD) from outcome telemetry for the CURRENT pass.
|
|
97
|
+
* `diagnostics.taskExits` is not populated by the v2 runtime, so without
|
|
98
|
+
* this every report read $0.00. Optional for backward compatibility.
|
|
99
|
+
*/
|
|
100
|
+
taskCostUsd?: Record<string, number>;
|
|
95
101
|
}
|
|
96
102
|
|
|
97
103
|
// ── Diagnostics Directory ────────────────────────────────────────────
|
|
@@ -140,8 +146,8 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE
|
|
|
140
146
|
classification = task.exitDiagnostic.classification;
|
|
141
147
|
}
|
|
142
148
|
|
|
143
|
-
// Cost: from taskExits, else 0
|
|
144
|
-
const cost = exitSummary?.cost ?? 0;
|
|
149
|
+
// Cost: from taskExits, else this pass's outcome telemetry, else 0
|
|
150
|
+
const cost = exitSummary?.cost ?? input.taskCostUsd?.[task.taskId] ?? 0;
|
|
145
151
|
|
|
146
152
|
// Duration: from taskExits, else compute from timestamps, else 0
|
|
147
153
|
let durationSec = 0;
|
|
@@ -177,6 +183,79 @@ export function buildDiagnosticEvents(input: DiagnosticReportInput): DiagnosticE
|
|
|
177
183
|
|
|
178
184
|
// ── JSONL Generation ─────────────────────────────────────────────────
|
|
179
185
|
|
|
186
|
+
// ── Cross-pass evidence preservation (#629 side-effect 2) ──
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Does this event carry any execution evidence? A task that was not executed
|
|
190
|
+
* in the current pass (e.g. a no-op resume) produces an evidence-empty event
|
|
191
|
+
* that would otherwise CLOBBER the prior pass's record when the report is
|
|
192
|
+
* rewritten (observed: a $55 / 1h42m run reported as $0 / 0s after two
|
|
193
|
+
* no-op resumes).
|
|
194
|
+
*/
|
|
195
|
+
export function hasExecutionEvidence(evt: DiagnosticEvent): boolean {
|
|
196
|
+
return (
|
|
197
|
+
(evt.classification !== "unknown" && evt.classification !== "") ||
|
|
198
|
+
evt.cost > 0 ||
|
|
199
|
+
evt.durationSec > 0 ||
|
|
200
|
+
evt.retries > 0 ||
|
|
201
|
+
evt.startedAt !== null ||
|
|
202
|
+
evt.endedAt !== null
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merge the previous report's events into the current pass's events.
|
|
208
|
+
*
|
|
209
|
+
* Rule: CURRENT STATE always comes from the new pass (`status`, `phase`,
|
|
210
|
+
* `mode`, `batchId`) — a legitimate retry or skip must be reflected. Execution
|
|
211
|
+
* EVIDENCE is merged FIELD-WISE: each evidence field takes the new pass's
|
|
212
|
+
* value when it is present/non-zero, else the previous value. This matters
|
|
213
|
+
* because resume's reconciliation synthesizes outcomes for tasks it did NOT
|
|
214
|
+
* execute that still carry the persisted classification and a fresh
|
|
215
|
+
* `endTime` — an all-or-nothing rule would treat those as "evidence" and
|
|
216
|
+
* erase the prior cost. Cost is never summed across passes (each pass's
|
|
217
|
+
* telemetry is that attempt's cost; no double counting). Tasks present only
|
|
218
|
+
* in the previous report are dropped (the new wave plan is authoritative for
|
|
219
|
+
* membership). Pure; deterministic order follows `next`.
|
|
220
|
+
*/
|
|
221
|
+
export function mergeDiagnosticEvents(
|
|
222
|
+
previous: DiagnosticEvent[],
|
|
223
|
+
next: DiagnosticEvent[],
|
|
224
|
+
): DiagnosticEvent[] {
|
|
225
|
+
const prevByTask = new Map<string, DiagnosticEvent>();
|
|
226
|
+
for (const p of previous) prevByTask.set(p.taskId, p);
|
|
227
|
+
return next.map((n) => {
|
|
228
|
+
const p = prevByTask.get(n.taskId);
|
|
229
|
+
if (!p) return n;
|
|
230
|
+
const knownClass = n.classification && n.classification !== "unknown";
|
|
231
|
+
return {
|
|
232
|
+
...n,
|
|
233
|
+
classification: knownClass ? n.classification : p.classification,
|
|
234
|
+
cost: n.cost > 0 ? n.cost : p.cost,
|
|
235
|
+
durationSec: n.durationSec > 0 ? n.durationSec : p.durationSec,
|
|
236
|
+
retries: n.retries > 0 ? n.retries : p.retries,
|
|
237
|
+
exitReason: n.exitReason || p.exitReason,
|
|
238
|
+
startedAt: n.startedAt ?? p.startedAt,
|
|
239
|
+
endedAt: n.endedAt ?? p.endedAt,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Parse a previously written events JSONL file (tolerant: bad lines skipped). */
|
|
245
|
+
export function parseEventsJsonl(content: string): DiagnosticEvent[] {
|
|
246
|
+
const out: DiagnosticEvent[] = [];
|
|
247
|
+
for (const line of content.split(/\r?\n/)) {
|
|
248
|
+
if (!line.trim()) continue;
|
|
249
|
+
try {
|
|
250
|
+
const obj = JSON.parse(line) as Partial<DiagnosticEvent>;
|
|
251
|
+
if (typeof obj.taskId === "string") out.push(obj as DiagnosticEvent);
|
|
252
|
+
} catch {
|
|
253
|
+
/* skip malformed */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
|
|
180
259
|
/**
|
|
181
260
|
* Serialize diagnostic events to JSONL format (one JSON object per line).
|
|
182
261
|
*/
|
|
@@ -222,7 +301,12 @@ export function buildMarkdownReport(
|
|
|
222
301
|
const { succeededTasks, failedTasks, skippedTasks, blockedTasks, totalTasks } = input;
|
|
223
302
|
|
|
224
303
|
const batchDurationSec = endedAt ? Math.round((endedAt - startedAt) / 1000) : 0;
|
|
225
|
-
|
|
304
|
+
// #629: header cost from batch diagnostics when populated, else the sum of
|
|
305
|
+
// per-task evidence (which survives no-op passes via mergeDiagnosticEvents).
|
|
306
|
+
const batchCost =
|
|
307
|
+
diagnostics.batchCost && diagnostics.batchCost > 0
|
|
308
|
+
? diagnostics.batchCost
|
|
309
|
+
: events.reduce((sum, e) => sum + (Number.isFinite(e.cost) ? e.cost : 0), 0);
|
|
226
310
|
|
|
227
311
|
const lines: string[] = [];
|
|
228
312
|
|
|
@@ -337,10 +421,21 @@ export function emitDiagnosticReports(input: DiagnosticReportInput): void {
|
|
|
337
421
|
const opId = resolveOperatorId(input.orchConfig);
|
|
338
422
|
const dir = ensureDiagnosticsDir(input.stateRoot);
|
|
339
423
|
|
|
340
|
-
const
|
|
424
|
+
const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
|
|
425
|
+
|
|
426
|
+
// #629: preserve prior-pass execution evidence for tasks this pass did
|
|
427
|
+
// not execute (state fields still come from this pass).
|
|
428
|
+
let events = buildDiagnosticEvents(input);
|
|
429
|
+
if (existsSync(jsonlPath)) {
|
|
430
|
+
try {
|
|
431
|
+
const previous = parseEventsJsonl(readFileSync(jsonlPath, "utf-8"));
|
|
432
|
+
events = mergeDiagnosticEvents(previous, events);
|
|
433
|
+
} catch {
|
|
434
|
+
/* unreadable prior report — proceed with this pass's events */
|
|
435
|
+
}
|
|
436
|
+
}
|
|
341
437
|
|
|
342
438
|
// ── JSONL event log ──
|
|
343
|
-
const jsonlPath = join(dir, `${opId}-${input.batchId}-events.jsonl`);
|
|
344
439
|
const jsonlContent = eventsToJsonl(events);
|
|
345
440
|
writeFileSync(jsonlPath, jsonlContent, "utf-8");
|
|
346
441
|
|
|
@@ -471,5 +566,13 @@ export function assembleDiagnosticInput(
|
|
|
471
566
|
blockedTasks: batchState.blockedTasks,
|
|
472
567
|
totalTasks: batchState.totalTasks,
|
|
473
568
|
stateRoot,
|
|
569
|
+
taskCostUsd: (() => {
|
|
570
|
+
const costs: Record<string, number> = {};
|
|
571
|
+
for (const [taskId, outcome] of outcomeByTaskId) {
|
|
572
|
+
const c = outcome.telemetry?.costUsd;
|
|
573
|
+
if (typeof c === "number" && Number.isFinite(c) && c > 0) costs[taskId] = c;
|
|
574
|
+
}
|
|
575
|
+
return costs;
|
|
576
|
+
})(),
|
|
474
577
|
};
|
|
475
578
|
}
|
|
@@ -53,6 +53,7 @@ export interface SessionTokenCounts {
|
|
|
53
53
|
* | `stall_timeout` | No STATUS.md progress for stall_timeout minutes |
|
|
54
54
|
* | `user_killed` | User manually killed the session (e.g., forced process kill) |
|
|
55
55
|
* | `spawn_failure` | Worker process never spawned (e.g., Pi CLI not findable, worktree provisioning) |
|
|
56
|
+
* | `review_gate_refusal`| Governance refusal: finalize blocked by an outstanding REVISE/RETHINK review verdict (#626/#629). The worker exited cleanly — NOT a crash, never auto-retried (the review file must change first) |
|
|
56
57
|
* | `unknown` | Could not determine cause |
|
|
57
58
|
*
|
|
58
59
|
* Note: `spawn_failure` (TP-190, #561) is set BEFORE any agent process exists —
|
|
@@ -72,6 +73,7 @@ export type ExitClassification =
|
|
|
72
73
|
| "stall_timeout"
|
|
73
74
|
| "user_killed"
|
|
74
75
|
| "spawn_failure"
|
|
76
|
+
| "review_gate_refusal"
|
|
75
77
|
| "unknown";
|
|
76
78
|
|
|
77
79
|
/**
|
|
@@ -88,6 +90,7 @@ export const EXIT_CLASSIFICATIONS: readonly ExitClassification[] = [
|
|
|
88
90
|
"stall_timeout",
|
|
89
91
|
"user_killed",
|
|
90
92
|
"spawn_failure",
|
|
93
|
+
"review_gate_refusal",
|
|
91
94
|
"unknown",
|
|
92
95
|
] as const;
|
|
93
96
|
|