feat(durable,chord): publish commits at adoption, address-following watches, synchronous observers

Durable publishes a stored commit inside Storage.commit's adopt: the
telemetry events, its own observers (document watches, conversation
views, task graph, scheduler, waits), and a dispatch of host
subscribeCommits listeners one microtask later, before every wait and
abort the commit triggers. A commit the storage rejects after adoption is
published before the Session fails; a throwing telemetry adapter is
reported as host_error "telemetry" instead of rejecting a durable commit.

Chord's watch listeners take one change object, { value, ops, retired }
({ value } for native watches); retirement is a flag, not an end.
Durable's document watches follow their address, and a task document's
watch ends after its retirement call. New synchronous observers for
adapters: observeDoc, observeConversation, observeTaskGraph; the watches
are built on them. The env file watcher's change type is renamed
FileChange.
This commit is contained in:
Mario Zechner 2026-10-26 20:44:20 +00:00
parent 363ad78bf0
commit 8b0caefc22
53 changed files with 3389 additions and 554 deletions

View file

@ -32,7 +32,7 @@ console.log("watch baseline:", notesWatch.value);
const busy = Promise.withResolvers<void>();
const done = Promise.withResolvers<void>();
let calls = 0;
notesWatch.start(async (value, ops, _deliveryContext) => {
notesWatch.start(async ({ value, ops }, _deliveryContext) => {
console.log("watch update:", value, JSON.stringify(ops));
// The first call stays busy, as a slow consumer would, while three more commits land.
if (++calls === 1) await busy.promise;

View file

@ -45,7 +45,7 @@ if (process.env.OPENAI_API_KEY === undefined) {
process.stdout.write(text.slice(printed.length));
printed = text;
};
liveWatch.start(async (value) => {
liveWatch.start(async ({ value }) => {
const block = value?.generation?.message?.content.find((content) => content.type === "text");
if (block?.type === "text") printText(block.text);
});

View file

@ -82,14 +82,14 @@ let stop: () => Promise<unknown>;
if (mode === "events") {
const stream = await watchEvents(harness, root.id, context);
print(stream.snapshot);
stream.start(async (events) => {
stream.start(async ({ value: events }) => {
for (const event of events) print(event);
});
stop = () => stream.stop();
} else {
const watch = await root.watch(context);
print({ view: watch.value });
watch.start(async (_value, ops) => print({ ops }));
watch.start(async ({ ops }) => print({ ops }));
stop = () => watch.stop();
}

View file

@ -7,7 +7,6 @@ import { Type } from "@earendil-works/pi-ai";
import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import {
type AgentEvent,
createRegistry,
defineExtension,
defineTool,
@ -63,7 +62,7 @@ console.log(
view.value.entries.map((entry) => entry.kind),
);
console.log("view tool slot:", live.tools?.[0]?.status, JSON.stringify(live.tools?.[0]?.output));
view.start(async (value) => {
view.start(async ({ value }) => {
const slot = (value.docs["pi.live"] as LiveState).tools?.[0];
if (slot?.status === "running") console.log("view output now:", JSON.stringify(slot.output));
});
@ -75,7 +74,7 @@ console.log(
stream.snapshot.tools.map((slot) => `${slot.name} ${slot.status}`),
);
let output = stream.snapshot.tools[0]?.output ?? "";
stream.start(async (events: readonly AgentEvent[]) => {
stream.start(async ({ value: events }) => {
for (const event of events) {
if (event.type === "tool_execution_update" && event.output !== undefined) {
output =

View file

@ -114,7 +114,7 @@ const attached = new Set<ConversationId>();
const attach = async (id: ConversationId, indent: string): Promise<void> => {
attached.add(id);
const stream = await watchEvents(harness, id, context);
stream.start(async (events) => {
stream.start(async ({ value: events }) => {
for (const event of events) {
print(indent, event);
if (event.type !== "tool_execution_update") continue;

View file

@ -14,7 +14,6 @@ import { createModels } from "@earendil-works/pi-ai/models";
import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
import {
type AgentEvent,
AssistantEntry,
type ConversationId,
configure,
@ -285,7 +284,7 @@ function contentText(content: string | readonly { type: string; text?: string }[
/** Print the main conversation's messages as they are committed. Subagents work in their own conversations. */
async function follow(harness: Harness, id: ConversationId): Promise<void> {
const stream = await watchEvents(harness, id, context);
stream.start(async (events: readonly AgentEvent[]) => {
stream.start(async ({ value: events }) => {
for (const event of events) {
if (event.type !== "message_end") continue;
const message = event.entry.model?.[0];