Run roj standalone in a Worker, on @cloudflare/computer - #16
Conversation
`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
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
|
Two more, both taking the "use SQLite natively where possible" route. A revision gate on Not
A session reaper ( Workspace files go through the filesystem adapter rather than raw SQL. The open question about
Neither live count nor debris is the cause. Both earlier probes were seeing One upstream gap worth recording: this counter is already public through |
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
|
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:
The remaining write is The LLM log's columns are split rather than one entry blob, because Both are reclaimed on the reaper's files branch, so
The file logger could write its lines out of order. Two corrections to this PR's own README. The cascade probe built its own
1174 tests pass. |
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
Runs the roj agent runtime standalone inside a Cloudflare Worker — a Durable Object with a
@cloudflare/computerworkspace 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-workeris a private harness.Verdict: it fits
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/computeris 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 rojPlatformover a computerWorkspace: filesystem,execFilerouted to the workspace shell, a git client, a SQLite event store overctx.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 (
isolatedropsservices,resources,uploads), an optionalgitcapability onPlatform, a requiredschedulercapability,BaseEventStoreexported for out-of-tree stores, and a Workers WebSocket platform in@roj-ai/transportover 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 bytopUpActor(), 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
Platformgainsscheduler: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 —
createBunPlatformtakes 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'sgit-statuspoll 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
LiveSchedulerthe 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 newgit-status.refreshmethod answers a client's pull. Additive to the RPC contract; no existing shape moved./limits/idleshows roj arming no timer at all across settle, eviction, resume and pull. The control is what makes it evidence: the same census against aLiveSchedulersession catches the 2 s interval immediately, with a stack trace.Three defects the probes turned up
SessionManager.shutdown()never ranonSessionClose, so no plugin released anything — a timer and a memory leak per session ever loaded. One test asserted this as intended behaviour, usingshutdown()to stand in for a crash.cause, soEventAppendErrorreported no reason at all.SessionFileStore.readcollapsed every failure intoFile not found.Worth knowing before this is relied on
tool_startedwith no terminal event; the agent never goes idle and replay carries atool_usewith notool_result. Stock config never gets there —read_file's guard fires 25× earlier and fails cleanly — but raisingmaxTokenswalks into it.createSessiongoes from ~15 ms to ~110 ms after a few thousand. A multi-session DO needs a reaper.sql.exec, so 400 ms of appends delivered one timer tick, 422 ms late.Deliberately not done
spawnstays ENOSYS, soservicescannot run in an isolate; under per-agent-type routing such an agent goes to E2B instead. ThedebounceCallbackpoll 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 devnever evicts a DO, so this shows the precondition, not the behaviour.🤖 Generated with Claude Code
https://claude.ai/code/session_01JrTY8BadgtRHgo8DNEP7fR