taskplane 0.30.4 → 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/context-repair.ts +158 -0
- 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 +1288 -241
- 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-dispatch.ts +103 -0
- package/extensions/taskplane/supervisor-primer.md +182 -2
- package/extensions/taskplane/supervisor.ts +247 -24
- 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,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-flight tool_use/tool_result ordering repair — issue #621 (defense in depth).
|
|
3
|
+
*
|
|
4
|
+
* The supervisor injects `custom` display messages via pi.sendMessage(). Any
|
|
5
|
+
* such injection that lands while the interactive agent has a tool call in
|
|
6
|
+
* flight splices a message BETWEEN an assistant `tool_use` and its
|
|
7
|
+
* `toolResult`. Anthropic then rejects the request:
|
|
8
|
+
*
|
|
9
|
+
* 400 messages.N.content.M: unexpected `tool_use_id` found in `tool_result`
|
|
10
|
+
* blocks ... Each `tool_result` block must have a corresponding `tool_use`
|
|
11
|
+
* block in the previous message.
|
|
12
|
+
*
|
|
13
|
+
* The batch-end epilogue gate (supervisor-dispatch.ts) prevents the most common
|
|
14
|
+
* source, but the supervisor has many other background `pi.sendMessage(...,
|
|
15
|
+
* {triggerTurn:false})` sites (integration progress/result, heartbeat, routing)
|
|
16
|
+
* that can splice the same way. Rather than gate each one, this module repairs
|
|
17
|
+
* the ORDERING of the outgoing message array on the pi `context` event, which
|
|
18
|
+
* fires before every provider request (`transformContext`, on the pi-internal
|
|
19
|
+
* AgentMessage[] before convertToLlm). Each assistant's tool results are pulled
|
|
20
|
+
* to immediately follow it (in tool-call order); any spliced-in `custom`/`user`
|
|
21
|
+
* messages move to after the tool-result group. The request is therefore always
|
|
22
|
+
* valid regardless of where a stray message was appended, and a mistimed
|
|
23
|
+
* injection can never wedge the session.
|
|
24
|
+
*
|
|
25
|
+
* This does not mutate the persisted session tree — it only transforms the
|
|
26
|
+
* per-request context — so it is safe, idempotent, and self-correcting across
|
|
27
|
+
* reloads.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface ToolCallBlock {
|
|
31
|
+
type: string;
|
|
32
|
+
id?: string;
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}
|
|
35
|
+
interface AgentMessageLike {
|
|
36
|
+
role?: string;
|
|
37
|
+
content?: unknown;
|
|
38
|
+
toolCallId?: string;
|
|
39
|
+
[key: string]: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function toolUseIds(msg: AgentMessageLike): string[] {
|
|
43
|
+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) return [];
|
|
44
|
+
const ids: string[] = [];
|
|
45
|
+
for (const block of msg.content as ToolCallBlock[]) {
|
|
46
|
+
if (
|
|
47
|
+
block &&
|
|
48
|
+
typeof block === "object" &&
|
|
49
|
+
block.type === "toolCall" &&
|
|
50
|
+
typeof block.id === "string"
|
|
51
|
+
) {
|
|
52
|
+
ids.push(block.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return ids;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reorder `messages` so every assistant `tool_use` is immediately followed by
|
|
60
|
+
* its matching `toolResult`(s), relocating any spliced-in non-tool messages to
|
|
61
|
+
* after the tool-result group.
|
|
62
|
+
*
|
|
63
|
+
* Robustness (Sage #621 review):
|
|
64
|
+
* - Duplicate `toolResult` messages sharing a `toolCallId` are all preserved
|
|
65
|
+
* (queue-based grouping, not last-wins), so repair never drops data.
|
|
66
|
+
* - Emitted results are tracked by message identity, not by id.
|
|
67
|
+
* - Also repairs the result-before-assistant shape: a `toolResult` whose owning
|
|
68
|
+
* assistant appears later is held and pulled forward at the owner.
|
|
69
|
+
* - A final safety-net pass appends any never-emitted result, guaranteeing no
|
|
70
|
+
* `toolResult` is ever lost regardless of input malformation.
|
|
71
|
+
*
|
|
72
|
+
* Returns the SAME array reference when already well-formed (no reordering
|
|
73
|
+
* needed), so callers can cheaply detect a no-op. Otherwise returns a new,
|
|
74
|
+
* reordered array. Pure: never mutates the input array or its elements.
|
|
75
|
+
*/
|
|
76
|
+
export function repairToolResultOrdering<T extends AgentMessageLike>(messages: T[]): T[] {
|
|
77
|
+
if (!Array.isArray(messages) || messages.length < 3) return messages;
|
|
78
|
+
|
|
79
|
+
// Collect ALL toolResult messages per toolCallId, preserving original order.
|
|
80
|
+
// A queue (array) rather than last-wins so duplicate results for the same id
|
|
81
|
+
// are never dropped (Sage #621 review: last-wins could silently lose data).
|
|
82
|
+
const resultsById = new Map<string, T[]>();
|
|
83
|
+
for (const m of messages) {
|
|
84
|
+
if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
|
|
85
|
+
const list = resultsById.get(m.toolCallId);
|
|
86
|
+
if (list) list.push(m);
|
|
87
|
+
else resultsById.set(m.toolCallId, [m]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (resultsById.size === 0) return messages;
|
|
91
|
+
|
|
92
|
+
// First-occurrence index of the assistant that owns each toolCallId. Lets us
|
|
93
|
+
// HOLD an in-place toolResult whose owning assistant appears LATER, repairing
|
|
94
|
+
// the result-before-assistant shape (Sage #621 review) instead of emitting it
|
|
95
|
+
// in a position that would still be invalid.
|
|
96
|
+
const ownerIndexById = new Map<string, number>();
|
|
97
|
+
for (let i = 0; i < messages.length; i++) {
|
|
98
|
+
for (const id of toolUseIds(messages[i])) {
|
|
99
|
+
if (!ownerIndexById.has(id)) ownerIndexById.set(id, i);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const out: T[] = [];
|
|
104
|
+
// Track emitted results by message IDENTITY, not by id, so duplicate result
|
|
105
|
+
// messages sharing a toolCallId are each accounted for individually.
|
|
106
|
+
const emitted = new Set<T>();
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < messages.length; i++) {
|
|
109
|
+
const m = messages[i];
|
|
110
|
+
if (m && m.role === "toolResult" && typeof m.toolCallId === "string") {
|
|
111
|
+
if (emitted.has(m)) continue; // already pulled forward next to its assistant
|
|
112
|
+
const owner = ownerIndexById.get(m.toolCallId);
|
|
113
|
+
// Owner appears later → hold; it will be pulled forward at the owner.
|
|
114
|
+
// Owner earlier (normal splice case) or orphan (no owner) → emit in place.
|
|
115
|
+
if (owner !== undefined && owner > i) continue;
|
|
116
|
+
out.push(m);
|
|
117
|
+
emitted.add(m);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out.push(m);
|
|
122
|
+
|
|
123
|
+
// Pull every matching toolResult (all of them, in original order) to
|
|
124
|
+
// immediately follow this assistant, in tool-call order.
|
|
125
|
+
for (const id of toolUseIds(m)) {
|
|
126
|
+
const list = resultsById.get(id);
|
|
127
|
+
if (!list) continue; // genuinely unanswered tool_use — not repairable here
|
|
128
|
+
for (const result of list) {
|
|
129
|
+
if (emitted.has(result)) continue;
|
|
130
|
+
out.push(result);
|
|
131
|
+
emitted.add(result);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Safety net: guarantee no toolResult is ever dropped. Any result not emitted
|
|
137
|
+
// above (only reachable via a held-but-never-pulled edge case) is appended in
|
|
138
|
+
// original order. Guarded by identity so it can never double-emit.
|
|
139
|
+
for (const m of messages) {
|
|
140
|
+
if (m && m.role === "toolResult" && typeof m.toolCallId === "string" && !emitted.has(m)) {
|
|
141
|
+
out.push(m);
|
|
142
|
+
emitted.add(m);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Return the original reference when nothing moved (cheap no-op detection).
|
|
147
|
+
if (out.length === messages.length) {
|
|
148
|
+
let identical = true;
|
|
149
|
+
for (let i = 0; i < out.length; i++) {
|
|
150
|
+
if (out[i] !== messages[i]) {
|
|
151
|
+
identical = false;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (identical) return messages;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|