Count a Bot repeating itself, and hand the count to the boundary - #17
Merged
davidmckayv merged 2 commits intoAug 19, 2026
Merged
Conversation
A model that cannot get something to work retries. It clicks the same button, reloads the same page, writes the same file, and nothing counted it. Every one of those attempts is a real action on somebody's live website and a real charge against somebody's model credit, and the audit trail recorded them one row at a time with no way to see that they were the same row thirty times over. The gateway is the one place this can be counted, because it is the one place every governed action already goes through and already writes a row for. `computer/repeat.ts` keys a call on the tool plus the argument that says which thing it acted on, over a sliding three-minute window, and the gateway asks it before it asks the policy. The count lands in `PolicyContext` as `repeat.count`, so a deployment can write `repeat.count >= 10` in its deny list and stop a Bot going in circles with the boundary it already has, on the page it already uses. The detector does not refuse anything, on purpose. Blocking here would be a second boundary with rules of its own, invisible on the Boundaries page, unanswerable to dry-run, and impossible to relax for the one Bot whose job really is to poll something. It observes; the policy decides. Crossing 3, 10, or 25 writes one `computer.action_repeated` row each, carrying the tool, a readable fingerprint and the count. It is deliberately not filed as a refusal: nothing was forbidden and nothing was stopped, and a trail that files an observation as a refusal teaches a reader to skim past the refusals that are real. The audit page gives it its own filter and its own words for the same reason. The window costs something and the preset says so. It is time-based, so a Bot slow enough to spread its attempts wider than the window never trips it, and one that varies a single argument each time round is ten different calls. COMPUTER_REPEAT_WINDOW_MS widens it for a deployment whose provider is slow enough to need that, and refuses to start rather than falling back, because a rule about repetition that never fires looks exactly like a Bot behaving itself.
…y it is about to need Three things the detector claimed and did not do. The outer map was described as bounded by how many Bots a deployment has. It is not: the id it counts against is the `:botId` in the request path, checked against a session and against nothing else, because no acting route resolves it to a row in `bots` first. A signed-in caller looping over invented ids bought a map, an inner map and an occurrence record each time round, before the policy was consulted and whether or not any such Bot existed, and none of it was ever given back. Both levels are capped now and neither drops anything that is still inside the window, so what is held is the recent past and nothing else. Eviction was least recently seen, described as the key furthest from tripping anything. For the one behaviour this feature exists to catch it is the nearest: a Bot going round a loop of more distinct calls than the cap holds lost each key exactly one step before it came back, so twenty rounds of a sixty-five step circle reported every call as a first attempt and no rule about repetition could fire. Nothing live is evicted at all now. A place is freed when a call ages out of the window, and while every place is held by something still inside it, a call the Bot has not made before is not counted rather than something live being thrown out for it. The cost is a Bot whose first sixty-four distinct calls are honest work and which only then gets stuck: its loop is invisible until one of those falls out of the window, at most one window away. A blind spot that clears itself is worth more than an eviction rule that can be wrong for as long as the loop lasts, and it buys the other half: a call already being counted can no longer be pushed out by a Bot doing other things in between. The observational audit row blocked. `recordAuditEvent` rethrows, and that row is written ahead of the policy, so a moment's trouble at the audit store refused every third, tenth and twenty-fifth identical call, on an action nothing objected to, from the one part of this that is not allowed to refuse anything. It is swallowed and logged now. The invariant is untouched: the decision row goes to the same store a few lines below, and a store that is really down stops the action there. Also honest about the costs where an operator sees them. The Boundaries preset named only the two ways the rule under-fires and never the way it over-fires, which is the failure that costs somebody their Bot: a Bot typing ten different searches into one box is ten repeats, and the rule refuses the tenth. That admission, the fact that the count lives in one process and so splits across replicas, and the fact that MCP tool calls always report one, now reach the preset, the `PolicyContext` docblock, `.env.example` and the architecture note. The threshold docblock said a count that falls out of the window and climbs again reports again; it is the window emptying completely that ends a run, which is what the code has always done and what the type now says.
davidmckayv
approved these changes
Aug 19, 2026
davidmckayv
left a comment
Contributor
There was a problem hiding this comment.
Reviewed against the code and run locally. Full suite passes on a clean database (bun run test:ci), format, lint and typecheck clean.
No security concerns: this touches no auth boundary and nothing it adds can be reached without going through the gateway.
jerelvelarde
marked this pull request as ready for review
August 19, 2026 23:48
jerelvelarde
requested review from
MikeRyanDev,
guidovizoso and
tylerslaton
as code owners
August 19, 2026 23:48
This was referenced Aug 20, 2026
davidmckayv
added a commit
that referenced
this pull request
Aug 20, 2026
The approval registry from #15 kept its pending questions in a Map in the process, and the repetition detector from #17 kept its counts the same way. Both are correct on a laptop and both stop being correct the moment a deployment runs a second server process, which is the deployment every part of this is aimed at: several processes behind a load balancer, serving a company. The failure mode is the reason to take them back rather than leave them and fix them later. Neither one breaks loudly. A question raised on one process and answered on another is reported as no longer open, which reads exactly like an expiry. Counts split across processes mean a rule written as `repeat.count >= 10` never fires, which reads exactly like a Bot behaving itself. A boundary that silently stops enforcing is worse than one that was never advertised, because the deployment is relying on it. The ask lists, the approval surface, the repetition context and the two audit event kinds go with them. What stays is everything that was already right: the stall watchdog, which tracks streams in the process that holds them and belongs there, and the client-side work from #16 and #19. The rule is now on the pull request template, stated before the work rather than at review. Our own gateway snapshot cache has the same problem and is next.
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 problem
A model that cannot get something to work retries. It clicks the same button, reloads the same page, writes the same file, and nothing in OpenBot counted it. Every attempt is a real action on somebody's live website and a real charge against somebody's model credit, and the trail recorded thirty of them one row at a time, with nothing to say they were the same row thirty times over.
The boundary could not help. Every rule it can express is about the shape of an action, and the thirtieth click has the same shape as the first: a rule able to refuse the thirtieth would refuse the first, and refusing the first is refusing the product. Only the count separates them, and nobody was counting.
The approach
Every governed action already passes through one function in
server/src/computer/gateway.ts, which already writes a row for it, so counting there is nearly free.server/src/computer/repeat.tskeys a call on the tool plus the argument saying which thing it acted on — the ref, the key pressed, the file path, the address being opened — and counts identical keys per Bot inside a sliding three-minute window. A bare tool name is deliberately not a key: a Bot working down a form would otherwise look exactly like one stuck on its first field.COMPUTER_REPEAT_WINDOW_MSwidens the window for a slower provider, and refuses to start on a malformed value, because a rule that never fires looks exactly like a Bot behaving itself.The detector observes and does not block. Blocking here would be a second boundary with rules of its own, invisible on the Boundaries page, unanswerable to
dry-run, and impossible to relax for the one Bot whose job really is to poll something. Instead the count goes intoPolicyContextasrepeat.count, taken before the policy is asked so that a rule decides the very attempt that crosses the line rather than the one after it, and a deployment writesrepeat.count >= 10in the deny list it already has. The field is required rather than optional: an absent identifier throws inside CEL and a throwing deny rule denies, so an optionalrepeatwould make one rule about repetition refuse everything.Crossing 3, 10 or 25 writes one
computer.action_repeatedrow each, carrying the tool, the count and a readable fingerprint (computer_click ref=e9). Each threshold fires once per run, and a run ends only when the window empties completely. The row is not filed as a refusal, and the audit page gives it its own filter and its own words, because a trail that dresses an observation up as a refusal teaches a reader to skim past the real ones. Its insert failure is swallowed and logged: an observation may not cost an action over a moment's trouble at the audit store, and the decision row still goes to the same store, so a store that is genuinely down stops the action there.What the detector remembers is capped at both levels, because the id it counts against is the one in the request path, checked against a session and nothing else. Nothing still inside the window is evicted to make room, though: least recently seen is exactly the key a Bot going round a long loop is about to make next, and dropping it would report every call in a tight circle as a first attempt. The count is wrong in both directions, as below, and the Boundaries preset,
.env.exampleanddocs/architecture.mdsay so where an operator meets them and suggestdry-runfirst.What is not covered
repeat.count >= 10refuses the tenth. Typed text is left out of the key because it is a password as often as anything else.server/src/plugins/store.tssuppliesrepeat: { count: 1 }so a rule reads false there rather than unevaluable; counting them means a second detector at a second call site.Merge notes
PolicyContextinserver/src/computer/policy.tsgains one required field,repeat: { count: number }, placed afteractorrather than appended so a branch adding optional fields at the end stays clear of it. Required means every construction site must supply it — two today, the gateway andserver/src/plugins/store.ts— and a third added elsewhere fails typecheck until it does.feat/policy-ask-a-personandfeat/routines-and-webhook-triggersboth changepolicy.tsand the gateway, so that is the contract to reconcile first. The rest is list-append: anauditEventTypesentry, an audit filter, a Boundaries preset, and additive blocks inserver/src/config.ts,.env.exampleanddocs/architecture.md. No schema change, so no migration.Verification
bun run formatandbun run format:check—Checked 293 files. No fixes applied.bun run lint— exit 0, with 24 pre-existing warnings in files this branch does not touch.bun run typecheck— app, server and worker all exit 0.bun run test— 628 pass, 5 skip, 0 fail across 70 files. Main's baseline is 594, so this branch adds 34 and takes none away.bun run build— app, server and worker all exit 0.server/tests/computer-repeat.test.tsis the one new file, holding the detector down on an injected clock: counts rising and staying apart, the window sliding rather than tumbling, each threshold once, a dip that does not end a run, both caps holding, and a loop wider than the cap still being counted. The existing gateway, policy and config suites gain the count reaching the policy, the repeat row landing ahead of the decision it explains, a lost observation row not refusing an allowed action, the CEL rule itself, and the malformed windows that stop the server.By hand, this branch's frontend was proxied to a running API on a spare port, confirming that the Boundaries preset renders with its cost line and that the "Going in circles" audit filter queries cleanly. Nothing was written to that deployment's database or policy, and the branch has not been driven end to end against a Bot, so the server-side behaviour rests on unit tests.