mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(agent): settle pico driver and storage contracts
This commit is contained in:
parent
4bd3f48df0
commit
08dc60bc52
3 changed files with 968 additions and 631 deletions
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue