git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
11 KiB
message_update Write Amplification
Scope: harness-local. Depends on delta tracking for the landed Chord
Op/WireOpvocabulary and scoped storage for durability. Chord delta tracking has landed; scoped storage and this Harness integration have not.
1. The problem
{ type: "message_update", runId, message, event, frame? }
Three representations of the same thing travel together:
message— the fullAssistantMessage;event— anAssistantMessageEvent, which itself carriespartial: AssistantMessage, a second full copy;frame— the actual delta, optional.
Per streamed token this is roughly two complete snapshots plus a delta, so bytes grow quadratically over a response.
The reducer does not even use the delta:
case "message_update":
if (next.operation?.id === event.runId && event.message.role === "assistant") {
next.operation.streamingMessage = event.message;
}
A straight assignment. So on any wire, sending the event is worse than sending a
snapshot — it ships two where replace ships one.
The wire adapter is already halfway to the fix: it drops event and sends message
plus frame. That is the full snapshot together with the delta that produced it.
2. The compact form already exists
/**
* Compact, replayable assistant-message progress. Terminal settlement is
* intentionally excluded and must be persisted separately.
*/
export type AssistantMessageFrame =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start" | "text_delta" | "text_end"; contentIndex: number; ... }
| { type: "thinking_start" | "thinking_delta" | "thinking_end"; ... }
| { type: "toolcall_start" | "toolcall_checkpoint" | "toolcall_delta" | "toolcall_end"; ... };
AssistantMessageFrameEncoder produces it, reduceAssistantMessageFrames folds it,
and openFrameProgress already persists it with appendList. The delta format is
built, used, and durable. It simply is not what message_update carries.
3. Precedent
pi already does wire-is-delta one layer down. PiMessagesEvent — the serialized form
a pi-messages backend sends — has no partial:
| { type: "text_delta"; contentIndex: number; delta: string }
| { type: "text_end"; contentIndex: number; content: string; contentSignature?: string }
pi-messages.ts then rehydrates: it holds a local partial, mutates it per event
(partial.content[i].text += event.delta), and returns the in-process
AssistantMessageEvent with partial attached.
So the convention is established. It just stops at the provider edge instead of continuing through the harness.
4. The change
| { type: "message_update"; runId: string; entryId: string; frame: AssistantMessageFrame }
message and event are removed; frame stops being optional.
The blast radius is small because HarnessEvent is not what most streaming consumers
read. AgentEvent (agent-loop.ts) and AgentSessionEvent (agent-session.ts) are
separate unions that happen to share the tag name and build their own
message_update from the pi-ai event directly. They are out of scope here.
Real consumers and producers of HarnessEvent.message_update:
| site | change |
|---|---|
runtime/drive/response.ts |
emit the required semantic frame; stop attaching full snapshots |
runtime/reducer.ts |
fold the frame instead of assigning event.message |
| lane/facet state adapter | run that fold under the Chord tracker and emit Op[]/encoded WireOp[] |
experimental/harness-wire-adapter.ts |
stop treating raw HarnessEvent as the final replication format |
harness/telemetry.ts |
name list only |
protocol/harness.ts |
replication carries encoded WireOp[], not the raw event |
message_end continues to carry the settled message, because frames deliberately
exclude terminal settlement. That is once per message, not once per token.
5. An incremental applier is needed
reduceAssistantMessageFrames is a whole-stream fold over an Iterable. The
reducer needs a step function:
export function applyAssistantMessageFrame(
state: AssistantFrameState,
frame: AssistantMessageFrame,
): void;
Plain mutation on a plain object. No Draft, no Immer. An earlier draft
argued for a draft-mutating signature so it would compose inside a produce
recipe; that motivation is gone (delta.md §8). The step function is still
needed — the whole-stream version becomes a loop over it — just for the simpler
reason that the reducer folds one frame at a time.
5.1 pi-ai frames stay at the pi-ai boundary; Chord ops cross replication boundaries
AssistantMessageFrame is pi-ai's semantic delta vocabulary (text_delta, text_end, …). Delta tracking §6 defines no second frame wrapper: in-process replication carries Op[], and a wire adapter carries encoded WireOp[]. Pi-ai frames stop at the fold; Chord ops cross the replication boundary.
AssistantMessageFrame is pi-ai's own delta vocabulary and stays. What changes is
that it is no longer the durable unit or the replication unit.
The harness folds frames into LaneView by plain mutation. Under the Chord tracker that yields:
["a",["operation","streamingMessage","content",0,"text"],"Let me "]
Measured, interned ops are smaller than frames on this workload — 13.6 KB against 21.5 KB raw for 200 deltas — because a frame carries three keys of its own where an interned op carries one integer. So the size argument for putting frames on the wire is dead.
What frames keep is semantic: text_end carries authoritative content plus a
signature in one atomic unit, where ops would need two with a weaker contract
about their relationship. That is why they remain the input vocabulary and are
folded before anything crosses a boundary.
5.2 Reducer state must live in the reduced value
Text and thinking fold purely: block.text += frame.delta, and *_end overwrites
with authoritative content plus signature. The ended flag in ReducerBlockState
is used only for validation and can be dropped.
Tool calls cannot. toolcall_delta does state.json += frame.delta, accumulating
a raw JSON string that is never stored on the message — the block holds
arguments, the parsed value. You cannot recover the accumulator from the
snapshot, and you cannot append a delta to a parsed object.
So the accumulator has to become part of the value being folded — e.g.
operation.frameState[contentIndex].json on LaneView — leaving
AssistantMessage clean. Generally:
A replicated reducer's state must be part of the replicated value. Anything held beside it diverges on any consumer that did not run the producer's fold.
The corollary is that arguments itself should not be replicated. It is
derived from json, and a parsed object is a fresh reference on every parse, so
storing both ships two copies of the same information. Derive it on demand.
This is safe precisely because parseStreamingJson is total — four fallbacks
ending in {}, it cannot throw — so a replica derives unconditionally with no
error path and no agreement protocol. There is no parse-failure state to
represent, and no block needs an error slot.
5.3 Parse cost
parseStreamingJson on a growing string once per delta is quadratic per message.
Since arguments is now derived rather than replicated, this cost falls on whoever reads it rather than on every consumer. Presentation can refresh derived arguments at semantic checkpoints and toolcall_end instead of parsing on every delta; that policy is separate from why the encoder currently emits a checkpoint (§6).
6. What toolcall_checkpoint is for
EncoderBlockState carries caughtUp and catchupJson because a queued provider event's shared partial may already be ahead of that event's delta. The checkpoint catches the semantic frame stream up to the authoritative tool-call arguments visible at block start; it is not currently a general late-subscriber protocol.
After frames fold into tracked state, a Chord root replacement (r) is the replication and durable-recovery resync point. toolcall_checkpoint remains semantic input to that fold rather than carrying transport responsibility.
7. Write volume for pending output
openFrameProgress calls appendList(pendingAssistantFrames(...)), so every
frame is a line. A long response is thousands of writes.
The address is renamed pendingAssistantOutput and becomes a list<WireOp[]>, matching pendingToolOutput (tool-output handoff §7.2), and it stops being a list of frames. The progress sink encodes tracked Op[] with one stateful encoder per response before appending each durable WireOp[] batch; explicit rebase() calls produce bounded root-replacement batches for recovery. The list lives in an ephemeral scope so it is unlinked on settle rather than persisting in the main log (scoped storage).
The important property is that this list is not history: deleteList runs on
settle in response.ts, deferred.ts, and terminal.ts. It exists so a crash
mid-response can recover a partial assistant message. The settled message is persisted
separately.
That means per-frame durability buys almost nothing, and the write rate can be traded away directly:
- Coalesce with a bounded, non-resetting window. The first pending frame opens the window; later frames join it without extending the deadline, so a continuously streaming response cannot postpone the first write indefinitely — the failure mode a naive debounce has. Frames admitted during an active write form the next batch.
- Concatenate on flush. A run of
text_deltaframes for onecontentIndexcollapses to a single frame with the joined text. The fold result is identical, so this is lossless for our purposes.
A crash then loses at most one window of in-flight streaming.
7.1 Contrast with DeepSeek Harness
DSH cannot make this trade. Its assistant/chunk events are canonical log entries, so
per-token durability is mandatory and it attacks size instead:
- the same bounded non-resetting write-behind window;
- packed rows — runs of consecutive chunk deltas stored as
text-chunks/reasoning-chunks/tool-call-chunks, lossless and roughly 60% smaller on a real session, with reading unconditional so layout never depends on the write switch; - checksummed zstd frames by default, with recovery from a torn final frame.
Their packing must reconstruct exact event boundaries, sequence numbers and timestamps,
because seq = log.length and validation requires a contiguous logical log. Ours does
not, because the frames are discarded at settle — so plain concatenation is available
to us and packing machinery is not needed.
8. Consequence for ad-hoc listeners
HarnessEvent.message_update stops being self-describing. A listener that attaches
mid-stream sees a delta against a partial it does not hold.
Any correct listener already has the base, because watch() installs the subscription
and captures the snapshot inside one readLane critical section, buffering until
start(). But a listener that calls on("message_update") directly, without a watch,
is no longer viable — worth knowing before committing.