feat(chord): shared watch-handle implementation (Watch, NativeWatch); Durable uses it

This commit is contained in:
Mario Zechner 2026-10-23 07:05:23 +00:00
parent 936e5f40ae
commit 42cb822f1c
12 changed files with 780 additions and 237 deletions

View file

@ -13,6 +13,10 @@
- `HarnessOptions.readOnly` opens a Harness as a viewer of a session another process drives: open leaves running tasks as committed instead of recovering them, every commit with writes rejects with `ReadOnlySession` before it reaches Storage, and `resume()` and every call that would run tasks reject with it.
### Changed
- Document, conversation view, task graph, and event watches use Chord's `Watch` implementation; behaviour is unchanged.
## [1.4.0] - 2026-10-21
### Breaking Changes

View file

@ -1,7 +1,6 @@
import type { Context, JsonValue, WatchEnd } from "@earendil-works/chord";
import { type Context, type JsonValue, Watch, type WatchEnd } from "@earendil-works/chord";
import type { Op, Path } from "@earendil-works/chord/delta";
import type { AssistantMessage, Message, Usage } from "@earendil-works/pi-ai";
import { CommittedWatch } from "../session/observation.ts";
import type {
CommitChange,
CommitPublication,
@ -168,7 +167,7 @@ export async function watchEvents(
context: Context,
): Promise<AgentEventStream> {
const views = conversationViews(harness);
let watch!: CommittedWatch<readonly AgentEvent[]>;
let watch!: Watch<readonly AgentEvent[]>;
let snapshot!: SnapshotEvent;
await views.attach(
conversationId,
@ -180,7 +179,7 @@ export async function watchEvents(
let current = initial;
snapshot = snapshotOf(initial);
// Batches are the watch's values; an overflow delivers a snapshot of the newest view instead.
watch = new CommittedWatch<readonly AgentEvent[]>(
watch = new Watch<readonly AgentEvent[]>(
[],
release,
(error) => views.report(error),

View file

@ -1,7 +1,6 @@
import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord";
import { type Context, type JsonValue, Watch, type WatchHandle } from "@earendil-works/chord";
import { BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import { CommittedWatch } from "../session/observation.ts";
import { contained, type SessionImpl } from "../session/session.ts";
import type {
CommitPublication,
@ -56,7 +55,7 @@ export type TaskGraph = {
export type TaskGraphWatch = WatchHandle<TaskGraph>;
type AnyTaskRecord = TaskRecord<JsonValue, JsonValue, JsonValue>;
type Observer = CommittedWatch<TaskGraph>;
type Observer = Watch<TaskGraph>;
type Mount = {
value: TaskGraph;
readonly observers: Set<Observer>;
@ -108,7 +107,7 @@ export class TaskGraphView {
/** A serialized exact-frame watch of the graph; cancelling `context` stops it. */
async watch(context: Context): Promise<TaskGraphWatch> {
const { observer } = await this.#attach(
(value, release) => new CommittedWatch<TaskGraph>(value, release, (error) => this.#session.report(error)),
(value, release) => new Watch<TaskGraph>(value, release, (error) => this.#session.report(error)),
context,
);
const signal = context.abortSignal;

View file

@ -1,7 +1,6 @@
import type { Context, JsonValue, WatchHandle } from "@earendil-works/chord";
import { type Context, type JsonValue, Watch, type WatchHandle } from "@earendil-works/chord";
import { withoutAbortSignal } from "@earendil-works/chord/context";
import { applyImmutable, type NonEmptyPath, type Op, type Path } from "@earendil-works/chord/delta";
import { CommittedWatch } from "../session/observation.ts";
import { contained, type SessionImpl } from "../session/session.ts";
import type {
CommitPublication,
@ -111,8 +110,7 @@ export class ConversationViews {
async watch(id: ConversationId, context: Context): Promise<WatchHandle<ConversationView>> {
const { observer } = await this.attach(
id,
(value, release) =>
new CommittedWatch<ConversationView>(value, release, (error) => this.#session.report(error)),
(value, release) => new Watch<ConversationView>(value, release, (error) => this.#session.report(error)),
context,
);
const signal = context.abortSignal;

View file

@ -1,186 +0,0 @@
import type { Context, JsonValue, WatchEnd, WatchHandle } from "@earendil-works/chord";
import { withoutAbortSignal } from "@earendil-works/chord/context";
import type { Op } from "@earendil-works/chord/delta";
import { errorMessage } from "../errors.ts";
import type { JsonObject } from "../types.ts";
export type ObservedDocumentValue = Readonly<JsonObject> | null;
/** Maximum exact committed frames retained behind one unavailable watch listener. */
const MAX_PENDING_WATCH_FRAMES = 100;
/** Canonical terminal frame of a retired document incarnation. */
const RETIREMENT_OPERATIONS: readonly Op[] = [["r", null]];
type WatchFrame<T> = {
readonly value: T;
readonly ops: readonly Op[];
readonly context: Context;
/** The retirement frame: the watch ends `retired` once it is delivered. */
readonly retires?: true;
};
type WatchListener<T> = (value: T, ops: readonly Op[], context: Context) => Promise<void>;
/**
* Serialized exact-frame watch bound to one document incarnation or conversation view. Its source ends it explicitly:
* `retire()` for a retired document, `end()` for a closed or failed Session. Termination drops every reference the
* watch holds into its source, so a stopped watch that is still referenced keeps nothing but its last value alive.
* @internal
*/
export class CommittedWatch<T = ObservedDocumentValue> implements WatchHandle<T> {
#detach: (() => void) | undefined;
#report: ((error: unknown) => void) | undefined;
#replace: (() => T) | undefined;
readonly #closed = Promise.withResolvers<WatchEnd>();
readonly #pending: WatchFrame<T>[] = [];
#value: T;
#listener: WatchListener<T> | undefined;
#started = false;
#scheduled = false;
#running = false;
#retired = false;
/** Set once, by `#terminate()`. */
#end: WatchEnd | undefined;
#cancellationSignal: AbortSignal | undefined;
#cancellationListener: (() => void) | undefined;
/**
* `report` gets a listener's throw, which also ends the watch. `replace` gives the value an overflow delivers; by
* default the newest value.
*/
constructor(value: T, detach: () => void, report: (error: unknown) => void, replace?: () => T) {
this.#value = value;
this.#detach = detach;
this.#report = report;
this.#replace = replace;
}
get value(): T {
return this.#value;
}
get closed(): Promise<WatchEnd> {
return this.#closed.promise;
}
start(listener: WatchListener<T>): void {
if (this.#started) throw new Error("Watch is already started");
if (this.#end !== undefined) throw new Error("Watch is stopped");
this.#started = true;
this.#listener = listener;
if (this.#pending.length > 0) this.#schedule();
}
stop(): Promise<WatchEnd> {
this.#terminate({ reason: "stopped" });
return this.#closed.promise;
}
observeCancellation(signal: AbortSignal): void {
if (this.#cancellationSignal !== undefined) throw new Error("Watch cancellation is already installed");
if (this.#end !== undefined) return;
this.#cancellationSignal = signal;
this.#cancellationListener = () => this.cancel();
signal.addEventListener("abort", this.#cancellationListener, { once: true });
if (signal.aborted) this.cancel();
}
cancel(): void {
this.#terminate({ reason: "cancelled" });
}
/** The source closed: end with `ended`, or with `failed` and the error when the Session failed. */
end(failure?: { readonly error: unknown }): void {
this.#terminate(failure === undefined ? { reason: "ended" } : { reason: "failed", error: failure.error });
}
advance(value: T, ops: readonly Op[], context: Context): void {
if (this.#end !== undefined || this.#retired) return;
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) {
this.#pending.length = 0;
const replacement = this.#replace?.() ?? value;
this.#pending.push({ value: replacement, ops: [["r", replacement as JsonValue]], context });
} else {
this.#pending.push({ value, ops, context });
}
if (this.#started) this.#schedule();
}
/**
* The document incarnation retired: queue the last frame, `[["r", null]]` with value `null`, after which the watch
* ends `retired`. An overflowing backlog is dropped, since the frame replaces the root anyway.
*/
retire(this: CommittedWatch<ObservedDocumentValue>, context: Context): void {
if (this.#end !== undefined || this.#retired) return;
this.#retired = true;
if (this.#pending.length >= MAX_PENDING_WATCH_FRAMES) this.#pending.length = 0;
this.#pending.push({ value: null, ops: RETIREMENT_OPERATIONS, context, retires: true });
if (this.#started) this.#schedule();
}
#schedule(): void {
if (this.#scheduled || this.#running || this.#end !== undefined) return;
this.#scheduled = true;
queueMicrotask(() => {
this.#scheduled = false;
void this.#drain();
});
}
async #drain(): Promise<void> {
if (this.#running || this.#end !== undefined || !this.#started) return;
this.#running = true;
try {
while (this.#end === undefined) {
const frame = this.#pending.shift();
const listener = this.#listener;
if (frame === undefined || listener === undefined) break;
this.#value = frame.value;
const deliveryContext = withoutAbortSignal(frame.context);
try {
await listener(frame.value, frame.ops, deliveryContext);
} catch (error) {
if (this.#end === undefined) {
this.#report?.(error);
this.#terminate({ reason: "listener_error", error: toError(error) });
}
break;
}
if (frame.retires === true) {
this.#terminate({ reason: "retired" });
break;
}
}
} finally {
this.#running = false;
if (this.#end === undefined && this.#pending.length > 0) this.#schedule();
}
}
/**
* End the watch, once: detach, drop pending frames and every reference into the source, and settle `closed`. A
* callback already running keeps running.
*/
#terminate(end: WatchEnd): void {
if (this.#end !== undefined) return;
this.#end = end;
const detach = this.#detach;
this.#detach = undefined;
this.#report = undefined;
this.#replace = undefined;
this.#listener = undefined;
this.#pending.length = 0;
if (this.#cancellationListener !== undefined) {
this.#cancellationSignal?.removeEventListener("abort", this.#cancellationListener);
}
this.#cancellationSignal = undefined;
this.#cancellationListener = undefined;
detach?.();
this.#closed.resolve(end);
}
}
function toError(error: unknown): Error {
return error instanceof Error ? error : new Error(errorMessage(error));
}

View file

@ -1,4 +1,4 @@
import type { Context, JsonValue } from "@earendil-works/chord";
import { type Context, type JsonValue, Watch } from "@earendil-works/chord";
import { awaitWithContext, BACKGROUND_CONTEXT, withoutAbortSignal } from "@earendil-works/chord/context";
import { type Op, track } from "@earendil-works/chord/delta";
import {
@ -56,9 +56,11 @@ import type {
TaskRecord,
Tx,
} from "../types.ts";
import { CommittedWatch, type ObservedDocumentValue } from "./observation.ts";
import { type LoadedDocument, Transaction, type TransactionHost, type TransactionScope } from "./transaction.ts";
/** What a document watch observes: the committed value of its incarnation, `null` once it retired. */
type ObservedDocumentValue = Readonly<JsonObject> | null;
/** The default source of a report: a listener, which never fails what ran it. */
const LISTENER: ReportInfo = { type: "listener_error" };
@ -775,10 +777,7 @@ export class SessionImpl implements Session {
* Attach a watch to one committed incarnation: check the definition, then forward this incarnation's committed
* changes, its retirement, and close. The watch's detach removes both subscriptions.
*/
#attachDocument(
definition: AnyDocToken["definition"],
loaded: LoadedDocument,
): CommittedWatch<ObservedDocumentValue> {
#attachDocument(definition: AnyDocToken["definition"], loaded: LoadedDocument): Watch<ObservedDocumentValue> {
checkRecordScope(definition, loaded.record);
checkRecordVersion(definition, loaded.record, loaded.storedVersion);
let unsubscribeCommit = (): void => {};
@ -790,9 +789,7 @@ export class SessionImpl implements Session {
unsubscribeClose();
unpin();
};
const observer = new CommittedWatch<ObservedDocumentValue>(loaded.tracker.value, detach, (error) =>
this.report(error),
);
const observer = new Watch<ObservedDocumentValue>(loaded.tracker.value, detach, (error) => this.report(error));
const observed = { version: loaded.valueVersion };
unsubscribeCommit = this.observeCommits((publication, context) => {
for (const change of publication.changes) {

View file

@ -1,6 +1,6 @@
import { setFlagsFromString } from "node:v8";
import { runInNewContext } from "node:vm";
import type { Context, JsonValue } from "@earendil-works/chord";
import type { Context } from "@earendil-works/chord";
import {
BACKGROUND_CONTEXT,
createContextKey,
@ -11,7 +11,6 @@ import {
import { applyImmutable } from "@earendil-works/chord/delta";
import { type DocumentWatch, defineDoc, MemoryStorage } from "@earendil-works/pi-durable";
import { describe, expect, it } from "vitest";
import { CommittedWatch } from "../src/session/observation.ts";
import { SessionImpl } from "../src/session/session.ts";
import { context, documentChanges, flush, openTestSession } from "./session-support.ts";
@ -508,31 +507,6 @@ describe("Session document watches", () => {
await watch.stop();
});
it("ends retired only on its source's retirement, never on a null value", async () => {
const watch = new CommittedWatch<JsonValue>(
1,
() => {},
() => {},
);
const frames: Array<{ value: JsonValue; ops: unknown }> = [];
watch.start(async (value, ops) => {
frames.push({ value, ops });
});
let end: unknown;
void watch.closed.then((settled) => {
end = settled;
});
watch.advance(null, [["r", null]], context);
watch.advance(2, [["r", 2]], context);
await flush();
expect(frames).toEqual([
{ value: null, ops: [["r", null]] },
{ value: 2, ops: [["r", 2]] },
]);
expect(end).toBeUndefined();
expect(await watch.stop()).toEqual({ reason: "stopped" });
});
it("retains neither its Session nor its listener once stopped, while still referenced", async () => {
setFlagsFromString("--expose-gc");
const gc = runInNewContext("gc") as () => void;