Skip to content

fix(byoa): a turn that cannot succeed must stop retrying on both paths - #173

Merged
WhichPaths merged 1 commit into
yetone:mainfrom
WhichPaths:feat/paused-agent-visibility
Sep 3, 2026
Merged

fix(byoa): a turn that cannot succeed must stop retrying on both paths#173
WhichPaths merged 1 commit into
yetone:mainfrom
WhichPaths:feat/paused-agent-visibility

Conversation

@WhichPaths

Copy link
Copy Markdown
Collaborator

The fifteen-minute operator pause was measured into existence, and the measurement is in its own doc comment: nine computers whose Claude was signed out produced 1,988 failed turns in forty minutes, with no backoff and nothing telling their operator why.

It was wired into the chat wake only.

maybeAgendaTurn() is the second path a turn ends on. It fires on its own every AGENDA_CHECK_MS (60s) whether or not anyone is talking to the agent, and it classified the same failure differently:

// chat path
if (engineError && !rateLimited && needsOperatorFix(engineError)) {
  this.engineBackoffUntil = Date.now() + ENGINE_BACKOFF_AFTER_OPERATOR_FIX_MS   // pause
}

// agenda path — the whole branch is absent
if (engineError && agendaRateLimited) {  } else if (!engineError) {  }

With engineError set and agendaRateLimited false, neither arm runs. The pause is never entered, so the next tick sixty seconds later spawns the signed-out engine again, and the one after that, for as long as it stays signed out.

So the agents that kept spinning were precisely the ones nobody was chatting with — the pause looked like it worked because the path that demonstrated it was the path that had it.

The notice is deduplicated over dedupeTtlSec: 900, the same fifteen minutes the pause covers. That sets the ratio: roughly fifteen dead spawns per agent per message the operator actually sees. Quietly burning the fleet's error budget is the failure mode the constant's doc comment already names.

What changes

One classifier, one assignment site.

export type TurnOutcome = 'ok' | 'rate-limited' | 'operator-fix' | 'transient'
export function classifyTurnOutcome(engineError: string | null | undefined): TurnOutcome
export function backoffUntilFor(outcome: TurnOutcome, now: number): number | null

and one private method both paths end in:

private applyTurnBackoff(outcome: TurnOutcome): void

this.engineBackoffUntil is now assigned in exactly one place in the file. A path can still fail to ask — that is one grep away — but the two can no longer hold different opinions about what a failure means, which is how they drifted.

Three details preserved deliberately:

  • A throttle still outranks an operator fix. The old order was !rateLimited && needsOperatorFix(…); "insufficient quota" matches both, and it must stay the short self-clearing cooldown that does not post to chat. There is a test pinning that precedence.
  • transient returns null, not 0. An unexplained failure must not cancel a pause an earlier, well-understood one established — otherwise the spin comes back with an extra step in it.
  • '' still counts as a clean turn, because the old code keyed on !engineError and changing it would pause agents that had done nothing wrong.

Also, a log line that misled

Both skip guards printed engine rate-limit cooldown regardless of cause, so an operator pause announced itself as a throttle for fifteen minutes — sending whoever reads the daemon log looking for a rate limit that was never there. This was already true on the chat path before this change. They now print engine paused (operator action needed) / (rate limit).

Verification

  • New test, 9 cases: the classification table, the throttle-outranks precedence, the three deadlines, and null ≠ 0.
  • Confirmed it fails on the old shape. Reverting only the agenda arm turns the two structural assertions red (expected one assignment, found 2; expected the chat and agenda paths, found 1), then green again on restore. Pure functions cannot catch a path that simply does not call them, so those two read the source — the same thing engine-stdin-safety.test.ts and electron-renderer-url.test.ts do.
  • The existing agents-computer-operator-fix-backoff.test.ts is untouched and still green; every string in it classifies as before.
  • Full unit suite: zero new failures against main — identical failure sets, all of them needing a live Postgres locally.
  • tsc --noEmit, biome lint ., and all three source guards clean.
  • I also walked the import graph of all 39 integration files: none of them reach computer/daemon.ts, so that suite neither exercises this nor can be disturbed by it. Saying so beats reporting a run that proves nothing.

What I did not do: reproduce a live signed-out agenda turn end to end — I have no BYOA engine credentials to sign out. The reasoning above is from the source and the maintainer's own measurement; the structural assertions are what pin it.

Deliberately not in scope: widening OPERATOR_FIX_RE itself. argvRejection() already detects an engine CLI too old for the flags we send — a failure that also cannot succeed on retry — but it is only wired into the probes, and the negative cases in the existing test show the bar for that regex is intentionally high. That is a separate argument, worth making separately.

The fifteen-minute operator pause was measured into existence — nine
computers whose Claude was signed out produced 1,988 failed turns in
forty minutes — but it was wired into the chat wake only.

maybeAgendaTurn() is the second path a turn ends on, and it fires on its
own every 60s whether or not anyone is talking to the agent. Its error
handling had no operator-fix branch at all: with engineError set and the
rate-limit test false, neither arm ran, so the pause was never entered
and the next tick spawned the signed-out engine again. The agents that
kept spinning were precisely the ones nobody was chatting with.

The notice those turns publish is deduplicated over the same fifteen
minutes the pause covers, so the ratio was roughly fifteen dead spawns
per agent per message the operator actually saw.

Classify once, in classifyTurnOutcome(), and assign the pause in one
method both paths end in. engineBackoffUntil now has exactly one
assignment site in the file, so the two paths cannot drift again.

Precedence is preserved exactly: a throttle still outranks an operator
fix ("insufficient quota" matches both and must stay the short,
self-clearing cooldown), a transient failure returns null rather than 0
so it cannot cancel a pause an earlier understood failure established,
and an empty error string still counts as a clean turn.

Both skip guards also printed "engine rate-limit cooldown" whatever the
cause, so an operator pause announced itself as a throttle for fifteen
minutes. They now name the real reason.
@WhichPaths
WhichPaths merged commit cdab8cc into yetone:main Sep 3, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant