Skip to content

Commit 1bb8210

Browse files
Merge pull request #579 from corbitsdev/cl-6964-delete-the-unreachable-thrash-matcher-hint-and-salvage-class
Kill the sticky salvage hard-block's false positives
2 parents 5cccb81 + cd568cd commit 1bb8210

4 files changed

Lines changed: 110 additions & 89 deletions

File tree

src/subagent/brief-dispatch.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
/**
22
* Parent-side re-dispatch caps for task briefs (CL-4343 + CL-5203).
33
*
4-
* Leaf stops already salvage thrash / no-progress / turn-budget / etc. This
4+
* Leaf stops already salvage no-progress / turn-budget / etc. This
55
* module tracks how often the *parent* re-spawns the same brief so:
6-
* - thrash-class salvages hard-block an identical re-dispatch for the rest of
6+
* - hard-block-class salvages refuse an identical re-dispatch for the rest of
77
* the parent chat session (sticky until the fingerprint changes)
88
* - turn-budget salvage flips from "raise maxTurns" to "stop" after enough
99
* same-brief dispatches without a successful complete
@@ -12,21 +12,20 @@
1212
*/
1313

1414
import type { TaskIntent } from "./report.js";
15-
import { parseSubAgentReport } from "./report.js";
1615
import {
1716
isDeadlineSubAgentReport,
17+
isForcedStopSubAgentReport,
1818
isNeverActedSubAgentReport,
1919
isNeverEditedSubAgentReport,
2020
isNoProgressSubAgentReport,
2121
isNoShipSubAgentReport,
2222
isRepetitionSubAgentReport,
23-
isThrashSubAgentReport,
2423
isTurnBudgetSubAgentReport,
2524
} from "./stop-policy.js";
2625

2726
/** Salvage classes that must not be re-dispatched with an identical brief. */
2827
export type HardBlockSalvage =
29-
"thrash" | "no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";
28+
"no-ship" | "no-progress" | "repetition" | "never-acted" | "never-edited";
3029

3130
export type BriefSalvageKind =
3231
HardBlockSalvage | "turn-budget" | "deadline" | "stalled" | "cancelled" | "incomplete-report";
@@ -54,7 +53,6 @@ export interface BriefDispatchRecord {
5453
export const TURN_BUDGET_STOP_AFTER_DISPATCHES = 3;
5554

5655
const HARD_BLOCK_SALVAGES = new Set<BriefSalvageKind>([
57-
"thrash",
5856
"no-ship",
5957
"no-progress",
6058
"repetition",
@@ -68,20 +66,17 @@ export function isHardBlockSalvage(kind: BriefSalvageKind): kind is HardBlockSal
6866

6967
/** True when the worker returned a stall salvage report. */
7068
export function isStalledSubAgentReport(report: string): boolean {
71-
const parsed = parseSubAgentReport(report);
72-
return parsed.summary.toLowerCase().includes("long silence");
69+
return isForcedStopSubAgentReport(report, "stalled");
7370
}
7471

7572
/** True when the worker returned a cancel salvage report. */
7673
export function isCancelledSubAgentReport(report: string): boolean {
77-
const parsed = parseSubAgentReport(report);
78-
return parsed.summary.toLowerCase().includes("cancelled");
74+
return isForcedStopSubAgentReport(report, "cancelled");
7975
}
8076

8177
/** True when the worker returned an incomplete-report salvage (narration, no envelope). */
8278
export function isIncompleteReportSubAgentReport(report: string): boolean {
83-
const parsed = parseSubAgentReport(report);
84-
return parsed.summary.toLowerCase().includes("narrated instead of writing a report envelope");
79+
return isForcedStopSubAgentReport(report, "incomplete-report");
8580
}
8681

8782
/**
@@ -90,7 +85,6 @@ export function isIncompleteReportSubAgentReport(report: string): boolean {
9085
*/
9186
export function classifyBriefSalvage(report: string): BriefSalvageKind | null {
9287
// Order: more specific salvage phrases first.
93-
if (isThrashSubAgentReport(report)) return "thrash";
9488
if (isNoShipSubAgentReport(report)) return "no-ship";
9589
if (isRepetitionSubAgentReport(report)) return "repetition";
9690
if (isNeverEditedSubAgentReport(report)) return "never-edited";
@@ -184,16 +178,11 @@ export function createBriefDispatchLedger(): BriefDispatchLedger {
184178
return;
185179
}
186180
if (salvage === null) {
187-
// Successful complete resets the same-brief retry budget. Hard-block
188-
// lastSalvage is sticky for the session and must not be cleared by a
189-
// concurrent twin that finishes after thrash was already recorded.
190-
if (existing.lastSalvage !== undefined && isHardBlockSalvage(existing.lastSalvage)) {
191-
byFingerprint.set(fingerprint, {
192-
dispatchCount: existing.dispatchCount,
193-
lastSalvage: existing.lastSalvage,
194-
});
195-
return;
196-
}
181+
// CL-6710: a successful complete clears the sticky hard-block too.
182+
// Two concurrent identical-brief dispatches can both admit; if one
183+
// salvages and the other succeeds, the success proves the brief is
184+
// re-dispatchable, so it must not leave the sibling's hard-block
185+
// standing for the rest of the session.
197186
byFingerprint.set(fingerprint, { dispatchCount: 0 });
198187
return;
199188
}

src/subagent/index.test.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2150,19 +2150,19 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
21502150
expect(changed).not.toBe(a);
21512151
});
21522152

2153-
test("hard-blocks identical brief after thrash salvage; allows changed brief", () => {
2153+
test("hard-blocks identical brief after no-progress salvage; allows changed brief", () => {
21542154
const ledger = createBriefDispatchLedger();
2155-
const fp = fingerprintTaskBrief({ prompt: "fix thrash", intent: "implement" });
2155+
const fp = fingerprintTaskBrief({ prompt: "fix no-progress job", intent: "implement" });
21562156
expect(ledger.admit(fp).ok).toBe(true);
2157-
ledger.recordOutcome(fp, "thrash");
2157+
ledger.recordOutcome(fp, "no-progress");
21582158
const blocked = ledger.admit(fp);
21592159
expect(blocked.ok).toBe(false);
21602160
if (blocked.ok) throw new Error("expected block");
21612161
expect(blocked.message).toContain("refused re-dispatch");
2162-
expect(blocked.message).toContain("thrash");
2162+
expect(blocked.message).toContain("no-progress");
21632163

21642164
const other = fingerprintTaskBrief({
2165-
prompt: "fix thrash with narrower scope",
2165+
prompt: "fix no-progress job with narrower scope",
21662166
intent: "implement",
21672167
successCriteria: ["one file only"],
21682168
});
@@ -2187,7 +2187,7 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
21872187
expect(second.dispatchCount).toBe(2);
21882188
});
21892189

2190-
test("successful complete resets retry budget; thrash hard-block is sticky", () => {
2190+
test("successful complete resets retry budget and clears soft salvage", () => {
21912191
const ledger = createBriefDispatchLedger();
21922192
const fp = fingerprintTaskBrief({ prompt: "ok job" });
21932193
expect(ledger.admit(fp).ok).toBe(true);
@@ -2199,13 +2199,24 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
21992199
expect(afterSuccess.ok).toBe(true);
22002200
if (!afterSuccess.ok) throw new Error("expected admit");
22012201
expect(afterSuccess.dispatchCount).toBe(1);
2202+
});
2203+
2204+
test("CL-6710: a parallel sibling success clears a hard-block salvage on the same fingerprint", () => {
2205+
const ledger = createBriefDispatchLedger();
2206+
const fp = fingerprintTaskBrief({ prompt: "parallel identical brief" });
2207+
2208+
// Two concurrent identical-brief dispatches both admit before either finishes.
2209+
expect(ledger.admit(fp).ok).toBe(true);
2210+
expect(ledger.admit(fp).ok).toBe(true);
2211+
2212+
// One sibling salvages (hard-block class)...
2213+
ledger.recordOutcome(fp, "no-progress");
2214+
// ...but the other sibling succeeds in the same wave.
2215+
ledger.recordOutcome(fp, null);
22022216

2203-
// Thrash is sticky for the session — success on a concurrent twin must not clear it.
2204-
const thrashFp = fingerprintTaskBrief({ prompt: "thrash sticky" });
2205-
ledger.admit(thrashFp);
2206-
ledger.recordOutcome(thrashFp, "thrash");
2207-
ledger.recordOutcome(thrashFp, null);
2208-
expect(ledger.admit(thrashFp).ok).toBe(false);
2217+
// The brief already produced a good report this wave — it must stay
2218+
// re-dispatchable, not stuck behind the losing sibling's hard-block.
2219+
expect(ledger.admit(fp).ok).toBe(true);
22092220
});
22102221

22112222
test("release undoes admit when run never produces a body", () => {
@@ -2239,6 +2250,39 @@ describe("brief re-dispatch ledger (CL-4343 / CL-5203)", () => {
22392250
);
22402251
});
22412252

2253+
test("CL-6704: a successful Summary containing forced-stop phrases is not classified as a salvage", () => {
2254+
const noProgressPhrase = formatSubAgentReport({
2255+
summary: "Investigated the flaky test; root cause is a race, not no progress on our side.",
2256+
findings: "Fixed the race in retry logic.",
2257+
blockers: "None",
2258+
paths: "src/retry.ts",
2259+
});
2260+
expect(classifyBriefSalvage(noProgressPhrase)).toBeNull();
2261+
2262+
const cancelledPhrase = formatSubAgentReport({
2263+
summary: "Implemented the cancelled-order refund flow end to end.",
2264+
findings: "Added refund handler and tests.",
2265+
blockers: "None",
2266+
paths: "src/refunds.ts",
2267+
});
2268+
expect(classifyBriefSalvage(cancelledPhrase)).toBeNull();
2269+
2270+
const longSilencePhrase = formatSubAgentReport({
2271+
summary: "Reduced UI flicker with a long silence period before re-render.",
2272+
findings: "Debounced the re-render.",
2273+
blockers: "None",
2274+
paths: "src/ui.ts",
2275+
});
2276+
expect(classifyBriefSalvage(longSilencePhrase)).toBeNull();
2277+
});
2278+
2279+
test("CL-6704: true forced-stop Summary strings still classify as their salvage kind", () => {
2280+
expect(classifyBriefSalvage(forcedStopReport("no-progress", "x"))).toBe("no-progress");
2281+
expect(classifyBriefSalvage(forcedStopReport("cancelled", "x"))).toBe("cancelled");
2282+
expect(classifyBriefSalvage(forcedStopReport("stalled", "x"))).toBe("stalled");
2283+
expect(classifyBriefSalvage(forcedStopReport("deadline", "x"))).toBe("deadline");
2284+
});
2285+
22422286
test("turn-budget parent hint flips after re-dispatch threshold", () => {
22432287
const report = forcedStopReport("turn-budget", "partial");
22442288
const first = appendSubAgentParentHints(report, { dispatchCount: 1 });

src/subagent/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export {
5252
appendNoProgressParentHint,
5353
appendRepetitionParentHint,
5454
appendSubAgentParentHints,
55-
appendThrashParentHint,
5655
appendTurnBudgetParentHint,
5756
evaluateSubAgentStop,
5857
fingerprintToolCalls,
@@ -62,7 +61,6 @@ export {
6261
isNeverEditedSubAgentReport,
6362
isNoProgressSubAgentReport,
6463
isRepetitionSubAgentReport,
65-
isThrashSubAgentReport,
6664
isTurnBudgetSubAgentReport,
6765
nextToolCallStreak,
6866
partialTextFromEvent,

src/subagent/stop-policy.ts

Lines changed: 42 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,27 @@ export type ForcedStopReason =
436436
| "repetition"
437437
| "incomplete-report";
438438

439+
// Exact Summary text for each forced-stop reason. This is the single source
440+
// of truth for both forcedStopReport (the producer) and the isXxxSubAgentReport
441+
// classifiers (the consumers) — CL-6704: classifying on a free-text substring
442+
// like "no progress" or "cancelled" hard-blocks a SUCCESSFUL report whose
443+
// Summary happens to contain that phrase. Matching the exact string a forced
444+
// stop actually produces closes that false-positive path without a report
445+
// schema change (a typed marker would need one; see CL-6786, out of scope).
446+
const FORCED_STOP_SUMMARIES: Record<ForcedStopReason, string> = {
447+
"no-progress": "Stopped: repeated the same tool calls with no progress.",
448+
"no-ship": "Stopped: implement intent searched many files without writing any.",
449+
"never-acted": "Stopped: completed without using any tools.",
450+
"never-edited": "Stopped: implement intent finished without writing any files.",
451+
cancelled: "Stopped: cancelled by operator before finishing.",
452+
deadline: "Stopped: wall-clock deadline reached before finishing.",
453+
stalled:
454+
"Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly.",
455+
repetition: "Stopped: degenerate repetition in streamed output (same window looping mid-turn).",
456+
"incomplete-report": "Stopped: worker narrated instead of writing a report envelope.",
457+
"turn-budget": "Turn budget reached before finishing.",
458+
};
459+
439460
/**
440461
* Build the parent-facing report when a leaf is force-stopped. There is no
441462
* further inference, so this must already be a full envelope — not an
@@ -449,26 +470,7 @@ export function forcedStopReport(
449470
partialText: string,
450471
detail?: string,
451472
): string {
452-
const summary =
453-
reason === "no-progress"
454-
? "Stopped: repeated the same tool calls with no progress."
455-
: reason === "no-ship"
456-
? "Stopped: implement intent searched many files without writing any."
457-
: reason === "never-acted"
458-
? "Stopped: completed without using any tools."
459-
: reason === "never-edited"
460-
? "Stopped: implement intent finished without writing any files."
461-
: reason === "cancelled"
462-
? "Stopped: cancelled by operator before finishing."
463-
: reason === "deadline"
464-
? "Stopped: wall-clock deadline reached before finishing."
465-
: reason === "stalled"
466-
? "Stopped after a long silence with no tool activity. The parent can re-dispatch or check the background work directly."
467-
: reason === "repetition"
468-
? "Stopped: degenerate repetition in streamed output (same window looping mid-turn)."
469-
: reason === "incomplete-report"
470-
? "Stopped: worker narrated instead of writing a report envelope."
471-
: "Turn budget reached before finishing.";
473+
const summary = FORCED_STOP_SUMMARIES[reason];
472474
const blockers =
473475
reason === "no-progress"
474476
? "Identical tool-call fingerprint repeated consecutively; parent must not re-dispatch the identical brief (it will be refused) — tighten success_criteria/do_not or change approach."
@@ -506,40 +508,40 @@ export function forcedStopReport(
506508
});
507509
}
508510

511+
/**
512+
* True when a report's Summary is exactly the forced-stop text for `reason`
513+
* (CL-6704: exact match, not a free-text substring — a successful report
514+
* whose Summary happens to mention the same words must not classify as a
515+
* forced stop).
516+
*/
517+
export function isForcedStopSubAgentReport(report: string, reason: ForcedStopReason): boolean {
518+
const parsed = parseSubAgentReport(report);
519+
return parsed.summary === FORCED_STOP_SUMMARIES[reason];
520+
}
521+
509522
/** True when the worker returned a turn-budget salvage report for the parent. */
510523
export function isTurnBudgetSubAgentReport(report: string): boolean {
511-
const parsed = parseSubAgentReport(report);
512-
return parsed.summary.includes("Turn budget reached");
524+
return isForcedStopSubAgentReport(report, "turn-budget");
513525
}
514526

515527
/** True when the worker returned a never-acted salvage report for the parent. */
516528
export function isNeverActedSubAgentReport(report: string): boolean {
517-
const parsed = parseSubAgentReport(report);
518-
return parsed.summary.includes("without using any tools");
529+
return isForcedStopSubAgentReport(report, "never-acted");
519530
}
520531

521532
/** True when implement intent finished without any write/edit tools. */
522533
export function isNeverEditedSubAgentReport(report: string): boolean {
523-
const parsed = parseSubAgentReport(report);
524-
return parsed.summary.includes("without writing any files");
534+
return isForcedStopSubAgentReport(report, "never-edited");
525535
}
526536

527537
/** True when the worker returned a deadline salvage report for the parent. */
528538
export function isDeadlineSubAgentReport(report: string): boolean {
529-
const parsed = parseSubAgentReport(report);
530-
return parsed.summary.includes("deadline reached");
531-
}
532-
533-
/** True when the worker returned a progressive-thrash salvage report. */
534-
export function isThrashSubAgentReport(report: string): boolean {
535-
const parsed = parseSubAgentReport(report);
536-
return parsed.summary.includes("progressive thrash");
539+
return isForcedStopSubAgentReport(report, "deadline");
537540
}
538541

539542
/** True when the worker returned a streamed-repetition salvage report. */
540543
export function isRepetitionSubAgentReport(report: string): boolean {
541-
const parsed = parseSubAgentReport(report);
542-
return parsed.summary.includes("degenerate repetition");
544+
return isForcedStopSubAgentReport(report, "repetition");
543545
}
544546

545547
const TURN_BUDGET_PARENT_HINT =
@@ -558,9 +560,6 @@ const NEVER_EDITED_PARENT_HINT =
558560
const DEADLINE_PARENT_HINT =
559561
"[Sub-agent hit an explicit wall-clock deadline before finishing. Continue from Findings rather than redoing completed work; re-dispatch with continuation context and a longer deadline only if more wall-clock time is warranted.]";
560562

561-
const THRASH_PARENT_HINT =
562-
"[Sub-agent stopped for progressive thrash (re-read pressure). Do not re-dispatch the identical brief (it will be refused) — change scope, success_criteria, and do_not; continue from Findings.]";
563-
564563
const NO_SHIP_PARENT_HINT =
565564
"[Sub-agent stopped after searching many files without writing any. Do not search the repo yourself and do not re-dispatch the identical brief (it will be refused) — change success_criteria and do_not, or treat findings as unexecuted.]";
566565

@@ -611,15 +610,9 @@ export function appendDeadlineParentHint(report: string): string {
611610
return `${DEADLINE_PARENT_HINT}\n\n${report}`;
612611
}
613612

614-
export function appendThrashParentHint(report: string): string {
615-
if (!isThrashSubAgentReport(report)) return report;
616-
return `${THRASH_PARENT_HINT}\n\n${report}`;
617-
}
618-
619613
/** True when the worker returned a no-ship (search-tour) salvage report. */
620614
export function isNoShipSubAgentReport(report: string): boolean {
621-
const parsed = parseSubAgentReport(report);
622-
return parsed.summary.includes("searched many files without writing");
615+
return isForcedStopSubAgentReport(report, "no-ship");
623616
}
624617

625618
export function appendNoShipParentHint(report: string): string {
@@ -634,16 +627,15 @@ export function appendRepetitionParentHint(report: string): string {
634627

635628
/** True when the worker returned a no-progress salvage report. */
636629
export function isNoProgressSubAgentReport(report: string): boolean {
637-
const parsed = parseSubAgentReport(report);
638-
return parsed.summary.includes("no progress");
630+
return isForcedStopSubAgentReport(report, "no-progress");
639631
}
640632

641633
export function appendNoProgressParentHint(report: string): string {
642634
if (!isNoProgressSubAgentReport(report)) return report;
643635
return `${NO_PROGRESS_PARENT_HINT}\n\n${report}`;
644636
}
645637

646-
/** Stack parent-visible salvage hints for thrash / budget / never-acted / deadline / repetition / no-progress. */
638+
/** Stack parent-visible salvage hints for budget / never-acted / deadline / repetition / no-progress. */
647639
export function appendSubAgentParentHints(
648640
report: string,
649641
options: SubAgentParentHintOptions = {},
@@ -652,9 +644,7 @@ export function appendSubAgentParentHints(
652644
appendNeverEditedParentHint(
653645
appendNeverActedParentHint(
654646
appendTurnBudgetParentHint(
655-
appendNoProgressParentHint(
656-
appendNoShipParentHint(appendThrashParentHint(appendRepetitionParentHint(report))),
657-
),
647+
appendNoProgressParentHint(appendNoShipParentHint(appendRepetitionParentHint(report))),
658648
options,
659649
),
660650
),

0 commit comments

Comments
 (0)