docs(agent): specify Pico5 harness API

This commit is contained in:
Mario Zechner 2026-09-15 19:27:23 +02:00
parent c8e4a5a552
commit b02eef4186
3 changed files with 712 additions and 108 deletions

View file

@ -15,10 +15,13 @@ facades, membranes, document routing, view projection, events, or clone chains.
## 1. Records, cursors, and memory tables
Implement IDs, sequences, conversations, entries, inputs, live/terminal tasks,
document metadata, storage writes, cursors, and detached `MemoryStorage` tables.
Implement IDs, sequences, Session metadata with stable root ID, conversations,
entries, inputs, live/terminal tasks, document metadata, storage writes, cursors,
and detached `MemoryStorage` tables.
Test mixed atomic commits, rollback, detached reads/writes, cursor boundaries,
Test atomic root-metadata/conversation creation, missing or dangling root
metadata rejection, mixed atomic commits, rollback, detached reads/writes,
cursor boundaries,
fork-aware entry scans through deep ancestor caps, head lookup,
entry-to-commit lookup, and full task replacement.
@ -170,8 +173,10 @@ Implement durable abort marks, signal/join/fresh-abort invocation, owned
conversation creation, subtree traversal, background behavior, and idle waits.
Test commit rejection after a run task is marked, crashes at every abort stage,
close precedence over a previously marked task, deep ownership trees, and atomic
retirement of task-scoped documents.
close precedence over a previously marked task, deep ownership trees, atomic
retirement of task-scoped documents, default non-inheritance, inheritance from
the current committed tail, an empty source conversation, document fork
policies, and explicit model/section seed overrides.
## 18. Inputs and positional inbox
@ -222,14 +227,18 @@ progress, owned APIs, interrupted/replay-safe recovery, result entries,
post-tools joining, controls, and boundaries using fake tools.
Test recovery from every phase, both stored/current replay-policy directions,
and atomic assistant/tool/post-tools settlement. Use a fake generation
successor; package 22 replaces it and reruns integration.
default and overridden bounds, streamed-content fallback, progress replacement
and coalesced commit settlement, drain-before-terminal ordering,
abort/close with buffered output, invocation-bound owned handles, and atomic
assistant/tool/post-tools settlement. Use a fake
generation successor; package 22 replaces it and reruns integration.
## 22. Generation task
Implement preparation, request intent, durable throttled partials, attempts,
retry policy, response classification, continuation, and deferred polling/
cancellation with the faux provider.
cancellation through pi-ai's exported `Models` interface. Do not add a Pico
model adapter. The faux test double implements that same interface.
Test every phase before and after reopen, aborted partial conversion, overflow
through a fake collapse kind, input settlement, and no visible-undurable update.
@ -254,13 +263,32 @@ state or entries, and plugin handler recovery.
## 25. Harness integration
Expose conversation/input/task handles, watches, dynamic registries, close,
suspend, service withdrawal/client detach, and product wiring. Implement the v1
Implement the exact Pico3-shaped public surface in specification §2.2:
`Harness.open/resume/suspend/close`, lifecycle gates, root/create/lookup
conversations, ordered `write` handles, send/input handles, conversation-bound
commits, fork/collapse/reset/abort/idle, typed task wait/abort, generic document
access, registries, and structural conversation watches. Do not restore Pico3's
namespace router, fixed document accessors, semantic view events, or manual Chord
view bridge.
Expose service withdrawal/client detach and product wiring. Implement the v1
host-plugin reload path as stop admission, close/join, dispose, rebuild with all
new definitions, reopen/migrate, and resume. Test that closing seals commit and
get-or-create admission, lets storage settlement for already-flushed admitted
commits finish despite caller cancellation, writes no abort or terminal outcome, starts no fresh abort
invocation, and does not run old and new generations concurrently.
commits finish despite caller cancellation, writes no abort or terminal outcome,
starts no fresh abort invocation, and does not run old and new generations
concurrently.
Run all package-specific tests and the repository check. Verify a local
Test stable persisted root identity; atomic conversation/config/section/input
creation; fork seed overrides; concrete-entry forks; collapse task-ID return;
busy reset admission and later placement; mark-only versus signalling abort;
conversation abort/join with surviving passive writes and background tasks;
quiescence with eligible work; listener initial/future delivery and isolation;
and runtime registration between open and resume without resurrection of a task
settled during open.
Compile-test every §2.2 signature and every usage sequence shown in slides
20–26. The erased registry test must include a concrete task with narrowed
input, multiple checkpoint phases, and custom hooks. Run all package-specific
tests and the repository check. Verify a local
coding-agent turn and a reopened interrupted turn, then stop for final review.

View file

