mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(agent): align assistant output handoff with Chord
This commit is contained in:
parent
2865393cec
commit
c02552484c
12 changed files with 143 additions and 314 deletions
|
|
@ -364,7 +364,7 @@ The file is the **replay recipe** for the Memory maps, not the state. One physic
|
|||
|
||||
**Snapshot compaction (J1 — specified, not implemented).** In SQLite a value `set` is an in-place upsert; in JSONL every `set` appends, so a 30-turn run leaves ~10 dead `pi.op.state` lines after the terminal `delete`: the file grows with write history even though logical state does not. The specified fix rewrites the file as `header + current entries + current values + surviving list elements + usage rows` via temp file + atomic rename. Surviving lines keep their original `seq` values (dropped-line gaps are legal; no renumbering). Each surviving list element is rewritten as an append record carrying its original `seq`, merged in sequence order — never collapsed into one synthetic append — so list cursors survive. Deleted lists produce no snapshot records; the `nextSeq` high-water mark is preserved so dropping a trailing delete line cannot permit sequence reuse. Compact on open when the dead-bytes ratio crosses a threshold, after a terminal or outcome-staging deletion pushes the file across it, and always after a schema migration (Part 7); between compactions, operation is append-only and O(1) per commit.
|
||||
|
||||
Until J1 lands, deleted pending payloads, superseded state revisions, superseded tool checkpoints, and deleted frame lists linger as bytes indefinitely — logical deletion is immediate; physical deletion currently never happens. Tool authors therefore own bounded checkpoint values, cadence, and duplicate suppression (bash: live updates at 100 ms, checkpoints at most every two seconds, only when changed; at 50 KiB per checkpoint, continuously changing output adds ~15 MiB per ten minutes). Assistant frame lists grow linearly with model output; M11 (Part 8) tracks bounding that amplification. One small immutable `pi.result` record per terminal operation is retained forever and copied into every later snapshot — result growth is linear in operation count by design. Deployments needing prompt physical removal of sensitive cancelled content compact eagerly at terminal boundaries, once J1 exists.
|
||||
Until J1 lands, deleted pending payloads, superseded state revisions, superseded tool checkpoints, and deleted frame lists linger as bytes indefinitely — logical deletion is immediate; physical deletion currently never happens. Tool authors therefore own bounded checkpoint values, cadence, and duplicate suppression (bash: live updates at 100 ms, checkpoints at most every two seconds, only when changed; at 50 KiB per checkpoint, continuously changing output adds ~15 MiB per ten minutes). Assistant frame lists grow linearly with model output; the [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md) replaces per-frame durable and replication writes with tracked output in scoped storage. One small immutable `pi.result` record per terminal operation is retained forever and copied into every later snapshot — result growth is linear in operation count by design. Deployments needing prompt physical removal of sensitive cancelled content compact eagerly at terminal boundaries, once J1 exists.
|
||||
|
||||
### SQLite
|
||||
|
||||
|
|
@ -747,7 +747,7 @@ A retry timer runs off the mutation line and enters `assistant.ready` only after
|
|||
|
||||
During an admitted assistant or deferred effect, one `AssistantMessageFrameEncoder` converts provider events to compact recovery frames. A convertible event synchronously enqueues one invocation-fenced append to `pendingAssistantFrames(operationId, responseEntryId)` and emits the corresponding live message event. The provider loop never awaits storage per frame; Session FIFO preserves order, every promise carries fault observation, and settlement awaits the latest queued frame write before `after_response` and the final commit.
|
||||
|
||||
Each append checks the same response id is still effect-pending: an append admitted before settlement may commit first; one reaching the line after settlement declines and cannot recreate the list. Frames are auxiliary — absence is legal, they do not prove request completion, and a complete-looking prefix still restores as unknown outcome until settlement commits. Recovery reduces the exact list with `reduceAssistantMessageFrames`, synthesizes the documented partial result, and deletes the list with its next durable decision. Structural summary streams intentionally persist no frames. JSONL records the appends physically until snapshot compaction (J1) even after logical deletion; bounding that amplification without weakening recovery is M11 (Part 8).
|
||||
Each append checks the same response id is still effect-pending: an append admitted before settlement may commit first; one reaching the line after settlement declines and cannot recreate the list. Frames are auxiliary — absence is legal, they do not prove request completion, and a complete-looking prefix still restores as unknown outcome until settlement commits. Recovery reduces the exact list with `reduceAssistantMessageFrames`, synthesizes the documented partial result, and deletes the list with its next durable decision. Structural summary streams intentionally persist no frames. JSONL records the appends physically until snapshot compaction (J1) even after logical deletion. The [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md) replaces this path with tracked pending output in ephemeral scoped storage while preserving unknown-outcome recovery.
|
||||
|
||||
### Classification order
|
||||
|
||||
|
|
@ -1275,14 +1275,14 @@ Workflow: keep a future package's row here until actionable; move exact files/te
|
|||
| WP02 | complete | Atomic prompt/skill/template acceptance, minimal open-operation attachment, Session mutation inspection, gap-free lane watch capture. | [Atomic acceptance and coherent attachment](work-packages/02-atomic-run-acceptance.md) |
|
||||
| WP03 | complete | Removed the wall-clock drive deadline and non-durable yielded outcome. | [Remove drive deadlines](work-packages/03-remove-drive-deadlines.md) |
|
||||
| WP04 | complete | Synchronous `emitBatch` publication; Session owns committed lane publication. | [Mutation publication and event delivery](work-packages/04-mutation-publication.md) |
|
||||
| WP05 | complete | The total direct durable graph, public/replicated lane surfaces, immutable results, atomic boundaries, cancellation reconciliation, lane-safe provider identity. M11 is its only recorded follow-up. | [Direct durable drive](work-packages/05-direct-durable-drive.md) |
|
||||
| WP05 | complete | The total direct durable graph, public/replicated lane surfaces, immutable results, atomic boundaries, cancellation reconciliation, lane-safe provider identity. The [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md) is its only recorded follow-up. | [Direct durable drive](work-packages/05-direct-durable-drive.md) |
|
||||
| WP06 | complete | Separated Session, Branch, AgentLane, AgentHarness; one keyless Session mutation line. | [Session, Branch, Lane separation](work-packages/06-session-branch-lane-separation.md) |
|
||||
| WP07 | complete | Removed SQLite storage-layer ownership; added live read-only source forks, no-create opens, deletion reservation, physical/path safety, and all-settled close. | [SQLite host ownership and live forks](work-packages/07-sqlite-host-ownership-live-forks.md) |
|
||||
| WP08 | actionable, not implemented | Replace implicit-main forks with named-branch/tree semantics and bounded-memory backend copies. | [Named-branch and tree forks with streaming copies](work-packages/08-named-branch-streaming-forks.md) |
|
||||
|
||||
WP05 subsumed the former R2–R12 execution rows; their implemented contract is in Parts 0–5 and the completed handoff.
|
||||
|
||||
Future candidates (detail and order in the roadmap): **WP08** — implement the actionable named-branch/tree fork handoff; **H1** — resolve the `OperationStatus.running`, abort signal/event-order, and private gate-close typing contracts and audit Part 9 coverage; **C1** — resolve the §2.8 raw-RemoteSession contradiction before implementing either direction; **L1** — repository ownership of open handles and all-settled close across the three backends; **J1** — implement the §1.7 snapshot rewrite, dead-byte triggers, preserved high-water/list sequences, physical reclamation; **M11** — measure Memory/SQLite/JSONL frame cost, then bound assistant-frame writes without weakening unknown-outcome recovery; **R12** — implement `watchSession`; **T1** — reconcile the declared telemetry schema, then implement retained local spans (RPC trace propagation and an exporter are separate follow-ups); **S3** — reconcile the draft search API, then implement the standalone service, repository catch-up utilities, and the reference SQLite FTS5 projection (§2.8); **R11** — chained migrate-on-open under exclusive host ownership with total mappings (Part 7), activated only before the first incompatible stabilized-format change.
|
||||
Future candidates (detail and order in the roadmap): **WP08** — implement the actionable named-branch/tree fork handoff; **H1** — resolve the `OperationStatus.running`, abort signal/event-order, and private gate-close typing contracts and audit Part 9 coverage; **C1** — resolve the §2.8 raw-RemoteSession contradiction before implementing either direction; **L1** — repository ownership of open handles and all-settled close across the three backends; **J1** — implement the §1.7 snapshot rewrite, dead-byte triggers, preserved high-water/list sequences, physical reclamation; the **[mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md)** — implement tracked assistant progress, scoped durability, and delta replication without weakening unknown-outcome recovery; **R12** — implement `watchSession`; **T1** — reconcile the declared telemetry schema, then implement retained local spans (RPC trace propagation and an exporter are separate follow-ups); **S3** — reconcile the draft search API, then implement the standalone service, repository catch-up utilities, and the reference SQLite FTS5 projection (§2.8); **R11** — chained migrate-on-open under exclusive host ownership with total mappings (Part 7), activated only before the first incompatible stabilized-format change.
|
||||
|
||||
Client watch/subscription incarnation fencing, SQLite branch/query performance, pending-payload measurement, and optional presentation/plugin capabilities are inventoried in the roadmap; they do not alter the Harness state machine. Protocol, client/server resnapshot, and lane reducer surfaces required by WP05 are already implemented; future protocol work extends them rather than redefining the lane contract.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
# 01-delta: known defects and measured findings
|
||||
|
||||
Everything here was found **after** `delta.md` and the shipped implementation were
|
||||
written. None of it is applied to the code in this directory. Each item is
|
||||
problem, evidence, then a candidate fix.
|
||||
Everything here was found **after** `delta.md` and the prototype implementation beside it were written. None of it is applied to the code in this directory. Production now lives in `packages/chord/src/delta/index.ts`: its flush-time dirty-tree design resolves D1, while D2 remains relevant to rolling-window producers that assign sliced strings, but its profile predates flush-time tracking and must be re-measured against production Chord. Each item below remains the original problem, evidence, and candidate-fix record.
|
||||
|
||||
Every number was measured with **`node --experimental-strip-types`**, not `tsx`.
|
||||
That matters: tsx transpiles through esbuild and inflated the same benchmark by
|
||||
|
|
|
|||
|
|
@ -1,59 +1,16 @@
|
|||
# Delta Tracking and the Op Vocabulary
|
||||
|
||||
> **Scope:** `packages/agent/src/harness/delta/`. It depends on nothing else in the
|
||||
> harness — not on session storage, the runtime, or facets — and those three
|
||||
> consume it. Keep that direction: a delta module that imports from `session/` or
|
||||
> `runtime/` cannot later be extracted, and extracting it is the point of the
|
||||
> boundary.
|
||||
> **Production status:** landed in `packages/chord/src/delta/index.ts`, with tests in `packages/chord/test/delta.test.ts`. Chord owns the dependency-free `Op`/`WireOp`, tracker, applier, codec, and validation boundary; Session storage, the Harness, and facets consume it. The implementation and tests beside this document are historical prototype and benchmark evidence, not production source.
|
||||
>
|
||||
> It lives inside `packages/agent` rather than as its own package because it has
|
||||
> exactly one consumer today. Split it out when a second one appears — the facet
|
||||
> host is the likely trigger, since a replica in another process needs the applier
|
||||
> without the harness.
|
||||
>
|
||||
> Nothing existing in `origin/dev` is modified by this unit alone.
|
||||
> The landed tracker computes deltas at `flush()` from a dirty tree and baseline rather than retaining one op per mutation. That resolves FINDINGS D1. FINDINGS D2 remains relevant to producers that repeatedly assign sliced rolling strings, but its profile predates flush-time tracking; re-measure it against production Chord and add an explicit append/truncate producer API before the tool-output hot path only if it remains hot.
|
||||
|
||||
One mechanism covers assistant partials, tool output, tool details, lane state,
|
||||
and arbitrary facet state — on the wire and in durable storage.
|
||||
|
||||
**Shipped alongside this doc**, all four verified:
|
||||
|
||||
| file | lands as |
|
||||
| --- | --- |
|
||||
| `delta.ts` | `harness/delta/types.ts` — the API surface as it should be exported |
|
||||
| `delta-impl.ts` | `harness/delta/index.ts` — tracker, applier, codec, safety guards |
|
||||
| `delta.test.ts` | `test/harness/delta.test.ts` — 42 tests. Port these first. |
|
||||
| `delta.bench.ts` | `test/harness/delta.bench.ts` — guards two performance cliffs |
|
||||
| `delta.examples.ts` | not shipped — runnable illustrations of §3.2.1 and §3.2.2 |
|
||||
|
||||
Re-export from `harness/session/index.ts` is **not** wanted: session storage is a
|
||||
consumer of this vocabulary, not its owner.
|
||||
|
||||
`packages/agent` already has an `exports` map with subpaths (`.`, `./node`, …).
|
||||
Give delta one too, so the boundary is visible from outside the package and a
|
||||
later extraction is a one-line change rather than a search for importers:
|
||||
|
||||
```jsonc
|
||||
"./delta": {
|
||||
"types": "./dist/harness/delta/index.d.ts",
|
||||
"import": "./dist/harness/delta/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
Consumers inside the harness import relatively; anything outside it imports
|
||||
`@earendil-works/pi-agent/delta`. If the second form starts appearing inside
|
||||
`packages/agent`, the module has outgrown its home — see the scope note above.
|
||||
One mechanism covers assistant partials, tool output, tool details, lane state, and arbitrary facet state — on the wire and in durable storage.
|
||||
|
||||
```bash
|
||||
npx vitest run delta.test.ts # 54 passing
|
||||
npx vitest bench delta.bench.ts
|
||||
node --experimental-strip-types delta.examples.ts # the two sharp edges, printed
|
||||
node "$(git rev-parse --show-toplevel)/node_modules/vitest/dist/cli.js" --run test/delta.test.ts
|
||||
# run from packages/chord
|
||||
```
|
||||
|
||||
Both import `./delta-impl.ts` directly and need no harness. `delta.ts` and
|
||||
`delta-impl.ts` are verified mutually assignable, so the declared API is
|
||||
satisfiable by the implementation rather than merely adjacent to it.
|
||||
|
||||
## 1. Why not an existing library
|
||||
|
||||
Immer, Valtio, Mutative and Colyseus all **record effects, not intent**. A string
|
||||
|
|
@ -80,7 +37,7 @@ old value; measured on 5000 elements the fallback was
|
|||
`{index: 0, remove: 5000, items: 5000}` — a full replace, in exactly the case the
|
||||
optimisation existed for.
|
||||
|
||||
So the win is not a better differ. It is **not diffing at all**.
|
||||
So the win is not a blind whole-value differ. The tracker records which paths and array operations became dirty, then compares only those subtrees against its accepted baseline at `flush()`.
|
||||
|
||||
## 2. Ops
|
||||
|
||||
|
|
@ -116,7 +73,7 @@ rather than a path, and misclassifies `["s", []]`.
|
|||
|
||||
**Only `p` may target the root**, and only because a tracked value can itself be
|
||||
an array — `entries.push(x)` on a root array is `["p", [], 3, 0, [x]]`. A `p`
|
||||
covering its entire target is normalised at record time to `r` (root) or `s`
|
||||
covering its entire target is normalised at flush time to `r` (root) or `s`
|
||||
(nested), so a root `p` is always a partial modification. The other four verbs
|
||||
take a `NonEmptyPath`: `["s", [], v]` and `["d", []]` do not typecheck.
|
||||
|
||||
|
|
@ -125,7 +82,7 @@ Two representations of one thing means two appliers, two size estimates, and a
|
|||
conversion nobody needs. Ops are tuples in memory, on the wire, and on disk;
|
||||
readability is what a debug formatter is for.
|
||||
|
||||
Five verbs. No `move`, `copy` or `test`: the first two are size optimisations for
|
||||
Six verbs. No `move`, `copy` or `test`: the first two are size optimisations for
|
||||
a case we do not have, and `test` belongs to a conflict model we do not have,
|
||||
since there is exactly one authoritative writer.
|
||||
|
||||
|
|
@ -143,7 +100,7 @@ decorators. You mutate normally.
|
|||
|
||||
```ts
|
||||
const t = track(laneView);
|
||||
t.state.operation.streaming.content[0].text += delta; // -> append
|
||||
t.state.operation.streamingMessage.content[0].text += delta; // -> append
|
||||
t.state.transcript.push(entry); // -> splice
|
||||
t.state.tools[0].details.failures.push({ name, msg }); // -> splice
|
||||
delete t.state.config.model; // -> delete
|
||||
|
|
@ -160,9 +117,7 @@ records `splice(len, 0, [x])` and then delegates. Intent is captured *before* th
|
|||
engine performs its index writes, which is why `unshift` is one op rather than
|
||||
O(n). This is the thing no other library does.
|
||||
|
||||
**Strings — overlap detection on the `set` trap.** Strings are primitives, so
|
||||
there is nothing to intercept. Given `prev` and `next`, find the longest suffix of
|
||||
`prev` that is a prefix of `next`:
|
||||
**Strings — dirty marking on the `set` trap, overlap detection at `flush()`.** Strings are primitives, so there is nothing to intercept. Given the accepted `prev` and final `next`, find the longest suffix of `prev` that is a prefix of `next`:
|
||||
|
||||
```
|
||||
overlap === prev.length -> append(next.slice(overlap))
|
||||
|
|
@ -194,10 +149,7 @@ the probe entirely.
|
|||
|
||||
### 3.2 Rules the implementation must hold
|
||||
|
||||
**Op payloads are structured-cloned at record time.** Without this a later
|
||||
mutation retroactively changes an already-emitted op, and a local replay aliases
|
||||
the source object. This was an observed bug in the prototype, not a hypothetical:
|
||||
producer and replica ended up sharing an object and double-appending.
|
||||
**Inserted values are adopted; emitted payloads are cloned at flush.** Callers may retain read-only references but must not mutate adopted objects outside the tracker. `flush()` clones payloads and advances a cloned accepted baseline, so emitted ops never alias producer state.
|
||||
|
||||
**`x = undefined` normalises to `delete`.** JSON has no `undefined`, and a `set`
|
||||
with a missing value is indistinguishable from a lost value after a
|
||||
|
|
@ -281,8 +233,7 @@ tracker.rebase(); // next flush is ["r", value]; value unchanged
|
|||
```
|
||||
|
||||
Discarding the pending ops is correct: the proxy mutates the target directly, so
|
||||
the value already carries them. `tracker.state = tracker.target` has the same
|
||||
effect, but re-wraps the proxy needlessly and reads like a mistake.
|
||||
the value already carries them. `tracker.state = tracker.state` has the same effect in the landed implementation, but `rebase()` states the intent directly.
|
||||
|
||||
**Nothing produces a base batch on its own.** `flush()` emits ops; a replacement
|
||||
happens only when the producer asks for one. So a stream of appends stays a
|
||||
|
|
@ -309,13 +260,9 @@ Two callers need this:
|
|||
|
||||
### 3.3 Known gaps
|
||||
|
||||
- `sort` / `reverse` / `fill` / `copyWithin` fall back to a whole-array `set`.
|
||||
Rare on replicated state; revisit if a real workload needs them.
|
||||
- A manual index-shift loop (`for (…) a[i] = a[i+1]`) costs O(n) sets. Correct,
|
||||
not minimal, and unavoidable — you genuinely did write every element.
|
||||
- Cross-realm identity. The interning table in §7 keys by id within one process;
|
||||
a replica in another process or language needs the same table built from the
|
||||
wire, which is not specified here.
|
||||
- `sort` / `reverse` / `fill` / `copyWithin` mark the array dirty and emit the resulting structural/index changes rather than preserving the producer's method intent. Add a dedicated op only if a measured workload needs one.
|
||||
- A manual index-shift loop (`for (…) a[i] = a[i+1]`) can still cost O(n) sets. Correct, not minimal, and unavoidable — the producer genuinely wrote every element.
|
||||
- Rolling-window string assignment still runs overlap discovery at flush. Re-measure FINDINGS D2 against the landed tracker, then add an explicit append/truncate producer API if it remains hot.
|
||||
|
||||
### 3.4 Constraints the string algorithm must hold
|
||||
|
||||
|
|
@ -351,11 +298,9 @@ a numeric PathRef references a previously defined id
|
|||
a shortened tuple reuses the previous op's path; arity disambiguates
|
||||
```
|
||||
|
||||
`encode(ops): WireOp[]` and `decode(wire): Op[]` are the only places either
|
||||
exists. Keeping them out of `Op` means `apply` has no id resolution, no `#`
|
||||
`encoder().encode(ops): WireOp[]` and `decoder().decode(wire): Op[]` are the only places either exists. Keeping them out of `Op` means `apply` has no id resolution, no `#`
|
||||
case, and no previous-path state — three branches removed from the hot path —
|
||||
and the dead-op pass gets a total `opPath`, where before it silently skipped
|
||||
interned refs.
|
||||
and dirty-tree generation always works with inline paths before encoding.
|
||||
|
||||
`["r", value]` carries no path, so it encodes to itself. That is why `isBase`
|
||||
works unchanged on either vocabulary.
|
||||
|
|
@ -407,9 +352,7 @@ interning exists to bound.
|
|||
|
||||
## 5. Flush emits ops, and drops the dead ones
|
||||
|
||||
`flush()` returns the recorded ops. There is no size comparison and no
|
||||
replacement heuristic: a replacement is something the producer asks for, by
|
||||
assigning `state` or calling `rebase()`.
|
||||
`flush()` computes ops for the dirty paths against the last accepted baseline. It does not retain one op per mutation, so repeated and interleaved writes stay bounded by changed state rather than write count. There is no size comparison or replacement heuristic: a replacement is something the producer asks for by assigning `state` or calling `rebase()`.
|
||||
|
||||
An earlier design compared op bytes against the value's serialised size and
|
||||
replaced when ops were larger. It was removed. Measured against emitting ops
|
||||
|
|
@ -420,49 +363,17 @@ the widest case (`run_end`) touches four fields. The rule cost a per-flush
|
|||
comparison, a running size estimate, and an invalidation rule for the ops that
|
||||
could not maintain it.
|
||||
|
||||
### 5.1 Dead-op elimination
|
||||
### 5.1 Dirty-tree collapse
|
||||
|
||||
Ops apply in order, so an op is dead if a later one overwrites the whole subtree
|
||||
it lives in. Only `s`, `d` and `r` do that — `a`, `t` and `p` modify what is
|
||||
already there, so they never dominate.
|
||||
The landed tracker records only which subtrees are dirty. At flush it compares each dirty subtree's accepted baseline with its final value and emits the surviving structural change. Repeated writes to one field collapse naturally; alternating writes to two fields retain two dirty paths rather than one op per mutation; a parent replacement subsumes dirty descendants where the final comparison permits it. `packages/chord/test/delta.test.ts` pins the interleaved rolling-window case at at most three ops after 1,000 alternating writes.
|
||||
|
||||
| written | emitted |
|
||||
| --- | --- |
|
||||
| same field three times | one `s` |
|
||||
| `x`, then `y`, then `x` again | two ops, first `x` dropped |
|
||||
| child, then parent replaced | one `s` on the parent |
|
||||
| set, then delete | one `d` |
|
||||
| parent replaced, then child | **both** — the child write survives |
|
||||
This replaces the prototype's backwards dead-op pass and adjacent-only coalescer. Do not port those algorithms into Chord: flush-time generation is the D1 fix.
|
||||
|
||||
Walk backwards keeping a map of overwritten paths and test each op's own
|
||||
prefixes against it: **O(n · d)** for n ops at path depth d. Comparing each op
|
||||
against every dominator instead is O(n² · d), which degrades on precisely the
|
||||
wide flush this pass exists to clean up.
|
||||
|
||||
Measured: 12–14 µs per op, flat from 100 to 5000 ops, depth 8 barely costlier
|
||||
than depth 3. A pathological producer writing the same two fields 5000 times
|
||||
emits **2 ops from 10 001 recorded**.
|
||||
|
||||
> **Object key order is not preserved.** A producer that does
|
||||
> `set c; set a; delete c; set c` leaves `c` last, because deleting and
|
||||
> reinserting moves it. The replica receives only the surviving `set c`, applies
|
||||
> it to a base where `c` never existed, and `c` lands first.
|
||||
>
|
||||
> Values are identical — 1823 randomised sequences, zero value mismatches — but
|
||||
> the serialised forms can differ. This is acceptable because nothing compares a
|
||||
> replica against its producer: the consumer holds the state and nobody diffs it
|
||||
> back. The one visible case is a consumer that iterates keys for display, where
|
||||
> two views could order rows differently, and it needs a delete-and-recreate of
|
||||
> the same key within one flush. **Do not hash or content-address a replicated
|
||||
> value.**
|
||||
|
||||
Adjacent ops on the same path are then merged: `a` + `a` becomes one append,
|
||||
`s` + `s` keeps the later, `s` + `a` folds into the set. Non-adjacent append
|
||||
merging is a future optimisation, not done.
|
||||
> **Object key order is not a replicated invariant.** Values round-trip, but delete-and-reinsert activity within one flush can produce a different insertion order on a replica. Do not hash or content-address a replicated value, and sort explicitly where display order matters.
|
||||
|
||||
## 6. There is no frame type
|
||||
|
||||
What travels is `Op[]`. Do not wrap it.
|
||||
The logical batch is `Op[]`; transport and durable storage carry the statefully encoded `WireOp[]`. Do not wrap either batch.
|
||||
|
||||
**`seq` does not belong in the payload.** It would defend against a lossy
|
||||
transport we do not have: a durable list element already carries `seq` from
|
||||
|
|
@ -475,7 +386,7 @@ which is where transport metadata belongs.
|
|||
replacement is an op (§2), and the address already says which value a batch
|
||||
belongs to. What would remain is a struct around an array.
|
||||
|
||||
So a batch of ops is just `Op[]`. A **base batch** is one whose first op is `r`;
|
||||
A producer batch is just `Op[]`, and its encoded boundary form is `WireOp[]`. A **base batch** is one whose first op is `r`;
|
||||
`isBase(ops)` is `ops[0]?.[0] === "r"`, exact rather than heuristic because flush
|
||||
guarantees `r` appears at index 0 or not at all (§5).
|
||||
|
||||
|
|
@ -635,7 +546,7 @@ be read as a top-level op, because nothing flattens.
|
|||
### 7.5 Applier
|
||||
|
||||
```ts
|
||||
export function apply<T>(target: T, ops: readonly Op[]): T;
|
||||
export function apply<T>(target: T | undefined, ops: readonly Op[]): T;
|
||||
```
|
||||
|
||||
Six verbs, no domain knowledge, no library, no tool code, no registry lookup, and
|
||||
|
|
@ -669,14 +580,13 @@ Concrete deletions, not simplifications in principle:
|
|||
|
||||
And three things not to build, each of which looks reasonable until §1:
|
||||
|
||||
- **A differ.** Nothing here diffs two values; every path records intent.
|
||||
- **A blind whole-value differ.** The landed tracker compares only dirty subtrees against its accepted baseline; it does not scan unrelated state.
|
||||
- **A keyed op shape plus a codec.** Tuples are the form everywhere (§2).
|
||||
- **A frame wrapper.** What travels is `Op[]` (§6).
|
||||
|
||||
## 9. Durable form
|
||||
|
||||
A tracked value is stored as a **list of frames**, one appended per flush. Base
|
||||
frames carry the storage tag `"base"`.
|
||||
A tracked value is stored as a **list of encoded `WireOp[]` batches**, one appended per flush. Base batches carry the storage tag `"base"`; one stateful decoder per value decodes them before `apply`.
|
||||
|
||||
Recovery reads backwards to the last base batch and applies forward:
|
||||
|
||||
|
|
@ -687,7 +597,7 @@ readList(address, { order: "desc", stopAtTag: "base", limit: 100 })
|
|||
`stopAtTag` is a stop condition *within a page*: if no base batch is in the page,
|
||||
the consumer pages again with the cursor. The tag lives on the storage record
|
||||
beside `seq`, never inside the value, so storage never parses ops. See
|
||||
`session-scopes.md` §11.
|
||||
[scopes.md](../02-scopes/scopes.md) §11.
|
||||
|
||||
This is what makes "a replacement truncates recovery" (§5) real rather than
|
||||
aspirational — a reader stops at the last base batch instead of replaying from
|
||||
|
|
@ -698,12 +608,11 @@ the beginning.
|
|||
- Prefix interning (a trie over path heads) if a workload emerges with many
|
||||
distinct paths sharing long prefixes. Second-use interning (§4) removes the
|
||||
pathological case; this would go further.
|
||||
- Non-adjacent append merging in `coalesce`. Dead-op elimination (§5.1) handles
|
||||
redundancy; this would handle interleaving.
|
||||
- Whether `sort` / `reverse` deserve real ops.
|
||||
- Re-measure rolling-window overlap under flush-time tracking (FINDINGS D2); if it remains hot, add an explicit append/truncate producer API so the producer can state what changed.
|
||||
- Whether array reordering needs a dedicated op; the landed tracker currently marks the array dirty and emits the resulting structural/index changes.
|
||||
- Cross-language replicas: the applier is a page of code in any language, but the
|
||||
interning tables and the wire framing are not specified for a non-JS consumer.
|
||||
|
||||
Address interning in the JSONL log is the same trick as path interning, applied
|
||||
one layer up over `namespace` + `key`. It is **not** an open question — it is
|
||||
specified in `session-scopes.md` §12 and is a separate dictionary from this one.
|
||||
specified in [scopes.md](../02-scopes/scopes.md) §12 and is a separate dictionary from this one.
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@
|
|||
> **Scope:** `packages/agent/src/harness/session/`, plus the call sites in
|
||||
> `runtime/` listed in §10.
|
||||
>
|
||||
> **Depends on `01-delta`** for the op vocabulary that pending state is stored in
|
||||
> (§2, §11) and for the `WireOp` form the record encoding carries (§12). The two
|
||||
> **Depends on [01-delta](../01-delta/delta.md)** for the landed Chord op vocabulary that pending state is stored in (§2, §11) and for the `WireOp` form the record encoding carries (§12). The two
|
||||
> are otherwise independent and compound: encoding shrinks every write, scopes
|
||||
> move one class of write out of the main log entirely.
|
||||
>
|
||||
|
|
@ -35,10 +34,7 @@ Three orders of magnitude of the file is scaffolding for finished operations.
|
|||
|
||||
**Two independent fixes, and they are not alternatives:**
|
||||
|
||||
- **Encoding** (`delta.md`): value writes carry ops rather than whole values, and
|
||||
addresses are interned. Takes the same workload to **5.32 MB in a single file**,
|
||||
with no change to atomicity. This is the larger and safer win, and it applies to
|
||||
every value and list, not just pending state.
|
||||
- **Encoding** ([delta.md](../01-delta/delta.md)): value writes carry Chord ops rather than whole values, and addresses are interned. Takes the same workload to **5.32 MB in a single file**, with no change to atomicity. The op vocabulary and flush-time tracker have landed; storage integration has not.
|
||||
- **Scopes** (this document): pending state leaves the main log entirely, so it is
|
||||
never history to begin with.
|
||||
|
||||
|
|
@ -85,12 +81,12 @@ distinguish *session from ephemeral* statically but cannot distinguish *two
|
|||
different ephemeral scopes*: the tag is in the type, the id is not.
|
||||
|
||||
```ts
|
||||
// Lists of op batches, not values — see `delta.md` §9 and §11 below.
|
||||
// Durable lists carry encoded batches; one encoder/decoder pair belongs to each value stream.
|
||||
export const pendingToolOutput = (operationId: string, invocationId: string) =>
|
||||
list<Op[]>("pi.pending.tool_output", `${operationId}:${invocationId}`, operationId);
|
||||
list<WireOp[]>("pi.pending.tool_output", `${operationId}:${invocationId}`, operationId);
|
||||
|
||||
export const pendingAssistantOutput = (operationId: string, entryId: string) =>
|
||||
list<Op[]>("pi.pending.assistant_output", `${operationId}:${entryId}`, operationId);
|
||||
list<WireOp[]>("pi.pending.assistant_output", `${operationId}:${entryId}`, operationId);
|
||||
|
||||
export const operationToolMemo = (operationId: string, invocationId: string, name: string) =>
|
||||
value<JsonValue>("pi.op.tool_memo", `${operationId}:${invocationId}:${name}`, operationId);
|
||||
|
|
|
|||
|
|
@ -96,21 +96,18 @@ after is deltas. The repeated content path is interned on its second use, and
|
|||
consecutive ops on it omit the id entirely:
|
||||
|
||||
```jsonc
|
||||
["s",[],{"content":[{"type":"text","text":""}],"details":{},"terminate":false,"truncation":{…}}]
|
||||
[["r",{"content":[{"type":"text","text":""}],"details":{},"terminate":false,"truncation":{…}}]]
|
||||
|
||||
["#",0,["content",0,"text"]]
|
||||
["a",0,"make: Entering directory …\n"]
|
||||
["a","cc -c src/a.c …\n"]
|
||||
[["a",["content",0,"text"],"make: Entering directory …\n"]]
|
||||
[["#",0,["content",0,"text"]],["a",0,"cc -c src/a.c …\n"]]
|
||||
[["a",0,"cc -c src/b.c …\n"]]
|
||||
…
|
||||
["s",["details","spillPath"],"/tmp/pi-session-x/bash-8f2.log"]
|
||||
[["s",["details","spillPath"],"/tmp/pi-session-x/bash-8f2.log"]]
|
||||
…
|
||||
["t",0,4096]
|
||||
["a","cc -c src/z.c\n"]
|
||||
["s",["truncation","totalBytes"],262144]
|
||||
[["t",0,4096],["a","cc -c src/z.c\n"],["s",["truncation","totalBytes"],262144]]
|
||||
```
|
||||
|
||||
Details are one `s`, in one flush out of twenty. The `t` + `a` pair is the window
|
||||
slide, stated by the sink rather than inferred — the case a differ degrades on.
|
||||
Details are one `s`, in one flush out of twenty. The `t` + `a` pair is the window slide. The current tracker recovers it by verified overlap; delta FINDINGS D2 requires re-measurement against production Chord before this becomes a hot tool-output loop; add an explicit append/truncate producer path if overlap remains dominant.
|
||||
|
||||
## 5. Harness events
|
||||
|
||||
|
|
@ -136,14 +133,9 @@ re-sending would duplicate any images.
|
|||
so it lives in `<session>.op_….jsonl` and is retired on settle rather than
|
||||
persisting in the main log forever. Retirement is a main-log `retireScope` record,
|
||||
so it commits atomically with the settle writes; the unlink is a consequence of
|
||||
replaying that record, not part of the transaction (`session-scopes.md` §5).
|
||||
replaying that record, not part of the transaction ([scopes.md §5](../02-scopes/scopes.md)).
|
||||
|
||||
A `list<Op[]>`, one batch appended per flush, base batches tagged `"base"` so
|
||||
recovery reads backwards with `stopAtTag` and stops there
|
||||
(`session-scopes.md` §11). Written under the adaptive rule (`delta.md` §5) — ops
|
||||
while they are smaller than a root set, otherwise the root set. For `make -j8` trickling a few KB per
|
||||
checkpoint that is ops; for `cat 1gb.txt` it is capped snapshots. The write
|
||||
interval is the crash-loss knob and lives in the capture policy.
|
||||
A `list<WireOp[]>`, one encoded batch appended per flush, base batches tagged `"base"` so recovery reads backwards with `stopAtTag` and stops there ([scopes.md §11](../02-scopes/scopes.md#11-list-tags-and-stop-conditions)). The tracker emits structural ops; the producer calls `rebase()` periodically to write a capped root replacement and bound recovery replay. The write interval is the crash-loss knob and lives in the capture policy.
|
||||
|
||||
Recovery seeds a fresh `ToolOutput` from that state — it does **not** delete it,
|
||||
which is what `clearReplayCheckpoint` does today and is a bug
|
||||
|
|
@ -189,7 +181,7 @@ not. The facet never writes an op.
|
|||
## 8. Wire and consumer
|
||||
|
||||
```jsonc
|
||||
{ "seq": 0, "ops": [["s",[],{"transcript":[],"operation":null}]] }
|
||||
{ "seq": 0, "ops": [["r",{"transcript":[],"operation":null}]] }
|
||||
{ "seq": 1, "ops": [ … ] }
|
||||
```
|
||||
|
||||
|
|
@ -197,7 +189,7 @@ One shape: a batch of ops. The first is a base batch — it begins with `r` —
|
|||
and everything after is deltas. A gap, a reconnect, a provider
|
||||
reload, or a fold that could not apply all take one path: send a fresh `replace`.
|
||||
|
||||
The consumer is `apply` — five verbs, no domain knowledge, no library, no tool
|
||||
The consumer is `apply` — six verbs, no domain knowledge, no library, no tool
|
||||
code, against a plain mutable object it owns.
|
||||
|
||||
## 9. What this run costs
|
||||
|
|
@ -206,7 +198,7 @@ code, against a plain mutable object it owns.
|
|||
|
||||
| | today | this design |
|
||||
|---|---|---|
|
||||
| durable writes | full `AgentToolResult` per checkpoint | ops, or a capped snapshot when smaller |
|
||||
| durable writes | full `AgentToolResult` per checkpoint | structural ops plus explicit periodic capped base batches |
|
||||
| durable location | main log, forever | sidecar, unlinked on settle |
|
||||
| wire per flush | whole snapshot | one `truncate` + one `append` |
|
||||
| details written | rebuilt whole, every flush | one `set`, once |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# Tool Output and Progress
|
||||
|
||||
> **Scope:** harness-local. Nothing here depends on the facet system, RPC, or any
|
||||
> presentation. Depends on `delta.md` (op vocabulary and tracker),
|
||||
> `executionenv.md` (where truncation happens), and `session-scopes.md`
|
||||
> (durability).
|
||||
> presentation. Depends on [delta tracking](../01-delta/delta.md) (the landed Chord op vocabulary and tracker), [execution environments](../03-execenv/execenv.md) (where truncation happens), and [scoped storage](../02-scopes/scopes.md) (durability).
|
||||
|
||||
## 1. The problem
|
||||
|
||||
|
|
@ -309,8 +307,7 @@ plus `details` and `usage`.
|
|||
|
||||
Note the implication that misled an earlier draft: because the checkpoint must
|
||||
*be* the current state, it was read as needing `value` semantics. It does not. It
|
||||
needs to be *derivable*, and folding a list of frames back to the last base batch
|
||||
derives it — which is the point of tagging base batchs (§7.3). `checkpoint: true`
|
||||
needs to be *derivable*, and folding encoded batches from the last base batch derives it — which is the point of tagging base batches (§7.3). `checkpoint: true`
|
||||
requests a durable write, not a replacement.
|
||||
|
||||
`pendingAssistantFrames` is a `list<AssistantMessageFrame>` appended per frame,
|
||||
|
|
@ -329,7 +326,7 @@ tracked state written as ops or snapshots.
|
|||
|
||||
### 7.3 What to write, and when
|
||||
|
||||
Both addresses are **ephemeral-scoped** (`session-scopes.md`), so they live in a
|
||||
Both addresses are **ephemeral-scoped** ([scopes.md](../02-scopes/scopes.md)), so they live in a
|
||||
sidecar retired on settle rather than in the main log forever.
|
||||
|
||||
Two independent wins, in order of importance:
|
||||
|
|
@ -339,33 +336,22 @@ Two independent wins, in order of importance:
|
|||
- **Scopes.** Pending state leaves the main log entirely: 5.32 MB to 0.06 MB
|
||||
surviving.
|
||||
|
||||
**Both are `list<Op[]>`**, not values. One batch is appended per flush, and a batch
|
||||
whose first op is `r` is tagged `"base"` on the storage record.
|
||||
**Both are `list<WireOp[]>`**, not values. The sink owns one stateful Chord encoder/decoder pair per durable value stream. One encoded batch is appended per flush, and a logical batch whose first op is `r` is tagged `"base"` on the storage record.
|
||||
Recovery reads backwards with `stopAtTag: "base"` and applies forward
|
||||
(`delta.md` §9, `session-scopes.md` §11).
|
||||
([delta.md §9](../01-delta/delta.md#9-durable-form), [scopes.md §11](../02-scopes/scopes.md#11-list-tags-and-stop-conditions)).
|
||||
|
||||
Writing one flush:
|
||||
|
||||
```ts
|
||||
const ops = out.flush();
|
||||
if (ops.length === 0) return;
|
||||
writes: [appendList(address, ops, isBase(ops) ? "base" : undefined)];
|
||||
const wire = enc.encode(ops);
|
||||
writes: [appendList(address, wire, isBase(ops) ? "base" : undefined)];
|
||||
```
|
||||
|
||||
`isBase` comes from the delta package. It is not `ops[0][0] === "s"`: a set on any
|
||||
path is `"s"`, and what makes a base is that it targets the root. Classification
|
||||
lives beside the vocabulary so the comparison is written once.
|
||||
`isBase` comes from Chord. It checks for the root replacement op `r`; ordinary nested sets use `s`. Classification lives beside the vocabulary so the comparison is written once.
|
||||
|
||||
Emission follows the adaptive rule from `delta.md` §5:
|
||||
|
||||
> Write ops when they are smaller than a replacement; otherwise write the
|
||||
> replacement — which is itself an op, `["s", [], value]`.
|
||||
|
||||
The crossover is exactly at the cap. Below it ops win by up to 30x; above it
|
||||
snapshots win unboundedly. A trickling build stays on cheap ops; `cat 1gb.txt`
|
||||
degrades to capped snapshots. Sweep in `delta.md` §5 — **do not quote a single
|
||||
ratio without it**, since an earlier draft claimed 18x from a rate that happened
|
||||
to favour ops.
|
||||
The landed tracker emits structural ops unconditionally. There is no serialized-size comparison or adaptive replacement heuristic. A producer requests a base batch explicitly with `rebase()`; the output sink's cap bounds that replacement, while periodic rebasing bounds recovery work. Before the rolling tool-output hot path lands, re-measure delta FINDINGS D2 against production Chord; if overlap discovery remains hot, add an explicit append/truncate producer API.
|
||||
|
||||
**Checkpointing is not deleted.** An earlier draft called for removing
|
||||
`BASH_CHECKPOINT_INTERVAL_MS`; that was wrong. Checkpoint frequency is the knob
|
||||
|
|
@ -420,7 +406,7 @@ setMemo(name, value) {
|
|||
```
|
||||
|
||||
Both addresses are **ephemeral-scoped**, so this is a single-file transaction and
|
||||
statically enforced as one (`session-scopes.md` §3, §6). `operationToolMemo` is
|
||||
statically enforced as one ([scopes.md §3 and §6](../02-scopes/scopes.md)). `operationToolMemo` is
|
||||
placed in that scope precisely for this. Ordering on the Session line would not be
|
||||
enough — two file writes are not atomic. The periodic checkpoint stays as it
|
||||
is — best-effort, for the interruption path; this forces one where correctness
|
||||
|
|
@ -463,7 +449,7 @@ memos.
|
|||
|
||||
### 7.7 Nothing on the durable path runs tool code
|
||||
|
||||
Ops are interpreted by a five-verb applier with no domain knowledge, so a tool
|
||||
Ops are interpreted by a six-verb applier with no domain knowledge, so a tool
|
||||
rewritten between crash and resume cannot make a persisted stream unreadable.
|
||||
|
||||
> **The durable path uses only harness-owned reducers.**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
# `message_update` Write Amplification
|
||||
|
||||
> **Scope:** harness-local. Depends on `delta.md` for the op vocabulary and
|
||||
> `session-scopes.md` for durability.
|
||||
> **Scope:** harness-local. Depends on [delta tracking](../01-delta/delta.md) for the landed Chord `Op`/`WireOp` vocabulary and [scoped storage](../02-scopes/scopes.md) for durability. Chord delta tracking has landed; scoped storage and this Harness integration have not.
|
||||
|
||||
## 1. The problem
|
||||
|
||||
|
|
@ -72,7 +71,7 @@ continuing through the harness.
|
|||
## 4. The change
|
||||
|
||||
```ts
|
||||
| { type: "message_update"; runId: string; entryId: string; ops: Op[] }
|
||||
| { type: "message_update"; runId: string; entryId: string; frame: AssistantMessageFrame }
|
||||
```
|
||||
|
||||
`message` and `event` are removed; `frame` stops being optional.
|
||||
|
|
@ -82,14 +81,16 @@ read. `AgentEvent` (`agent-loop.ts`) and `AgentSessionEvent` (`agent-session.ts`
|
|||
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 of `HarnessEvent.message_update`:
|
||||
Real consumers and producers of `HarnessEvent.message_update`:
|
||||
|
||||
| consumer | change |
|
||||
| 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` |
|
||||
| `experimental/harness-wire-adapter.ts` | forward the frame; stop sending `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` | schema |
|
||||
| `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.
|
||||
|
|
@ -108,26 +109,21 @@ export function applyAssistantMessageFrame(
|
|||
|
||||
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
|
||||
recipe; that motivation is gone ([delta.md §8](../01-delta/delta.md#8-what-this-removes-from-the-codebase)). 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; our frames go on the wire
|
||||
### 5.1 pi-ai frames stay at the pi-ai boundary; Chord ops cross replication boundaries
|
||||
|
||||
Two things are called "frame" and they are not the same. `AssistantMessageFrame`
|
||||
is pi-ai's semantic delta vocabulary (`text_delta`, `text_end`, …). What crosses a
|
||||
boundary in this design is
|
||||
`delta.md` §6 has no frame type at all — what travels is `Op[]`. The pi-ai frames
|
||||
stop at the fold; ops are what cross a boundary.
|
||||
`AssistantMessageFrame` is pi-ai's semantic delta vocabulary (`text_delta`, `text_end`, …). [Delta tracking §6](../01-delta/delta.md#6-there-is-no-frame-type) 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 tracker
|
||||
(`delta.md`) that yields:
|
||||
The harness folds frames into `LaneView` by plain mutation. Under the Chord tracker that yields:
|
||||
|
||||
```json
|
||||
{"op":"append","path":["operation","streaming","content",0,"text"],"value":"Let me "}
|
||||
["a",["operation","streamingMessage","content",0,"text"],"Let me "]
|
||||
```
|
||||
|
||||
Measured, interned ops are **smaller than frames** on this workload — 13.6 KB
|
||||
|
|
@ -170,31 +166,20 @@ 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. Honouring `toolcall_checkpoint`
|
||||
and `toolcall_end` only, so `arguments` refreshes at checkpoint granularity, is
|
||||
almost certainly why checkpoints exist.
|
||||
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`, so a subscriber joining
|
||||
mid-tool-call receives a checkpoint to resync against rather than the deltas it
|
||||
missed. It is a replication mechanism inside an encoder.
|
||||
`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.
|
||||
|
||||
With a delta-folding reducer it stops being a correctness requirement and becomes what
|
||||
it should be: a resync point, and the bound on how much must be replayed.
|
||||
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<Op[]>`,
|
||||
matching `pendingToolOutput`
|
||||
(`harness-tools.md` §7.2), and it stops being a list of frames. It holds tracked
|
||||
state written as ops or snapshots under the adaptive rule (`delta.md` §5), in an
|
||||
**ephemeral scope** so it is unlinked on settle rather than persisting in the main
|
||||
log (`session-scopes.md`).
|
||||
**The address is renamed `pendingAssistantOutput`** and becomes a `list<WireOp[]>`, matching `pendingToolOutput` ([tool-output handoff §7.2](../04-tool-output/harness-tools.md#72-renaming)), 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](../02-scopes/scopes.md)).
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -483,14 +483,9 @@ proxy and reconstructed at the endpoint.
|
|||
|
||||
### 9.2 Replicated state
|
||||
|
||||
One authoritative writer, many readers. The stream carries opcodes:
|
||||
One authoritative writer, many readers. The stream carries one base op batch followed by delta batches; the vocabulary is in [delta.md](../../01-harness/01-delta/delta.md).
|
||||
|
||||
The stream carries a snapshot then ops; the vocabulary is in `delta.md` and the
|
||||
frame type is given below.
|
||||
|
||||
Full-value replication is the degenerate configuration where every update is a
|
||||
`replace`. It is not a separate primitive, and it is what the adaptive rule
|
||||
selects automatically when a value changes wholesale.
|
||||
Full-value replication is the degenerate configuration where the producer explicitly replaces or rebases on every update. It is not a separate primitive: Chord emits the root replacement op `r`.
|
||||
|
||||
#### State is plain TypeScript
|
||||
|
||||
|
|
@ -528,35 +523,28 @@ Consumers apply ops. They never see mutation names and never run provider code.
|
|||
This matters for a specific reason: a consumer running provider code would have
|
||||
to resolve a definition from a registry, making the fold depend on ambient
|
||||
registration — a reload changes the answer for the same input. Ops remove that,
|
||||
and make non-JS consumers trivial, since the applier is five verbs.
|
||||
and make non-JS consumers trivial, since the applier is six verbs.
|
||||
|
||||
Consequences:
|
||||
|
||||
- mutation names are not part of the wire contract and appear in no member table,
|
||||
so there is no mutation-name version skew;
|
||||
- a consumer needs no schema for arguments, only for the value in the `replace`
|
||||
frame;
|
||||
- a consumer needs no schema for arguments, only for the value in the root `r` op;
|
||||
- `x = undefined` normalises to `delete`, since JSON has no `undefined`.
|
||||
|
||||
#### Wire protocol
|
||||
|
||||
A subscription carries batches of ops — `Op[]`. There is no frame type and no
|
||||
`seq` in the payload: the SSE binding stamps `id:`, which is where transport
|
||||
The producer and applier use `Op[]`; a subscription carries encoded `WireOp[]`. There is no frame type and no `seq` in the payload: the SSE binding stamps `id:`, which is where transport
|
||||
metadata belongs (`delta.md` §6).
|
||||
|
||||
`Op` and its encoding — five verbs, array paths, tuple form, second-use path
|
||||
interning — are specified in `delta.md` §2 and §4. They are not restated here.
|
||||
`Op` and its encoding — six verbs, array paths, tuple form, second-use path interning — are specified in [delta.md](../../01-harness/01-delta/delta.md) §2 and §4. They are not restated here.
|
||||
|
||||
**A replacement is an op**, `["s", [], value]` — a set at the root path
|
||||
(`delta.md` §6). There is no frame discriminator. A frame whose ops begin with a
|
||||
root set is a **base frame**, and frame zero is always one. This collapses several
|
||||
things `plugins.md` treats separately:
|
||||
**A replacement is an op**, `["r", value]`. There is no frame discriminator. A batch whose first op is `r` is a **base batch**, and batch zero is always one. This collapses several things `plugins.md` treats separately:
|
||||
|
||||
- there is no distinct hydration path — the snapshot is simply the first frame;
|
||||
- no buffering-during-snapshot dance, and therefore no two-phase
|
||||
`watch()`/`start()` handshake as in mini today;
|
||||
- there is no distinct wire hydration shape — the snapshot is simply the first batch;
|
||||
- snapshot buffering remains a local adapter detail rather than a second wire protocol;
|
||||
- cold start, reconnect, provider reload, session switch, a sequence gap, and a
|
||||
fold that could not apply are **one code path**: send a fresh base frame.
|
||||
fold that could not apply are **one code path**: send a fresh base batch.
|
||||
|
||||
That last item is why there is no `Rebase` type anywhere in this design.
|
||||
|
||||
|
|
@ -564,27 +552,19 @@ The SSE `id:` is stamped by the host binding, starts at zero on every
|
|||
subscription, and is contiguous. A consumer seeing a gap discards its replica and
|
||||
resubscribes.
|
||||
|
||||
#### Resubscription is a base frame plus buffered frames
|
||||
#### Resubscription is a base batch plus buffered batches
|
||||
|
||||
There is no cross-subscription resume and no `Last-Event-ID`. Resubscribing works
|
||||
the way the harness already hands out lane state: snapshot the current value as
|
||||
frame zero, buffer whatever arrives while the client is catching up, then deliver
|
||||
the buffer as ordinary frames once it is ready.
|
||||
base batch zero, buffer whatever arrives while the client is catching up, then deliver the buffer as ordinary batches once it is ready.
|
||||
|
||||
That is the same two-phase shape as `AgentHarness.watch` — `snapshot` then
|
||||
`start` — and it is a purely local detail of the provider binding, invisible on
|
||||
the wire. A consumer sees a base frame followed by contiguous frames, exactly as
|
||||
on cold start.
|
||||
the wire. A consumer sees a base batch followed by contiguous batches, exactly as on cold start.
|
||||
|
||||
Real resume would need a retained op log so a client could ask for "everything
|
||||
after seq N". We deliberately do not keep one: the durable form is a list of
|
||||
frames per *value* (`delta.md` §9), retired with its scope, not a per-subscription
|
||||
history. Buffering costs a bounded amount of memory for the duration of a
|
||||
handshake; an op log would cost unbounded disk forever.
|
||||
Real resume would need a retained op log so a client could ask for "everything after seq N". We deliberately do not keep one: the durable form is a list of batches per *value* ([delta.md §9](../../01-harness/01-delta/delta.md#9-durable-form)), retired with its scope, not a per-subscription history. Buffering costs a bounded amount of memory for the duration of a handshake; an op log would cost unbounded disk forever.
|
||||
|
||||
**Emission is adaptive** (`delta.md` §5): ops when they are smaller than a root
|
||||
set, otherwise the root set. The crossover is at the size of the bounded value,
|
||||
which bounds traffic in both directions without any sliding-window diffing.
|
||||
The landed tracker emits structural ops without a serialized-size heuristic. Provider replacement, reconnect, and policy-driven recovery bounds call `replace()`/`rebase()` explicitly; those are the only sources of a root `r` batch.
|
||||
|
||||
#### The harness does not emit ops
|
||||
|
||||
|
|
@ -629,7 +609,7 @@ subscriber table, revision stamping, and disposal.
|
|||
|
||||
```ts
|
||||
interface State<T> {
|
||||
readonly value: T | undefined; // undefined until frame zero
|
||||
readonly value: T | undefined; // undefined until base batch zero
|
||||
subscribe(listener: (value: T) => void): void; // returns nothing (§13)
|
||||
}
|
||||
|
||||
|
|
@ -667,10 +647,7 @@ export const transcriptSession = defineFacet({
|
|||
});
|
||||
```
|
||||
|
||||
A `mutate()` call advances the authoritative value **and** publishes the resulting
|
||||
ops — one statement, so the value and the wire cannot disagree. `replace()`
|
||||
publishes a whole new snapshot and is what reconnect, provider reload, and the
|
||||
adaptive rule (`delta.md` §5) use.
|
||||
A `mutate()` call advances the authoritative value **and** publishes the resulting ops — one statement, so the value and the wire cannot disagree. `replace()` publishes an explicit root `r` batch and is what reconnect, provider reload, resnapshot, and policy-driven recovery bounds use.
|
||||
|
||||
Consumer side:
|
||||
|
||||
|
|
@ -685,7 +662,7 @@ export const transcriptTui = defineFacet({
|
|||
const transcript = ctx.use(Transcript);
|
||||
const tui = ctx.use(Tui);
|
||||
|
||||
transcript.tail.subscribe((tail) => render(tail.entries)); // frame zero arrives here too
|
||||
transcript.tail.subscribe((tail) => render(tail.entries)); // base batch zero arrives here too
|
||||
tui.commands.register("transcript.older", async (context) =>
|
||||
render(await transcript.page({ before: oldestId(), limit: 100 }, context)),
|
||||
);
|
||||
|
|
@ -695,8 +672,7 @@ export const transcriptTui = defineFacet({
|
|||
});
|
||||
```
|
||||
|
||||
The consumer never sees opcodes. It receives values, and frame zero is delivered as
|
||||
an ordinary update — so there is no separate "ready" callback and no hydration
|
||||
The consumer never sees opcodes. It receives values, and base batch zero is delivered as an ordinary update — so there is no separate "ready" callback and no hydration
|
||||
branch in facet code.
|
||||
|
||||
#### Worked: adapting `AgentHarness.watch`
|
||||
|
|
@ -723,7 +699,7 @@ interface WatchHandle<T> {
|
|||
`readLane` critical section, so both are taken under the same lock. Events arriving
|
||||
before `start()` are buffered, then flushed. `resnapshot()` does the same with a
|
||||
`markBoundary()` callback re-establishing where the stream resumes. That is exactly
|
||||
the guarantee frame zero needs, so the adapter is thin.
|
||||
the guarantee base batch zero needs, so the adapter is thin.
|
||||
|
||||
`LaneSnapshot` is per lane, so `Lane` is keyed — one instance per lane, each taking
|
||||
its own `watch()`. The harness already filters by lane
|
||||
|
|
@ -778,7 +754,7 @@ replica.
|
|||
Four things to notice.
|
||||
|
||||
- **The `watch`/`start` handshake does not cross the wire.** It is a local detail of
|
||||
one adapter. Remote consumers see frame zero, then ops. Mini's buffering dance
|
||||
one adapter. Remote consumers see base batch zero, then ops. Mini's buffering dance
|
||||
disappears, because the harness already buffers between the two phases and the flush
|
||||
lands as ordinary mutations after the initial value.
|
||||
- **The facet owns the replicated shape.** `LaneView` is the facet's, not the
|
||||
|
|
@ -786,7 +762,7 @@ Four things to notice.
|
|||
regardless, so the harness gains nothing by emitting ops itself.
|
||||
- **Rebase never reaches consumers, and is not a type.** `navigation_end` is an
|
||||
ordinary event the adapter answers with `resnapshot()` plus `replace()` — the
|
||||
*same* frame a reconnecting client receives. `markBoundary()` supplies the
|
||||
same base batch a reconnecting client receives. `markBoundary()` supplies the
|
||||
ordering. No reducer signals anything by returning a value.
|
||||
- **`HarnessEvent` is not a wire format.** `message_update` today carries the full
|
||||
message *and* an `AssistantMessageEvent` holding a second copy *and* the delta,
|
||||
|
|
@ -814,12 +790,10 @@ an open question in §16.
|
|||
|
||||
Everything below is host machinery, not facet API:
|
||||
|
||||
- stamping `seq` per frame within the provider binding, so business snapshots and
|
||||
ops carry no transport metadata;
|
||||
- delivering `replace` before any ops on a fresh subscription;
|
||||
- choosing ops versus `replace` per flush by the adaptive rule (`delta.md` §5);
|
||||
- applying only consecutive frames, and requesting a fresh `replace` after a gap,
|
||||
reconnect, or provider replacement;
|
||||
- stamping `seq` per batch within the provider binding, so business snapshots and ops carry no transport metadata;
|
||||
- delivering a root `r` base batch before deltas on a fresh subscription;
|
||||
- resetting each subscriber's encoder dictionary on that base batch;
|
||||
- applying only consecutive batches, and requesting a fresh base batch after a gap, reconnect, or provider replacement;
|
||||
- hardening values as they cross the boundary (§14.3);
|
||||
- announcing the state value definition ID in the member table. Mutation names are
|
||||
**not** announced, because they never travel — ops are structural, so a consumer
|
||||
|
|
@ -837,7 +811,7 @@ Two rules follow from the provider and every replica running the same fold:
|
|||
|
||||
Mutation-name version skew does not exist: mutations never cross a boundary, so
|
||||
adding, renaming or removing one is not a breaking change. The version-skew surface
|
||||
is the *value shape* alone, which the `replace` frame's schema describes.
|
||||
is the *value shape* alone, which the root `r` batch's schema describes.
|
||||
|
||||
Prefer several coarse state values to one large value. A cold replica has
|
||||
`value === undefined`; that is local readiness and never crosses the wire.
|
||||
|
|
@ -1720,7 +1694,7 @@ export const Transcript = defineService<Transcript>("pi.transcript", {
|
|||
`page` and `tail` are published. `subscribeRaw` is absent from `protocol`, so it is
|
||||
local — nothing marks it, and publishing it later means adding one entry.
|
||||
|
||||
Only the state **value** needs a schema, for the `replace` frame. Mutations do not,
|
||||
Only the state **value** needs a schema, for the root `r` batch. Mutations do not,
|
||||
because recipes never leave the provider (§9.2) and ops are structural.
|
||||
|
||||
Method parameters are a single object, not positional. Names then survive into the
|
||||
|
|
@ -1792,27 +1766,26 @@ POST /v1/call/pi.transcript/page
|
|||
GET /v1/state/pi.lane/main/snapshot
|
||||
Accept: text/event-stream
|
||||
id: 0
|
||||
event: replace
|
||||
data: {"lane":"main","transcript":[],"operation":null}
|
||||
event: ops
|
||||
data: [["r",{"lane":"main","transcript":[],"operation":null}]]
|
||||
|
||||
id: 1
|
||||
event: ops
|
||||
data: [{"op":"splice","path":["transcript"],"index":12,"remove":0,"items":[{"id":"e12"}]}]
|
||||
data: [["p",["transcript"],12,0,[{"id":"e12"}]]]
|
||||
|
||||
id: 2
|
||||
event: ops
|
||||
data: [{"op":"append","path":["operation","streamingMessage","content",0,"text"],"value":"Sure, I"}]
|
||||
data: [["a",["operation","streamingMessage","content",0,"text"],"Sure, I"]]
|
||||
```
|
||||
|
||||
`id` is stamped by the binding, not carried in the payload. The first batch is
|
||||
always a base batch, so hydration is not
|
||||
a separate route. `Last-Event-ID` is **not** honoured: `seq` restarts per
|
||||
subscription, and resubscription is a base frame plus buffered frames (§9.2); a gap, an unknown id, and a provider reload all take that same path. A
|
||||
subscription, and resubscription is a base batch plus buffered batches (§9.2); a gap, an unknown id, and a provider reload all take that same path. A
|
||||
closed keyed instance ends the stream with `event: closed` and the client does not
|
||||
retry.
|
||||
|
||||
A foreign client needs only the five ops of `delta.md` §2 — `set`, `delete`,
|
||||
`append`, `truncate`, `splice`. No mutation names, no recipes, no provider code. The applier is
|
||||
A foreign client needs only the six ops of [delta.md §2](../../01-harness/01-delta/delta.md#2-ops) — `replace`, `set`, `delete`, `append`, `truncate`, `splice`. No mutation names, no recipes, no provider code. The applier is
|
||||
a page of code in any language.
|
||||
|
||||
**This is where non-conformance becomes visible**, and it is the one place worth
|
||||
|
|
@ -1824,7 +1797,7 @@ work.
|
|||
Two mitigations, neither of which is "conform":
|
||||
|
||||
- The catalogue advertises the op vocabulary explicitly, so a client discovers the
|
||||
five verbs rather than assuming six different ones.
|
||||
six verbs rather than assuming a different vocabulary.
|
||||
- If a client genuinely needs RFC 6902, the server can offer a lossy downgrade behind
|
||||
a content negotiation header — join paths with slashes and `~0`/`~1` escaping,
|
||||
materialise `append` and `truncate` into whole-value `replace`, expand `splice`
|
||||
|
|
@ -1851,14 +1824,14 @@ silently mean freezing, and §16 lists version negotiation as unresolved.
|
|||
| validation | requires running facet code | pure manifest analysis |
|
||||
| mode | declared per call site, validated | property of the token |
|
||||
| cycles | tolerated via laziness | rejected; explicit `deferred()` escape |
|
||||
| replication | `ReplicatedState` full-value; `DeltaState` deferred | one primitive; `replace` + five-verb ops |
|
||||
| hydration | separate atomic snapshot + buffering | frame zero of the stream |
|
||||
| replication | `ReplicatedState` full-value; `DeltaState` deferred | one primitive; explicit root `r` + six-verb ops |
|
||||
| hydration | separate atomic snapshot + buffering | base batch zero of the stream |
|
||||
| presentation facets | loaded locally | delivered by server and worker |
|
||||
| server↔worker | unspecified inversion | reporting registries; deps point upstream |
|
||||
| authority | `requireClientIdentity` in method bodies | principal on context; filtered views + handle checks |
|
||||
| per-client state | not addressed | `peer` mode: one instance per peer, consumed as a singleton |
|
||||
| resource ownership | explicit `own()` | implicit; every handle is a self-disposing binding |
|
||||
| state updates | hand-written patch unions | plain mutation on the provider; five-verb ops on the wire |
|
||||
| state updates | hand-written patch unions | plain mutation on the provider; six-verb ops on the wire |
|
||||
| UI mounting | facet-owned panels, unspecified host API | slots with `claim`/`add`; host unmounts on disposal |
|
||||
| isolation | trusted code, unspecified | SES compartment per presentation facet |
|
||||
| foreign clients | not addressed | opt-in `protocol` block; JSON Schema catalogue + HTTP/SSE |
|
||||
|
|
@ -1879,5 +1852,4 @@ silently mean freezing, and §16 lists version negotiation as unresolved.
|
|||
lane state is replace-only.
|
||||
- Whether `deferred()` survives contact with real plugins, or should be removed.
|
||||
- Property tests for the tracker (`delta.md` §3.3) before any facet depends on it.
|
||||
- Where the adaptive ops-vs-replace threshold is measured — per flush against the
|
||||
current snapshot size, or amortised.
|
||||
- Which durable values need an explicit periodic `rebase()` cadence to bound recovery work.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ later units consume earlier ones.
|
|||
|
||||
```
|
||||
01-harness/
|
||||
01-delta/ op vocabulary, tracker, applier, codec [CODE + 60 tests]
|
||||
01-delta/ op vocabulary, tracker, applier, codec [LANDED IN CHORD]
|
||||
02-scopes/ storage scopes, list tags, JSONL encoding [SPEC + type check]
|
||||
03-execenv/ bounded Shell output, capture, spill [CODE + 29 tests]
|
||||
04-tool-output/ the ToolOutput sink [SPEC ONLY]
|
||||
|
|
@ -22,10 +22,7 @@ Base everything on a clean checkout of `origin/dev`.
|
|||
**Three units ship working code. Four are specifications.** The table above says
|
||||
which. Do not assume a doc describes something that exists.
|
||||
|
||||
**`01-delta/FINDINGS.md` is not optional.** The shipped delta implementation has a
|
||||
known unbounded-memory defect (D1) and a known 94.5%-of-runtime hotspot (D2),
|
||||
neither of which is fixed in the code. `04-tool-output` depends on D1 being fixed
|
||||
first. Read it before touching `delta-impl.ts`.
|
||||
**`01-delta/FINDINGS.md` is not optional.** The production implementation now lives in `packages/chord/src/delta/index.ts`. Its flush-time dirty-tree design fixes D1 (interleaved paths no longer retain one op per write); D2 remains relevant to rolling-window producers that assign sliced strings, but its 94.5% figure predates flush-time tracking. Re-measure it against production Chord and add an explicit append/truncate producer API before `04-tool-output` if it remains hot. The code beside the handoff is historical prototype evidence, not production source.
|
||||
|
||||
**If a doc and the code disagree, the code wins** — fix the doc and say so in the
|
||||
commit.
|
||||
|
|
@ -42,7 +39,7 @@ five more measurement traps, each of which produced a confident wrong conclusion
|
|||
|
||||
| unit | ships | state |
|
||||
| --- | --- | --- |
|
||||
| **01-delta** | impl, 60 tests, bench, examples, spec | works; **two known defects, see FINDINGS.md** |
|
||||
| **01-delta** | production implementation and tests in `packages/chord`; prototype evidence here | landed; D1 fixed by flush-time tracking, D2 remains for rolling-window producers |
|
||||
| **02-scopes** | spec + `scopes.variance.ts` | spec aligned to code; not implemented. The variance check compiles. |
|
||||
| **03-execenv** | impl, rewritten bash, 2 diffs, spec | ran green (29 tests). **Its checkpoint policy is wrong** — see `04-tool-output/rate-limiting.md` §5 |
|
||||
| **04-tool-output** | spec + design notes | **not built.** The piece every measurement of the op encoding depends on |
|
||||
|
|
@ -52,15 +49,11 @@ five more measurement traps, each of which produced a confident wrong conclusion
|
|||
|
||||
## Suggested order
|
||||
|
||||
1. **`01-delta` D1** — interleaving. Unblocks everything in 04.
|
||||
2. **`01-delta` D2** — the explicit append API. 94.5% of tracker time.
|
||||
3. **`03-execenv`** — land the shipped code, minus the checkpoint policy and the
|
||||
two redundant knobs (`rate-limiting.md` §5).
|
||||
4. **`02-scopes`** — the compiler finds the call sites for you once `Write<Sc>` is
|
||||
invariant.
|
||||
5. **`04-tool-output`** — decide the cadence question first (`rate-limiting.md` §4.3
|
||||
lists what is undecided), then build.
|
||||
6. **05**, then **02-plugins**.
|
||||
1. **`01-delta` D2** — re-measure against production Chord; if it remains hot, add the explicit append/truncate producer API before the rolling tool-output path depends on it.
|
||||
2. **`03-execenv`** — land the shipped code, minus the checkpoint policy and the two redundant knobs (`rate-limiting.md` §5).
|
||||
3. **`02-scopes`** — the compiler finds the call sites for you once `Write<Sc>` is invariant.
|
||||
4. **`04-tool-output`** — decide the cadence question first (`rate-limiting.md` §4.3 lists what is undecided), then build.
|
||||
5. **05**, then **02-plugins**.
|
||||
|
||||
## Live bugs on `origin/dev`, independent of this design
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ The inventory was checked against current source, tests, package READMEs, the co
|
|||
|
||||
## Executive result
|
||||
|
||||
WP05 is complete through M10. M11 is its only recorded follow-up. The current Harness execution graph has no unfinished runtime path: `watchSession()` is the sole `SliceNotImplemented` Harness method.
|
||||
WP05 is complete through M10. Its remaining assistant-output work is owned by the [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md) and its numbered prerequisites. The current Harness execution graph has no unfinished runtime path: `watchSession()` is the sole `SliceNotImplemented` Harness method.
|
||||
|
||||
That does **not** mean the surrounding durable system is complete. The remaining audit findings are:
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ Define one coherent capture and fold for dynamic lane inventory and fault state.
|
|||
|
||||
**Dependency**
|
||||
|
||||
Independent of M11 and SQLite internals. It should precede any revisioned Transcript service or remote session-wide observation built on it.
|
||||
Independent of the mobile assistant-output handoff and SQLite internals. It should precede any revisioned Transcript service or remote session-wide observation built on it.
|
||||
|
||||
### JSONL snapshot compaction
|
||||
|
||||
|
|
@ -58,11 +58,11 @@ Independent of M11 and SQLite internals. It should precede any revisioned Transc
|
|||
|
||||
**Consequence**
|
||||
|
||||
Superseded `pi.op.state`, deleted pending payloads, deleted tool checkpoints, and deleted assistant-frame lists remain physical bytes indefinitely. Generic compaction and M11 are distinct mechanisms but their recorded scopes overlap: WP05 M11 also cites dead post-settlement JSONL frame history, while `harness.md` tracks generic reclamation separately. Compaction reclaims all dead write history after the fact; frame-specific bounding limits peak logical/physical progress while an effect is pending.
|
||||
Superseded `pi.op.state`, deleted pending payloads, deleted tool checkpoints, and deleted assistant-frame lists remain physical bytes indefinitely. Generic compaction and the [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md) are complementary. Compaction reclaims dead session-scoped write history after the fact; the handoff moves pending assistant/tool output into ephemeral scopes so it never becomes main-log history, and replaces full/per-frame replication with Chord op batches.
|
||||
|
||||
**Dependency**
|
||||
|
||||
Memory/SQLite M11 measurement and mechanism design may proceed in parallel. Implement and measure JSONL snapshot compaction before setting M11's final JSONL post-settlement budget, so M11 distinguishes pending-effect volume from already-dead physical history and does not invent a second reclamation mechanism.
|
||||
The assistant-output handoff does not depend on J1: scoped storage is the intended lifetime mechanism for pending output, while J1 remains the reclamation mechanism for superseded session-scoped state. Measure both independently so their effects are not conflated.
|
||||
|
||||
### Remote Session contract contradiction — decision required
|
||||
|
||||
|
|
@ -111,7 +111,7 @@ Before implementation, replace or reconcile the draft public interface and decid
|
|||
|
||||
### R11 — Schema migrations, activation-gated
|
||||
|
||||
No current format-4 migration is required. Memory is current-only; JSONL and SQLite reject unsupported storage versions; SQLite runs only idempotent `001_initial.sql`. R11 becomes required immediately before the first incompatible durable storage version/address/state change after format 4 is stabilized. It is not prerequisite work for M11 or current WIP format replacement.
|
||||
No current format-4 migration is required. Memory is current-only; JSONL and SQLite reject unsupported storage versions; SQLite runs only idempotent `001_initial.sql`. R11 becomes required immediately before the first incompatible durable storage version/address/state change after format 4 is stabilized. It is not prerequisite work for the mobile assistant-output handoff or current WIP format replacement.
|
||||
|
||||
When activated, it must provide ordered transactional migrate-on-open under exclusive ownership, version-specific JSONL decoding plus post-migration compaction, and total mappings for every reachable open operation leaf and surviving value/list.
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ Define cross-backend query-limit semantics in agent conformance, then chunk SQLi
|
|||
- The production gate-close contract permits only `HarnessClosed | HarnessFault`, but the private source primitive accepts any `Error` and isolated tests use that wider type. Narrow the source declaration and fixtures or explicitly retain the private widening.
|
||||
- Part 9 of `harness.md` is the required conformance matrix. Existing focused tests cover the graph extensively, including cancellation reconciliation over all 13 leaves, but there is no audited one-to-one proof that every close/reopen leaf case and every race row has both deterministic orders. Audit the matrix and add only the missing cases rather than claiming blanket completion.
|
||||
|
||||
Keep this package separate from telemetry, RemoteSession, and M11; it is local contract/test closure.
|
||||
Keep this package separate from telemetry, RemoteSession, and the mobile assistant-output handoff; it is local contract/test closure.
|
||||
|
||||
### Disabled real worker persistence regression
|
||||
|
||||
|
|
@ -157,7 +157,7 @@ Keep this package separate from telemetry, RemoteSession, and M11; it is local c
|
|||
|
||||
## Performance debt
|
||||
|
||||
### M11 — durable assistant-frame volume
|
||||
### Mobile assistant-output handoff — durable and replication amplification
|
||||
|
||||
The user-supplied motivating mini Session outside the repository is 303,920 bytes across 569 physical lines. These external measurements are evidence, not a reproducible checked-in fixture:
|
||||
|
||||
|
|
@ -165,11 +165,9 @@ The user-supplied motivating mini Session outside the repository is 303,920 byte
|
|||
- 12 frame-list deletes; physical lines mentioning the frame namespace total 148,214 bytes;
|
||||
- about 51,568 bytes of superseded `pi.op.state` writes and 26,192 bytes of one structural preparation, showing why generic JSONL compaction and frame-specific bounding are distinct.
|
||||
|
||||
M11 must first add a deterministic repository fixture and measurement script that reproduces or replaces these figures.
|
||||
The authoritative design is the [mobile assistant-output handoff](mobile-handoff/01-harness/05-assistant-output/message-update.md), following the numbered `01-harness` prerequisites in [`mobile-handoff/README.md`](mobile-handoff/README.md). Chord delta tracking has landed; scoped storage, tool-output integration, and assistant-output integration have not.
|
||||
|
||||
M11 must measure Memory logical elements, SQLite rows/pages/WAL, JSONL peak bytes during an effect, JSONL post-settlement bytes, and JSONL reopen/replay time. It must then bound persisted progress without weakening unknown-outcome recovery, invocation fencing, non-blocking provider streaming, or settlement cleanup. Live event fidelity need not imply one durable write per provider event.
|
||||
|
||||
Candidate mechanisms remain design inputs, not approved architecture: coalesced durable checkpoints, a bounded full snapshot plus short delta tail, or logarithmic snapshot points. The exit condition needs numerical budgets derived from fixtures, not only “smaller than before.”
|
||||
Implementation must add a deterministic repository fixture and measurement script that reproduces or replaces these figures, then measure Memory logical elements, SQLite rows/pages/WAL, JSONL peak sidecar/main-log bytes, and reopen/replay time. The handoff must preserve unknown-outcome recovery, invocation fencing, non-blocking provider streaming, and settlement retirement while eliminating per-frame durable writes and quadratic `message_update` replication. Numerical budgets belong in its implementation handoff/tests, not in a competing standalone design.
|
||||
|
||||
### SQLite branch divergence
|
||||
|
||||
|
|
@ -232,8 +230,8 @@ The order is by data safety first, then dependencies. Independent tracks may pro
|
|||
1. **Harness contract/conformance closure.** Resolve `OperationStatus.running`, abort signal/event binding order, gate-close typing, and the Part 9 coverage matrix.
|
||||
2. **Remote Session decision (decision only).** Resolve the false normative boundary early. If process-local wins, repair the docs. If raw RemoteSession wins, later create a dedicated protocol/client/server/worker package; do not fold it into telemetry or R12.
|
||||
3. **Client watch/subscription staleness** and **repository lifecycle contract.** Small independent correctness packages; complete them before expanding server/worker lifecycle semantics. The lifecycle package must also address Memory's fail-fast repository close.
|
||||
4. **JSONL snapshot compaction.** Implement the already-normative physical reclamation path and metrics.
|
||||
5. **M11 durable frame volume.** Memory/SQLite measurement can start earlier; set final JSONL budgets only with compaction measured, and preserve all recovery boundaries.
|
||||
4. **[Mobile Harness handoff](mobile-handoff/README.md).** Follow its numbered prerequisites through scoped storage, tool output, and assistant output; preserve all recovery boundaries and land deterministic amplification measurements.
|
||||
5. **JSONL snapshot compaction.** Implement the already-normative physical reclamation path and metrics for remaining session-scoped history.
|
||||
6. **R12 session-wide watch.** Complete the only Harness method stub before building revisioned Transcript/session-wide remote observation.
|
||||
7. **Telemetry, if retained:** reconcile schemas, then local instrumentation, then RPC propagation, then an optional exporter. RPC propagation follows the Remote Session/product-boundary decision.
|
||||
8. **WP08 — named-branch and streaming forks.** Implement the actionable handoff without reopening WP07 ownership or lifecycle decisions.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# WP05 — Direct durable drive
|
||||
|
||||
**Status: WP05 complete through M10: lane-owned inbox, immutable result records, 13 family-neutral leaves, atomic boundary planning, total cancellation/dispatch, public and replicated lane surfaces, documentation reconciliation, and lane-safe provider cache identity. M11 is a tracked storage-amplification follow-up outside the public-drive gate.**
|
||||
**Status: WP05 complete through M10: lane-owned inbox, immutable result records, 13 family-neutral leaves, atomic boundary planning, total cancellation/dispatch, public and replicated lane surfaces, documentation reconciliation, and lane-safe provider cache identity. Remaining assistant-output work is owned by the [mobile assistant-output handoff](../mobile-handoff/01-harness/05-assistant-output/message-update.md), outside the public-drive gate.**
|
||||
|
||||
WP06's Session/Branch/Lane separation is part of the foundation. Public drive is enabled; `watchSession` is the sole deferred Harness method.
|
||||
|
||||
|
|
@ -521,11 +521,11 @@ rg -i 'promotion|drainedSteer|drainedFollowUp|laneLastResult|lastResult|skipInbo
|
|||
|
||||
Ordinary assistant requests use the derived lane identity, structural requests keep fresh identities with `cacheRetention: "none"`, and deferred handle polling sends no cache identity. The policy is documented in `docs/harness.md` and independently reviewed before WP05 completion.
|
||||
|
||||
## 13. M11 — Bound durable streaming-frame volume
|
||||
## 13. Mobile assistant-output handoff
|
||||
|
||||
**Tracked follow-up; not part of the M8/M10 public-drive gate.** A trivial mini coding-agent Session produced an approximately 300 KB JSONL file because live assistant streaming persists many `pi.pending.assistant_frame` list appends. Logical frame cleanup does not reclaim bytes already appended to the JSONL history, so short conversations can have disproportionate durable storage and replay cost.
|
||||
|
||||
M11 must measure frame count and encoded-byte growth across Memory, JSONL, and SQLite, then bound amplification without weakening the existing crash contract: an admitted assistant effect remains reconstructible, progress observation remains useful, and settlement still removes operation-owned pending-frame state. Investigate coalesced durable checkpoints, bounded frame snapshots plus a short delta tail, and JSONL snapshot-compaction interaction. Do not solve this by dropping unknown-outcome recovery, persisting only the final response, or adding a generic retention framework.
|
||||
The authoritative follow-up is the [mobile assistant-output handoff](../mobile-handoff/01-harness/05-assistant-output/message-update.md) and its numbered prerequisites in the [mobile handoff README](../mobile-handoff/README.md). It addresses the complete path rather than only batching frames: Chord op tracking, scoped pending-output durability, tool/assistant output reduction, and `message_update` replication amplification. Preserve the existing crash contract: an admitted assistant effect remains reconstructible, progress observation remains useful, and settlement retires operation-owned pending state.
|
||||
|
||||
Exit checks:
|
||||
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ Do not include:
|
|||
- catalog redesign, async database replacement, or VACUUM policy;
|
||||
- search/FTS;
|
||||
- R11 migration machinery or a storage-version bump;
|
||||
- M11 assistant-frame changes or JSONL compaction;
|
||||
- [mobile assistant-output handoff](../mobile-handoff/01-harness/05-assistant-output/message-update.md) changes or JSONL compaction;
|
||||
- repository-wide `SessionRepo.close()` contract changes;
|
||||
- removal of shared-container support;
|
||||
- transaction DSLs, schedulers, generic lock managers, or compatibility layers.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue