diff --git a/fleetd/src/main/java/dev/ltms/fleet/inject/CompletionResolver.java b/fleetd/src/main/java/dev/ltms/fleet/inject/CompletionResolver.java index 008e472..0837ebd 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/inject/CompletionResolver.java +++ b/fleetd/src/main/java/dev/ltms/fleet/inject/CompletionResolver.java @@ -380,7 +380,9 @@ public final class CompletionResolver implements TurnListener { + "matched the profile's exhausted pattern): {}", target, reason); // CB-578 stage B: only on the resolution that actually won the race — a late // duplicate must never quarantine a credential twice for one refusal. - exhaustionSink.onExhausted(target, reason); + if (startsWithExhaustion(matchedLine, exhausted)) { + exhaustionSink.onExhausted(target, reason); + } } return; } @@ -478,7 +480,9 @@ public final class CompletionResolver implements TurnListener { + "usable assistant block; no fleet_reply): {}", target, reason); // CB-578 stage B: only on the resolution that actually won the race — a late // duplicate must never quarantine a credential twice for one refusal. - exhaustionSink.onExhausted(target, reason); + if (startsWithExhaustion(matchedLine, exhausted)) { + exhaustionSink.onExhausted(target, reason); + } } return true; } @@ -614,19 +618,38 @@ public final class CompletionResolver implements TurnListener { } /** - * True when the error pattern begins the matched pane line, rather than appearing in prose. + * True when the pattern appears before the first sentence ending in the matched pane line, rather + * than after a member has started prose about it. * *
Leading terminal chrome is skipped first — box-drawing characters, bullets, gutter bars and - * spaces. #339 introduced this check with a bare {@code lookingAt}, and that rejected a genuine - * error line rendered as {@code "| 503 Service Unavailable: ..."}: the send still failed, but the - * credential outage was never recorded. That is the false negative #339's own invariant 3 called - * worse than the false positive it set out to fix — measured with a throwaway probe on the - * raw-scrape path, which is exactly the path whose comment says to expect leading chrome. + * spaces. Exhaustion patterns often name only the decisive words in a provider message, such as + * {@code "usage limit has been reached"}; they do not include its leading {@code "The"}. A bare + * {@code lookingAt} would therefore reject that genuine refusal, including one behind terminal + * chrome. * - *
Skipping only a leading run of non-letter, non-digit characters keeps the fix's intent. A - * member's prose ({@code "I checked the retry path. An API Error: makes it back off."}) still - * does not match, because there the pattern sits after words, not after chrome. + *
A member's prose about a refusal normally follows a completed sentence. That sentence ending
+ * is enough to keep it from reaching a cooldown sink while the send still fails with the full pane
+ * tail. This is deliberately less strict than the backend-error check because exhausted patterns
+ * may start after a provider's leading words.
*/
+ private static boolean startsWithExhaustion(String line, Pattern pattern) {
+ int i = 0;
+ while (i < line.length() && !Character.isLetterOrDigit(line.charAt(i))) {
+ i++;
+ }
+ var matcher = pattern.matcher(line);
+ if (!matcher.find()) {
+ return false;
+ }
+ for (int prefix = i; prefix < matcher.start(); prefix++) {
+ if (".!?".indexOf(line.charAt(prefix)) >= 0) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** True when an error pattern begins the matched pane line after optional terminal chrome. */
private static boolean startsWithBackendError(String line, Pattern pattern) {
int i = 0;
while (i < line.length() && !Character.isLetterOrDigit(line.charAt(i))) {
diff --git a/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java b/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java
index 15f2ad0..8e73b5d 100644
--- a/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java
+++ b/fleetd/src/test/java/dev/ltms/fleet/inject/CompletionResolverTest.java
@@ -484,6 +484,27 @@ class CompletionResolverTest {
// --- CB-578 stage A: backend-exhausted classification ---------------------------------
+ @Test
+ void aNormalMemberReportMentioningTheExhaustionPatternDoesNotNotifyTheSink() {
+ String block = "⏺ I reviewed capacity handling. The usage limit has been reached means no more work can start.\n❯ ";
+ FakeHerdr herdr = new FakeHerdr().readText(block);
+ Rendezvous rendezvous = new Rendezvous();
+ ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached");
+ java.util.List