Skip to content

feat(directory): add a per-project Lobby cell beside the directory - #335

Merged
iamnbutler merged 7 commits into
mainfrom
lobby-cell
Oct 15, 2026
Merged

iamnbutler merged 7 commits into
mainfrom
lobby-cell

Conversation

@iamnbutler

@iamnbutler iamnbutler commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Second of four stacked PRs for project lobbies (base: #334). Refs #17, #14.

Adds the Lobby Durable Object to the directory's Worker: one per project, named by the shared project ID, at GET /lobbies/<project> behind the same ACE_SECRET. The Directory object, its routes, and its stored data are unchanged. Wire types live in @ace/directory/protocol so hosts import them instead of copying.

  • Presence is never stored. Each host replaces its owner's presence; the lobby keeps it in the socket's hibernation attachment and sends everyone the whole list on each change. The attachment holds 2 KiB, so the lobby drops open channels beyond what fits and never fails to serialize.
  • Sockets are keyed by host process. hello carries a per-process ID. A reconnect replaces only the same process's stale socket, so an installed and a source Ace on one machine each keep theirs instead of evicting each other.
  • Lifecycle events get one seq per project. A host says hello with the newest seq it applied and gets the events after it, then synced. When those events are gone (pruned, from before the lobby's storage, or a first visit) synced carries resync: true and the host reads listings again.
  • Retention, per the confirmed decision: an alarm prunes events older than 30 days and beyond the newest 2,000 per project. Age is measured from when the lobby received each event, on its own clock, so a host's clock never affects it. The same alarm runs every 5 minutes and closes sockets silent for 90 s, so a host that slept without closing TCP leaves within about 6.5 minutes; hosts' 30 s pings are auto-answered without waking the object. A host joining a lobby that sat empty re-arms the 5-minute sweep, since the empty lobby's alarm waits for its oldest event to expire.
  • Write budget. Durable Objects bill each setAlarm() as one row written, and the free tier allows 100,000 a day. Re-arming the sweep is the lobby's only steady write: 288 a day per project while any host is connected (the sweep was every 60 s, 1,440 a day, before the rebase). Pings, hello, and presence never write; presence lives only in socket attachments. A lifecycle event writes 3 rows (the seq kv key, the row, and its at index entry), and pruning it later writes 2 more. An empty lobby writes one alarm when its last host leaves and one when a host returns.
  • A malformed project ID answers 400.
  • Migrations: Durable Object SQLite refuses PRAGMA user_version (SQLITE_AUTH in miniflare), so the schema version is counted in the object's version kv key and migrations run in one transaction. seq lives in kv too, so pruning every row never reuses a seq.

Deploying needs only wrangler deploy of services/directory: it adds the LOBBY binding and a v2 migration with new_sqlite_classes: ["Lobby"]. No new secrets. Not deployed by this PR. The live ace-directory Worker's last applied migration tag is v1, read from the Workers scripts API, so v2 applies cleanly.

Smoke-tested with wrangler dev (miniflare): hello/resume/resync at since 0, current, older, and ahead of the log; replay of missed events; presence fan-out and removal on close; ping/pong; eviction of a silent socket after about 120 s; 401 without the secret; the directory's PUT /hosts and GET / unchanged.

After review:

  • A malformed ID returned 400.
  • Two processes with the same host name both stayed connected, and the same process reconnecting replaced its stale socket.
  • A presence listing 200 channels was kept with the 54 that fit.
  • An event without at was stored and broadcast.
  • The re-armed sweep: one event was stored, then the lobby was left empty for 75 s so its alarm moved out to that event's expiry. A host that then connected and stayed silent was closed as silent 120 s later.

Rebased onto main after #391. The directory's write-minimizing publish is unchanged; this PR only adds the /lobbies route and the Lobby export beside it. Smoke-tested again from the top of the stack with wrangler dev: a host that said hello and then stayed silent was closed as silent 300 s later, at the first 5-minute sweep.

Built in Ace

No. This was built with Claude Code outside Ace by the overnight watcher, because this Mac's Ace Canary is a participant in the two-person study and can't be used for development tonight. See #5.

The directory's Worker gains a Lobby Durable Object, one per project ID, at
GET /lobbies/<project> behind the same ACE_SECRET. Hosts hold one socket per
project: they replace their owner's presence, which lives only in hibernation
attachments, and send channel lifecycle events, which the lobby numbers with
one seq per project and keeps as a 30-day, 2,000-event resume buffer. A host
resuming from a pruned or unknown seq is told to read listings again. Pings
are auto-answered and an alarm evicts sockets silent for 90 s. The Directory
object and its contract are unchanged. Refs #17, #14.

Why: services/directory/src/lobby.ts#const MIGRATIONS | Durable Object SQLite refuses PRAGMA user_version, so the schema version lives in the kv `version` key; never edit a shipped migration
Why: services/directory/src/lobby.ts#method webSocketMessage | seq lives in kv apart from the rows so pruning every event never reuses a seq a host resumes from
Why: services/directory/src/lobby.ts#type Attachment | presence must never reach SQLite; it lives only in socket attachments
Re-arm the 60 s sweep when a host joins a lobby whose alarm waits on the
oldest event, prune by the lobby's own receipt time, fit presence into the
2 KiB hibernation attachment, replace sockets only within one host
process, and answer a malformed project ID with 400.

Why: services/directory/src/lobby.ts#method fetch | a joining host must re-arm the sweep when the alarm is further than SWEEP away, or silent sockets stay here until the oldest event expires
Why: services/directory/src/lobby.ts#function fit | serializeAttachment throws above 2 KiB, so presence must be trimmed to fit before every serialize
Why: services/directory/src/lobby.ts#method webSocketMessage | the events.at column is the lobby's receipt time, never the host's clock, since retention and resync depend on it
Each sweep re-arms the alarm, which Durable Objects bill as one row write, so
a 60 s sweep cost 1,440 writes a day per project with a host connected. Every
5 minutes costs 288. Silent sockets still close after 90 s without a ping, now
within about 6.5 minutes.

Why: services/directory/src/lobby.ts#const SWEEP | the alarm re-arm is the lobby's only steady storage write; pings and presence must never write, and the sweep must not run more often than the write budget allows
@iamnbutler
iamnbutler changed the base branch from lobby-project-id to main October 15, 2026 10:19
@iamnbutler
iamnbutler merged commit b8a2800 into main Oct 15, 2026
1 of 2 checks passed
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