feat(durable): windowed conversation view, ConversationTranscript and runFailure; remove watchEvents

ConversationView.entries is a window over the fork-aware history with before;
conversation.watch(ctx, { from, min, max }) and observeConversation take the
window. ConversationTranscript merges windows into a client's transcript with
gaps, loadEarlier, fillGap and resume. pi.live.runFailure says why a run ended
without an answer. watchEvents and its event types are removed; the changelog
has the migration guide.

closes #11327
This commit is contained in:
Mario Zechner 2026-10-27 16:57:24 +00:00
parent 72363383ff
commit f3255de765
37 changed files with 3074 additions and 2065 deletions

View file

@ -315,8 +315,10 @@ Context derivation:
kind ahead of the baseline therefore keeps the baseline from leading.
Stored entries and `contributions` keep the committed order.
Views carry raw active entries. UI reduction and model-context reduction are
separate consumers. Older stored history is available through the owning
The conversation view (section 9.3) carries a window over the visible
entries in ID order, head markers in place; `Conversation.context()` derives
the active transcript. UI reduction and model-context reduction are separate
consumers. Entries outside the window are available through the owning
`Conversation` object's cursor-based `entries()` scan.
### 2.2 Public Harness surface
@ -685,8 +687,8 @@ interface Conversation {
/** `{ background: true }` also aborts background work under the conversation (section 5.4). */
abort(context: Context, options?: { readonly background?: boolean }): Promise<void>;
waitForIdle(context: Context): Promise<void>;
/** The structural view (section 9.3) as a watch. */
watch(context: Context): Promise<ConversationWatch>;
/** The structural view (section 9.3) as a watch; `window` bounds its entries. */
watch(context: Context, window?: ConversationWindow): Promise<ConversationWatch>;
}
interface Harness extends Session {
@ -716,10 +718,10 @@ interface Harness extends Session {
waitForIdle(context: Context): Promise<void>;
/** Session total of every conversation's `pi.usage` (section 8.6). */
usage(context: Context): Promise<UsageState>;
/** Live tasks as a structural view (section 9.5), as a watch. */
/** Live tasks as a structural view (section 9.4), as a watch. */
watchTaskGraph(context: Context): Promise<TaskGraphWatch>;
/** Synchronous observers of a conversation view and of the task graph, for adapters (section 9.1). */
observeConversation(id: ConversationId, observer: Observer<ConversationView>, context: Context): Promise<Observation<ConversationView>>;
observeConversation(id: ConversationId, observer: Observer<ConversationView>, context: Context, window?: ConversationWindow): Promise<Observation<ConversationView>>;
observeTaskGraph(observer: Observer<TaskGraph>, context: Context): Promise<Observation<TaskGraph>>;
}
@ -924,7 +926,7 @@ replace its model configuration without reopening the Harness. Settings are sync
modes are read on the Session line. `stream` is forwarded to generation
requests; `stream.maxRetries` are provider retries inside one request, while
`retry` governs durable generation attempts (section 8.3). Settings changes make
no commit and emit no event.
no commit and change no view.
```ts
// The user's settings, read live through getters.
@ -1025,7 +1027,7 @@ wait aborts only that waiter.
Conversation handles are stateless; compare them by `id`. Hosts discover
conversations through lookups and scans. There is no creation listener; the task
graph view (section 9.5), `inspect()`, and the idle waits report live work.
graph view (section 9.4), `inspect()`, and the idle waits report live work.
`submit()` returns after durable admission, not settlement. An input submission
creates a user message with the Harness clock's timestamp at placement, which is
@ -1867,7 +1869,7 @@ at its stored version. Anything else, such as an entry, a task state, a
submission, another document, a document's creation, migration, or retirement,
makes it immediate, a normal commit. A held commit is adopted in memory by
pointer swap, resolves, and is published to the Session's own observers: document
watches, the conversation view and agent events (section 9). Its stored
watches and the conversation view (section 9). Its stored
version and delta count stay; its operations wait, folded per incarnation
(Chord's `PendingOps`), and the incarnation is pinned in the cache, so idle
eviction skips it at any retention.
@ -2404,7 +2406,7 @@ runtime state, not a persisted task status. A blocked task is not active in
`wake()` (section 5.6): only a registry change, or `retryTask()` for a parked
one, can let it run, and either makes `onWake` report it active, so a host need
not poll it. The task graph shows the reason and follows registry changes
(section 9.5). Document migration remains
(section 9.4). Document migration remains
access-driven.
Aborting a blocked task cannot run its abort handler, because that code is
@ -2694,7 +2696,7 @@ An input is placed as one entry whose `model` is the user message of its
`content`: a `pi.user` entry, or, with `entry: { kind, data }`, an entry of that
kind carrying that data. Only `kind` and `data` are taken from `entry`; admission
rejects a kind that is not a non-empty string or starts with `pi.`. Nothing in
the Harness reads input entries by kind; context, compaction, events, and run
the Harness reads input entries by kind; context, compaction, and run
accounting go by `model` and the submission records.
A write of only `kind` and `data`, with no `model`, `head`, or `edits` (present
@ -3076,8 +3078,8 @@ await conversation.configure({ extensions: { add: [Venv] } }, context);
| boundaries and idle admission, on the Session line | queue modes | per boundary (section 6) |
| tool task, `prepare`, `runtime.env()` | environment | at each call (section 2.2) |
Registry installs and settings changes make no commit, so they emit nothing
(section 9.4). A conversation on the default selection changes when the
Registry installs and settings changes make no commit, so no view changes
(section 9.3). A conversation on the default selection changes when the
settings or the installed extensions change; its next preparation appends the
system delta (section 7.4). A UI showing resolved tools or a resolved model
resolves again through `Conversation.agent()`.
@ -3689,7 +3691,7 @@ that submission's result. Provider call IDs are not unique across a Session and
must not serve as request IDs. Aborting or abnormally
terminalizing the tool task cascades through the owned scope. The tool reports
the child in its running details, for example `api.details({ conversationId })`,
so a UI that sees the call can attach to the child's view or events.
so a UI that sees the call can attach to the child's view.
```ts
export const Subagent = defineExtension({
@ -4206,7 +4208,7 @@ type LiveState = {
* IDs ascend.
*/
nestedTools?: {
/** `<parentCallId>/<key>`: the call's ID in tool events, unique because keys contain no `/`. */
/** `<parentCallId>/<key>`: the call's ID, unique because keys contain no `/`. */
callId: string;
parentCallId: string;
/** The calling tool task, model-issued or nested; the Harness relates slots by task ID. */
@ -4252,6 +4254,25 @@ type LiveState = {
* after the task ended.
*/
compactionFailure?: CompactionFailure;
/**
* Why the newest run ended without an answer, so a client can show it after the run ended. Set in the commit that
* ends the run; removed when a later commit starts a run.
*/
runFailure?: RunFailure;
};
/** The newest run that ended without an answer. */
type RunFailure = {
/** The run's task at its end. */
taskId: TaskId;
/** The run's inputs, settled `unanswered`. */
inputs: SubmissionId[];
reason: "model_error" | "no_model" | "aborted" | "reset" | "faulted" | "orphaned";
/**
* `model_error`: the error text; `no_model`: which model is missing; `faulted`: the error message; `orphaned`: why
* the task could not run (`missing_task`, `task_too_old`, `migration_failed`).
*/
detail?: string;
};
type CompactionFailure = {
@ -4321,7 +4342,26 @@ that outcome holds as `completing` (section 5.5), or the scheduler's cleanup
It stays small: the summary text lives in the task's placement, never here. Partials are
normalized to strict JSON before assignment. Every terminal path of a run task
removes `run`, `generation`, and `tools` in the commit that settles the run's
inputs. `tx.settleSubmission()` stages each input's new status
inputs.
A run that ends without an answer records why in `runFailure`, in the commit
that settles its inputs `unanswered`:
| reason | the run ended because | input settlement |
|---|---|---|
| `model_error` | the response failed after any retries, or a blocking or overflow compaction it needed failed; `detail` is the error text | `model_error` with the text |
| `no_model` | no model is configured, or the configured one is not available; `detail` names it | `no_model` |
| `aborted` | its task was aborted (`Conversation.abort()`, `abortTask()`) | `aborted` |
| `reset` | a queued reset started a new context at the post-tools boundary (a tool's handoff continues the run) | `reset` |
| `faulted` | the scheduler faulted its task (section 5.4); `detail` is the error message | `faulted` with the message |
| `orphaned` | its task was aborted while no definition could take it; `detail` is the reason | the reason |
The next commit that starts a run removes it. A run started in the same commit,
the queue's next run under `afterFailedRun: "continue"` (section 6) or after a
reset, keeps it, so the failure is shown until a later run starts. A run that
ends with an answer, including a round whose tools all ask to terminate, leaves
it alone. Inputs settled without a run (withdrawn, stale, aborted while
queued, `run_failed`) are not run failures; their submission records say why. `tx.settleSubmission()` stages each input's new status
and resolves the transaction's latest candidate submission record, falling back
to committed state, during assembly, like task-document validation (section 3.3),
so it is not a caller table read and works after the commit's first table write.
@ -4922,7 +4962,7 @@ interface Session {
// ... and the other five scopes (section 3.3), the observer before the context
}
interface Harness {
observeConversation(id: ConversationId, observer: Observer<ConversationView>, context: Context): Promise<Observation<ConversationView>>;
observeConversation(id: ConversationId, observer: Observer<ConversationView>, context: Context, window?: ConversationWindow): Promise<Observation<ConversationView>>;
observeTaskGraph(observer: Observer<TaskGraph>, context: Context): Promise<Observation<TaskGraph>>;
}
```
@ -4936,7 +4976,7 @@ the graph at most one call, and a document observer one call per change at its
address: a commit that retires the document and creates it again gives two, the
retirement and then the new incarnation's `[["r", value]]`. The task graph also
advances without a commit, when a registry change or a failed migration changes
a task's `blocked` reason (section 9.5): that call runs on the Session line,
a task's `blocked` reason (section 9.4): that call runs on the Session line,
outside any `adopt`, with a context of its own. A document observer follows its
address as a document watch does, and `observeDoc()` gives `undefined` for an
absent one. `end` comes once: when the Session closes (`undefined`) or fails
@ -4960,8 +5000,9 @@ and returns: no awaits, no Session calls, no throws, and quick, as its time is
part of every commit; so does `end` when a task ends. One that throws anyway is
reported as `listener_error`, detached, and ended with the error; the commit and
the other observers are not affected. The change object is plain data, shared by
every observer of a view and by the watches built on it, and must not be
mutated.
every observer of a document or the task graph and by the watches built on it,
and must not be mutated; each observer of a conversation view gets its own, for
its own window (section 9.3), sharing the document values.
### 9.2 `watchDoc`
@ -5033,227 +5074,188 @@ rejection is observed.
### 9.3 Conversation view
The public view is a fixed structural mount of the conversation's active
The public view is a fixed structural mount of a window over the conversation's
transcript and its built-in documents:
```ts
type ConversationView = {
readonly conversation: ConversationRecord;
/** Raw active entries, as `ContextView.entries` (section 2.1): the head marker, then the non-head entries from its head. */
/**
* A window over the transcript, its fork-aware history (`Conversation.entries()`), in entry ID order. Head markers
* such as compaction summaries sit where they were written; their `head` tells where the entries they summarize end.
*/
readonly entries: readonly EntryRecord[];
/** ID of the entry just before the window; absent when the window starts the transcript. */
readonly before?: EntryId;
/** `pi.agent`, `pi.live`, `pi.inbox`, `pi.provider`, and `pi.usage`, keyed by kind; absent documents are absent. */
readonly docs: Readonly<Record<string, JsonObject>>;
};
type ConversationWindow = {
/**
* Where the first value's window starts, inclusive, such as right after the newest entry a client has
* (`ConversationTranscript.resume`). It selects only that first value.
*/
readonly from?: EntryId;
/** Default 50; an integer, at least 1. */
readonly min?: number;
/** Default `2 * min`; an integer, at least `min`. */
readonly max?: number;
};
interface Conversation {
watch(context: Context, window?: ConversationWindow): Promise<ConversationWatch>;
}
interface Harness {
observeConversation(id: ConversationId, observer: Observer<ConversationView>, context: Context, window?: ConversationWindow): Promise<Observation<ConversationView>>;
}
```
These kinds and their fields are public protocol. Third-party documents are
exposed through their own Chord objects, not mounted.
exposed through their own Chord objects, not mounted. Everything a renderer
draws is state in this value: the transcript in `entries`, the run and its
presentation in `pi.live` (`run`, `generation`, `tools`, `nestedTools`,
`compactions`, `compactionFailure`, `runFailure`, section 8.2), the queue in
`pi.inbox`, the agent in `pi.agent`, spend in `pi.usage`. A renderer renders
the newest value; it never assembles state from a stream of events.
**The window.** Each watch and observer has its own window. It starts with the
newest `min` entries and the ID of the entry before them in `before`. With
`from`, it starts at `from` instead, when at most `max` entries are at or after
it; then `before` is the newest entry before `from`, and a window that resumes
right after the newest entry starts empty. Otherwise it starts at the newest
`min`, and `before` shows the client the entries it missed. `from` only picks
that first value: every entry committed after the attachment is appended,
whatever its ID, so a `from` past the newest entry, such as one a client
computed against another replica, gives an empty window whose `before` is the
newest entry, and then every entry that follows. The window has a stable start,
so new entries are appends. When it would hold more than `max`
entries, its oldest leave in one splice down to `min`, and `before` moves to
the last one that left:
```text
min 3, max 5; the window holds 4..7, before 3
commit appends 8: ["p", ["entries"], 4, 0, [8]] window 4..8
commit appends 9: ["p", ["entries"], 5, 0, [9]], ["p", ["entries"], 0, 3, []], ["s", ["before"], 6]
window 7..9, before 6
```
A commit that appends more than `max` entries at once keeps its newest `min`.
A first value, and an overflow replacement (section 9.2), therefore hold at
most `max` entries, never the whole transcript. Entry IDs are ordered but not
contiguous: a client never counts entries or infers ranges from IDs. `before`
tells whether a window continues what the client has, and
`Conversation.entries()` with `minEntryId`/`maxEntryId` and its cursor fetches
what lies in between; resuming from right after the newest entry and
`maxEntryId: oldest - 1` are exclusive bounds, the only arithmetic on IDs a
client does, and `ConversationTranscript` does both. A window with
bounds that are not integers, or `max` below `min`, rejects with a
`RangeError`.
Head markers stay in place: a compaction summary, a reset, or a handoff is an
appended entry like any other. The view does not derive the active
transcript; `Conversation.context()` does (section 2.1).
The Harness keeps at most one mount per conversation. The first `watch()` or
event attachment builds its revision on the Session line from the
committed active transcript and documents; the mount is dropped when its last
observer builds its documents on the Session line, and each attachment reads
its window there with bounded scans; the mount is dropped when its last
observer detaches. A mount derives each next revision from the Session's
commits as memory adopts them, for a stored commit inside Storage's `adopt`
(section 4) and for a held one when memory adopts it (section 4.1), like
`watchDoc()`: it shows committed state,
including deferred commits before they are stored (section 4.1). One publication that
touches the conversation yields one Chord batch:
`watchDoc()`: it shows committed state, including deferred commits before they
are stored (section 4.1). One publication that touches the conversation yields
one Chord batch per observer: the document operations, applied once and
shared, then that observer's entry splices:
```text
document op ["s", ["generation", "message"], value]
-> view op ["s", ["docs", "pi.live", "generation", "message"], value]
```
An appended entry without a head is a splice at the end of `entries`. An
appended head marker `H` makes the entries `H` followed by the current non-head
entries at or after `H.head`: the kept entries stay, and splices remove the
others and insert `H` at the front. Every kept entry is already mounted because
Harness writers never target a head before the current range: resets and
handoffs head themselves, compaction summaries target a kept entry of the
current range, and head writes that reach further back are stale (section 6). A raw head write that targets further back shows only the
mounted entries (section 12).
Document creation and retirement set and delete the `docs` key. Parent entries
through `parent.at` are immutable, so a child's mount follows only its own
conversation. A publication that touches nothing mounted creates no revision; a
redundant nonempty batch remains a real publication. Mounted document revisions
are structurally shared under the trusted immutability contract. The mount
performs no semantic projection and owns no persistence.
are structurally shared under the trusted immutability contract, and an entry
that stays in a window keeps its identity across revisions. The cost of a
commit is its operations plus, per observer that gets entries, one copy of its
window, at most `max` entries. The mount performs no semantic projection and
owns no persistence.
`Conversation.watch()` exposes the mount through the serialized watch of
section 9.2, with folded pending commits and a full-value overflow replacement;
`Harness.observeConversation()` attaches a synchronous observer of the same mount
(section 9.1). A view never retires, so its changes never set `retired`.
### 9.4 Agent events (experimental)
`watchEvents(harness, conversationId, context): Promise<AgentEventStream>`,
exported from the package root, is an experimental adapter that
translates one conversation's committed publications into agent events shaped
like the coding agent's `AgentSessionEvent`s. It owns no tracker or persistence,
covers exactly one conversation (not its owned subtree), and emits an event only
after the commit that makes it true. Its protocol may change without notice.
**A client's transcript.** A window only ever adds to what a client has seen:
entries never change once committed, and those that leave the window stay with
the client. `ConversationTranscript` keeps them, as a layer over any view,
wherever it comes from (a watch, or a replica of it on another host):
```ts
/**
* Which call a tool event is about. `toolCallId` is the provider's ID for a model-issued call and
* `<parent call ID>/<key>` for a nested one; consumers match it and never parse it. `taskId` is the call's tool task,
* absent for a call that never got one. A nested call also names its caller by call ID and by tool task.
*/
type ToolEventCall = {
toolCallId: string;
toolName: string;
taskId?: TaskId;
parentToolCallId?: string;
parentTaskId?: TaskId;
};
type TranscriptItem =
| { readonly kind: "entry"; readonly entry: EntryRecord }
/** Entries strictly between `after` and `upTo` are missing: render "load more". */
| { readonly kind: "gap"; readonly after: EntryId; readonly upTo: EntryId }
/** The transcript continues above: render "load earlier". */
| { readonly kind: "earlier" };
type AgentEvent =
| {
type: "snapshot";
entries: readonly EntryRecord[];
run?: { inputs: readonly SubmissionId[] };
/** Current generation attempt: its in-flight partial, retry backoff, or deferred poll. */
generation?: { attempt: number; message?: AssistantMessage; retry?: { at: number; error: string }; deferred?: { pollAt: number } };
tools: readonly ToolSlot[];
/** `pi.live.nestedTools` (section 8.2). */
nestedTools: readonly NestedToolSlot[];
/** `pi.live.compactions` (section 8.2). */
compactions: readonly CompactionStatus[];
/** `pi.live.compactionFailure` (section 8.2). */
compactionFailure?: CompactionFailure;
inbox: readonly { id: SubmissionId; mode: InboxItem["mode"] }[];
/** `pi.agent`; `{}` when absent. */
agent: AgentState;
usage: UsageState;
}
| { type: "run_start"; inputs: readonly SubmissionId[] }
| { type: "run_end"; inputs: readonly SubmissionId[] }
| { type: "turn_start" }
| { type: "turn_end" }
| { type: "message_start"; message: Message }
| { type: "message_update"; usage: Usage; changes: readonly MessageChange[] }
| { type: "message_end"; entry: EntryRecord }
| ({ type: "tool_execution_start"; args: JsonObject } & ToolEventCall)
| ({
type: "tool_execution_update";
/** A front trim and then an append of the retained window, or its replacement. */
output?: { trimStart?: number; append?: string } | { set: string };
details?: JsonValue;
diagnostics?: readonly ToolDiagnostic[];
} & ToolEventCall)
/** A model-issued call ends with its `entry`, a nested call with its `result`; see below for when neither. */
| ({ type: "tool_execution_end"; entry?: EntryRecord; result?: NestedToolExecutionResult } & ToolEventCall)
| { type: "inbox_update"; items: readonly { id: SubmissionId; mode: InboxItem["mode"] }[] }
| { type: "submission"; record: SubmissionRecord }
| { type: "auto_retry_start"; attempt: number; at: number; errorMessage: string }
| { type: "auto_retry_end"; attempt: number }
| { type: "deferred_poll"; pollAt: number }
| { type: "entry_appended"; entry: EntryRecord }
| { type: "agent_changed"; agent: AgentState }
| { type: "usage_changed"; usage: UsageState }
| { type: "task_failed"; taskId: TaskId; kind: string; message: string }
| { type: "compaction_start"; taskId: TaskId; reason: CompactionReason; blocking: boolean }
/** The task's receipt tells whether it produced a summary; the summary entry has its own events. */
| { type: "compaction_end"; taskId: TaskId; reason: CompactionReason; error?: string };
/** The conversation view as a renderer sees it: `items` instead of the window. */
type TranscriptView = Omit<ConversationView, "entries" | "before"> & { readonly items: readonly TranscriptItem[] };
/** One change to the in-flight assistant message, relative to that message. */
type MessageChange =
| { type: "text_start" | "thinking_start" | "toolcall_start"; contentIndex: number; block: AssistantMessage["content"][number] }
| { type: "text_delta" | "thinking_delta"; contentIndex: number; delta: string }
| { type: "toolcall_delta"; contentIndex: number; path: readonly (string | number)[]; delta: string }
| { type: "block"; contentIndex: number; block: AssistantMessage["content"][number] }
| { type: "message"; message: AssistantMessage };
type EntryLoader = (query: Omit<EntryQuery, "conversationId">, limit: number, context: Context) => Promise<Page<EntryRecord, Cursor>>;
interface AgentEventStream {
/** The `snapshot` event at attachment. */
readonly snapshot: Extract<AgentEvent, { type: "snapshot" }>;
/** Each call's `value` is one commit's batch, or a `snapshot` after an overflow (Chord's `NativeWatchChange`). */
start(listener: (change: { readonly value: readonly AgentEvent[] }, context: Context) => Promise<void>): void;
stop(): Promise<WatchEnd>;
readonly closed: Promise<WatchEnd>;
class ConversationTranscript {
constructor(load: EntryLoader, options?: { readonly pageSize?: number }); // default 50
/** Call with every value of the view. */
merge(view: ConversationView): TranscriptView;
/** The page before the oldest entry held. */
loadEarlier(context: Context): Promise<TranscriptView>;
/** The next page of a gap, from its start. */
fillGap(gap: Extract<TranscriptItem, { kind: "gap" }>, context: Context): Promise<TranscriptView>;
/** The ID the transcript reaches: its newest entry, or a later empty window's `before`. */
readonly newest: EntryId | undefined;
/** `{ from }` right after `newest`, `{}` for an empty transcript, undefined before the first merge. */
readonly resume: ConversationWindow | undefined;
}
```
Every event is relative to the `snapshot` and the events before it, never to
Durable's document layout. `snapshot` replaces everything a consumer holds; every
other event applies on top of it. Attachment captures the snapshot and
registers for later publications atomically on the Session line.
```ts
const transcript = new ConversationTranscript((query, limit, ctx) => conversation.entries(query, limit, undefined, ctx));
const watch = await conversation.watch(context);
render(transcript.merge(watch.value));
watch.start(async ({ value }) => render(transcript.merge(value)));
// A marker scrolled into view:
render(await transcript.loadEarlier(context));
// Reconnect after a drop:
const resumed = await conversation.watch(context, transcript.resume);
```
Events derive from committed changes:
A window whose `before` lies past the newest entry the client has does not
continue it: after a stall, an overflow, or a reconnect, the entries in between
become a gap. While the client keeps up there is never one: the window only
appends, and its splice removes entries the client already holds.
`fillGap()` loads `pageSize` entries from the gap's start and shrinks it, or
removes it once nothing is missing; `loadEarlier()` loads the page before the
oldest entry held, and the `earlier` item disappears once a page reaches the
transcript's start. A window, or a page, that covers part of a gap fills that
part. Items are in ID order: `earlier` first, then entries, each gap before
the entry after it.
- `run_start`/`run_end`: `pi.live.run` appears, is removed, or is replaced by a
successor run whose first input differs. Steers joining the current run and
handovers between its tasks are not run events. The inputs' outcomes are
`submission` events.
- `turn_start`: a `pi.generation` task is created. `turn_end`: a generation's
outcome is committed, when it holds `completing` or becomes terminal,
whichever comes first, so a successor created at hold starts after it.
- `message_start`: the first committed partial of an attempt, or, for a message
entry without a committed partial, the entry itself; `message_end` follows for
every entry with `model` messages. Generation commits a partial only once it has
content, and every built-in path that clears one appends its entry, so each
started message ends with its entry. Entries without messages are
`entry_appended`.
- `message_update`: the Chord operations on the committed partial, translated:
an insertion into `content` starts blocks, an append to a block's `text` or
`thinking` is a delta, an append to a string inside a tool call's `arguments`
is a `toolcall_delta` with the path relative to `arguments`, any other change
inside a block sends that `block`, which already holds the batch's later
changes to it, and any other change to `content` sends the whole `message`.
Each update carries the partial's current `usage`, as the coding agent's JSON
mode does; a change of only `usage` sends no changes. Only deltas travel, so throttled
partials cause no write amplification on the wire.
- `tool_execution_start`: a slot becomes `running`; `args` come from the tool
task's intent checkpoint in the same commit. A slot that becomes `done`
without running (a call not offered, invalid arguments, a block, a fault
before intent) gets only `tool_execution_end`. `tool_execution_update`: output
appends, front trims, and replacements of the slot's retained window, and the
slot's current `details` and `diagnostics` when they change; removed ones, as
when a safe replay restarts the tool, send `null` and `[]`.
`tool_execution_end`: the slot becomes `done`, with its `pi.tool-result`
entry, which carries the diagnostics, or without one after a fault or
orphan. An unfinished slot that disappears because its run ended ends with the
result entry appended in the same commit, as for the unstarted calls of an
aborted round, directly before its `message_start`, or without an entry.
Nested slots produce the same events with `parentToolCallId`; a nested call
ends with its `result`, taken from the `NestedCallDoc` value its terminal
commit published, or without one after a fault or orphan or when its slot
disappears unfinished. Snapshots show done nested slots with their summary,
not their result; a late subscriber reads a live caller's results from its
`NestedCallDoc`, keyed by call key (the slot's `callId` after `<parentCallId>/`).
- `inbox_update`, `agent_changed`, `usage_changed`: the document changed; a
retired one reads as its initial value, as in a snapshot. Registry installs
and settings changes make no commit and emit nothing; a UI showing resolved
tools or a resolved model resolves again through `Conversation.agent()`.
- `auto_retry_start`/`auto_retry_end`, `deferred_poll`: `pi.live.generation`
gains or drops `retry`, or gains `deferred` or moves its `pollAt`.
- `task_failed`: a task of the conversation settles `faulted` or `orphaned`.
- `compaction_start`/`compaction_end`: a status appears in or disappears from
`pi.live.compactions`. `compaction_end` carries `error` when the same commit
recorded the task in `pi.live.compactionFailure`. A retry backoff shows only
in the snapshot's `compactions`. A queued summary is placed later with its own
`message_start`/`message_end` and `submission` events.
`items` is a plain array. A merge costs O(items), never re-sorts or
re-fetches, and one that adds nothing returns the previous `items` array; with
the same `docs` and `conversation` it returns the previous view itself.
Concurrent `loadEarlier()` calls, and concurrent `fillGap()` calls for one gap,
share one load, also when one passes a gap descriptor that has shrunk since; a
load that rejects, or whose loader throws, is not kept, so the next call loads
again. A page without a next cursor, or without items, ends the range: the gap
closes, or the transcript's start is reached. An empty window adds nothing.
`loadEarlier()` before the first merge rejects; after the start is reached, and
`fillGap()` for a gap that is gone, return the current view.
Keeping the scroll position when items are inserted above is the renderer's
job.
One commit produces one batch, in this order: `tool_execution_start`,
`message_start` of a first partial, `message_update`, `tool_execution_update`,
and retry/deferred events; then entries in append order with their
`message_start`/`message_end` or `entry_appended`, where a tool result's
`tool_execution_end` directly precedes its `message_start`, as in the coding
agent, and the `tool_execution_end` of nested calls, children first, precede
the entries; then the `tool_execution_end` of tools ending without an entry;
then `compaction_end`, `task_failed`, `turn_end`, `run_end`; then `submission`
events in ID order; then `inbox_update`, `agent_changed`, and `usage_changed`;
and last `compaction_start`, `run_start`, and `turn_start`. A stream buffers at most 100 undelivered
batches; adding another replaces every undelivered batch with one `snapshot` of
the newest committed state. The stream therefore converges but does not promise
every transition; `Submission.wait()` reports exact outcomes. A reconnecting
consumer attaches again and starts from its `snapshot`; nothing is replayed.
Transport backpressure and disconnect policy belong to the consumer.
Print mode awaits its own input `Submission` and prints its answer. A TUI
renders `ConversationView`; events may drive transient animation.
### 9.5 Task graph view
### 9.4 Task graph view
The task graph view shows every live task of the Session as one structural
value, for UIs and debugging: what runs, what waits for what, and which
@ -5574,8 +5576,7 @@ may not have committed, so a host that resubmits after reopening reuses its
and every wait that starts later rejects with `SessionFailed`, whose `cause` is
the first error; so does every Storage call still underway when it returns,
whatever it returned.
- Watches and event streams end with `{ reason: "failed", error }` and keep
their last value.
- Watches end with `{ reason: "failed", error }` and keep their last value.
- Running task invocations get their abort signal. `HarnessOptions.onReport`
gets the error once, after the Session is sealed, so a report handler that
calls back finds it failed.
@ -5785,8 +5786,8 @@ These are contracts, not invitations to add defensive machinery:
messages, can misjudge; the Harness estimate uses entry order (section 8.3).
- **Raw head writes into the past:** a head written directly with `tx.appendEntry()`
that targets an entry before the conversation's active range changes model
context, but a mounted view keeps only the entries it already holds until the
mount is rebuilt. Use a write submission, whose stale check rejects it.
context in ways a queued write would not. Use a write submission, whose stale
check rejects it.
- **Double-counted subagent spend:** a tool that runs an owned conversation must
not report that conversation's usage in its result; the child's `pi.usage`
already counts it, and subtree sums would count it twice.