save in server/src/computer/snapshot-store.ts is an INSERT ... ON CONFLICT DO UPDATE, and its only-ever-forward guard is setWhere: lt(computerSnapshot.snapshotId, values.snapshotId) at line 108. setWhere qualifies the update branch, so it decides nothing when there is no row to conflict with.
resetComputer deletes the row (server/src/computer/gateway.ts:577) precisely because the refs the last snapshot handed out describe a page that no longer exists and a fresh computer counts generations from one again. Between the computer answering /snapshot and the save landing (gateway.ts:313) there is a window, and a reset inside it leaves the delete already done. The in-flight save then inserts unconditionally.
What follows:
- Session A reaches generation 7. The row holds A's page.
- A snapshot is in flight.
- The reset lands. The profile is wiped, the row is deleted.
- The in-flight save arrives, finds no row, and inserts. The wiped session's page is back at generation 7.
- The fresh computer counts from one. Its saves take the conflict path,
lt(7, 1) is false, and the update is skipped without error.
So refs resolve against a page from a session that was deleted, which is the thing step 3 exists to prevent, and no fresh snapshot lands until the generation climbs past the dead one's.
The larger half
Step 5 does not need the race. agent-computer/src/index.ts:124 keeps the generation in an in-process Map, sessionFor mints snapshotId: 0 for any Bot it is not already holding (:148), and forgetIdleSessions drops sessions whose profile is not live once the map passes 32 (:140-143). Generations therefore restart at one after any container restart, redeploy, crash, or eviction, and none of those clear the server's row. A restarted computer's snapshots are then silently dropped until its counter climbs past whatever the row still holds.
To be clear about what I ran: the server-side half is executed against Postgres, including a generation-1 save being dropped while the row holds 7. The computer restarting its counter I have read at those lines but not executed, since agent-computer/src/index.ts imports Playwright at module scope.
Reproducing it
Two cases against a real database, both failing on be18bc9, with the six existing cases in the file still passing. They drop into server/tests/computer-snapshot-store.integration.test.ts:
test("a save still in flight when the computer was wiped does not bring the old page back", async () => {
const store = createSnapshotStore(database);
await store.save("default", snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }], "https://old.example/order"));
await store.clear("default");
// The old session's last snapshot, arriving after the wipe.
await store.save("default", snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }], "https://old.example/order"));
expect(await createSnapshotStore(database).load("default")).toBeUndefined();
});
test("a wiped computer's fresh snapshots are not blocked by the generation the old one reached", async () => {
const store = createSnapshotStore(database);
await store.save("default", snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }], "https://old.example/order"));
await store.clear("default");
await store.save("default", snapshot(7, [{ ref: "e9", role: "button", name: "Submit order" }], "https://old.example/order"));
await store.save("default", snapshot(1, [{ ref: "e1", role: "link", name: "Sign in" }], "https://fresh.example/start"));
const loaded = await createSnapshotStore(database).load("default");
expect(loaded?.url).toBe("https://fresh.example/start");
expect(loaded?.snapshotId).toBe(1);
});
The file's existing "an older snapshot arriving late does not overwrite the newer one" covers the same guard through the conflict path, which is why the insert path read as covered.
Why there is no PR with this
I had a fix ready to write, an epoch owned by the server, bumped on reset and ordered against the generation. Pricing the plumbing is what turned up the restart case, and an epoch bumped on reset does not cover it. Whatever the ordering keys on has to change on any new computer session rather than only on a wipe, which points at the computer stamping a session identity at startup and the server ordering on (session, generation). That spans both services and is your call on where session identity should live, so I stopped rather than guess at it.
One smaller thing found alongside: createInMemorySnapshotStore (snapshot-store.ts:144) has no monotonic guard at all, so the two implementations disagree about the property #46 established.
saveinserver/src/computer/snapshot-store.tsis anINSERT ... ON CONFLICT DO UPDATE, and its only-ever-forward guard issetWhere: lt(computerSnapshot.snapshotId, values.snapshotId)at line 108.setWherequalifies the update branch, so it decides nothing when there is no row to conflict with.resetComputerdeletes the row (server/src/computer/gateway.ts:577) precisely because the refs the last snapshot handed out describe a page that no longer exists and a fresh computer counts generations from one again. Between the computer answering/snapshotand the save landing (gateway.ts:313) there is a window, and a reset inside it leaves the delete already done. The in-flight save then inserts unconditionally.What follows:
lt(7, 1)is false, and the update is skipped without error.So refs resolve against a page from a session that was deleted, which is the thing step 3 exists to prevent, and no fresh snapshot lands until the generation climbs past the dead one's.
The larger half
Step 5 does not need the race.
agent-computer/src/index.ts:124keeps the generation in an in-processMap,sessionFormintssnapshotId: 0for any Bot it is not already holding (:148), andforgetIdleSessionsdrops sessions whose profile is not live once the map passes 32 (:140-143). Generations therefore restart at one after any container restart, redeploy, crash, or eviction, and none of those clear the server's row. A restarted computer's snapshots are then silently dropped until its counter climbs past whatever the row still holds.To be clear about what I ran: the server-side half is executed against Postgres, including a generation-1 save being dropped while the row holds 7. The computer restarting its counter I have read at those lines but not executed, since
agent-computer/src/index.tsimports Playwright at module scope.Reproducing it
Two cases against a real database, both failing on
be18bc9, with the six existing cases in the file still passing. They drop intoserver/tests/computer-snapshot-store.integration.test.ts:The file's existing "an older snapshot arriving late does not overwrite the newer one" covers the same guard through the conflict path, which is why the insert path read as covered.
Why there is no PR with this
I had a fix ready to write, an epoch owned by the server, bumped on reset and ordered against the generation. Pricing the plumbing is what turned up the restart case, and an epoch bumped on reset does not cover it. Whatever the ordering keys on has to change on any new computer session rather than only on a wipe, which points at the computer stamping a session identity at startup and the server ordering on
(session, generation). That spans both services and is your call on where session identity should live, so I stopped rather than guess at it.One smaller thing found alongside:
createInMemorySnapshotStore(snapshot-store.ts:144) has no monotonic guard at all, so the two implementations disagree about the property #46 established.