feat(examples): Harness capability with a remote Claude Code runtime in a Container - #2285
mattzcarey wants to merge 9 commits into
Conversation
… runtime One developer API for every harness (session().prompt/interrupt/requests/reply/ messages/status/result/wait/events), one Harness capability over a HarnessRuntime port, and a remote runtime that drives Claude Code in a Container over Cap'n Web. Registered in the design indexes.
examples/next/harnesses/shared (@cloudflare/agents-next-harness): the one Harness Lifecycle capability every harness example composes. The base owns admission (a durable inbox), operation and request rows, one Streams log per operation plus one per session, the Tasks driver that wakes the runtime, the browser link and the useHarnessSession React hook. A HarnessRuntime owns the agent loop and the transcript; runtimes add vocabulary through a HarnessProtocol type parameter. Also ContainerHarnessRuntime: a runtime whose engine runs as a daemon in a Cloudflare Container, dialled over getTcpPort and Cap'n Web, with a durable outbox cursor, a doorbell for waking an evicted object, an idle policy that parks and stops the container, and a transcript mirror so a new container resumes the engine's own session from entries held in the Durable Object.
…nto the shared Harness PiHarness, CodexHarness and SelfModifyingHarness stop being capabilities and become HarnessRuntime implementations (PiRuntime, CodexRuntime, SelfModifyingRuntime) behind the shared Harness. Their bespoke WebSocket transports, React hooks and intake tables are deleted; every client uses useHarnessSession over the shared browser link, and host-only demo features moved to plain HTTP routes on the object. Tests are ported and extended (eviction mid-turn, steer, interrupt).
examples/next/harnesses/claude-code: one Durable Object per coding session, one container per object. The container runs harnessd, a Node 24 daemon (Cap'n Web over ws, a node:sqlite outbox) hosting the Claude Agent SDK in streaming-input mode, plus an echo engine for keyless smoke deploys. Permissions round-trip as durable requests, the transcript is mirrored to the Durable Object through the SDK SessionStore so a killed container resumes the conversation, and AI Gateway credentials enter through ANTHROPIC_BASE_URL plus a gateway token. The daemon is a workspace package so its typecheck, tests and build run in CI.
# Conflicts: # examples/next/README.md
|
⚪ agents import sizesMeasured 343 runtime imports as minified bundles. The primary size is gzip; raw minified size is included for diagnosis. An existing import growing by more than 10% is marked red. This report is informational.
Compared No import sizes changed. All 343 current runtime imports
Reported by agent-think[bot]. |
agents
@cloudflare/ai-chat
@cloudflare/codemode
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
- daemon: record a delivery key as applied only after the engine took the row, with an in-flight guard for a duplicate racing the first; a daemon that dies between the two no longer leaves evidence of an input the engine never received - harness: seed the session seq from every log that can hold the tail (settled operations' last_seq, running operations' streams, the session log), not the newest-created stream; a steer that settles a newer operation while an older one keeps appending no longer reuses seqs - harness: delete a session's streams page by page until none remain - harness: sessions.list() cursors carry the (created_at, session_id) key, so sessions created in one millisecond page without skips - remote: the launch digest covers env values as well as keys (the per-generation runtime id excepted), so a rotated credential relaunches - react: the hook resets its cursor when the agent or sub path changes - outbox: byte accounting follows the commit, so a rolled-back batch counts nothing Tests: seqs stay strictly increasing across an eviction mid-turn; sessions page without skips; the outbox applied-key API.
| const stop = new AbortController(); | ||
| await Promise.race([ | ||
| done.then(() => stop.abort()), | ||
| ctx.inbox.wait(stop.signal) | ||
| ]); | ||
| if (!outcome) await this.#deliverSteer(ctx, lane, context); |
There was a problem hiding this comment.
| function promptText(payload: JsonValue): string { | ||
| // SAFETY: the base writes `HarnessPromptPayload` into every prompt row. | ||
| const input = (payload as unknown as HarnessPromptPayload).input; | ||
| const text = typeof input === "string" ? input : (input?.text ?? ""); | ||
| if (text.trim() === "") { | ||
| throw new SelfModifyingInputError("Turn prompt must not be empty"); | ||
| } | ||
| return text; |
| if (request.method === "POST" && segments.at(-1) === "restart") { | ||
| // Evict this object after replying, so a reader can watch a turn the | ||
| // container is still running survive the eviction: the outbox holds | ||
| // the frames, the doorbell wakes a fresh incarnation, and reconcile | ||
| // replays them. | ||
| setTimeout(() => this.ctx.abort("restart requested from the demo"), 50); | ||
| return new Response(null, { status: 202 }); | ||
| } | ||
| if (request.method === "POST" && segments.at(-1) === "kill") { | ||
| // Destroy the container, workspace and all. The next prompt launches a | ||
| // fresh one and hands the engine back the transcript this object kept, | ||
| // so the model remembers the conversation the dead container ran. | ||
| await this.runtime.stop("killed from the demo"); | ||
| return new Response(null, { status: 202 }); |
| return ( | ||
| (await routeAgentRequest(request, env, { cors: true })) ?? | ||
| new Response("Not found", { status: 404 }) |
There was a problem hiding this comment.
| const url = new URL(request.url); | ||
| if (url.pathname.startsWith(HARNESS_DOORBELL_PATH)) { | ||
| // The daemon cannot address a Durable Object; this entrypoint can. | ||
| return (ctx as ExportsContext).exports | ||
| .HarnessDoorbell({ props: { namespace: "ClaudeCodeSession" } }) | ||
| .fetch(request); |
There was a problem hiding this comment.
| async onRequest(request: Request): Promise<Response> { | ||
| const { pathname } = new URL(request.url); | ||
| if (request.method !== "GET" || !pathname.endsWith("/snapshot")) { | ||
| return new Response("Not found", { status: 404 }); | ||
| } | ||
| await this.lifecycle.start(); | ||
| return Response.json(this.runtime.snapshot()); |
- Outbox persists its high-water seq in meta so a fully pruned outbox does
not restart at seq 1 on a reopened daemon under the same runtime id.
- ContainerHarnessRuntime treats a failed attach (container starting, port
not listening) as a retry with backoff instead of failing the running
operation; a handshake the runtime already settled still propagates, and
attach failures give up after five minutes.
- interrupt({ operationId }) only targets operations in the caller's session.
- begin() marks the row running in the same transaction that writes
operation_started.
- The daemon's request timeout and shutdown replies use each request's own
reply shape, through a shared harnessTimeoutReply helper.
- pi delivers steers already in the inbox before waiting for the next row.
- The self-modifying runtime reads prompt parts when there is no text.
- The browser transport validates each client message's fields.
- The doorbell bounds the object name it will route to.
- READMEs say plainly that the examples and the socket are unauthenticated.
Tests: outbox reopen after prune, cold-port dial retries, interrupt scoping,
request timeout shapes.
…n delete - ContainerHarnessRuntime no longer advertises resumePolicy: nothing read it, and a lost operation always settles as E_ENGINE_LOST while the next prompt continues the session on the restored transcript. The RFC says the same and marks the SIGTERM-continuation item as the blocker. - ContainerHarnessRuntime.delete() and the Codex runtime's delete() clear the Sessions transcript they projected, so a recreated session starts empty. - A begin or settle frame for an operation the base no longer has is dropped with a warning instead of failing the drive pass. - The remote test fixture ends the fake daemon's generation when the container is destroyed, as the platform does.
…t have An event frame for an unknown operation reached ctx.begin() through the handle lookup and threw on every retry, so the drain never advanced past it. The guard now wraps the whole frame in the batch loop: a frame the base cannot place is dropped with a warning and the cursor moves on. The fake daemon can leave a stray turn in its outbox to test it.
Summary
One developer API for every harness, one
Harnesscapability over a pluggable runtime, and Claude Code running in a Cloudflare Container as a remote runtime driven over Cap'n Web. Closes the direction of #1829 without adopting@ai-sdk/harness: the SDK's own Tasks, Streams and Sessions stay the durability model.Design:
design/rfc-harness-capability.md(registered in the design indexes).What is in the PR
examples/next/harnesses/shared(@cloudflare/agents-next-harness). TheHarnessLifecycle capability: durable inbox admission, operation and request rows, one Streams log per operation plus one per session, a Tasks driver that wakes the runtime, the browser link and theuseHarnessSessionReact hook. Runtimes implementHarnessRuntime(drive(ctx)plusmessages()) and add their own vocabulary through aHarnessProtocoltype parameter. Example-local on purpose; nothing is exported fromagentsyet.ContainerHarnessRuntime(shared/src/remote.ts). A runtime whose engine runs as a daemon in a Container: dialled throughgetTcpPort().fetch(upgrade)andnewWebSocketRpcSession, the Durable Object exports nothing, events arrive on aReadableStreamof batches from a durable outbox in the container. Generation-scoped wire cursor committed in the same transaction as the frames, a doorbell for waking an evicted object, an idle policy that closes the socket (detachAfterIdleMs), keeps the container alive withsetInactivityTimeoutand destroys it (stopContainerAfterIdleMs), and a transcript mirror so a new container resumes the engine's own session.examples/next/harnesses/claude-code. One Durable Object per coding session, one container per object.container/isharnessd: Node 24, Cap'n Web overws, anode:sqliteoutbox, the Claude Agent SDK in streaming-input mode (one long-livedquery(),PreToolUsegate pluscanUseToolfor permissions as durable requests, aSessionStoremirror), and an echo engine for keyless smoke deploys (HARNESS_ENGINE=echo). The daemon is a workspace package so its typecheck, tests and build run in CI; the Dockerfile builds from theharnessesdirectory.PiRuntime,CodexRuntime,SelfModifyingRuntimebehind the sharedHarness. Their bespoke transports, hooks and intake tables are deleted (net 6.1k lines removed); every client uses the shared hook; host-only demo features moved to plain HTTP routes on the object.The developer API
compact,fork,rewind,configure,cancelQueuedare always present and throw unlessstatus().capabilitiesadvertises them.Verified in production
Deployed on two accounts and driven over the shared wire with
shared/scripts/drive.mjs:cf-aig-metadataproject tag): a Write turn in under 10 s warm, a Bash turn with a permission round trip, usage and cost frames.setInactivityTimeoutkeeps the container alive across a Durable Object eviction.POST .../restartevicts the object mid-turn; the fresh incarnation re-attaches within 4 s and the turn settles once.POST .../killdestroys the container mid-conversation; the next container resumes the Claude Code session from the transcript mirrored into the Durable Object (signed thinking included, because Claude Code replays its own entries) and answers a question about the earlier turn.Tests
Shared 25 (harness API, replay from any cursor, live tails with previews, idempotent ids, interrupt with drain, request round trip and timeout, multiple sessions, eviction between and during operations; a fake daemon over a
WebSocketPaircovers the wire: reconnect from the cursor, generation change, redelivery, restore chunking, tool-result projection), pi 4, codex 5, self-modifying 10, claude-code 6 (a live-daemon suite that drives the realContainerHarnessRuntimeagainst a localharnessd, skipped when none listens), daemon 44 (outbox, wire, projection fixtures, session mirror).Known edges
mirror_errorevent and a later restore resumes a transcript with a hole; nothing detects the hole on the wire yet.interceptOutboundHttpsmode is future work.messages()has no paging in any example; pi'ssessions.delete()cannot delete a pi lane.inforeports the daemon build digest for that reason.Decisions this PR takes (from the RFC's "The decision")
Harness; the examples are ported, not kept.rfc-coding-agent.md'sHarnessEnginepath over@ai-sdk/harnessis closed.messages(),requests()andreply()stay in the core even for harnesses that return one page and an empty list.container/), not aspackages/entries, until the wire stabilises.getAgentByNameandRoutedAgentsare untouched: a container-backed session is one top-level object and the example has no hub.