@ -132,89 +132,92 @@ Recovery records them as interrupted, then retries only when safe.</code></pre>
</div></section>
<section class="slide" data-slide="7"><div class="frame wide">
<h2>What Lane got right</h2>
<pre><code>TX 10 operation = { phase: "requesting", requestId: "r1" }
<span class="comment">// durable intent</span>
provider request runs
<span class="comment">// process may die here</span>
TX 11 assistant entry + operation = { phase: "tools", calls: [...] }
<span class="comment">// durable outcome</span></code></pre>
<p class="subtitle step">Atomic transactions and explicit restart points were correct.</p>
<h2>Lane solved a narrower problem well</h2>
<div class="grid2">
<div class="panel step"><h3>Session</h3><ul class="tight"><li>immutable entry tree</li><li>typed current values and lists</li><li>atomic commits</li><li>usage ledger</li></ul></div>
<div class="panel step"><h3>AgentLane</h3><ul class="tight"><li>one configured branch</li><li>queued input</li><li>at most one core operation</li><li>durable restart state</li></ul></div>
</div>
<p class="subtitle step">Lane is our durable conversation runtime. One operation is an agent run, compaction, or navigation.</p>
</div></section>
<section class="slide" data-slide="8"><div class="frame wide">
<h2>Lane’s state machine was one fixed machine</h2>
<pre class="code-small"><code><span class="kw">type</span> <span class="type">OperationState</span> =
| Starting | Checkpoint | AssistantReady | AssistantEffectPending
| AssistantRetryWait | Tools | DeferredSuspended | DeferredEffectPending
| SummaryDeciding | SummaryReady | SummaryEffectPending
| SummaryRetryWait | NavigationReadyToCommit;
<h2>Products need independently registered durable tasks</h2>
<pre><code>generation G7
├─ tool T8 ───────────────┐
├─ tool T9 ───────────────┴─ post-tools P10 ─ generation G11
└─ collapse C12 <span class="comment">independent background work</span>
<span class="kw">switch</span> (state.at) {
<span class="comment">// one core driver understands every operation and recovery edge</span>
}</code></pre>
<p class="subtitle step">Adding a first-class durable operation kind required changing the core graph and recovery.</p>
</div></section>
<section class="slide" data-slide="9"><div class="frame wide">
<h2>Lane’s application storage was values + lists</h2>
<pre><code>value&lt;T&gt; set or replace the complete value
list&lt;T&gt; append one element or delete the whole list
entry append immutable transcript content
transaction commit all writes atomically</code></pre>
<p class="subtitle step">Good storage primitives. Poor extension programming model for nested shared state.</p>
</div></section>
<section class="slide" data-slide="10"><div class="frame wide">
<h2>Canvas with a Lane value: naive split read/write</h2>
<pre class="code-small"><code><span class="kw">const</span> Canvas = value&lt;<span class="type">CanvasState</span>&gt;(<span class="str">"app.canvas"</span>);
<span class="kw">const</span> stored = <span class="kw">await</span> session.getValue(Canvas, context);
<span class="kw">const</span> next = stored?.value ?? { strokes: [] };
next.strokes.push(stroke);
<span class="kw">await</span> session.setValue(Canvas, next, context);</code></pre>
background job J20 ─ poll ─ output ─ reschedule
plugin task K30 ─ owned conversation ─ child tasks</code></pre>
<ul class="compact steps">
<li class="step">Read, copy, and serialize the complete canvas per stroke.</li>
<li class="step">Use Lane’s existing <code class="inline">session.mutate()</code> to serialize read-modify-write.</li>
<li class="step">Maintain a separate replicated UI copy.</li>
<li class="step">Lane already supports multiple lanes and parallel tools.</li>
<li class="step">Missing: plugin-defined tasks with their own checkpoints, recovery, cancellation, and dependencies.</li>
</ul>
</div></section>
<section class="slide" data-slide="11"><div class="frame wide">
<h2>Canvas with a Lane list</h2>
<pre class="code-small"><code><span class="kw">const</span> CanvasOps = list&lt;<span class="type">CanvasOp</span>&gt;(<span class="str">"app.canvas.ops"</span>);
<span class="kw">await</span> session.appendList(CanvasOps, {
type: <span class="str">"stroke.added"</span>, stroke,
}, context);
<section class="slide" data-slide="9"><div class="frame wide">
<h2>New first-class operation kinds require core changes</h2>
<pre class="code-small"><code><span class="comment">// selected states from the fixed core union</span>
<span class="kw">type</span> <span class="type">OperationState</span> =
| Starting | AssistantEffectPending | AssistantRetryWait
| Tools | DeferredEffectPending
| SummaryEffectPending | SummaryRetryWait
| NavigationReadyToCommit;
<span class="comment">// The extension now owns:</span>
foldOperations(); checkpoint(); compact(); restore(); publishToClients();</code></pre>
<p class="subtitle step">Small writes—but every extension starts building its own document store.</p>
<span class="kw">switch</span> (state.at) {
<span class="comment">// one driver owns every phase, recovery edge, and terminal path</span>
}</code></pre>
<p class="subtitle step">A plugin job can store markers—but first-class scheduling means editing Lane itself.</p>
</div></section>
<section class="slide" data-slide="10"><div class="frame wide">
<h2>Concrete example: a durable plugin job</h2>
<pre class="code-small"><code>download(url), independent of an agent run
1 persist intent + stable request ID
2 start remote work; persist returned handle
3 poll handle + checkpoint bounded progress
4 restart: poll, deduplicate if supported, or report interruption
5 abort and clean up
6 persist a terminal result for dependent jobs
Lane tools already have checkpoints, memos, cancellation, and recovery.
Independent jobs still need plugin-owned scheduling,
recovery, cancellation, and dependency handling.</code></pre>
<p class="subtitle step">That is a second task runtime hidden inside an extension.</p>
</div></section>
<section class="slide" data-slide="11"><div class="frame wide">
<h2>Values and lists are storage—not documents</h2>
<table class="table"><thead><tr><th>Lane primitive</th><th>Canvas write</th><th>Application must add</th></tr></thead><tbody>
<tr class="step"><td><code class="inline">value&lt;Canvas&gt;</code></td><td><code class="inline">session.mutate()</code>, copy and replace full canvas</td><td>nested deltas and replica updates</td></tr>
<tr class="step"><td><code class="inline">list&lt;CanvasOp&gt;</code></td><td>append one small stroke operation</td><td>fold, base, compact, restore, hydrate</td></tr>
</tbody></table>
<p class="subtitle step">Both are valid primitives. Neither is a tracked JSON document with a committed change stream.</p>
</div></section>
<section class="slide" data-slide="12"><div class="frame wide">
<h2>Durability and UI replication split</h2>
<pre class="code-small"><code><span class="kw">await</span> session.setValue(ReviewValue, next, context); <span class="comment">// durable copy</span>
<h2>Application replication is a second pipeline</h2>
<pre><code>Lane application value/list application-owned Chord/UI mirror
──────────────────────────── ─────────────────────────────────
atomic durable writes materialized nested document
current reads + recovery ────────→ snapshots + incremental updates
serialized mutation line subscriptions + remote hydration
uiState.state.review = next; <span class="comment">// second mutable copy</span>
uiState.publish(context); <span class="comment">// second publication</span>
<span class="comment">// On restart: read storage, reconstruct uiState, avoid races.</span></code></pre>
<p class="subtitle step">Every facet owns the seam between storage and Chord.</p>
for every application:
define the mapping, startup hydration, ordering, and failure handling</code></pre>
<p class="subtitle step">Lane exposes its own snapshots/events; arbitrary application values do not become ReplicatedState.</p>
</div></section>
<section class="slide" data-slide="13"><div class="frame wide">
<h2>Lane lacked entry-anchored application-state history</h2>
<pre><code>conversation A: e1 ─ e2 ─ e3 ─ e4
└── fork B at e3
<h2>Forking cannot recover application state at an entry</h2>
<pre><code>source: canvas A ── e1 ── add B ── e2 ── delete A ── e3
transcript at e3: available
current plan-mode value: available
plan-mode value at e3: <span class="bad">not available</span>
review comments at e3: <span class="bad">not available</span></code></pre>
<p class="subtitle step">Tree forks copy current application state; branch forks exclude it. Neither reconstructs state at e3.</p>
fork requested at e2 after e3 exists
Lane branch fork transcript through e2; application values/lists excluded
Lane tree fork whole entry tree; <span class="warn">current</span> application state: only B
needed for product transcript through e2; application state: A + B</code></pre>
<p class="subtitle step">Current values have no replaced/deleted history to reconstruct the state at e2.</p>
</div></section>
<section class="slide" data-slide="14"><div class="frame wide">
@ -293,6 +296,127 @@ many independently attached presentations</code></pre>
</div></section>
<section class="slide" data-slide="20"><div class="frame wide">
<h2>The specified Pico5 host API</h2>
<pre class="code-small"><code><span class="comment">// Normative public surface; implementation is package 25.</span>
<span class="kw">const</span> storage: <span class="type">Storage</span> = <span class="kw">await</span> openAppStorage(context);
<span class="kw">const</span> options: <span class="type">HarnessOptions</span> = {
models, <span class="comment">// pi-ai Models</span>
tools: [read, bash],
taskKinds: [Reindex],
documents: [PlanDoc, JobOutputDoc],
sections,
root: { model: { provider: <span class="str">"anthropic"</span>, modelId: <span class="str">"claude-sonnet-4-5"</span> } },
};
<span class="kw">const</span> harness = <span class="kw">await</span> Harness.open(storage, options, context);
harness.resume();
<span class="kw">const</span> conversation = <span class="kw">await</span> harness.root(context);</code></pre>
<p class="subtitle step">Definitions are installed before migration, recovery, and task dispatch.</p>
</div></section>
<section class="slide" data-slide="21"><div class="frame wide">
<h2>Send input and await its durable answer</h2>
<pre class="code-small"><code><span class="kw">const</span> input = <span class="kw">await</span> conversation.send({
content: <span class="str">"Summarize the repository."</span>,
requestId: <span class="str">"slack:1719432.0021"</span>,
whenBusy: <span class="str">"followUp"</span>,
}, context);
<span class="kw">const</span> settled = <span class="kw">await</span> input.wait(context);
<span class="kw">if</span> (settled.status === <span class="str">"done"</span>) {
console.log(<span class="str">"answer entry"</span>, settled.answer);
} <span class="kw">else</span> {
console.log(<span class="str">"unanswered"</span>, settled.reason);
}</code></pre>
<p class="subtitle step">Send returns after durable admission. Cancelling wait does not withdraw the input.</p>
</div></section>
<section class="slide" data-slide="22"><div class="frame wide">
<h2>Write without starting another turn</h2>
<pre class="code-small"><code><span class="kw">const</span> first = <span class="kw">await</span> conversation.write({
kind: <span class="str">"app.note"</span>, data: { text: <span class="str">"Build passed"</span> },
}, context);
<span class="kw">const</span> second = <span class="kw">await</span> conversation.write({
kind: <span class="str">"app.note"</span>, data: { text: <span class="str">"Deploy started"</span> },
}, context);
<span class="kw">const</span> [a, b] = <span class="kw">await</span> Promise.all([
first.wait(context), second.wait(context),
]);
console.log(a.entry, b.entry); <span class="comment">// passive writes settle with entry, not answer</span></code></pre>
<ul class="compact steps">
<li class="step">Idle: append immediately.</li>
<li class="step">Busy: queue, then append at a safe boundary in input-ID order.</li>
<li class="step">A passive write never triggers generation.</li>
</ul>
</div></section>
<section class="slide" data-slide="23"><div class="frame wide">
<h2>Create, wait for, or abort a custom task</h2>
<pre class="code-small"><code><span class="kw">const</span> ref = <span class="kw">await</span> conversation.commit(
tx =&gt; tx.createTask(Reindex, { path: <span class="str">"/repo"</span> }, {
background: <span class="kw">true</span>, <span class="comment">// conversationId defaults to this handle</span>
}),
context,
);
<span class="comment">// Request durable cancellation, then observe terminal settlement.</span>
<span class="kw">await</span> harness.abortTask(ref.id, context);
<span class="kw">const</span> task = <span class="kw">await</span> harness.waitForTask(ref, context);
console.log(task.state.outcome);</code></pre>
<p class="subtitle step">Dependencies use terminal task records; no separate receipt store.</p>
</div></section>
<section class="slide" data-slide="24"><div class="frame wide">
<h2>Fork at an entry and inspect historical state</h2>
<pre class="code-small"><code><span class="comment">// PlanDoc is rewindable; checkpointEntryId is visible in the parent.</span>
<span class="kw">const</span> child = <span class="kw">await</span> conversation.fork(checkpointEntryId, {}, context);
<span class="kw">const</span> historicalPlan = <span class="kw">await</span> harness.snapshotAsOf(
PlanDoc, conversation.id, checkpointEntryId, context,
); <span class="comment">// undefined when no instance existed at that commit</span>
<span class="kw">const</span> childPlan = <span class="kw">await</span> harness.snapshot(PlanDoc, {
scope: <span class="str">"conversation"</span>, conversationId: child.id,
}, context); <span class="comment">// normal access may initialize</span></code></pre>
<p class="subtitle step"><code class="inline">asOf</code>, <code class="inline">current</code>, or <code class="inline">initial</code> determines the child document.</p>
</div></section>
<section class="slide" data-slide="25"><div class="frame wide">
<h2>Observe the conversation as committed state</h2>
<pre class="code-small"><code><span class="kw">const</span> watch = <span class="kw">await</span> conversation.watch(context);
render(watch.value);
watch.start((view, ops, revision, _context) =&gt; {
render(view);
console.log(revision, ops);
});
<span class="comment">// A Session facet may forward the same committed ops.</span>
<span class="kw">function</span> disposeUi() { <span class="comment">// on UI teardown</span>
watch.stop();
}</code></pre>
<p class="subtitle step">Capture and listener registration are atomic; close stops the watch.</p>
</div></section>
<section class="slide" data-slide="26"><div class="frame wide">
<h2>Close and reopen without aborting work</h2>
<pre class="code-small"><code><span class="kw">await</span> harness.close(context);
<span class="comment">// Close causes no abort/terminal write. Admitted commits settle;</span>
<span class="comment">// remaining work and checkpoints stay durable.</span>
<span class="kw">const</span> reopenedStorage = <span class="kw">await</span> openAppStorage(context);
<span class="kw">const</span> recovered =
<span class="kw">await</span> Harness.open(reopenedStorage, options, context);
recovered.resume();
<span class="kw">const</span> recoveredConversation =
<span class="kw">await</span> recovered.conversation(conversation.id, context);
<span class="comment">// Old handles/sources are closed; reacquire from recovered.</span></code></pre>
<p class="subtitle step">Plugin reload also disposes old facets and constructs the new definition set before reopen.</p>
</div></section>
<section class="slide" data-slide="27"><div class="frame wide">
<h2>Four durable concepts</h2>
<table class="table"><thead><tr><th>Concept</th><th>What it stores</th><th>Lifetime</th></tr></thead><tbody>
<tr class="step"><td class="accent">Entry</td><td>Transcript and permanent facts</td><td>Session history</td></tr>
@ -302,7 +426,7 @@ many independently attached presentations</code></pre>
</tbody></table>
</div></section>
<section class="slide" data-slide="21"><div class="frame wide">
<section class="slide" data-slide="28"><div class="frame wide">
<h2>Conversations are cheap branches</h2>
<pre><code>Conversation A
entries: e1 ─ e2 ─ e3 ─ e4
@ -314,7 +438,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">No transcript copy. No whole-Session tree in memory.</p>
</div></section>
<section class="slide" data-slide="22"><div class="frame wide">
<section class="slide" data-slide="29"><div class="frame wide">
<h2>A document declares its semantics</h2>
<pre class="code-small"><code><span class="kw">type</span> <span class="type">Stroke</span> = { color: <span class="type">string</span>; points: { x: <span class="type">number</span>; y: <span class="type">number</span> }[] };
<span class="kw">type</span> <span class="type">CanvasState</span> = { strokes: <span class="type">Stroke</span>[] };
@ -329,7 +453,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">Scope defines lifetime. Only conversation documents choose history and fork.</p>
</div></section>
<section class="slide" data-slide="23"><div class="frame wide">
<section class="slide" data-slide="30"><div class="frame wide">
<h2>Conversation history and fork are explicit</h2>
<table class="table"><thead><tr><th>Setting</th><th>Meaning</th></tr></thead><tbody>
<tr class="step"><td><code class="inline">history: latest</code></td><td>Only current value matters; old records can be reclaimed after a base.</td></tr>
@ -340,7 +464,7 @@ head entry → model context begins here; old entries remain stored</code
</tbody></table>
</div></section>
<section class="slide" data-slide="24"><div class="frame wide">
<section class="slide" data-slide="31"><div class="frame wide">
<h2>One mutation, one authority</h2>
<pre class="code-small"><code><span class="kw">const</span> target = { scope: <span class="str">"session"</span> } <span class="kw">as const</span>;
@ -356,7 +480,7 @@ head entry → model context begins here; old entries remain stored</code
</div>
</div></section>
<section class="slide" data-slide="25"><div class="frame wide">
<section class="slide" data-slide="32"><div class="frame wide">
<h2>Chord: the live delivery half</h2>
<ul class="compact steps">
<li class="step"><span class="accent">Facet</span> — one feature’s setup unit.</li>
@ -367,7 +491,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">Chord transports state. Pico5 commits it.</p>
</div></section>
<section class="slide" data-slide="26"><div class="frame wide">
<section class="slide" data-slide="33"><div class="frame wide">
<h2>Canvas service: acquire once</h2>
<pre class="code-tight"><code><span class="kw">interface</span> <span class="type">CanvasService</span> {
<span class="kw">readonly</span> state: ReplicatedState&lt;<span class="type">CanvasState</span> | <span class="kw">null</span>&gt;;
@ -381,7 +505,7 @@ head entry → model context begins here; old entries remain stored</code
<span class="comment">// documentReplicatedState is the small Pico5→Chord adapter.</span></code></pre>
</div></section>
<section class="slide" data-slide="27"><div class="frame wide">
<section class="slide" data-slide="34"><div class="frame wide">
<h2>Canvas service: provide durable methods</h2>
<pre class="code-tight"><code><span class="kw">const</span> CanvasFacet = defineFacet({
id: <span class="str">"app.canvas/session"</span>,
@ -400,7 +524,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">The method resolves after durability—not after every UI callback.</p>
</div></section>
<section class="slide" data-slide="28"><div class="frame wide">
<section class="slide" data-slide="35"><div class="frame wide">
<h2>Consumers see snapshots, then updates</h2>
<pre class="code-small"><code><span class="kw">const</span> CanvasConsumer = defineFacet({
id: <span class="str">"app.canvas/consumer"</span>,
@ -421,7 +545,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">Late join: current value. Connected client: ordered committed updates.</p>
</div></section>
<section class="slide" data-slide="29"><div class="frame wide">
<section class="slide" data-slide="36"><div class="frame wide">
<h2>A generation task commits what clients see</h2>
<pre class="code-small"><code><span class="comment">// Schematic: LiveDoc is the conversation-scoped presentation document.</span>
<span class="comment">// Read provider frames off the Session line.</span>
@ -438,7 +562,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">Storage and every Chord client receive the same committed operations.</p>
</div></section>
<section class="slide" data-slide="30"><div class="frame wide">
<section class="slide" data-slide="37"><div class="frame wide">
<h2>Task kinds replace the fixed graph</h2>
<pre class="code-small"><code><span class="kw">type</span> <span class="type">GenerationState</span> =
| { phase: <span class="str">"prepare"</span> }
@ -455,7 +579,7 @@ head entry → model context begins here; old entries remain stored</code
<p class="subtitle step">Core scheduler knows tasks. Each kind knows its own phases.</p>
</div></section>
<section class="slide" data-slide="31"><div class="frame wide">
<section class="slide" data-slide="38"><div class="frame wide">
<h2>The generation task graph grows by commits</h2>
<pre class="code-tight"><code><span class="comment">Selected business commits; reservation/preparation/progress omitted</span>
@ -475,7 +599,7 @@ F ordinary post-tools settlement:
<p class="subtitle step">The scheduler knows dependencies. Generation, tools, and post-tools create the graph.</p>
</div></section>
<section class="slide" data-slide="32"><div class="frame wide">
<section class="slide" data-slide="39"><div class="frame wide">
<h2>The effect sandwich stays</h2>
<pre><code><span class="comment">successful response with no tool calls</span>
@ -488,7 +612,7 @@ commit append assistant entry E
replace task with completed result { entryId: E }</code></pre>
</div></section>
<section class="slide" data-slide="33"><div class="frame wide">
<section class="slide" data-slide="40"><div class="frame wide">
<h2>Private task state ≠ presentation state</h2>
<div class="grid2">
<div class="panel step"><h3>Task record</h3><pre class="code-small"><code>{
@ -505,7 +629,7 @@ commit append assistant entry E
</div>
</div></section>
<section class="slide" data-slide="34"><div class="frame wide">
<section class="slide" data-slide="41"><div class="frame wide">
<h2>Task scope carries bounded output</h2>
<pre class="code-tight"><code><span class="kw">type</span> <span class="type">JobOutputState</span> = { stdout: <span class="type">string</span>; chunks: <span class="type">number</span> };
@ -525,7 +649,7 @@ watch.start((value, ops) =&gt; consume(value, ops));
<p class="subtitle step">The producer bounds stdout on append; checkpoints only bound replay cost.</p>
</div></section>
<section class="slide" data-slide="35"><div class="frame wide">
<section class="slide" data-slide="42"><div class="frame wide">
<h2>Abort is durable and semantic</h2>
<pre><code>conversation C
├─ generation G7 ──owns──→ sub-conversation D ── tool T9
@ -540,7 +664,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<p class="subtitle step">Clients observe committed consequences; process-local abort signals are not replicated state.</p>
</div></section>
<section class="slide" data-slide="36"><div class="frame wide">
<section class="slide" data-slide="43"><div class="frame wide">
<h2>Close is not abort</h2>
<table class="table"><thead><tr><th>Operation</th><th>Durable writes</th><th>Distribution and reopen</th></tr></thead><tbody>
<tr class="step"><td>Cancel a Context</td><td>None by itself</td><td>Requests cancellation of one call/wait; remote propagation depends on transport. Committed work is not undone.</td></tr>
@ -550,7 +674,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<p class="subtitle step">Disconnecting a client does not silently abort shared work.</p>
</div></section>
<section class="slide" data-slide="37"><div class="frame wide">
<section class="slide" data-slide="44"><div class="frame wide">
<h2>Hooks and tools remain extensible</h2>
<div class="grid2">
<div class="panel step"><h3>Hooks</h3><ul class="tight"><li>declared by task kind</li><li>replacement chains or first decision</li><li>may rerun before their consuming commit</li><li>memos persist idempotency receipts</li></ul></div>
@ -559,7 +683,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<p class="subtitle step">V1: client facets may reload; Session-side plugin code closes and reopens the Harness.</p>
</div></section>
<section class="slide" data-slide="38"><div class="frame wide">
<section class="slide" data-slide="45"><div class="frame wide">
<h2>Target contract: open pi-ai PR #9548</h2>
<pre class="code-tight"><code><span class="comment">// e10 — complete baseline</span>
{ role: <span class="str">"system"</span>, content: basePrompt,
@ -577,7 +701,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<p class="subtitle step">Replay in transcript order; stored rendered sections remain authoritative.</p>
</div></section>
<section class="slide" data-slide="39"><div class="frame wide">
<section class="slide" data-slide="46"><div class="frame wide">
<h2>The conversation view is a mount</h2>
<pre class="code-small"><code><span class="kw">type</span> <span class="type">ConversationView</span> = {
conversation: <span class="type">Conversation</span>;
@ -590,7 +714,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<p class="subtitle step">Path prefixing, not a second tracker or semantic projection.</p>
</div></section>
<section class="slide" data-slide="40"><div class="frame wide">
<section class="slide" data-slide="47"><div class="frame wide">
<h2>Status is state, not an event</h2>
<pre><code>live.collapse = { stage: "summarizing", through: 920 }
live.collapse = { stage: "retrying", retryAt: 1730000000 }
@ -602,7 +726,7 @@ late client during retry:
<p class="subtitle step">A “started” event cannot help a client that attaches later.</p>
</div></section>
<section class="slide" data-slide="41"><div class="frame wide">
<section class="slide" data-slide="48"><div class="frame wide">
<h2>Storage is one contract, two products</h2>
<div class="grid2">
<div class="panel step"><h3>SQLite</h3><ul class="tight"><li>long-running/cloud Sessions</li><li>one SQL transaction per commit</li><li>indexed base + delta ranges</li><li>current task rows</li></ul></div>
@ -611,7 +735,7 @@ late client during retry:
<p class="subtitle step">Backends receive the same entries, task records, document ops, and candidate values.</p>
</div></section>
<section class="slide" data-slide="42"><div class="frame wide">
<section class="slide" data-slide="49"><div class="frame wide">
<h2>Build it in reviewable slices</h2>
<div class="flow">
<div class="node step"><span class="num">1</span><br>storage<br><span class="small">memory · SQLite · JSONL</span></div><div class="arrow step">→</div>
@ -622,7 +746,7 @@ late client during retry:
<p class="subtitle step">Each slice has intrinsic conformance tests and stops for sign-off.</p>
</div></section>
<section class="slide" data-slide="43"><div class="frame wide">
<section class="slide" data-slide="50"><div class="frame wide">
<h2>The deliberate sharp edges</h2>
<div class="grid2">
<div class="panel step"><h3>Ownership</h3><ul class="tight"><li>draft/proxy escape</li><li>inserted and duplicated aliases</li><li>table read after table write</li><li>large values in terminal task records</li></ul></div>
@ -631,7 +755,7 @@ late client during retry:
<p class="subtitle step">Stable definition IDs, mounted paths, and fork settings are persisted protocol.</p>
</div></section>
<section class="slide" data-slide="44"><div class="frame wide">
<section class="slide" data-slide="51"><div class="frame wide">
<h2>Footguns: drafts have one owner</h2>
<pre class="code-small"><code><span class="kw">let</span> escaped: <span class="type">CanvasState</span> | <span class="kw">undefined</span>;
<span class="kw">await</span> session.commit(<span class="kw">async</span> tx =&gt; {
@ -651,7 +775,7 @@ escaped!.strokes.push(otherStroke); <span class="bad">// wrong: transaction ende
<p class="subtitle step">Do not retain drafts, nested proxies, bound methods, or assigned mutable aliases.</p>
</div></section>
<section class="slide" data-slide="45"><div class="frame wide">
<section class="slide" data-slide="52"><div class="frame wide">
<h2>Footguns: do not block or outrun the line</h2>
<pre class="code-tight"><code><span class="comment">// Independent mistake 1: external effect on the Session line.</span>
<span class="kw">await</span> session.commit(<span class="kw">async</span> _tx =&gt; requestModel(context), context);
@ -676,7 +800,7 @@ callback failure before flush → <span class="good">baseline resto
<p class="subtitle step">Also remember: normal document reads may create, and IDs/paths are protocol.</p>
</div></section>
<section class="slide" data-slide="46"><div class="frame center">
<section class="slide" data-slide="53"><div class="frame center">
<p class="big">Entries preserve history.</p>
<p class="big step" style="margin-top:24px">Tasks preserve work.</p>
<p class="big step" style="margin-top:24px">Documents preserve <span class="good">current truth</span>.</p>

View file

@ -8,9 +8,12 @@ import type { Context, JsonValue } from "@earendil-works/chord";
import type { Op } from "@earendil-works/chord/delta";
import type {
Message as ModelMessage,
Models,
TextContent,
Tool,
ToolReference,
ToolResultMessage,
UserMessage,
} from "@earendil-works/pi-ai";
type JsonObject = { [key: string]: JsonValue };
@ -168,6 +171,278 @@ Views carry raw active entries. UI reduction and model-context reduction are
separate consumers. Older stored history is available through cursor-based
entry scans.
### 2.2 Public Harness surface
This is the v1 host-facing API. Pico5 is not implemented yet, but implementations
must expose this shape rather than inventing a different facade during package
25.
```ts
type ModelRef = {
readonly provider: string;
readonly modelId: string;
};
type UserInput = UserMessage["content"];
type SendInput = {
readonly content: UserInput;
readonly requestId?: string;
readonly whenBusy?: "steer" | "followUp" | "reject";
};
type SectionSeed =
| { readonly key: string; readonly value: JsonValue }
| { readonly key: string; readonly remove: true };
type ConversationSpec = {
readonly parent?: { readonly conversationId: Id; readonly at: Id };
readonly model?: ModelRef;
readonly sections?: readonly SectionSeed[];
};
type AnyTaskKind = {
readonly definition: {
readonly name: string;
readonly version: number;
readonly initial: unknown;
readonly phases: Readonly<Record<string, unknown>>;
readonly abort: unknown;
readonly migrate?: unknown;
readonly hooks?: object;
};
};
type HarnessOptions = {
readonly models: Models; // the pi-ai Models interface
readonly tools?: readonly ToolRegistration[];
readonly taskKinds?: readonly AnyTaskKind[];
readonly documents?: readonly AnyDocToken[];
readonly sections?: readonly SystemSection<JsonValue>[];
readonly plugins?: Readonly<Record<string, PluginHandler>>;
readonly processHost?: ProcessHost;
readonly now?: () => number;
readonly root?: Omit<ConversationSpec, "parent">;
readonly onReport?: (error: unknown) => void;
};
interface EntryKind<E extends Entry = Entry> {
readonly kind: string;
is(entry: Entry | undefined): entry is E;
}
function defineEntry<E extends Entry>(kind: string): EntryKind<E>;
type ContextView = {
readonly head: Entry | undefined;
readonly entries: readonly Entry[];
readonly messages: readonly RequestMessage[];
};
type SettledInput = Input & {
readonly status: "done" | "unanswered";
};
interface InputHandle {
readonly id: Id;
result(context: Context): Promise<Input | undefined>;
wait(context: Context): Promise<SettledInput>;
abort(
context: Context,
): Promise<"aborted" | "already_placed" | "not_found">;
}
type SettledTask<R> = Task<JsonValue, JsonValue, R> & {
readonly state: TerminalTask<R>;
};
interface ConversationWatch {
readonly value: ConversationView;
readonly revision: number;
start(listener: (
value: ConversationView,
ops: readonly Op[],
revision: number,
context: Context,
) => void): void;
stop(): void;
}
type HooksOf<K> = K extends TaskKind<infer _I, infer _S, infer _R, infer H>
? H
: never;
interface ConversationHandle {
readonly id: Id;
send(input: SendInput, context: Context): Promise<InputHandle>;
write(entry: EntryDraft, context: Context): Promise<InputHandle>;
commit<T>(
change: (tx: Tx) => T | Promise<T>,
context: Context,
): Promise<T>;
context(context: Context): Promise<ContextView>;
fork(
at: Id,
spec: Omit<ConversationSpec, "parent">,
context: Context,
): Promise<ConversationHandle>;
collapse(instructions: string | undefined, context: Context): Promise<Id>;
reset(handoff: string | undefined, context: Context): Promise<void>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
hooks<K extends AnyTaskKind>(
owner: string,
kind: K,
handlers: Partial<HooksOf<K>>,
options?: { readonly subtree?: boolean },
): () => void;
watch(context: Context): Promise<ConversationWatch>;
}
interface Harness extends Session {
resume(): void;
suspend(context: Context): Promise<void>;
quiescent(): boolean;
hold(): () => void;
registerDocument(token: AnyDocToken): () => void;
registerTaskKind(kind: AnyTaskKind): () => void;
registerTool(tool: ToolRegistration): () => void;
registerSection(section: SystemSection<JsonValue>): () => void;
registerEntryKind(kind: EntryKind): () => void;
hooks<K extends AnyTaskKind>(
owner: string,
kind: K,
handlers: Partial<HooksOf<K>>,
): () => void;
root(context: Context): Promise<ConversationHandle>;
onConversation(listener: (conversation: ConversationHandle) => void): () => void;
conversation(id: Id, context: Context): Promise<ConversationHandle | undefined>;
createConversation(
spec: ConversationSpec & { readonly input?: UserInput },
context: Context,
): Promise<ConversationHandle>;
entries(
query: EntryQuery,
cursor: EntryCursor | undefined,
limit: number,
context: Context,
): Promise<Page<Entry, EntryCursor>>;
getTask(id: Id, context: Context): Promise<Task<JsonValue, JsonValue, JsonValue> | undefined>;
abortInput(
id: Id,
context: Context,
conversationId?: Id,
): Promise<"aborted" | "already_placed" | "not_found">;
abortTask(id: Id, context: Context): Promise<"marked" | "terminal">;
markTask(id: Id, context: Context): Promise<"marked" | "terminal">;
waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
waitForIdle(context: Context): Promise<void>;
}
declare const Harness: {
open(
storage: Storage,
options: HarnessOptions,
context: Context,
): Promise<Harness>;
};
```
This intentionally retains the useful Pico3 host shape. It removes Pico3's
fixed `rewindable()`/`sticky()` accessors, namespace router, semantic view events,
and manual Chord view bridge. Typed Pico5 documents and the structural
conversation watch replace those surfaces. `write()` now returns an
`InputHandle`, because a busy passive write may remain queued before it receives
an entry ID.
`Harness.open()` installs built-in definitions followed by supplied documents,
task kinds, tools, sections, and plugins. It then completes document migration,
changes surviving `running` tasks to `pending`, migrates task records, and
settles unknown or unmigratable live kinds as `orphaned` before resolving. IDs
and names must be unique. No handler dispatches during open. Dynamic registration is available
after open and does not resurrect a task already settled by that pass.
Storage contains one stable root conversation ID in Session metadata. Empty
storage creates the conversation and metadata together from `options.root`;
reopen reads that ID rather than inferring it from a conversation scan.
`options.root` never overwrites existing state. `root()` returns the stored
handle. A conversation with no configured model produces a durable `no_model`
generation failure.
`resume()` is idempotent while running and only enables scheduling. It does not
repeat open-time reconciliation. `suspend()` is terminal for that Harness
instance and follows the close semantics below; `resume()` after suspend/close
rejects. `quiescent()` means no task invocation is currently executing; eligible
or delayed durable tasks may still exist. `hold()` is available only while
quiescent and pauses reservation until its idempotent release function runs.
Runtime registration rejects duplicate IDs/names and returns an idempotent
function that unregisters only that exact token. It supports declaration
movement during normal operation, not replacement of executing plugin code;
section 7.4 governs code reload.
Conversation creation atomically commits the conversation, built-in
configuration and section seeds, and optional input admission. For a fork,
omitted model/section values follow their document definitions' fork policies;
provided values override those forked built-in values in the same commit. Other
documents follow their own definitions without special handling.
A `ConversationHandle.commit()` is a Session commit bound to that conversation.
`tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
Generic Session-wide document operations remain available directly on `Harness`
because `Harness extends Session`.
`fork()` requires a concrete visible parent entry and applies section 3.7.
`collapse()` returns the newly admitted background collapse task ID, not its
future summary entry. `reset()` durably admits a passive self-head reset or
handoff write and then resolves; while busy, placement follows section 6 and may
occur later. Observe its placement through the conversation watch. An idle wait
does not guarantee placement of queued passive writes.
`markTask()` only commits `abortRequested`; it neither signals nor joins an
active invocation. The scheduler notices the mark on its next drain.
`abortTask()` also signals and joins an active run before starting the abort
invocation. `ConversationHandle.abort()` withdraws queued non-write inputs, marks
non-background tasks in the conversation and owned subtree, signals them, and
resolves only after that subtree is ordinarily idle. Passive writes and
background tasks survive. Conversation idle means no non-background live task
in that conversation or its ownership subtree; Harness idle applies the same
rule Session-wide. Pending dependency- or deadline-blocked work is still live
and therefore not idle. Cancelling an idle wait aborts only that waiter.
`onConversation()` synchronously visits the currently loaded committed
conversations in ascending ID order, then reports each later creation after its
commit. Listener failures go to `onReport` and do not stop other listeners. Its
idempotent disposer removes the listener; Harness close removes all remaining
listeners.
`send()` and `write()` return after durable admission, not settlement. `send`
creates a user message with the admission timestamp. `whenBusy` defaults to
`followUp`. `write()` uses the ordered passive path in section 6 and never starts
generation. A completed passive write identifies its entry in `Input.entry`; a
completed send identifies the answer in `Input.answer`. Cancelling `wait()` only
cancels that wait. It does not withdraw or abort the input; `InputHandle.abort()`
is the explicit queued-input withdrawal operation.
`abortTask()` durably requests cancellation and returns `marked` after the mark
is committed, any active run invocation has joined, and an abort invocation has
been scheduled; it does not await terminal settlement. `waitForTask()` observes
the terminal receipt. Aborting an already terminal task returns `terminal`; an
unknown ID rejects. Explicit task abort includes a background task. Cancelling a
task or idle wait does not abort work.
`ConversationHandle.watch()` atomically captures the current structural view and
subscribes to later complete Session commits. It has the same stopped-start,
unbounded pre-start buffering, listener isolation, and close ownership as
`watchDoc()`. It carries no semantic events and owns no second tracker.
`close()` is equivalent to `suspend()` for v1: it stops reservation, signals and
joins invocations, closes watches and storage, and writes no task outcome. Handles
belong to that open Harness and must be reacquired after reopen.
## 3. Documents
### 3.1 Definitions
@ -710,7 +985,7 @@ settles, the scheduler rereads the task and applies the first matching rule:
6. Checkpoint unchanged: write terminal `faulted` because no durable progress
was made.
On open, orphan reconciliation changes surviving `running` tasks back to
On open, running-task reconciliation changes surviving `running` tasks back to
`pending`, preserving their checkpoint and abort mark. Task migration then runs
before dispatch. One callback handles every supported older version; newer or
unmigratable live tasks become orphaned.
@ -886,6 +1161,89 @@ semantic event channel; current UI status is document state.
### 7.2 Tools
```ts
type ToolControl = {
readonly addTools?: readonly string[];
readonly terminate?: true;
readonly handoff?: string;
};
type ToolExecutionResult = {
readonly content?: ToolResultMessage["content"];
readonly isError?: boolean;
readonly details?: JsonValue;
readonly control?: ToolControl;
};
interface OwnedConversationHandle {
readonly id: Id;
send(input: SendInput, context: Context): Promise<InputHandle>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
}
interface ToolExecutionApi extends DocumentObserver {
readonly taskId: Id;
readonly conversationId: Id;
readonly callId: string;
stream(chunk: string | Uint8Array): void;
commit<T>(change: (tx: Tx) => T | Promise<T>, context: Context): Promise<T>;
progress(value: JsonObject, context: Context): Promise<void>;
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
createTask<I, S extends { phase: string }, R, H extends object>(
kind: TaskKind<I, S, R, H>,
input: I,
options: Omit<TaskOptions, "conversationId">,
context: Context,
): Promise<TaskRef<R>>;
getTask<R>(ref: TaskRef<R>, context: Context): Promise<Task<JsonValue, JsonValue, R> | undefined>;
waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
createConversation(
spec: Omit<ConversationSpec, "parent"> & { readonly inherit?: boolean },
context: Context,
): Promise<OwnedConversationHandle>;
}
type PluginHandler = (
input: JsonValue,
api: ToolExecutionApi,
context: Context,
) => Promise<JsonValue>;
type ToolRegistration = Tool & {
readonly replay?: "safe" | "unsafe";
readonly output?: {
readonly maxBytes?: number;
readonly maxLines?: number;
readonly retain?: "head" | "tail";
};
execute(
args: JsonValue,
api: ToolExecutionApi,
context: Context,
): Promise<ToolExecutionResult>;
};
```
Omitted `replay` is `unsafe`. Omitted output bounds are 64 KiB, 200 lines, and
`retain: "head"`. `stream()` synchronously accepts UTF-8 output into that
invocation-owned bounded buffer and throws after invocation end. Throttled
commits publish the retained output and dropped byte/line counts in the tool
presentation document. If `execute()` omits `content`, the final retained stream
becomes one text content item; no stream becomes an empty content list. Explicit
text in explicit result content is bounded by the same limits before transcript
persistence; non-text content is retained as declared by its pi-ai type.
`progress(value)` replaces the invocation's complete JSON `progress` payload; it
does not merge keys. Its promise resolves after the corresponding or coalesced
document commit. During normal settlement, accepted output updates drain before
the tool-result entry and terminal task record commit. Abort and close obey
invocation and Session admission gates: uncommitted buffered updates may be
discarded, while admitted commits settle. Pre-flush cancellation or callback
failure does not poison the Session. Post-flush, checkpoint, or storage failure
follows the fatal Session rule.
Tools are dynamically registered declarations with name, description, JSON
schema, replay policy, and execute function. A tool call is accepted only if it
was offered in the request's effective system/tool history. Arguments are
@ -902,8 +1260,25 @@ A tool executes in a durable task. It may:
- create and wait for tasks;
- create owned conversations;
- observe documents for which it has a token/reference;
- mutate authorized documents through `commit()`;
- return bounded model content and separate diagnostic details.
Tool and plugin operations use the invoking task's admission and
invocation-lifetime gates. Task creation defaults to that task's conversation.
Trusted document access follows sections 3.3 and 9.2; there is no additional
document subtree authorization layer. `createConversation()` records the
invoking task as owner and returns an invocation-bound handle. `inherit` defaults
to `false`. When true, creation atomically selects the invoking conversation's
newest committed visible entry as `parent.at`; if no entry exists, it creates no
history parent. Conversation documents then apply section 3.7 at that entry, and
explicit model/section seeds override their inherited built-in values. With no
parent, documents initialize normally. That handle's
operations reject after the invocation ends. An `InputHandle` returned by its
`send()` is invocation-bound in the same way; the admitted input itself remains
durable after those methods reject. Invocation-owned document watches stop when
the invocation ends. A
plugin handler receives the same API; `callId` is its durable invocation key.
A tool result may request `addTools`, `terminate`, or `handoff`. Post-tools
applies added tool names to configured loadout, uses a final boundary for
terminate/handoff, and writes a headed handoff entry when requested.
@ -917,6 +1292,18 @@ tool terminal outcomes retain their tool-result entry ID for post-tools.
### 7.3 System sections and dynamic tools
```ts
interface SystemSection<T extends JsonValue = JsonValue> {
readonly key: string;
render(value: T): string;
}
function defineSystemSection<T extends JsonValue>(definition: {
readonly key: string;
render(value: T): string;
}): SystemSection<T>;
```
Pico stores prompt and tool changes directly as PR #9548 `SystemMessage` values
at their transcript positions:
@ -1011,6 +1398,43 @@ isolation and is not a v1 promise.
## 8. Built-in tasks
```ts
type ProcessSpec = {
readonly command: string;
readonly args: readonly string[];
readonly cwd: string;
readonly env?: Readonly<Record<string, string>>;
};
type ProcessStatus =
| {
readonly status: "running";
readonly stdout: string;
readonly stderr: string;
readonly droppedStdout: number;
readonly droppedStderr: number;
}
| {
readonly status: "exited";
readonly exitCode: number;
readonly stdout: string;
readonly stderr: string;
readonly droppedStdout: number;
readonly droppedStderr: number;
}
| { readonly status: "unknown" };
interface ProcessHost {
start(key: string, spec: ProcessSpec, context: Context): Promise<void>;
status(key: string, context: Context): Promise<ProcessStatus>;
kill(
key: string,
signal: "SIGTERM" | "SIGKILL",
context: Context,
): Promise<void>;
}
```
The initial implementation provides:
| kind | responsibility |
@ -1022,6 +1446,14 @@ The initial implementation provides:
| job | run a process, persist bounded output, delay or reschedule |
| plugin | run a registered durable handler when needed |
Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
from the prepared prompt/messages/tools, and calls `models.streamSimple()` with
the task invocation's abort signal and configured reasoning/options. Deferred
continuation calls `models.fetchDeferred()` and `models.cancelDeferred()` with
that same model and signal. Missing models and synchronous/streamed pi-ai errors
are classified into the durable generation outcomes below.
Generation and tool progress are throttled durable document commits. A crash may
lose only the uncommitted throttle window. Recovery converts committed partials
to normal interrupted/aborted transcript entries, clears presentation state,
@ -1143,6 +1575,14 @@ Entry appends/head changes and every changed mounted document are included in
the same publication. The mount owns no tracker and performs no semantic
projection. Its revision is contiguous per view.
`ConversationHandle.watch()` exposes that mount directly. Capture of
`ConversationView`, revision, and listener registration is atomic on the Session
line. Like `watchDoc()`, it returns stopped, buffers without a fixed bound until
`start()` or `stop()`, drains in order, isolates listener failures, and stops on
Harness close. Chord Session facets may forward the captured value and committed
ops through services, but that product wiring is not part of the Harness facade
and must not add another tracker or semantic event envelope.
## 10. Storage contract
Ordered scans are cursor-based. Exact identity lookups are keyed.
@ -1194,7 +1634,12 @@ type StoredDocument = {
];
};
type SessionMetadata = {
readonly rootConversationId: Id;
};
type StorageWrite =
| { readonly type: "session.metadata"; readonly value: SessionMetadata }
| { readonly type: "conversation"; readonly value: Conversation }
| { readonly type: "entry"; readonly value: Entry }
| { readonly type: "task"; readonly value: Task<JsonValue, JsonValue, JsonValue> }
@ -1218,6 +1663,7 @@ interface Storage {
commit(writes: readonly StorageWrite[], context: Context): Promise<Seq>;
mintId(): Id;
metadata(context: Context): Promise<SessionMetadata | undefined>;
conversation(id: Id, context: Context): Promise<Conversation | undefined>;
scanConversations(cursor: ConversationCursor | undefined, limit: number, context: Context): Promise<Page<Conversation, ConversationCursor>>;
@ -1238,6 +1684,11 @@ interface Storage {
}
```
`metadata()` is absent only for empty storage. The first root creation commits
its conversation and metadata together. Existing records with missing metadata,
or metadata naming a missing conversation, make open fail rather than guessing a
root.
`EntryQuery` supports conversation ancestry, a strict `before` entry cursor,
kind filtering, and `withHead`. Document queries support definition, scope,
instance, current/as-of membership, and retired membership. Task queries support
@ -1335,8 +1786,9 @@ These are contracts, not invitations to add defensive machinery:
- **Schema stability:** definition IDs, mounted document IDs, and visible paths
are persisted/public protocol. Value migration cannot rename an ID; an ID
change requires explicit copy and retirement.
- **Unstarted watches:** `watchDoc()` buffers every committed update until
`start()` or `stop()`. Delaying both can consume unbounded memory.
- **Unstarted watches:** `watchDoc()` and `ConversationHandle.watch()` buffer
every committed update until `start()` or `stop()`. Delaying both can consume
unbounded memory.
- **Durable progress cadence:** clients see only committed progress. A crash may
lose the current uncommitted throttle window.
- **Large terminal results:** terminal task records remain queryable. Put large