From f429ca1a504d456f183351cf7f549f913cfe963c Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 4 Sep 2026 16:43:16 +0700 Subject: [PATCH] Avoid exhaustion cooldown for member prose --- .../ltms/fleet/inject/CompletionResolver.java | 45 ++++++++++++++----- .../fleet/inject/CompletionResolverTest.java | 40 +++++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) 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 notified = new java.util.ArrayList<>(); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); + CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); + + var waiter = rendezvous.open("term_a"); + resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null)); + + assertEquals(Rendezvous.Kind.BACKEND_EXHAUSTED, waiter.getNow(null).kind(), + "a matching report still fails the send as exhausted"); + assertTrue(waiter.getNow(null).text().contains("I reviewed capacity handling."), + "the exhausted result keeps the whole matched pane line"); + assertTrue(notified.isEmpty(), + "a normal report mentioning an exhaustion pattern must not quarantine a credential"); + } + @Test void classifiesAMatchingScrapeAsBackendExhaustedInsteadOfACompletedReply() { String block = "⏺ Working on it...\nThe usage limit has been reached. Try again later.\n❯ "; @@ -534,6 +555,25 @@ class CompletionResolverTest { "the sink is told the matched reason: " + notified.get(0)); } + @Test + void aRealExhaustionBehindTerminalChromeStillNotifiesTheSink() { + String block = "⏺ │ The usage limit has been reached. Try again later.\n❯ "; + FakeHerdr herdr = new FakeHerdr().readText(block); + Rendezvous rendezvous = new Rendezvous(); + ExhaustedPatternLookup patterns = target -> Pattern.compile("usage limit has been reached"); + java.util.List notified = new java.util.ArrayList<>(); + ExhaustionSink sink = (target, reason, profile) -> notified.add(target + ": " + reason); + CompletionResolver resolver = new CompletionResolver(new AgentControl(herdr), rendezvous, patterns, sink); + + var waiter = rendezvous.open("term_a"); + resolver.resolve("term_a", new CompletionResolver.InFlight(waiter, null)); + + assertEquals(Rendezvous.Kind.BACKEND_EXHAUSTED, waiter.getNow(null).kind(), + "a real exhaustion must still fail the send as exhausted"); + assertEquals(1, notified.size(), + "a real exhaustion behind terminal chrome must reach the sink"); + } + @Test void aLosingBackendExhaustedClassificationNeverNotifiesTheExhaustionSink() { // The waiter was already resolved (e.g. by the worker's own reply) before this scrape landed —