fix(agentbox): never shrink a pool on an unanswered replica count - #544
Open
jacoblee-io wants to merge 2 commits into
Open
jacoblee-io wants to merge 2 commits into
jacoblee-io wants to merge 2 commits into
Conversation
A shared AgentBox namespace has more than one writer, and the pool reconciler did not know it. `spawner.list()` is scoped to the namespace and the `app=agentbox` label — nothing identifies the Runtime — so every tick walked siblings' pods and judged them by this Runtime's own configuration. `resolveReplicas` then collapsed the two answers the control plane can give into one. "Not assigned to this Runtime" and "one replica" became the same number, so a sibling's healthy three-box pool read as two boxes too many, was drained, and was removed at the drain deadline while those boxes were still reporting metrics-flush. The whole chain was in the production log, buried under a copy of the lookup failure per tick. Split the answer at the source: `lookupReplicas` returns `undefined` for "cannot establish", and only `resolveReplicas` — the serving path — reads that as one box. A request naming the agent is itself evidence that this Runtime serves it, so ownership gates destruction, not service. The check is the first statement of the reconcile loop body, ahead of crash healing, staleness marking, roll advance and shrink; anywhere later and the destruction has already happened. A failed lookup stays uncached, because a remembered unknown would hold a multi-replica agent at one box for the whole TTL and send every new session to instance 0. Its two costs are paid separately instead: the log line is rate-limited, and the per-tick RPC for a sibling's agent is absorbed by a memo that `reconcilePoolSizes` alone reads — one reader is what makes it provably invisible to the serving path. Not fixed here, and now written down: `sweepOrphans`' capability-box branch asks this Runtime's `isLive` about a sibling's live kb-compile box. Its chat-box branch removes terminal pods only, which is safe across Runtimes. §1.3's CA-fingerprint bullet is corrected too — it assumed a singleton Runtime Deployment made the namespace single-writer, which is the reading that let this through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reframes the previous commit and adds the rule that actually covers a deploy. The earlier root-cause story — several Runtimes sharing one AgentBox namespace — was inferred, not observed, and it is not the production topology. The defect does not need it. `agents.replicas` arrives over RPC, and a failed lookup carries no information about the pool's correct size. The old code returned 1, so one failure shrank a live pool to a single box. Why it fails is not one thing: `FrontendWsClient.request` rejects immediately when the WS is down (it does not queue), the RPC can time out against a restarting control plane, and the control plane can refuse an agent whose record moved. The drain reaper starts with `setBoxStatusProbe` and does not wait for the control plane, so a Runtime restart puts its first tick ten seconds in — which makes a deploy the likeliest trigger, with one Runtime and one namespace. Three rules, and the first two are not sufficient alone: - `lookupReplicas` answers `undefined` for "cannot establish"; only `resolveReplicas`, the serving path, may read that as one box. A request naming the agent is evidence this Runtime serves it, so destruction requires an answer and service does not. - The check is the first statement of the reconcile loop body, ahead of crash healing, staleness marking, roll advance and shrink. - A `DrainMark` now records WHY, and an `excess` mark is withdrawn once the pool is not over its count. This is what the other two cannot cover: a mark is acted on ticks after it is made, so a lookup that fails once and recovers leaves a shrink queued against a pool that was never surplus, and nothing revisited it. The withdrawal compares `boxes.length`, never `accepting.length` — the latter has already had the marks subtracted and can only answer "no". `stale` and `unresponsive` marks are re-reached on re-examination, so withdrawing them would only churn the drain budget. `reapDrainedBoxes` re-confirms before `stop()` and drops a mark whose agent became unresolvable; holding it would keep the box out of its own pool's `accepting` set forever, since every later tick answers unknown too. A failed lookup stays uncached — a remembered unknown would hold a multi-replica agent at one box for the whole TTL — so its two costs are paid separately: the log line is rate-limited, and the repeated RPC is absorbed by a memo only `reconcilePoolSizes` reads. Test fixtures reached into the private `draining` map in eight places with a literal mark shape; they now go through `markDraining`, which is why the reason field was missed on the first pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
agents.replicasarrives over RPC (config.getAgenton the control-plane WS). The old code read a failed lookup as 1:That comment shows the trap: the author considered a failed lookup and reasoned about one direction — it must not scale an agent up. The other direction is worse. A few lines later:
So one failed RPC shrinks a live multi-box pool to a single box. A failed lookup carries no information about the pool's correct size, and the two directions of a wrong guess are not symmetric: guessing high costs a pod, guessing low costs a running pool.
Why it fires on a deploy
Every one of these produces the same rejected promise, and none needs a second Runtime:
FrontendWsClient.requestrejects immediately — it does not queue:And the timing lines up with a restart. The drain reaper is armed in
setBoxStatusProbeand does not wait for the control plane, so its first tick is 10 s after the Runtime process starts — while the WS may still be connecting, or the control plane may itself be rolling. AgentBox pods are independent of the Runtime and are adopted on restart, so the previous generation's full pool is already there to be judged.The fix — three rules, and the first two are not sufficient
lookupReplicasanswersundefinedfor "cannot establish". OnlyresolveReplicas— the serving path — may read that as one box: a request naming the agent is evidence this Runtime serves it, and one box is the safe shape for work that has to happen. Destruction requires an answer; service does not.The check is the first statement of the reconcile loop body, ahead of
healCrashedBoxes/markStaleBoxesDraining/advanceRoll/ shrink. It previously sat after the shrink's ownaccepting.length <= 1short-circuit — that micro-optimisation is what put the question in the wrong place.A
DrainMarknow records WHY, and anexcessmark is withdrawn once the pool is not over its count. This is the rule that covers the deploy, and neither of the first two can: a mark is acted on ticks after it is made, so a lookup that fails for one tick and recovers leaves a shrink queued against a pool that was never surplus — and nothing revisited it.The withdrawal compares
boxes.length, neveraccepting.length: the latter has already had the marks subtracted, so it can only ever answer "no".staleandunresponsivemarks are judged from what the box itself presents and are re-reached on re-examination, so withdrawing them would only churn the drain budget. As a bonus this also fixes an operator raisingreplicasback up mid-shrink, which previously did not cancel it.Also:
reapDrainedBoxesre-confirms beforestop()and drops a mark whose agent became unresolvable — holding it would keep the box out of its own pool'sacceptingset forever, since every later tick answers unknown too.A failed lookup deliberately stays uncached: a remembered unknown would hold a multi-replica agent at one box for the whole TTL and send every new session to instance 0. Its two costs are paid separately instead — the log line is rate-limited (
REPLICAS_UNKNOWN_LOG_INTERVAL_MS), and the repeated RPC absorbed by a memo (UNOWNED_MEMO_MS) thatreconcilePoolSizesalone reads.Why this never reproduced in a test namespace
The shrink path is gated on
accepting.length <= 1, which is checked before the lookup. A single-box agent never reaches it at all. Across every namespace in the test cluster there is exactly one multi-replica pool, and it was created by hand to exercise pooling:Not "low probability" — unreachable. Pooling is only used in production, so production is the only place this path executes.
Tests
Nine cases in
manager.test.ts, each reverse-verified by reverting the corresponding rule and confirming failure.FrontendWsClient is not connectederrorstalemark is not withdrawn (re-examination reaches it again; withdrawing churns the budget)Fixtures now mark boxes through
markDrainingrather than eight literal copies of the mark shape — which is exactly how the reason field was missed on the first pass.Full suite: 294 files, 6579 passed.
Known gaps, documented rather than half-fixed
reconcilePoolSizesheals crashes and advances rolls but never re-fills a shrink, so recovery waits for a request to reachgetOrCreatePooled. A quiet agent stays short.sweepOrphans' capability-box branch asks this Runtime'sisLiveabout a run. Its oracle already fails safe on a thrown store lookup (catch { return true }); the remaining gap is a store answering null for a run it does not own. Its chat-box branch removes terminal pods only.spawner.list()is scoped to the namespace and theapp=agentboxlabel, and pods carry no runtime identity. If several Runtimes ever share one AgentBox namespace, the rules above are what make that safe — there is no label to filter on, and adding one cannot be retroactive.Docs
New
invariants.md§1.5, and a correction to §1.3's CA-fingerprint bullet: a singleton Runtime Deployment makes a second writer unlikely but does not establish it, since nothing in the pod labels identifies one.🤖 Generated with Claude Code