Skip to content

Take back the two features that only worked on one machine - #21

Merged
davidmckayv merged 1 commit into
mainfrom
revert/in-process-state
Aug 20, 2026
Merged

Take back the two features that only worked on one machine#21
davidmckayv merged 1 commit into
mainfrom
revert/in-process-state

Conversation

@davidmckayv

Copy link
Copy Markdown
Contributor

What this changes

Reverts #15 (the approval registry) and #17 (the repetition detector), and puts the deployment rule on the pull request template.

Why

Both features keep state that outlives a request in a Map inside the server process. That is correct on one machine and wrong the moment a deployment runs a second one, which is the deployment this product is aimed at: several server processes behind a load balancer, serving a whole company, with people bringing their own agents.

Neither fails loudly, which is why they are being taken back rather than left in place and fixed later:

  • A question raised on one process and answered on another is reported as no longer open. That is indistinguishable from an expiry, so nobody investigates.
  • Repetition counts split across processes, so a rule written as repeat.count >= 10 never fires. That is indistinguishable from a Bot behaving itself.

A boundary that quietly stops enforcing is worse than one that was never advertised, because the deployment is relying on it.

What stays

Everything that was already right. The stall watchdog from #19 tracks open streams in the process that holds them, which is where they belong. The client-side work in #16 and #19 is unaffected. The Did not happen audit filter keeps agent.stream_stalled and loses only the repetition entry.

What goes

The ask policy list and its surface, the approval routes and registry, the repetition context on the policy engine, COMPUTER_REPEAT_WINDOW_MS, and the computer.action_repeated and approval audit kinds. Migration 0001_gigantic_sumo goes with it; it added one column, action_policy.ask. Any database that already applied it keeps a defaulted column nothing reads, and drizzle-kit generate reports no drift.

Still to do

The gateway resolves every action's target from a snapshot cache that is also a Map in the process. It predates all of this and it is load-bearing for the action boundary, so it is the next thing to move to Postgres.

Proof

format:check, lint and typecheck clean. 647 pass, 5 skip, 0 fail. db:generate reports no schema changes.

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.
@davidmckayv
davidmckayv merged commit 06a1a84 into main Aug 20, 2026
3 checks passed
@davidmckayv
davidmckayv deleted the revert/in-process-state branch August 20, 2026 02:19
kevin9327 added a commit to kevin9327/openbot that referenced this pull request Aug 21, 2026
The gateway turns the opaque ref in an acting call into the element it points at, from the snapshot
this server took, and then decides and records against that element. That mapping lived in a Map in
the process. OpenBot runs several server processes behind a load balancer, and the process that
answers a snapshot is rarely the one that answers the click that uses its refs, so on every other
replica the Map was empty: the ref resolved to nothing, the policy decided with no element in front
of it, and the audit row could not name what was touched.

Neither outcome is loud. A deny rule written against the element fails closed and refuses every
click on the replicas that did not snapshot, which reads as the computer being flaky. A rule that
does not name the element lets the click through unresolved and unrecorded, which reads as the
boundary being quiet because nothing matched. Either way the boundary is not doing what the operator
wrote, and nothing says so. CopilotKit#21 took two features back for this exact shape and noted this cache had
it too.

So the snapshot goes through Postgres, the way channel activity and the policy already do: one row
per computer, upserted on every snapshot, read on the action path. A ref resolves on whichever
replica the click lands on.

Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by
the generation the far-side computer stamps on every snapshot. A ref resolves only when its
generation matches the stored one, so a ref from a superseded page resolves to nothing rather than
to whatever now holds it: it cannot resolve to a name that is no longer on screen, because a ref
from an old screen no longer matches. The computer makes the same generation check when the action
reaches it; this keeps the policy decision and the audit row honest first, on whichever replica the
action landed.

The client-supplied snapshotId only ever narrows resolution. It can yield the true element of the
current snapshot or nothing, never a different element than the ref already names, so nothing new is
trusted from the client that the server does not resolve itself.

Without a database the gateway still keeps snapshots in memory, so a single-process test does not
need Postgres, exactly as the policy store does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
davidmckayv pushed a commit to kevin9327/openbot that referenced this pull request Aug 21, 2026
The gateway turns the opaque ref in an acting call into the element it points at, from the snapshot
this server took, and then decides and records against that element. That mapping lived in a Map in
the process. OpenBot runs several server processes behind a load balancer, and the process that
answers a snapshot is rarely the one that answers the click that uses its refs, so on every other
replica the Map was empty: the ref resolved to nothing, the policy decided with no element in front
of it, and the audit row could not name what was touched.

Neither outcome is loud. A deny rule written against the element fails closed and refuses every
click on the replicas that did not snapshot, which reads as the computer being flaky. A rule that
does not name the element lets the click through unresolved and unrecorded, which reads as the
boundary being quiet because nothing matched. Either way the boundary is not doing what the operator
wrote, and nothing says so. CopilotKit#21 took two features back for this exact shape and noted this cache had
it too.

