Skip to content

Run roj standalone in a Worker, on @cloudflare/computer - #16

Open
matej21 wants to merge 53 commits into
mainfrom
cf-computer
Open

Run roj standalone in a Worker, on @cloudflare/computer#16
matej21 wants to merge 53 commits into
mainfrom
cf-computer

Conversation

@matej21

@matej21 matej21 commented Aug 10, 2026

Copy link
Copy Markdown
Member

Runs the roj agent runtime standalone inside a Cloudflare Worker — a Durable Object with a @cloudflare/computer workspace as its filesystem — and measures where it stops.

Two new packages, plus the SDK changes needed to make a host without a process table a first-class citizen. Nothing published; computer-worker is a private harness.

Verdict: it fits

roj uses limit
script size 1.57 MB gzip 10 MB (3 MB free)
startup CPU ~103 ms 400 ms
per turn ~10 ms CPU, 2 subrequests, 9 events 30 s / 1000
live sessions per DO 448 with no degradation
parallel agents 20 for the cost of 1.46× 20 children per parent

Turn cost does not grow with session age (+0.03 ms/turn over 60 turns) or with tool-result size. Subrequests bind ~5× before CPU — an await-until-idle request tops out near 500 turns.

Startup CPU inverts what the byte counts suggest: @cloudflare/computer is 5.9 MB and costs ~23 ms, the SDK is 1.1 MB and costs ~55 ms. Startup is paid for module-scope work — zod schema construction — not code size.

What's here

packages/computer-platform — a roj Platform over a computer Workspace: filesystem, execFile routed to the workspace shell, a git client, a SQLite event store over ctx.storage, and a Durable Object alarm scheduler.

packages/computer-worker — the harness. /run, /bench, /shell, /git, and /limits/<name> for seven probes, each on its own DO so one that OOMs cannot poison the next.

SDK changes: a runtime plugin profile (isolate drops services, resources, uploads), an optional git capability on Platform, a required scheduler capability, BaseEventStore exported for out-of-tree stores, and a Workers WebSocket platform in @roj-ai/transport over DO hibernation sockets.

The scheduler port

The agent loop re-entered through a bare setTimeout. In a DO that runs, but it cannot pay for itself: the actor's CPU budget is refilled by topUpActor(), which runs only when an event is delivered (io-context.c++:271). A timer callback never tops up, and an interval silently stops rescheduling once the budget is spent (io-context.c++:793). A DO alarm is a delivered event.

So Platform gains scheduler: wake(key, delayMs) / cancel(key). A wake carries no closure — the isolate that armed it may be gone when it comes due — so the key is the whole routing table and dispatch is a pure function of the key plus a session loadable from its event log.

Measured, with a negative control: a request sends a message, throws the booted SDK away, and returns at 3 events. 22 more land across three alarms and the session settles 1068 ms later. With the wakes cancelled, the same run stays at 3 events, zero alarms, never settling.

Bun is unchanged — createBunPlatform takes a timer-backed implementation, which is what the SDK did all along.

Letting the DO sleep

In workerd every armed actor timer registers a wait-until task that drain() waits on (io-context.c++:828), and a repeating timer arms a fresh one each tick. One live session's git-status poll was therefore enough to keep a DO permanently awake.

Who can write a workspace is a host question, so the host decides. Where the scheduler is a LiveScheduler the poll runs unchanged — that host has writers roj never sees, a user's editor or a dev server. Where it is not, every byte arrives through a tool call or a request, so the plugin arms nothing and a new git-status.refresh method answers a client's pull. Additive to the RPC contract; no existing shape moved.

/limits/idle shows roj arming no timer at all across settle, eviction, resume and pull. The control is what makes it evidence: the same census against a LiveScheduler session catches the 2 s interval immediately, with a stack trace.

Three defects the probes turned up

  • SessionManager.shutdown() never ran onSessionClose, so no plugin released anything — a timer and a memory leak per session ever loaded. One test asserted this as intended behaviour, using shutdown() to stand in for a crash.
  • Every logger dropped an error's cause, so EventAppendError reported no reason at all.
  • SessionFileStore.read collapsed every failure into File not found.

Worth knowing before this is relied on

  • An oversized event hangs the agent. Past the 2.2 MB SQLite value ceiling the log stops at tool_started with no terminal event; the agent never goes idle and replay carries a tool_use with no tool_result. Stock config never gets there — read_file's guard fires 25× earlier and fails cleanly — but raising maxTokens walks into it.
  • A DO degrades by lifetime, not load. Closed sessions leave workspace directories and event rows behind; createSession goes from ~15 ms to ~110 ms after a few thousand. A multi-session DO needs a reaper.
  • A burst of writes starves every timer in the isolate. Both the event store and the workspace filesystem go through synchronous sql.exec, so 400 ms of appends delivered one timer tick, 422 ms late.

Deliberately not done

spawn stays ENOSYS, so services cannot run in an isolate; under per-agent-type routing such an agent goes to E2B instead. The debounceCallback poll stays on a raw timer — its guards are in-memory cancellation state a resumed wake cannot reconstruct. roj-platform integration is not started.

Only a deploy can settle

Real inference CPU (no API key here, so the provider is mocked); which budget timer-driven work is charged to in production (the OSS enforcer is a no-op, the real one is closed-source); the 128 MB isolate ceiling (locally only V8's ~1.41 GB is reachable, and it aborts the whole workerd process); and hibernation itself — wrangler dev never evicts a DO, so this shows the precondition, not the behaviour.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR

matej21 and others added 30 commits August 7, 2026 15:12
`open()` promised a full `node:fs/promises` FileHandle, but the SDK only ever
calls `stat`, positional `read` and `close` on it — two sites, both reading
the tail of a file.

A platform whose filesystem exposes raw fds rather than handle objects could
not satisfy the wider promise without fabricating the rest. Introduce
`ReadableFileHandle` with the three methods and return that instead. A real
FileHandle still satisfies it, so bun-platform is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…uter

Lets `@roj-ai/sdk` run against a workspace filesystem — SQLite inside a
Durable Object — instead of a host filesystem.

The adapter targets `workspace.provider()` rather than `workspace.fs`. The
provider is the `@platformatic/vfs`-shaped surface with full symlink support,
so `stat`, `lstat`, `readdir`, `mkdir`, `unlink`, `rename`, `realpath`,
`access` and `exists` forward one-to-one.

Four gaps are filled here. `appendFile` and `copyFile` reject with ENOSYS
upstream: appends go through `writeRangeSync` at the current size, copies walk
the tree. `rm -r` and `cp -r` have no provider equivalent and recurse
explicitly. `open()` yields a raw fd, so it is wrapped in the
`ReadableFileHandle` subset over `readSync`/`fstatSync`/`closeSync`.

The ProcessRunner rejects everything with ENOSYS. An isolate has no process
table, and routing to `workspace.runtime.exec` needs a ChildProcess-shaped
shim over the exec handle — deliberately left out. Plugins that shell out fail
at the call site, not at registration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
… isolate

Answers the open question directly: does the agent runtime run in workerd at
all, against a workspace filesystem rather than a host one.

It does. A `RojAgentDO` owns one `Workspace`, `createComputerPlatform` turns
its provider into a roj `Platform`, and that goes through the SDK's normal
composition root. A scripted LLM drives the orchestrator into spawning a
`writer` agent, which writes a file through the filesystem plugin. Both agents
settle, 25 events persist to `events.jsonl` via the adapter's `appendFile`,
and the file reads back through the adapter. DO state survives isolate
restarts. ~1.6s wall per run, nearly all of it agent debounce, never near a
CPU limit.

Four things had to give. `loadConfig()` reads `process.env` and
`process.cwd()`, so the harness passes a `Config` literal. Non-sandboxed
presets resolve relative agent paths against `process.cwd()`, so the preset
sets `sandboxed: true`. `git-status` polls `git` every 2s and warns on a loop,
with no way to deselect it — the built-in plugin set is a hardcoded array that
RPC types are inferred from. And `DurableObjectStorage` does not satisfy
computer's `DurableObjectStorageLike`: it types `exec<Row extends object>`
where workers-types has `exec<T extends Record<string, SqlStorageValue>>`. The
parameter is phantom at runtime, so one cast bridges it at the DO boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Writes up what the cf-computer spike established, what it deliberately did not,
and the phases between here and a runtime that actually lives in an isolate.

Phase 0 is a gate, not a step: the spike measured wall time dominated by agent
debounce, which says nothing about CPU under streaming inference or replaying a
long event log. That number decides whether the rest is worth building.

Records the four decisions that change the shape of the work — where
SQLite-backed code lives, whether E2B goes away or the container backend stays
for dev servers, how git gets ported, and whether the ChildProcess shim is worth
building — and the two upstream bugs the spike had to work around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The abstract base carries the whole append/metadata contract, but only the two
in-tree stores could reach it. A host with its own durable storage — a Durable
Object with SQLite, say — had no way to implement the interface without
duplicating the base.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…the VFS

`FileEventStore` works over a computer workspace, but every append rewrites a
JSONL blob through the VFS into SQLite, and `load` reads and parses the whole
file. One row per event turns append into an insert and `loadRange` into a seek.

The schema clusters on `(session_id, seq)` WITHOUT ROWID, so a session's events
are contiguous and a range query never leaves the index. `seq` is the same
0-based index the EventStore contract reports as `fromIndex`/`toIndex`, so no
translation is needed. A batch is one multi-tuple insert and lands whole or not
at all.

Storage is taken as a bare `{ sql: { exec } }`, structurally what computer's
`DurableObjectStorageLike` already is, so anything with a SQL surface can back
the store — and the tests drive it through `bun:sqlite` without a Worker.

Two semantics differ from the JSONL store, both deliberate. `loadRange` counts
real rows rather than trusting `metadata.metrics.totalEvents`, which
`updateMetadataFromEvents` resets to 1 on every `session_created`; identical for
any well-formed log. And `limit: 0` returns an empty page instead of everything,
which is what asking for zero events should mean.

Also exports the event-store errors from the SDK. They were defined and thrown
but exported nowhere, so an out-of-tree `BaseEventStore` could not throw the same
classes — two same-named types would have been live at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Four built-ins need an OS process table: `services` spawns dev servers,
`resources` and `uploads` shell out to unzip and document converters, and
`git-status` runs `git` on a 2 s interval. In a Worker isolate there is no
process table, so `git-status` warned on a loop forever with no way to turn it
off — the set was a hardcoded array.

It could not simply become configurable, because `BuiltinMethodSchemas` is
inferred from that array and flows into every client's RPC types. So the
contract stays pinned to the full set and only *registration* becomes a choice.
`isolatePlugins` is declared `satisfies` a subset of `fullPlugins`, so it cannot
name a plugin the RPC contract does not know.

`bootstrap()` is overloaded: the existing three-argument form is unchanged in
signature and behaviour, and `Services`' new type parameter defaults to `'full'`,
so bare `Services` still means what it meant. The full-profile overload of
`createSystemFromServices` is declared last so `ReturnType<>` keeps resolving to
it.

Calling an unregistered plugin's method degrades cleanly: the session returns a
validation error, and the HTTP dispatcher never gets that far because it gates on
the registered method map first.

Also collapses the test harness's copy of the plugin list — it was a second
array that drifted silently from the one production bootstraps with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Phase 0 asked whether an isolate can carry a real session. Replay was the half
that could be measured without a deploy: rehydration reduces the whole event log
with no network in between, so wall time is a usable proxy for CPU.

It is not the risk. Replay is linear to ~20 000 events, then the reducer's
copy-on-append — a state spread, a fresh agents Map and a full conversation
history copy, once per event — turns quadratic and worse, reaching 30 s at
roughly 320 000 events. A turn costs about six events, so a 20-turn session is
~250 and replays in well under 150 ms, most of that fixed session construction
rather than replay. The exit criterion clears by three orders of magnitude.

Appends are flat in log size at ~0.1 ms/event batched, which confirms the
platform adapter's appendFile shim really does a ranged write rather than
reading and rewriting the blob.

Two incidental findings. `SessionManager.loadSession` calls `EventStore.load`
twice, once for the presetId and once inside `SessionStore.load` — at small N
that doubling is the entire I/O cost. And a 400 000-event log stops responding
where 350 000 completes, which smells like the isolate memory ceiling but is not
proven.

What stays unmeasured is CPU under streaming inference: no API key and no
wrangler auth here, and it needs a deploy to read real cpu_ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`ws` and `@types/ws` were declared but imported nowhere in src. The WebSocket
transport lives in `@roj-ai/transport`, which splits by platform and uses the
runtime's own WebSocket.

Left alone, it would have looked like a porting job for the Worker platform that
does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`loadSession` read the whole log to get the preset off event 0, then
`SessionStore.load` read it again. Neither store caches: the file store did a
full read, split and per-line schema parse, twice. Only the memory store hid it,
by returning the same array reference — which is why no test caught it.

At realistic session sizes that doubling was essentially the entire I/O cost of
opening a session.

`SessionStore.fromEvents` takes an already-loaded log; `load` keeps its old
signature and delegates. Every error branch stays where it was, so a missing
session, an empty log and a first event that is not `session_created` all behave
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`agentEvents` was exported but `llmEvents`, `toolEvents` and `sessionEvents`
were not, so anything outside the package that needs to construct domain events
— a benchmark seeding a log, a host replaying fixtures — could only reach for
agent events and substitute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…on sockets

The platform split already had `browser` and `bun`. Workers needs its own,
because a DO socket is not an event emitter: after `acceptWebSocket` the runtime
delivers `webSocketMessage`/`Close`/`Error` to the DO class, not to the socket.
`createWorkersWebSocketHandlers` is the bridge the DO forwards into.

Two things workerd does not provide are emulated. There is no `ws.subscribe` /
`ws.publish`, so topics live in an in-memory registry scoped to one DO —
hibernation tags look like a fit but are fixed at accept time and cannot express
a dynamic `subscribe()`. And adapters, subscriptions and the `ServerConnection`
objects above them die with the isolate, so `restore(state.getWebSockets())`
rebuilds the tree on wake; an event on an unknown socket lazily opens one rather
than dropping the frame.

The socket is described structurally, so the package still has no dependency on
`@cloudflare/workers-types`.

Deliberately no client factory. workerd's `WebSocket` extends `EventTarget` and
has no `onmessage`/`onclose` properties at all, so `browserWebSocketFactory`
would type-check against the ambient Bun `WebSocket` and be wrong at runtime.
A DO that needs to dial out wants a separate `addEventListener`-based factory.

`IWebSocketServer.upgrade` cannot work in Workers — an upgrade must return a 101
carrying the client half of a `WebSocketPair`. It is declared in `types.ts` and
implemented nowhere, not even by Bun, so it stays untouched.

Also widens `AppServices` to be generic over the bootstrap plugin profile,
defaulting to `full`. Mounting the Hono app over an isolate `Services` was a
type error; bare `AppServices` still means exactly what it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`appendBatch` bound three parameters per event into one statement, and a Durable
Object rejects a statement binding more than 100. Measured on a real DO: 33
events land, 34 fail with `too many SQL variables`. Every batch above that — a
seeded log, a fork, any burst of agent activity — was lost.

The unit tests passed because their bun:sqlite fake allows roughly 32 000
parameters, so an over-bound statement only ever failed in a Worker. The fake now
enforces the Durable Object limit, which turns the existing 200-event batch test
into the regression test this needed: with the chunking removed it fails with
exactly the error a DO raises.

Chunks are inserted with no await between them, so a host that commits a
synchronous run together still lands the batch whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…t store

Wave 1 built both and the harness used neither.

`bootstrap(..., { pluginProfile: 'isolate' })` drops the four built-ins that need
a process table. Verified by A/B in one dev session: zero `git-status` warnings
across `/run` and five minutes of `/bench`, then four the instant the literal is
flipped to `full` and eleven nine seconds later — the 2 s poll.

`SqliteEventStore` replaces the `FileEventStore` that `bootstrap()` builds off
`config.persistence`, swapped in on the `Services` object since `Config` has no
third mode. `/bench` now takes `?stores=sqlite,file` and runs one seed against
both.

The result is half of what the plan predicted. Writes are a decisive win: a
single append is under 1 ms at every log length where the file store pays ~12 ms,
because it lstats and rewrites the tail through the VFS each time. Reads are not.
Full `load` is a wash — `JSON.parse` plus zod dominates and rows add overhead
that cancels the file read they save — and the "indexed loadRange" claim never
materialises, because `FileEventStore.loadRange` already reads only the tail.
Both answer a poller in under 4 ms at every size.

Also fixes the harness's system type: it was `ReturnType<typeof
createSystemFromServices>`, which resolves to the full overload and was simply
wrong here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`execFile` buffers and resolves, which is exactly what
`workspace.runtime.exec(...).result()` gives — so it maps onto a shell backend
running over the same SQLite filesystem the fs adapter writes to. Argv is quoted
back into a command line with `shellQuote`, so no caller string reaches the shell
unquoted; cwd, env and timeout are mapped, and a non-zero exit rejects
Node-style with `code`, `stdout` and `stderr` on the error.

`spawn` stays ENOSYS. It returns a Node `ChildProcess` — streams, `kill()`,
`'exit'` — and only the `services` plugin needs it.

Wiring the backend took four things, three of them easy to miss and one
undocumented upstream: the `worker_loaders` binding, the `experimental` flag that
gates it, the `enable_ctx_exports` flag without which `ctx.exports` is undefined
and every exec dies reaching for `WorkspaceServiceProxy`, and that proxy
re-exported from the worker's main module with an `__getWorkspaceStub()` on the
DO — which upstream's `withWorkspace` mixin would have supplied, but this harness
builds its `Workspace` directly.

`GET /shell` probes 41 commands through both entry points rather than assuming
coverage. just-bash turns out to implement most of a userland — grep, sed, awk,
find, jq, sqlite3, pipes, redirects, subshells, globs, loops — but not `git`
(needs `WorkspaceOptions.git`), `node`, `unzip`, `pdftotext`, `python3` or
`curl`. So `git-status` and every upload preprocessor still cannot run here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The plan was written before any of it ran. Three of its claims did not survive
contact.

SqliteEventStore was predicted to win on reads as well as writes. It does not:
JSON.parse plus zod dominates a full load and both stores pay it identically, and
the "indexed loadRange" it promised never materialised because the file store
already reads only the tail. Writes are a decisive win, which is the reason to
keep it.

Replay's quadratic term was blamed on three copies; only `conversationHistory` is
a scaling term, the other two are constants. Two more growth sites — mailbox,
which never shrinks, and user-chat — were never exercised by the benchmark.

And `shell`/`snapshotting` were listed as things a plugin profile would drop.
Neither was ever a built-in; they register through `preset.plugins`, which
bootstrap has no reach into.

Also records what the work turned up that no plan would have predicted: the
Durable Object 100-parameter statement limit, the undocumented
`enable_ctx_exports` flag, and that workerd's WebSocket has no `onmessage`
property so the browser factory would compile and then fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`git-status` shelled out to the `git` binary, so in an isolate it failed every
2 s forever. Computer ships a client that works directly against the VFS, so the
plugin can be rewritten rather than dropped.

`git` joins `fs` and `process` on `Platform`, but optional: a host that cannot do
git omits it, and no existing implementation has to grow one. `bun-platform` is
untouched, and Bun hosts keep taking the `execFile` path with the original code
and the original warning.

The port is four methods, each derived from a call the plugin actually makes
rather than from what git can do — status, log, count-ahead, default-branch.
`defaultBranch` returns undefined for "unknown" so the caller keeps its own
fallback, which is what computer forces: its `symbolic-ref` accepts only `HEAD`,
so a remote's default branch is unreadable.

`git-status` also stops polling when the host reports ENOSYS, instead of warning
on every tick — the loop the isolate harness has logged since the spike — and
rejoins the isolate profile now that it no longer needs a process.

The Cloudflare implementation lives in `@roj-ai/computer-platform`, so the git
dependency never reaches the SDK. It turned out to be `@platformatic/vfs`, not
`isomorphic-git`: the latter is already bundled into `@cloudflare/computer`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…e Object

`createApp` is plain Hono, so `POST /rpc` and every other route it mounts work
unmodified once handed the in-DO `SessionManager`. The upgrade lives here rather
than in the transport package: `IWebSocketServer.upgrade` returns a boolean,
Bun's shape, and workerd needs a 101 `Response` carrying the client half of a
`WebSocketPair`.

Hibernation is the part worth proving rather than asserting, and it holds. A
client opened a socket, idled past eviction — the log shows a fresh bootstrap for
the next request, so the accepting isolate was gone — and still received every
notification from a live two-agent run, because `restore(getWebSockets())`
rebuilds the adapter tree in the new constructor. Verified again here: 10
`agentStatus` frames over a socket opened before the run started.

Four things about `createApp` under workerd, none blocking, all worth knowing:
`node:path` is imported at module scope by the file routes and the file store, so
the app only loads because `nodejs_compat` is set; upload and resource routes
mount unconditionally, so under the isolate profile they fail at request time
rather than 404; `/status` under-reports after eviction because `getStats` walks
only in-memory sessions and nothing calls `loadAllSessions`; and with no
`agentToken` the bearer middleware is a no-op, which is right for a harness and
must not survive contact with anything reachable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`/limits/<name>` routes to one probe per file under `src/limits/`, each on its
own Durable Object so a probe that OOMs or fills storage cannot poison the next
one's measurements. Probe bodies land in follow-ups.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
A Worker gets 400 ms of CPU to evaluate its modules, and `wrangler check
startup` measures that locally — no deploy needed. Profiling the 7 MB harness
bundle as one file only says "103 ms", so this adds three minimal Workers over
slices of the dependency graph and attributes the cost by difference.

The result inverts what the byte counts suggest: @cloudflare/computer is 5.9 MB
and costs ~23 ms, the SDK is 1.1 MB and costs ~55 ms. Startup is paid for
module-scope work — zod schema construction — not for code size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…requests

`Agent.scheduleProcessing` re-enters through `setTimeout`, and nothing in the
harness or the transport calls `waitUntil` or sets an alarm. A Worker makes no
promise to keep an isolate alive for a pending timer, so a session started by a
request that returns early has no durable reason to continue.

The probe splits the two halves apart: `?phase=held` keeps the request in flight
as a control, `?phase=start` returns immediately and `?phase=check` reports what
happened while nothing held the Durable Object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Phases 4 and 5 are done, so the status table and both sections now describe what
was built rather than what was intended — including two corrections Phase 4 made
to this document's own claims.

Phase 6 moves from blocked to deferred: the target shape is settled as per-agent-
type routing, where an agent needing a container stays on E2B and one that fits
an isolate takes the DO path. Integration itself is not the current subject.

Adds Phase 7 (limits) with the two ceilings already measured, and Phase 8, whose
finding is that the agent loop's re-entry timer has no durability on a Worker.
The four "wake me later" sites all recompute from state rather than closing over
it, so a `wake(key, delayMs)` port covers them without serialising anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
… at each

Four dimensions, each bisecting its own ceiling rather than quoting a documented
number, and each journalled to SQLite before it runs so an attempt that never
returns is still visible on the next request.

Measured, in the order a single value hits them: a DO SQLite column value stops
at 2,199,994 B; read_file's own guard at 10 MiB; a WebSocket frame over a real
hop at exactly 32 MiB; the workspace filesystem has no payload-shaped ceiling
and is bounded by isolate memory instead.

Stock configuration is safe because roj's own read guard fires first, 25x under
the SQLite ceiling, and fails cleanly. The cliff is what happens past it: an
oversized event leaves tool_started with no terminal event, so the agent never
goes idle and the session carries a dangling tool call into replay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Turns are driven for real — session, mailbox, plugin hooks, tool executor, event
store — against a scripted provider, so the numbers are roj's own CPU with the
network excluded by construction.

A turn costs ~10 ms and does not grow with session age: over 60 turns the slope
is +0.03 ms/turn, so the reducer's whole-history copy needs the bench's 10^4
events to bite and a turn adds nine. It costs 2 subrequests plus one per shell
exec, which is what binds first — 500 turns per invocation against CPU's ~2800.

Wall time is a different story: the default 500 ms debounce turns ~9 ms of work
into 1009 ms of wall, two hops accounting for 99% of it.

Also records that `Date.now()` advances during synchronous execution under
`wrangler dev` but not in production, which is what makes these spans readable
here and unreadable if the same probe were deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
A DO is single-threaded and roj's multi-agent model assumes agents progress in
parallel, so the two had to be reconciled by measurement rather than argument.

They reconcile, with a sharp edge. For work that awaits, parallelism is real —
20 agents cost 1.46x one, with 20 inferences counted open simultaneously inside
the provider. For work that computes, it is bookkeeping: peak concurrency is 1
and wall time is linear. Real inference is network-bound, so fanning out on one
DO does buy speedup; prompt building, replay and tool bodies do not.

Also measured: 448 live sessions with no degradation, 128 concurrent shell execs
with no queueing, and a hard-coded cap of 20 children per parent.

Two findings that outlive the numbers. A DO degrades by lifetime rather than
load — closed sessions leave workspace directories and event rows behind, and
createSession goes from ~15 ms to ~110 ms after a few thousand of them, so a
multi-session DO needs a reaper. And because both the event store and the
workspace filesystem go through synchronous sql.exec, a burst of writes never
yields: 400 ms of appends delivered one timer tick, 422 ms late, stalling every
other agent's debounce in the isolate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…nits

workerd exposes no heap counter, so headroom is measured the only way available:
keep taking touched 1 MiB buffers until the allocator refuses. Every step is
journalled to ctx.storage before it runs, so a dimension that kills the isolate
still reports how far it got.

`wrangler dev` does not enforce 128 MB — the only local ceiling is V8's own
~1.41 GB JS heap, about 11x production, and it is not catchable: V8 aborts and
the whole workerd process goes with it, every isolate and DO in it. So the
absolute numbers here are local artefacts; what transfers is the consumption
ratio. Against a ballast-imposed ~128 MB budget a replayed session holds 200 000
events, and conversation history costs ~2.1x its stored payload because the
log's copy and the reconstructed array are live at once.

Memory is not what bounds live sessions per DO. CPU is: git-status arms a 2 s
interval per open session, and creation degrades from 13 ms to 174 ms between
100 and 1000 of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…orker

Consolidates the five probes into one place, with every number marked as a real
ceiling or a local artefact — `wrangler dev` enforces almost nothing, so the
distinction is the difference between a budget and a guess.

The headline is that roj fits: ~103 ms of the 400 ms startup budget, ~10 ms and
2 subrequests per turn, flat in session age and payload, and 448 live sessions
per DO without degradation. What binds is subrequests, ~5x before CPU.

The three findings worth acting on are none of those: an oversized event hangs
the agent instead of failing it, a DO degrades by lifetime rather than load, and
synchronous sql.exec means a burst of writes starves every timer in the isolate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The loop re-entered through a bare `setTimeout`, which a Durable Object cannot
make durable. workerd does keep actor timers alive — it registers a wait-until
task for each one and cancels actor work only at actor shutdown — but the CPU
budget behind them is refilled by `topUpActor()`, which runs only when an event
is *delivered*. A timer callback never tops up, and an interval silently stops
rescheduling once the budget is spent. A DO alarm is a delivered event.

So `Platform` gains a required `scheduler`: `wake(key, delayMs)` / `cancel(key)`.
A wake carries no closure, because the isolate that armed it may be gone by the
time it comes due — the key is the whole routing table, and dispatch is a pure
function of the key plus a session that can be loaded from its event log.

`SessionManager.dispatchWake` is the host's entry point. Hosts whose process
outlives the delay implement `LiveScheduler` and deliver their own wakes; a DO
implements the plain interface and calls `dispatchWake` from `alarm()`.

`createBunPlatform` is unchanged in behaviour — it takes the timer-backed
implementation, which is what the SDK did all along. `createComputerPlatform`
takes it too for now, and accepts an alarm-backed one once that exists.

The `debounceCallback` poll deliberately stays on a raw timer: its guards are
in-memory cancellation state a resumed wake cannot reconstruct, and re-arming
every 100 ms would mean an alarm per agent per tick.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The agents plugin kept a Map of live timers on its plugin context; that context
held nothing else, so it is gone. Supervision now arms a `plugin:` wake, a second
key namespace alongside `agent:` — routing to a plugin method by name, which a
session loaded from its event log can already serve.

Two guards the migration forced. A wake can now outlive `close()`, and the tick
emits a mailbox event, so it returns early on a closed session rather than
appending to a sealed log. And because `SessionManager.shutdown()` does not run
`onSessionClose`, armed wakes survive it — without a stopped flag a leftover wake
would *reload* the session afterwards, which is worse than the leaked timer it
replaced.

git-status keeps its raw interval, deliberately, and the file now says why: it is
not a "wake me later" but an unbounded clock. As wakes it would re-arm forever,
and since dispatch loads a session from its event log, every session ever opened
would replay its whole log every poll period and keep an alarm-driven host
permanently awake. Slowing the interval only bills for that more slowly. The real
fix is to stop being a server-side poll, which changes the client and worker
contract rather than this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Implements the SDK's Scheduler port over `DurableObjectState`, so the agent loop
re-enters through `alarm()` rather than a timer. An alarm is a delivered event —
workerd tops the actor's CPU budget up for it, while a timer callback draws down
a budget nothing refills — and it outlives the isolate that armed it.

A DO has one alarm slot and the loop arms many keys, so wakes live as rows in the
synchronous KV mirrored by an in-memory map, with the alarm always set to the
minimum. `alarm()` drains everything due, re-arms to the next, and only then
dispatches, so a delivery that runs long or throws cannot strand the ones behind
it.

The SDK arms without awaiting — `scheduleProcessing()` is synchronous — so the
map and the row are written synchronously and cannot reorder against each other.
Only the alarm write is async: it is serialized on a tail chain whose steps
re-read the map when they run, handed to `waitUntil` so they outlive the frame
that dropped them, and followed by a storage sync, since a plain put lands at an
I/O checkpoint an aborted isolate never reaches. The factory re-arms from the
rows on boot, which heals an isolate that died between the two writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
matej21 and others added 9 commits August 8, 2026 12:13
The DO takes `createAlarmScheduler(ctx)` as its platform scheduler and gains an
`alarm()` that drains due wakes into `SessionManager.dispatchWake`. A new
`/limits/scheduler` probe sends a message, throws the booted SDK away as an
eviction would, and returns.

Measured: 22 of 25 events land across three alarms with nothing in flight, the
file is written, and the session settles 1068 ms after the request returned. The
second alarm drains two agents' keys at once. The negative control is what makes
it evidence — `?keepWakes=0` cancels the wakes and the session stays at 3 events,
zero alarms, no file, never settling.

The probes that build their own SessionManager over the DO's services had to be
told apart from the DO's own: an alarm dispatches into the booted manager only,
so those hung. They now take a timer scheduler, which is precisely the case
LiveScheduler exists for — their managers do not outlive their request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The original reasoning was half wrong. A bare timer *is* kept alive in a Durable
Object — workerd registers a wait-until task for every actor timer and cancels
actor work only at actor shutdown — so the loop escaping its invocation, measured
three times, is deliberate runtime behaviour rather than a dev-server artefact.

What a timer cannot do is pay for itself: the actor's CPU budget is refilled only
when an event is delivered, a timer callback never tops up, and an interval stops
rescheduling once it runs out, silently. An alarm is a delivered event. That is
the argument the port actually rests on.

Records the measured result with its negative control, the two key namespaces,
the LiveScheduler split, and why git-status keeps its interval.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Plugin cleanup only ever ran off `session_closed`: `SessionManager.shutdown()`
called `Session.shutdown()`, which stopped agents and nothing else, so no
plugin's `onSessionClose` fired. git-status kept a 2 s interval running for a
session nobody could reach, and because its tick closes over the session hook
context, the whole session graph stayed reachable — once per session ever loaded.

`shutdown()` now runs the close hooks and then stops agents, without emitting
`session_closed`: the host is going away, the session is not over, and writing
that event would mark it closed for good. Hooks run at most once per instance,
since `close()` clears the plugin contexts and a later `shutdown()` would
otherwise re-enter them with no context at all.

Cancelling durable wakes here is correct rather than a hazard. Real eviction
never calls `shutdown()` — the isolate simply disappears — and all three callers
mean "we are done"; a session loaded again later re-arms through
`checkPendingAgents()`.

The orphaned-process-group test asserted the old behaviour as intended, using
`shutdown()` to stand in for a crash. A crash runs no teardown at all, so it now
simulates one by abandoning the harness, and tears it down once the orphan is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
All three serialisers took `{ name, message, stack }`, so `cause` was dropped for
every error type. `EventAppendError` is the sharp case: its message is only
"Failed to append event to session: X" and the real failure lives in `cause`, so
a probe that hit the Durable Object's 2.2 MB SQLite value ceiling logged
`SQLITE_TOOBIG` nowhere at all.

`serializeError` walks the cause chain for all three, and is defensive about what
it might find there: cycles, an accessor that throws, a non-Error cause, or a
chain deeper than five. Key order is unchanged, so logs that are matched as
strings still read the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`SessionFileStore.read` caught with an empty block and discarded the error, so a
file too large to fit in memory, a permission error and a genuine ENOENT all came
back as "File not found". ENOENT still reads plainly; everything else keeps its
reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
In workerd an actor timer is not free-floating: every armed timer registers a
wait-until task that `IncomingRequest::drain()` waits on, and a repeating timer
arms a fresh one each tick. So one live session's 2 s poll was enough to keep the
wait-until queue permanently non-empty and a Durable Object permanently awake.

Moving the poll onto the scheduler port would have been worse — a wake carries no
closure, so it must re-arm from its own tick, and since dispatch loads a session
from its event log, every session ever opened would replay its whole log every
period. The poll had to stop being a clock instead.

Who can write the workspace is a host question, so the host now decides. Where
the scheduler is a `LiveScheduler` the process outlives a delay, which is exactly
the property that makes a free-running interval cheap — and that host has writers
roj never sees, a user's editor or a dev server. The poll runs there unchanged.
Where it is not, every byte arrives through a tool call or a request, so the
plugin arms nothing: `afterToolCall` marks the session touched, `onComplete`
reads git at the turn boundary, and a new `git-status.refresh` method answers a
client's pull.

The cost on such a host is that an out-of-band write is only seen on a pull, and
that a client must pull for its first snapshot. `git-status.refresh` is additive
to the RPC contract; no existing shape moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`/limits/idle` wraps the timer globals before the SDK boots — the only census of
an actor timer available from inside the isolate — then counts timers, wakes and
the alarm slot at each checkpoint.

Measured: over the whole run roj arms no timer at all. At `settled`, six seconds
later, after the SDK is dropped, after a fresh message replays the session from
its event log and settles again, and after a git-status pull, every checkpoint
reads `outstanding: []`, `wakes.pending: 0`, `alarmAt: null`. The one armed timer
is the probe's own self-check, so a zero means the census works rather than that
it is blind.

The control is what makes it evidence: the same census against a `LiveScheduler`
session catches git-status's 2 s interval immediately, with a stack trace into
`onSessionReady`. Same isolate, opposite answer.

`wrangler dev` never evicts a Durable Object, so this is the precondition for
hibernation and not hibernation itself. The probe output, the README and the plan
all say so rather than letting the numbers imply otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`git-status` recomputed on every refresh, and on an evictable host every refresh
is a client pull with no poll to amortise it. The computer workspace is SQLite,
and dofs already keeps a counter row that every mutation bumps before it lands,
so a single primary-key lookup answers "can the previous snapshot still stand".

~0.07 ms against ~9 ms for a whole RPC pull, roughly 130x.

Read from `vfs_meta`, not `MAX(vfs_nodes.rev)` — a delete removes the node row
and so *lowers* that maximum instead of raising it, which would have made the
gate miss deletions silently. The meta counter is monotonic across every mutation
site, with no tombstone join needed.

`FsRevision` is its own optional port rather than a method on `FileSystem`, which
mirrors node:fs and is built from `workspace.provider()` with no handle on the
counter, and rather than a field on the git port, since the signal is bytes on
disk. A host without it returns undefined, which callers read as "recompute" —
the same path as a host that cannot tell. Bun keeps polling exactly as it did.

The private-schema read is isolated to one file: an absent or reshaped table
degrades to undefined rather than throwing. Upstream already exposes this counter
through `provider.watch()`, but only wrapped in a 100 ms setInterval — the one
thing an evictable host must not hold — so the pull form is what is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
A Durable Object degraded by lifetime rather than load: a closed session left its
`/workspace/<sessionId>` directory and its event rows in place forever.

The reaper is a host call, not an `onSessionClose` hook, for three reasons.
`Session.close()` and `Session.shutdown()` both run the same hooks, so a hook
that deleted data would wipe every loaded session when the isolate merely goes
away. `reopen()` is a supported operation, which deleting at close would make a
lie. And the event log is the only record a session ever existed — so files are
reclaimable by default and the log is not, `deleteSession` sitting outside the
`EventStore` interface so nothing in the SDK can reach it.

Live data is guarded by the store's own durable `status === 'closed'`, re-read
after the removal because that await is exactly where a `reopen()` can land, plus
a grace period, a protection callback and a rule that a workspace directory not
named for its session is reported rather than removed.

Workspace files go through the filesystem adapter, not raw SQL: `vfs_blobs` is
content-addressed with no refcount, so two files with the same bytes share a
blob, and deleting nodes by hand would either leak every blob or corrupt
unrelated files sharing the hash.

The measurement settles the open question by ruling both answers out separately.
Within one run of 400 sessions per arm, growth in createSession across the arm:
sessions held live cost 5.81x on a timer-driven manager and 0.98x on the DO's
alarm scheduler, while the same debris with one live session costs 1.33x and
1.05x. So neither live count nor debris is the cause — both probes were seeing
git-status's polling branch, whose per-session work grows with the accumulated
filesystem. This DO already avoids it; the reaper's case here is storage, and on
a Bun host it is the latency fix too.

Reaping costs ~15 ms a session against createSession's ~10 ms, so it belongs on a
schedule rather than in a close path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
@matej21

matej21 commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Two more, both taking the "use SQLite natively where possible" route.

A revision gate on git-status (330fb365). dofs keeps a counter row in vfs_meta that every mutation bumps before it lands, so a single primary-key lookup answers whether the previous snapshot still stands. A gated pull costs ~0.07 ms against ~9 ms ungated — ~130x — and that is the whole RPC pull, not just the read.

Not MAX(vfs_nodes.rev), which was the obvious choice and is wrong: a delete removes the node row and lowers that maximum instead of raising it, so the gate would have missed deletions silently.

FsRevision is its own optional port. A host without it returns undefined, which callers read as "recompute" — the same path as a host that cannot tell — so Bun keeps polling exactly as it did.

A session reaper (c9c20275), as a host call rather than an onSessionClose hook: close() and shutdown() run the same hooks, so a hook that deleted data would wipe every loaded session when the isolate merely goes away, and reopen() would become a lie. Files are reclaimable by default, the event log is not — it is the only record a session existed.

Workspace files go through the filesystem adapter rather than raw SQL. vfs_blobs is content-addressed with no refcount, so two files with identical bytes share a blob; deleting nodes by hand would either leak every blob or corrupt unrelated files sharing the hash.

The open question about createSession degradation is settled, by ruling out both candidate answers separately. Growth across one arm, 400 sessions each, measured in a single run:

arm scheduler live sessions debris growth
hold timer grows grows 5.81x
hold-do alarm grows grows 0.98x
off timer 1 grows 1.33x
off-do alarm 1 grows 1.05x
all reaping on 0.95x

Neither live count nor debris is the cause. Both earlier probes were seeing git-status's polling branch, whose per-session work grows with the accumulated filesystem — they just each saw one face of it. This DO already avoids it; the reaper's case here is storage, and on a Bun host it is the latency fix too.

One upstream gap worth recording: this counter is already public through provider.watch(), but only wrapped in a 100 ms setInterval — precisely the armed timer an evictable host must not hold. The pull form (revision(), changesSince(rev, { path })) is what is missing, and coalesceChanges already implements the second one internally.

matej21 and others added 9 commits August 10, 2026 15:44
The probe *is* the FileSystem the SDK boots on — it wraps `platform.fs` before
`boot()`, so every call the composition root hands out is counted with its path
and byte count, rather than sampled from the outside.

One two-agent turn makes 32 calls, 24 of them writes, 4453 B. Twenty-three of
those writes are `session.log`; the agent's actual work product is a single write
of 76 B. So 96% of a turn's write operations and 98% of its bytes are log-shaped.

Cost, at 600 ops per figure: an `appendFile` of a log line runs 0.6–1.2 ms and is
flat in file size, against 0.053 ms for one bound INSERT into a clustered table —
12–27x. That puts `session.log` at ~19–21 ms of CPU per turn, about what the
agent loop it records costs.

It hides because `FileLogger` never awaits its append, so the work lands inside
the debounce where `turn-cost`'s `workMs` cannot see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Measured by /limits/payload: with the peer gone, 600 broadcasts left 500 frames
parked in the send buffer, dropped 100, delivered none, and reported nothing.
`broadcast()` returned void and discarded the count `ConnectionManager` already
computed, so certain data loss was indistinguishable from an idle topic.

`broadcast()` stays fire-and-forget — it runs on hook and reducer paths that must
not await a socket — but it now answers. `trySend` names the three outcomes that
`send()`'s boolean collapsed: handed to an open socket, parked to bridge a
disconnect, or discarded because the buffer was full. `send()` is unchanged,
defined as `trySend(...) === 'sent'`, so no caller moves.

`ServerAdapter` warns on a drop and on a frame past 16 MiB — the lower of
workerd's 32 MiB 1009 ceiling and Bun's default, sent anyway because the SDK
cannot know its host's limit — and on a close that leaves frames outstanding,
since a server connection never reconnects and those frames die there.

The harness had constructed its adapter with no logger, so even existing warnings
went nowhere. `createDoTransport` now requires one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Two ways `createApp` misreported what the host could do.

Upload and resource routes mounted unconditionally, so under the isolate profile
— which registers neither plugin — they failed at request time instead of
answering 404. They now mount only for the full profile. File routes read
`platform.fs` and stay mounted everywhere.

`/status` reported `getStats()`, which walks sessions currently in memory. In a
Durable Object that is whatever survived the last eviction, so the number moved
with isolate lifetime and "no sessions loaded" was indistinguishable from "no
sessions". The live fields keep their meaning and are now documented as a
liveness signal; `storedSessionCount` answers the durable question beside them.

`countStoredSessions()` reads ids only — no metadata, no replay. Measured against
the alternative on the file store, which is what polls this in production:
0.21/0.76/1.62 ms at 100/500/2500 sessions, against 3.9/15.7/84.5 ms for a
metadata census. Per-session durable detail stays with `sessions.list`, which
already filters and pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`/limits/fs-traffic` counted a turn's filesystem calls: 32, of which 24 were
writes, and 23 of those were `session.log`. The agent's own work product was a
single write of 76 B. An append costs 0.6–2.0 ms and is flat in file size against
0.06 ms for a bound INSERT, so the log cost about as much CPU as the agent loop
it records — hidden, because `FileLogger` never awaits and the work lands inside
the debounce.

Measured on the same wrangler process, toggling only the capability: **32 calls
to 7, 24 writes to 1, 4453 bytes to 76**, with all 23 log lines still recorded
and the same nine distinct messages. The remaining write is the agent's file.

`sessionLog` is an optional `Platform` capability, so a host without one keeps
real files, byte offsets and identical content — `git?` and `fsRevision?` are the
precedent. Both sinks now format through one `JsonlLogger`, with a test asserting
a swap cannot change an entry. `FileLogger.level` stays pinned to debug: the
session log is the detailed record and gutting it was never the point.

Three things checking the code caught that the design had not:

- The row cursor must be 1-based. `logs.tail` defaults `since` to 0 and the debug
  UI initialises the same, so a 0-based seq with an exclusive comparison would
  have silently dropped the first line of every session.
- An empty read must rewind rather than hold. The file path answers with the file
  size, so a stale cursor self-corrects; returning `since` unchanged would stick
  forever once a reap emptied the table.
- The reaper needed its own port. The delete sits on the files branch beside the
  data directory it replaces, so `events: false` still reclaims it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…vents

It selected distinct ids straight out of the events table, so it read every row —
~0.24 µs each. `/status` calls it on every poll, and commit c5149b7 put it on the
durable session count, so a Durable Object's status endpoint slowed down with log
length: 141 ms at 500 000 events, 755 ms at 3.3 M.

A recursive term seeks from one distinct id straight to the next down the
`(session_id, seq)` primary key, whose leading column is already `session_id`, so
no second index is added and the append path pays nothing. Measured on the same
on-disk database: 755 ms to 4.1 ms, with an identical result set.

The union stays, because neither table implies the other. A session created by
`updateMetadata` alone has no events yet, and `FileEventStore` lists it. And a
session can have events and no metadata: `doAppendBatch` inserts synchronously and
then awaits the metadata write, so only the second can fail on its own — leaving
rows that `exists()` and `load()` can see while `getMetadata()` returns null, with
the log the only record the session ran. Both cases are now tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…g them

The probe built its ServerAdapter with no logger and worked loss out as
`600 - buffered - delivered`. Commit 82bb216 made `broadcast()` return a delivery
record and made the adapter warn, so the probe now passes a logger and reports what
the transport says: peers 600, delivered 0, buffered 500, dropped 100 — the same
figures, no longer a subtraction, with 200 discard warnings in the DO log to match.

Re-confirms the 32 MiB frame ceiling over a real hop, and shows the new 16 MiB
warning behaving as designed: a 24 MiB frame is flagged as larger than a host may
accept and delivered whole anyway, since the SDK cannot know its host's limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`probeCascade` built its own SessionManager without `withOwnScheduler()`, so it
rode the DO's alarm scheduler while `alarm()` dispatched into the DO's own booted
manager. Its wakes were never delivered and every attempt timed out at 30 s with
three events — so the cascade numbers the README states as measured could not be
reproduced. It was the only site left; every other probe already wraps.

Re-measured, and two claims survive exactly: the chain first breaks at
10 485 760 B, `maxReadSize` to the byte, and truncation pins the event at
~85 KB for any file size.

Two do not:

- With truncation off the break is at 2 132 961 B, not 2 132 080 B, and the
  persist link binds *twice* — the result is written as `tool_completed` and
  again inside the following `inference_started`, 122 B larger. So there is a
  band where the result is on record and the turn still hangs; only past
  ~2 133 080 B does the log stop at `tool_started` as described.
- "roj's own guard fires 25x earlier and fails cleanly" named one guard for two.
  The 25x margin is `maxTokens` truncation; the clean failure is `maxReadSize`,
  which sits 4.8x *above* the store's ceiling and so never protects it. Stock
  config is safe because of truncation alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
The second file-shaped table. Each call was written twice — once on `createCall`,
again on `completeCall` after reading the whole entry back — and pretty-printed,
so a turn cost 8 `writeFile` and 4 `readFile` for ~110 KB. Measured on the same
process, files against rows: **20 filesystem calls to 7, 9 writes to 1, 110 950
bytes to 76**, with the four calls stored as 42 044 B of rows. ~7.9 ms to ~0.8 ms
of CPU per turn.

The columns are split rather than one entry blob, and `completeCall` is why: a
single blob would have kept the read-modify-write and merely moved it off the
VFS. With the outcome in its own columns, completing a call is a keyed UPDATE of
a few hundred bytes — 0.065 ms against 0.93 ms for the rewrite it replaces. The
scalars are columns because `listCalls` orders, pages and counts on them;
`request` stays one blob because nothing queries inside it.

Ordering is `call_id DESC`, which is the order the sorted `readdir` produced:
UUIDv7 is time-ordered and fixed-width, so lexicographic is chronological, and
unlike a timestamp it is total — two calls in one millisecond cannot overlap or
skip across a page.

`request` is the one column that grows without bound, so the store declares the
host's ceiling and the logger clamps to it. Otherwise an oversized prompt throws
inside an awaited `createCall` and turns a logged call into a failed inference —
a failure mode rows introduce and files did not have.

Retention is explicit: the newest 200 calls per session, trimmed on insert.
The blob grows with the conversation, so an uncapped table is superlinear in
turns inside an object shared by every session it ever ran. A file host keeps
everything, unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
`writeLine` started an append per line and awaited none of them, with nothing
serialising them, so three lines logged back to back could land in any order.
`FileLogger` had always done this; it only became visible when a second sink
started writing the same entries to rows and claiming its sequence
synchronously, and the test comparing them began failing 5 runs in 20 under
load. The test was right.

Lines logged while an append is in flight now queue behind it, keyed by file
path, so unrelated files are not serialised against each other and ordering
holds across instances — `child()` and a separately constructed logger over the
same path included. `writeLine` stays synchronous and errors stay swallowed: a
logger that throws into an agent loop is worse than a missing line.

Queued lines coalesce into one append, so the syscall count falls rather than
rises — a strict one-append-per-line chain would have undone the perf work this
sits next to. It also makes the computer adapter's `appendFile` emulation safe,
since that is an `lstat` followed by a write at the observed size and two of
those interleaving would have overwritten one another rather than merely
reordering.

Four tests pin it deterministically, including appends that finish in reverse
order; the comparison test only caught it about one run in four. Flake rate under
load went from 5/20 to 0/20, and 0/30 across the whole logger directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
@matej21

matej21 commented Aug 10, 2026

Copy link
Copy Markdown
Member Author

Four more, continuing the "use SQLite natively where possible" route.

Both file-shaped logs are now rows on a host that has a table. Measured on the same wrangler process, toggling only the capability:

filesystem calls per turn writes bytes
session log, before → after 32 → 7 24 → 1 4 453 → 76
LLM call log, before → after 20 → 7 9 → 1 110 950 → 76

The remaining write is note.md — what the agent actually produced. ~44 ms → ~1.4 ms and ~7.9 ms → ~0.8 ms of CPU per turn respectively.

The LLM log's columns are split rather than one entry blob, because completeCall is a read-modify-write: a single blob would have kept it and merely moved it off the VFS. With the outcome in its own columns, completing a call is a keyed UPDATE of a few hundred bytes — 0.065 ms against 0.93 ms for the whole-file rewrite. Ordering is call_id DESC: UUIDv7 is time-ordered and fixed-width, so lexicographic is chronological, and unlike a timestamp it is total.

Both are reclaimed on the reaper's files branch, so events: false still frees them.

listSessions() scaled with events rather than sessions — it read every row of the log, and c5149b71 had just put it on /status. A recursive term now seeks from one distinct id to the next down the existing (session_id, seq) primary key, so no index is added and the append path pays nothing: 755 ms → 4.1 ms at 3.3 M events on the same on-disk database. The union with the metadata table stays — a session can have events and no metadata, because doAppendBatch inserts synchronously and then awaits the metadata write, so only the second can fail alone.

The file logger could write its lines out of order. writeLine started an append per line and awaited none, with nothing serialising them; it had always done this, and only became visible once a second sink wrote the same entries to rows. Worse on the DO than it sounds: the computer adapter emulates appendFile as an lstat followed by a write at the observed size, so two interleaving would have overwritten one another, not merely reordered. Lines now queue per path. Flake rate under load 5/20 → 0/20.

Two corrections to this PR's own README. The cascade probe built its own SessionManager without withOwnScheduler(), so it rode the DO's alarm scheduler while alarm() dispatched elsewhere — it had been timing out at 30 s, and the numbers it documents could not be reproduced. Repaired and re-measured:

  • Survived exactly: the chain breaks at 10 485 760 B, maxReadSize to the byte, and truncation pins the event at ~85 KB for any file size.
  • Moved: with truncation off the break is 2 132 961 B, and the persist link binds twice — the result is written as tool_completed and again inside the following inference_started — so there is a band where the result is on record and the turn still hangs.
  • Wrong as written: "roj's own guard fires 25× earlier and fails cleanly" named one guard for two. The 25× margin is maxTokens truncation; the clean failure is maxReadSize, which sits 4.8× above the store's ceiling and never protects it. Stock config is safe because of truncation alone.

1174 tests pass.

matej21 and others added 5 commits August 10, 2026 18:53
Wake keys are `:`-separated and the parser demands an exact segment count,
but the segments are caller-supplied: `createSession` accepts any string as
a `sessionId`, and plugin names come from user config. An id like
`tenant:acme` minted a key that never parsed back, and `dispatchWake` drops
an unparseable key on purpose — so the agent silently never resumed after a
debounce, with nothing in the log to say why.

Percent-encode every dynamic segment. Ordinary ids (uuidv7, `orchestrator_1`,
`git-status`) are unchanged byte-for-byte, so the minted keys stay readable.

Reported by Codex in review of #16.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
Three ways a host was told teardown had finished while it was still running:

- `runSessionCloseHooks` guarded itself with a boolean set before its first
  await, so a second caller returned immediately mid-run.
- `close()` emitted `session_closed` and returned; the hooks ran detached
  from a synchronous listener.
- The manager evicts a session from its cache the moment that event fires,
  so `shutdown()` no longer saw the session it needed to wait for.

Together that let `SessionManager.shutdown()` return while `services.stop()`
was still terminating workers — on a Durable Object, work outliving the
request that owns it.

Memoise the run as a promise, keep a handle on the teardown the event
starts, and have the manager hold that handle across eviction.

Reported by Codex in review of #16; the test fails on each of the three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
…esystem

`GET /clone` shallow-clones a repo into a fresh DO and times the clone, a
walk of what landed, three consecutive `git status` runs, and one write
followed by the status an editor would wait on.

Against the two largest sites running on this stack today, the clone is a
one-time provisioning cost the DO's durability absorbs. `git status` is not:
it costs ~1.2 ms per tracked file, never warms up, and lands on every edit.

Auth goes through Basic, not the Bearer the upstream README shows — a Bearer
header 401s against git-over-HTTPS even on a public repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
FsRevision answered one question — has anything changed — which turns a walk
of the tree into a lookup, but leaves the walk itself intact once the answer
is yes. On a real site repository that walk is the whole cost: `git status`
hashes every tracked file at ~1.2 ms each.

So the port gains `changedSince`, and the computer adapter answers it from
the index dofs already keeps for its own sync — `vfs_nodes_by_rev` for live
nodes, `vfs_changes_by_rev` for deletions, names resolved by walking
`vfs_dirents` upward, which costs the depth of the tree rather than its size.

A host that cannot answer says `undefined`, same as one with no port: too
many rows, an unfamiliar schema, no such index. Callers recompute in full,
which is what they did before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
git-status asked git the same question after every save, and git answered it
by hashing the whole working tree. Measured against the two largest sites
running on this stack, cloned into a Durable Object:

    ochrance-web     7,657 files   git status 9,425 ms   delta 2 ms
    svet-neziskovek  2,090 files   git status 1,152 ms   delta 3 ms

The revision gate did not help here: an edit moves the counter, so every save
paid the full read. Now a full read seeds the set of paths that differ from
HEAD and the filesystem delta carries it forward, so the cost follows the edit
instead of the repository.

Between commits the count can only err high — a file rewritten with the bytes
it already had moves the revision without differing from HEAD. A commit moves
HEAD, which is what makes files clean again and what no delta can report, so
that is where the full read runs again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR
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