pi-mono/packages/agent/docs/pico-v3.md
2026-09-18 13:48:19 +02:00

2113 lines
87 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# pico v3
Design under discussion. Code and traces illustrate the proposed behavior; they are not existing
package exports.
## 1. Goals and assumptions
### 1.1 Small concepts, replaceable behavior
Run agents using conversations, immutable entries, durable tasks, and scoped values/lists.
Built-in agent behavior is composed from tasks, not embedded in a fixed scheduler state machine.
```text
┌─ tool A ─┐
generation ──────────┼─ tool B ─┼─ post_tools ─ generation
└─ tool C ─┘
replace a task definition
→ change that behavior
→ keep the scheduler and storage
```
The scheduler understands task lifecycle, dependencies, timing and cancellation. It does not
understand prompts, tool arguments or summaries. Storage understands stored objects and atomic
mutations, not task behavior.
### 1.2 One writer per session
Concurrent execution is allowed. Concurrent independent writers to the same session are not.
```text
caller ───────┐
tool ─────────┼─ session owner ─ serialized commands ─ storage
background ──┘
one command:
read committed state
construct mutations and assign numeric IDs
commit the complete batch
publish committed changes
```
All mutations, including working-state writes, pass through that owner. IDs are unique within the
session and are persisted unchanged. Uncommitted IDs cannot escape to callers or external effects.
Different sessions may have different owners and share a SQLite database. SQLite still serializes
its write transactions. After a crash, a replacement owner resumes from durable state; it must not
run alongside the previous owner.
### 1.3 Durable work, explicit recovery
Durable storage preserves work that has been accepted, including work that has not started yet.
Opening a session starts no task effects.
```text
commit pending task
→ crash
→ open: task is still pending
→ drive: execute it
commit external-effect intent
→ perform external effect
→ commit outcome and required successor tasks together
```
A crash during an external effect leaves uncertainty, not proof that nothing happened. Each task
definition must decide how to recover: retry safely, adopt existing work, or report interruption.
Durability does not promise exactly-once external effects.
```text
close → stop local execution; leave unfinished durable tasks resumable
abort → durably cancel selected work; drive its cancellation to settlement
```
### 1.4 Long-lived sessions without residency machinery
Old transcript entries and terminal tasks remain queryable. The harness should not retain them
merely because it encountered them earlier.
```text
check a dependency:
read the named task
inspect its terminal status
return
// no harness-owned reference needs to survive the check
```
Active executions naturally retain their inputs and working data. Other query results are ordinary
local variables. No pin/unpin API, residency manager, or sweep that maintains a second resident
model. Routine execution should depend on current work and context, not scan accumulated history.
Backend memory is a separate choice:
| Backend | Storage-owned memory | Persistence |
|---|---|---|
| Memory | All stored data and indexes | None; useful for tests |
| JSONL | All stored data and indexes, loaded on open | Main file plus working sidecars |
| SQLite | Read results and database caches; no required full-history JS copy | Database |
JSONL loading everything is intentional, not a violation of the working-set goal. Choose SQLite
when minimizing resident process memory matters. No fixed session-length limit is imposed; storage
capacity, numeric sequence limits and the amount of simultaneously live work remain real limits.
### 1.5 Efficiency must be demonstrated
```text
same faux-provider workload on each harness/backend
→ CPU time
→ total process memory
→ actual disk bytes, including working data and auxiliary files
```
Prefer small atomic writes, indexed queries and batched reads. Do not turn a point lookup into a
history scan or reload every object to answer a small question. Measure hot paths before adding
caches or specialized machinery. A faster spike is evidence to investigate, not proof that its
interfaces or correctness shortcuts belong in this design.
## 2. Conversations
A conversation has a linear transcript of immutable entries and a context list selecting entries
for the model. Tasks and state are associated with it, but do not implicitly become model input.
### 2.1 Entries and the transcript
Minimal shapes; `JsonValue` means strict JSON. Historical-position and ownership metadata are
introduced separately.
```ts
interface Entry {
readonly id: number;
readonly conversationId: number;
readonly kind: string;
readonly content: JsonValue;
}
interface Conversation {
readonly id: number;
readonly context: readonly number[];
}
```
These are read snapshots. Changing context produces a new conversation snapshot; an existing
entry's content never changes.
```text
conversation A
entry ID kind content
10 user "Inspect the parser"
20 assistant "The parser has two problems ..."
30 user "Explain the first one"
40 assistant "The first problem is ..."
transcript: [10, 20, 30, 40]
```
- New entries append; existing entries are never patched, reordered or renumbered.
- IDs increase in transcript order, but need not be consecutive numbers.
- A note can belong to the transcript without belonging to model context.
### 2.2 Context selects; projection converts
```text
transcript context IDs provider messages
what happened what is selected what the model receives
[10, 20, 30, 40] ──────────> [10, 20, 30, 40] ──────> user, assistant, user, assistant
projection
```
The context stores IDs, not copied messages or a second transcript.
```text
entryById = readEntries(conversation.context) // batched read
messages = project selected entries in context order
normalize tool-result groups into their call order
```
An entry's kind defines its message contribution. A summary can project as a user message; a
selected custom entry can project nothing. Projection reads content and returns messages. It
writes no entries, state or tasks.
Tool completion order need not equal call order:
```text
assistant calls: [A, B]
transcript: assistant → result B → result A
model messages: assistant → result A → result B
```
The transcript remains unchanged by that normalization.
### 2.3 Three context operations
```text
appendContext(ids)
extend with newly appended selected entries, in transcript order
replaceContextPrefix(through, replacement)
replace the prefix ending at through; preserve the existing suffix
resetContext(ids)
replace the whole context with an empty or bootstrap context
```
Appending selected content and appending its context ID happen in the same commit. Context-only
changes do not create generation tasks.
**Compaction:** append a summary entry, then replace a prefix in the same commit.
```text
before:
transcript [10, 20, 30, 40]
context [10, 20, 30, 40]
commit:
append summary 50 describing the prefix through 20
replaceContextPrefix(through=20, replacement=50)
after:
transcript [10, 20, 30, 40, 50]
context [50, 30, 40]
```
Summary 50 is newer than entries 30 and 40, but appears before them in model context. The entries
it summarizes remain in the transcript. Repeated compaction summarizes the previous summary too,
not an ever-growing chain of summary heads.
**Reset:** discard the selected context, not the transcript.
```text
commit:
append handoff entry 70
resetContext([70])
after:
transcript [10, 20, 30, 40, 50, 70]
context [70]
resetContext([])
→ empty model context
→ same transcript
```
Baseline constraints:
```text
context shape: optional summary/handoff head + selected entries in transcript order
IDs: existing entries in this conversation or its inherited prefix; no duplicates
replacement: a contiguous PREFIX of the current context, not a consecutive numeric ID range
exchange: replacement/reset must not split an assistant's tool-call/result exchange
concurrency: no replacement/reset during a provider request or unresolved tool exchange
```
A summary prepared against an old prefix may still replace it after later appends. A competing
replacement/reset invalidates that prepared prefix. The commit must check this before publishing.
These operations are not an arbitrary list editor: no middle deletion or unrestricted reordering.
Reset accepts an empty context or a bootstrap head, not an arbitrary permutation of old entries.
Reset alone neither cancels existing tasks nor requests a new model response.
### 2.4 Forks share history, not future changes
A historical fork creates a new conversation at a selected historical position. Its transcript
shares the source prefix; new entries belong to the new conversation.
```text
A transcript: 10 ─ 20 ─ 30 ─ 40 ─ 50 ─ 70
│
└────────────── 90 ─ 100 B
B transcript: [10, 20, 90, 100]
shared local
```
Entries 10 and 20 retain their original IDs and owning conversation. There are no copied entry
rows for them. Each conversation still presents a linear transcript.
The fork inherits the context that existed at its selected position, not today's context truncated
by entry ID:
```text
P = historical position after the first exchange
A context at P: [10, 20]
A context now: [70]
create B from A at P:
B context starts as [10, 20]
NOT [] obtained by filtering today's [70]
```
B has its own context list. Later appends, compaction and resets in A cannot change B. Live tasks
are not inherited; continuing B requires explicit new work.
Here P is a historical position already selected by the caller. §3 defines how selecting a
transcript entry resolves to that position, including the entry-versus-commit distinction. State
initialization and historical inheritance are defined in §4.
### 2.5 Fork history and task ownership are different relationships
```text
historical relationship:
conversation A ── fork at P ──> conversation B
execution ownership:
conversation A ── task T ── owns ──> conversation C
```
B is an independent fork. C is an owned child, such as a subagent conversation. A child can start
with fresh or inherited context; that choice is separate from which state gets initialized.
```text
historical source → where inherited content/state came from
owning task → which execution owns this conversation
```
A historical relationship alone does not pull B into A's drive or cancellation scope. Ownership
is explicit; scheduling and cancellation follow the task relationships defined in §§5–6.
## 3. Identity, commits and historical positions
One session-global sequence orders mutations. A creation mutation's sequence is the new object's
numeric ID. A complete commit, not an individual mutation, is a historical snapshot boundary.
### 3.1 Allocate while constructing the command
```text
last committed sequence: 99
build command:
100 append user entry → entry ID 100
101 append context ID 100
102 set planMode = true
103 create generation task → task ID 103
persist ONE batch [100–103]
publish the committed changes
return entry ID 100 to caller
```
The command can reference IDs it just created. It cannot call external services with them or
return them to callers before persistence succeeds. Storage preserves the supplied numbers.
```text
command A constructs [100–103] ─ commits ─ command B starts at 104
command A rejects ─ no write ─ command B starts at 100
```
Numbers are positive safe integers; exhaustion rejects. There are no UUID columns on ordinary
entries, conversations or tasks, and no separately reserved ID ranges.
### 3.2 Commit boundaries are part of the read result
Extend the entry snapshot from §2 with its creation commit's end:
```ts
interface Entry {
readonly commitEnd: number;
}
```
The end is known once construction finishes. The caller does not need to enumerate the other
mutations in the commit.
```text
getEntry(100)
→ { id:100, conversationId:1, commitEnd:103, ... }
getConversation(1, asOf=103)
→ context as of that boundary
getValue(conversation=1, planMode, asOf=103)
→ true
```
```text
Memory entry metadata contains commitEnd=103
JSONL commit envelope contains [100,103]; replay derives the entry metadata
SQLite entry row contains commit_end=103, inserted in the same SQL transaction
```
Task creation at 103 belongs to the source's durable work. It does not mean a fork inherits that
task. Context and rewindable state are historical; task execution is not rewound.
### 3.3 Proposed fork contract: after the containing commit
```text
fork(A, atEntry=100)
→ resolve entry 100 to commitEnd 103
→ fork A's transcript/context/rewindable state through 103
→ store source A and boundary 103
→ inherit no live tasks
```
This deliberately means **after the commit containing the selected entry**, not an intermediate
state after only mutation 100. No second filter truncates context to IDs <= 100.
```text
commit [200–203]:
200 append entry X
201 append entry Y
202 append context IDs [200,201]
203 set value V
fork at X ─┐
├─ same snapshot: includes X, Y and V
fork at Y ─┘
```
This is a proposed semantic choice to review, not merely an indexing trick. A UI must not imply
that X and Y select different snapshots. Strict entry-prefix forks would need a different rule
for associating context/state changes with entries; they must not silently mix a truncated
transcript with later state from the same commit.
A caller can also select a complete main-commit boundary directly:
```text
commit [204]: set V = 2 // no transcript entry
fork(A, asOf=204) // selects this state-only change
```
Entry and explicit-boundary targets use the same historical query path. A position inside a
commit rejects. A fork requiring usable model context must also select a complete tool exchange;
reject an incomplete exchange rather than silently moving to a different historical position.
### 3.4 Sequence gaps are normal
Working writes use the same allocator but do not become retained conversation history.
```text
100–103 main commit
104–109 working progress
110–112 main settlement, including retirement of that working scope
retained main history: 100,101,102,103,110,111,112
next sequence: 113
```
Never use an array offset as the durable identity. A deleted working file does not free its IDs.
Working commits are not fork targets. The backend preserves the global high-water mark even when
working contents are retired; main historical boundaries remain independently identifiable.
### 3.5 Failure before or after admission
```text
construction/validation rejection:
no durable writes; no IDs consumed; session remains usable
persistence outcome uncertain:
stop using this session handle
reopen storage
recover the last complete durable batch and its high-water mark
continue from recovered state, not the old process's guess
```
No observer may see half a commit. No effect may treat an uncertain write failure as permission
to retry the same external action with newly allocated IDs.
### 3.6 Caller-provided identities use ordinary state
External request keys are optional session-scoped values, not alternate internal IDs.
```text
accept(input, requestId="client-42"), ONE command:
read session value (pi.request-input, "client-42")
if present: resolve the existing acceptance
otherwise:
create accepted input and its required task/queue changes
set session value (pi.request-input, "client-42") = input ID
commit succeeds, reply is lost
→ caller retries "client-42"
→ same accepted input ID, no second input
```
Lookup and insertion happen inside the serialized command. Core owns these mappings; normal
callers cannot overwrite them. Conflicting key reuse must reject rather than silently accept a
different request; the exact input-equivalence check belongs with acceptance in §8. Mapping a key
to an input does not answer which eventual result belongs to that input.
## 4. State
State is addressed independently of transcript content. Its mutations share the commit sequence,
so a historical boundary selects context and rewindable state together.
### 4.1 Scope and rewind behavior
| Address scope | Rewind behavior | Lifetime |
|---|---|---|
| Session | Sticky; one current value/list | Session |
| Conversation | Rewindable or sticky | Conversation and required fork history |
| Named working scope | Never rewound or inherited | Until explicit retirement |
```ts
const sessionName = sessionValue<string>("pi.session.name");
const plan = conversationValue<boolean>("plugin.plan", { rewind: true });
const expanded = conversationValue<boolean>("ui.expanded", { rewind: false });
const moves = conversationList<Move>("game.moves", { rewind: true });
await conversation.setValue(plan, true);
const enabled = await conversation.getValue(plan) ?? false;
await conversation.appendList(moves, move);
```
Addresses bind namespace, optional key, scope and rewind policy. Payload types come from the
address; writes must not infer a different type to accommodate the supplied value.
```ts
// Value<T> and ValueList<T> denote typed addresses, not wrappers around current data.
interface StateWrites {
setValue<T>(address: Value<T>, value: NoInfer<T>): Promise<void>;
appendList<T>(address: ValueList<T>, value: NoInfer<T>): Promise<number>;
}
```
The final interfaces also distinguish address scopes. A conversation address cannot be used as a
session address. Constructors require no global registration; defaults belong at the read site.
Storage persists scope/rewind information rather than depending on token objects surviving restart.
All persisted payloads are strict JSON; callers cannot mutate stored data through returned aliases.
### 4.2 Small mutation vocabulary
```text
value: set(value), delete()
list: append(value), clear()
read: getValue(address), readList(address, cursor, limit)
```
An append gets a numeric element ID from its mutation sequence. Baseline lists preserve append
order; they do not support replacing individual elements, inserting in the middle or moving them.
Structured editing uses delta values, as illustrated in §4.5.
```text
rewindable list:
40 append A
50 append B
60 clear
70 append C
read through 50 → [A, B]
read through 60 → []
read through 70 → [C]
```
Clear hides older elements at and after its position; it does not erase history required by earlier
forks. Deleting a rewindable scalar similarly records absence. JSON null remains a stored value.
Session/sticky state exposes only its current contents. JSONL may still contain previous mutations;
the harness does not interpret them as rewindable history.
### 4.3 Initialization is explicit; a historical fork preserves history
Creating a new subagent conversation chooses initial state independently of context:
```ts
await conversation.spawn({
prompt: "Inspect the parser",
context: "inherit",
values: {
inherit: [model, thinkingLevel],
set: [setValue(activeTools, ["read", "grep"])],
},
});
```
```text
ONE creation command:
read selected current source values
create child conversation
copy selected present values, then apply overrides
append prompt and create initial generation
unselected plan mode → absent in child
inherited context → does not imply inherited plugin state
```
This initialization copies values, not a live link to parent state. Subsequent reads have no
unselected parent fallback. An absent selected value stays absent unless overridden. The baseline
selection is scalar-only; lists require an explicit initialization policy, not an accidental copy
of an unbounded log.
A historical fork has a different purpose:
```text
fork A at P:
rewindable conversation values/lists → inherit all visible history through P
sticky conversation state → copy only explicitly selected current values/overrides
session state → same session-wide state, not copied
working state → never inherited
```
The proposed sticky default is no inheritance; callers opt in at creation. Sticky values selected
for copying come from the current source, not P: sticky state is outside the historical snapshot
promise. This is a separate choice from the fork's automatic rewindable inheritance.
### 4.4 Rewindable lookup follows the fork boundary
Only local changes need new records. A historical fork does not copy every scalar version or list
prefix into its own storage rows.
```text
A: plan=false at 40; plan=true at 80
B: fork A through 60
read B.plan:
local version? no
follow source A, capped at 60
newest version <= 60 is false
B deletes plan:
local deletion found → absent; do not fall back to A
B sets plan=true:
local set found → true
```
At every ancestor, retain the tighter cutoff:
```text
readValue(C, address, cutoff):
local = newest local version at or before cutoff
if local is a set: return its value
if local is a deletion: return absent
if C has no historical-state source: return absent
return readValue(C.source, address, min(cutoff, C.sourceBoundary))
```
Lists combine inherited visible ranges and local appends, honoring clear operations and the same
ancestor cutoffs. A list cursor identifies an element position, not an array offset. Fixed asOf
plus cursor/limit gives stable pages while later writes continue.
Memory/JSONL use indexed arrays; SQLite uses indexed version/range queries. Cost may include fork
depth, but not a scan of unrelated session history. An independently initialized child terminates
fallback; a later historical fork of that child inherits its local history normally.
### 4.5 Structured state uses deltas and checkpoints
```text
checkpoint value: { through: 500, state: ... }
delta list: ... 501, 507, 510, 518 ...
hydrate at P=510:
read checkpoint visible at P
read visible deltas after checkpoint.through through P
apply deltas to privately owned state
```
```text
update, ONE command:
read/derive from committed state
append delta batch
optionally set checkpoint including that batch
commit
publish the updated state
```
The consumer owns the delta vocabulary, reducer and checkpoint cadence. Chord's delta machinery is
a candidate, not a storage dependency decided here. Storage does not execute plugin reducers.
Checkpointing bounds replay only when its cadence is maintained; it does not authorize deleting
older history while arbitrary historical forks remain supported. A live consumer can retain its
current state; it need not hydrate on every edit.
### 4.6 Working state is ordinary state with a named lifetime
```ts
const work = workingScope(String(taskId));
const frames = list<Frame>("pi.pending.frames", { scope: work });
const checkpoint = value<Checkpoint>("pi.pending.checkpoint", { scope: work });
```
```text
working-only commit:
append frame
set checkpoint
main settlement commit:
append immutable result assembled from working data
settle task
retireWorkingScope(work)
```
Working values/lists survive restart until explicit retirement. They are not transcript entries,
rewindable state, or a separate scratch-storage API. Keys distinguish attempts so recovery cannot
mistake an earlier request's frames for the current request's output.
```text
allowed commit: main writes, including retirement of working scopes
allowed commit: value/list writes in ONE working scope
rejected: direct main + working writes, or writes in two working scopes
```
This lets JSONL persist each batch to one file. Main retirement is authoritative; unlink happens
afterward. SQLite can delete working rows inside the main settlement transaction.
```text
crash before settlement → task and working data remain recoverable
crash after settlement → result and retirement both exist; leftover file is invisible
close → no retirement
```
Before settlement, stop admitting progress from that execution and drain already admitted writes.
Late callbacks must be rejected, not allowed to recreate retired task data. Generic scope names
can be reused deliberately; retirement ends the old contents, not every future use of the name.
Storage never guesses retirement from a missing task, task kind or owner status.
## 5. Tasks
A task is durable work associated with one conversation. Its definition supplies behavior; its
stored record supplies the inputs, progress and eventual outcome needed across process lifetimes.
### 5.1 Task records and lifecycle
```ts
type TaskRole = "ready" | "inflight" | "waiting" | "terminal";
interface Task<State = JsonValue> {
readonly id: number;
readonly conversationId: number;
readonly kind: string;
readonly status: string;
readonly role: TaskRole;
readonly state: State;
readonly after: readonly number[];
readonly foreground: boolean;
readonly spawnedBy?: number;
readonly ownedConversationId?: number;
}
```
Timing and cancellation metadata are added in §6. State is validated JSON, not a promise, closure
or process handle. External handles must have a durable representation that recovery understands.
| Role | Meaning | Action when covered by a drive and not executing locally |
|---|---|---|
| ready | Can execute once dependencies and timing/permissions allow | Execute |
| inflight | External-effect intent was committed; outcome may be unknown | Recover |
| waiting | A child or external observer supplies the next transition | Restore observation when needed |
| terminal | Immutable outcome | None |
A definition maps its statuses to these generic roles:
```text
generation:
pending, retry_wait, deferred → ready
streaming, polling → inflight
done, failed, aborted → terminal
job:
planned → ready
spawning → inflight
running → waiting
exited, killed, lost → terminal
```
The harness derives role from status when validating a write. Storage persists/indexes that generic
metadata so querying live work does not decode every historical task's state or execute kind code.
Role is not independently editable. Terminal records accept no further patches.
### 5.2 Definitions are replaceable units of behavior
```ts
interface TaskDefinition<State> {
readonly kind: string;
readonly initialStatus: string;
readonly roles: Readonly<Record<string, TaskRole>>;
readonly transitions: Readonly<Record<string, readonly string[]>>;
validateState(value: unknown): State;
execute(task: Task<State>, ctx: TaskExecution): Promise<void>;
recover(task: Task<State>, ctx: TaskExecution): Promise<void>;
abort(task: Task<State>, ctx: TaskExecution): Promise<void>;
}
```
TaskExecution supplies the execution signal, working scope and serialized command access. Its
concrete surface belongs in §§6 and 9; it is not another durable object.
```text
registry:
"generation" → custom generation definition
"tool" → built-in tool definition
"post_tools" → custom exchange policy
"job" → application process integration
```
The scheduler does not change when a definition changes. A replacement must understand the
persisted statuses/state of its live tasks; incompatible durable formats need an explicit migration.
Unknown live kinds or invalid live statuses reject restoration before execution begins.
A task handles ordinary provider/tool failures according to its policy. Storage failures and
invariant violations are not ordinary tool errors: they fault the session instead of being
swallowed and retried indefinitely.
### 5.3 Dependencies mean terminal, not successful
These traces assume T has a ready-role status and its timing/permissions allow execution.
```text
T.after = [A, B]
A running, B done → T blocked
A failed, B done → T eligible
A aborted, B done → T eligible unless T itself was cancelled
```
T interprets the outcomes it requires. The scheduler does not propagate success/failure policy
along edges. Referenced tasks must exist, and dependency edits must preserve an acyclic graph.
Dependencies stay within one execution-ownership tree; independent forks do not become runnable
merely because another tree refers to them.
```text
spawnedBy = who created this task // provenance
after = which tasks must settle // execution dependency
```
These are different. A generation can create a collapse task and then depend on it; that provenance
link does not create a dependency cycle.
### 5.4 A generation publishes tools and their join atomically
```text
G returns assistant with calls [A, B]
ONE settlement command:
append assistant entry
create tool A
create tool B
create P = post_tools, after:[A,B]
settle G
A settles its own result ─┐
├─ P becomes eligible ─ { settle P; create G2 }
B settles its own result ─┘
```
Each terminal command also records known usage and retires its working scope when present. A tool
settles only itself: no sibling inspection, queue draining or next-generation creation. P owns the
exchange decision and remains foreground even after both tools have settled.
```text
parallel:
A; B; C; P.after=[A,B,C]
sequential:
A; B.after=[A]; C.after=[B]; P.after=[A,B,C]
```
Same tools, same join, different dependencies. No tool-specific scheduler lock is required.
```text
crash before G settlement → G remains live; normal recovery applies
crash after G settlement → assistant, tools and P all exist
crash after last tool → P already exists; no tail scan or successor repair
```
A final-answer generation either creates its explicit continuation in its terminal command or
ends the chain. An idle user-shaped context does not create work by inference.
### 5.5 A durable wait is not a task-lifetime promise
```text
DO NOT:
create child task
await child task's entire lifetime inside this effect
DO:
commit dependency and continuation state
return from this effect
execute the continuation when its dependencies become terminal
```
A foreground tool delegating to a subagent can reuse its own task:
```text
tool execute:
ONE command:
create subagent owner S
set tool status=finishing, after=[S]
return
S starts child conversation, then returns in waiting status
child final settlement also settles S
existing tool.finishing becomes eligible
→ read S's result
→ run result hooks
→ commit own tool result and terminal status
```
Recovery uses stored tasks/dependencies, not reconstructed chains of waitFor promises. Joining this
process's current execution of the SAME task during cancellation is different; §6 defines that
ordering so effect and abort handlers cannot write concurrently.
### 5.6 Foreground work and detached background work
Foreground is chosen when work is created, not inferred from a tool name or the transcript tail.
```text
local foreground roots:
generation, its tools, post_tools, automatic collapse
required foreground work:
those roots + unfinished dependencies
+ required foreground work in their owned conversations
detached background work:
other live tasks, such as background jobs, schedules and background subagents
```
A task needed by a foreground dependency is part of that required work even if it was not created
as a local foreground root. To detach a launch, return its ID instead of creating such a dependency.
Detailed cancellation selection is defined in §6.
```text
foreground launch:
{ create S; tool finishing after:[S] }
parent exchange cannot finish until S supplies a result
background launch:
{ create S; tool result contains S.id; settle tool }
parent exchange can finish while S continues
```
Normal conversation abort targets foreground/required work, not detached background work. The
public API also needs cancellation of a selected background operation and all-background shutdown;
these select cancellation scopes, not arbitrary edits to task status.
### 5.7 Ownership links a task to a child conversation
```text
conversation A
task S ── owns ──> conversation C
generation, tools, post_tools, ...
```
```ts
interface Conversation {
readonly ownerTaskId?: number;
}
```
S.ownedConversationId and C.ownerTaskId are reciprocal and commit together. Fork provenance does
not establish ownership. Child completion settles its existing owner in the same command when
appropriate; it does not depend on a later notification manufacturing missing work.
An ordinary fork has no owner and no copied tasks. A task can create a child, initialize its chosen
state/context, append its prompt and create its first generation atomically. Starting any of those
effects waits until that creation command is durable.
### 5.8 What every definition must specify
```text
creation inputs/settings captured; initial status; foreground/dependencies
execution durable intent before external work; bounded working-state writes
parking persisted continuation/dependency/observer state; effect returns
settlement output + usage + terminal state + successors + working retirement
recovery retry/adopt/interrupted policy for every nonterminal status
cancellation stop/join own execution; required result/cleanup; no normal successors
```
Normal successor-creating commands re-check current task status and cancellation before committing.
Task-specific cancellation must preserve required result records, such as an error result for an
unexecuted tool call. No definition may depend on the scheduler knowing its domain semantics.
## 6. Scheduling and cancellation
The scheduler executes durable task records. A drive call selects where execution is allowed;
it does not create work or become a second owner of tasks already executing.
### 6.1 Generic scheduling metadata
```ts
interface Task<State = JsonValue> {
readonly notBefore?: number; // earliest execution time
readonly requiredPermit?: string; // optional host permission, e.g. deferred polling
readonly abortRequested?: true; // durable cancellation membership
}
interface DriveOptions {
readonly permits?: readonly string[];
readonly signal?: AbortSignal; // cancels this caller's wait, not durable tasks
}
```
The scheduler tests a permit name, not a provider's polling semantics. A retry stores a timestamp
on the existing task, not a promise that must survive restart.
```text
ready to execute = ready role
+ every after dependency terminal
+ notBefore reached
+ requiredPermit supplied by a covering drive
+ not cancelled or already executing
```
### 6.2 Drive scopes and outcomes
```text
conversation.drive() → that conversation and descendants reached through task ownership
harness.drive() → all independent conversation trees
fork provenance → never expands a drive scope
```
Overlapping callers share one scheduler. Permissions apply only inside the caller's scope; a
polling permit for one tree must not authorize unrelated trees.
| Outcome | Conversation drive | Session drive |
|---|---|---|
| idle | No required foreground work or selected cancellation remains in its tree | No live tasks anywhere |
| suspended | Work remains but needs an outside event/permission | Same |
| closed | Host closed before the requested condition | Same |
A future timer is local progress: wait for it rather than report suspension. An installed human
or process observer can leave the drive suspended. The observer may commit its task's outcome;
subsequent task execution requires a covering drive again.
Foreground completion is rooted at the requested conversation: foreground tasks inside a detached
child do not make its parent busy. Driving that child directly does wait for its own foreground.
A conversation drive services background tasks while active, but does not wait solely for detached
work. Hosts supervising background work drive the session and resume after relevant external events.
### 6.3 One execution claim per task
```ts
interface ExecutionClaim {
readonly controller: AbortController;
readonly promise: Promise<void>;
}
const executing = new Map<number, ExecutionClaim>();
```
This map owns actual executions, not a mirror of stored tasks. Waiting observers and their re-arm
markers exist only for live waiting phases; discard them on phase exit, settlement or close.
Determine scope membership from live task queries and ownership ancestor point reads, not by
walking every completed child conversation ever created. Temporary ancestor lookups can be shared
within a pass without becoming a permanent resident model.
```text
scheduler pass:
read live tasks in covered scopes, using bounded indexed queries
signal EVERY affected executing cancellation target
reconcile cancellation targets under exclusive claims
for each unmarked, unclaimed task:
ready → test dependencies/time/permit; claim and execute
inflight → claim and recover
waiting → if not armed for this waiting phase: claim and recover to re-arm
derive caller outcomes from committed state
await commit | execution completion | next timer | outside notification
```
Claim before launching. Do not await an entire effect inside the scan before considering the next
task. Initial execution can install its observer; recovery must not install a duplicate. A waiting
owner whose child supplies completion needs no subscription and can simply mark re-arming complete.
Effects and observers re-check current task status, cancellation and their execution validity on
the command line before writing. Events wake checks; they are not durable continuation messages.
An unchanged ready task must not be relaunched forever after an unexpected exception: report it
and fault the handle. Ordinary retry belongs in a committed task transition.
### 6.4 Cancellation selects work, then drives cleanup
```text
conversation.abort():
select current required foreground work (§5.6)
conversation.abortBackground(taskId):
require a detached background root in this conversation
select it, unfinished dependencies, and all live work in their owned subtrees
conversation.abortBackground():
apply that selection to detached background work in this ownership tree
```
The background handle is a task ID, but this is scope cancellation, not permission to terminalize
an arbitrary node or skip its cleanup. A task currently required by foreground work is not detached;
use conversation abort for that chain. Further dependency-sharing policies remain a review point.
```text
ONE cancellation command:
capture selected task IDs; mark every nonterminal target abortRequested
for foreground abort, cancel currently queued steer/followUp in affected conversations
keep context-only writes and nextRun input
repeated abort while the same foreground cancellation is outstanding:
join existing cancellation; do not drain newly arrived input again
```
The marks survive restart. Do not shrink selection as earlier tasks settle. New foreground input
queues while cancellation is outstanding; normal successors and owned-child creation cannot escape
a marked task. Background cancellation also prevents new work beneath its marked owner.
### 6.5 Join an execution before invoking its abort handler
```text
for each marked task:
keep/acquire ONE exclusive execution claim
signal and await its current local execution, if any
report its rejection; storage faults forbid further writes
re-read committed task
if already terminal: release claim
otherwise: invoke definition.abort under the SAME claim
release only after cleanup returns
```
Signal all affected executions before waiting on a slow one. Unstarted tasks can abort without
waiting for their normal after dependencies. An uncooperative effect can delay its own cleanup;
a timeout must not silently admit a second writer for that task.
A marked child-conversation owner settles only after its selected child work is terminal. The
scheduler waits for that condition, not an effect-held child-lifetime promise. Child completion
checks the owner's cancellation mark before publishing success. Direct foreground abort of an
owned child must also complete its owner as cancelled. That child drive may finish cancellation
of its marked owning task after selected child work is terminal, even though the owner record is
in the parent. It must not start other parent effects; parent-side continuation requires a covering
drive of the parent.
```text
post_tools settlement wins:
{ settle P; create G2 } → abort selects G2
abort wins:
{ mark P } → P.abort settles without G2
```
Task-specific error/result records still belong to task handlers. Cancellation does not synthesize
provider messages or interpret tool arguments in the scheduler.
### 6.6 Open, close, shutdown and deletion
```text
open:
open storage; validate live task definitions
expose inspect(), state and transcript queries
start no provider, tool or process effects
close:
seal public/effect admission
signal local executions; stop observers/progress admission
wait for cooperative executions and admitted persistence
release storage; write no cancellation or terminal outcomes
harness.shutdown:
seal public admission but allow internal cancellation commands
mark ALL live session work, including background and owned descendants
cancel all queued inputs, including write/nextRun; preserve their immutable history
drive selected cancellation to settlement
close
```
Close itself adds no outcomes; commands already admitted before its barrier can still complete,
including terminal settlements. It interrupts local execution, not necessarily an external process
that can be adopted later. Shutdown uses each kind's abort policy to stop external work where supported. A crash during
shutdown leaves durable cancellation marks for the next owner to reconcile.
Observer-wait cancellation only removes that caller's drive interest; it never signals shared
executions as if the conversation were aborted. Close remains cooperative and may block on a
broken external integration. Bounded-close/forced-process policies need an explicit host contract.
Deleting a conversation is a separate atomic mutation: reject while its ownership subtree has
live work. Once deleted, reject new work there. Preserve immutable history and source metadata
needed by independent forks; logical deletion is not permission to erase their inherited data.
## 7. Storage
One storage interface answers entity/state queries and commits atomic mutation batches. There is
no public journal reader, separate read-index interface, scratch store or payload-address system.
### 7.1 Store the information queries need
Complete the conversation snapshot with its historical source and context-replacement revision:
```ts
interface Conversation {
readonly source?: { readonly conversationId: number; readonly asOf: number };
readonly inheritState: boolean;
readonly prefixRevision: number;
}
interface Entry {
readonly metadata?: JsonObject; // small preview/attribution/exchange fields
readonly key?: string; // optional indexed content key, not a state address
readonly byTaskId?: number;
}
type EntryHeader = Omit<Entry, "content">;
```
Source links cap inherited transcript access. Historical forks also set inheritState=true;
independently initialized children set it false even when inheriting context. Owner links remain
separate. Source and ownership identity are not arbitrary mutable configuration.
Current context is materialized. Its historical edits are retained. Prefix replacement/reset bumps
prefixRevision; append does not. Task role, conversation ID and scheduling fields are queryable
without decoding kind-specific state. Entry metadata permits exchange checks without loading large
message/image content.
### 7.2 Queries, motivated by callers
| Caller | Required read | Must not do |
|---|---|---|
| Scheduler | Live tasks in a scope; dependency IDs in one batch | Load terminal task history |
| Generation | Current context IDs, then those entries in a batch | Reconstruct context by scanning transcript |
| UI | Transcript page before/after an ID, with a limit | Load the whole conversation |
| Validation | Named task/conversation records and entry headers | Load unrelated message content |
| Fork | Entry commit boundary; historical context/state | Truncate today's context |
| State consumer | Latest value/version; bounded list range | Replay unrelated journal records |
| Background status | Task and named result entry | Keep an ancient task resident forever |
| Reopen on SQLite | Live tasks and required conversations | Replay all historical task transitions |
Filters apply before limits. Latest matching record means an indexed descending query with limit=1,
not loading a collection and taking its last item. Conversation batch reads matter just as entry
and task batch reads do.
```ts
interface CursorPage {
readonly after?: number;
readonly before?: number;
readonly order?: "asc" | "desc";
readonly limit: number;
}
interface Page<T> {
readonly items: readonly T[];
readonly asOf: number;
readonly next?: number;
}
interface ConversationQuery extends CursorPage {
readonly sourceConversationId?: number;
readonly ownerTaskId?: number;
}
interface EntryQuery extends CursorPage {
readonly conversationId: number;
readonly kind?: string;
readonly key?: string;
readonly asOf?: number;
}
interface TaskQuery extends CursorPage {
readonly conversationIds?: readonly number[];
readonly live?: boolean;
readonly role?: TaskRole;
readonly kind?: string;
readonly foreground?: boolean;
readonly abortRequested?: boolean;
}
interface ListQuery extends CursorPage { readonly asOf?: number }
interface StoredValue<T> { readonly seq: number; readonly value: T }
interface ListElement<T> { readonly id: number; readonly value: T }
interface CommitBoundary { readonly first: number; readonly last: number }
```
Transcript queries return the effective inherited prefix plus local entries; each entry retains
its original conversationId. Tasks are never inherited by that query convention. Historical state
uses §4's capped source traversal. A page cutoff is not a generic timestamp for every data class:
sticky state and task scans expose current state, not historical snapshots. Transcript/rewindable
pages carry a main boundary; current task/sticky/working pages carry a global observation cutoff,
which is not a fork target. Historical reads on sticky/working addresses reject. Coherent current
multi-page reads use the command line; a cursor alone does not freeze mutable sticky contents.
### 7.3 One interface, no second transaction layer
```ts
interface Storage {
readonly head: number; // global allocator high-water mark
readonly mainHead: number; // last main commit, not another allocator
commit(batch: CommitBatch): Promise<CommitBoundary>;
getConversations(ids: readonly number[], asOf?: number): Promise<ReadonlyMap<number, Conversation>>;
scanConversations(query: ConversationQuery): Promise<Page<Conversation>>;
getEntries(ids: readonly number[]): Promise<ReadonlyMap<number, Entry>>;
getEntries(ids: readonly number[], options: { content: false }): Promise<ReadonlyMap<number, EntryHeader>>;
scanEntries(query: EntryQuery): Promise<Page<EntryHeader>>;
getTasks(ids: readonly number[]): Promise<ReadonlyMap<number, Task>>;
scanTasks(query: TaskQuery): Promise<Page<Task>>;
getValue<T>(address: Value<T>, asOf?: number): Promise<StoredValue<T> | undefined>;
scanValues(query: ValueQuery): Promise<ValuePage>;
readList<T>(address: ValueList<T>, query: ListQuery): Promise<Page<ListElement<T>>>;
close(): Promise<void>;
}
```
ValueQuery is a bounded scope/namespace scan ordered by address key; its cursor is an address key,
not a numeric entity cursor. ValuePage returns addresses with their current visible values. No
arbitrary predicates, plugin reducers, user comparators or general SQL expressions cross this API.
State tokens arrive with their conversation/working identity bound; scope-specific receiver shapes
are shown in §9.
Each logical read, including ancestor traversal, observes one committed state. The session command
line supplies coherent multi-read operations and read-modify-write; no second public storage
transaction callback is needed. Historical context reads affect context, not current sticky state.
Tasks remain current even when their originating entries are old.
### 7.4 Atomic mutations
```text
main mutation vocabulary:
createConversation, deleteConversation
appendEntry, createTask, patchTask
appendContext, replaceContextPrefix, resetContext
setValue, deleteValue, appendList, clearList
retireWorkingScope
working mutation vocabulary:
setValue, deleteValue, appendList, clearList
```
```ts
interface CommitBatch {
readonly scope: "main" | { readonly working: string };
readonly writes: readonly Write[]; // tagged mutations above; each has its final seq
}
```
```text
harness command:
read committed state; construct mutations and IDs
validate against committed state + earlier writes in this batch
no mutations → return builder result without a storage commit
otherwise persist one batch
notify observers; resolve caller
storage commit:
check scope, supplied sequences and structural constraints
prepare only touched data/index changes
persist atomically
publish new query-visible state and heads together
```
Task definitions validate status transitions/state; the harness enforces ownership, dependency,
context and admission rules. Storage stores the validated generic records without running kinds.
Rejected construction changes nothing. An admitted failure faults the handle (§3.5). Cancelling a
caller after persistence admission must not abandon the transaction or expose half a batch.
### 7.5 Memory and JSONL: the same queries, with optional files
```text
current maps: conversations, tasks, scoped values/lists
ordered data: entries per conversation, state versions, context edits
lookup maps: IDs, addresses, live-task membership, source/owner relationships
```
Memory applies prepared changes without exposing intermediate state. Indexes may reference the
same immutable payload objects; they need not duplicate content. JSONL uses that representation
and loads it fully on open.
```json
{"first":100,"last":103,"writes":[
{"type":"appendEntry","conversationId":1,"kind":"user","content":"Inspect"},
{"type":"appendContext","conversationId":1,"ids":[100]},
{"type":"setValue","conversationId":1,"namespace":"plugin.plan","value":true},
{"type":"createTask","conversationId":1,"kind":"generation","status":"pending"}
]}
```
Shown across lines for reading; the file contains one complete line per batch. This is illustrative
encoding, omitting address/task fields. A compact encoding may derive each stored seq from first+i;
logical writes already carried their final IDs before encoding.
```text
JSONL commit → encode whole batch → write all bytes + newline → publish in-memory changes
JSONL open → validate complete batches → rebuild maps/indexes → expose queries
```
Do not publish in-memory changes before the write completes. Handle short writes. Discard only an
unterminated final batch; malformed complete lines fail open rather than silently deleting history.
```text
session.jsonl main batches
session.scopes/<encoded-id> working batches for one named scope
```
On open, compute global high-water from all complete main/working batches before cleanup. Main
retirement hides that scope's records through its retirement seq. Later deliberate reuse survives;
serialize old unlink before reuse so delayed cleanup cannot delete a new lifetime. Only explicit
retirement authorizes cleanup. Process-crash durability is required; fsync/power-loss policy is an
explicit backend option, not a claim that ordinary writes are immune to power loss.
### 7.6 SQLite: queryable rows, not mandatory raw-journal replay
```text
conversations / current_context_elements
entries
tasks // current records, including terminal outcomes
current_values / value_versions
list_elements / historical_clear_markers
context_edits
commit_boundaries / session_metadata
```
Every shared-container key starts with session ID. Suggested indexed suffixes:
```text
entries (conversation, id), (conversation, kind, key, id)
tasks (role, id), (conversation, role, id)
values (scope, namespace, key)
value_versions (conversation, namespace, key, seq)
list_elements (scope, namespace, key, id)
context_edits (conversation, seq)
conversations (sourceConversationId), (ownerTaskId)
```
Live scans also need ordered partial indexes, for example (session_id, id) WHERE role != 'terminal',
so scanning across nonterminal roles does not sort all task history. Additional combinations follow
actual query plans, not an index for every possible filter.
```sql
-- One local step in a rewindable scalar lookup.
SELECT seq, deleted, value
FROM value_versions
WHERE session_id = :session AND conversation_id = :conversation
AND namespace = :namespace AND key = :key AND seq <= :cutoff
ORDER BY seq DESC LIMIT 1;
```
```text
BEGIN
validate global head
insert entry 100 with commit_end=103
append context element 100; retain context edit 101
insert rewindable value version 102
insert current task 103 with indexed role
record main boundary 103; advance global head
COMMIT
```
No full transcript/context rewrite per append. Current context can use ordered element rows;
prefix replacement removes the selected prefix and inserts its head without rewriting the retained
suffix. A historical fork stores a source link and its initial context, not copies of source entry
or value/list inventories. Sticky updates overwrite current contents; working retirement deletes
working rows inside the settlement transaction. Retain only the histories the API promises.
### 7.7 Historical context is not today's context minus later IDs
```text
contextAsOf(C, P):
start from latest reset <= P, otherwise C's initial context
page later context edits through P in order
append → extend
replace → replace named prefix, keeping the existing suffix
reset → replace whole list
```
A replacement is not a full checkpoint: it says nothing about the retained suffix's earlier
appends. The baseline replay may span from creation/reset; it is correct but not necessarily cheap
for ancient forks. Indexed context checkpoints can optimize it later if measurements justify them.
Current generation requests use the materialized context instead of this historical path.
## 8. Built-in task flows
These are task definitions and boundary policies, not scheduler special cases. Braces below group
one main commit. Every attempt outcome includes its reported usage; terminal outcomes retire their
working data. Hooks and external calls run outside the command line.
### 8.1 Acceptance, placement and result ownership
Proposed input representation: use an immutable admission entry as the input identity. Idle input
can be a selected user entry directly; busy input is unselected until a later placement entry.
```text
idle accept A:
{ user A with inline message; select A; G.inputs=[A]; result[A]=running }
busy accept B:
{ unselected input B with inline message; enqueue B; result[B]=queued }
later consume B:
{ user placement U referring to B; select U; dequeue B;
create G2.inputs=[B]; result[B]=running }
```
The placement is new, so context remains in transcript order. Its message is resolved from B's
immutable content, not copied into another stored payload. Projection batch-loads such references;
placement references point directly to admission entries, never chains of placements. UI rendering
can distinguish arrival from placement rather than displaying the same message twice.
Queues use ordinary sticky values under conversation namespaces, one address per queued input.
Separate namespaces identify queue modes; fixed-width decimal input-ID keys preserve numeric
admission order in bounded key scans. Dequeue deletes the current queue value; it does not require
rewriting a growing inbox array or retaining consumed queue rows.
Session-scoped input-result values preserve each acceptance's outcome independently of that queue.
```text
G1.inputs=[A]
B queued while G1 runs
G1 produces final answer R
{ result[A]=done(R); settle G1;
place B; dequeue B; G2.inputs=[B]; result[B]=running }
result(B) is NOT R just because R was appended after B's admission
```
At a tool boundary, newly consumed steering joins the current pending input group. A yield hook's
continuation also retains that group. Only a completed answer/stopping boundary resolves it.
Follow-ups consumed after an answer start a new group. Several inputs can legitimately share one
answer. Carry input IDs through generation/post_tools state, not through a new durable run object.
```ts
type InputResult =
| { readonly status: "queued" | "running" }
| { readonly status: "done" | "placed"; readonly entryId: number }
| { readonly status: "cancelled" | "failed" | "stopped"; readonly reason?: string; readonly entryId?: number };
```
Placed is the terminal receipt for a context-only write, not an assistant answer. Cancelling a
queued input records cancelled; cancelling an active chain resolves its pending inputs in the
terminal boundary command. Retrying/deferred tasks leave them running. Session-wide request keys
resolve the original admission; reusing a key for another conversation or different normalized
semantic input rejects. Harness-generated metadata is not part of input equivalence.
This representation/result contract is proposed for review. It avoids chronological attribution,
a new input table, and duplicate message payloads; it does add placement records for queued input.
### 8.2 Queue boundaries
| Input mode | When it can be placed | Requests generation? |
|---|---|---|
| write | Next safe context boundary | No |
| steer | Next tool or final-answer boundary; explicit idle acceptance | Yes |
| followUp | Normal final-answer boundary; explicit idle acceptance | Yes |
| nextRun | Next explicit idle acceptance only | Yes |
```text
idle acceptance:
place eligible queued items, then new input
create ONE generation with all selected trigger IDs
post_tools:
place safe writes and selected steering
continue current input group unless cancelled/terminated
final-answer generation:
if yield hook continues: retain current input group
otherwise resolve current group with this answer
eligible steer/followUp → place them and create next group/generation
failure/termination:
resolve current group; do not consume triggers with no successor
foreground abort:
cancel queued steer/followUp; preserve write/nextRun
```
Steering/follow-up modes select all eligible items or one oldest item of each mode. Preserve
admission order among selected items. Safe write placement never becomes a hidden run request.
Selection, placement, dequeue, result-state changes and required generation creation share a commit.
Queued input takes precedence over an old yield decision; stale hook preparation is discarded.
### 8.3 Generation
```text
create:
capture model, thinking, tools, provider options and retry policy
persist inputs and pending status
pending/retry execution:
check current status/cancellation and request/exchange exclusion
completed captured collapse → interpret its outcome once; do not recreate it on every retry
live collapse → attach dependency and return
automatic collapse needed → create C and G.after=[C] together; return
capture main historical context boundary; commit streaming intent
project context; apply before_request; re-check execution validity
call provider; persist frames under this attempt's working keys
outcome:
calls → { assistant; tool tasks; post_tools with inputs; settle G }
final answer → { assistant; resolve/continue inputs; settle G; required successor }
deferred → { usage; same G.deferred(handle), requiredPermit="deferred-poll" }
retryable → { usage; same G.retry_wait, attempt+1, notBefore }
overflow → { usage; same G waiting on one captured collapse retry }
failure → { partial/error if needed; resolve inputs failed; settle G }
```
A generation task exists throughout preflight/collapse/retry. Retries do not create a replacement
with a fresh budget. Polling records intent before fetching the stored handle; another deferred
response updates the same task. Transitions clear permit/timing metadata that no longer applies;
a normal retry must not accidentally retain a deferred-poll requirement. Poll permits and retry
timestamps remain generic scheduler data.
Known usage commits even for failed, deferred or discarded attempts; no report means unknown cost,
not zero and not an exactly-once billing guarantee.
```text
recover polling → deferred; retain handle and budget
recover streaming → committed partial: publish interrupted outcome + required call errors
no partial: retry within budget or fail
abort → drain frames; best-effort provider cancellation; required partial/results;
resolve pending inputs cancelled; no tools or normal successor
```
The interrupted-stream policy and provider-specific retry/overflow classification need parity
review. Do not catch persistence/invariant failures as provider errors. A never-started generation
has no fabricated assistant response. Stop reasons containing unusable/truncated calls still need
error results without invoking those tools.
### 8.4 Tool execution and post_tools
```text
tool execute:
validate offered-tool authorization and arguments
before_tool → allow | block | hold
allow → commit running intent with effective args and replay policy
invoke tool with execution signal, identity and bounded sink
block/truncated/missing → prepare own error result without external invocation
run after_tool outside line
{ own result; usage; active-tool additions; control intent; settle tool }
```
Registry presence is not authorization: the generation must have offered the tool. Persist the
actual arguments/replay decision before invocation. Ordinary throws produce error results, not
cancellation. Capture terminate/handoff intent in the outcome; the tool does not reset context or
inspect siblings. Foreground delegation uses the finishing phase in §5.5.
```text
recover → replay only when captured AND current tool policy permit it;
otherwise interrupted result from checkpoint, no uncertain control side effects
abort → join own execution; drain working writes; own aborted error result
```
```text
post_tools, ONE command:
require all calls terminal; re-check its cancellation
read outcomes and choose exchange policy
stopped → resolve current inputs stopped/cancelled; no successor
continue → place eligible writes/steering; apply valid handoff; create G2 with inputs
settle post_tools
post_tools.abort:
{ resolve its pending input group cancelled; settle post_tools }
no context edit, trigger consumption or successor
```
A new_context tool returns handoff intent. The join appends/selects that handoff and resets context
only after the exchange is complete. Ordinary errors can still be followed by generation. The draft
uses any terminate request to stop; conflicting handoffs reject the handoff rather than choosing
completion order. These policies are reviewable independently of the join mechanism.
### 8.5 Collapse, including speculative completion
```text
create C:
capture complete prefix, through, prefixRevision, historical context and model/settings
allow one live collapse per conversation; generation can depend on that existing C
execute:
commit summarizing intent → call summarizer
persist complete candidate, including reported usage, in working scope
{ usage; candidate reference/phase identifying that the attempt outcome was recorded }
editable context + matching prefix → { summary; replace prefix; settle C }
stale prefix/cancelled → terminal outcome without context publication
context busy → park C waiting for an editable-context predicate
```
The parked task restores an observer that checks committed context/request state, then makes C
ready to publish. Events only wake that check. Re-check prefix and editability in the publishing
command; do not record usage twice.
**Do not wait for a generation's terminal status to make context editable:** a retry can keep the
same generation alive while it waits for C. That would form a deadlock. The context predicate,
not the generation's lifetime, controls speculative publication.
```text
G retry_wait after:[C] → no provider request in flight
C sees editable context → publish and settle
G becomes eligible → retry with new context
```
Recovery reuses a durable complete candidate or retries within budget. Abort records known usage
and settles without replacement. C never creates G. Failed/declined overflow compaction ends the
bounded overflow retry; threshold/manual failure can leave G using unchanged context.
### 8.6 Subagent and approval
```text
subagent owner S:
{ child with owner=S; selected initial state; prompt; first generation; S waiting }
return
child terminal foreground boundary:
{ its output/input outcomes; terminal child task; settle S with result reference }
```
For inherited subagent context, the proposed default is the last complete-exchange boundary before
the launching exchange, not its unresolved calls. Selected initial values still come from the
creation command. This is initialization, not a historical fork claiming one state/context snapshot.
Recovery drives the existing child tasks; it does not recreate the child or await a child-lifetime
promise. Cancellation completion follows §6.5 and must not report success for a marked owner.
```text
hold tool:
{ create approval A; tool waiting after:[A] }
grant:
{ A granted; matching tool ready with approved arguments }
deny:
{ A denied; matching tool finishing with denial outcome }
tool runs its result hooks, writes own result and settles
```
Approval recovery restores the human decision surface; it never grants automatically. Cancellation
marks the approval and its required tool independently. Grant/deny versus abort is serialized;
late decisions cannot restart terminal work.
### 8.7 Jobs and schedules
```text
job:
{ spawning intent with durable adoption/log identity }
spawn or reconcile existing process
{ running with verified process identity }
install output/exit observers; return
exit:
drain output → { immutable result; usage if reported; terminal job }
```
Recovery adopts the same process, or records lost if its outcome cannot be recovered. PID alone is
not identity. Abort stops the process through its host service, drains output and records killed.
Close detaches local observation; an adoptable external job can survive it. Process launch/exit
reconciliation and log retention are explicit host capabilities, not storage guesses.
A recurring schedule needs a stable cancellation handle, not a trail of successor IDs:
```text
schedule S ready at notBefore:
{ create action J; S collecting after:[J] }
return
J terminal → S collecting:
{ record outcome reference;
S scheduled with next notBefore and after:[], or terminal if complete }
```
The action can be a job or subagent owner. S remains the background root; cancellation includes
its unfinished dependency J and owned work. Recurrence and missed-run policy are persisted;
positive intervals and bounded catch-up prevent a restart from launching an unbounded backlog.
Recovery uses the stored timestamp/dependency; it must not create a second J while collecting.
Abort settles S without recurrence. Captured cancellation marks keep its action/owned work selected
even if S settles first; S does not hold an abort promise across J's lifetime.
### 8.8 Hooks are preparation or observation, not another scheduler
| Point | Decision |
|---|---|
| before_request | Transform the prepared request |
| after_response | Observe provider outcome |
| before_tool | Allow, block with result, or hold for approval |
| after_tool | Process/observe this tool's result |
| on_yield | Stop or request explicit continuation |
| before_collapse | Allow with instructions or decline |
Run hooks outside the command line, then revalidate their decisions before committing. Hooks may
repeat after a crash; external hook side effects need their own idempotence. Commit listeners,
unlike hooks, must not await another command on the same serialized line.
Hook ordering, transformation rights and exception policy need an explicit compatibility decision;
the task chain does not settle those questions. Cancellation suppresses yield/normal successor
creation even when a hook was already preparing a continuation.
## 9. Public API and observation
The earlier shapes describe stored snapshots and behavior. Handles issue commands; they are not
stored conversation objects. These sketches assemble those surfaces without requiring a second
state model or exposing backend layout.
### 9.1 Conversation and session handles
```ts
interface Accepted { readonly inputId: number }
type HistoryTarget = { readonly atEntry: number } | { readonly asOf: number };
type DriveOutcome = "idle" | "suspended" | "closed";
interface ConversationHandle {
readonly id: number;
snapshot(): Promise<Conversation>;
accept(input: AgentInput, options?: { requestId?: string }): Promise<Accepted>;
result(inputId: number): Promise<InputResult | undefined>;
drive(options?: DriveOptions): Promise<DriveOutcome>;
prompt(input: AgentInput, options?: { requestId?: string }): Promise<{ inputId: number; result: InputResult }>;
appendMessage(message: AgentMessage): Promise<Accepted>; // context only; may queue
steer(input: AgentInput): Promise<Accepted>;
followUp(input: AgentInput): Promise<Accepted>;
nextRun(input: AgentInput): Promise<Accepted>;
cancelQueued(inputId: number): Promise<"cancelled" | "already_consumed" | "not_found">;
abort(): Promise<void>; // durable intent; drive performs cleanup
abortBackground(taskId?: number): Promise<void>;
collapse(options?: { instructions?: string }): Promise<number>;
resetContext(ids: readonly number[]): Promise<void>;
fork(target: HistoryTarget, options?: { sticky?: Initialization }): Promise<ConversationHandle>;
rewind(target: HistoryTarget, options?: { keepRunning?: boolean; sticky?: Initialization }): Promise<ConversationHandle>;
spawn(options: SpawnOptions): Promise<number>; // subagent owner ID; no effect starts here
getValue<T>(address: ConversationValue<T>, asOf?: number): Promise<T | undefined>;
setValue<T>(address: ConversationValue<T>, value: NoInfer<T>): Promise<void>;
deleteValue<T>(address: ConversationValue<T>): Promise<void>;
appendList<T>(address: ConversationList<T>, value: NoInfer<T>): Promise<number>;
clearList<T>(address: ConversationList<T>): Promise<void>;
readList<T>(address: ConversationList<T>, query: ListQuery): Promise<Page<ListElement<T>>>;
command<T>(build: (tx: ConversationCommand) => T | Promise<T>): Promise<T>;
}
interface Harness {
root(): Promise<ConversationHandle>;
conversation(id: number): Promise<ConversationHandle | undefined>;
conversations(query: ConversationQuery): Promise<Page<Conversation>>;
inspect(): Promise<{ ready: readonly Task[]; inflight: readonly Task[]; waiting: readonly Task[] }>;
drive(options?: DriveOptions): Promise<DriveOutcome>;
entry(id: number): Promise<Entry | undefined>;
entries(conversationId: number, query: CursorPage): Promise<Page<Entry>>;
task(id: number): Promise<Task | undefined>;
getValue<T>(address: SessionValue<T>): Promise<T | undefined>;
setValue<T>(address: SessionValue<T>, value: NoInfer<T>): Promise<void>;
deleteValue<T>(address: SessionValue<T>): Promise<void>;
appendList<T>(address: SessionList<T>, value: NoInfer<T>): Promise<number>;
clearList<T>(address: SessionList<T>): Promise<void>;
readList<T>(address: SessionList<T>, query: ListQuery): Promise<Page<ListElement<T>>>;
command<T>(build: (tx: Command) => T | Promise<T>): Promise<T>;
command<T>(scope: WorkingScope, build: (tx: WorkingCommand) => T | Promise<T>): Promise<T>;
watch(conversationId: number, options: { limit: number }, listener: (event: WatchEvent) => void): Promise<() => void>;
decideApproval(taskId: number, decision: "grant" | "deny"): Promise<void>;
deleteConversation(id: number): Promise<void>;
shutdown(): Promise<void>;
close(): Promise<void>;
}
```
AgentInput is string/user-message/message-batch input normalized at acceptance. AgentMessage and
provider model/usage types reuse the existing agent/pi-ai boundary. Initialization is the typed
scalar selection/override shape in §4.3; SpawnOptions adds prompt and fresh/inherit context.
These are proposed signatures, not new copies of provider message types. Direct spawn creates a
detached owner; foreground tool delegation makes that work required through its dependency.
Prompt accepts, drives while observing that input, and returns its result when terminal or progress
suspends/closes. It never substitutes another input's answer. Rewind normally requests/drains source
foreground cancellation before forking; keepRunning leaves source work alone. Validate the target
before cancellation so an invalid history request does not abort otherwise valid work.
### 9.2 Commands return results only after commit
Command exposes the mutations from §7.4 and read-only storage queries. Creation/list-append methods
return final numeric IDs immediately inside the builder; the outer command resolves only after
persistence and publication. ConversationCommand binds the conversation automatically. WorkingCommand
exposes only value/list writes, checked against one exact scope ID.
```ts
await conversation.command(async tx => {
const current = await tx.getValue(plan) ?? false;
tx.setValue(plan, !current);
});
const entryId = await harness.command(tx => {
const id = tx.appendEntry(conversationId, "note", content);
tx.setValue(labelAddress(id), "checkpoint");
return id;
});
// entryId can escape here, not during the builder
```
Reads see pre-command committed state; validation applies buffered mutations in order. Buffer
construction does not perform external effects or publish plugin state. A throwing builder discards
its mutations. Runtime checks supplement token typing for strict JSON, address policy and exact
working-scope identity; changing an established address's rewind policy is not an implicit migration.
### 9.3 Task and tool integration
```ts
interface TaskExecution {
readonly signal: AbortSignal;
readonly workingScope: WorkingScope;
readonly services: TaskServices;
command<T>(build: (tx: Command) => T | Promise<T>): Promise<T>;
command<T>(scope: WorkingScope, build: (tx: WorkingCommand) => T | Promise<T>): Promise<T>;
observe<Event>(
subscribe: (notify: (event: Event) => void) => () => void,
reconcile: (event: Event | undefined, ctx: TaskExecution) => Promise<void>,
): void;
}
interface Tool {
readonly name: string;
readonly description: string;
readonly parameters: JsonObject; // provider-compatible JSON Schema
readonly replay?: "safe" | "never";
execute(args: JsonObject, ctx: ToolExecution): Promise<void | ToolDelegation>;
}
```
TaskServices supplies models, registered tools, hooks and host resources. ToolExecution supplies
invocation/call identity, the signal and bounded output/checkpoint/memo access using ordinary working
values/lists. The tool schema validates arguments before invocation. ToolDelegation identifies a
subagent/job launch and foreground/background choice; it does not contain an in-process promise.
```text
tool output surface:
write text / add image → durable bounded progress; capped final result
set details / report usage → final result/attempt metadata
addTools / terminate / handoff → control intent interpreted at settlement/boundary
checkpoint / typed memo → ordinary working values, retired with that execution
```
Output backpressure must be explicit: await admitted progress or use a bounded buffer drained before
settlement. Delta frames/checkpoints must not silently rewrite an entire growing assistant message
for every token. Caps, image handling and durable log references are part of tool policy.
Observe installs a live-phase subscription, not another executor. Each reconciliation receives a
fresh execution context/signal under the same per-task claim used by effects/recovery/abort. Register
before an initial reconciliation with event=undefined, so an already-satisfied condition is not lost.
Events arriving during a claim queue/coalesce within a bounded policy. Phase exit/cancellation seals
and drains admitted callbacks. A stale callback cannot write through an expired context. Waiting
recovery restores missing subscriptions, not duplicates. Teardown and settlement are distinct.
### 9.4 Watch committed state without gaps
```text
ONE serialized step:
capture main/global observation boundaries
read conversation, bounded transcript page, live task metadata,
queued-input state and requested reduced working previews
register listener
then:
deliver relevant main commits as whole batches
deliver committed working previews and transient notifications separately
```
```text
base { conversation, entries, referencedInputs, tasks, queued, previews, asOf }
commit { firstSeq, lastSeq, changes:[...] }
preview { taskId, kind, value }
signal { kind:"fault" | "handler_error" | "idle", ... }
```
This union is WatchEvent. Entry pages use immutable IDs and a cutoff for deduplication. Commit
filtering can omit unrelated changes but must preserve batch boundaries; clients publish after
reducing the complete delivered batch. A terminal task and its successor must not appear as an
observable idle gap.
Batch-load admission payloads referenced by visible placement entries into referencedInputs; they
need not appear as extra transcript rows. Arbitrary plugin state is hydrated by its explicit state
consumer, not automatically by a transcript watch. A commit listener cannot await a new command on
the same line; schedule it after delivery. Listener exceptions do not undo commits. Slow-consumer
buffering is bounded; a disconnected/lagging client can rebind from a fresh base.
If a Chord adapter needs consecutive revisions, those belong to its live binding:
```text
main commits 104, 117, 130 → binding updates 1, 2, 3
rebind/rewind → fresh base, update counter starts again
```
Journal gaps are not transport gaps. Do not persist another state-sequence allocator. The adapter
publishes only after durable commit and rejects events from retired bindings; its concrete codec
and private-draft integration remain to be chosen.
### 9.5 End-to-end usage
```ts
const h = await Harness.open(storage, hostOptions);
const c = await h.root();
const off = await h.watch(c.id, { limit: 100 }, render);
const accepted = await c.accept("Inspect the parser", { requestId: "web-42" });
const outcome = await c.drive();
const result = await c.result(accepted.inputId);
if (result?.status === "done") show(await h.entry(result.entryId));
// suspended: wait for the needed event/permission, then drive again
```
```ts
// Cancellation is durable intent; keep or start a drive for cleanup.
const driving = c.drive();
await c.abort();
await driving;
await c.drive(); // also covers a previously idle/suspended drive
const child = await c.spawn({
prompt: "Inspect only tests",
context: "fresh",
values: { inherit: [model], set: [setValue(activeTools, ["read"])] },
});
await h.drive(); // supervise detached work too; may suspend on outside events
console.log(await h.task(child));
const alternate = await c.fork({ atEntry: completedAnswerId });
await alternate.accept("Try a different implementation");
await alternate.drive(); // source and unrelated forks remain parked
off();
await h.close();
```
Harness.open receives storage, task definitions, tools/model services, hooks/resources and initial
root values. It creates root plus initial values only for empty storage. Reopening existing storage
creates no new work. Host invocation cancellation/telemetry should be threaded through these calls,
not replaced with a separate tracing or cancellation framework.
## 10. Validation, performance and review decisions
This is a design, not a claim of implementation or benchmark parity. Tests should prove behavior
from committed state, then benchmarks should measure equivalent work under the chosen guarantees.
### 10.1 Backend conformance from the same mutation stream
```text
validated batches ─┬─ reference replay used only by tests
├─ Memory
├─ JSONL → close → reopen
└─ SQLite → close → reopen
compare:
current objects and generic live-task metadata
transcript pages and inherited prefixes
historical contexts, scalar tombstones, list clears/ranges
input/request mappings and result outcomes
visible working lifetimes and next allocated sequence
```
Reference replay is a test oracle, not a required public storage API. Use randomized small histories
and explicit deep-fork cases. Include value-only commits, multi-entry commits and independent child
initialization so message-only tests cannot accidentally pass a broken history implementation.
```text
fork before/after a value delete → correct absence/fallback
fork before/after list clear → correct inherited ranges
fork before a later summary → earlier context, not today's filtered context
nested source cutoffs → no later ancestor version leaks
sticky state → unchanged by historical reads; explicit copy on creation
```
### 10.2 Failure and concurrency matrix
| Scenario | Required observation |
|---|---|
| Persistence paused after construction | Queries/watchers still see the old complete state |
| Commit durable but caller reply lost | Reopen sees the whole batch; keyed retry reuses the input |
| Main + working writes in one batch | Reject without partial mutation |
| Torn final JSONL batch | Discard only that incomplete tail |
| Malformed complete JSONL batch | Fail open, never truncate valid later history silently |
| Retirement committed, unlink fails | Old working contents remain invisible |
| Reused scope, delayed old cleanup | New lifetime survives |
| Effect returns while abort starts | One claim covers effect join and abort reconciliation |
| Parallel tools settle in either order | Existing join becomes ready; exactly one continuation |
| Abort versus join/final settlement | No unmarked successor escapes the selected cancellation |
| Child finishes during owner cancellation | No success outcome overwrites cancellation |
| Observer notification during close | Drain/reject by admission order; no late terminal overwrite |
| Two overlapping drives | One execution per task; permissions stay scoped |
| One drive caller cancels its wait | Other callers and durable task intent are unaffected |
| Queued B precedes A's answer in transcript | B remains pending until its own group completes |
| Provider retry while collapse waits to publish | No generation/collapse dependency deadlock |
| Watch registration races a commit | Base includes it or the stream delivers it, never neither |
Use faux providers, fake processes/clocks and storage barriers. No real providers or paid tokens.
Exercise both durable orders, not a test that happens to win the same race every time. Validate
that failed/no-op commands consume no IDs and that old task IDs remain queryable after compaction.
### 10.3 Performance measurements
```text
workloads:
many short turns, with/without tools
frequent compaction plus cold reopen
parallel/sequential tools and held approvals
large streaming output/checkpoints
deep forks and repeated historical state reads
many completed child conversations, few live tasks
many genuinely live background tasks
queued input, cancellation and result lookup
measure separately:
CPU user/system time; wall latency distributions
process RSS, heap, external/array-buffer memory; peaks and post-GC retained size
actual main/sidecar/database/WAL/auxiliary-file bytes
query count, rows decoded, logical mutations and physical bytes written
```
Same provider messages, tool output, frame cadence, compaction boundaries, hooks, durability mode
and observation workload on both implementations. Warm-up and measurement instrumentation must be
comparable. Include fault handling costs required by the final contract, not just a happy-path spike.
Memory/JSONL history growth is expected. SQLite should avoid full-history JS copies. In every case,
separate backend-owned data, live execution data and faux-provider/test bookkeeping before claiming
anything about the harness working set. Do not hide backend memory when reporting total process use.
The exported pico2 spike reports 9.7 s versus 35.9 s for the lane harness on 2,000 faux turns with
two tools per turn. That motivates investigation, not a validated result for this design. Its
four-way test measures wall time and serialized main-mutation character counts; pico2 scratch and
usage writes bypass that counter. The export has no SQLite implementation. Its projection cache
also means the timing difference cannot be attributed solely to storage interfaces.
The useful lessons are cheap current/live queries, batched ID reads, small mutations and avoiding
history scans on routine execution—not adopting its types, residency machinery or unsafe shortcuts.
### 10.4 Decisions to review before implementation
| Decision | Current proposal / remaining work |
|---|---|
| Historical position | Entry selects its whole commit; reject incomplete exchange targets (§3.3) |
| Sticky conversation inheritance | Copy only explicitly selected current scalars; lists need a deliberate policy (§4.3) |
| State/list vocabulary | Set/delete and append/clear; structured edits as deltas, not arbitrary mutable-list operations |
| Input identity and results | Admission/placement entries plus ordinary result values and input groups (§8.1); finalize normalization/queue policies |
| Background dependency sharing | Required dependencies join foreground cancellation; review interaction with previously detached work |
| Recovery and shutdown | Cooperative close; explicit external adoption/idempotence and bounded host policies |
| Compaction | Correct historical replay first; review checkpoints, token budgeting and oversized/split turns |
| Hooks and tool policy | Confirm transformation/error rules, effective arguments, termination aggregation and conflicting handoffs |
| Observation/replication | Finalize bounded buffering, requested-state hydration and Chord adapter/codec boundaries |
| Backend details | Concrete schema/query plans, snapshot ownership and power-loss policy; no inferred crash guarantees |
| Old-harness capabilities | Explicit parity decisions for provider options/cache behavior, telemetry, usage adjustments, navigation/import and host context |
Do not silently remove an existing capability because the smaller core does not mention it. Decide
whether it belongs in a task, a host integration, or the public contract before implementing that
part. The next step is feedback on this design, not copying the exported spike into the repository.