SqliteStorage.commit(batches) reads the check state of all batches up front,
checks each batch in memory against what the earlier ones left (same
prefix-stop refusals, no savepoints), and writes the accepted batches per
table: small rows as one JSON array parameter (json_each), rows with much
text bound column by column within 100 parameters. A group commit over a
remote SQLite takes a few round trips instead of about eight per batch;
local groups are faster too. Randomized differential test against
MemoryStorage, raw-table and statement-count guards.
src/next is src (the main export), src/next-node is src/node with short subpaths (/http, /workers, /plugins,
/plugins/runtime, /app, /surfaces, /web, /react, /tui, /tui/launcher); the foundations get /json and /watch;
Chord v1 moves to src/old (/old, /old/node) until p2 runs on v2; tests and docs move likewise (test/, docs/,
test/old, docs/old). Durable and env import the foundations from their subpaths.
Ports the coding agent's tool ranking fixes for #11331 (#11440, #11625)
to durable, which keeps its own copy of the ranker in `src/search.ts`.
Problem: durable's `rankDocuments()` was still the coding agent's ranker
from before both fixes. `thread_read` with `limit: 1` returned
`queue_read`, and a tool name among other words (`peekaboo app`) lost to
tools that only mention the words in their descriptions.
Change:
- `src/search.ts`: same scoring as the coding agent's `Bm25Ranker`, plus
coverage first: a tool matching more of the query's terms (in any field)
ranks above one matching fewer, whatever the scores. Without it, name
weighting regressed `screenshot window` (1 to 2, `window` beat `image`)
and `describe app` (1 to 2, `app` beat `see`). The name is a separate
field (3x IDF per term, above any number of description mentions), a
tool whose whole local name is in the query gets a bonus worth one more
match of every query term, a query that is exactly a tool's name ranks
it first, and namespace text is a separate 0.5x field that is not
length-normalized.
- `tool_search`: the search text no longer repeats the name; the ranker
reads it from `name`.
- `searchTools()`: same, and the namespace text now includes the
namespace instructions, as in the coding agent.
- `test/search.test.ts`: the coding agent's ranker cases.
Remaining difference from the coding agent: durable's `tool_search` has
no namespaces (they are a code mode option), and `searchTools()` ranks
on the tool sample from the start data (description plus TypeScript
declaration) rather than description plus schema text.
`searchTools()` results are journaled, so replaying a script started
before this change returns the old results; no `CODEMODE_MODULE_VERSION`
bump.
## Before/after
`searchTools()` ranking on a made-up set of 16 MCP tools from 5 servers.
Each tool is searched with its description; each server is a code mode
namespace (prefix `mcp__<server>__`) with the description and
instructions below. Rank of the wanted tool among all matches, with the
top result when it is another tool.
| Server | Description | Instructions | Tools: description |
|---|---|---|---|
| `github` | GitHub repositories, issues, and pull requests | Use
owner/repo for repositories. Issues and pull requests share numbers. |
`list_issues`: List issues in a repository, filtered by state, labels,
or assignee.<br>`get_issue`: Get an issue with its comments by
number.<br>`create_issue`: Open a new issue in a
repository.<br>`search_code`: Search code across
repositories.<br>`create_pull_request`: Open a pull request from a
branch. |
| `linear` | Linear issue tracker | Search issues before creating new
ones. Issues belong to teams. | `search_issues`: Search Linear issues by
text.<br>`create_issue`: Create a Linear issue in a team. |
| `peekaboo` | macOS automation | Peekaboo controls macOS apps. Use app
names or bundle IDs. Peekaboo can capture any app window. | `app`:
Launch, quit, relaunch, focus, hide, or switch applications. Targets an
application by name, bundle identifier, or process ID, and can wait
until it is ready.<br>`see`: Capture the app window and describe the UI
elements of the app.<br>`window`: Move, resize, or close a window of an
app.<br>`menu`: Click a menu item in an app.<br>`image`: Take a
screenshot of the screen, an app, or a window. |
| `app` | Thread and queue state of the app | Threads hold messages; the
queue holds pending messages of a thread. | `queue_read`: Read a queued
message of this thread.<br>`thread_read`: Read durable state and a
paginated timeline from any thread. The default view returns user
messages, assistant messages and plans. Continue with afterPosition
until hasMore is false. |
| `docs` | Project documentation | Search the docs before answering
questions about the API. | `search`: Search the
documentation.<br>`read_page`: Read a documentation page by path. |
Before, a tool's search text was its name, the name with `_` as spaces,
its description, and the namespace name and description (no
instructions). After, the name and the namespace (with instructions) are
separate fields.
| Query | Wanted | Before: rank | After: rank | Change |
|---|---|---|---|---|
| `thread_read` | `app__thread_read` | 2 (top: `app__queue_read`) | 1 |
$\color{green}{\textsf{better}}$ |
| `mcp__app__thread_read` | `app__thread_read` | 2 (top:
`app__queue_read`) | 1 | $\color{green}{\textsf{better}}$ |
| `peekaboo app` | `peekaboo__app` | 5 (top: `peekaboo__see`) | 1 |
$\color{green}{\textsf{better}}$ |
| `app` | `peekaboo__app` | 4 (top: `app__queue_read`) | 1 |
$\color{green}{\textsf{better}}$ |
| `read thread messages` | `app__thread_read` | 2 (top:
`app__queue_read`) | 1 | $\color{green}{\textsf{better}}$ |
| `open the peekaboo app tool` | `peekaboo__app` | 5 (top:
`peekaboo__see`) | 1 | $\color{green}{\textsf{better}}$ |
| `launch app` | `peekaboo__app` | 1 | 1 | $\color{gray}{\textsf{same}}$
|
| `create linear issue` | `linear__create_issue` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `search documentation` | `docs__search` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `read docs` | `docs__read_page` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `pull request` | `github__create_pull_request` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `get issue comments` | `github__get_issue` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `screenshot window` | `peekaboo__image` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `describe app` | `peekaboo__see` | 1 | 1 |
$\color{gray}{\textsf{same}}$ |
| `create issue` | `github__create_issue` | 2 (top:
`linear__create_issue`) | 2 (top: `linear__create_issue`) |
$\color{gray}{\textsf{same}}$ |
| `search github issues` | `github__list_issues` | 5 (top:
`github__search_code`) | 5 (top: `github__search_code`) |
$\color{gray}{\textsf{same}}$ |
No regressions on this set. `search github issues` stays at 5:
`list_issues` does not contain "search", so it ties on coverage (2 of 3
terms) with `search_code` and `linear__search_issues`, and their scores
win.
Adds a `beforeProviderRequest` generation hook so extensions can replace
the provider request body before it is sent. It is the durable
counterpart of coding-agent's `before_provider_request`.
Durable calls `streamSimple()`, so apps can't set provider-specific
fields such as Anthropic's `thinking.display: "updates"` (#10804)
without wrapping `Models`. Now it's an extension:
```ts
hook(GenerationTask, {
beforeProviderRequest: ({ model, payload }) => {
if (model.api !== "anthropic-messages") return undefined;
// change payload
return { payload };
},
})
```
A Session no longer holds its line while a commit is stored. Commits that
arrive while one storage call is in flight go out together in the next call,
each with its own seq and outcome; a job runs until it touches a key an
unpublished commit wrote, then waits for that commit's publication. Outcomes
are delivered in line order; publication is in line order, one commit per
macrotask. The scheduler's decisions run as regions that record the keys they
read and rerun after a wait, with side effects only after a clean check.
Storage.commit(batches, receive, context) replaces commit(writes, adopt,
context): prefix semantics (Durable's own checks refuse a batch and keep the
ones before it; any other failure rejects the call), receipts per stored
batch, optional publishing() scope. Memory, SQLite, JSONL, IndexedDB and
StoreStorage implement it natively; StoreStorage through the new
ChordStore.commitSeries (one store transaction per call), replacing
commitInBatch.
A normal run makes no storage reads: bounded record cache, conversation
tails, task-document index, kept context ranges. A tool slot's running status
and a generation's attempt are stored with the next commit (12 instead of 14
commits per run).
One Harness with 128 conversations over storage with a 4 ms round trip: about
5 -> 440 agent runs per second. Design: packages/durable/docs/group-commit.md.
Registries: shared tokens (defineRegistry), one provider per host (host.serve(R, { compose, accepts })),
contributions from facets and host-lifetime objects, plain values or followed Chord watchables, a table
sorted by plugin, lazy compose in the batch that changed the table, failures left out and reported with
the last good value kept; self.registry as a declared dependency, host.registry outside objects, older
readers mapped by accepts[old].value; keyedEntries and orderedEntries. Native sources: self.writable,
registry values and ref.state. self.retryOn wakes failed objects on registry values. host.root gives code
outside objects a call scope sealed at settle, abort and host.close.
Durable: registryReader(value, context); a Session close ends synchronous observers failed
("session closed"), watches still end ended.
A database an earlier unreleased build wrote at pi.live version 2 has a run
without run.turns; a run in flight across the upgrade then counted NaN turns
and failed its next tool round with "Value contains a non-finite number and
is not strict JSON". Version 3's migrate adds turns: 0 wherever it is
missing.
GenerationHooks.afterRun asks, after a run answered, for durable tasks
(runEndTask(task, input)). The answer commits and the run's inputs settle
done first; the generation then creates the tasks as its children in a
finish phase and waits for them in drain, keeping pi.live.run (run.afterRun)
and the conversation busy, so idle waits include them, Stop aborts them and
a crash resumes them. Queued input is placed when they are terminal.
pi.live.run.first and runFailure.first name where a run's history starts,
also for a continue after a failure.
closes#11141
HookApi.entry() and entries(), and TaskRuntime.entries(): a hook or task
reads the conversation's raw history, which compaction never changes, so a
range bounded by entry IDs reads the same before and after a compaction and
in a rerun.
Conversation.submit(tx, draft) and ConversationHandle.submit(tx, draft) run
the same admission inside the caller's commit and return the submission ID,
so a host's own checks and writes and the admission are stored together or
not at all. Admission must come before the commit's first table write, once
per commit, and throws ReadAfterWrite otherwise on every path. A first
argument that is not a draft is taken as a transaction; one of another
Session, or a wrapped one, is refused. The handle form also rejects once its
invocation is aborted. The pi.durable.submit span covers the admission only.
closes#11149
An input with whenIdle: "reject" is refused with the new ConversationIdle
when the conversation has no run, before any write, so it never starts a
run. With whenBusy: "steer" it steers the current run or is refused.
Request-ID deduplication runs first.
Chord's shared watch: listeners take a tagged call, { kind: "value", value, ops, retired } or
{ kind: "failed", error }; a recoverable failure keeps the watch open and the next value arrives as
[["r", value]]; WatchEnd is tagged by kind, and an aborted context ends a watch "aborted".
Durable: observers take the watch's shapes (advance(call, ctx), end(end)); onReport(report) with
report.kind and report.error, session_failed is storage_failed; subscribeCommits is observeCommits;
SessionEnd and ReportFrom are tagged by kind; FileChange is tagged and file watchers stop with
stop(ctx); CompactionFailure and CompactionStatus name their trigger (pi.live version 2 with a
migrate); the task_outdated report uses the scheduler's reasons; TaskInspection's ready is pending;
DocumentCommitChange is tagged. Stored formats are unchanged.
A generation whose request or poll fetch was in flight at a host death counts
one failed attempt of the retry policy (backoff, the partial kept, the same
deferred handle polled again); without retries left the run ends with
runFailure interrupted. restartLimit ends every other task visibly instead of
parking it (tool error result, compactionFailure, runFailure restart_limit,
faulted extension tasks) and reports task_interrupted; retryTask, task_parked
and the restart_limit blocked reason are removed.
closes#11139
ConversationView.entries is a window over the fork-aware history with before;
conversation.watch(ctx, { from, min, max }) and observeConversation take the
window. ConversationTranscript merges windows into a client's transcript with
gaps, loadEarlier, fillGap and resume. pi.live.runFailure says why a run ended
without an answer. watchEvents and its event types are removed; the changelog
has the migration guide.
closes#11327
Durable publishes a stored commit inside Storage.commit's adopt: the
telemetry events, its own observers (document watches, conversation
views, task graph, scheduler, waits), and a dispatch of host
subscribeCommits listeners one microtask later, before every wait and
abort the commit triggers. A commit the storage rejects after adoption is
published before the Session fails; a throwing telemetry adapter is
reported as host_error "telemetry" instead of rejecting a durable commit.
Chord's watch listeners take one change object, { value, ops, retired }
({ value } for native watches); retirement is a flag, not an end.
Durable's document watches follow their address, and a task document's
watch ends after its retirement call. New synchronous observers for
adapters: observeDoc, observeConversation, observeTaskGraph; the watches
are built on them. The env file watcher's change type is renamed
FileChange.
A commit may be marked deferrable ({ persistence: { kind: "deferred", withinMs? } }) on Session, Conversation,
TaskRuntime and a tool's api.commit. When every write changes an existing conversation document with history
"latest" and fork "initial", it is adopted in memory and shown to watches at once, and stored with the next commit
that reads or writes the document, before the document leaves memory, at close, or within withinMs (default
settings.progress.persistWithinMs, 1000 ms). The generation partial throttle and the tool output throttle use it.
subscribeCommits() gets these commits only once stored, with exactly what was stored; internal observers get
memory-only publications. Spec §1/§13 changed, new §4.1. 32 streams over SQLite: storage transactions 562/s -> 99/s,
disk 14.3 -> 4.9 MB/s, CPU 29% -> 22% of one core. A crash loses up to about 1 s of streamed content.
- chord/delta: PendingOps (push, size, take -> PendingTake): raw pending ops, r drops everything
before it, a leaf index merges appends and primitive sets per path (cleared by any other op),
past 10,000 ops only "replace with the current value"; one batch is kept by reference and
received ops are never mutated
- chord: Watch queues frames in PendingOps while its listener is busy or not started and delivers
one call with the newest value, the folded ops (or [["r", newest]] after an overflow) and the
newest frame's context; replaces the 100-frame collapse. NativeWatch is unchanged and takes an
optional replace()
- durable: watchEvents keeps every event batch in order (NativeWatch with a snapshot replace);
document, view and task-graph watches fold; spec, README, example 04 and tests updated
- tests: fold fuzzers (tracker, appendText, splices/reorders, root arrays, diffRevisions) with
codec round trips, adversarial cases, cap, ownership, cost and GC guards
HarnessOptions.readOnly opens a viewer of a session another process drives, such as a synced copy on a device that only shows it. Open leaves running tasks as committed, commits with writes reject with ReadOnlySession before they reach Storage, and resume() and every call that would run tasks reject with it.
Breaking changes:
- Durable Storage.commit(writes, adopt, context): storages call adopt(seq) once,
synchronously, when the batch is durable and before their next commit
- Chord traceIn(options, fn, context): context last
- WatchHandle and WatchEnd move to Chord (Durable re-exports them); end reasons
ended (was session_closed) and failed (was session_failed); retirement is an
explicit source event, and an ended watch drops its references into the Session
- Removed Session.documentState(), Conversation.viewState(), Harness.taskGraph()
and DocumentState; use watchDoc(), watch(), watchTaskGraph()
Every built-in storage implements ReplayableStorage: head(), replay() at an explicit sequence, and export() of the current state as a log that replays to the same reads. SQLite schema version 3 adds sequence indexes so export streams. @earendil-works/pi-durable/sync adds ReplicatingStorage, which appends each committed batch to an outbox with fork copies resolved, compactRecords(), and versioned JSON log lines.
Breaking change: defineDoc and defineDocFamily now require an explicit checkpointWhen predicate; no default is supplied. Set policies on built-in documents, examples, tests, and triage state, and document the required API.
onReport info is tagged by type (session_failed, task_faulted, task_parked,
migration_failed, task_outdated, task_error, extension_error, host_error,
listener_error). Task reports carry a ReportTask; restart-limit and outdated
definition reasons are typed fields instead of error.cause strings; host_error
names the failing HarnessOptions callback.
- Close reads the records it writes back on the line, after admitted commits, so a queued abort mark survives.
- A task's report names the extension that installed the definition it ran, captured at reservation.
- Terminal and completing records drop the restart count; retryTask() ignores them.
- wake() counts a reconcile pass only when it has work, so a run's end sends no extra active wake.
- A failed agent resolution no longer breaks the tool result path that names a tool's extension.
- Spec §10.1 list, changelog sections, test cleanup of killed storages.
- A close writes the tasks it stopped back as pending, so only a crash counts a restart.
- An abort mark starts the count over; an abort handler cut off restartLimit times ends the task faulted.
- Only a changed state resets the count; raised and lowered limits reach the scheduler and the graph.
- wake() stays active while a reconcile pass is scheduled or underway; failed migrations recompute it.
- Reports name the extension whose code failed (hooks, wrappers, sections, tools, a task's installer),
runtime.report() takes a source, and the parked report carries blocked: restart_limit.
- The task graph keeps only what blocked reasons read, and shows abandoned tasks waiting for a definition.
- Open counts restarts per task; at settings.restartLimit (default 5) a task is parked as blocked
(restart_limit) until Harness.retryTask().
- onReport(error, info) with ReportInfo: session, task (faulted), extension, listener, host, timer, wake.
Task faults are reported.
- settings.afterFailedRun: keep, continue, or withdraw the inputs queued behind a failed run.
- TaskGraphNode.blocked follows registry changes and failed migrations.
- README: fallback alarm before Harness.open in the Durable Object example.
The README example and the test worker arm the recovery alarm before resume() in the constructor and in
alarm(). Timers should not fire early, and callers recheck; nativeTimer no longer needs a non-null assertion.
Due sleeps are picked up at every scheduling pass, so a pass that drops the timer cannot strand them.
Code mode drops a global's result after the script finished instead of reading an ended invocation's clock.
A foreground timer the host declines falls back to setTimeout; the default timer never fires early; Progress
rechecks its clock; an undelivered onWake is offered again. Spec, README, and tests follow.
Runs the storage conformance suite, the schema migration, an alarm-woken sleep across an eviction, and a
turn recovered through the recovery alarm in workerd via @cloudflare/vitest-plugin (npm run test:workers).
fix(durable): decline background timers in Cloudflare Workers. With nodejs_compat, timers have an unref(),
so the idle-memory timers were armed and a pending one kept a Durable Object from hibernating or being evicted.
Tasks end a phase asleep with runtime.sleepUntil() or { status: "sleeping", until, checkpoint }:
no invocation is held, the deadline survives restarts, and the task resumes at its checkpoint once due.
Generation retry/poll and compaction retry sleep this way. Harness.wake() and HarnessOptions.onWake let
a host such as a Durable Object wake the process itself. HarnessOptions.setTimer arms every Harness
timer, and chord Progress paces on an injectable clock. SQLite migrates to schema version 2 to admit
the sleeping status.
Like env, models and settings may be a function returning the current value.
Generation, compaction, hooks and tools read it at each use (api.models at each
access), so a host can replace its model configuration without reopening the
Harness. A plain object still works.
Context derivation moved a system message to the front only when user messages
alone preceded it. After a compaction that keeps an assistant message, the
baseline written for the next run followed the kept history and the new input,
so Anthropic and OpenAI Codex requests had no initial prompt or tool set.
Derivation now identifies the harness baseline: the first pi.system entry after
the head marker, or of the transcript when no earlier entry carries a system
message, even one omitted later. Its message leads even past assistant
messages. Only the first system message ever moves, so replay is unchanged;
other system messages after an assistant message stay in place.
closes#10904
An environment may list the files, directories and symbolic links below a
directory: relative / paths, depth first in name order, without .git and
what git ignores in work trees, bounded with a truncated flag. Callers fall
back to their own walk when it is missing or fails with not_supported.
The env conformance suite checks it with the new listFiles option.
Chord: README rewritten for the new runtime; notes.md and requests.md removed, their open limits and
decisions folded into spec.md section 12; guide and spec status lines updated. Durable: drops the
pico-v5 handoff and delta findings, renames the Pico spec, and uses current Chord terms. Delta README,
issue categories, and PI_RADIUS_GATEWAY updated.
Moves chord2 (contracts, objects, cells, roots, plugins, hot reload, memory/IPC/WebSocket links)
from p2 into packages/chord, with its tests (now on vitest) and docs. The root export is the new
runtime plus the shared base (Context, JsonValue, JsonRepresentation, Draft, copyJson, isJsonValue,
replicatedState and its types). ./node now holds the host manager, child control, process locks,
and nodeLoader. ./context and ./delta are unchanged; ./bundler is removed.
replicatedState stays a standalone module with the same exports. It no longer exposes the exact-op
subscription the removed service provider used, so listener failures of mutable states are always
reported asynchronously. The package depends on typebox instead of esbuild. The wire format stays JSON.
A wait is rejected when it closes a cycle in the wait-for graph: waits of
tasks that are not abort-marked, and holds of live ordinary owned work. The
rejection names the cycle. Replaces the owner-chain rule, so a background task
may wait on its conversation's owner. Approach and tests from #10533 by
@odysseus0.
fixes#10411
- ToolRegistration.deferred: not offered to the model until modelTools names it
- modelTools gains { remove?, add? }; control.addTools writes names to add, so
loaded tools follow the others in load order and a load only appends
- Agent.deferred lists deferred tools not loaded yet
- @earendil-works/pi-durable/tool-search: the coding agent's tool_search (BM25)
- the codemode section leaves deferred tools out
- structuredOutput is always the tool's own value; a schema tool's error text
without one becomes a tool_error diagnostic on nested results
- code mode: a schema tool's error result with its value resolves (bash nonzero
exit), schema-less tools resolve with their text, as in the coding agent
- codemode prompt section with tool declarations by namespace within an inline
budget; searchTools/describeTool/describeNamespace from the start data
- result layout of the coding agent: header, text markers, console block,
truncation within max_output_tokens and the tool's output limits, spill and
image files in a recorded output directory (a rerun overwrites them)
- models.*: catalog globals; classify/generateImages as hidden internal tools
(nested calls with usage), at most four at once
- Lark grammar of the script source as constrainedSampling
A host that closes its CodemodeSandbox before the Harness, as on a graceful
shutdown, made the running code mode call store "Script error (aborted):
Sandbox closed" as its result, so the script never resumed. A run the sandbox
aborts without the call asking for it now stores nothing and ends with the
call's invocation; the next process replays the script.
details.calls (CodemodeToolDetails): each tool call the script made, in call
order, with status ok/error/cancelled, duration, and cut arguments and error;
calls still running or not yet launched when the script ended are cancelled.
Written once with the result: live progress is pi.live.nestedTools.
bench:codemode compares a code mode script with a plain tool making the same
nested calls: 1,000 parallel calls 0.82 vs 0.78 ms/call, sequential 1.52 vs
1.10 ms/call (two more commits per call: the journal entry and the count).
A codemode tool whose scripts call the conversation's tools as nested calls and
survive a restart. The script runs deterministically (pi-codemode start()); each
result is journaled before it is delivered. A rerun starts the script again from
the stored start data, delivers the journaled results in order (nested calls
reattach by key c<id>), checks every recorded call count, holds calls the
journal lacks until replay ends and checks them against earlier attempts' keys,
then continues live. A call launches only once the count of the drain that made
it is committed, so a rerun can prove which calls are new. Divergence, a changed
module version or sandbox fingerprint, and a passed deadline end the call with a
stored error; a decided outcome is returned without a VM. store()/load() keep
values per conversation, rewindable and forked as of the fork's entry.
pi-codemode: CodemodeRun.finished, set when the outcome is decided.
Programs calling a tool with structuredOutputSchema got isError and no text when
its error result omitted structuredOutput; they now get its bounded output,
collapsed like a schema-less tool's, so a code mode script sees the error text.
A thrown error's message stays in its tool_error diagnostic.