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
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review-analysis pure helpers — review-boundary supervisor notifications (Stage 3).
|
|
3
|
+
*
|
|
4
|
+
* These functions turn a reviewer's on-disk review markdown into the structured
|
|
5
|
+
* signals the supervisor uses to distinguish a converging review loop from a
|
|
6
|
+
* revision spiral: severity-bucketed finding counts, a converging-vs-circling
|
|
7
|
+
* trend, and the review round label.
|
|
8
|
+
*
|
|
9
|
+
* DESIGN: strictly pure and dependency-free (no Pi, no fs, no execution/engine
|
|
10
|
+
* imports) so they are trivially unit-testable and cannot introduce an import
|
|
11
|
+
* cycle. The severity vocabulary is CONFIGURABLE (never hardcoded): core ships
|
|
12
|
+
* ["critical","important","minor"], but a project whose reviewer emits e.g.
|
|
13
|
+
* P0/P1/P2 supplies its own ordered list. Any finding whose severity matches no
|
|
14
|
+
* configured label is bucketed under "other" so counts are never silently lost.
|
|
15
|
+
*
|
|
16
|
+
* @module taskplane/review-analysis
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ReviewDisposition } from "./types.ts";
|
|
20
|
+
|
|
21
|
+
/** Sentinel bucket for findings whose severity matches no configured label. */
|
|
22
|
+
export const OTHER_SEVERITY_BUCKET = "other";
|
|
23
|
+
|
|
24
|
+
/** Coarse converging-vs-circling signal for a step's latest review vs the prior. */
|
|
25
|
+
export type FindingTrend = "dropping" | "flat" | "rising";
|
|
26
|
+
|
|
27
|
+
/** Result of comparing two rounds' finding counts. */
|
|
28
|
+
export interface FindingTrendResult {
|
|
29
|
+
/**
|
|
30
|
+
* Lexicographic trend by severity order: the direction of the HIGHEST-severity
|
|
31
|
+
* label whose count changed. "dropping" = converging (let it run); "rising" =
|
|
32
|
+
* getting worse; "flat" = no change (or no prior baseline).
|
|
33
|
+
*/
|
|
34
|
+
trend: FindingTrend;
|
|
35
|
+
/** Per-label delta (curr - prev), including OTHER_SEVERITY_BUCKET; 0 when unchanged. */
|
|
36
|
+
deltas: Record<string, number>;
|
|
37
|
+
/** True when some labels rose while others dropped (opposing movement). */
|
|
38
|
+
mixed: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Count review findings by severity from review markdown.
|
|
43
|
+
*
|
|
44
|
+
* Handles both shipped reviewer formats:
|
|
45
|
+
* - code: `1. **[File:Line]** [Severity] — ...`
|
|
46
|
+
* - plan: `1. **[Severity: critical/important/minor]** — ...`
|
|
47
|
+
* plus project-custom severity vocabularies. Only findings inside the
|
|
48
|
+
* `### Issues Found` section are counted; a missing section yields an empty map.
|
|
49
|
+
*
|
|
50
|
+
* Best-effort and NON-THROWING: malformed input yields the best partial count
|
|
51
|
+
* it can (never throws), so a parse hiccup can't break the worker run.
|
|
52
|
+
*
|
|
53
|
+
* @param markdown Raw review file contents.
|
|
54
|
+
* @param severityLabels Ordered severity vocabulary (highest severity first).
|
|
55
|
+
* @returns Map of severity label -> count for labels with >0 findings, plus an
|
|
56
|
+
* `other` bucket for unrecognized-severity findings when any exist.
|
|
57
|
+
*/
|
|
58
|
+
export function parseFindingCounts(
|
|
59
|
+
markdown: string | undefined | null,
|
|
60
|
+
severityLabels: string[],
|
|
61
|
+
): Record<string, number> {
|
|
62
|
+
const counts: Record<string, number> = {};
|
|
63
|
+
if (!markdown || typeof markdown !== "string") return counts;
|
|
64
|
+
const labels = severityLabels.filter((l) => typeof l === "string" && l.trim().length > 0);
|
|
65
|
+
|
|
66
|
+
// Isolate the "Issues Found" section: from its heading to the next heading
|
|
67
|
+
// (### or ##) or end of file. Case-insensitive on the heading text.
|
|
68
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
69
|
+
let inSection = false;
|
|
70
|
+
const sectionLines: string[] = [];
|
|
71
|
+
const issuesHeadingRe = /^#{2,4}\s+Issues\s+Found\b/i;
|
|
72
|
+
const anyHeadingRe = /^#{2,4}\s+\S/;
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (!inSection) {
|
|
75
|
+
if (issuesHeadingRe.test(line)) inSection = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (anyHeadingRe.test(line)) break; // next section
|
|
79
|
+
sectionLines.push(line);
|
|
80
|
+
}
|
|
81
|
+
if (sectionLines.length === 0) return counts;
|
|
82
|
+
|
|
83
|
+
// A finding entry is a list item: "1. ..." / "2) ..." / "- ..." / "* ...".
|
|
84
|
+
const entryRe = /^\s*(?:\d+[.)]|[-*])\s+/;
|
|
85
|
+
// Precompile per-label word-boundary matchers (case-insensitive).
|
|
86
|
+
const labelMatchers = labels.map((label) => ({
|
|
87
|
+
label,
|
|
88
|
+
re: new RegExp(`\\b${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i"),
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
const bump = (bucket: string) => {
|
|
92
|
+
counts[bucket] = (counts[bucket] ?? 0) + 1;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
for (const raw of sectionLines) {
|
|
96
|
+
if (!entryRe.test(raw)) continue; // not a finding line (blank, prose, etc.)
|
|
97
|
+
// Assign the entry to the FIRST configured label (highest severity first)
|
|
98
|
+
// that appears in the line; else the OTHER bucket. Taking highest-first
|
|
99
|
+
// means a line mentioning two labels is charged to the more severe one.
|
|
100
|
+
let assigned = false;
|
|
101
|
+
for (const { label, re } of labelMatchers) {
|
|
102
|
+
if (re.test(raw)) {
|
|
103
|
+
bump(label);
|
|
104
|
+
assigned = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!assigned) bump(OTHER_SEVERITY_BUCKET);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return counts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compare two rounds' finding counts into a converging-vs-circling trend.
|
|
116
|
+
*
|
|
117
|
+
* Uses a LEXICOGRAPHIC rule over the severity order (highest first): the coarse
|
|
118
|
+
* `trend` is the direction of the highest-severity label whose count changed.
|
|
119
|
+
* This matches human triage ("did the criticals go down?") and correctly reads
|
|
120
|
+
* "criticals down, minors up" as `dropping` (converging at the severity that
|
|
121
|
+
* matters) while still flagging `mixed: true` for nuance.
|
|
122
|
+
*
|
|
123
|
+
* @param prev Prior round's counts, or null on the first review.
|
|
124
|
+
* @param curr Current round's counts.
|
|
125
|
+
* @param severityLabels Ordered severity vocabulary (highest severity first).
|
|
126
|
+
*/
|
|
127
|
+
export function computeFindingTrend(
|
|
128
|
+
prev: Record<string, number> | null | undefined,
|
|
129
|
+
curr: Record<string, number>,
|
|
130
|
+
severityLabels: string[],
|
|
131
|
+
): FindingTrendResult {
|
|
132
|
+
const labels = [
|
|
133
|
+
...severityLabels.filter((l) => typeof l === "string" && l.trim().length > 0),
|
|
134
|
+
OTHER_SEVERITY_BUCKET,
|
|
135
|
+
];
|
|
136
|
+
const deltas: Record<string, number> = {};
|
|
137
|
+
let anyUp = false;
|
|
138
|
+
let anyDown = false;
|
|
139
|
+
for (const label of labels) {
|
|
140
|
+
const p = prev?.[label] ?? 0;
|
|
141
|
+
const c = curr?.[label] ?? 0;
|
|
142
|
+
const d = c - p;
|
|
143
|
+
deltas[label] = d;
|
|
144
|
+
if (d > 0) anyUp = true;
|
|
145
|
+
if (d < 0) anyDown = true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// No prior baseline → nothing to compare; report flat.
|
|
149
|
+
if (!prev) {
|
|
150
|
+
return { trend: "flat", deltas, mixed: false };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Lexicographic: first (highest-severity) label with a non-zero delta decides.
|
|
154
|
+
let trend: FindingTrend = "flat";
|
|
155
|
+
for (const label of labels) {
|
|
156
|
+
const d = deltas[label];
|
|
157
|
+
if (d !== 0) {
|
|
158
|
+
trend = d < 0 ? "dropping" : "rising";
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { trend, deltas, mixed: anyUp && anyDown };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Per-step review streak state (the spiral-detection core). This is the SHARED
|
|
167
|
+
* transition model used both live (lane-runner) and during resume
|
|
168
|
+
* reconstruction, so the two can never drift.
|
|
169
|
+
*/
|
|
170
|
+
export interface ReviewStreakState {
|
|
171
|
+
/** Consecutive REVISE/RETHINK reviews on this step (reset on APPROVE). */
|
|
172
|
+
consecutiveNonApprove: number;
|
|
173
|
+
/** Count of verdict/attempt reviews seen for this step (the review round). */
|
|
174
|
+
round: number;
|
|
175
|
+
/** Finding counts from the previous round (for trend), or null. */
|
|
176
|
+
lastCounts: Record<string, number> | null;
|
|
177
|
+
/** Recent dispositions (oldest→newest, bounded). */
|
|
178
|
+
recentDispositions: ReviewDisposition[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** A fresh, zeroed streak state. */
|
|
182
|
+
export function freshReviewStreakState(): ReviewStreakState {
|
|
183
|
+
return { consecutiveNonApprove: 0, round: 0, lastCounts: null, recentDispositions: [] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Apply ONE review-boundary outcome to a step's streak state (mutates it). This
|
|
188
|
+
* is the single source of truth for the counter transitions:
|
|
189
|
+
* - every END boundary increments `round`;
|
|
190
|
+
* - APPROVE resets the consecutive streak to 0;
|
|
191
|
+
* - REVISE/RETHINK (and UNAVAILABLE iff `treatUnavailableAsNonApprove`)
|
|
192
|
+
* increment the streak;
|
|
193
|
+
* - REFUSED / UNAVAILABLE / UNKNOWN otherwise leave the streak unchanged
|
|
194
|
+
* (orthogonal or non-verdict outcomes);
|
|
195
|
+
* - `lastCounts` advances only when this round produced finding counts;
|
|
196
|
+
* - `recentDispositions` is appended (bounded by `recentCap`).
|
|
197
|
+
*
|
|
198
|
+
* Escalation/cooldown decisions are intentionally NOT here — they are live-only
|
|
199
|
+
* side effects layered on top by the caller.
|
|
200
|
+
*/
|
|
201
|
+
export function advanceReviewStreak(
|
|
202
|
+
state: ReviewStreakState,
|
|
203
|
+
opts: {
|
|
204
|
+
disposition: ReviewDisposition | undefined;
|
|
205
|
+
counts: Record<string, number> | null;
|
|
206
|
+
treatUnavailableAsNonApprove: boolean;
|
|
207
|
+
recentCap: number;
|
|
208
|
+
},
|
|
209
|
+
): void {
|
|
210
|
+
state.round += 1;
|
|
211
|
+
if (opts.counts && Object.keys(opts.counts).length > 0) {
|
|
212
|
+
state.lastCounts = opts.counts;
|
|
213
|
+
}
|
|
214
|
+
if (opts.disposition) {
|
|
215
|
+
state.recentDispositions.push(opts.disposition);
|
|
216
|
+
while (state.recentDispositions.length > opts.recentCap) state.recentDispositions.shift();
|
|
217
|
+
}
|
|
218
|
+
if (opts.disposition === "APPROVE") {
|
|
219
|
+
state.consecutiveNonApprove = 0;
|
|
220
|
+
} else if (
|
|
221
|
+
opts.disposition === "REVISE" ||
|
|
222
|
+
opts.disposition === "RETHINK" ||
|
|
223
|
+
(opts.disposition === "UNAVAILABLE" && opts.treatUnavailableAsNonApprove)
|
|
224
|
+
) {
|
|
225
|
+
state.consecutiveNonApprove += 1;
|
|
226
|
+
}
|
|
227
|
+
// REFUSED / UNAVAILABLE (uncounted) / UNKNOWN: no streak change.
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Rebuild per-step streak state by replaying a task's historical review
|
|
232
|
+
* boundaries (from events.jsonl) on resume — "maintain the truth" rather than
|
|
233
|
+
* resetting counters to zero. Uses {@link advanceReviewStreak} so reconstruction
|
|
234
|
+
* and the live path share identical transition semantics.
|
|
235
|
+
*
|
|
236
|
+
* @param events Ordered review END boundaries for ONE task: `{ reviewStep,
|
|
237
|
+
* disposition, findingCounts }` (review_completed / review_failed).
|
|
238
|
+
* @returns Map keyed by `stepNum` string.
|
|
239
|
+
*/
|
|
240
|
+
export function reconstructReviewStreaks(
|
|
241
|
+
events: Array<{
|
|
242
|
+
reviewStep?: number;
|
|
243
|
+
disposition?: ReviewDisposition | string;
|
|
244
|
+
findingCounts?: Record<string, number> | null;
|
|
245
|
+
}>,
|
|
246
|
+
opts: { treatUnavailableAsNonApprove: boolean; recentCap: number },
|
|
247
|
+
): Map<string, ReviewStreakState> {
|
|
248
|
+
const byStep = new Map<string, ReviewStreakState>();
|
|
249
|
+
for (const e of events) {
|
|
250
|
+
if (typeof e.reviewStep !== "number") continue;
|
|
251
|
+
const key = String(e.reviewStep);
|
|
252
|
+
let st = byStep.get(key);
|
|
253
|
+
if (!st) {
|
|
254
|
+
st = freshReviewStreakState();
|
|
255
|
+
byStep.set(key, st);
|
|
256
|
+
}
|
|
257
|
+
advanceReviewStreak(st, {
|
|
258
|
+
disposition: e.disposition as ReviewDisposition | undefined,
|
|
259
|
+
counts: e.findingCounts ?? null,
|
|
260
|
+
treatUnavailableAsNonApprove: opts.treatUnavailableAsNonApprove,
|
|
261
|
+
recentCap: opts.recentCap,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
return byStep;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Tuning inputs for the spiral escalation decision. */
|
|
268
|
+
export interface SpiralGateConfig {
|
|
269
|
+
enabled: boolean;
|
|
270
|
+
threshold: number;
|
|
271
|
+
cooldownReviews: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Fully-resolved spiral tuning (gate config + counter policy). */
|
|
275
|
+
export interface ResolvedSpiralConfig extends SpiralGateConfig {
|
|
276
|
+
treatUnavailableAsNonApprove: boolean;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sanitize possibly-partial/malformed spiral config (from JSON env threading)
|
|
281
|
+
* into a safe, fully-populated shape. Clamps `threshold` and `cooldownReviews`
|
|
282
|
+
* to >= 1 (a zero/negative threshold would escalate on every review; a zero
|
|
283
|
+
* cooldown would re-fire every round), and coerces the booleans. Absent →
|
|
284
|
+
* sensible defaults (enabled, threshold 3, cooldown 2, don't count UNAVAILABLE).
|
|
285
|
+
*/
|
|
286
|
+
export function sanitizeSpiralConfig(
|
|
287
|
+
raw:
|
|
288
|
+
| Partial<{
|
|
289
|
+
enabled: boolean;
|
|
290
|
+
threshold: number;
|
|
291
|
+
cooldownReviews: number;
|
|
292
|
+
treatUnavailableAsNonApprove: boolean;
|
|
293
|
+
}>
|
|
294
|
+
| null
|
|
295
|
+
| undefined,
|
|
296
|
+
): ResolvedSpiralConfig {
|
|
297
|
+
const intOr = (v: unknown, dflt: number): number => {
|
|
298
|
+
const n = typeof v === "number" && Number.isFinite(v) ? Math.floor(v) : dflt;
|
|
299
|
+
return n >= 1 ? n : dflt;
|
|
300
|
+
};
|
|
301
|
+
return {
|
|
302
|
+
enabled: raw?.enabled !== false, // default true; only explicit false disables
|
|
303
|
+
threshold: intOr(raw?.threshold, 3),
|
|
304
|
+
cooldownReviews: intOr(raw?.cooldownReviews, 2),
|
|
305
|
+
treatUnavailableAsNonApprove: raw?.treatUnavailableAsNonApprove === true,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Decide whether to fire (or re-fire) a revision-spiral escalation.
|
|
311
|
+
*
|
|
312
|
+
* - Never below threshold, or when disabled.
|
|
313
|
+
* - First crossing (no prior escalation this streak) always fires.
|
|
314
|
+
* - Re-fire only when NOT converging (trend is flat/rising, never "dropping")
|
|
315
|
+
* AND the cooldown spacing has elapsed since the last escalation. The
|
|
316
|
+
* trend gate is the key anti-nag rule: a converging spiral is left to run.
|
|
317
|
+
*
|
|
318
|
+
* Pure: the caller applies the side effect (fire + set lastEscalationRound).
|
|
319
|
+
*/
|
|
320
|
+
export function shouldFireSpiral(
|
|
321
|
+
state: { consecutiveNonApprove: number; round: number; lastEscalationRound: number | null },
|
|
322
|
+
cfg: SpiralGateConfig,
|
|
323
|
+
trend: FindingTrend | undefined,
|
|
324
|
+
): boolean {
|
|
325
|
+
if (!cfg.enabled) return false;
|
|
326
|
+
if (state.consecutiveNonApprove < cfg.threshold) return false;
|
|
327
|
+
if (state.lastEscalationRound === null) return true; // first escalation this streak
|
|
328
|
+
const converging = trend === "dropping";
|
|
329
|
+
const cooldownElapsed = state.round - state.lastEscalationRound >= cfg.cooldownReviews;
|
|
330
|
+
return !converging && cooldownElapsed;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Decide whether to fire an order-violation (REFUSED) escalation: actionable on
|
|
335
|
+
* each occurrence, throttled by the cooldown spacing.
|
|
336
|
+
*
|
|
337
|
+
* Pure: the caller applies the side effect (fire + set lastRefusedRound).
|
|
338
|
+
*/
|
|
339
|
+
export function shouldFireOrderViolation(
|
|
340
|
+
state: { round: number; lastRefusedRound: number | null },
|
|
341
|
+
cfg: SpiralGateConfig,
|
|
342
|
+
): boolean {
|
|
343
|
+
if (!cfg.enabled) return false;
|
|
344
|
+
return (
|
|
345
|
+
state.lastRefusedRound === null || state.round - state.lastRefusedRound >= cfg.cooldownReviews
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Parse the reviewer's verdict directly from the review markdown file — the
|
|
351
|
+
* authoritative source of truth (the reviewer always writes
|
|
352
|
+
* `## Verdict: APPROVE|REVISE|RETHINK` to disk). Used by lane-runner to resolve
|
|
353
|
+
* the disposition robustly even when the tool-return extraction upstream is
|
|
354
|
+
* empty/ambiguous (#624). Matches the executor's verdict parser
|
|
355
|
+
* (task-executor-core.ts).
|
|
356
|
+
*
|
|
357
|
+
* Returns the verdict as a {@link ReviewDisposition}, or undefined if no
|
|
358
|
+
* recognizable `Verdict:` heading is present (e.g. an empty/aborted review).
|
|
359
|
+
*/
|
|
360
|
+
export function parseReviewVerdict(
|
|
361
|
+
markdown: string | undefined | null,
|
|
362
|
+
): ReviewDisposition | undefined {
|
|
363
|
+
if (!markdown || typeof markdown !== "string") return undefined;
|
|
364
|
+
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
|
365
|
+
// A verdict line starts with optional heading hashes and/or bold markers, then
|
|
366
|
+
// the word "Verdict". Reviewer LLMs vary the format: '## Verdict: REVISE',
|
|
367
|
+
// '**Verdict:** REVISE', 'Verdict - APPROVE', '#### Verdict — RETHINK',
|
|
368
|
+
// '## Verdict: [REVISE]', or the token on the following line. The old
|
|
369
|
+
// '/#{2,4}\\s*Verdict[:\\s]*(...)/' missed most variants, and the old caller
|
|
370
|
+
// (review_step) fell back to an approve-biased substring scan — flipping
|
|
371
|
+
// REVISE reviews to APPROVE (#624 severity upgrade: workers advanced past
|
|
372
|
+
// REVISE verdicts and nearly shipped unreviewed code).
|
|
373
|
+
const markerRe = /^\s*(?:#{1,4}\s*)?(?:\*{1,2}\s*)?Verdict\b/i;
|
|
374
|
+
const tokenRe = /\b(APPROVE|REVISE|RETHINK)\b/gi;
|
|
375
|
+
// After 'Verdict', only separators/decoration may precede the token — prose
|
|
376
|
+
// like 'Verdict criteria: APPROVE means…' must NOT match.
|
|
377
|
+
const firstTokenRe = /^[\s:\-—–*_[\]]*\b(APPROVE|REVISE|RETHINK)\b/i;
|
|
378
|
+
|
|
379
|
+
const resolveFrom = (text: string): ReviewDisposition | undefined => {
|
|
380
|
+
const tokens = [...text.matchAll(tokenRe)].map((t) => t[1].toUpperCase());
|
|
381
|
+
const distinct = new Set(tokens);
|
|
382
|
+
// 2+ distinct verdict words = the template placeholder
|
|
383
|
+
// ('[APPROVE | REVISE | RETHINK]') or criteria prose — not a verdict.
|
|
384
|
+
if (distinct.size !== 1) return undefined;
|
|
385
|
+
const m = text.match(firstTokenRe);
|
|
386
|
+
return m ? (m[1].toUpperCase() as ReviewDisposition) : undefined;
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
for (let i = 0; i < lines.length; i++) {
|
|
390
|
+
const marker = lines[i].match(markerRe);
|
|
391
|
+
if (!marker) continue;
|
|
392
|
+
const rest = lines[i].slice((marker.index ?? 0) + marker[0].length);
|
|
393
|
+
const fromLine = resolveFrom(rest);
|
|
394
|
+
if (fromLine) return fromLine;
|
|
395
|
+
// Token may sit on the next non-empty line ('## Verdict\nREVISE').
|
|
396
|
+
if ([...rest.matchAll(tokenRe)].length === 0) {
|
|
397
|
+
for (let j = i + 1; j < Math.min(i + 3, lines.length); j++) {
|
|
398
|
+
const next = lines[j].trim();
|
|
399
|
+
if (!next) continue;
|
|
400
|
+
const fromNext = resolveFrom(next);
|
|
401
|
+
if (fromNext) return fromNext;
|
|
402
|
+
break; // only the first non-empty line counts
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// Placeholder or prose — keep scanning for a later real verdict line.
|
|
406
|
+
}
|
|
407
|
+
return undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Group review filenames by gate (`{type}-step{N}`) and return the LATEST
|
|
412
|
+
* (highest R-number) filename per gate. Filenames must follow the
|
|
413
|
+
* `R{NNN}-{type}-step{N}.md` convention; non-matching names are ignored.
|
|
414
|
+
*
|
|
415
|
+
* Used by the #626 minimal finalize gate: a task must not finalize while any
|
|
416
|
+
* gate's latest review verdict is still REVISE/RETHINK — a later re-review
|
|
417
|
+
* (higher R number) with APPROVE clears the gate.
|
|
418
|
+
*
|
|
419
|
+
* Pure: operates on filename strings only.
|
|
420
|
+
*/
|
|
421
|
+
export function latestReviewFilesPerGate(filenames: string[]): Map<string, string> {
|
|
422
|
+
const latest = new Map<string, { round: number; filename: string }>();
|
|
423
|
+
for (const name of filenames) {
|
|
424
|
+
const m = name.match(/^R(\d+)-([a-z]+)-step(\d+)\.md$/i);
|
|
425
|
+
if (!m) continue;
|
|
426
|
+
const round = Number.parseInt(m[1], 10);
|
|
427
|
+
const gate = `${m[2].toLowerCase()}-step${m[3]}`;
|
|
428
|
+
const existing = latest.get(gate);
|
|
429
|
+
if (!existing || round > existing.round) {
|
|
430
|
+
latest.set(gate, { round, filename: name });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return new Map([...latest.entries()].map(([gate, v]) => [gate, v.filename]));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Extract the review round label (e.g. "R008-code-step4") from a review file
|
|
438
|
+
* path like ".reviews/R008-code-step4.md". Returns undefined if the path does
|
|
439
|
+
* not match the expected R{NNN}-{type}-step{N} naming.
|
|
440
|
+
*
|
|
441
|
+
* @param reviewPath Review file path (relative or absolute), or nullish.
|
|
442
|
+
*/
|
|
443
|
+
export function parseReviewLabelFromPath(
|
|
444
|
+
reviewPath: string | undefined | null,
|
|
445
|
+
): string | undefined {
|
|
446
|
+
if (!reviewPath || typeof reviewPath !== "string") return undefined;
|
|
447
|
+
const base = reviewPath.replace(/\\/g, "/").split("/").pop() ?? "";
|
|
448
|
+
const m = base.match(/^(R\d+-[a-z]+-step\d+)\b/i);
|
|
449
|
+
return m ? m[1] : undefined;
|
|
450
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* segment-recovery.ts — Segment-record writers for supervisor recovery tools.
|
|
3
|
+
*
|
|
4
|
+
* On the v2 (segment) runtime, `segments[]` is the authoritative execution
|
|
5
|
+
* record: resume's `reconstructSegmentFrontier()` re-derives each task's
|
|
6
|
+
* status FROM its segment records. A recovery tool that mutates only the
|
|
7
|
+
* task record is therefore silently undone on the next resume (#629: retry
|
|
8
|
+
* reset the task to `pending`, the segment stayed `failed`, the wave was
|
|
9
|
+
* counted as done and the batch no-op'd).
|
|
10
|
+
*
|
|
11
|
+
* These helpers keep segment authority intact by fixing the WRITERS: every
|
|
12
|
+
* tool that changes a task's terminal status must change its segments too.
|
|
13
|
+
* They are pure (mutate the passed state, return a summary) so they can be
|
|
14
|
+
* unit-tested against fixture states.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { PersistedBatchState, PersistedSegmentRecord } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export interface SegmentResetSummary {
|
|
20
|
+
/** Segment IDs reset to pending */
|
|
21
|
+
resetSegmentIds: string[];
|
|
22
|
+
/** Segment IDs left untouched because they already succeeded/skipped */
|
|
23
|
+
preservedSegmentIds: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Reset a task's failed/stalled segments to `pending` for re-execution.
|
|
28
|
+
*
|
|
29
|
+
* - Clears exit data (`startedAt`, `endedAt`, `exitDiagnostic`, `exitReason`).
|
|
30
|
+
* - Increments `retries` (semantics: retry REQUESTS — see engine.ts spawn
|
|
31
|
+
* path, which only increments on restart when `startedAt !== null`, so a
|
|
32
|
+
* cleared `startedAt` does not double-count).
|
|
33
|
+
* - KEEPS `laneId`, `sessionName`, `worktreePath`, `branch`: resume's
|
|
34
|
+
* re-execute path reuses the existing worktree so partial work survives.
|
|
35
|
+
* - Succeeded/skipped segments are preserved (multi-segment tasks resume
|
|
36
|
+
* from the failed segment, not from scratch).
|
|
37
|
+
* - Also clears the task's `activeSegmentId` so the frontier re-derives it.
|
|
38
|
+
*/
|
|
39
|
+
export function resetTaskSegmentsForRetry(
|
|
40
|
+
state: PersistedBatchState,
|
|
41
|
+
taskId: string,
|
|
42
|
+
): SegmentResetSummary {
|
|
43
|
+
const summary: SegmentResetSummary = { resetSegmentIds: [], preservedSegmentIds: [] };
|
|
44
|
+
for (const seg of state.segments ?? []) {
|
|
45
|
+
if (seg.taskId !== taskId) continue;
|
|
46
|
+
if (
|
|
47
|
+
seg.status === "failed" ||
|
|
48
|
+
seg.status === "stalled" ||
|
|
49
|
+
seg.status === "running" ||
|
|
50
|
+
seg.status === "skipped"
|
|
51
|
+
) {
|
|
52
|
+
// `running` is included defensively: a segment left `running` by a
|
|
53
|
+
// dead engine that the operator then retries should re-execute, not
|
|
54
|
+
// be reconstructed as in-flight forever. `skipped` is included so a
|
|
55
|
+
// task wrongly skipped by a runtime defect (pause→skipped) re-executes;
|
|
56
|
+
// an intentionally skipped task is only retried on explicit operator
|
|
57
|
+
// request, which is what orch_retry_task is.
|
|
58
|
+
seg.status = "pending";
|
|
59
|
+
seg.startedAt = null;
|
|
60
|
+
seg.endedAt = null;
|
|
61
|
+
seg.exitDiagnostic = undefined;
|
|
62
|
+
seg.exitReason = "";
|
|
63
|
+
seg.retries = (seg.retries ?? 0) + 1;
|
|
64
|
+
summary.resetSegmentIds.push(seg.segmentId);
|
|
65
|
+
} else {
|
|
66
|
+
summary.preservedSegmentIds.push(seg.segmentId);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const task = state.tasks.find((t) => t.taskId === taskId);
|
|
70
|
+
if (task) task.activeSegmentId = null;
|
|
71
|
+
return summary;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Mark a task's non-terminal-success segments as `skipped`.
|
|
76
|
+
*
|
|
77
|
+
* Today `reconstructSegmentFrontier` happens to preserve a task-level
|
|
78
|
+
* `skipped` even when segments read `failed`; this makes the records agree
|
|
79
|
+
* so the invariant does not rest on that accident. Succeeded segments stay
|
|
80
|
+
* succeeded (their merged work is real).
|
|
81
|
+
*/
|
|
82
|
+
export function markTaskSegmentsSkipped(
|
|
83
|
+
state: PersistedBatchState,
|
|
84
|
+
taskId: string,
|
|
85
|
+
endedAt: number = Date.now(),
|
|
86
|
+
): string[] {
|
|
87
|
+
const skipped: string[] = [];
|
|
88
|
+
for (const seg of state.segments ?? []) {
|
|
89
|
+
if (seg.taskId !== taskId) continue;
|
|
90
|
+
if (seg.status === "succeeded" || seg.status === "skipped") continue;
|
|
91
|
+
seg.status = "skipped";
|
|
92
|
+
seg.endedAt = seg.endedAt ?? endedAt;
|
|
93
|
+
seg.exitReason = seg.exitReason || "Skipped by supervisor";
|
|
94
|
+
skipped.push(seg.segmentId);
|
|
95
|
+
}
|
|
96
|
+
return skipped;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply a re-execution outcome (resume's `re-execute` path, which runs the
|
|
101
|
+
* task in its existing worktree) to the task's segment records.
|
|
102
|
+
*
|
|
103
|
+
* Resume copies `segments[]` into the runtime state but — before #629 — never
|
|
104
|
+
* transitioned them when re-execution finished, so a successful retry could
|
|
105
|
+
* persist `task=succeeded, segment=pending`; the next resume then normalized
|
|
106
|
+
* the task back to pending and refused `.DONE` authority.
|
|
107
|
+
*
|
|
108
|
+
* SCOPE: re-execution builds its execution unit from the task's
|
|
109
|
+
* `activeSegmentId`, i.e. it runs ONE segment (or the whole task for
|
|
110
|
+
* single-segment/legacy tasks, `segmentId` null). Only that executed segment
|
|
111
|
+
* may take the outcome; marking every pending segment succeeded would
|
|
112
|
+
* silently skip downstream segments. When `executedSegmentId` is null, all
|
|
113
|
+
* still-non-terminal segments are the whole task and take the status.
|
|
114
|
+
* Already-terminal segments are never touched.
|
|
115
|
+
*/
|
|
116
|
+
export function applyReExecutionOutcomeToSegments(
|
|
117
|
+
segments: PersistedSegmentRecord[] | undefined,
|
|
118
|
+
taskId: string,
|
|
119
|
+
status: "succeeded" | "failed",
|
|
120
|
+
outcome: {
|
|
121
|
+
startTime?: number | null;
|
|
122
|
+
endTime?: number | null;
|
|
123
|
+
exitReason?: string;
|
|
124
|
+
exitDiagnostic?: PersistedSegmentRecord["exitDiagnostic"];
|
|
125
|
+
},
|
|
126
|
+
executedSegmentId: string | null = null,
|
|
127
|
+
now: number = Date.now(),
|
|
128
|
+
): string[] {
|
|
129
|
+
const updated: string[] = [];
|
|
130
|
+
for (const seg of segments ?? []) {
|
|
131
|
+
if (seg.taskId !== taskId) continue;
|
|
132
|
+
if (executedSegmentId !== null && seg.segmentId !== executedSegmentId) continue;
|
|
133
|
+
if (seg.status !== "pending" && seg.status !== "running") continue;
|
|
134
|
+
seg.status = status;
|
|
135
|
+
seg.startedAt = seg.startedAt ?? outcome.startTime ?? now;
|
|
136
|
+
seg.endedAt = outcome.endTime ?? now;
|
|
137
|
+
seg.exitReason =
|
|
138
|
+
outcome.exitReason ??
|
|
139
|
+
(status === "succeeded" ? "Re-executed task completed" : "Re-executed task failed");
|
|
140
|
+
seg.exitDiagnostic = status === "failed" ? outcome.exitDiagnostic : undefined;
|
|
141
|
+
updated.push(seg.segmentId);
|
|
142
|
+
}
|
|
143
|
+
return updated;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* After applying a segment outcome: is the TASK complete (every segment
|
|
148
|
+
* terminal-success)? Drives whether resume may count a re-executed task as
|
|
149
|
+
* completed. Tasks without segment records are complete iff the outcome was
|
|
150
|
+
* a success (caller decides).
|
|
151
|
+
*/
|
|
152
|
+
export function taskSegmentsAllSucceeded(
|
|
153
|
+
segments: PersistedSegmentRecord[] | undefined,
|
|
154
|
+
taskId: string,
|
|
155
|
+
): boolean | null {
|
|
156
|
+
const own = (segments ?? []).filter((s) => s.taskId === taskId);
|
|
157
|
+
if (own.length === 0) return null;
|
|
158
|
+
return own.every((s) => s.status === "succeeded" || s.status === "skipped");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Advance a task's `activeSegmentId` to its next non-terminal segment (in
|
|
163
|
+
* `segmentIds` order) after a segment outcome was applied. Returns the new
|
|
164
|
+
* active segment id, or null when every segment is terminal. Without this, a
|
|
165
|
+
* re-executed non-final segment left the task pointing at the segment that
|
|
166
|
+
* just succeeded, so the next execution re-ran it and mistook that segment's
|
|
167
|
+
* success for whole-task completion.
|
|
168
|
+
*/
|
|
169
|
+
export function advanceActiveSegment(state: PersistedBatchState, taskId: string): string | null {
|
|
170
|
+
const task = state.tasks.find((t) => t.taskId === taskId);
|
|
171
|
+
if (!task) return null;
|
|
172
|
+
const order = task.segmentIds ?? [];
|
|
173
|
+
const byId = new Map((state.segments ?? []).map((s) => [s.segmentId, s] as const));
|
|
174
|
+
for (const id of order) {
|
|
175
|
+
const seg = byId.get(id);
|
|
176
|
+
const status = seg?.status ?? "pending";
|
|
177
|
+
if (status === "pending" || status === "running") {
|
|
178
|
+
task.activeSegmentId = id;
|
|
179
|
+
return id;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
task.activeSegmentId = null;
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Convenience for tests/diagnostics: segment records belonging to a task. */
|
|
187
|
+
export function segmentsForTask(
|
|
188
|
+
state: PersistedBatchState,
|
|
189
|
+
taskId: string,
|
|
190
|
+
): PersistedSegmentRecord[] {
|
|
191
|
+
return (state.segments ?? []).filter((s) => s.taskId === taskId);
|
|
192
|
+
}
|