chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
1 parent
5ebc7018c3
commit
4def357d09
1669 files changed
+397627
No files matched your search
Vendored
+604
@@ -0,0 +1,604 @@
|
||||
# Scoped storage Step 1 — actionable implementation handoff
|
||||
|
||||
**Status: actionable, not implemented.**
|
||||
|
||||
**Prepared against:** `c4b0e35ab` (`dev`). Re-audit named source files if the implementation baseline changes materially.
|
||||
|
||||
This handoff implements storage scopes only. **Read this file before [`scopes.md`](scopes.md).** It supersedes conflicting details there, especially per-scope sequence spaces, operation-derived orphan detection, one-use scope IDs, raw-string scope arguments, sidecars ending in `.jsonl`, the proposed interned retirement tuple, and bundling JSONL delta/address encoding into the same change. The broader motivation and measurements in `scopes.md` remain design evidence; its signatures and implementation order are not current where this file differs.
|
||||
|
||||
Step 2—Chord-encoded JSONL values, address interning, compact tuple records, and their measurements—is a separate package. Do not begin Step 2 until Step 1 has been tested, reviewed, explicitly approved by the user, committed, and pushed.
|
||||
|
||||
## 0. Delivery protocol
|
||||
|
||||
This package has a mandatory user checkpoint:
|
||||
|
||||
1. Implement Step 1 only.
|
||||
2. Run every required focused test, `npm run check`, and `./test.sh`.
|
||||
3. Review the complete diff and report results. Delegated reviews, if used, run with provider `anthropic` and model `claude-fable-5`.
|
||||
4. **Stop and wait for explicit user approval.**
|
||||
5. After approval, commit only this package's files and push that commit.
|
||||
6. Confirm the pushed commit before designing or implementing Step 2.
|
||||
|
||||
Do not combine the Step 1 and Step 2 commits. Do not commit or push before approval.
|
||||
|
||||
## 1. Mandatory reading
|
||||
|
||||
Read completely before editing:
|
||||
|
||||
1. `packages/agent/docs/harness.md` §§0.2–0.9, 1.1–1.7, 2.7–2.8, 3.7–3.8, 3.13, 4.4–4.8, 5.4, Part 7, Part 9.
|
||||
2. `packages/agent/docs/values.md`.
|
||||
3. `packages/agent/docs/mobile-handoff/README.md`.
|
||||
4. [`scopes.md`](scopes.md), using this handoff where they conflict.
|
||||
5. `../01-delta/delta.md` for vocabulary ownership only; Step 1 does not store Chord ops.
|
||||
6. `../04-tool-output/harness-tools.md` §§7.1–7.7 for later-consumer context; do not implement its output redesign here.
|
||||
7. `../05-assistant-output/message-update.md` §7 for later-consumer context; do not implement it here.
|
||||
8. `packages/agent/src/harness/session/{types,values,commit,in-memory-storage-state,memory,session,index}.ts`.
|
||||
9. Every file under `packages/agent/src/harness/session/jsonl/` and the `FileSystem` declaration/implementation in `packages/agent/src/harness/{types,env/nodejs}.ts`.
|
||||
10. `packages/agent/src/harness/runtime/{progress,lane}.ts`, `runtime/drive/{terminal,response,deferred,tools,reconcile,structural,boundary}.ts`, and every caller of `operationCleanupWrites`. Inspect `tool-placement.ts` through the grep audit, but current source has no scoped delete there.
|
||||
11. `packages/session-backends/sqlite-node/src/{index,sqlite/index}.ts`, `sqlite/{storage,repo}.ts`, `migrations/001_initial.sql`, and `session/{values,session-sequences}.ts`.
|
||||
12. Shared storage/repository conformance and every focused test named in §9.
|
||||
13. `scopes.variance.ts` for the covariant-address/invariant-write phantom mechanism only. Its current no-ID `EphemeralScope` and `retireScope(id: string)` signatures are superseded by §2 and must be updated in Slice A.
|
||||
|
||||
Do not use `dist/` as implementation input. Format 4 remains WIP; no R11 migration is part of this package.
|
||||
|
||||
## 2. Fixed scope contract
|
||||
|
||||
### 2.1 Runtime scope identity
|
||||
|
||||
A scope ID is a reusable logical lifetime name, not a globally one-use token:
|
||||
|
||||
```ts
|
||||
export type SessionScope = { readonly kind: "session" };
|
||||
|
||||
export interface EphemeralScope {
|
||||
readonly kind: "ephemeral";
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export type Scope = SessionScope | EphemeralScope;
|
||||
|
||||
export function ephemeralScope(id: string): EphemeralScope;
|
||||
```
|
||||
|
||||
`ephemeralScope(id)` requires a non-empty well-formed Unicode string and returns a frozen value. Equal IDs identify the same physical scope; object identity has no meaning. JSONL encodes IDs with `encodeURIComponent`. The encoded component must be at most 180 ASCII characters; reject longer IDs and lone-surrogate input. The physical filename always has a fixed `scope-` prefix, so encoded `.`/`..` values cannot become path segments. Do not directly interpolate unencoded caller input into a path.
|
||||
|
||||
No create/open-scope transaction exists. The first scoped value/list write creates physical state. A later `retireScope(scope)` ends everything in that scope through its assigned global sequence. A write under the same ID after retirement begins a new logical lifetime.
|
||||
|
||||
Harness operation scopes use `ephemeralScope(operationId)`. Generic application scopes may use stable names. Storage never parses a scope ID as an operation ID and never reads Harness operation state to decide lifetime.
|
||||
|
||||
### 2.2 Reuse and retirement boundary
|
||||
|
||||
Global sequence order defines reusable generations:
|
||||
|
||||
```text
|
||||
seq 10 scoped write S
|
||||
seq 20 scoped write S
|
||||
seq 30 retireScope(S)
|
||||
seq 40 scoped write S # new lifetime
|
||||
```
|
||||
|
||||
For a given scope ID, only scoped records with `seq` greater than its latest committed retirement sequence are live. Memory and SQLite realize this by deleting current rows/maps at retirement and allowing later writes to recreate them. JSONL uses the retirement sequence as its replay boundary (§6).
|
||||
|
||||
Retirement is idempotent in the storage sense: retiring a scope with no current state is legal and advances the boundary. A later write still begins a new lifetime. Storage does not maintain an all-time used-ID registry or reject reuse.
|
||||
|
||||
A scoped write reaching the commit line after retirement is therefore a new-lifetime write. Harness invocation fencing and settlement drains must continue to ensure a late assistant/tool progress job cannot recreate operation output after terminal retirement. Add explicit tests for this boundary.
|
||||
|
||||
### 2.3 Explicit retirement is the sole authority
|
||||
|
||||
```ts
|
||||
export function retireScope(scope: EphemeralScope): Write<SessionScope>;
|
||||
```
|
||||
|
||||
Storage never infers orphanage from operation absence, namespace, current state, or a missing owner. The only valid lifetime boundary is a committed `retireScope` write. If an owner never retires its scope, that is an owner defect and the scope remains live.
|
||||
|
||||
Crash behavior:
|
||||
|
||||
- crash before the retirement transaction commits: the scope remains live and reopens;
|
||||
- crash after retirement commits but before JSONL unlink: replay sees the retirement boundary, ignores pre-boundary sidecar records, and retries physical deletion;
|
||||
- crash during a later reused lifetime: records after the latest retirement reopen normally.
|
||||
|
||||
Repository deletion separately removes every physical sidecar belonging to the deleted Session. It does not need operation semantics.
|
||||
|
||||
### 2.4 Address and write typing
|
||||
|
||||
Addresses carry a covariant scope tag; writes carry an invariant scope tag. Preserve value-type invariance independently:
|
||||
|
||||
```ts
|
||||
interface Value<T, Sc extends Scope = SessionScope> { /* existing fields + scope */ }
|
||||
interface ValueList<T, Sc extends Scope = SessionScope> { /* existing fields + scope */ }
|
||||
|
||||
declare function value<T>(namespace: string, key?: string): Value<T, SessionScope>;
|
||||
declare function value<T>(namespace: string, key: string, scope: EphemeralScope): Value<T, EphemeralScope>;
|
||||
declare function list<T>(namespace: string, key?: string): ValueList<T, SessionScope>;
|
||||
declare function list<T>(namespace: string, key: string, scope: EphemeralScope): ValueList<T, EphemeralScope>;
|
||||
```
|
||||
|
||||
A session-scoped address has no runtime scope ID. An ephemeral address carries its scope ID. Scope is part of physical identity: equal namespace/key addresses in session scope and ephemeral scope, or in two different ephemeral IDs, are distinct.
|
||||
|
||||
`setValue`, `deleteValue`, `appendList`, and `deleteList` preserve the address scope in `Write<Sc>`. Entry and usage writes are session-scoped. `retireScope` is session-scoped even though applying it deletes ephemeral state.
|
||||
|
||||
All writes in an ordinary transaction have one static scope. A session transaction may include session values, entries, usage, and one or more `retireScope` writes. It may not include a direct ephemeral set/delete/append. An ephemeral transaction may include value/list writes only. At runtime, every ephemeral write in one transaction must carry the same scope ID; two different IDs have the same TypeScript tag and require this assertion.
|
||||
|
||||
Make the minimum generic changes necessary to preserve this through `Write`, `CommittedWrite`, `Storage.commit`, Session mutation capabilities, `CommitDecision`, and lane commands. Follow the variance proof in `scopes.variance.ts`; do not make readers invariant or propagate unnecessary scope type parameters through read-only APIs.
|
||||
|
||||
### 2.5 One global sequence space
|
||||
|
||||
Memory, JSONL main records, JSONL sidecar records, and SQLite rows share one Session-global sequence space. Every admitted commit remains serialized and receives increasing sequences in admission order. Gaps remain legal.
|
||||
|
||||
- Memory continues using one `nextSeq`.
|
||||
- SQLite continues allocating from `sessions.next_seq` inside the write transaction.
|
||||
- JSONL main and sidecar appends share one commit queue and one resident `nextSeq`.
|
||||
|
||||
JSONL open computes the high-water mark from the main header and every complete record in every matching sidecar **before** deleting or ignoring retired physical files. A retired sidecar's sequences may then become gaps but can never be reused. Torn uncommitted final transactions do not advance the durable high-water mark.
|
||||
|
||||
No reservation record, per-scope sequence counter, or range allocator is permitted.
|
||||
|
||||
## 3. List tags
|
||||
|
||||
Step 1 adds only the mechanism required by later tracked-output recovery:
|
||||
|
||||
```ts
|
||||
export interface ListElement<T> {
|
||||
seq: number;
|
||||
value: T;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface ListReadOptions {
|
||||
cursor?: ListCursor;
|
||||
order?: "asc" | "desc";
|
||||
limit?: number;
|
||||
stopAtTag?: string;
|
||||
}
|
||||
|
||||
export function appendList<T, Sc extends Scope>(
|
||||
address: ValueList<T, Sc>,
|
||||
element: NoInfer<T>,
|
||||
tag?: string,
|
||||
): ListAppendWrite<Sc>;
|
||||
```
|
||||
|
||||
Tags are non-empty strings when present. Storage stores and returns the tag without interpreting the element.
|
||||
|
||||
Read semantics:
|
||||
|
||||
1. Apply address scope, exclusive cursor, and order.
|
||||
2. Inspect at most the normalized `limit` elements.
|
||||
3. Stop at and include the first element carrying `stopAtTag`.
|
||||
4. Return that possibly shortened page.
|
||||
|
||||
`stopAtTag` never searches beyond the page limit. If absent from the page, the caller pages again from the last returned sequence. Do **not** add the draft `tag` filter; no current consumer needs it and combining filtering with the existing cursor shape is ambiguous.
|
||||
|
||||
SQLite may fetch the indexed `limit` rows and truncate at the first returned tag in TypeScript; storage still never parses payloads. Preserve the existing primary-key query plan with no temporary sort.
|
||||
|
||||
## 4. Step 1 durable payloads remain unchanged
|
||||
|
||||
This package changes lifetime and routing, not output representation:
|
||||
|
||||
- `pendingToolOutput` remains `Value<AgentToolResult<unknown>, EphemeralScope>`;
|
||||
- `pendingAssistantFrames` remains `ValueList<AssistantMessageFrame, EphemeralScope>`;
|
||||
- `operationToolMemo` remains a `JsonValue` scalar;
|
||||
- JSONL transaction records remain readable keyed objects;
|
||||
- assistant progress still appends one frame per accepted frame;
|
||||
- tool checkpoints still replace whole snapshots;
|
||||
- `message_update` and `tool_update` event shapes do not change;
|
||||
- safe replay retains its current checkpoint behavior, including the known delete-before-replay bug owned by the later tool-output package.
|
||||
|
||||
Do not rename `pendingAssistantFrames`, introduce `pendingAssistantOutput`, store `WireOp[]`, add Chord trackers/codecs, redesign `AgentHarnessTool`, or change output cadence here.
|
||||
|
||||
## 5. Runtime ownership and cleanup
|
||||
|
||||
### 5.1 Addresses moved to the operation scope
|
||||
|
||||
Construct these addresses with `ephemeralScope(operationId)`:
|
||||
|
||||
- `operationToolMemo(operationId, invocationId, name)`;
|
||||
- `pendingToolOutput(operationId, invocationId)`;
|
||||
- `pendingAssistantFrames(operationId, responseEntryId)`.
|
||||
|
||||
All other current built-ins remain session-scoped. In particular, `pi.op.state`, `pi.op.meta`, `pi.op.tool_args`, `pi.op.preparation`, and `pi.pending.entry` remain in the main scope because their writes coordinate atomically with lane/operation state.
|
||||
|
||||
### 5.2 No cross-file cleanup transaction
|
||||
|
||||
Remove direct ephemeral deletes from transactions that also write session state:
|
||||
|
||||
- assistant response settlement;
|
||||
- deferred-response supersession;
|
||||
- tool outcome staging in `drive/tools.ts`;
|
||||
- cancellation/recovery decisions;
|
||||
- terminal per-address cleanup.
|
||||
|
||||
Current `drive/tool-placement.ts` has no ephemeral delete; do not invent a change there.
|
||||
|
||||
`operationCleanupWrites` stops scanning tool memos and tool outputs and stops constructing a state-directed assistant-frame delete. It keeps session-scoped operation/meta/args/preparation/pending-entry cleanup and adds exactly one `retireScope(ephemeralScope(operationId))` in the universal terminal suffix.
|
||||
|
||||
After removing the current staging/terminal scans, remove `operationToolMemoPrefix` and `pendingToolOutputPrefix`; current source has no other production consumers. Update the normative exported prefix-constructor count from five to three and update exact constructor tests rather than retaining dead inventory APIs.
|
||||
|
||||
An ephemeral-only delete remains legal. For example, the current safe-replay checkpoint delete remains because its transaction contains no session write; fixing its behavior belongs to the later tool-output package, not scoped storage or JSONL optimization.
|
||||
|
||||
### 5.3 Unreachable scoped residue
|
||||
|
||||
After a response settles, a deferred response is superseded, or a tool reaches `outcome_ready`, its former frames/checkpoints/memos may remain physically and logically addressable inside the still-active operation scope. Current scalar operation state no longer references them; no recovery or snapshot path scans the scope to infer authority. The terminal retirement deletes the whole scope.
|
||||
|
||||
Call this **unreachable scoped residue**, not orphanage. Update `harness.md` invariants that currently require an `outcome_ready` call to have no physically present memo/checkpoint. The replacement invariant is that settled child state never consumes or exposes residue and terminal retirement removes all scoped state atomically with operation completion.
|
||||
|
||||
Close and fault are controlled crashes and never retire the scope. Reopen restores scoped progress only through addresses derived from current authoritative operation state.
|
||||
|
||||
## 6. JSONL sidecars
|
||||
|
||||
### 6.1 Physical layout
|
||||
|
||||
The main session file remains unchanged except for committed scope-retirement records. Every main file owns one known sidecar directory at `${mainPath}.scopes`; this requires no dirname API or `FileSystem` extension. Create it lazily with `createDir(..., { recursive: true })`. Discover scopes with `listDir` on that exact path. A sidecar filename is exactly `scope-${encodeURIComponent(scope.id)}.scope`, subject to §2.1's 180-character encoded-component limit. The suffix does not end in `.jsonl`, and the sidecar directory itself is a directory, so repository listing never mistakes either for a Session.
|
||||
|
||||
A sidecar begins with this exact header:
|
||||
|
||||
```ts
|
||||
interface JsonlScopeHeader {
|
||||
v: 4;
|
||||
kind: "scope_header";
|
||||
sessionId: string;
|
||||
scopeId: string;
|
||||
storageVersion: 1;
|
||||
}
|
||||
```
|
||||
|
||||
Validate the exact session/scope identity before replay. Create the first header through a temp file inside `${mainPath}.scopes` plus atomic rename. Temporary filenames end in `.tmp` and are never discovered as sidecars. Use the existing `FileSystem` capability; do not add a dirname method, extend `JsonlStorageOptions`, or add Node-only filesystem access to agent core.
|
||||
|
||||
Step 1 sidecar writes retain the current object record spelling and carry/validate their scope ID in committed shapes. Each physical transaction remains one complete line or one array line. Sidecar transactions may contain only value/list writes for that exact scope.
|
||||
|
||||
### 6.2 Retirement record
|
||||
|
||||
The main log records an explicit committed write:
|
||||
|
||||
```ts
|
||||
interface CommittedScopeRetireWrite {
|
||||
kind: "scope";
|
||||
op: "retire";
|
||||
seq: number;
|
||||
scopeId: string;
|
||||
}
|
||||
```
|
||||
|
||||
Its Step 1 JSON representation is the corresponding readable object. Do not use the draft `['!', addrId, seq]`: a scope is not an interned value/list address. Compact tuple spelling belongs to Step 2 and must be designed there.
|
||||
|
||||
Applying a committed retirement removes resident values/lists in that scope. After the complete main transaction is durable, JSONL synchronously serializes lifecycle cleanup on the same commit queue: close any owned sidecar resource, attempt unlink, then release the queue. Failure to unlink is non-fatal to the already-committed business transaction; the sidecar remains logically bounded by the retirement sequence and deletion is retried on reopen.
|
||||
|
||||
### 6.3 Replay and reusable IDs
|
||||
|
||||
Open performs these logical stages before admitting writes:
|
||||
|
||||
1. Read/repair the main file and parse complete main transactions.
|
||||
2. Discover and validate matching sidecars; ignore unrelated files.
|
||||
3. Parse complete sidecar transactions and repair torn final transactions.
|
||||
4. Compute the global sequence high-water mark from the header and **all** complete main/sidecar records, including records later excluded by retirement.
|
||||
5. Determine the latest retirement sequence per scope from main transactions.
|
||||
6. Keep sidecar writes whose sequence is greater than that scope's latest retirement.
|
||||
7. Merge retained main and sidecar transactions by global sequence and replay them in global order, preserving transaction boundaries and rejecting duplicate/non-monotonic sequences.
|
||||
8. Advance to the precomputed high-water mark.
|
||||
9. Remove sidecars with no records after their latest retirement; cleanup failure does not make retired contents live.
|
||||
|
||||
Main transactions have sequence gaps where sidecar transactions occurred. Sidecars have gaps where other scopes/main commits occurred. Both remain valid.
|
||||
|
||||
A sidecar containing records after a retirement is an active reused lifetime and must not be deleted. Serialize retirement cleanup against later writes so an old unlink cannot race and remove a newly reused sidecar.
|
||||
|
||||
Malformed interior records or identity mismatch in an active sidecar are storage corruption and fail open. A torn final transaction is discarded wholly. Temporary files left by interrupted atomic creation are cleanup artifacts and never become sidecars.
|
||||
|
||||
### 6.4 Legacy v3
|
||||
|
||||
Legacy v3 has no scopes. An ordinary Harness operation commits session-scoped acceptance before any progress write, so it upgrades first in the normal path. Still define and test the generic storage behavior when the first caller write is ephemeral: complete the existing v3-to-v4 main rewrite/usage adjustment under the global commit queue, then commit the scoped transaction without placing it in the main file. A crash before the sidecar append leaves no committed caller transaction and may reuse its uncommitted sequence.
|
||||
|
||||
### 6.5 Repository and fork behavior
|
||||
|
||||
- Repository `list()` ignores `${mainPath}.scopes` directories and their files through its existing non-file/`.jsonl` filtering.
|
||||
- Repository `delete()` removes the main file through its current path, then removes `${mainPath}.scopes` recursively with `force: true`; a scope-directory cleanup failure rejects deletion after the main-file result is known, without inferring Harness state. Missing scope directories are legal.
|
||||
- `JsonlStorage.close()` drains its admitted main/sidecar commits as today; `JsonlSessionRepo.close()` retains its current lifecycle behavior because repository ownership is a separate package. No progress scope is retired merely because a handle closes.
|
||||
- Preserve the current fork allowlist exactly. Do not add generic application values or lists to either fork scope. Ensure source snapshots and backend fork readers ignore all ephemeral values/lists regardless of namespace; current destinations remain logically unchanged except that ephemeral state cannot leak.
|
||||
- Fork destinations contain no sidecars or retirement state.
|
||||
- WP08 may later replace fork materialization but must preserve this scope policy.
|
||||
|
||||
J1 compaction is excluded. When J1 eventually lands, it may omit a historical retirement record only after no physical sidecar can be made live by losing that boundary.
|
||||
|
||||
## 7. Memory backend
|
||||
|
||||
Extend `InMemoryStorageState` physical value/list identity with scope. Session scope and each ephemeral ID are distinct. Preserve one global `nextSeq` and current stats behavior.
|
||||
|
||||
Applying `CommittedScopeRetireWrite` deletes every scalar/list in that scope in the same synchronous application of the surrounding transaction. It does not delete entries or usage, which cannot be ephemeral. A later scoped write recreates state under the same ID.
|
||||
|
||||
Snapshots expose scope so fork code can reject ephemeral state, but `createForkSnapshot` keeps its current built-in allowlist and must not start copying generic session-scoped application values or lists. Instrumentation must still report exact committed write order, including retirement.
|
||||
|
||||
## 8. SQLite backend
|
||||
|
||||
Change WIP schema in place with no migration:
|
||||
|
||||
```sql
|
||||
scalar_values(
|
||||
session_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
PRIMARY KEY(session_id, scope_id, namespace, key)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
list_values(
|
||||
session_id TEXT NOT NULL,
|
||||
scope_id TEXT NOT NULL,
|
||||
namespace TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
tag TEXT,
|
||||
PRIMARY KEY(session_id, scope_id, namespace, key, seq)
|
||||
) WITHOUT ROWID;
|
||||
```
|
||||
|
||||
Use `scope_id = ''` for session scope and the exact scope ID otherwise. Every point read, prefix scan, set/delete, append/delete, snapshot, and query-plan assertion includes scope identity.
|
||||
|
||||
Inside the same `BEGIN IMMEDIATE` transaction, a retirement write executes:
|
||||
|
||||
```sql
|
||||
DELETE FROM scalar_values WHERE session_id = ? AND scope_id = ?;
|
||||
DELETE FROM list_values WHERE session_id = ? AND scope_id = ?;
|
||||
```
|
||||
|
||||
It still consumes its assigned global sequence through `sessions.next_seq`, even though no retired-scope tombstone row remains. A later write under that ID recreates rows naturally. Retirement rollback must restore both scoped rows and all sibling main writes.
|
||||
|
||||
Fork paths copy only `scope_id = ''`. Shared-container deletion remains Session-scoped and removes all values/lists regardless of scope along with the other Session rows.
|
||||
|
||||
## 9. Required tests
|
||||
|
||||
Port tests before or with each implementation slice. Use the repository Vitest binary from the owning package. Do not run the full Vitest suite directly.
|
||||
|
||||
### 9.1 Type and constructor tests
|
||||
|
||||
- Session addresses default to `SessionScope`.
|
||||
- Ephemeral overloads retain `EphemeralScope` while preserving invariant `T`.
|
||||
- Address scope is covariant for reads; write scope is invariant.
|
||||
- Same-scope session transactions typecheck.
|
||||
- Same-scope ephemeral transactions typecheck.
|
||||
- Session writes plus `retireScope` typecheck.
|
||||
- Direct session + ephemeral writes in one transaction fail with `@ts-expect-error`.
|
||||
- Two ephemeral IDs evade static distinction but fail the runtime same-ID assertion.
|
||||
- Scope object identity is irrelevant; equal IDs access the same state.
|
||||
- Empty and overlong encoded IDs reject; separators/Unicode encode without path escape.
|
||||
- Session and ephemeral addresses with equal namespace/key do not alias.
|
||||
|
||||
### 9.2 Shared backend conformance
|
||||
|
||||
Run identically on Memory, JSONL, and SQLite:
|
||||
|
||||
- ephemeral scalar set/get/replace/delete;
|
||||
- ephemeral list append/page/whole-list-delete;
|
||||
- independent scopes with equal namespace/key;
|
||||
- session scope independent from equal ephemeral address;
|
||||
- mixed session/ephemeral transaction rejection before mutation;
|
||||
- two ephemeral IDs in one transaction rejection before mutation;
|
||||
- retirement deletes every value/list in the exact scope and no other scope;
|
||||
- retirement atomic with session sibling writes;
|
||||
- retirement of absent state is legal;
|
||||
- write after retirement recreates a new lifetime;
|
||||
- repeated retirement advances the boundary without making later writes disappear;
|
||||
- main, scope A, scope B, retirement, and reused-A commits receive globally increasing sequences in admission order;
|
||||
- retired/deleted physical elements leave legal gaps and first post-reopen commit allocates above every complete prior record;
|
||||
- list tags round-trip, page limits remain bounded, and ascending/descending `stopAtTag` includes the marker;
|
||||
- stats ignore scoped values/lists and retirement;
|
||||
- close drains admitted scoped commits and rejects later reads/writes.
|
||||
|
||||
### 9.3 JSONL focused tests
|
||||
|
||||
- first scoped write creates exactly one valid sidecar and no main value/list record;
|
||||
- several writes in one scoped transaction remain one physical sidecar line;
|
||||
- main and multiple sidecars replay in global sequence order;
|
||||
- header `nextSeq` and all complete sidecar records contribute to reopen high-water;
|
||||
- torn sidecar final transaction is discarded wholly and repaired;
|
||||
- malformed interior active sidecar and header identity mismatch fail open;
|
||||
- crash before retirement leaves scoped data live;
|
||||
- crash after main retirement but before unlink leaves data logically absent and cleanup retries;
|
||||
- unlink failure does not reject the already-committed terminal transaction;
|
||||
- reuse after retirement replays only records after the latest retirement sequence;
|
||||
- multiple retirement/reuse cycles choose the latest boundary;
|
||||
- retirement cleanup is serialized so a later reuse cannot be deleted by an earlier unlink;
|
||||
- ignored pre-boundary records still prevent sequence reuse;
|
||||
- sidecars and temp files never appear in repository listing;
|
||||
- repository deletion removes all sidecars;
|
||||
- forks exclude generic application-owned ephemeral values/lists;
|
||||
- legacy-v3 first session write and first scoped-write paths both produce valid v4 state;
|
||||
- close/reopen preserves active operation progress and does not retire it.
|
||||
|
||||
### 9.4 SQLite focused tests
|
||||
|
||||
- schema and query helpers include `scope_id`; list rows preserve nullable tags;
|
||||
- one transaction atomically deletes scoped scalar/list rows and writes terminal main state;
|
||||
- forced failure after scope deletion rolls back deletion and sibling writes;
|
||||
- reuse after retirement recreates rows under the same scope ID;
|
||||
- `sessions.next_seq` advances for retirement;
|
||||
- list paging plan still uses the scoped primary key with no temp b-tree;
|
||||
- both per-file and shared-container layouts isolate Session and scope IDs;
|
||||
- fork and Session deletion exclude/remove scoped rows correctly.
|
||||
|
||||
### 9.5 Harness integration
|
||||
|
||||
Update existing exact-write tests rather than weakening them:
|
||||
|
||||
- assistant/deferred settlement drops mixed-scope frame deletes;
|
||||
- tool outcome staging drops mixed-scope checkpoint/memo deletes;
|
||||
- unreachable residue is never consumed by snapshot/recovery after its owning child settles;
|
||||
- safe-replay's current ephemeral-only delete remains ordered and fenced;
|
||||
- every terminal leaf emits one retirement in the documented cleanup position;
|
||||
- close/fault emits no retirement and reopen recovers current frames/checkpoint/memos;
|
||||
- cancellation and unknown-outcome recovery retire only at terminal completion;
|
||||
- late queued assistant/tool progress cannot commit after terminal retirement or reopen the scope;
|
||||
- terminal cleanup no longer scans memo/output prefixes;
|
||||
- every focused test and fixture found by the §12 old-delete/prefix greps is updated deliberately; do not rely on a historical numeric test count;
|
||||
- every terminal path reached through `operationCleanupWrites` callers in `drive/{response,reconcile,structural,boundary}.ts` is covered, including each operation family and cancellation.
|
||||
|
||||
Likely focused files include:
|
||||
|
||||
```text
|
||||
packages/agent/test/harness/values.test.ts
|
||||
packages/agent/test/harness/{memory,jsonl}-storage*.test.ts
|
||||
packages/agent/test/harness/{memory,jsonl}-session-repo*.test.ts
|
||||
packages/agent/test/harness/runtime/drive-{terminal,retry-deferred,tools,reconcile,generation}.test.ts
|
||||
packages/session-backends/sqlite-node/test/{storage,storage-conformance,repo,repo-conformance}.test.ts
|
||||
```
|
||||
|
||||
Use compiler and grep guards to find additional affected tests rather than assuming this list is exhaustive.
|
||||
|
||||
## 10. Implementation slices
|
||||
|
||||
### Slice A — contract, types, Memory reference, list tags
|
||||
|
||||
Primary files:
|
||||
|
||||
```text
|
||||
CREATE packages/agent/src/harness/session/scope.ts for `SessionScope`, `EphemeralScope`, `Scope`, `ephemeralScope`, scope phantoms/helpers, and runtime scope-ID validation
|
||||
MODIFY packages/agent/src/harness/session/{types,values,commit,in-memory-storage-state,memory,session,index}.ts
|
||||
MODIFY packages/agent/src/harness/session/testing/{conformance/storage,instrumented-storage,storage-decorator,gating-storage}.ts
|
||||
MODIFY packages/agent/test/harness/{values,memory-conformance,memory-storage,storage-backed-session}.test.ts
|
||||
MODIFY packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.variance.ts
|
||||
```
|
||||
|
||||
1. Add scope values/address/write variance and reusable-ID semantics.
|
||||
2. Add retirement committed write and one-scope transaction validation.
|
||||
3. Add tag/`stopAtTag` behavior.
|
||||
4. Implement Memory physical identity, retirement, global sequences, snapshots.
|
||||
5. Pass type tests and Memory conformance before continuing.
|
||||
|
||||
### Slice B — SQLite
|
||||
|
||||
Primary files:
|
||||
|
||||
```text
|
||||
MODIFY packages/session-backends/sqlite-node/src/sqlite/migrations/001_initial.sql
|
||||
MODIFY packages/session-backends/sqlite-node/src/sqlite/{storage,repo}.ts
|
||||
MODIFY packages/session-backends/sqlite-node/src/sqlite/session/{values,session-sequences}.ts
|
||||
MODIFY packages/session-backends/sqlite-node/test/{storage,storage-conformance,repo,repo-conformance}.test.ts
|
||||
```
|
||||
|
||||
1. Add scoped schema/query identity and tags.
|
||||
2. Implement atomic `DELETE ... WHERE scope_id` retirement.
|
||||
3. Preserve global sequence allocation, stats, branch indexes, shared-container behavior.
|
||||
4. Exclude ephemeral rows from forks.
|
||||
5. Pass SQLite focused/conformance tests before continuing.
|
||||
|
||||
### Slice C — JSONL sidecars
|
||||
|
||||
Primary files:
|
||||
|
||||
```text
|
||||
CREATE packages/agent/src/harness/session/jsonl/scope-files.ts for fixed directory/file naming, exact header parsing/serialization, discovery, atomic first creation, torn-tail parsing/repair, and recursive cleanup helpers
|
||||
MODIFY packages/agent/src/harness/session/jsonl/{types,codec,storage,repo,legacy-v3,index}.ts
|
||||
MODIFY packages/agent/test/harness/jsonl-{storage,storage-conformance,session-repo,session-repo-conformance,v3-migration}.test.ts
|
||||
```
|
||||
|
||||
1. Add sidecar naming/header/create/discovery.
|
||||
2. Route scoped commits to sidecars while allocating globally.
|
||||
3. Persist main retirement records and serialized unlink.
|
||||
4. Implement multi-file high-water calculation and globally ordered replay with reusable boundaries.
|
||||
5. Handle torn tails, corruption, v3, listing, delete, close, and forks.
|
||||
6. Pass all JSONL tests before runtime migration.
|
||||
|
||||
### Slice D — Harness migration
|
||||
|
||||
Primary files:
|
||||
|
||||
```text
|
||||
MODIFY packages/agent/src/harness/session/values.ts
|
||||
MODIFY packages/agent/src/harness/runtime/{progress,lane}.ts to propagate scoped write generics through progress channels and lane commands
|
||||
MODIFY packages/agent/src/harness/runtime/drive/{terminal,response,deferred,tools,reconcile,structural,boundary}.ts
|
||||
INSPECT packages/agent/src/harness/runtime/drive/tool-placement.ts; current source needs no scoped cleanup change
|
||||
MODIFY all focused runtime tests found by the §12 greps
|
||||
```
|
||||
|
||||
1. Scope memo/tool/frame addresses by operation ID.
|
||||
2. Remove mixed-scope deletes and cleanup scans.
|
||||
3. Add terminal retirement in the universal suffix.
|
||||
4. Preserve current output/replay/event behavior.
|
||||
5. Prove late-write fencing and every terminal/close/recovery path.
|
||||
|
||||
### Slice E — documentation and final validation
|
||||
|
||||
Update current normative/reference docs:
|
||||
|
||||
```text
|
||||
packages/agent/docs/harness.md
|
||||
packages/agent/docs/values.md
|
||||
packages/agent/docs/post-wp05-roadmap.md
|
||||
packages/agent/docs/mobile-handoff/README.md
|
||||
packages/agent/docs/mobile-handoff/01-harness/02-scopes/scopes.md
|
||||
packages/session-backends/sqlite-node/README.md
|
||||
packages/agent/src/harness/telemetry.ts and generated `packages/agent/docs/telemetry-schema.md`: add the `scope` session-write item kind without implementing spans
|
||||
```
|
||||
|
||||
Do not rewrite historical WP00–WP07 handoffs or released changelog sections. On `dev`, do not add changelog entries under the repository's main-only rule.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
./test.sh
|
||||
```
|
||||
|
||||
Also run every modified focused test after each slice. Record full command results for the approval checkpoint.
|
||||
|
||||
## 11. Exclusions
|
||||
|
||||
Do not include:
|
||||
|
||||
- Chord `Op`/`WireOp` storage integration;
|
||||
- JSONL address/path interning or compact tuple records;
|
||||
- pending-output address renames;
|
||||
- `ToolOutput`, tool API changes, replay seeding, memo/checkpoint atomicity, progress cadence, rate limiting, exec-env changes;
|
||||
- compact `message_update`, assistant incremental reducer changes, or protocol replication changes;
|
||||
- J1 main-log snapshot compaction;
|
||||
- WP08 fork redesign beyond excluding ephemeral state from current forks;
|
||||
- repository lifecycle contract changes;
|
||||
- telemetry implementation beyond keeping the declared schema accurate for the new write kind;
|
||||
- migrations or compatibility for older WIP format-4 SQLite schemas;
|
||||
- application-level automatic scope ownership, operation-derived orphan inference, leases, TTLs, or background scope collection.
|
||||
|
||||
If implementation requires an excluded item, stop and revise this handoff before expanding scope.
|
||||
|
||||
## 12. Grep guards
|
||||
|
||||
Before final review, inspect every remaining match:
|
||||
|
||||
```text
|
||||
operationToolMemoPrefix
|
||||
pendingToolOutputPrefix
|
||||
deleteList(pendingAssistantFrames
|
||||
deleteValue(pendingToolOutput
|
||||
scopeId
|
||||
retireScope
|
||||
stopAtTag
|
||||
```
|
||||
|
||||
Expected outcomes:
|
||||
|
||||
- no session-scoped transaction directly deletes an ephemeral address;
|
||||
- no terminal path omits retirement;
|
||||
- no backend point read/write omits scope identity;
|
||||
- no fork copies ephemeral state;
|
||||
- no JSONL sidecar is treated as a Session file;
|
||||
- no per-scope sequence counter exists;
|
||||
- no operation-state/orphan inference exists in storage.
|
||||
|
||||
## 13. Exit condition
|
||||
|
||||
Step 1 is implementation-complete when:
|
||||
|
||||
- scope/address/write typing rejects mixed-scope transactions and runtime validation rejects two ephemeral IDs in one transaction;
|
||||
- IDs are reusable through latest-retirement global sequence boundaries with no historical ID registry;
|
||||
- Memory, JSONL, and SQLite share one global sequence space and pass common conformance;
|
||||
- SQLite retirement deletes exact scoped rows atomically with terminal state;
|
||||
- JSONL writes ephemeral values/lists only to sidecars, records retirement only in the main log, replays all files in global order, preserves high-water marks, and deletes retired sidecars without operation inference;
|
||||
- list tags and bounded `stopAtTag` work identically across backends;
|
||||
- current tool/assistant payload and event formats are unchanged;
|
||||
- every terminal path retires once, while close/fault preserves active scope recovery;
|
||||
- forks and repository listing/deletion handle sidecars correctly;
|
||||
- focused tests, `npm run check`, and `./test.sh` pass;
|
||||
- final review has no blocker;
|
||||
- the complete diff and results have been presented to the user and implementation has stopped for approval.
|
||||
|
||||
Only after explicit approval may Step 1 be committed and pushed. Step 2 starts only after that push is confirmed.
|
||||
+574
@@ -0,0 +1,574 @@
|
||||
# Session Storage: Scopes
|
||||
|
||||
> **Status:** Design evidence. The actionable Step 1 contract is [`implementation-handoff.md`](implementation-handoff.md), which supersedes this document on sequence allocation, reusable scope IDs, retirement authority/records, sidecar layout, implementation order, and the separation of JSONL encoding into Step 2.
|
||||
>
|
||||
> **Scope:** `packages/agent/src/harness/session/`, plus the call sites in
|
||||
> `runtime/` listed in §10.
|
||||
>
|
||||
> **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.
|
||||
>
|
||||
> Every claim here was checked against `runtime/lane.ts`, `runtime/progress.ts`
|
||||
> and `runtime/drive/*.ts`. The type-level enforcement in §6 was verified with
|
||||
> `tsc --strict`, including that mixed-scope commits are the *only* thing it
|
||||
> rejects.
|
||||
|
||||
## 1. The problem
|
||||
|
||||
Operation state — pending tool output, pending assistant output — matters only
|
||||
while the operation runs. It is crash-recovery scaffolding, not history.
|
||||
|
||||
The JSONL log is append-only, so it persists anyway. Worse, deleting it *adds*
|
||||
records: `deleteValue` and `deleteList` write a line each, and those lines also
|
||||
persist. Today only a full snapshot rewrite reclaims the space, and no in-place
|
||||
compaction exists (`jsonl/storage.ts` has `createFromSnapshot`, used for fork).
|
||||
|
||||
Measured on 20 operations, each 400 assistant frames plus 2 tools at 60
|
||||
checkpoints of a 50 KB window:
|
||||
|
||||
| | size |
|
||||
| --- | --- |
|
||||
| total JSONL | 93.89 MB |
|
||||
| settled transcript entries — the part that is actually history | **0.06 MB** |
|
||||
|
||||
Three orders of magnitude of the file is scaffolding for finished operations.
|
||||
|
||||
**Two independent fixes, and they are not alternatives:**
|
||||
|
||||
- **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.
|
||||
|
||||
Do the encoding first. Scopes are worth it because 5.32 MB of dead weight per 20
|
||||
operations still accumulates, and there is no compaction pass to reclaim it.
|
||||
|
||||
## 2. Scope on the address
|
||||
|
||||
Scope has two halves that must not be confused: a **type-level tag** carrying no
|
||||
data, and a **runtime scope id** that names the file.
|
||||
|
||||
```ts
|
||||
export type SessionScope = { readonly kind: "session" };
|
||||
export type EphemeralScope = { readonly kind: "ephemeral" };
|
||||
export type Scope = SessionScope | EphemeralScope;
|
||||
|
||||
// Passing a scopeId is what makes an address ephemeral.
|
||||
export function value<T>(namespace: string, key: string): Value<T, SessionScope>;
|
||||
export function value<T>(namespace: string, key: string, scopeId: string): Value<T, EphemeralScope>;
|
||||
|
||||
export function list<T>(namespace: string, key: string): ValueList<T, SessionScope>;
|
||||
export function list<T>(namespace: string, key: string, scopeId: string): ValueList<T, EphemeralScope>;
|
||||
|
||||
/** A main-log record retiring an ephemeral scope. See §5. */
|
||||
export function retireScope(id: string): Write<SessionScope>;
|
||||
```
|
||||
|
||||
The suffix is not decoration: `Session` already names the session interface in
|
||||
`harness/session/types.ts`, and reusing it produces `TS2440`/`TS2484` plus an
|
||||
ambiguous re-export from `session/index.ts`.
|
||||
|
||||
```ts
|
||||
interface Value<T, Sc extends Scope = SessionScope> extends ScopedAddress<Sc> {
|
||||
readonly namespace: string;
|
||||
readonly key: string;
|
||||
/** Present iff Sc is Ephemeral. Routes the write and names the sidecar. */
|
||||
readonly scopeId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
The id is a plain runtime string, because it is an operation id — generated at
|
||||
runtime and unavailable to the type system. That is precisely why §6 can
|
||||
distinguish *session from ephemeral* statically but cannot distinguish *two
|
||||
different ephemeral scopes*: the tag is in the type, the id is not.
|
||||
|
||||
```ts
|
||||
// Durable lists carry encoded batches; one encoder/decoder pair belongs to each value stream.
|
||||
export const pendingToolOutput = (operationId: string, invocationId: string) =>
|
||||
list<WireOp[]>("pi.pending.tool_output", `${operationId}:${invocationId}`, operationId);
|
||||
|
||||
export const pendingAssistantOutput = (operationId: string, entryId: string) =>
|
||||
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);
|
||||
|
||||
// unchanged — no scopeId, so Session by inference
|
||||
export const laneStateValue = (lane: string) => value<DurableLaneState>("pi.lane.state", lane);
|
||||
```
|
||||
|
||||
**Scope is the file.** A session-scoped write goes to the main log; an
|
||||
ephemeral-scoped write goes to `<session>.<scopeId>.jsonl`.
|
||||
|
||||
Other backends need no sidecars at all: in-memory drops a `Map` keyed by scope id,
|
||||
SQLite issues one `DELETE ... WHERE scope = ?`. Scope is meaningful to them only as
|
||||
a lifetime hint. It is JSONL that needs the file split, and that is where the
|
||||
constraints in §4 and §5 come from.
|
||||
|
||||
## 3. Which addresses are scoped, and why
|
||||
|
||||
The boundary is drawn by **write isolation**, not by lifetime.
|
||||
|
||||
Scoping everything under `pi.op.*` on the grounds that it dies with the operation
|
||||
looks right and is wrong. `lane.ts` shows why: **every** operation commit bundles
|
||||
`operationState` with `laneState`.
|
||||
|
||||
```ts
|
||||
// lane.ts:405/431, 659/660, 749/750, 1071/1072 — the same shape each time
|
||||
writes: [
|
||||
...decision.writes,
|
||||
setValue(operationStateValue(operationId), decision.operationState),
|
||||
setValue(laneStateValue(this.name), durableLaneState(...)),
|
||||
]
|
||||
```
|
||||
|
||||
That pair is coordination state: lane state says the operation is at step N,
|
||||
operation state holds the data for step N. Splitting them across files makes a
|
||||
crash produce a lane that believes it is somewhere the data does not support.
|
||||
|
||||
The **bulk** values behave differently. `openProgress` commits exactly one write
|
||||
(`runtime/progress.ts:51`), and `pendingToolOutput` / `pendingAssistantFrames`
|
||||
appear in multi-write transactions only as *deletes*.
|
||||
|
||||
| | addresses |
|
||||
| --- | --- |
|
||||
| **ephemeral (sidecar)** | `pi.pending.tool_output`, `pi.pending.assistant_output`, `pi.op.tool_memo` |
|
||||
| **session (main log)** | `pi.lane.state`, `pi.op.state`, `pi.op.meta`, `pi.result`, `pi.pending.entry`, `pi.branch.tip`, `pi.op.tool_args`, `pi.op.preparation` |
|
||||
|
||||
`operationToolMemo` is ephemeral so that the memo-plus-checkpoint bundling in
|
||||
`harness-tools.md` §7.5 is a single-file transaction. `terminal.ts:34` already
|
||||
treats `operationToolMemoPrefix` and `pendingToolOutputPrefix` as the same cleanup
|
||||
class, so this matches how the code already thinks about them.
|
||||
|
||||
The size win is unaffected by leaving `operationState` behind: effectively all of
|
||||
the 93.89 MB in §1 is pending tool and assistant output.
|
||||
|
||||
## 4. Two files are not atomic
|
||||
|
||||
Serialising on the Session line gives **ordering, not atomicity**. Two `write()`
|
||||
calls to two descriptors, two fsyncs, no journal spanning them. A crash between
|
||||
leaves the files disagreeing, and JSONL has nothing to repair it with.
|
||||
|
||||
So the design does not try to make cross-file transactions work. It ensures none
|
||||
exist:
|
||||
|
||||
> **All writes in a transaction share one scope.**
|
||||
|
||||
That is achievable because it is already true of the code — see §5 for the audit.
|
||||
|
||||
Two consequences of *not* needing cross-file commits:
|
||||
|
||||
- **No write ordering between files is required.** The commit-record pattern
|
||||
(sidecar first, fsync, then a main-log record acknowledging it) is unnecessary if
|
||||
no transaction spans files. There is nothing to acknowledge.
|
||||
- **No commit marker for sidecar-only writes.** One tempting design is a small
|
||||
main-log record for `openProgress`, since nothing acknowledges its write. It is
|
||||
not needed: that transaction is one write in one file and already atomic. Torn
|
||||
tails are handled the same way as in the main log — every line is a complete op
|
||||
or a whole batch, so a reader stops at the last intact one.
|
||||
|
||||
What remains is a **durability** choice, not a consistency one: if the sidecar is
|
||||
fsynced less aggressively, recent checkpoints can be lost. That is the same bounded
|
||||
loss the checkpoint interval already accepts, but it should be a deliberate policy
|
||||
rather than an accident.
|
||||
|
||||
## 5. Audit: does the existing code satisfy the rule?
|
||||
|
||||
Every commit in `lane.ts` (12 sites) and every `writes:` producer in
|
||||
`runtime/drive/*.ts` (32 sites) was read.
|
||||
|
||||
**Result: yes, with one adjustment.** No transaction writes to two files. The
|
||||
spanning cases are all *deletes* of ephemeral state bundled with main-log writes:
|
||||
|
||||
- `response.ts:340` — the settle commit: `insertEntry`, `insertUsage`,
|
||||
`setValue(branchTip)`, plus `deleteList(pendingAssistantFrames(...))`.
|
||||
- `terminal.ts:50` `operationCleanupWrites` — bundles main-log deletes with
|
||||
`toolMemos` and `toolOutputs` deletes, returned into the same array as
|
||||
`operationResultValue` and `laneStateValue`.
|
||||
- `tools.ts:230, 257` — `deleteValue(pendingToolOutput)` with main-log writes.
|
||||
- `deferred.ts:157` — `deleteList(pendingAssistantFrames)`.
|
||||
|
||||
**The adjustment: retirement becomes a main-log record.**
|
||||
|
||||
```ts
|
||||
retireScope(operationId): Write<SessionScope>
|
||||
```
|
||||
|
||||
The log is the truth; the sidecar file is a cache. Recovery reads the retire record
|
||||
and ignores — then removes — the sidecar. Unlinking eagerly after commit is a pure
|
||||
optimisation; losing the unlink costs disk, never correctness.
|
||||
|
||||
This makes all four sites above single-scope, because the individual ephemeral
|
||||
deletes disappear. It also *simplifies* `operationCleanupWrites`: the `scanValues`
|
||||
calls for `operationToolMemoPrefix` and `pendingToolOutputPrefix` are replaced by
|
||||
one `retireScope`.
|
||||
|
||||
And it makes the open-time sweep principled rather than heuristic. A sidecar is
|
||||
dead iff the main log retired it or its operation is absent — no inference from
|
||||
operation status.
|
||||
|
||||
### 5.1 Two ephemeral scopes in one transaction cannot arise
|
||||
|
||||
A lane holds `operation: { meta, state } | null` — **singular**. Every write set
|
||||
uses one `operationId`, either `drive.operationId` or the newly created one in
|
||||
`startOperation`, which asserts no operation is active. Retire and start are always
|
||||
separate transactions: the settle commits at `lane.ts:427` and `response.ts:339`
|
||||
set lane state to `null`.
|
||||
|
||||
Concurrency is across **lanes**, and each lane has at most one active operation, so
|
||||
two ephemeral scopes never meet in a commit. The runtime assertion in §6 is defence
|
||||
against a future change, not a live gap.
|
||||
|
||||
## 6. Static enforcement
|
||||
|
||||
> **`scopes.variance.ts` ships alongside this doc** and is the executable form of
|
||||
> this section: `npx tsc --noEmit --strict --lib es2023 scopes.variance.ts`.
|
||||
> Silence means the rule holds. Its three `@ts-expect-error` lines fail the
|
||||
> compile if they ever start type-checking, so it catches the enforcement being
|
||||
> weakened as well as broken.
|
||||
|
||||
|
||||
Two phantom types, differing only in where `Sc` appears. This distinction is
|
||||
load-bearing and is the single easiest thing to get wrong here.
|
||||
|
||||
```ts
|
||||
declare const storedScopeType: unique symbol;
|
||||
|
||||
/** Addresses: COVARIANT — Sc only in return position. */
|
||||
export interface ScopedAddress<Sc extends Scope> {
|
||||
readonly [storedScopeType]?: () => Sc;
|
||||
}
|
||||
|
||||
/** Writes: INVARIANT — Sc in both parameter and return position. */
|
||||
export interface Scoped<Sc extends Scope> {
|
||||
readonly [storedScopeType]?: (scope: Sc) => Sc;
|
||||
}
|
||||
|
||||
export interface Value<T, Sc extends Scope = SessionScope>
|
||||
extends StoredAddressBase, ScopedAddress<Sc> { … }
|
||||
|
||||
export interface ValueSetWrite<Sc extends Scope> extends Scoped<Sc> { … }
|
||||
```
|
||||
|
||||
**Why they differ.** Reading an address is safe at any scope — `getValue` does not
|
||||
care which file a value lives in, so `Value<T, SessionScope>` must be usable where
|
||||
`Value<T, Scope>` is expected. Forming a transaction is *not* safe at any scope,
|
||||
because two files are not atomic, so a commit must pin down exactly one.
|
||||
|
||||
Enforcement therefore belongs where transactions are formed, not where addresses
|
||||
are read. `setValue<T, Sc>(address: Value<T, Sc>, …): Write<Sc>` is the bridge: it
|
||||
infers the scope from a covariant address and stamps it onto an invariant write.
|
||||
|
||||
> **Getting this wrong is expensive.** Making addresses invariant too breaks every
|
||||
> reader — `getValue`, `scanValues`, `readList` and everything downstream — and
|
||||
> produced a 36-error cascade across storage backends that had done nothing wrong.
|
||||
> The tempting workaround, making each reader generic over `Sc` to mean "any
|
||||
> scope", propagates a type parameter through the whole storage stack to express
|
||||
> something variance gives for free.
|
||||
|
||||
Verified with `tsc --noEmit --strict`. Reads pass at both scopes:
|
||||
|
||||
```ts
|
||||
getValue(laneState); // Value<T, SessionScope>
|
||||
getValue(pendingOutput); // Value<T, EphemeralScope>
|
||||
```
|
||||
|
||||
Single-scope commits pass:
|
||||
|
||||
```ts
|
||||
commit([setValue(laneState, a), setValue(operationState, b)]);
|
||||
commit([setValue(toolMemo, m), setValue(pendingOutput, o)]);
|
||||
commit([setValue(laneState, a), retireScope("op_1")]);
|
||||
```
|
||||
|
||||
Mixed-scope commits fail, and are the *only* things that fail:
|
||||
|
||||
```ts
|
||||
commit([setValue(laneState, a), setValue(pendingOutput, o)]); // ERROR
|
||||
commit([setValue(pendingOutput, o), retireScope("op_1")]); // ERROR
|
||||
```
|
||||
|
||||
### 6.1 What the type system cannot catch
|
||||
|
||||
Two different ephemeral scopes both type as `Write<EphemeralScope>`, because the id
|
||||
is a runtime string (§2). A runtime assertion at commit covers it — compare
|
||||
`scopeId` across all writes — and per §5.1 it should never fire.
|
||||
|
||||
## 7. Recovery and sweeping
|
||||
|
||||
On open, enumerate sidecars alongside the main log and load them. Their records
|
||||
participate in replay exactly as main-log records do.
|
||||
|
||||
A sidecar is removed when the main log holds a `retireScope` record for it, or when
|
||||
its operation is absent — a crash between commit and unlink. Torn-tail semantics
|
||||
are unchanged: each line is self-contained, so a crash mid-write loses exactly the
|
||||
trailing line.
|
||||
|
||||
## 8. Sequence numbers
|
||||
|
||||
Scoped writes get their own sequence space per scope. Nothing outside a scope
|
||||
orders against its contents, and keeping them separate means `nextSeq` in the main
|
||||
header does not account for numbers consumed by files that will be deleted.
|
||||
|
||||
Shared numbering would leave large gaps after several operations settled, and the
|
||||
high-water mark would need separate persistence.
|
||||
|
||||
## 9. Measured effect
|
||||
|
||||
Same workload as §1:
|
||||
|
||||
| | size | of today |
|
||||
| --- | --- | --- |
|
||||
| today, single JSONL | 93.89 MB | — |
|
||||
| ops + interned addresses, single JSONL | 5.32 MB | 5.7% |
|
||||
| **plus scopes — main log** | **0.06 MB** | **0.06%** |
|
||||
| — sidecars, retired on settle | 5.26 MB | peak 0.26 MB per operation |
|
||||
|
||||
The 5.7% figure is **rate-dependent and must not be quoted alone**: it holds at
|
||||
2 KB of tool output per checkpoint and inverts above the cap, where a whole-value
|
||||
replacement wins.
|
||||
|
||||
The scope result is **not** rate-dependent. The main log holds settled entries
|
||||
regardless of encoding, and that is the number that governs session file growth.
|
||||
|
||||
## 10. What changes in the existing code
|
||||
|
||||
The compiler finds these for you: once addresses carry a scope and `Write<Sc>` is
|
||||
invariant, every site that mixes scopes in one transaction fails to typecheck.
|
||||
This list is what a full pass turned up, so you know when you are done.
|
||||
|
||||
**New, in `session/values.ts` and `session/types.ts`**
|
||||
|
||||
`SessionScope` / `EphemeralScope` / `Scope`; the two phantoms from §6;
|
||||
`Value<T, Sc>` and `ValueList<T, Sc>`; `scopeId` on the address; the `value` /
|
||||
`list` overloads; `retireScope`; scope-preserving `setValue` / `deleteValue` /
|
||||
`appendList` / `deleteList`; `Write<Sc>` with entries, usage and retirement
|
||||
session-only by construction.
|
||||
|
||||
**Generified**
|
||||
|
||||
`CommitDecision<TResult, Sc>`, `lane.command<TResult, Sc>`, `Storage.commit<Sc>`.
|
||||
Give `Sc` a default of `SessionScope`: conditional types are not inference sites,
|
||||
so without a default a mixed array falls back to the constraint `Scope` and every
|
||||
call site fails. `settleOperation` and `continueOperation` stay **non**-generic —
|
||||
see §5.
|
||||
|
||||
**Committed writes**
|
||||
|
||||
`CommittedScopeRetireWrite`, and `scopeId` carried through the committed shapes so
|
||||
`JsonlStorage` can route on it.
|
||||
|
||||
**Call sites that must change** — each currently mixes scopes in one transaction:
|
||||
|
||||
| site | what it does today | what it becomes |
|
||||
| --- | --- | --- |
|
||||
| `drive/terminal.ts` `operationCleanupWrites` | enumerates tool memos and tool outputs via `scanValues`, deletes each alongside session deletes | one `retireScope(operationId)`; two of its four scans disappear |
|
||||
| `drive/response.ts` (settle) | `deleteList(pendingAssistantOutput)` bundled with operation state | drop it — the sidecar is discarded at retire |
|
||||
| `drive/deferred.ts` (superseded response) | same | same |
|
||||
| `drive/tools.ts` (tool settle) | `deleteValue(pendingToolOutput)` plus memo deletes in a session write array | drop them |
|
||||
| `drive/tool-placement.ts` | `deleteValue(pendingToolOutput)` | drop it |
|
||||
|
||||
The pattern is the same everywhere: **intra-operation cleanup of ephemeral state
|
||||
is impossible**, because those commits also write operation and lane state. The
|
||||
sidecar is discarded wholesale at retire. The cost is that a multi-turn operation
|
||||
holds superseded pending output until settle — bounded by turns, and never
|
||||
reaching the main log.
|
||||
|
||||
**Storage**
|
||||
|
||||
Sidecar routing in `JsonlStorage.commit` (assert one scope id per transaction,
|
||||
route the append), unlink on a `scope` record **after** the commit, and an
|
||||
open-time sweep that loads sidecars and removes any the main log already retired.
|
||||
In-memory drops a `Map` keyed by scope id; SQLite issues one
|
||||
`DELETE ... WHERE scope = ?` and treats a retire record as a no-op, because it has
|
||||
no sidecar and the terminal transaction deletes the scoped values explicitly.
|
||||
|
||||
**Expected test churn**
|
||||
|
||||
Nine tests assert the old cleanup write set and will fail — `drive-terminal` (3),
|
||||
`drive-retry-deferred` (2), `drive-tools`, `drive-reconcile`, `drive-generation`,
|
||||
and one pinning reserved namespaces. They expect six or seven individual deletes
|
||||
where the new code emits fewer plus one `retireScope`. That is the change landing,
|
||||
not a regression.
|
||||
|
||||
## 11. List tags and stop conditions
|
||||
|
||||
Tracked state is stored as a list of op batches (`delta.md` §9). Recovery needs "the
|
||||
batches since the last base batch" without unpacking every row, which lists cannot
|
||||
express today: `ListReadOptions` has `cursor`, `order` and `limit` only.
|
||||
|
||||
`BranchScan` already solves the same problem for entries, and its vocabulary is
|
||||
the one to copy — `stopAtType` / `stopAtId` for a terminator, plain field names
|
||||
for a filter.
|
||||
|
||||
```ts
|
||||
appendList<T, Sc>(address: ValueList<T, Sc>, element: T, tag?: string): ListAppendWrite<Sc>;
|
||||
|
||||
export interface ListElement<T> {
|
||||
seq: number;
|
||||
value: T;
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface ListReadOptions {
|
||||
cursor?: ListCursor;
|
||||
order?: "asc" | "desc";
|
||||
limit?: number;
|
||||
/** Include elements up to and including the first carrying this tag, then stop. */
|
||||
stopAtTag?: string;
|
||||
/** Return only elements carrying this tag. */
|
||||
tag?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**`stopAtTag` is a stop condition within a page, not a guarantee.** It can only end
|
||||
a page earlier than `limit` would; it never overrides it. If the tagged element is
|
||||
not in the page, the consumer sees no tagged element and pages again with the
|
||||
cursor — the same loop `readAssistantFrames` already runs. There is no ordering
|
||||
question between the two bounds and no "not found" error.
|
||||
|
||||
For that loop to work, `readList` must return the tag on each element. Otherwise a
|
||||
consumer cannot tell "page ended at the tag" from "page ended at the limit"
|
||||
without parsing the value, which is the thing the tag exists to avoid.
|
||||
|
||||
**The producer sets the tag, not storage.** For a tracked value the caller
|
||||
already knows, because `isBase(ops)` is a token comparison on the first op
|
||||
(`delta.md` §2):
|
||||
|
||||
```ts
|
||||
const ops = tracker.flush();
|
||||
if (ops.length === 0) return;
|
||||
writes: [appendList(address, enc.encode(ops), isBase(ops) ? "base" : undefined)];
|
||||
```
|
||||
|
||||
Storage must never inspect an element to derive a tag. That is what keeps op
|
||||
batches opaque to the storage layer, and it is the same property that makes
|
||||
`EntryScan.type` work: the discriminant is a stored column, not something derived
|
||||
from the payload.
|
||||
|
||||
**The tag lives on the storage record**, beside `seq` and `value`:
|
||||
|
||||
```jsonc
|
||||
["l",7,9,[["r",{…}]],"base"] // per §12.1
|
||||
```
|
||||
|
||||
Storage must never parse an element to evaluate a predicate. That is what keeps
|
||||
ops opaque to storage and the durable path free of domain knowledge
|
||||
(`harness-tools.md` §7.7). It is also why `EntryScan.type` works: the discriminant
|
||||
is a stored column, not something derived from the payload.
|
||||
|
||||
`order` stays `"asc" | "desc"` rather than `BranchScan`'s
|
||||
`"newestFirst" | "oldestFirst"`. Branch order is semantic — a walk from a tip
|
||||
through a tree. List order is over `seq`. Borrowing the branch words would imply a
|
||||
traversal that is not happening.
|
||||
|
||||
Backends: SQLite gets a `tag` column and a real predicate. JSONL holds the list in
|
||||
memory already, so it scans backwards and checks a field — no worse than today.
|
||||
In-memory likewise.
|
||||
|
||||
The primitive generalises past frames: any list wanting "the tail since the last
|
||||
checkpoint" gets it.
|
||||
|
||||
## 12. JSONL record encoding
|
||||
|
||||
Two dictionaries at two layers. They are the same trick and are independent: the
|
||||
**address** dictionary is over `namespace` + `key` on the record; the **path**
|
||||
dictionary is over paths *inside* a value's ops (`delta.md` §4).
|
||||
|
||||
### 12.1 Records
|
||||
|
||||
```
|
||||
["@", addrId, namespace, key] address definition
|
||||
["v", addrId, seq, wireOps] value write — WireOp[], delta.md §4
|
||||
["l", addrId, seq, element, tag?] list append — element is WireOp[]
|
||||
for a tracked value
|
||||
["x", addrId, seq] delete (value or list)
|
||||
["!", addrId, seq] retire an ephemeral scope
|
||||
```
|
||||
|
||||
**Record verbs and op verbs are separate vocabularies read at different levels**,
|
||||
but they must not look alike. `!` rather than `r` for retire: `r` is delta's
|
||||
replace op, and a reader scanning a file should not have to remember which nesting
|
||||
level they are at to know what a tuple means.
|
||||
|
||||
Retire takes an address id like everything else, defined by an `["@", id, …]`
|
||||
whose namespace is the scope. That keeps one interning rule for the whole file
|
||||
rather than a special case for one record.
|
||||
|
||||
Entries and usage rows keep their current keyed form: they are written once, never
|
||||
repeat an address, and are read by machinery that has nothing to do with ops.
|
||||
|
||||
An address is defined on its **second** use, matching path interning — a
|
||||
definition is pure overhead for an address written once, and a session has many of
|
||||
those.
|
||||
|
||||
### 12.2 Worked example
|
||||
|
||||
Four writes across two addresses, before and after.
|
||||
|
||||
```jsonc
|
||||
// today — 1250 bytes
|
||||
{"kind":"value","op":"set","seq":7,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"starting","control":{…},"settings":{…},"latestAssistantEntryId":null}}
|
||||
{"kind":"value","op":"set","seq":8,"namespace":"pi.lane.state","key":"main","value":{"currentOperationId":"01a04cf6-…",…}}
|
||||
{"kind":"value","op":"set","seq":9,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"checkpoint",…}}
|
||||
{"kind":"value","op":"set","seq":12,"namespace":"pi.op.state","key":"01a04cf6-…","value":{"at":"assistant.ready",…}}
|
||||
```
|
||||
|
||||
```jsonc
|
||||
// with both dictionaries — 547 bytes
|
||||
["@",0,"pi.op.state","01a04cf6-…"]
|
||||
["v",0,7,[["r",{"at":"starting","control":{…},"settings":{…},"latestAssistantEntryId":null}]]]
|
||||
["@",1,"pi.lane.state","main"]
|
||||
["v",1,8,[["r",{"currentOperationId":"01a04cf6-…",…}]]]
|
||||
["v",0,9,[["#",0,["at"]],["s",0,"checkpoint"]]]
|
||||
["v",0,12,[["s","assistant.ready"]]]
|
||||
```
|
||||
|
||||
| line | before | after |
|
||||
| --- | --- | --- |
|
||||
| address definition | – | 61 |
|
||||
| op.state -> `starting` (base batch) | 353 | 256 |
|
||||
| address definition | – | 31 |
|
||||
| lane.state (base batch) | 181 | 114 |
|
||||
| op.state -> `checkpoint` | 355 | **48** |
|
||||
| op.state -> `assistant.ready` | 361 | **37** |
|
||||
| **total** | **1250** | **547** (44%) |
|
||||
|
||||
The shape matters more than the total. Base batches barely shrink — 353 to 256 is
|
||||
envelope only, since the value ships whole either way. The transitions collapse
|
||||
~8x because they carry one changed field instead of the whole state including the
|
||||
never-changing `settings` block.
|
||||
|
||||
Both dictionaries appear on line 5: `0` in `["v",0,9,…]` is an **address** id,
|
||||
while `["#",0,["at"]]` defines a **path** id inside the value's ops. Same trick,
|
||||
different layers, separate tables. Line 6 then drops the path entirely — arity
|
||||
omission, because it targets the same path as the previous op in that batch.
|
||||
|
||||
**Do not quote 44% as the file-level saving.** Dictionary entries are one-time, so
|
||||
a real operation's ~11 op.state writes amortise them and the ratio improves; but
|
||||
transcript entries are untouched, and they were 18 KB of a 67 KB tool-heavy run.
|
||||
Applied there this hits the ~27 KB of value writes and would take the file down by
|
||||
roughly a fifth, not by half.
|
||||
|
||||
### 12.3 Reading
|
||||
|
||||
A reader builds both tables while replaying, so a torn tail costs exactly the
|
||||
trailing lines and no header needs rewriting. A snapshot rewrite starts fresh
|
||||
tables and re-emits definitions naturally.
|
||||
|
||||
A numeric `addrId` for which no definition has been seen is a corrupt file, not a
|
||||
recoverable state — unlike a missing path id, which cannot occur because path
|
||||
definitions are inside the same record as their first use.
|
||||
|
||||
## 13. Open questions
|
||||
|
||||
- File-handle pressure across concurrent lanes, each with a live sidecar. Probably
|
||||
fine, unmeasured.
|
||||
- fsync policy for sidecars (§4) — matched to the main log, or relaxed given the
|
||||
contents are bounded-loss scaffolding.
|
||||
- Whether a long-running operation should rotate its sidecar. `rebase()`
|
||||
(`delta.md` §3.2.3) bounds *recovery* length but not file size, so a command
|
||||
running for hours still grows its sidecar without bound.
|
||||
- Whether an in-place compaction pass for the main log is worth building anyway.
|
||||
`createFromSnapshot` plus atomic replace is the mechanism; scopes reduce the need
|
||||
but do not remove it, since superseded session-scoped values still accumulate.
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Executable form of scopes.md §6.
|
||||
*
|
||||
* npx tsc --noEmit --strict --lib es2023 scopes.variance.ts
|
||||
*
|
||||
* Silence means the rule holds: reads accept either scope, single-scope commits
|
||||
* pass, and mixed-scope commits are the ONLY thing rejected. The three
|
||||
* `@ts-expect-error` lines fail the compile if they ever start type-checking,
|
||||
* so this catches the enforcement being weakened as well as broken.
|
||||
*
|
||||
* The variance split is the part that is easy to get backwards. Making addresses
|
||||
* invariant too breaks every reader — getValue, scanValues, readList and
|
||||
* everything downstream — and the tempting fix, making each reader generic over
|
||||
* Sc, propagates a type parameter through the whole storage stack to express
|
||||
* what variance gives for free.
|
||||
*/
|
||||
declare const tag: unique symbol;
|
||||
type SessionScope = { readonly kind: "session" };
|
||||
type EphemeralScope = { readonly kind: "ephemeral" };
|
||||
type Scope = SessionScope | EphemeralScope;
|
||||
|
||||
/** Addresses: COVARIANT — Sc only in return position. Readers take any scope. */
|
||||
interface ScopedAddress<Sc extends Scope> { readonly [tag]?: () => Sc }
|
||||
/** Writes: INVARIANT — Sc in both positions. Mixed commits cannot unify. */
|
||||
interface Scoped<Sc extends Scope> { readonly [tag]?: (s: Sc) => Sc }
|
||||
|
||||
interface Value<T, Sc extends Scope = SessionScope> extends ScopedAddress<Sc> { k: "v" }
|
||||
interface ValueList<T, Sc extends Scope = SessionScope> extends ScopedAddress<Sc> { k: "l" }
|
||||
interface VSet<Sc extends Scope = Scope> extends Scoped<Sc> { kind: "value"; op: "set" }
|
||||
interface VDel<Sc extends Scope = Scope> extends Scoped<Sc> { kind: "value"; op: "delete" }
|
||||
interface LApp<Sc extends Scope = Scope> extends Scoped<Sc> { kind: "list"; op: "append" }
|
||||
interface EntryWrite { kind: "entry" }
|
||||
interface RetireWrite extends Scoped<SessionScope> { kind: "scope" }
|
||||
|
||||
type Write<Sc extends Scope = SessionScope> =
|
||||
| VSet<Sc> | VDel<Sc> | LApp<Sc>
|
||||
| (Sc extends SessionScope ? EntryWrite | RetireWrite : never);
|
||||
|
||||
declare function setValue<T, Sc extends Scope>(a: Value<T, Sc>, v: T): VSet<Sc>;
|
||||
declare function deleteValue<T, Sc extends Scope>(a: Value<T, Sc>): VDel<Sc>;
|
||||
declare function appendList<T, Sc extends Scope>(a: ValueList<T, Sc>, v: T): LApp<Sc>;
|
||||
declare function retireScope(id: string): RetireWrite;
|
||||
declare function insertEntry(e: unknown): EntryWrite;
|
||||
declare function commit<Sc extends Scope = SessionScope>(w: readonly Write<Sc>[]): void;
|
||||
declare function getValue<T>(a: Value<T, Scope>): T; // reader: any scope
|
||||
|
||||
const laneState = null as unknown as Value<string>; // session
|
||||
const pendingOut = null as unknown as ValueList<string, EphemeralScope>; // ephemeral
|
||||
const toolMemo = null as unknown as Value<string, EphemeralScope>;
|
||||
|
||||
// READS accept either scope — that is what covariance buys.
|
||||
getValue(laneState);
|
||||
getValue(toolMemo);
|
||||
|
||||
// Single-scope commits pass.
|
||||
commit([setValue(laneState, "a")]);
|
||||
commit([setValue(laneState, "a"), insertEntry({}), retireScope("op_1")]);
|
||||
commit([appendList(pendingOut, "x"), setValue(toolMemo, "m")]);
|
||||
|
||||
// @ts-expect-error mixed scopes in one transaction
|
||||
commit([setValue(laneState, "a"), appendList(pendingOut, "x")]);
|
||||
// @ts-expect-error a retire is session-only, so it cannot join an ephemeral commit
|
||||
commit([appendList(pendingOut, "x"), retireScope("op_1")]);
|
||||
// @ts-expect-error entries are session-only
|
||||
commit<EphemeralScope>([insertEntry({})]);
|
||||
Reference in new issue
Block a user