docs(agent): settle pico driver and storage contracts

This commit is contained in:
Mario Zechner 2026-09-10 16:30:22 +02:00
parent 4bd3f48df0
commit 08dc60bc52
3 changed files with 968 additions and 631 deletions

View file

@ -9,6 +9,7 @@ why things are the way they are.
## Table of Contents
- [The Mental Model](#the-mental-model)
- [Calls and Cancellation](#calls-and-cancellation)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Sessions and Conversations](#sessions-and-conversations)
@ -88,6 +89,35 @@ Nothing runs by itself. Opening a session starts nothing. **Driving** a conversa
its tasks execute, and a `prompt` is just accept + drive + read the answer. A UI **watches** a
conversation and gets a **view** it can render from directly.
## Calls and cancellation
Every asynchronous harness, conversation and task-runtime operation takes a required final `Call`.
`Call` is a type alias of Chord `Context`: it carries an abort signal, telemetry parent and, for task
code, a private typed invocation identity. It is not model context. No casts or admission helpers
are needed. Pure accessors, synchronous registrations and methods inside a transaction take no Call.
```typescript
import type { Call } from '@earendil-works/pi-agent';
import { BACKGROUND_CONTEXT, withCancel } from '@earendil-works/chord/context';
const call: Call = BACKGROUND_CONTEXT; // host call, without cancellation
const { context: waitingCall, cancel } = withCancel(call);
const waiting = conversation.drive(waitingCall);
cancel(); // removes this waiter; does not abort durable work
await waiting; // rejects with cancellation
```
Tasks receive `(task, runtime: TaskRuntime, call: Call)` and forward `call`. Tools receive
`(toolCallId, params, out, runtime: ToolRuntime, call: Call)`. Environment/provider/hook operations
interpret the signal; custom handlers must cooperate. Derive a Call for nested telemetry or a tighter
deadline and pass it onward. The driver does not inherit a drive caller's signal into task execution.
The line reads task identity through a private `createContextKey<Invocation>`; Chord returns the
correct type from `call.value(key)`. Derived calls preserve that exact object. Stale task writes
reject. No invocation identity is serialized over RPC; trusted host bindings supply it locally.
Deliberately using an unrelated host Call or ignoring cancellation is an in-process escape, not
something a facade or type can prevent.
## Installation
```bash
@ -97,10 +127,13 @@ npm install @earendil-works/pi-agent
## Quick Start
```typescript
import { Harness, JsonlStorage } from '@earendil-works/pi-agent';
import { Harness, JsonlStorage, type Call } from '@earendil-works/pi-agent';
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
import { readTool, writeTool, bashTool } from '@earendil-works/pi-agent/tools';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';
const call: Call = BACKGROUND_CONTEXT;
// One file per session. Reopening the same file resumes it.
const storage = await JsonlStorage.open('./session.jsonl');
@ -109,10 +142,10 @@ const storage = await JsonlStorage.open('./session.jsonl');
const h = await Harness.open(storage, {
models: builtinModels(),
tools: [readTool, writeTool, bashTool],
});
}, call);
const { generation: generationKind } = h.kinds; // the registered kinds, for config and hooks
const c = await h.root();
const c = await h.root(call);
// Config is declared by the kinds that read it. The generation kind declares model, thinking and
// selected tools; `settings` is shorthand for its config.
@ -120,12 +153,12 @@ await c.settings.set({
model: { provider: 'anthropic', modelId: 'claude-opus-5' },
thinking: 'high',
selectedTools: ['read', 'write', 'bash'],
});
}, call);
// The system prompt is not stored as config. Each turn the harness asks this hook what the
// instructions should be right now, compares with what the transcript says the model was told,
// and writes only the difference. See "System Prompt and Tool Loadout".
c.hooks.on(generationKind, 'system_instructions', async ({ config }) => ({
c.hooks.on(generationKind, 'system_instructions', async ({ config }, call) => ({
sections: {
identity: 'You are a careful engineer working in this repository.',
cwd: `Working directory: ${process.cwd()}`,
@ -135,7 +168,7 @@ c.hooks.on(generationKind, 'system_instructions', async ({ config }) => ({
// Watch the conversation. `view` is a plain object a UI renders from; attach whenever you like,
// the view is complete as of the moment you attach and events follow from there.
const w = await h.watch(c.id, { tail: 100 });
const w = await h.watch(c.id, { tail: 100 }, call);
w.start(event => {
if (event.type === 'task_output') {
// the generation's preview is the partial assistant message; a tool's is its output so far
@ -146,24 +179,25 @@ w.start(event => {
});
// accept the input, drive the conversation until it is idle, return the answer to that input
const answer = await c.prompt('Inspect the parser and list the public API');
const answer = await c.prompt('Inspect the parser and list the public API', undefined, call);
console.log(answer?.model[0]?.content);
w.unsubscribe();
await h.close(); // cancels nothing durable; reopening the file continues exactly here
await h.close(call); // cancels nothing durable; reopening the file continues exactly here
```
Run it, kill it in the middle, run it again: the second run recovers whatever was in flight
(publishing a partial answer, rerunning or reporting an interrupted tool) and continues. That is the
whole point.
Snippets below assume `h` and `c` set up like this.
Snippets below assume `h`, `c` and `call` set up like this. Optional options before Call are passed
as `undefined` when unused. A task or hook always forwards its supplied Call, not this host root.
## Sessions and Conversations
### Opening
`Harness.open(storage, options)` takes the storage backend and everything that defines behaviour:
`Harness.open(storage, options, call)` takes the storage backend and everything that defines behaviour:
| option | what it is |
|---|---|
@ -189,8 +223,8 @@ change:
```typescript
import { generationKind } from '@earendil-works/pi-agent/kinds';
const h = await Harness.open(storage, { models, tools, replace: {
generation: { ...generationKind, async execute(task, ctx) { await audit(task); return generationKind.execute(task, ctx); } },
}});
generation: { ...generationKind, async execute(task, runtime, call) { await audit(task); return generationKind.execute(task, runtime, call); } },
}}, call);
```
Open checks the recorded kind strings without scanning the transcript and reports live work, but
@ -199,7 +233,7 @@ starts nothing. An unregistered historical entry kind is reported, not rejected:
unavailable. A live task kind is still required.
```typescript
const { start, inflight } = await h.inspect();
const { start, inflight } = await h.inspect(call);
// start: tasks that never began or must begin again (a planned tool, a retry, a scheduled job)
// inflight: tasks the last process was running when it stopped; recover() will handle them
```
@ -212,18 +246,31 @@ scope. Each promise resolves when its scope has no live foreground task. Its att
work remains served afterwards. A UI usually attaches a watch to what it shows and drives that.
```typescript
const h = await Harness.open(storage, opts);
void h.drive(); // resume everything in the background
const h = await Harness.open(storage, opts, call);
void h.drive(call).catch(error => console.error(error)); // resume; report session faults
```
### Closing
```typescript
await h.close(); // cancel in-process work, write nothing; everything resumes on the next open
await h.shutdown(); // mark all live work aborted, drain queues, wait for cleanup, then close
await h.close(call); // cancel in-process work, write nothing; everything resumes on the next open
await h.shutdown(call); // mark live tasks, wait for abort cleanup, close; queued input survives
```
`close` is the normal exit: a crash and a close leave storage in the same state.
`close` is the normal exit: it stops admission on the commit line, then signals and joins owned
invocations outside it, without writing task outcomes. Earlier line operations have already finished;
later mutations reject. Close waits for actual invocation completion, regardless of caller cancellation.
An uncooperative task can delay it indefinitely.
`shutdown` closes normal admission and atomically marks live tasks only. Queued input and its queued
result records remain stored, including in idle conversations. Fresh abort handlers resolve their
already-running input groups and finish cleanup. Built-in child cleanup marks tasks only, never drains
child queues, including after a crash and reopen. Shutdown waits for both live tasks and running calls
to disappear before closing. Preserved queues create no work by inference on reopen/drive.
Once admitted, caller cancellation does not abandon shutdown. Repeated lifecycle calls share
completion; explicit close interrupting shutdown makes shutdown reject. Task calls cannot invoke
host lifecycle methods.
## Configuration
@ -250,16 +297,16 @@ a plugin declares its own values the same way (see [Task Kinds](#task-kinds)).
```typescript
// all of a kind's values, one batched read
const { model, thinking } = await c.config(generationKind).get();
const { model, thinking } = await c.config(generationKind).get(call);
// some of them, one commit
await c.config(generationKind).set({ thinking: 'low' });
await c.config(generationKind).set({ thinking: 'low' }, call);
// `settings` is config(generationKind), because that is what every UI touches
await c.settings.set({ model: { provider: 'openai', modelId: 'gpt-5.6' } });
await c.settings.set({ model: { provider: 'openai', modelId: 'gpt-5.6' } }, call);
// one value, one point read, by its declared address
const tools = await c.value(generationKind.config.selectedTools).get();
const tools = await c.value(generationKind.config.selectedTools).get(call);
```
A change is a normal commit: it appears in the watch stream as a `value` event, and the next
@ -303,11 +350,11 @@ in the order given) and the **tool definitions** the model may call. Register it
with `subtree: true` so subagents inherit it unless they register their own:
```typescript
c.hooks.on(generationKind, 'system_instructions', async ({ conversationId, config }) => ({
c.hooks.on(generationKind, 'system_instructions', async ({ conversationId, config }, call) => ({
sections: {
identity: IDENTITY,
cwd: `Working directory: ${cwd}`,
context_files: await renderContextFiles(cwd), // AGENTS.md and friends
context_files: await renderContextFiles(cwd, call), // AGENTS.md and friends
skills: skills.render(),
},
tools: h.tools.select(config.selectedTools), // complete definitions, from the catalogue
@ -332,10 +379,10 @@ Every change is either a config write or something the host renders differently.
written at that moment; the next generation notices and appends one entry:
```typescript
await c.settings.set({ selectedTools: ['read', 'write', 'bash', 'grep'] });
await c.settings.set({ selectedTools: ['read', 'write', 'bash', 'grep'] }, call);
// next turn → 20 system { toolsAdded: [grep] }
await c.settings.set({ selectedTools: ['read'] });
await c.settings.set({ selectedTools: ['read'] }, call);
// next turn → 30 system { toolsRemoved: [write, bash, grep] }
// the user installs a skill; skills.render() changes
@ -357,15 +404,16 @@ A plugin contributes sections from its own state. Handlers for the same point me
```typescript
const planMode = conversationValue<boolean>('plan.mode', { rewind: true });
h.hooks.on(generationKind, 'system_instructions', async ({ conversationId }) => ({
sections: {
plan_mode: (await h.conversation(conversationId).value(planMode).get()) ? PLAN_GUIDANCE : undefined,
},
}));
h.hooks.on(generationKind, 'system_instructions', async ({ conversationId }, call) => {
const conversation = await h.conversation(conversationId, call);
return { sections: {
plan_mode: (await conversation?.value(planMode).get(call)) ? PLAN_GUIDANCE : undefined,
} };
});
await c.value(planMode).set(true);
await c.value(planMode).set(true, call);
// next turn → 60 system { sections: [plan_mode] } "the plan_mode guidance now applies: ..."
await c.value(planMode).set(false);
await c.value(planMode).set(false, call);
// next turn → 70 system { sections: [plan_mode removed] }
```
@ -398,10 +446,10 @@ const childId = await c.spawn({
[generationKind.config.profile, 'auditor'],
],
},
});
}, call);
const child = await h.conversation(childId);
child.hooks.on(generationKind, 'system_instructions', async ({ config }) => ({
const child = await h.conversation(childId, call);
child.hooks.on(generationKind, 'system_instructions', async ({ config }, call) => ({
sections: { identity: AUDITOR_IDENTITY, cwd: `Working directory: ${cwd}` },
tools: h.tools.select(config.selectedTools),
}));
@ -420,9 +468,9 @@ Nothing to do in any of them.
first turn diffs the two:
```typescript
const b = await c.fork(12); // config as of 12: tools [read, write, bash]
await b.settings.set({ selectedTools: ['read'] });
await b.prompt('...'); // b's turn: toolsRemoved [write, bash]; the prefix's 11 is still the baseline
const b = await c.fork(12, undefined, call); // config as of 12: tools [read, write, bash]
await b.settings.set({ selectedTools: ['read'] }, call);
await b.prompt('...', undefined, call); // b's turn: toolsRemoved [write, bash]; the prefix's 11 is still the baseline
```
- **A restart** with a changed host (new skill, different cwd) emits "these sections now apply"
@ -436,7 +484,7 @@ Nothing to do in any of them.
explicit result for that input.
```typescript
const answer = await c.prompt('Inspect the parser');
const answer = await c.prompt('Inspect the parser', undefined, call);
// AssistantEntry | undefined (the run ended without an answer: aborted, failed)
```
@ -445,11 +493,11 @@ element, even when idle acceptance places and removes it in the same commit. Gen
`post_tools` carry input ids explicitly, so result lookup never scans the transcript:
```typescript
const { inputId } = await c.accept('Inspect the parser', { requestId: 'req-42' });
const outcome = await c.drive(); // 'idle' | 'closed'
const result = await c.result(inputId); // one sticky-value point read
const { inputId } = await c.accept('Inspect the parser', { requestId: 'req-42' }, call);
const outcome = await c.drive(call); // 'idle' | 'closed'
const result = await c.result(inputId, call); // one sticky-value point read
const answer = result?.status === 'done'
? await h.getEntry(assistantKind, result.resultEntryId)
? await h.getEntry(assistantKind, result.resultEntryId, call)
: undefined;
```
@ -459,15 +507,15 @@ Context-only writes finish as `placed`. Terminal results never change.
A remote caller that lost the accept response performs an explicit request lookup:
```typescript
const existing = await h.acceptance('req-42');
const existing = await h.acceptance('req-42', call);
if (existing) {
const conversation = await h.conversation(existing.conversationId);
const conversation = await h.conversation(existing.conversationId, call);
return {
inputId: existing.inputId,
result: await conversation?.result(existing.inputId),
result: await conversation?.result(existing.inputId, call),
};
}
return c.accept(input, { requestId: 'req-42' });
return c.accept(input, { requestId: 'req-42' }, call);
```
A concurrent duplicate `accept` rejects as `RequestAlreadyAccepted` and identifies the first
@ -485,13 +533,13 @@ While a turn runs, new input is queued rather than refused. The mode determines
input-group behavior:
```typescript
const steer = await c.steer('Focus on the tokenizer first'); // post_tools: joins current group;
const steer = await c.steer('Focus on the tokenizer first', call); // post_tools: joins current group;
// final answer: starts next group
const follow = await c.followUp('Then write the tests'); // after final answer; starts next group
const next = await c.nextRun('Remind me to commit'); // waits for the next explicit idle accept
const follow = await c.followUp('Then write the tests', call); // after final answer; starts next group
const next = await c.nextRun('Remind me to commit', call); // waits for the next explicit idle accept
const write = await c.write(noteKind, { // next safe boundary; no generation
data: { text: 'user stepped away' },
});
}, call);
```
When a generation emits calls, its settlement creates every tool plus exactly one `post_tools`
@ -504,22 +552,60 @@ updates are append/remove/clear operations, not whole-array replacements. Any it
until it lands:
```typescript
await c.cancelQueued(steer.inputId); // 'cancelled' | 'not_found'
await c.cancelQueued(steer.inputId, call); // 'cancelled' | 'not_found'
```
### Aborting
```typescript
await c.abort(); // every live foreground task of this conversation, and of conversations they own
await h.abortTask(id); // one background task: a job, a schedule
await c.abort(call); // every live foreground task of this conversation, and of conversations they own
await h.abortTask(id, call); // one background task: a job, a schedule
```
An abort is a durable mark on the task, then cleanup. The live generation or `post_tools` owns the
active input ids and resolves all of them as cancelled without a normal successor. A generation may
retain its partial for the UI but creates no tool tasks/results and is excluded from later requests;
tool tasks that already exist write their own error results; a subagent tool aborts its child. If
the process dies between the mark and cleanup, the next one finishes it. Queued `steer` and
`followUp` are removed and marked cancelled; `write` and `nextRun` stay queued.
An abort mark is a durable request, not terminal settlement:
```text
100 generation streaming, execute invocation A running
110 abort=true commits; A can no longer write main state or scratch
line releases; A's signal fires; provider exits; A returns
driver removes A and starts abort invocation B with a fresh Call
120 B commits optional display-only partial, cancelled input results, terminal aborted status
scratch is retired; B returns
```
There is no mark/signal branch in normal settlement. If the mark wins, `runtime.commit` rejects
`TaskCancelled` before its closure runs; execute unwinds and the kind's `abort()` writes the durable
cancellation result. If normal settlement wins first, the task is already terminal. Standard effects
observe the signal cooperatively; no effect gate exists, so an operation may start before the signal
is delivered and then cancel. Cancellation does not undo external effects.
`abortTask` marks exactly one task; if not owned or attached, cleanup awaits a later drive. Conversation
abort marks the current foreground ownership closure. Fresh parent cleanup explicitly cancels its
foreground children and recorded non-detached jobs. Queued `steer`/`followUp` are removed and marked
cancelled; `write`/`nextRun` remain. This is the explicit conversation-abort policy. Shutdown and
built-in task abort cleanup mark tasks only and preserve all queued items and their queued results.
A generation's abort handler updates its explicit input group in the same commit as settlement.
Here `inputResult(id)` is the sticky value address holding that input's result:
```typescript
async abort(task, runtime, call) {
await runtime.commit(async tx => {
for (const id of task.state.inputs) {
const address = inputResult(id);
const result = await tx.value(address).get();
if (result?.status !== 'running') throw new Error(`Invalid active input ${id}`);
tx.value(address).set({ ...result, status: 'cancelled' });
}
tx.settle(task.id, 'aborted', task.state);
}, call);
}
```
The driver never resolves input results itself. Tool and generation kinds own their respective
outcomes. They can recover only committed scratch; missing final usage is unknown, not zero.
Runtime scratch writes reject after cancellation too: await/catch them. Harness sinks handle and
drain their own scratch promises and drop late callbacks, never silently recreate retired scratch.
## Watching
@ -529,10 +615,10 @@ A UI never reads storage or parses commits. It watches a conversation and gets a
JSON object the harness keeps current, plus typed events that say what changed.
```typescript
const w = await h.watch(c.id, { tail: 100, values: [myPlugin.config.mode] });
const w = await h.watch(c.id, { tail: 100, values: [myPlugin.config.mode] }, call);
w.view // ConversationView, captured atomically with the subscription
w.start(listener);
w.resnapshot(); // fresh capture, same subscription (if the client fell behind)
w.resnapshot(call); // fresh capture, same subscription (if the client fell behind)
w.unsubscribe();
```
@ -636,7 +722,7 @@ same module.
```typescript
// worker (has the harness) // ui process
const w = await h.watch(c.id, { tail: 100 }); on('view', m => { view = m.view; render(view); });
const w = await h.watch(c.id, { tail: 100 }, call); on('view', m => { view = m.view; render(view); });
send({ type: 'view', view: w.view });
w.start(e => send({ type: 'event', event: e })); on('event', m => { applyEvent(view, m.event); render(view, m.event); });
```
@ -647,7 +733,7 @@ What isn't one conversation's: the conversation list, session values, usage tota
reports (a hook threw, a task kind misbehaved and was stopped).
```typescript
const sw = await h.watch();
const sw = await h.watch(call);
sw.view.conversations; sw.view.values; sw.view.faulted;
sw.start(e => {
if (e.type === 'usage') status.setCost(e.totals);
@ -663,10 +749,10 @@ copied and nothing in the source is deleted. It carries the context as it was at
rewindable values in force there, and the prompt-as-sent, so its first turn is exact.
```typescript
const alt = await c.fork(answer.id); // the source keeps running, untouched
await alt.prompt('Try a different implementation');
const alt = await c.fork(answer.id, undefined, call); // the source keeps running, untouched
await alt.prompt('Try a different implementation', undefined, call);
const back = await c.fork(earlier.id, { abort: true }); // "go back": aborts the source's foreground first
const back = await c.fork(earlier.id, { abort: true }, call); // "go back": aborts the source's foreground first
```
Any transcript entry is a valid fork point, including an assistant with unanswered tool calls or one
@ -675,9 +761,9 @@ exchange without inheriting or executing the source tasks. Which conversation a
"current" is the UI's business; the harness only has conversations.
```typescript
const all = await h.conversations();
const all = await h.conversations(undefined, call);
const independent = all.items.filter(x => x.owner === undefined); // root and forks: their own drive scopes
const children = await h.conversations({ parent: c.id }); // forks and owned children of c
const children = await h.conversations({ parent: c.id }, call); // forks of c; owned children use ownedFrom
```
## Compaction and Reset
@ -689,16 +775,16 @@ remain after the prepared boundary. Only a competing head makes the summary stal
not.
```typescript
const collapseId = await c.collapse();
const collapseId = await c.collapse({ instructions: 'keep the API decisions verbatim' });
const collapseId = await c.collapse(undefined, call);
const collapseId = await c.collapse({ instructions: 'keep the API decisions verbatim' }, call);
```
Threshold and overflow compaction happen inside the generation; nothing to call. Reset starts the
context over, with or without a handoff message:
```typescript
await c.reset('Continue from here: we settled on a recursive-descent parser.'); // handoff
await c.reset(); // /clear
await c.reset('Continue from here: we settled on a recursive-descent parser.', call); // handoff
await c.reset(undefined, call); // /clear
```
The transcript keeps everything either way; only the context changes.
@ -721,11 +807,11 @@ subagent({ command: 'stop', id: 88 })
```typescript
// the API
const childId = await c.spawn({ prompt: 'Profile the build', context: 'fresh',
values: { inherit: [generationKind.config.model] } });
const child = await h.conversation(childId);
await child.accept('also check CI');
await child.drive(); // or let h.drive() / the parent's drive carry it
await child.abort();
values: { inherit: [generationKind.config.model] } }, call);
const child = await h.conversation(childId, call);
await child.accept('also check CI', undefined, call);
await child.drive(call); // or let h.drive() / the parent's drive carry it
await child.abort(call);
```
`run` keeps the calling tool inflight while it drives the child, so the child is part of the
@ -751,12 +837,12 @@ From the API a job is a task; a schedule is a job with `every`:
```typescript
const dev = await c.commit(tx => tx.task(jobKind, { background: true,
state: { cmd: ['npm', 'run', 'dev'], cwd } }));
state: { cmd: 'npm run dev', cwd } }), call);
const nightly = await c.commit(tx => tx.task(jobKind, { background: true,
state: { cmd: ['npm', 'test'], cwd, every: 24 * 3600_000, notBefore: tonightAt(2) } }));
state: { cmd: 'npm test', cwd, every: 24 * 3600_000, notBefore: tonightAt(2) } }), call);
await h.abortTask(nightly); // ends the schedule wherever it is
await h.abortTask(nightly, call); // ends the schedule wherever it is
```
A schedule is one task looping `planned → running → planned`; there is no trail of run ids to
@ -777,16 +863,16 @@ const moves = conversationList<Move>('game.moves', { rewind: true });
const expanded = conversationValue<boolean>('ui.expanded', { rewind: false });
const name = sessionValue<string>('pi.session.name');
await c.value(planMode).set(true);
const on = await c.value(planMode).get();
const then = await c.value(planMode).get(entryId); // as of an entry: rewindable only
await c.value(planMode).set(true, call);
const on = await c.value(planMode).get(call);
const then = await c.value(planMode).get(entryId, call); // as of an entry: rewindable only
const elementId = await c.list(moves).append({ x: 1, y: 2 });
const page = await c.list(moves).read({ limit: 50 });
await c.list(moves).remove(elementId);
await c.list(moves).clear();
const elementId = await c.list(moves).append({ x: 1, y: 2 }, call);
const page = await c.list(moves).read({ limit: 50 }, call);
await c.list(moves).remove(elementId, call);
await c.list(moves).clear(call);
await h.value(name).set('parser work');
await h.value(name).set('parser work', call);
```
Rewindable state is what makes plugins survive forks: a fork before the move doesn't see the move,
@ -795,7 +881,9 @@ a fork before plan mode was turned on doesn't have it on. Nothing to register, n
### Atomic Commits
Anything that must land together goes in one commit: a closure on the session's write line. Reads
inside it see committed state; ids are final when returned; a throw discards everything.
inside it are asynchronous and see committed state; await them in an async builder. Writes are
synchronous and ids are final when returned; a throw discards everything. Awaiting storage reads does
not release the line. Never await external effects, driver waits or another commit inside a builder.
```typescript
const entry = await c.commit(tx => {
@ -805,7 +893,7 @@ const entry = await c.commit(tx => {
tx.task(myPlugin.reminderKind, { background: true, // tasks anywhere
state: { about: id, at: Date.now() + 3600_000 } });
return id;
});
}, call);
```
One rule: rewindable conversation value/list writes must precede entries in the same commit. That
@ -836,8 +924,8 @@ export const countLinesTool: Tool<{ i: string; path: string; pattern?: string },
output: { maxBytes: 64_000, maxLines: 200, retain: 'head' }, // the sink enforces this
replay: 'safe', // read-only: may be rerun after a crash
async execute(toolCallId, params, signal, out, ctx) {
const lines = getOrThrow(await ctx.env.readTextLines(params.path, {}, { signal })); // ExecutionEnv: FileSystem & Shell
async execute(toolCallId, params, out, runtime, call) {
const lines = getOrThrow(await runtime.env.readTextLines(params.path, {}, call)); // ExecutionEnv: FileSystem & Shell
const re = params.pattern ? new RegExp(params.pattern) : undefined;
let matching = 0;
for (const [i, line] of lines.entries()) {
@ -866,7 +954,8 @@ The sink:
| `diag(severity, message, code?)` | commentary about the call, kept out of the data |
A tool never touches the transcript, its siblings or the queue. What it may do is create things in
its own conversation through `ctx.conversation.commit(...)`: a job, a child conversation.
its own conversation through `runtime.commit(..., call)`: a job, a child conversation. The runtime
provides conversation lookup, reads and cancellation methods, not a raw host-lifecycle Harness.
### Diagnostics
@ -891,27 +980,46 @@ with the budget from config; if the budget runs out, the call settles with what
goes on:
```typescript
async execute(toolCallId, params, signal, out, ctx) {
const job = await ctx.conversation.commit(tx => tx.task(jobKind, { background: true,
state: { cmd: params.cmd, cwd: params.cwd ?? ctx.env.cwd,
origin: { tool: 'bash', task: ctx.taskId, callId: toolCallId } } })); // so UIs render it as bash
async execute(toolCallId, params, out, runtime, call) {
const job = await runtime.commit(tx => {
const id = tx.task(jobKind, { background: true,
state: { cmd: params.cmd, cwd: params.cwd ?? runtime.env.cwd,
origin: { tool: 'bash', task: runtime.taskId, callId: toolCallId } } });
tx.patch(runtime.taskId, { state: {
jobId: id, cancelJobOnAbort: !params.background,
} });
return id;
}, call);
if (params.background) { out.delegate(job); out.write(`started job ${job}`); return; }
let done: boolean;
try { done = await ctx.waitForTask(job, { budgetMs: ctx.budgetMs, signal }); }
catch (e) { if (isAbortError(e)) await ctx.harness.abortTask(job); throw e; } // user aborted: kill what we started
const o = await ctx.jobOutput(job); // the job's ToolOutputState, live or settled
out.replace(o.text); out.capture(o.truncation);
if (done) { out.details.exitCode = o.exitCode; if (o.exitCode) out.diag('warn', `exit code ${o.exitCode}`, 'exit'); }
else { out.delegate(job); out.diag('info', `still running as job ${job}; use job wait / status / stop`, 'budget');
await ctx.conversation.commit(tx => tx.patch(job, { state: { notify: true } })); }
const done = await runtime.waitForTask(job, { budgetMs: runtime.budgetMs }, call);
const output = await runtime.jobOutput(job, call);
out.replace(output.text); out.capture(output.truncation);
if (done) {
out.details.exitCode = output.exitCode;
if (output.exitCode) out.diag('warn', `exit code ${output.exitCode}`, 'exit');
} else {
out.delegate(job);
out.diag('info', `still running as job ${job}; use job wait / status / stop`, 'budget');
}
}
```
The job owns its output from the first byte; the tool only ever copies a snapshot. A UI shows the
job's output live either way, rendered with `bash`'s component.
The job owns its output from the first byte; the tool copies a snapshot. Creation stores the cleanup
reference on the tool atomically. A durable abort rejects execute mutations, including catch-handler
writes, so the fresh tool-kind abort handler reads that reference and cancels the non-detached job.
Already-terminal jobs count as successful cleanup; check-and-mark must be atomic or handle that result.
The tool kind's successful delegation settlement marks the job detached and requests a notice through
a separate sticky notification record keyed by job id. Job completion consumes it atomically; if the
job is already terminal when delegation commits, delegation places the notice itself. Apply this
protocol to exited, killed and lost outcomes, including abort and recovery. The parent never patches
a running job's state. This covers either completion/delegation order.
A UI renders the job's output with `bash`'s component. Adopting an arbitrary unfinished tool promise
after budget expiry is still an open integration design: it requires an explicit effect/sink ownership
transfer. The initial job-first path above does not race or abandon any invocation.
## Hooks
@ -922,24 +1030,25 @@ like (a human approval is a hook that waits).
```typescript
// policy: everywhere
h.hooks.on(toolKind, 'before_tool', async ({ toolName, args, conversationId }) => {
if (toolName === 'write' && await h.conversation(conversationId).value(planMode).get())
h.hooks.on(toolKind, 'before_tool', async ({ toolName, args, conversationId }, call) => {
const conversation = await h.conversation(conversationId, call);
if (toolName === 'write' && await conversation?.value(planMode).get(call))
return { block: { reason: 'plan mode: no edits' } };
if (toolName === 'bash' && !(await ui.approve(args)))
if (toolName === 'bash' && !(await ui.approve(args, { signal: call.abortSignal })))
return { block: { reason: 'denied by user' } };
return { args }; // may rewrite arguments
});
h.hooks.on(toolKind, 'after_tool', async ({ toolCallId, output }) => { metrics.record(output.usage); });
h.hooks.on(toolKind, 'after_tool', async ({ toolCallId, output }, call) => { metrics.record(output.usage); });
h.hooks.on(generationKind, 'before_request', async ({ request }) => ({ request: withTracing(request) }));
h.hooks.on(generationKind, 'before_request', async ({ request }, call) => ({ request: withTracing(request) }));
h.hooks.on(generationKind, 'on_yield', async ({ answer, conversationId }) => {
if (await goalIncomplete(conversationId)) return { continue: 'The goal is not met yet; continue.' };
h.hooks.on(generationKind, 'on_yield', async ({ answer, conversationId }, call) => {
if (await goalIncomplete(conversationId, call)) return { continue: 'The goal is not met yet; continue.' };
});
h.hooks.on(collapseKind, 'before_collapse', async ({ reason, context }) => {
if (reason === 'manual' && context.length < 10) return { decline: true };
h.hooks.on(collapseKind, 'before_collapse', async ({ reason, entries }, call) => {
if (reason === 'manual' && entries.length < 10) return { decline: true };
});
// instructions: per conversation (see System Prompt and Tool Loadout)
@ -958,8 +1067,10 @@ Points and their fail behaviour:
| tool | `after_tool` | nothing | reported |
| collapse | `before_collapse` | `{ decline? \| instructions? \| summary? }` | reported, skipped |
A handler may run again after a crash (the task it belongs to is re-executed), so its external side
effects need their own idempotence.
These failure policies exclude cancellation control errors, which propagate to unwind the invocation.
A handler receives the active Call as its final argument and must forward it to waits/effects. The
harness awaits its actual return, not an abandoned raced promise. A handler may run again after a
crash, so its external side effects need their own idempotence.
## Writing Kinds
@ -980,7 +1091,7 @@ export const noteKind = defineEntryKind<NoteEntry>('myplugin.note');
await c.commit(tx => tx.entry(noteKind, {
data: { text: 'Parser plan accepted', pinned: false },
})); // transcript/UI only; the model sees nothing
}), call); // transcript/UI only; the model sees nothing
```
A plugin that wants both typed data and a model message keeps the append-time conversion in an
@ -1027,8 +1138,8 @@ another head or edit when context should change; no entry-kind callback runs dur
Reads are typed by the kind, or untyped and narrowed:
```typescript
const note = await h.getEntry(noteKind, id); // NoteEntry | undefined (also undefined for another kind)
const any = await h.getEntry(id); // Entry | undefined
const note = await h.getEntry(noteKind, id, call); // NoteEntry | undefined (also undefined for another kind)
const any = await h.getEntry(id, call); // Entry | undefined
if (noteKind.is(any)) any.data.pinned;
```
@ -1055,19 +1166,19 @@ export const reminderKind = defineTaskKind({
config: { intervalMs: conversationValue<number>('myplugin.reminder.interval', { rewind: false }) },
hooks: { before_fire: { failClosed: false } },
async execute(task, ctx) {
if (task.state.at > Date.now()) await ctx.sleep(task.state.at); // throws on abort
await ctx.commit(tx => tx.patch(task.id, { status: 'firing' })); // record intent before any effect
const { skip } = await ctx.hooks(reminderKind).run('before_fire', { about: task.state.about });
await ctx.commit(tx => {
if (!skip && !tx.getTask(task.id)!.abort) // a marked task creates no successor
async execute(task, runtime, call) {
if (task.state.at > runtime.now()) await runtime.sleep(task.state.at, call); // throws on abort
await runtime.commit(tx => tx.patch(task.id, { status: 'firing' }), call); // record intent before any effect
const { skip } = await runtime.hooks(reminderKind).run('before_fire', { about: task.state.about }, call);
await runtime.commit(tx => {
if (!skip) // marked execution's whole commit rejects
tx.entry(noticeKind, { model: [noticeMessage(`Reminder: see entry ${task.state.about}`)] });
tx.settle(task.id, 'done', { ...task.state, fired: !skip });
});
}, call);
},
async recover(task, ctx) { return this.execute(task, ctx); }, // safe to redo
async abort(task, ctx) { await ctx.commit(tx => tx.settle(task.id, 'aborted', task.state)); },
async recover(task, runtime, call) { return this.execute(task, runtime, call); }, // safe to redo
async abort(task, runtime, call) { await runtime.commit(tx => tx.settle(task.id, 'aborted', task.state), call); },
// no preview: nothing to show while sleeping
});
@ -1079,13 +1190,24 @@ The rules an execution follows, and the driver enforces:
crash after goes through `recover`.
2. You may block on the world: a provider stream, a process, a child conversation, a sleep. The
driver runs executions concurrently; a blocked one holds up nothing.
3. Change your status or settle before returning. Returning unchanged is reported as a precise
contract violation; the driver does not try to diagnose changing but buggy status cycles.
4. Never wait on another task. Depend on it at creation (`after`), or let it be a conversation you
drive.
3. Make a committed status transition or settle before returning, except cancellation/close unwind.
The live task's epoch counts actual transitions, so `planned → running → planned` is valid.
Same-status/state-only patches and abort marks do not count. An abort handler must settle.
4. Prefer `after` for prerequisites; drive children or use the bounded job-wait API. Forward Call
to every wait. Known self/dependency waits reject; never race away an unfinished task/tool/hook.
The scheduler runs on the commit line, keeps live-task/dependency indexes updated from whole committed
batches, and starts effects outside the line. It scans storage once at open, not after every call.
Only one invocation owns each task, but different tasks run concurrently. Repeated drives share an
attachment and create only temporary waiters. Historical completed children are never traversed.
An unexpected error escaping a task kind or an unchanged return faults the session: pending drives
reject immediately, admission stops, running invocations are signalled and joined, then storage closes.
Domain tool/provider errors must be settled by their kinds. Reopen starts nothing; inspect and mark
work before driving, or install a replacement kind that can settle a broken persisted task.
A kind with a `preview` decides what a UI sees while the task runs: `preview.init(scratch)` builds
it once on attach or reopen, and after that the kind mutates `ctx.preview.state` in place (the
it once on attach or reopen, and after that the kind mutates `runtime.preview.state` in place (the
generation applies stream events to a partial message; a tool's preview is its sink). The harness
flushes the Chord delta tracker after each scratch commit into `task_output` ops, so a token costs
one append op, not a diff of the whole object.
@ -1095,8 +1217,8 @@ one append op, not a diff of the whole object.
There is nothing to write. After a crash:
```typescript
const h = await Harness.open(storage, opts);
await h.drive();
const h = await Harness.open(storage, opts, call);
await h.drive(call);
```
| what was running | what happens |
@ -1121,10 +1243,25 @@ startable.
| `JsonlStorage` | everything, from one append-only file plus a scratch file per live task | local sessions; the default |
| `SqliteStorage` | only what queries return | long sessions, many sessions in one file, servers |
All three answer the same queries with the same results; the conformance suite replays one
mutation stream into each and compares. JSONL encodes large values as Chord deltas so a plugin's
structured state stays linear in the file; SQLite stores values whole so a point read is never a
replay.
All three answer the same queries with the same results. Entries are immutable and read whole;
indexed queries select the relevant entries before decoding. Values are stored whole on every set;
lists store append/remove/clear operations. There is no automatic value diffing or Chord storage codec.
Chord deltas are only for preview/watch delivery. Streaming scratch appends compact assistant frames
or explicit output operations, not raw provider events with repeated growing partial snapshots.
JSONL reopens by replaying complete main batches, then scratch for surviving live tasks. Per-file
sequence gaps are expected; the next id follows the maximum complete batch endpoint across both.
Retired scratch is ignored even if unlink failed; its later main settlement already covers its ids.
Only unterminated final lines are discarded and removed before further appends; malformed complete
main/live-scratch batches fail open.
```text
main ends at 100; live scratch ends at 150 → reopen lastSeq=150, next write=151
main settles task at 151; scratch unlink fails → ignore retired scratch, lastSeq=151
```
The conformance suite compares one mutation stream across all backends. A repeated whole-value set
can write quadratic bytes when the value grows; incremental lists avoid that without hidden encoding.
```typescript
const storage = await SqliteStorage.open('./sessions.db', { session: 'parser-work' });

File diff suppressed because it is too large Load diff

View file

@ -14,8 +14,10 @@ shows the surface each package has to end up with.
`Id`, `EntryIdentity`, `EntryBase`, the composable `EntryData` / `ModelProjection` /
`ContextHead` / `ContextEdits` facets, `Entry`, `EntryKind`, `EntryInput`, `ContextEdit`, `Task`,
`TaskRole`, stored task roles, `Conversation`, `InboxItem`, `InputResult`, `InboxOp`, acceptance
receipts, `Address`/`Value`/`List`/`Scope`, `Write`/`CommitBatch`, `Page`/`Cursor` and the query shapes
(§2, §4.1, §5.1, §7.2–7.3). No code.
receipts, `Call` (an alias of Chord Context), `TaskRuntime`/`ToolRuntime`, private typed invocation
identity, `Address`/`Value`/`List`/`Scope`, `Write`/`CommitBatch`, `Page`/`Cursor` and query shapes.
Every async harness/handle/runtime method takes a required final Call; no duplicate signal options,
task-conversation facades or admission gates.
Test: it compiles; fixtures cover entries with no data, no model, every individual facet and the
built-in facet combinations.
@ -25,17 +27,20 @@ built-in facet combinations.
`Storage` and `MemoryStorage`: `commit` numbering from `lastSeq`, point reads, fork-aware
`scanEntries` / `scanTasks` / `scanConversations` with cursors, target-capped `newestHead` over stored
numeric boundaries, stored entry data/model/edits, value versions and list elements read at a
position, `ownedFrom`, the set of entry kind strings written.
position, `ownedFrom`, the set of entry kind strings written and session-wide live-task scans.
Use existing batched `getTasks` for named owners; no header/projection APIs or inbox-specific queries.
Tests: every row of the §7.2 query table against hand-built batches; ids are `lastSeq + 1 + i`; a
head is found without a kind; entry headers omit arbitrary data but retain model/head/edits; a
live-task scan decodes no terminal rows; `remove` and `clear` hide by position.
head is found without a kind; entry reads/scans/head lookup return full entries after indexed
selection; live-task scans skip terminal rows (named owner reads may fetch them); `remove` and
`clear` hide by position.
## 3. The line and `Tx`
`commit(plan)` on a serialized line: buffered writes, ids final at call time, rewindable
`commit(plan, call)` on a serialized line: buffered writes, ids final at call time, rewindable
conversation value/list writes after an entry throw, a throwing plan discards everything, publish
after persist, `kick` when a batch touched a task. Session and sticky conversation state, task
after persist; apply the entire batch to live indexes before scheduling or testing idle. Driver
callbacks, signals and task methods dispatch outside the line. Session and sticky conversation state, task
writes and conversation writes may appear anywhere. `task` / `patch` / `settle` materialize the role
from the kind's status map. `value` / `list` / `entry` / `task` / `patch` / `settle` build the batch
of §7.3.
@ -43,7 +48,11 @@ of §7.3.
Tests: concurrent commits serialize; a rejected commit consumes no ids; each builder verb produces
the expected write; reads inside a plan see committed state only; session and sticky conversation
writes may follow and reference a new entry; rewindable value set/delete and list
append/remove/clear after an entry each reject.
append/remove/clear after an entry each reject; exact invocation-token checks run on the line;
post-mark main/scratch mutation rejects before builder; only an owned task's current invocation may
patch its state/status or settle it (host abort marks remain allowed); caller cancellation never
abandons admitted persistence; Tx/ScratchTx reads are asynchronous and builders may await them
without releasing the line; writes remain synchronous. No external effects or nested line entry in builders.
## 4. Entry kinds and context
@ -71,38 +80,56 @@ results but inherit no tasks; state committed after the entry (a model change) i
## 6. Task kinds and the driver
`TaskKind`, the registry, kind-declared status cycles, `TaskContext` (`commit`, `scratch`, `sleep`,
`signal`, `config`, `hooks`), the driver of §6.1 (`owned`, `Wake`, attached scopes, `drive` as a
waiter, unchanged-status and poison guards, abort join and re-read), scopes of §6.2. Test kinds only:
a counter, a blocker, a cycling schedule, a kind that returns without changing status, a kind that
ignores its signal. There is no parked role or successor chain for retries.
`TaskKind` with `(task, runtime, call)` methods, registry and `TaskRuntime`; one Call-final convention
for operations, existing Context-aware env/provider/hook boundaries. Driver decisions, attachments,
waiters, invocation completion and lifecycle run on the commit line. Only effects/callbacks dispatch
outside it. Seed live tasks once; committed batches update live, reverse-dependency and conversation
indexes. Resolve ownership upward only for live/owned work; evict unused ancestry. Stable attachments
are distinct from temporary waiters. No Wake, polling loop, worker pool, poison graph, gate or parked role.
Tests: persisted roles select execute / recover without a role-map lookup; a blocked execute holds
nothing up; a valid status cycle; the exact unchanged-status guard; `after` gates a start;
conversation and harness drive both start foreground/background work, resolve on foreground idle
and keep serving background work; a full-quiescence wait remains pending on a recurring schedule;
abort marks in both race orders; reopen recovers inflight; a mark written by one process is applied
by the next.
One invocation per task ID, concurrent across IDs; reserve before dispatch and release only after
actual return, even if already terminal. Durable abort revokes execute/recover main and scratch writes,
then signals outside line; fresh abort invocation alone writes cancellation outcomes. Status epochs
count actual committed transitions, including same-start/end cycles. Task-contract faults fail-stop:
reject waiters, stop admission, signal/join and close; preserve durable recovery state.
Tests: one open scan, no full scan per completion; 100,000 historical children with two live tasks;
whole-batch settle/successor publication; exact epoch rules; owned-task exclusive mutation; one
invocation per ID; blocked calls do not block others; abort bypasses dependencies and never overlaps
execute; repeated mark spares running abort; abort-handler failure; cooperative signal window;
known direct/dependency self-waits, including background caller and new cycle-creating admission;
already-aborted waiter registration and every cancellation/idle race; repeated drive uses one
attachment; foreground idle while attached background work continues; reopen recovers marked work.
## 7. Scratch
Scratch batches, one task per batch, `ScratchTx`, retire on settle, sidecar-independent (memory).
Tests: a crash before settle keeps scratch; settle deletes it; a write after settle is rejected; a
new attempt clears its list.
Tests: a crash before settle keeps scratch; settle deletes it; writes after mark/settle/retired token
reject; new attempts clear scratch; persisted assistant frames use the pi-ai compact frame encoder,
not raw cumulative provider events; a rejected stream-frame write cancels and joins the producer
before invocation completion (iterator exit is insufficient); harness progress bridges own every promise, suppress expected
late cancellation only, report persistence errors, and drain before invocation completion. Raw task
scratch calls must be awaited/caught; no successful silent no-op.
## 8. Harness shell and handles
`Harness.open` (built-in registries, `kinds` and `replace` options, kinds-set check, `inspect`,
`drive`, `close`, `shutdown`), `ConversationHandle` (`commit`, `value` / `list`, `config` /
`settings`, `fork` with `abort`, `abort`, `hooks.on` scoped with `subtree`), `acceptance(requestId)`,
`result(inputId)`, `abortTask`, `conversations` with `parent` / independent filtering. No agent
behaviour yet.
`result(inputId, call)`, `abortTask`, `conversations` with `parent` / independent filtering. Call is
required on every async public/runtime operation, including reads and lifecycle; no raw host-lifecycle
Harness exposed to tasks. No agent behaviour yet.
Tests: open on empty vs existing storage; an unregistered entry kind is reported without scanning
entries and its stored model/head/edits still derive context; an unknown live task kind rejects;
replace by name keeps `h.kinds.<name>` consistent; `settings` round-trips; scoped hooks run after
harness-wide ones, innermost last.
harness-wide ones, innermost last; derived Call preserves typed private identity and telemetry;
stale/foreign task token rejects; close stops admission in one nonpersistent line job, joins outside;
shutdown atomically marks live tasks only, then permits abort cleanup; queued items/results remain
unchanged, including idle inbox-only conversations; crash/reopen child cleanup marks tasks only and
preserves queues; repeated close/shutdown share completion; close interrupt rejects shutdown;
lifecycle calls with task identity reject; delete rejects outstanding terminal invocations.
## 9. Generation kind
@ -117,8 +144,11 @@ attempt.
Tests: the six system-entry traces from the guide (tool added, removed, host section changed, MCP
schema changed, plugin section on and off, `addTools`); fresh baseline after a head; a fork diffs
against its own config; a restart emits only the changed section; in-band `aborted` stop reason;
crash while streaming publishes the partial; retry budget exhausted → failed with no successor.
against its own config; a restart emits only the changed section; in-band provider abort without a durable mark has a
kind-level outcome, but post-mark execute settlement rejects and fresh abort writes optional partial,
cancelled input results and known usage atomically; no mark branch in normal settlement; missing
post-cutoff usage is unknown; crash while streaming publishes the partial; retry budget exhausted
→ failed with no successor.
## 10. Tools, post_tools, exchanges
@ -165,8 +195,9 @@ The `subagent` tool (`run`, `spawn`, `send`, `status`, `wait`, `stop`), ownershi
reach through live owners, `run`'s recover driving the child again, `spawn` initialization of config,
explicit child input results.
Tests: restart in the middle of `run`; parent abort reaches a `run` child and spares a `spawn`
child; `stop`; nested children; the child's first turn writes its own baseline.
Tests: restart in the middle of `run`; conversation abort reaches a `run` child and spares a `spawn`
child; abortTask marks only owner and fresh cleanup cancels its child; drive waiter observes Call;
cleanup tolerates already-terminal child/job; `stop`; nested children; child's own baseline.
## 14. Jobs and the budget
@ -174,14 +205,20 @@ child; `stop`; nested children; the child's first turn writes its own baseline.
delegating first and waiting with the budget, `notify` and the `notice` entry, the `job` tool,
schedules.
Tests: budget expiry settles the call with `delegated` and the job continues; the notice appears
on completion; a schedule cycles one stable task id and `abortTask` ends it; it does not block
ordinary harness drive after foreground idle; recover → `lost` or rerun by policy; a user abort
during the wait kills a non-backgrounded job.
Tests: job creation atomically stores job id and cancellation policy on the tool; fresh tool abort
uses those durable references, not execute locals or catch writes; budget expiry settles delegated
and job continues; delegation requests notification in separate sticky state, not a patch to an owned
job; both notification/completion orders publish once for exited, lost and killed; schedule cycles one
stable id; foreground drive
ignores background recurrence; recover → lost or safe rerun; abort kills non-detached job.
Remaining integration design: arbitrary-promise budget adoption must explicitly transfer effect and
sink ownership before the tool invocation releases. A raced, abandoned promise is not permitted.
Preserve the capability (crash outcome lost), but implement job-first execution until transfer is settled.
## 15. Previews
`ctx.preview` as a Chord tracker per task; the generation applies stream events to a partial
`runtime.preview` as a Chord tracker per task; the generation applies stream events to a partial
message, the tool's preview is its sink state, the job's the same; `preview.init` on attach and
reopen; flush after each scratch commit.
@ -204,12 +241,17 @@ fold, failed and aborted attempts included.
## 17. JSONL storage
Append-only batches, replay on open, torn tail discarded, malformed line fails open, entry
`data`/`model`/`head`/`edits` and the kind-string set from replay, values as Chord deltas (full when
first or small, ops otherwise), scratch sidecars retired after the main-file settle.
`data`/`model`/`head`/`edits` and the kind-string set from replay. Whole value sets and intrinsic list
operations in main and scratch files; no Chord storage codec. Replay main first, then surviving live
scratch; per-file sequence gaps are valid. Recover lastSeq from maximum complete surviving batch
endpoint, including clear/remove. Retired scratch is ignored; its later settlement covers its ids.
Physically remove torn suffixes before appending; malformed complete replayed batches fail.
Tests: conformance against memory (one mutation stream, identical query results); linear file
growth under repeated sets of a large value; accepted payload plus placement has two JSONL copies,
including idle acceptance; crash between the settle write and the sidecar unlink.
Tests: conformance against memory; full replacement values on disk; compact incremental frames/output
operations avoid repeated growing snapshots; main=100/live scratch=150 reopens at 150; settle=151
with failed unlink ignores retired scratch, even malformed; clear/remove high-water; torn main and
live scratch tails; nonoverlapping ranges; accepted payload plus placement has two JSONL copies,
including idle acceptance. Backend memory and disk growth claims match whole-value behavior.
## 18. SQLite storage
@ -217,15 +259,18 @@ Tables and indexes of §7.5, nullable entry JSON columns for data/model/edits an
head boundary, scratch rows deleted in the settle transaction, storage version and `migrate`.
Tests: conformance three ways; removed sticky inbox elements may be physically discarded while
input results remain point-readable; a cold reopen decodes only live rows (count them); a version
mismatch rejects.
input results remain point-readable; a cold reopen decodes live tasks and only named required owner
records, including terminal owners; no retained terminal payload cache; a version mismatch rejects.
## 19. Race matrix and telemetry
The §10.2 matrix in both orders with faux clocks, fake processes and storage barriers; spans per
task call and per commit.
Tests: the matrix; span tree for one turn with a tool and a retry.
Tests: the matrix; span tree for one turn with a tool and retry; nested Call preserves active telemetry
parent and private invocation identity; drive-caller cancellation does not become the task signal;
providers/env interpret signals, hooks propagate cancellation rather than swallowing it; no callback
runs on the line; stale RPC-bound invocation rejected. Metadata transport never carries task authority.
## 20. Clients
@ -254,7 +299,8 @@ Copy (under `packages/agent/src/harness/` unless noted):
Depend on, as packages (they are not the old harness):
- `@earendil-works/chord/delta` (15, 16, 17)
- `@earendil-works/chord` Context types and `@earendil-works/chord/context` helpers (1, 6, 8, 19)
- `@earendil-works/chord/delta` (15, 16: preview/watch only; not storage)
- `@earendil-works/pi-ai`: `faux` provider for tests, `utils/estimate` for thresholds, `SystemMessage` (4, 9, 12)
Read before writing the equivalent, then close the file: