docs(agent): pico3 review decisions: revision, visibility, memos, waiting, reload

- Envelope carries a contiguous per-watch revision; storage Seq stays internal;
  Chord bridge publishes off the line through a bounded ordered adapter
- Namespaces reach the view only through a declared view projection; memos and
  slot working state are never in the view
- memoOnce uses property presence; memos are coordination/evidence, not
  exactly-once; idempotency key before the effect
- Hooks bound to their namespace token; BeforeToolApi.waiting(ctx) is async;
  memo keys are (task, namespace, name)
- Reload: quiescent hold/reload/release, otherwise suspend/reopen; no handler
  withdrawal machinery in v1; Namespace.unregister
- pi.* entry names reject except pi.notice; config reset is an operation;
  generation failures run the final boundary and start successors
This commit is contained in:
Mario Zechner 2026-09-13 22:49:14 +02:00
parent fd3b009d9f
commit 09b031600d
3 changed files with 251 additions and 141 deletions

View file

@ -32,11 +32,15 @@ Specifically superseded in this document:
|---|---|
| Accepted direction #3 (separate `rewindable`/`sticky` view fields with separate op lists) | one flat view document with `config`, `inbox`, `turn`, `compaction`, `tasks`, `plugins` and one op list (`view-and-events.md` §2–§4); the storage split stays |
| Accepted direction #2 "live task slots ... in the view" and §13 last paragraph (`TaskRef<K>` slot typing) | slots remain, but the view publishes kind-described `tasks[id].status` via `describe()`, never raw task records (`view-and-events.md` §2.2, §6.6; `plugins.md` §4.3) |
| §9 Watch protocol: envelope shape and `applyDelta` | `Envelope { seq, ops, events }` folded with `applyImmutable`; the active-transcript definition and capture rules in §9 here remain normative (`view-and-events.md` §3, §5, §9) |
| §9 bounded capacity / overflow-close | retained for in-process watches; with Chord `ReplicatedState` as the transport (`plugins.md` §6.1) Chord owns sequencing and gap detection |
| §9 Watch protocol: envelope shape, `Seq` ordering and `applyDelta` | `Envelope { revision, ops, events }` with a contiguous per-watch `revision` (storage `Seq` is not exposed), folded with `applyImmutable`; the active-transcript definition and capture rules in §9 here remain normative (`view-and-events.md` §3, §5, §9) |
| §9 bounded capacity / overflow-close | the raw watch invokes its listener synchronously in order; only the pre-`start` buffer is bounded (overflow closes the watch); asynchronous consumers own their queue (`view-and-events.md` §3; `plugins.md` §6.1 for the Chord bridge) |
| §13 plugin state visibility | a namespace is in the view only through its declared `view` projection; memos and slot working state are never in the view (`plugins.md` §1.5, §2, §4.5) |
| §15 `beforeTool` throw = block; hooks by name | hooks are registered with their namespace token (`h.hooks(ns, kind, handlers)`); `BeforeToolApi.waiting(ctx)` is asynchronous; memo keys are `(task, namespace, name)` (`plugins.md` §4.2) |
| §16 / view §6.2 generation failure closures | every generation failure resolves the group `unanswered/failed`, runs the final boundary and starts a successor for queued triggers (`view-and-events.md` §6.2) |
| §4.4 entries / §1 `pi.*` | `pi.*` entry names reject for non-core callers except the allow-listed `pi.notice` (`plugins.md` §4.4) |
| §13 plugin state (`plugins[pluginId]` slices, extra document shard) | `h.namespace(ns, defaults)` tokens, `tx.plugins(ns)`, `tx.emit(ns, …)`, lazy seeding; extra shards stay deferred (`plugins.md` §2, §3, §4.5) |
| §15 tool API (candidate) | `ToolApi` with `memo` (slot memoOnce), `waiting`/`memo` on `BeforeToolApi`, `waitingOn` on the tool slot (`plugins.md` §4.1–§4.2; `view-and-events.md` §2, §6.3) |
| §1 "install fixed built-ins internally" / registration at open | registration capabilities callable while open, before-`resume()` rule, `hold()/release()`, handler withdrawal and chain rerun (`plugins.md` §3, §5) |
| §1 "install fixed built-ins internally" / registration at open | registration capabilities callable while open, before-`resume()` rule, `quiescent()/hold()` for idle reload and `suspend()/reopen` otherwise; no handler-withdrawal machinery in v1 (`plugins.md` §3, §5) |
| §16 `pi.plugin` handlers map | folded into custom task kinds; `pi.plugin` may stay as a convenience kind but is not the plugin mechanism |
Everything else here (authority explanation, proxy lifetime, prospective busy,
@ -262,11 +266,11 @@ Cancellation only removes/rejects that waiter; it does not cancel durable work.
Keep the rendering-friendly `ConversationView` and Chord document ops, but implement the settled watch behavior:
- capture and subscription are one line operation;
- committed envelopes are delivered in commit-`Seq` order;
- committed envelopes are delivered in commit order with a contiguous per-watch `revision` (storage `Seq` is not exposed);
- one commit produces one envelope folded into the view before the listener runs;
- listener execution is off-line;
- listener execution is off-line, synchronous and in order;
- listener throw or decoder failure closes only that watch and reports through `onError`; it never rejects the already-persisted writer;
- bounded capacity (default 256); overflow closes the watch;
- envelopes before `start()` are buffered with a bounded capacity (default 256); overflow closes the watch; no other queue exists in the raw watch;
- no cursor, replay, acknowledgement, deduplication, or resnapshot protocol; the client opens a fresh watch;
- `stop`/unsubscribe is idempotent;
- no callbacks after close/stop.
@ -352,7 +356,7 @@ Make the standalone `tsconfig` portable: no `/home/claude` paths, exclude design
Keep fixed core document fields and the flat typed `c.config` facade.
- Derive/seed defaults from registered kind definitions rather than duplicating them in `defaultRewindable`/`defaultSticky` and kind configs.
- Deleting/unsetting a key restores its declared default; only model remains optional among built-ins.
- Resetting a key is an explicit operation (`config.reset(keys)`; RPC `configReset`), never assigning `null`: storage deletes the override, the resolved view sets the declared default; only `model`, which has no default, is deleted from the view.
- Do not use `value ?? default`, because durable `null` is a legitimate value. Test property presence.
- Custom kind config defaults must be visible both through `c.config` and to the task itself before any explicit set.
- Reject config-key collisions at compile time where practical and always at installation.

View file

@ -23,8 +23,11 @@ Read order: §1–§5 are the harness alone (no Chord); §6 is the Chord layer;
touch `turn`/`inbox`, resolve inputs, create core tasks, or write outside their
namespace or their own task's slot. Enforced at runtime on every method, by
type at the surface.
5. **Everything an extension does is visible through the view** (`plugins[ns]`,
`tasks[id]`, `turn.tools[i]`, entries) and nowhere else.
5. **An extension's presentation is only what it projects.** The view shows a
namespace's declared `view` projection (nothing by default), a kind's
`describe()` output, the core-rendered tool slot fields, and entries. Raw
namespace state (caches, keys, secrets) and slot working state (memos,
idempotency evidence) never appear in the view.
## 2. Durable primitives
@ -40,10 +43,15 @@ Rules:
- **Checkpoint** is the kind's recovery program counter, nothing else. It is not
a place for working state (that is the slot) or long-lived state (namespace).
- **Slot** is working state that should die with the task: progress, streamed
output, and **memos**. `memoOnce(slot, name, candidate)` is one operation:
`slot.memos[name] ??= candidate` inside one commit; it returns the durable
winner to every caller. It is the pico3 equivalent of the lane harness's
invocation memo and of Codex's item completion record.
output, and **memos**. `memoOnce(slot, key, candidate)` is one operation inside
one commit: if `Object.hasOwn(slot.memos, key)` return the stored value, else
store `candidate` and return it (`??=` is wrong: `null` is a valid value). It
returns the durable winner to every caller. A memo is durable coordination and
evidence; it is **not** exactly-once execution by itself. For an external
effect, persist the idempotency key *before* the effect and replay the effect
with the same key, relying on the external side to dedupe or to answer a
result lookup; persist a result *after* an effect only when a duplicate is
otherwise harmless. Slot contents are never in the view.
- **Namespace slice** defaults are declared at registration, per document, and
seeded lazily (`plugins[ns] ??= defaults` on first access). Choose the
document by semantics: rewindable when a fork should see the value as it was
@ -73,18 +81,22 @@ the harness is open (§5 for what happens then).
h.registerTool(declaration: ToolDeclaration): () => void
h.registerSection(section: SystemSection): () => void
h.registerTaskKind(kind: Kind): () => void // ordinary kinds only; config keys checked for collisions
h.namespace<T extends NamespaceShape>(ns: string, defaults: T): Namespace<T> // token; unique, not `pi.*`
h.hooks(kind, handlers: Partial<HooksOf<typeof kind>>, opts?): () => void // harness-wide
c.hooks(kind, handlers, { subtree? }): () => void // one conversation (+ owned)
defineEntry<E>(kind: string): EntryKind<E> // stateless; no registration
h.namespace<T extends NamespaceShape>(ns: string, defaults: T, opts?: { view?: (slice: T) => JsonValue }): Namespace<T>
// token with idempotent `unregister()`; unique, not `pi.*`; no `view` ⇒ nothing in the view
h.hooks(ns: Namespace, kind, handlers: Partial<HooksOf<typeof kind>>, opts?): () => void // harness-wide; bound to the namespace (§4.2)
c.hooks(ns, kind, handlers, { subtree? }): () => void // one conversation (+ owned)
defineEntry<E>(kind: string): EntryKind<E> // stateless; throws on `pi.*` (§4.4)
h.conversation(id, ctx): Promise<ConversationHandle | undefined>
h.hold(): () => void // §5
h.quiescent(): boolean; h.hold(): () => void; h.suspend(ctx): Promise<void> // §5
```
Token identity: the object passed to `tx.plugins(ns)`, `tx.emit(ns, …)`,
`tx.createTask(kind, …)`, `h.hooks(kind, …)` must be the currently registered
one; a redeclared or unregistered token rejects with `StaleDefinition`. This is
what makes reload safe: code from a retired generation cannot keep writing.
`tx.createTask(kind, …)`, `h.hooks(ns, kind, …)` must be the currently
registered one; a redeclared or unregistered token rejects with
`StaleDefinition`. The namespace *string* is what persists (memo keys, state
slices); the token is only current runtime authority. This is what makes reload
safe: code from a retired generation cannot keep writing, and a replacement
registration of the same string finds the same state and memos.
Before `resume()`: task kinds whose tasks may exist in storage, and hook
handlers that recovery reruns (`beforeTool`, `systemInstructions`,
@ -98,8 +110,8 @@ Inside a commit, extensions get:
```ts
tx.plugins(ns): T // typed slice in the declared document(s), tracked proxy
tx.emit(ns, name: string, data: JsonValue): void // -> { type: `plugin.${ns}.${name}`, data } in this commit's envelope
tx.slot(task): Slot // own task only (kinds); tools/hooks use api/info variants
memoOnce(tx.slot(task), name, candidate) // first writer wins
tx.slot(task): Slot // own task only (kinds); tools/hooks use the api variants
memoOnce(tx.slot(task), key, candidate) // first writer wins; hasOwn semantics (§2)
tx.write(conversationId, entryKind, input): Promise<Id>
tx.createTask(kind, input, opts): TaskRef<K> // ordinary kinds
```
@ -136,10 +148,13 @@ inside `pi.tool`, whose `started{call}` checkpoint is the effect evidence.
How memos and `replay` interact: `beforeTool` and the `started` checkpoint
happen before `execute`; a crash after `started` re-invokes only `replay:
"safe"` tools. A safe tool that performed a keyed external effect records the
key or result as a memo *before* the effect and reads it first on re-invocation,
so the effect is not repeated. The memo is retired with the task, so it cannot
leak into a later call with the same id.
"safe"` tools. A safe tool that performs a keyed external effect records the
idempotency key as a memo *before* the effect and reuses it on re-invocation;
the external side dedupes or answers a result lookup for that key. Recording a
result *after* the effect (as the question tool does) is only correct when a
repeated effect is harmless, which asking a human again is. Memos are scoped to
the tool task's slot and retired with it, so they cannot leak into a later call
with the same id.
Example: a question tool that waits for a human. `ask` is whatever the host
wires in (§6.2 wires a Chord dialog service; a test wires a promise).
@ -179,10 +194,15 @@ interface HookApi { readonly kind: string; readonly taskId: Id; readonly convers
interface BeforeToolApi extends HookApi {
readonly callId: string;
waiting(on: string): void; // kernel sets turn.tools[i].waitingOn = on until the handler returns (any way)
memo<T extends JsonValue>(name: string, candidate: T, ctx: Context): Promise<T>; // memoOnce on the tool task's slot
waiting(ctx: Context): Promise<void>; // commits turn.tools[i].waitingOn = <this handler's namespace>; idempotent
memo<T extends JsonValue>(name: string, candidate: T, ctx: Context): Promise<T>; // memoOnce on the tool task's slot, keyed (task, namespace, name)
memo<T extends JsonValue>(name: string, ctx: Context): Promise<T | undefined>;
emit(name: string, data: JsonValue, ctx: Context): Promise<void>; // plugin.<namespace>.<name>
}
// Hooks are registered with their namespace token (`h.hooks(ns, kind, handlers)`), so `waiting`, `memo` and
// `emit` need no namespace argument and two plugins cannot collide on a memo name. `waitingOn` is cleared by the
// kernel atomically with `started` (handler allowed), with the synthetic result (handler blocked or threw), or in
// its own commit when the handler unwinds on abort or suspend.
interface ToolHooks {
beforeTool(call: ToolCall, api: BeforeToolApi, ctx: Context): { call?: ToolCall; block?: string } | void | Promise<…>;
@ -193,30 +213,33 @@ interface ToolHooks {
```
Durability: a hook has no task of its own. State tied to the invocation it is
running in goes in the **hosting task's slot** via the point's `api.memo`
(retired with that task, no cleanup code); state that outlives tasks goes in a
**namespace**.
Hooks commit through `h.conversation(api.conversationId, ctx)` handles and may
wait as long as they like; `beforeTool` handlers rerun after a crash (they run
before `started`) and after withdrawal (§5), which is why their decision must be
a memo.
running in goes in the **hosting task's slot** via the point's `api.memo`,
keyed by `(hosting task, namespace string, name)` so it survives recovery and
re-registration and cannot collide with another plugin's memo (retired with
that task, no cleanup code); state that outlives tasks goes in a **namespace**.
There is no hook invocation id: nothing durable identifies "the same handler
invocation" across a crash or a reload, and the rerun must find the earlier
decision. Hooks commit through `h.conversation(api.conversationId, ctx)`
handles and may wait as long as they like; `beforeTool` handlers rerun after a
crash or a suspend/reopen (they run before `started`), which is why their
decision must be a memo.
Example: tool approval.
```ts
export function installApproval(h: Harness, ask: (req: ApprovalRequest, ctx: Context) => Promise<ApprovalDecision>): () => void {
const policy = h.namespace<{ autoAllow: string[] }>("approval", { sticky: { autoAllow: [] } }); // long-lived: which tools skip approval
const ns = h.namespace<{ autoAllow: string[] }>("approval", { sticky: { autoAllow: [] } }); // long-lived: which tools skip approval; no `view` ⇒ private
return h.hooks(kinds.tool, {
return h.hooks(ns, kinds.tool, {
async beforeTool(call, api, ctx) {
const c = await h.conversation(api.conversationId, ctx);
const auto = await c.commit((tx) => tx.plugins(policy).autoAllow.includes(call.name), ctx);
const auto = await c.commit((tx) => tx.plugins(ns).autoAllow.includes(call.name), ctx);
if (auto) return;
let d = await api.memo<ApprovalDecision>("approval", ctx); // decided before a crash / reload?
let d = await api.memo<ApprovalDecision>("decision", ctx); // decided before a crash / reload? key = (task, "approval", "decision")
if (d === undefined) {
api.waiting("approval"); // view: turn.tools[i].waitingOn = "approval"; event tool.waiting
await api.waiting(ctx); // view: turn.tools[i].waitingOn = "approval"; event tool.waiting
const candidate = await ask({ conversationId: c.id, callId: call.id, taskId: api.taskId, name: call.name, args: call.arguments }, ctx);
d = await api.memo("approval", candidate, ctx); // first writer wins
d = await api.memo("decision", candidate, ctx); // first writer wins
}
return d.decision === "deny" ? { block: `denied${d.by ? ` by ${d.by}` : ""}` } : undefined;
},
@ -226,8 +249,10 @@ export function installApproval(h: Harness, ask: (req: ApprovalRequest, ctx: Con
No `afterTool` cleanup: the memo lives in the tool task's slot and is retired
with it. Late joiners see `waitingOn` in the snapshot. Two clients deciding at
once both get the memo winner. Abort unwinds `ask` through `ctx`; the kernel
clears `waitingOn` when the handler returns for any reason.
once both get the memo winner. Abort or suspend unwinds `ask` through `ctx`;
the kernel clears `waitingOn` when the handler returns for any reason. Asking a
human twice after a crash between `waiting` and the memo is harmless, which is
why a post-effect memo is acceptable here.
### 4.3 Custom task kinds
@ -266,10 +291,10 @@ export const indexer = defineTask<IndexInput, IndexCheckpoint, { files: number }
},
phases: {
scanning: (task, rt, ctx) => scan(task, task.checkpoint.cursor, rt, ctx),
// in-flight: only entered after a crash. The batch key is the memo that tells us whether the write landed.
// in-flight: only entered after a crash. The batch key was persisted before the effect; the write is
// repeated with the same key and is safe only because writeBatch(key) is idempotent on the receiving side.
async writing(task, rt, ctx) {
const done = await rt.commit((tx) => tx.slot(task).memos?.[task.checkpoint.batchKey], ctx);
if (done === undefined) await writeBatch(task.checkpoint.batchKey, ctx); // idempotent by key
await writeBatch(task.checkpoint.batchKey, ctx);
return { next: { phase: "scanning", cursor: task.checkpoint.batchKey } };
},
},
@ -285,11 +310,10 @@ async function scan(task, cursor, rt, ctx) {
return { status: "completed", result: { files: tx.slot(current).files } };
} };
}
await rt.commit((tx) => tx.checkpoint({ phase: "writing", batchKey: batch.key }), ctx); // before the effect
await rt.commit((tx) => tx.checkpoint({ phase: "writing", batchKey: batch.key }), ctx); // idempotency key before the effect
await writeBatch(batch.key, ctx);
return { next: (tx, current) => {
const s = tx.slot(current); s.files += batch.files.length; s.lastFile = batch.files.at(-1);
(s.memos ??= {})[batch.key] = true; // effect evidence
return { phase: "scanning", cursor: batch.key };
} };
}
@ -313,21 +337,32 @@ for (const e of view.entries) if (ci.is(e)) render(e.data.run);
```
Omit `model` for bookkeeping the model must not see. `head` and `edits` are
core-only. Placement is boundary-safe: immediately when idle, otherwise at the
core-only. `defineEntry` throws on a `pi.*` name and `c.write`/`tx.write` reject
`pi.*` at runtime for non-core callers even after a cast, with one allow-listed
exception: `pi.notice` (one user message, optional `data`, no `head`/`edits`),
which hosts, jobs and tools use for notices. No entry registry or schema is
required. Placement is boundary-safe: immediately when idle, otherwise at the
next boundary (`view-and-events.md` §6.1).
### 4.5 Namespaces
```ts
const ns = h.namespace<{ planMode: { enabled: boolean } }>("plan", { rewindable: { planMode: { enabled: false } } });
const ns = h.namespace<{ planMode: { enabled: boolean }; cache: Record<string, string> }>(
"plan",
{ rewindable: { planMode: { enabled: false } }, sticky: { cache: {} } },
{ view: (s) => ({ planMode: s.planMode }) }, // only this projection reaches the view; `cache` never does
);
await c.commit((tx) => { tx.plugins(ns).planMode.enabled = true; tx.emit(ns, "toggled", { enabled: true }); }, ctx);
// view: plugins.plan.planMode; envelope: ["s", ["plugins","plan","planMode","enabled"], true] + { type: "plugin.plan.toggled", data }
env.own(ns.unregister); // idempotent; state stays, token goes stale
```
Defaults are per document; a slice may span documents (`{ rewindable: …, sticky:
… }`) and `tx.plugins(ns)` returns the merged typed object with each key routed
to its document (same mechanism as core `config`). Re-registering with changed
defaults adds new keys lazily and leaves removed keys as inert stored data.
to its document (same mechanism as core `config`). `view` is a pure projection
the kernel re-evaluates when the slice changes; without it the namespace has no
presence in the view. Re-registering the same string with changed defaults adds
new keys lazily and leaves removed keys as inert stored data.
### 4.6 Owned conversations and jobs
@ -337,36 +372,46 @@ extend them. See `pico-handoff-v2.md` §8, §10.5 and the hardening handoff §15
## 5. Unregister, re-register, reload
Every registration can be undone and redone while the harness is open. What
happens to in-flight work:
Every registration can be undone and redone while the harness is open.
| contribution | on unregister | on re-register |
|---|---|---|
| namespace | token stale; `tx.plugins`/`tx.emit` with it reject | new token for the same `ns`; stored state untouched; new defaults seeded lazily |
| hooks | removed from chains; a **running** handler is cancelled through its registration ctx and the chain re-runs from the start with current handlers (`beforeTool`: safe, it precedes `started`; observers: skipped) | joins the chain |
| namespace (`ns.unregister()`, idempotent) | token stale; `tx.plugins`/`tx.emit`/`h.hooks(ns, …)` with it reject | new token for the same string; stored state and memos untouched; new defaults seeded lazily |
| hooks | removed from future chains; a handler already running continues to completion (it is cancelled only by abort or `suspend`) | joins the chain |
| task kind | pending tasks of the kind are not dispatched (not orphaned; orphaning is open-time only); running invocations keep the kind object they captured | pending tasks dispatch with the new implementation; config keys re-checked |
| tool / section | revision bump; in-flight invocations keep their captured declaration; preparation snapshots retry | same |
| entry kind | nothing | nothing |
Handler withdrawal is what makes a reload of the approval feature safe: the old
handler's `ask` promise can never settle once its dialog is gone, so the kernel
cancels it and re-runs `beforeTool`; the new handler asks again through the new
dialog, or finds the memo if a decision had already landed.
Reload replaces code, never durable state. Two modes, chosen by the host:
`h.hold(): () => void` defers *starting* new hook chains and new task dispatch
until released; commits, streaming and running invocations continue. The
reload sequence a host must follow:
- **Quiescent reload.** `h.quiescent()` is true when the scheduler holds **no
invocation**, running or sleeping: no phase handler, no `retrying`/`deferred`
sleep, no job poll, no waiting `beforeTool`. Live task *records* may exist
(pending, or between invocations). Then: `release = h.hold()` (defers new
dispatch and new hook chains; commits and reads continue) → deactivate old
facets (their disposals unregister) → activate new ones (re-register) →
`release()`.
- **Suspend and reopen** when not quiescent (the first safe version for active
work). `h.suspend(ctx)`: stop dispatch, cancel every in-process invocation
context, await unwind, close storage; task records stay live and nothing is
terminalized. Reopen with the new registrations and let ordinary recovery run:
pre-effect phases rerun, `started` tools replay if safe and still safe, unsafe
ones become interrupted results, other phases recover per their kind. Memos
make reruns cheap (an approval already decided is not asked again). The
injected `ProcessHost` must outlive the harness instance so running jobs
reconcile through `status(key)` instead of becoming `unknown`.
```text
release = h.hold()
deactivate old facets → their env.own() disposals unregister
activate new facets → onActivate re-registers (same ns / kind names, new tokens)
release()
```
Cancelling live calls of a tool or kind that the new generation no longer
registers is not a transparent migration: it durably aborts them, cannot undo
external effects, and post-tools observes aborted results. It is an explicit
host policy (`suspend({ abortRemoved: true })`), never the default. Generation
leases (old registrations kept alive until captured invocations drain while new
work uses replacements) are a possible later refinement; they are not required
for v1.
Registration therefore belongs in `onActivate`, not `setup`: during a
shape-preserving reload the replacement's `setup` runs before the old facet is
deactivated, and two live registrations of one namespace would collide.
Registration belongs in `onActivate`, not `setup`: during a shape-preserving
reload the replacement's `setup` runs before the old facet is deactivated, and
two live registrations of one namespace string would collide.
## 6. The Chord layer
@ -400,7 +445,7 @@ export const harnessSessionFacet = defineFacet({
await handle.open(storage, { models, processHost }, ctx);
handle.onConversation((c) => { // existing at open, and every fork/subagent created later
const view = env.replicatedState(c.snapshot()); // built on the line
c.attachView(view); // kernel mutates view.state and calls view.publish(ctx) on the line after each commit
c.attachView(view); // bounded ordered adapter: raw envelopes → view.state + publish(), off the line (below)
env.own(conversations.spawn(String(c.id), { view, send: (i, cx) => c.send(i, cx).then((r) => r.id), /* … */ }));
});
});
@ -409,11 +454,23 @@ export const harnessSessionFacet = defineFacet({
});
```
`ConversationView` gains `commit: { seq, events }` so events ride inside the
replicated member (one Chord sequence = one commit); a subscriber reads
`value.commit.events` after each delivery. Chord's subscription snapshot gives
late joiners the member at its current sequence, so §9 of `view-and-events.md`
is implemented by Chord, not by us.
`ConversationView` gains `commit: { events }` so events ride inside the
replicated member; a subscriber reads `value.commit.events` after each delivery.
No revision or storage sequence is placed in the view: Chord's
`ReplicatedStateDelivery.sequence` is contiguous per member and plays that role.
Publication must not happen on the Session line. Chord's `publish()` invokes
source and subscriber listeners synchronously and a subscriber may throw or be
slow, and `subscribe()` itself publishes pending mutations. The bridge is a
bounded ordered adapter: the kernel enqueues raw envelopes from its own view
tracker; the adapter, off the line, applies one envelope's ops to
`view.state`, sets `commit.events`, and calls `publish(ctx)` in the **same
synchronous block** (no await between apply and publish). One raw envelope =
one publish = one Chord sequence. If the queue overflows or `publish` throws,
close that keyed instance and respawn it with a fresh snapshot; a persisted Pico
commit is never turned into a failure. Chord's subscription snapshot then gives
late joiners the member at its current sequence, so §9 of
`view-and-events.md` is implemented by Chord, not by us.
### 6.2 A feature: contract + session facet + presentation facet
@ -493,31 +550,46 @@ export const indexerSessionFacet = defineFacet({
### 6.3 What the session worker does at reload
```text
release = harness.hold()
FacetHost.reload(candidates) // old facets' env.own() disposals unregister; new onActivate re-registers
release()
if (harness.quiescent()) {
release = harness.hold()
FacetHost.reload(candidates) // old facets' env.own() disposals unregister; new onActivate re-registers
release()
} else {
await harness.suspend(ctx) // cancel invocations, close storage; task records stay live
FacetHost.reload(candidates)
await harness.reopen(ctx) // recovery runs against the new registrations
}
```
Nothing else. Durable state, running tasks and open conversations are
untouched; withdrawn hook handlers are re-run per §5.
Durable state, task records and open conversations are untouched either way
(§5).
## 7. Tests the hardening must include
- Memo: two concurrent `api.memo` writers receive the same winner; a crash after
the memo skips `ask`; before it re-asks; the memo is gone after the tool task
terminalizes.
- Hooks: `BeforeToolApi.waiting` sets/clears `waitingOn` (also on throw and abort);
withdrawal cancels a waiting `beforeTool` and re-runs the chain; the decision
memo survives a crash between `waiting` and `started`.
- Custom kind: crash in the in-flight phase re-enters it; batch memo prevents a
duplicate write; `describe()` output appears in `tasks[id].status`;
- Memo: two concurrent `api.memo` writers receive the same winner; `null` is a
stored value (hasOwn, not `??=`); a crash after the memo skips `ask`, before it
re-asks; the memo is gone after the tool task terminalizes; two namespaces
using the same memo name on one task do not collide.
- Hooks: `await api.waiting(ctx)` sets `waitingOn` in its own commit and it is
cleared atomically with `started`, with the synthetic result on block/throw,
and in its own commit on abort/suspend; the decision memo survives a crash
between `waiting` and `started`; memos never appear in the view.
- Custom kind: crash in the in-flight phase re-enters it and repeats the keyed
effect (test with a fake idempotent sink and a crash between effect and the
next checkpoint); `describe()` output appears in `tasks[id].status`;
unregistered kind leaves pending tasks pending and orphans only at open.
- Namespaces: writes outside `plugins[ns]` reject; stale token rejects;
re-register keeps values and seeds new defaults; rewindable slice is inherited
by a fork and readable at an entry.
- Reload: `hold()` defers dispatch and hook chains but not commits; the
hold/reload/release sequence with the approval facet lets a pending call be
approved through the new generation.
`unregister` is idempotent; re-register keeps values and memos and seeds new
defaults; only the `view` projection appears in the view; rewindable slice is
inherited by a fork and readable at an entry.
- Entries: `defineEntry("pi.x")` throws; `write` of any `pi.*` except `pi.notice`
rejects at runtime after a cast; `pi.notice` with `head`/`edits` rejects.
- Reload: `quiescent()` is false while any invocation exists (including a
waiting `beforeTool` and a polling job); `hold()` defers dispatch and hook
chains but not commits; suspend/reopen with the approval facet lets a pending
call be approved through the new generation without asking twice when a
memo exists; `ProcessHost` survives suspend/reopen and a running job
reconciles.
- Chord: loopback facet tests for approval and question (session + presentation
fixtures), late-joiner hydration of an open dialog instance, and
`ConversationService.view` sequence equals commit sequence.

View file

@ -10,12 +10,15 @@ Companion: `plugins.md` (harness extension surface, durability, Chord facets).
1. **One snapshot, then one envelope per commit.** A watch captures the view and
subscribes in one line operation; every later commit that touches the
conversation produces exactly one envelope, delivered in commit-`Seq` order.
No cursor, replay or acknowledgement: a gap means "open a fresh watch".
conversation produces exactly one envelope, delivered in commit order with a
contiguous per-watch `revision`. No cursor, replay or acknowledgement: a gap
means "open a fresh watch".
2. **Ops are the truth, events are annotations.** The envelope carries Chord ops
that transform the view document, and named events the kernel attaches because
it produced those ops and knows why. A client that folds only ops is never
wrong. A client that consumes only events never scans the view.
it produced those ops and knows why. Ops fully determine the replica: a client
that folds only ops is never wrong. Events annotate cause and outcome and may
carry transient facts the view deliberately does not retain; a client that
consumes only events never scans the view.
3. **Path = event.** Every field of the view has a stable path, so an op on that
path is a typed notification. Nothing in the view is ever discovered by
diffing.
@ -63,7 +66,7 @@ interface ConversationView {
/** Non-turn live tasks (jobs, plugin kinds), described by their kind. Removed on terminal. */
tasks: { [taskId: string]: { kind: string; background?: true; marked?: true; status: JsonValue } };
/** Plugin state slices, by namespace. */
/** Plugin presentation slices, by namespace: the namespace's declared `view` projection, never raw state. */
plugins: { [namespace: string]: JsonValue };
}
@ -75,6 +78,7 @@ interface TurnView {
}
type GenerationStatus =
| { stage: "waiting"; on: "compaction" } // replacement generation pending behind a collapse
| { stage: "preparing" } // before `prepared`
| { stage: "requesting"; attempt: number } // request sent, no frame yet
| { stage: "streaming"; attempt: number } // first frame applied
@ -84,13 +88,13 @@ type GenerationStatus =
interface ToolSlot {
callId: string; name: string; args: JsonValue;
status: "pending" | "running" | "done" | "error" | "aborted";
waitingOn?: string; // set by the kernel while a beforeTool handler called BeforeToolApi.waiting(on); cleared when it returns
waitingOn?: string; // namespace of the beforeTool handler that awaited BeforeToolApi.waiting(ctx); cleared when it returns
output?: string; // bounded stream so far (api.stream)
progress?: string; details?: JsonValue;
continuedBy?: Id; // background task that took over; see tasks[continuedBy]
entry?: Id; // the pi.tool_result entry once landed
memos?: { [name: string]: JsonValue };// tool/hook memos (plugins.md §2); retired with the task
}
// Memos and other slot working state are never in the view (plugins.md §2); only the fields above are rendered.
```
### 2.1 Active transcript
@ -120,16 +124,25 @@ interface ToolSlot {
```ts
interface Envelope {
seq: Seq; // commit sequence; strictly increasing per watch
revision: number; // contiguous per watch: snapshot.revision + 1, +2, …; storage Seq is not exposed
ops: Op[]; // Chord ops over ConversationView (§4)
events: ViewEvent[]; // named annotations for this commit (§5); may be empty
}
```
Delivery: folded into the kernel's view tracker on the line, listener called off
the line, one envelope per commit that changed anything visible to this
conversation. A listener throw closes that watch and reports through `onError`.
`stop` is idempotent; nothing is delivered after `stop`.
`revision` is per watch, not the storage commit sequence: a conversation watch
sees only commits that touch its conversation, so the global `Seq` would have
legitimate gaps. Storage `Seq` stays internal unless a public commit-correlation
use appears.
Delivery: folded into the kernel's view tracker on the line; the listener is
invoked **synchronously, in order, off the line**, one envelope per commit that
changed anything visible to this conversation. Envelopes arriving before
`start()` are buffered (bounded; overflow closes the watch). Asynchronous
consumers (the Chord bridge, application queues) own their own queue and
backpressure policy (`plugins.md` §6.1). A listener throw closes that watch and
reports through `onError`. `stop` is idempotent; nothing is delivered after
`stop`.
Base flush: after the kernel rebases the view tracker (reopen of a cold watch is a
fresh snapshot instead), an envelope may carry `["r", view]` as its only op.
@ -146,7 +159,7 @@ delete, `["a", path, str]` string append, `["t", path, n]` string truncate,
|---|---|---|
| `["entries"]` | `["p", ["entries"], n, 0, [e…]]` | entries appended (always at the end) |
| `["entries"]` | `["p", ["entries"], 0, k, []]` then `["p", …, n, 0, [head]]` | new head: transcript truncated, head entry appended |
| `["config", key]` | `s` / `d` | config changed / reset to default |
| `["config", key]` | `s` / `d` | config changed; `config.reset(keys)` sets the declared default (only `model`, which has none, is deleted) |
| `["inbox"]` | `p` | queued, placed (removed), aborted (removed) |
| `["turn"]` | `["s", ["turn"], {…}]` / `["d", ["turn"]]` | input group started / ended |
| `["turn","generation"]` | `s` / `d` | generation stage changes / generation ended |
@ -155,14 +168,13 @@ delete, `["a", path, str]` string append, `["t", path, n]` string truncate,
| `["turn","message","content",i,"arguments"]` | `s` | tool-call arguments materialized at checkpoint/end |
| `["turn","tools"]` | `s` (whole array, with the assistant entry) | tool slots created for a new assistant message |
| `["turn","tools",i,"status"]` | `s` | pending → running → done/error/aborted |
| `["turn","tools",i,"waitingOn"]` | `s` / `d` | a `beforeTool` handler is waiting on a human/service (approval, question); cleared when it returns |
| `["turn","tools",i,"memos",name]` | `s` | a memo was recorded (`api.memo` / hook memo) |
| `["turn","tools",i,"waitingOn"]` | `s` / `d` | a `beforeTool` handler (namespace = value) is waiting on a human/service; cleared when it returns |
| `["turn","tools",i,"output"]` | `s` | bounded stream flush (whole string; retention may drop the head) |
| `["turn","tools",i,"progress"]`, `[…,"details"]`, `[…,"continuedBy"]` | `s` | `api.progress` |
| `["compaction"]` | `s` / `d` | collapse started / stage changed / ended |
| `["tasks", id]` | `s` / `d` | non-turn task appeared / status changed / terminal |
| `["tasks", id, "status", …]` | any | kind-described status detail (jobs: stdout, exitCode, …) |
| `["plugins", ns, …]` | any | plugin state |
| `["plugins", ns, …]` | any | the namespace's `view` projection |
Rule for kernel authors: mutate the tracked view through its proxy so the
tracker records minimal ops; never rebuild subtrees. Streaming applies each
@ -171,9 +183,12 @@ frame to `turn.message` in place (same reducer as pi-ai's frame reducer).
## 5. Events
Events are emitted by core code inside the commit that causes them
(`tx.emit(event)`, core authority). Plugin kinds may emit
`{ type: "plugin.<ns>.<name>", … }` from their own commits. Every event is
reconstructible from the same envelope's ops; the reverse is not required.
(`tx.emit(event)`, core authority). Extensions emit `{ type: "plugin.<ns>.<name>", … }`
through their namespace token from their own commits. Rule: ops fully determine
the replica; events annotate the cause and outcome of the commit and may carry
transient facts the view does not retain (`turn.ended.reason`,
`task.ended.outcome`, `compaction.failed`, `warning`). An event must never be the
only place a late joiner could learn something needed to render steady state.
```ts
type ViewEvent =
@ -194,7 +209,7 @@ type ViewEvent =
| { type: "generation.completed"; taskId: Id; entry: Id; toolCalls: number }
| { type: "generation.failed"; taskId: Id; reason: "provider" | "overflow" | "retries_exhausted" | "no_model"; detail: string; entry?: Id }
// tools
| { type: "tool.waiting"; taskId: Id; callId: string; on: string } // a beforeTool handler is waiting (approval, question, …)
| { type: "tool.waiting"; taskId: Id; callId: string; on: string } // a beforeTool handler is waiting; `on` is its namespace
| { type: "tool.started"; taskId: Id; callId: string; name: string }
| { type: "tool.finished"; taskId: Id; callId: string; entry: Id; isError: boolean; control?: ToolControl }
| { type: "tool.aborted"; taskId: Id; callId: string; entry: Id }
@ -212,7 +227,7 @@ type ViewEvent =
| { type: `plugin.${string}`; data: JsonValue };
```
`warning` is the one event with no state counterpart; it exists for exactly the
`warning` has no state counterpart at all; it exists for exactly the
occurrences Codex models as `warning` / `error{willRetry}` notifications.
## 6. Commit-by-commit mapping
@ -245,18 +260,19 @@ Ops are abbreviated; every row is one atomic envelope.
| final answer, no triggers, no continuation (closure) | `entries +pi.assistant`; `turn` deleted; boundary placements (`inbox` splices, `entries +writes`) | `entry.added`, `generation.completed`, `turn.ended{done, answer}`, `input.placed`/`entry.added` for placed writes |
| final answer, `onYield` continuation | `entries +pi.assistant`, `entries +pi.user{continuation}`; `turn.generation = { stage: "preparing" }` | `entry.added`×2, `generation.completed`; **no** `turn.ended` (same group continues) |
| final answer with queued followUp/steer triggers | as "no triggers", then `entries +pi.user`, `turn = { inputs: triggers, tools: [] }`, `turn.generation = { stage:"preparing" }` | `turn.ended{done}`, `input.placed`, `entry.added`, `turn.started` |
| terminal error / aborted-by-provider (closure) | `entries +pi.assistant` (display-only; `model` absent, message in `data`); `turn` deleted | `entry.added`, `generation.failed{provider|retries_exhausted, entry}`, `turn.ended{unanswered, failed}` |
| `no_model`, overflow with nothing collapsible | `turn` deleted; overflow: `entries +pi.notice` (placed immediately: the closing task counts as gone) | `generation.failed`, `turn.ended{unanswered, failed}`, `entry.added` |
| overflow with collapse (closure) | `compaction = { reason: "overflow", … }`; `turn.generation = { stage: "preparing" }` (replacement created `after` collapse) | `generation.failed{overflow}`, `compaction.started`; turn continues |
| terminal error / aborted-by-provider (closure) | `entries +pi.assistant` (display-only; `model` absent, message in `data`); group resolved `unanswered/failed`; **final boundary runs**: placed writes, selected triggers; `turn` deleted, or replaced by a successor turn for triggers | `entry.added`, `generation.failed{provider|retries_exhausted, entry}`, `turn.ended{unanswered, failed}`, then `input.placed`/`entry.added`/`turn.started` as in "with triggers" |
| `no_model`, overflow with nothing collapsible | as above; overflow additionally `entries +pi.notice` (placed immediately: the closing task counts as gone). `no_model` is not special-cased: a successor for queued triggers fails fast and resolves its own group | `generation.failed`, `turn.ended{unanswered, failed}`, `entry.added`, … |
| overflow with collapse (closure) | `compaction = { reason: "overflow", … }`; `turn.generation = { stage: "waiting", on: "compaction" }` (replacement created `after` collapse) | `generation.failed{overflow}`, `compaction.started`; turn continues |
| abort (fresh abort closure) | optional `entries +pi.assistant` (display-only partial, only if content was streamed); `turn` deleted | (`entry.added`), `turn.ended{unanswered, aborted}` |
### 6.3 Tool
| moment | ops | events |
|---|---|---|
| `beforeTool` handler calls `api.waiting(on)` (`BeforeToolApi`, `plugins.md` §4.2) | `turn.tools[i].waitingOn = on` | `tool.waiting{callId, on}` |
| handler returns / is withdrawn | `turn.tools[i].waitingOn` deleted | none |
| `started` checkpoint (after `beforeTool`, before invocation) | `turn.tools[i].status = "running"` | `tool.started` |
| `beforeTool` handler awaits `api.waiting(ctx)` (`BeforeToolApi`, `plugins.md` §4.2) | `turn.tools[i].waitingOn = ns` (its own commit) | `tool.waiting{callId, on: ns}` |
| `started` checkpoint (after `beforeTool` allowed, before invocation) | `turn.tools[i].waitingOn` deleted; `turn.tools[i].status = "running"` (one commit) | `tool.started` |
| handler blocks/throws | `waitingOn` deleted atomically with the synthetic error result below | |
| handler unwinds on abort/suspend | `waitingOn` deleted in its own commit | none |
| stream flush (≤ every 100 ms) | `turn.tools[i].output = text` | none |
| `api.progress` | `turn.tools[i].{progress,details,continuedBy}` | none |
| result (closure) | `entries +pi.tool_result`; `turn.tools[i].status = done|error`; `turn.tools[i].entry` | `entry.added`, `tool.finished{isError, control}`, (`warning` if truncated) |
@ -287,8 +303,13 @@ Ops are abbreviated; every row is one atomic envelope.
| hook-supplied summary | same as "summary lands" without a provider stage | same |
Manual collapse (`c.collapse`) is `background`; it appears in `compaction`
exactly the same way. `NothingToCollapse` rejects the call and produces no
envelope.
exactly the same way. At most one live collapse and exactly one live generation
per conversation are core invariants (rejected before persistence, never
generalized to maps in the view): a manual collapse while one is live rejects
`CollapseInProgress`; a threshold trigger while one is live is skipped;
`NothingToCollapse` rejects the call and produces no envelope. An overflow
replacement generation waits behind its collapse as `turn.generation = { stage:
"waiting", on: "compaction" }`.
### 6.6 Jobs and plugin kinds
@ -390,11 +411,11 @@ own ops. Project image blobs out of `entries[].model` there, not in the kernel.
document through its proxy, `flush()`es once, attaches the commit's events, and
hands the same `Envelope` to every watcher. Fan-out cost is per conversation;
the kernel never knows how many clients exist or what they render.
- `watch(ctx)` returns `{ view, start, stop }` where `view` is a
`structuredClone` of the tracker target taken **on the line**, tagged with the
current commit `seq`, and the subscription is installed in the same line
operation. The first envelope a watcher receives is therefore exactly
`view.seq + 1`; there is no window in which a commit can land between snapshot
- `watch(ctx)` returns `{ view, revision, start, stop }` where `view` is a
`structuredClone` of the tracker target taken **on the line**, `revision` is
this watch's starting revision, and the subscription is installed in the same
line operation. The first envelope a watcher receives is therefore exactly
`revision + 1`; there is no window in which a commit can land between snapshot
and subscription.
### 9.2 Late joiners
@ -407,7 +428,7 @@ in progress is state and is in the snapshot.
| assistant message streaming | `turn.message` (partial), `turn.generation.stage === "streaming"` |
| provider backoff | `turn.generation = { stage: "retrying", retryAt, lastError }` |
| tool running | `turn.tools[i].status === "running"`, `output` so far |
| tool waiting for approval / a question | `turn.tools[i].waitingOn` (+ the plugin's own Chord service instance, `plugins.md` §6) |
| tool waiting for approval / a question | `turn.tools[i].waitingOn === ns` (+ the plugin's own Chord service instance, `plugins.md` §6) |
| compaction | `compaction` |
| background job | `tasks[id].status` |
| queued follow-ups | `inbox` |
@ -418,10 +439,12 @@ and still cannot show "retrying".
### 9.3 Reconnect
A client keeps `lastSeq`. If an envelope arrives with `seq !== lastSeq + 1`
(only possible on a transport drop between a Chord RPC service and its client),
the client discards its replica and opens a fresh watch. Nothing is
client-specific, so there is nothing to resume, acknowledge or replay.
A client keeps `lastRevision`. If an envelope arrives with
`revision !== lastRevision + 1` (only possible on a transport drop between a
Chord RPC service and its client; in that transport Chord's own
`ReplicatedStateDelivery.sequence` plays this role), the client discards its
replica and opens a fresh watch. Nothing is client-specific, so there is nothing
to resume, acknowledge or replay.
### 9.4 Concurrent decisions
@ -447,9 +470,12 @@ namespaces), where its durable state lives, how it appears in this view, and how
it is packaged as Chord facets is specified in `plugins.md`. The view-facing
rules are:
- extension state appears only as `plugins[ns]` (namespace slices),
`tasks[id].status` (kind-described), `turn.tools[i].{waitingOn,memos}` and
`tasks[id].memos` (task slots), and entries;
- an extension's presentation is only what it projects: the namespace's
declared `view` projection under `plugins[ns]` (nothing by default), the kind's
`describe()` output under `tasks[id].status`, the core-rendered tool slot fields
(`status`, `waitingOn`, `output`, `progress`, `details`, `continuedBy`, `entry`),
and entries. Raw namespace state, memos and other slot working state never
appear;
- extension events are `plugin.<ns>.*`, emitted with the namespace token from the
extension's own commits; core events remain core-only;
- nothing about Chord services is visible in the view. A keyed service instance
@ -468,8 +494,14 @@ rules are:
records by kind-owned `describe(task)`; the raw records never leave the kernel.
- Display-only assistant entries carry the message in `data.display` and no
`model`, so context derivation needs no special case.
- Envelope assembly happens on the line; listener dispatch off the line; a
listener throw closes that watch only.
- Envelope assembly happens on the line; listener dispatch off the line,
synchronous and in order; a listener throw closes that watch only.
- Chord bridge (`plugins.md` §6.1): a bounded ordered adapter applies one raw
envelope's ops to the Chord `MutableReplicatedState` and calls `publish()` in
the same synchronous block (Chord's `subscribe()` publishes pending mutations
itself, so apply and publish must not be separated by an await); overflow or
publish failure closes and respawns that instance; a persisted commit is never
failed by a listener.
- Tests: for every row in §6, assert the exact ops and events of that commit
(memory backend, fake provider), plus snapshot-equals-fold after every
envelope and a head-in-parent capture case.
@ -477,6 +509,8 @@ rules are:
## 12. Open items
- `warning` payload shape (source enum vs free string).
- Application-level backpressure for slow asynchronous consumers is TBD; the
raw watch itself is not (synchronous listener, bounded pre-start buffer).
- Whether `turn.tools` should keep finished slots of the previous assistant
message when the continuation generation prepares (current: cleared at
`prepared`; Codex keeps all items of the turn). Recommendation: keep them, keyed