Architecture
System boundaries
People in browsers
|
v
Private Cohent room ---- append-only room events ---- presence/read models
|
+---- task/run contracts ---- local CLI daemon
| remote sandbox
| container worker
|
+---- reviewed change sets ---- merge queue (in-database today) ---- main
The control plane owns coordination data, not source truth. Git owns source and GitHub owns repository permissions and the durable merge result.
Control plane
The D1 schema models users, projects, membership, invites, rooms, tasks, agent profiles, runs, run events, messages, change sets, reviews, merge queue entries, file claims, presence, notifications, integrations, GitHub identities, installations, repository access, web sessions, project prompt policies, targeted control commands, webhook deliveries, audit records, and append-only room events.
Every API action:
- derives the user from trusted platform headers;
- resolves project membership server-side;
- checks the required role;
- validates and bounds input;
- mutates the authoritative database;
- appends a room event when the action changes shared state.
Editable room fields carry a revision. A stale writer receives 409 Conflict
instead of silently overwriting a teammate.
Every run captures that revision plus immutable intent, outcome, decision, file scope, and dependency context. If the room changes later, Cohent marks the run as context-drifted and requires elevated review rather than silently treating old work as current.
Reads, and the room clock
Reading a room does not change it. Every projection is read in one D1 transaction and nothing in that path writes; a run's visible progress is derived from its own clock rather than persisted by whoever looked.
A GET writes nothing — not idempotently, not coalesced, not once per read.
tests/snapshot-consistency.test.mjs fails if the read path gains a write, a call to
the clock, or an unconditional identity write. That last one is the exception that had
to be closed separately: getContext stamped lastSeenAt on every call, so a read
wrote a users row two calls away from anything a span check could see. Refreshing
"last seen" is now something a caller asks for, and only writes ask. First sight of an
identity still inserts one row, once per person; GET /api/agent still writes, because
an agent asking for commands is an agent checking in.
What is genuinely a function of time — publishing a finished run, expiring prompts
past retention, expiring uncollected commands — is one idempotent operation,
advanceRoomClock, driven only by things that are already writes: every mutating
action (coalesced per room per 1.5 s), the presence heartbeat a viewing browser sends
on arrival and every fifteen seconds, the explicit reconcile-room action, and the
Worker's scheduled handler via advanceAllRooms for rooms nobody is in. Every
effect is a single transaction and can fire at most once per unit of state, so four
drivers are four chances to notice the same due work, not four sources of it.
The reachability argument that once justified writing on read — "a read is the only
thing that reliably happens near a room" — was answered by adding drivers rather than
by accepting the write. What it buys is that "can a GET corrupt this room?" is
answerable by reading one function, and that a room's history no longer depends on
whether anyone was looking. What it costs is that a room with no viewer, no writer and
no cron tick waits; the scheduled handler exists for exactly that case.
The sweep visits rooms least-recently-advanced first, ordered by clock_advanced_at
with nulls first. That ordering is what makes its per-invocation ceiling incapable of
starving a room rather than merely unlikely to: a room the ceiling cut off holds the
oldest clock in the table and sorts to the front of the next tick. Every driver writes
that stamp, in the same transaction as the sweeps beside it, so a room whose work failed
is not marked advanced and is retried first. GET /api/health reports the oldest stamp
across unarchived rooms — as an explicit allowlist, since the endpoint is unauthenticated
and how many rooms a deployment holds is a business fact rather than a health one —
because progression is the one part of this system that fails silently — nothing errors, runs simply never finish — and that lag is the only evidence
available from inside the Worker about whether the platform's cron fires at all.
An agent's lease belongs to its client session, not to the pairing token that authenticates it. A token identifies a person for thirty days and is shared by every session that person runs, so token-scoped renewal let one live poll refresh every abandoned intent on it. Each intent records the session that announced it, and both lease renewal and command delivery match on it — a caller that names a session touches only that session's intents, and a caller that names none touches only intents that have none.
The archive transition is a compare-and-swap on the room row rather than a decision
made from the value read at authorisation time, so two concurrent archives — or an
archive interleaving with a restore — cannot both proceed and leave the archive's
marker on a run in an active room. pause, resume and cancel clear that marker,
because a person acting on a run takes ownership of it. Archiving also drives the clock
first, uncoalesced, so a run that had genuinely finished is published rather than parked
at 100% and re-based by the restore.
An archived room's clock stops, twice over. advanceRoomClock refuses an archived
room and advanceAllRooms excludes it in the query — and archiving also parks the
room's active runs, pausing each with the progress it had reached and marking
runs.paused_by_archive. Restoring resumes exactly those, re-basing started_at so
the simulated clock does not treat the archived interval as elapsed work, and leaves a
run a person paused alone. Without parking, an active run in an archived room reads as
working forever off a clock nobody advances and cannot be stopped, because the room
refuses every write that could stop it.
Publishing a finished run is exactly-once by construction rather than by ordering: the
change set is created by an insert … select that re-tests the run's status and
requires no change set to exist, the announcement carries a unique dedupe key derived
from the run, and the run and task transitions are gated on the change-set id that
call generated. A caller that creates nothing does nothing.
Both of those insert … select statements take their value list from
lib/insert-select.ts, which walks the table in the same order drizzle names the
insert's columns. An INSERT … SELECT is positional, so a hand-written value list is
correct only until somebody reorders db/schema.ts — and then it silently writes each
value into its neighbour's column. Generating the list makes the two orders one order.
Realtime model
The API exposes an integer room-event cursor and a resumable server-sent event
stream. SSE sends bounded invalidation events, reconnects with Last-Event-ID,
and authorizes once per short-lived stream. A browser freezes the EventSource
URL when it constructs it, so a valid Last-Event-ID takes precedence over the
after query parameter — including when it is lower, because re-delivering an
event is cheaper than skipping one. A malformed header falls back to after
rather than rewinding, while a malformed after is rejected with 400. Clients retain a ten-second
authoritative snapshot fallback and use BroadcastChannel for immediate local
tab fan-out. Presence expires after 45 seconds without a heartbeat.
The same cursor permits a future Durable Object/WebSocket fan-out without changing domain state. Transport events are invalidation hints; the database remains authoritative.
Execution plane
Runs are immutable requests plus ordered events. A runner may live:
- beside a developer's local Git checkout;
- in an ephemeral container;
- in a remote VM or agent cloud;
- behind a vendor adapter.
The hosted product test uses a deterministic contract simulator and labels it as such. It never claims to write repository files. A connected worker creates the worktree, launches the agent CLI with the exact permission profile, streams events, checkpoints commits, runs checks, and publishes the resulting SHA.
Merge safety
Agents never share a mutable checkout. File claims provide early overlap warnings. Change sets preserve file lists, check evidence, and conflict state. Human review is a durable record. Approval inserts a merge-queue item; a GitHub integration worker would be responsible for rebasing, enforcing branch protection, and reporting the actual merge SHA.
Not implemented. Approving a review inserts a merge-queue row already marked
merged and flips the change set and task in the database. Nothing calls a
GitHub API, rebases, or reports a commit SHA; base_sha and head_sha are
always NULL. One query reads the queue to assign the next position; nothing acts
on its contents.
Task dependencies are an acyclic graph. The reachability test and the insert are one
statement, so concurrent edges cannot close a cycle between them, and each response
reports what that request actually did: created only for the writer, 409 only for
a real cycle, and re-adding an existing edge is a no-op rather than an error.
Launching is prevented while prerequisites are incomplete. Overlapping write claims
require an owner/admin override, which becomes an append-only audit event.
Scale path
- SSE cursor stream → Durable Object/WebSocket fan-out
- Snapshot reads → cursor-delta materialized projections
- D1 write serialization → per-project actor/queue
- contract simulator → signed runner leases and webhook events
- one room template → arbitrary projects, rooms, dependencies, and automations
- file overlap → AST/symbol ownership and semantic conflict prediction