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:

  1. derives the user from trusted platform headers;
  2. resolves project membership server-side;
  3. checks the required role;
  4. validates and bounds input;
  5. mutates the authoritative database;
  6. 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
CohentPractical guides for using and understanding Cohent