So the snapshot goes through Postgres, the way channel activity and the policy already do: one row
per computer, upserted on every snapshot, read on the action path. A ref resolves on whichever
replica the click lands on.

Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by
the generation the far-side computer stamps on every snapshot. A ref resolves only when its
generation matches the stored one, so a ref from a superseded page resolves to nothing rather than
to whatever now holds it: it cannot resolve to a name that is no longer on screen, because a ref
from an old screen no longer matches. The computer makes the same generation check when the action
reaches it; this keeps the policy decision and the audit row honest first, on whichever replica the
action landed.

The client-supplied snapshotId only ever narrows resolution. It can yield the true element of the
current snapshot or nothing, never a different element than the ref already names, so nothing new is
trusted from the client that the server does not resolve itself.

Without a database the gateway still keeps snapshots in memory, so a single-process test does not
need Postgres, exactly as the policy store does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
davidmckayv added a commit that referenced this pull request Aug 21, 2026
… it (#46)

* Keep the snapshot a ref resolves against where every replica can read it

The gateway turns the opaque ref in an acting call into the element it points at, from the snapshot
this server took, and then decides and records against that element. That mapping lived in a Map in
the process. OpenBot runs several server processes behind a load balancer, and the process that
answers a snapshot is rarely the one that answers the click that uses its refs, so on every other
replica the Map was empty: the ref resolved to nothing, the policy decided with no element in front
of it, and the audit row could not name what was touched.

Neither outcome is loud. A deny rule written against the element fails closed and refuses every
click on the replicas that did not snapshot, which reads as the computer being flaky. A rule that
does not name the element lets the click through unresolved and unrecorded, which reads as the
boundary being quiet because nothing matched. Either way the boundary is not doing what the operator
wrote, and nothing says so. #21 took two features back for this exact shape and noted this cache had
it too.

So the snapshot goes through Postgres, the way channel activity and the policy already do: one row
per computer, upserted on every snapshot, read on the action path. A ref resolves on whichever
replica the click lands on.

Staleness, the reason a persisted snapshot cache is rightly regarded with suspicion, is answered by
the generation the far-side computer stamps on every snapshot. A ref resolves only when its
generation matches the stored one, so a ref from a superseded page resolves to nothing rather than
to whatever now holds it: it cannot resolve to a name that is no longer on screen, because a ref
from an old screen no longer matches. The computer makes the same generation check when the action
reaches it; this keeps the policy decision and the audit row honest first, on whichever replica the
action landed.

The client-supplied snapshotId only ever narrows resolution. It can yield the true element of the
current snapshot or nothing, never a different element than the ref already names, so nothing new is
trusted from the client that the server does not resolve itself.

Without a database the gateway still keeps snapshots in memory, so a single-process test does not
need Postgres, exactly as the policy store does not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Keep the newest snapshot when two replicas take one at once

Two things the cross-replica move needs that a single process did not.

The upsert was last-write-wins. Two replicas snapshotting the same computer is the case this table
exists for, and Postgres has no say in which of their writes arrives second, so the older generation
could overwrite the newer one and every ref the model was holding would stop resolving. The
generation decides instead: it is stamped by the computer and increases by one per snapshot, so a
lower number is an older page. Clocks cannot decide it, because there are two of them.

Wiping a computer now clears the row. A fresh computer counts generations from one again, so a row
left behind lets a ref from the previous session match the new session's first snapshot, and the
policy decides against an element from a page that is gone. The generation only tells snapshots apart
within a session, so the row has to go when the session does.

Tests for both, and for clear on each store. The ordering one fails without the guard.

---------

Co-authored-by: kevin9327 <kevin9327@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: David McKay <davidmckayv@users.noreply.github.com>
davidmckayv pushed a commit that referenced this pull request Aug 22, 2026
#118 asked for the knowledge/ modules and the three tables to be dropped in a
change that says so. This is the half that can be done now.

server/src/knowledge/acl.ts, repository.ts and types.ts have no importer outside
their own two test files. InMemoryKnowledgeRepository holds documents, chunks
and ACLs in a Map in the process, which is the shape #21 took back, and
canRead(actor, entries[]) filters ACL rows already pulled into memory rather
than in SQL. Neither is a starting point for anything #119 describes.

The three tables stay for now. PR #97 has not merged, so
connectors/sync-persistence.ts still imports documents, chunks, document_acls,
connector_cursors and sync_runs and writes to all of them; dropping the tables
breaks typecheck there. It is already orphaned at runtime, its only caller being
its own integration test, so this is a typecheck dependency rather than a live
one, but it is #97's to remove.

Left alone deliberately: connector_instances, which server/src/connectors.ts
writes to from production code; connector_cursors and sync_runs, which are
connector-side and go with #97; and webhook_subscriptions, which is referenced
nowhere at all and wants its own change. Docs that describe pgvector holding
knowledge records stay accurate while the tables exist and belong in the commit
that drops them.
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