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.
@@ -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
+ }