diff --git a/README.md b/README.md index 16eb1751..bdeaaed8 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,11 @@ added behind the same interface. See [`docs/workspace.md`](docs/workspace.md). ## A minimal sketch +The first application needs only the root package. Host adapters and the +advanced transport protocol are separate concerns, available from +`@ambionframework/ambion/host` and `@ambionframework/ambion/protocol` when an +embedding environment needs them. + ```ts import { defineAgent, defineHuman, startSession, visitSession } from '@ambionframework/ambion'; diff --git a/docs/agent.md b/docs/agent.md index 7b120282..030fa463 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -676,14 +676,30 @@ system prompt and the context, and sends the two strings with the model id and the hand the activation holds. The seat side resolves the definition by name through the runtime's catalog, builds the Pi `Agent`, and reaches the room through three calls: `view`, `commit` and `lease`. The room -reaches a seat through one, `wake`, which carries the line a running -activation is steered with when a message caused it. Every request and -response survives a round trip through `JSON.stringify` unchanged +reaches a seat through two. `wake` carries the line a running activation +is steered with when a message caused it. `cut` names an activation whose +lease the room ended, so the seat side stops it now, wherever the seat +runs. Every request and response survives a round trip through +`JSON.stringify` unchanged ([`wire.ts`](../packages/ambion/src/wire.ts)), so a seat and a room can live in two processes. The room answers the three calls from the fold: a lease is a row on the log, and the seat side releases it when the activation ends. +**A cut is the room's word, and the record is written before it.** The +room ends every lease the seat holds as `revoked`, and then it cuts. A +seat that never hears the cut is refused whatever it writes, because its +lease ended. A seat that hears it aborts the activation and moves on to +the wake that queued behind it. A model call that ignores the abort +finishes on its own, past a seat that has moved on. A renewal the room +refuses cuts the activation the same way, and a renewal that never +reached the room leaves the lease to expire where it stands: the seat +cuts the activation at that expiry, when the room expires the lease. + +**A cut reaches a seat the run never woke.** A run that resumes over a +live lease holds no wire to the seat that took it. The room opens one to +say the cut, so a seat that still runs the activation stops. + **A host owns a `Runtime`.** It holds the clock, the session opener, the model call, the catalog, the rooms that are running, the workspace names that are taken, and the policy for wakes and retries: how long a lease diff --git a/packages/ambion/README.md b/packages/ambion/README.md index 6a4d0391..bd695c09 100644 --- a/packages/ambion/README.md +++ b/packages/ambion/README.md @@ -7,11 +7,12 @@ whether it has anything to add. The assistant selects specialists from the room's reserve and consolidates multi-agent work when needed, without gaining general-purpose authority over the application. -`defineAgent` makes an agent, `defineHuman` names a person, `defineTool` gives -agents hands, and `defineWorkspace` names the identity and data boundary those -hands reach into. `startSession` brings up the room, `visitSession` puts -somebody in it, `readSession` reads it without starting anything, and -`stopSession` takes it down. +The root import is deliberately the small application surface. `defineAgent` +makes an agent, `defineHuman` names a person, `defineTool` gives agents hands, +and `defineWorkspace` names the identity and data boundary those hands reach +into. `startSession` brings up the room, `visitSession` puts somebody in it, +`readSession` reads it without starting anything, and `stopSession` takes it +down. ```ts import { @@ -55,6 +56,12 @@ await session.quiet(); await stopSession(session); ``` +Hosts that replace time, persistence, model sessions, transport, or workspace +storage import those integration points from `@ambionframework/ambion/host`. +Transport authors can import the advanced, JSON-safe seat protocol from +`@ambionframework/ambion/protocol`. Neither subpath is needed to define and run +an application with the defaults. + The design contract is [`docs/agent.md`](https://github.com/ambionframework/ambion/blob/main/docs/agent.md), with presence — who is in a session, and what the agents do about it — in [`docs/presence.md`](https://github.com/ambionframework/ambion/blob/main/docs/presence.md), diff --git a/packages/ambion/package.json b/packages/ambion/package.json index e1896b6d..e6f65b2d 100644 --- a/packages/ambion/package.json +++ b/packages/ambion/package.json @@ -22,6 +22,14 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./host": { + "types": "./dist/host.d.mts", + "import": "./dist/host.mjs" + }, + "./protocol": { + "types": "./dist/protocol.d.mts", + "import": "./dist/protocol.mjs" + }, "./package.json": "./package.json" }, "main": "./dist/index.mjs", diff --git a/packages/ambion/src/host.ts b/packages/ambion/src/host.ts new file mode 100644 index 00000000..a3268d85 --- /dev/null +++ b/packages/ambion/src/host.ts @@ -0,0 +1,28 @@ +/** Host integration points. Application code normally needs only the root package. */ +export type { + ExecutionEnv, + SessionMetadata, + SessionRepo, + SessionStorage, +} from '@earendil-works/pi-agent-core'; +export { + InMemorySessionRepo, + InMemorySessionStorage, + JsonlSessionRepo, +} from '@earendil-works/pi-agent-core'; +export type { + CreateRuntimeOptions, + RunningRoom, + Runtime, + SessionRepoLike, + Transport, +} from './host/runtime.ts'; +export { createRuntime, defaultRuntime, sessionsOver, systemClock } from './host/runtime.ts'; +export type { + MemoryBackendFile, + MemoryBackendOptions, + MemoryWorkspaceBackend, + SeedWriter, +} from './tools/just-bash.ts'; +export { directoryBackend, memoryBackend } from './tools/just-bash.ts'; +export type { Clock, ModelResolver, SessionOpener, WorkspaceBackend } from './types.ts'; diff --git a/packages/ambion/src/index.ts b/packages/ambion/src/index.ts index 353e933d..b3b4880f 100644 --- a/packages/ambion/src/index.ts +++ b/packages/ambion/src/index.ts @@ -1,47 +1,13 @@ /** - * The Ambion runtime: five primitives, and a dependency for every other concern. + * Ambion's application surface: define the participants and tools, give an + * agent a workspace, and start or visit a session. * - * `defineAgent` makes an agent, `defineHuman` names a person, `defineTool` - * gives agents hands, `defineWorkspace` names the identity and data boundary - * an agent's tools reach into, `seated` chooses what wakes a seat — with - * `passive` and `attentive` for the two points worth naming — and - * `startSession` brings up a named room the agents work in and people visit. - * A person's question opens an exchange, the room works, and quiescence - * closes it — the exchange every other feature reads. `stopSession` takes - * the room down, `readSession` reads a name without starting anything, - * `visitSession` puts a person in a running room, and `destroyWorkspace` - * retires a workspace for good. The room's assistant composes the room at - * the open of an exchange, from the agents held in reserve, and writes the - * one message a person reads when the exchange closes. The design contracts - * live in docs/agent.md, docs/exchange.md, docs/presence.md, - * docs/assistant.md, docs/workspace.md and docs/roster.md. + * Host adapters live at `@ambionframework/ambion/host`; the seat protocol + * lives at `@ambionframework/ambion/protocol`. */ -export type { - ExecutionEnv, - SessionMetadata, - SessionRepo, - SessionStorage, -} from '@earendil-works/pi-agent-core'; -// Storage is Pi's, re-exported — Ambion adds no storage abstraction of its own. -// `ExecutionEnv` is what a workspace backend's `connect` returns, and Pi's too. -export { - InMemorySessionRepo, - InMemorySessionStorage, - JsonlSessionRepo, -} from '@earendil-works/pi-agent-core'; export type { DefineAgentOptions, DefineHumanOptions, DefineToolOptions } from './define.ts'; export { attentive, defineAgent, defineHuman, defineTool, passive, seated } from './define.ts'; -export type { - CreateRuntimeOptions, - RunningRoom, - Runtime, - SessionRepoLike, - Transport, -} from './host/runtime.ts'; -export { createRuntime, defaultRuntime, sessionsOver, systemClock } from './host/runtime.ts'; -export type { SeatContext } from './seat/seat.ts'; -export { inProcessTransport, SeatActor } from './seat/seat.ts'; export type { ReadSessionOptions, ResumeSessionOptions, @@ -51,16 +17,6 @@ export type { Visit, } from './session.ts'; export { readSession, resumeSession, startSession, stopSession, visitSession } from './session.ts'; -export type { - MemoryBackendFile, - MemoryBackendOptions, - MemoryWorkspaceBackend, - SeedWriter, -} from './tools/just-bash.ts'; -// A workspace over a real directory, or the in-memory default with seeding -// and read-back. Neither import is needed for the in-memory default's own -// use inside `defineWorkspace` — only a host that wants to seed or read it. -export { directoryBackend, memoryBackend } from './tools/just-bash.ts'; export type { DefineWorkspaceOptions } from './tools/workspace.ts'; export { defineWorkspace, destroyWorkspace } from './tools/workspace.ts'; export type { @@ -69,13 +25,11 @@ export type { AgentSeatInfo, AmbionTool, Attention, - Clock, ClosedExchange, Exchange, HumanDefinition, HumanSeatInfo, Message, - ModelResolver, Participant, PresenceChange, PresenceMessage, @@ -85,35 +39,10 @@ export type { SeatStatus, Seq, SessionEvent, - SessionOpener, SpokenMessage, SummaryMessage, ToolContext, Workspace, - WorkspaceBackend, WorkspaceHandle, } from './types.ts'; export { isPresence, isSpoken, isSummary } from './types.ts'; -export type { - ActivationView, - CloseRow, - Commit, - CommitResponse, - CompositionRow, - EndReason, - Hand, - Intent, - Lease, - LeaseResponse, - LeaseRow, - SeatPort, - SeatRoom, - SeatRow, - Stale, - ViewResponse, - Wake, -} from './wire.ts'; -export { assertWire, roundTrip } from './wire.ts'; - -/** Kept in step with package.json by a test. */ -export const PACKAGE_NAME = '@ambionframework/ambion'; diff --git a/packages/ambion/src/protocol.ts b/packages/ambion/src/protocol.ts new file mode 100644 index 00000000..c7c908db --- /dev/null +++ b/packages/ambion/src/protocol.ts @@ -0,0 +1,20 @@ +/** + * Advanced seat protocol for transport implementors. These JSON-safe values + * are the complete contract crossing a room/seat boundary. + */ +export type { SeatContext } from './seat/seat.ts'; +export { inProcessTransport, SeatActor } from './seat/seat.ts'; +export type { + ActivationView, + Commit, + CommitResponse, + Lease, + LeaseResponse, + LeaseRow, + SeatPort, + SeatRoom, + Stale, + ViewResponse, + Wake, +} from './wire.ts'; +export { assertWire, roundTrip } from './wire.ts'; diff --git a/packages/ambion/src/seat/seat.ts b/packages/ambion/src/seat/seat.ts index 0ba1e50f..daa7b9d7 100644 --- a/packages/ambion/src/seat/seat.ts +++ b/packages/ambion/src/seat/seat.ts @@ -102,6 +102,9 @@ interface Current { activation: Activation; /** The activation ran to its end, and its release is in flight. It takes no steer. */ over: boolean; + /** Resolves when the room ended the lease: the actor moves on, whatever the run still does. */ + cut: () => void; + cutOff: Promise; } /** @@ -156,22 +159,44 @@ export class SeatActor implements SeatPort { if (!this.queued.includes(id)) this.queued.push(id); } + /** + * The room ended this activation's lease. The activation is aborted, and + * the actor moves on at once: a run that ignores the abort is left to + * finish on its own, and every call it still makes is answered stale. + */ + async cut(activation: string): Promise { + if (this.current?.id === activation) this.cutCurrent(); + } + /** Cut the activation in flight, whatever its id. The room hears how it ended. */ abort(): void { - this.current?.activation.abort(); + this.cutCurrent(); + } + + private cutCurrent(): void { + const current = this.current; + if (current === undefined) return; + current.activation.abort(); + current.cut(); } private async take(id: string): Promise { // Held before the claim, so a steer that lands while the claim is in // flight reaches the activation and not the floor. const activation = new Activation(id, this.context.seat, this.host(id)); - const current: Current = { id, activation, over: false }; + let cut = () => {}; + const cutOff = new Promise((resolve) => { + cut = resolve; + }); + const current: Current = { id, activation, over: false, cut, cutOff }; this.current = current; const claimed = await this.claim(id); if (claimed !== undefined) { - const stopRenewing = this.renewUntil(activation, claimed.expiry); + const stopRenewing = this.renewUntil(current, claimed.expiry); try { - await activation.run(); + // The cut ends the wait, and never the run: a run that ignores the + // abort finishes on its own, past a seat that took its next wake. + await Promise.race([activation.run(), cutOff]); } finally { stopRenewing(); // Over, and holding the seat through the release: a wake that lands @@ -225,31 +250,44 @@ export class SeatActor implements SeatPort { } } - /** One renewal: the new expiry, or nothing when the room refused it or it never reached the room. */ - private async renew(activation: Activation): Promise { + /** + * One renewal: the new expiry, `stale` when the room refused it, or + * `lost` when it never reached the room. + */ + private async renew(activation: Activation): Promise { try { const renewed = await this.room.lease({ activation: activation.id, phase: 'running' }); - return 'stale' in renewed ? undefined : renewed.ok.expiry; + return 'stale' in renewed ? 'stale' : renewed.ok.expiry; } catch { - return undefined; + return 'lost'; } } /** * Renew at half the expiry, for as long as the activation runs and the - * room renews it. The cancel stops the loop for good: a renewal in flight - * when the activation ends arms nothing when it comes back. + * room renews it. A refused renewal cuts the activation now: its lease + * ended, so nothing it writes lands. A renewal that never reached the + * room leaves the lease to expire where it stands, and the actor cuts + * the activation at that expiry, when the room expires the lease. The + * cancel stops the loop for good: a renewal in flight when the + * activation ends arms nothing when it comes back. */ - private renewUntil(activation: Activation, firstExpiry: number): () => void { + private renewUntil(current: Current, firstExpiry: number): () => void { const clock = this.context.clock; let stopped = false; let cancel = () => {}; + const cut = () => { + if (this.current === current) this.cutCurrent(); + }; const schedule = (expiry: number) => { - cancel = clock.alarm(clock.now() + (expiry - clock.now()) / 2, () => void again()); + cancel = clock.alarm(clock.now() + (expiry - clock.now()) / 2, () => void again(expiry)); }; - const again = async () => { - const renewed = await this.renew(activation); - if (!stopped && renewed !== undefined) schedule(renewed); + const again = async (held: number) => { + const renewed = await this.renew(current.activation); + if (stopped) return; + if (renewed === 'stale') cut(); + else if (renewed === 'lost') cancel = clock.alarm(held, cut); + else schedule(renewed); }; schedule(firstExpiry); return () => { diff --git a/packages/ambion/src/session.ts b/packages/ambion/src/session.ts index 7ace5d9b..62a717ba 100644 --- a/packages/ambion/src/session.ts +++ b/packages/ambion/src/session.ts @@ -38,7 +38,7 @@ import { activationId, draftId, isExpired, isLive, parseId, seatOf } from './roo import type { VisitRuntime } from './room/presence.ts'; import { type Decision, decide, liveSeats, working } from './room/reconcile.ts'; import { type RoomFacts, seatsOf, viewOf } from './room/view.ts'; -import { inProcessTransport, SeatActor, wakes } from './seat/seat.ts'; +import { inProcessTransport, wakes } from './seat/seat.ts'; import { type AgentDefinition, type AgentSeat, @@ -1280,13 +1280,14 @@ class SessionImpl implements Session, RunningRoom { /** * Cut one seat: every lease it holds ends revoked, every wake pending for - * it and every draft due for it is written off the same way, and the - * activation in flight is aborted where the seat runs in this process. + * it and every draft due for it is written off the same way, and the seat + * side is told to stop, wherever the seat runs. The room writes first, so + * a seat that never hears the cut is refused whatever it writes after it. */ private async cut(seat: string, ids: string[]): Promise { for (const id of ids) await this.end(id, seat, 'revoked'); - const port = this.ports.get(seat); - if (port instanceof SeatActor) port.abort(); + const port = this.port(seat); + for (const id of ids) void port.cut(id).catch(() => {}); } /** Closes the run: what is live is revoked, what is present is marked gone, and the name comes free. */ diff --git a/packages/ambion/src/wire.ts b/packages/ambion/src/wire.ts index e2f92b2c..0454ba26 100644 --- a/packages/ambion/src/wire.ts +++ b/packages/ambion/src/wire.ts @@ -9,8 +9,10 @@ * The seat reaches the room through three calls: `view` reads what an * activation is given, `commit` puts one message on the record, and * `lease` claims, renews or releases the activation. The room reaches a - * seat through one: `wake` names an activation the seat runs, and carries - * the line a running activation is steered with when a message caused it. + * seat through two: `wake` names an activation the seat runs, and carries + * the line a running activation is steered with when a message caused it; + * `cut` names an activation whose lease the room ended, so the seat side + * stops it now. */ import type { Attention, Message, Seq } from './types.ts'; @@ -93,6 +95,8 @@ export interface Wake { export interface SeatPort { wake(wake: Wake): Promise; + /** The room ended this activation's lease: stop it, and run what queued behind it. */ + cut(activation: string): Promise; } // -- a seat reaching its room ------------------------------------------------- diff --git a/packages/ambion/test/assistant.test.ts b/packages/ambion/test/assistant.test.ts index 4cbe0399..a2af1d7d 100644 --- a/packages/ambion/test/assistant.test.ts +++ b/packages/ambion/test/assistant.test.ts @@ -4,15 +4,12 @@ import { Type } from 'typebox'; import { afterEach, describe, expect, it } from 'vitest'; import { attentive, - createRuntime, defineAgent, defineHuman, defineTool, - InMemorySessionRepo, isSpoken, type Message, passive, - type Runtime, type Session, type SessionEvent, type SummaryMessage, @@ -20,6 +17,7 @@ import { stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime, InMemorySessionRepo, type Runtime } from '../src/host.ts'; import { renderRecord } from '../src/render.ts'; import { fakeClock } from './support/clock.ts'; import { assistantEnded, collect, deferred, roomName as name, tick } from './support/room.ts'; diff --git a/packages/ambion/test/chaos.test.ts b/packages/ambion/test/chaos.test.ts index 5fba7d09..8adda8b3 100644 --- a/packages/ambion/test/chaos.test.ts +++ b/packages/ambion/test/chaos.test.ts @@ -20,13 +20,13 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { - createRuntime, isPresence, resumeSession, type Session, stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; import { agents, priya, type Question, questions, sam, script, TIMING } from './support/cast.ts'; import { idle, liveLeases, outcome, World, within } from './support/chaos.ts'; import { type FakeClock, fakeClock } from './support/clock.ts'; diff --git a/packages/ambion/test/consistency.test.ts b/packages/ambion/test/consistency.test.ts index edc31151..cbeb8acc 100644 --- a/packages/ambion/test/consistency.test.ts +++ b/packages/ambion/test/consistency.test.ts @@ -10,9 +10,6 @@ */ import { describe, expect, it } from 'vitest'; import { - createRuntime, - inProcessTransport, - type Runtime, resumeSession, type Session, type SessionEvent, @@ -21,6 +18,8 @@ import { type Visit, visitSession, } from '../src/index.ts'; +import { createRuntime, type Runtime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { foldRoom } from '../src/room/fold.ts'; import { agents, assistant, colleague, priya, product, sam, troubled } from './support/cast.ts'; import { liveLeases } from './support/chaos.ts'; @@ -43,7 +42,7 @@ function mulberry32(seed: number): () => number { }; } -const OPERATIONS: Operation[] = ['wake', 'view', 'commit', 'lease']; +const OPERATIONS: Operation[] = ['wake', 'cut', 'view', 'commit', 'lease']; const RETRY = { attempts: 3, backoff: (attempt: number) => attempt * 30_000 }; /** One room over one storage, and the run that holds it now. */ @@ -60,7 +59,7 @@ class Cluster { session!: Session; private disk: FailMode = false; private failedBefore = 0; - /** Room calls the nemesis dropped, counted when taken: every one fails an activation, and that is one error. A dropped wake is sent again and fails nothing. */ + /** Room calls the nemesis dropped, counted when taken: every one fails an activation, and that is one error. A dropped wake is sent again, and a dropped cut leaves the record's word to stand, so both fail nothing. */ private dropped = 0; private droppedBefore = 0; /** Leases live when time jumped past the whole expiry: every one expires, and that is one error. */ @@ -255,7 +254,7 @@ class Cluster { const kind = this.pick(['drop', 'duplicate', 'delay'] as const); const on = this.pick(OPERATIONS); const taken = () => { - if (kind === 'drop' && on !== 'wake') this.dropped += 1; + if (kind === 'drop' && on !== 'wake' && on !== 'cut') this.dropped += 1; return true; }; this.faults.push({ on, kind, match: taken, ...(kind === 'delay' ? { ms: 2_000 } : {}) }); diff --git a/packages/ambion/test/doubt.test.ts b/packages/ambion/test/doubt.test.ts index 429bec6e..14bc4939 100644 --- a/packages/ambion/test/doubt.test.ts +++ b/packages/ambion/test/doubt.test.ts @@ -5,7 +5,6 @@ */ import { describe, expect, it } from 'vitest'; import { - createRuntime, defineAgent, defineHuman, isPresence, @@ -14,6 +13,7 @@ import { startSession, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; import { fakeClock } from './support/clock.ts'; import { collect, roomName, rowsOf } from './support/room.ts'; import { diff --git a/packages/ambion/test/hosts.test.ts b/packages/ambion/test/hosts.test.ts index 647202c9..27770258 100644 --- a/packages/ambion/test/hosts.test.ts +++ b/packages/ambion/test/hosts.test.ts @@ -8,14 +8,9 @@ * `AMBION_CHAOS=all` widens the handover to a crash at every write. */ import { describe, expect, it } from 'vitest'; -import { - createRuntime, - inProcessTransport, - resumeSession, - startSession, - stopSession, - visitSession, -} from '../src/index.ts'; +import { resumeSession, startSession, stopSession, visitSession } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { agents, assistant, diff --git a/packages/ambion/test/imports/application.ts b/packages/ambion/test/imports/application.ts new file mode 100644 index 00000000..3c9cbb27 --- /dev/null +++ b/packages/ambion/test/imports/application.ts @@ -0,0 +1,13 @@ +import { + defineAgent, + defineHuman, + defineTool, + defineWorkspace, + startSession, + type Message, + type SessionEvent, + type Visit, +} from '@ambionframework/ambion'; + +void [defineAgent, defineHuman, defineTool, defineWorkspace, startSession]; +void ((value: Message | SessionEvent | Visit) => value); diff --git a/packages/ambion/test/imports/host.ts b/packages/ambion/test/imports/host.ts new file mode 100644 index 00000000..8f607774 --- /dev/null +++ b/packages/ambion/test/imports/host.ts @@ -0,0 +1,10 @@ +import { + createRuntime, + directoryBackend, + type Clock, + type SessionOpener, + type Transport, +} from '@ambionframework/ambion/host'; + +void [createRuntime, directoryBackend]; +void ((value: Clock | SessionOpener | Transport) => value); diff --git a/packages/ambion/test/imports/protocol.ts b/packages/ambion/test/imports/protocol.ts new file mode 100644 index 00000000..03808897 --- /dev/null +++ b/packages/ambion/test/imports/protocol.ts @@ -0,0 +1,13 @@ +import { + assertWire, + SeatActor, + type Commit, + type CommitResponse, + type LeaseRow, + type SeatRoom, + type ViewResponse, + type Wake, +} from '@ambionframework/ambion/protocol'; + +void [assertWire, SeatActor]; +void ((value: Commit | CommitResponse | LeaseRow | SeatRoom | ViewResponse | Wake) => value); diff --git a/packages/ambion/test/lease.test.ts b/packages/ambion/test/lease.test.ts index 31a484dc..e9946c0c 100644 --- a/packages/ambion/test/lease.test.ts +++ b/packages/ambion/test/lease.test.ts @@ -8,20 +8,18 @@ import type { Context } from '@earendil-works/pi-ai'; import { afterEach, describe, expect, it } from 'vitest'; import { - createRuntime, defineAgent, defineHuman, - inProcessTransport, isSpoken, isSummary, - type LeaseRow, - type SeatRoom, type Session, startSession, stopSession, type Visit, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; +import { inProcessTransport, type LeaseRow, type SeatRoom } from '../src/protocol.ts'; import { type FakeClock, fakeClock } from './support/clock.ts'; import { assistant, collect, deferred, enter, roomName, rowsOf, tick } from './support/room.ts'; import { diff --git a/packages/ambion/test/live/record.test.ts b/packages/ambion/test/live/record.test.ts index 5e072f6f..8ae7dfa4 100644 --- a/packages/ambion/test/live/record.test.ts +++ b/packages/ambion/test/live/record.test.ts @@ -5,13 +5,13 @@ */ import { expect, it } from 'vitest'; import { - InMemorySessionRepo, isPresence, readSession, startSession, stopSession, visitSession, } from '../../src/index.ts'; +import { InMemorySessionRepo } from '../../src/host.ts'; import { collect, roomName } from '../support/room.ts'; import { agent, diff --git a/packages/ambion/test/live/support.ts b/packages/ambion/test/live/support.ts index 244fb013..1ec5b248 100644 --- a/packages/ambion/test/live/support.ts +++ b/packages/ambion/test/live/support.ts @@ -16,7 +16,6 @@ import { type DefineAgentOptions, defineAgent, defineHuman, - InMemorySessionRepo, isSpoken, type Message, type Session, @@ -24,6 +23,7 @@ import { type StartSessionOptions, startSession, } from '../../src/index.ts'; +import { InMemorySessionRepo } from '../../src/host.ts'; import { collect, roomName } from '../support/room.ts'; /** The model every live seat runs on. The example reads the same variable. */ diff --git a/packages/ambion/test/live/workspace.test.ts b/packages/ambion/test/live/workspace.test.ts index fec66090..82aa0638 100644 --- a/packages/ambion/test/live/workspace.test.ts +++ b/packages/ambion/test/live/workspace.test.ts @@ -5,7 +5,8 @@ * pick them up and use them against a filesystem it has never seen. */ import { expect, it } from 'vitest'; -import { defineWorkspace, destroyWorkspace, memoryBackend, stopSession } from '../../src/index.ts'; +import { defineWorkspace, destroyWorkspace, stopSession } from '../../src/index.ts'; +import { memoryBackend } from '../../src/host.ts'; import { enter, roomName } from '../support/room.ts'; import { agent, diff --git a/packages/ambion/test/log.test.ts b/packages/ambion/test/log.test.ts index ccca607d..1fda069b 100644 --- a/packages/ambion/test/log.test.ts +++ b/packages/ambion/test/log.test.ts @@ -6,7 +6,8 @@ import { describe, expect, it } from 'vitest'; import { sessionsOver } from '../src/host/runtime.ts'; -import { InMemorySessionRepo, type SpokenMessage } from '../src/index.ts'; +import { type SpokenMessage } from '../src/index.ts'; +import { InMemorySessionRepo } from '../src/host.ts'; import { RoomLog } from '../src/log/log.ts'; import { deferred, roomName } from './support/room.ts'; import { faultyOpener, memory } from './support/storage.ts'; diff --git a/packages/ambion/test/matrix.test.ts b/packages/ambion/test/matrix.test.ts index 3046ef0e..0027d50f 100644 --- a/packages/ambion/test/matrix.test.ts +++ b/packages/ambion/test/matrix.test.ts @@ -4,7 +4,8 @@ * through to disk and reads back. */ import { describe, expect, it } from 'vitest'; -import { createRuntime, inProcessTransport } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { fakeClock } from './support/clock.ts'; import { roomName } from './support/room.ts'; import { scenarios } from './support/scenarios.ts'; diff --git a/packages/ambion/test/package.test.ts b/packages/ambion/test/package.test.ts index dd0181c1..109ba5a8 100644 --- a/packages/ambion/test/package.test.ts +++ b/packages/ambion/test/package.test.ts @@ -1,10 +1,13 @@ import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { expect, it } from 'vitest'; -import { PACKAGE_NAME } from '../src/index.ts'; -it('keeps the exported package name in step with the manifest', async () => { +it('publishes explicit application, host, and protocol entry points', async () => { const manifestPath = fileURLToPath(new URL('../package.json', import.meta.url)); - const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { name: string }; - expect(PACKAGE_NAME).toBe(manifest.name); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + name: string; + exports: Record; + }; + expect(manifest.name).toBe('@ambionframework/ambion'); + expect(Object.keys(manifest.exports)).toEqual(['.', './host', './protocol', './package.json']); }); diff --git a/packages/ambion/test/presence.test.ts b/packages/ambion/test/presence.test.ts index dc2a2eb5..092a0dc7 100644 --- a/packages/ambion/test/presence.test.ts +++ b/packages/ambion/test/presence.test.ts @@ -1,20 +1,18 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { attentive, - createRuntime, defineAgent, defineHuman, - InMemorySessionRepo, isSpoken, type Message, passive, readSession, type Session, - type SessionOpener, startSession, stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime, InMemorySessionRepo, type SessionOpener } from '../src/host.ts'; import { andrei, assistant, collect, deferred, roomName as name } from './support/room.ts'; import { contextText, quiet, scripted } from './support/scripted.ts'; import { type FaultyOpener, faultyOpener, memory } from './support/storage.ts'; diff --git a/packages/ambion/test/property.test.ts b/packages/ambion/test/property.test.ts index b80d01d0..620a84b6 100644 --- a/packages/ambion/test/property.test.ts +++ b/packages/ambion/test/property.test.ts @@ -9,13 +9,10 @@ */ import { describe, expect, it } from 'vitest'; import { - createRuntime, defineAgent, defineHuman, - inProcessTransport, isSummary, passive, - type Runtime, resumeSession, type Session, type SessionEvent, @@ -24,6 +21,8 @@ import { type Visit, visitSession, } from '../src/index.ts'; +import { createRuntime, type Runtime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { liveLeases } from './support/chaos.ts'; import { type FakeClock, fakeClock } from './support/clock.ts'; import { invariants } from './support/invariants.ts'; @@ -108,7 +107,7 @@ const STEPS = [ 'crash', ] as const; type Step = (typeof STEPS)[number]; -const OPERATIONS: Operation[] = ['wake', 'view', 'commit', 'lease']; +const OPERATIONS: Operation[] = ['wake', 'cut', 'view', 'commit', 'lease']; /** * What a step may hear back: the storage refused the write, the visit is diff --git a/packages/ambion/test/restart.test.ts b/packages/ambion/test/restart.test.ts index ae16c331..634d1b54 100644 --- a/packages/ambion/test/restart.test.ts +++ b/packages/ambion/test/restart.test.ts @@ -6,13 +6,10 @@ */ import { describe, expect, it } from 'vitest'; import { - createRuntime, defineAgent, defineHuman, - inProcessTransport, isSpoken, isSummary, - type Runtime, readSession, resumeSession, type Session, @@ -20,6 +17,8 @@ import { stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime, type Runtime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { type FakeClock, fakeClock } from './support/clock.ts'; import { collect, crash, deferred, roomName, rowsOf, tick } from './support/room.ts'; import { @@ -484,6 +483,64 @@ describe.each(storages)('a room resumed on $name', (storage) => { } }); + it('cuts a lease the last run took, over a wire this run has not opened yet', async () => { + const { opened, clock } = await world(storage); + try { + // the activation never answers, so the run the crash leaves behind + // writes nothing after the test ends + const script = byAgent({ + alpha: (_c, _n, call) => + call === 1 ? new Promise(() => {}) : Promise.resolve(quiet()), + }); + const name = roomName(`restart-${storage.name}`); + const first = createRuntime({ sessions: opened.sessions, clock, agents }); + const session = startSession({ + name, + assistant, + agents: [alpha], + runtime: first, + streamFn: scripted(script), + }); + const visit = await visitSession(session, priya); + await visit.deliver({ text: 'Anyone?' }); + await tick(); + expect(session.seats().find((s) => s.name === 'alpha')).toMatchObject({ status: 'active' }); + crash(first, session); + + // the resumed run inherits the live lease and never wakes alpha, so it + // holds no port for that seat when the abort revokes what it inherited + const cuts: string[] = []; + const inProcess = inProcessTransport(); + const second = createRuntime({ + sessions: opened.sessions, + clock, + agents, + transport: { + connect: (room, seat, host) => { + const port = inProcess.connect(room, seat, host); + return { + wake: (wake) => port.wake(wake), + cut: (activation) => { + cuts.push(activation); + return port.cut(activation); + }, + }; + }, + }, + }); + const resumed = await resumeSession(name, { runtime: second, streamFn: scripted(script) }); + expect(resumed.seats().find((s) => s.name === 'alpha')).toMatchObject({ status: 'active' }); + resumed.abort(); + await resumed.settled(); + await tick(); + // the seat side hears the cut over the wire, and the room opened it to say so + expect(cuts).toEqual(['2:alpha']); + await stopSession(resumed); + } finally { + await opened.dispose(); + } + }); + it('refuses to resume a name whose seats the catalog does not hold, and one with no composition', async () => { const { opened, runtime } = await world(storage); try { diff --git a/packages/ambion/test/roster.test.ts b/packages/ambion/test/roster.test.ts index 8f9ba8f1..4791a759 100644 --- a/packages/ambion/test/roster.test.ts +++ b/packages/ambion/test/roster.test.ts @@ -3,7 +3,6 @@ import { fauxAssistantMessage } from '@earendil-works/pi-ai'; import { afterEach, describe, expect, it } from 'vitest'; import { attentive, - createRuntime, defineAgent, defineHuman, isPresence, @@ -17,6 +16,7 @@ import { stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; import { fakeClock } from './support/clock.ts'; import { assistantEnded, collect, deferred, roomName as name, tick } from './support/room.ts'; import { diff --git a/packages/ambion/test/runtime.test.ts b/packages/ambion/test/runtime.test.ts index 71a5e491..1bda1686 100644 --- a/packages/ambion/test/runtime.test.ts +++ b/packages/ambion/test/runtime.test.ts @@ -7,7 +7,6 @@ import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { - createRuntime, defineWorkspace, destroyWorkspace, isSpoken, @@ -16,6 +15,7 @@ import { stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; import { fakeClock } from './support/clock.ts'; import { andrei, assistant, roomName } from './support/room.ts'; import { quiet, scripted } from './support/scripted.ts'; diff --git a/packages/ambion/test/seat.test.ts b/packages/ambion/test/seat.test.ts index f69c8b61..dd7a036c 100644 --- a/packages/ambion/test/seat.test.ts +++ b/packages/ambion/test/seat.test.ts @@ -4,19 +4,19 @@ * queued behind it runs next. */ import type { StreamFn } from '@earendil-works/pi-agent-core'; +import { createAssistantMessageEventStream } from '@earendil-works/pi-ai'; import { describe, expect, it } from 'vitest'; +import { defineAgent } from '../src/index.ts'; +import { type Clock, createRuntime } from '../src/host.ts'; import { - type Clock, type CommitResponse, - createRuntime, - defineAgent, type Lease, type LeaseResponse, SeatActor, type SeatRoom, type ViewResponse, type Wake, -} from '../src/index.ts'; +} from '../src/protocol.ts'; import { fakeClock } from './support/clock.ts'; import { deferred, tick } from './support/room.ts'; import { quiet, scripted } from './support/scripted.ts'; @@ -43,6 +43,10 @@ class PlayedRoom implements SeatRoom { readonly releasing = deferred(); /** The first release waits here. */ readonly letGo = deferred(); + /** Every renewal from now on is answered stale: the room ended the lease. */ + refuseRenewals = false; + /** Every renewal from now on never reaches the room. */ + loseRenewals = false; constructor(private readonly clock: Clock) {} @@ -68,8 +72,12 @@ class PlayedRoom implements SeatRoom { const ok = { ok: { expiry: this.clock.now() + 60_000, lastSeq: 1 } }; if (lease.phase === 'running') { // A lease not yet held is a claim; one held is a renewal. - if (!this.holding.has(lease.activation)) this.claimed(lease.activation); - return ok; + if (!this.holding.has(lease.activation)) { + this.claimed(lease.activation); + return ok; + } + if (this.loseRenewals) throw new Error('the renewal never reached the room'); + return this.refuseRenewals ? { stale: 'the lease ended' } : ok; } if (this.releases.length === 0) { this.releasing.resolve(); @@ -87,6 +95,9 @@ class PlayedRoom implements SeatRoom { } } +/** A model call that never answers and never hears an abort. */ +const deaf: StreamFn = () => createAssistantMessageEventStream(); + function play(stream: StreamFn = scripted(() => quiet())) { const clock = fakeClock(); const runtime = createRuntime({ clock, stream }); @@ -101,7 +112,7 @@ function play(stream: StreamFn = scripted(() => quiet())) { stream: runtime.stream, model: runtime.model, }); - return { room, actor }; + return { room, actor, clock }; } const wakeOf = (activation: string): Wake => ({ room: 'played', seat: 'product', activation }); @@ -155,6 +166,53 @@ describe('a seat actor', () => { expect(room.mostHeld).toBe(1); }); + it('cuts an activation whose run ignores the abort, and runs what queued behind it', async () => { + const { room, actor } = play(deaf); + room.letGo.resolve(); + const ran = actor.run('1:product'); + await until(() => room.claims.length === 1); + await actor.wake(wakeOf('2:product')); + // the room ended the first lease: the actor moves on now, and the deaf run is left behind + await actor.cut('1:product'); + await until(() => room.claims.length === 2); + expect(room.releases).toEqual(['1:product']); + await actor.cut('2:product'); + await ran; + expect(room.releases).toEqual(['1:product', '2:product']); + expect(room.mostHeld).toBe(1); + }); + + it('cuts the activation when the room refuses its renewal', async () => { + const { room, actor, clock } = play(deaf); + room.letGo.resolve(); + const ran = actor.run('1:product'); + await until(() => room.claims.length === 1); + // the room answers the renewal stale: the lease ended, so nothing this + // activation writes lands, and the actor stops waiting on it + room.refuseRenewals = true; + // the claim is answered; one tick lets the actor arm its renewal alarm + await tick(); + await clock.advance(31_000); + await ran; + expect(room.releases).toEqual(['1:product']); + }); + + it('cuts the activation at the expiry it held when a renewal never reached the room', async () => { + const { room, actor, clock } = play(deaf); + room.letGo.resolve(); + const ran = actor.run('1:product'); + await until(() => room.claims.length === 1); + // the renewal is lost, so the room expires the lease where it stands: the + // actor waits for that expiry and cuts the activation there, not before + room.loseRenewals = true; + await tick(); + await clock.advance(31_000); + expect(room.releases).toEqual([]); + await clock.advance(30_000); + await ran; + expect(room.releases).toEqual(['1:product']); + }); + it('resolves run once every wake that queued behind the activation has run, in order and once each', async () => { const { room, actor } = play(); const ran = actor.run('1:product'); diff --git a/packages/ambion/test/session.test.ts b/packages/ambion/test/session.test.ts index e6cbb8b6..65adaf8c 100644 --- a/packages/ambion/test/session.test.ts +++ b/packages/ambion/test/session.test.ts @@ -1,10 +1,8 @@ import type { Context } from '@earendil-works/pi-ai'; import { describe, expect, it } from 'vitest'; import { - createRuntime, defineAgent, defineHuman, - InMemorySessionRepo, isSpoken, type Message, passive, @@ -13,6 +11,8 @@ import { stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime, InMemorySessionRepo } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import { andrei, assistant, collect, deferred, enter, roomName } from './support/room.ts'; import { byAgent, contextText, quiet, scripted, speak } from './support/scripted.ts'; @@ -621,6 +621,52 @@ describe('startSession', () => { await stopSession(session); }); + it('tells the seat side to stop, over the wire, when it cuts a lease', async () => { + const hangs = deferred(); + const solo = defineAgent({ + name: 'solo', + identity: 'Never stops.', + instructions: 'wait', + model: 'scripted/solo', + }); + // a transport of the host's own: the room reaches it through the wire alone + const cuts: string[] = []; + const inProcess = inProcessTransport(); + const runtime = createRuntime({ + transport: { + connect: (room, seat, host) => { + const port = inProcess.connect(room, seat, host); + return { + wake: (wake) => port.wake(wake), + cut: (activation) => { + cuts.push(activation); + return port.cut(activation); + }, + }; + }, + }, + }); + const session = startSession({ + name: roomName('cut'), + assistant, + agents: [solo], + runtime, + streamFn: scripted(async () => { + hangs.resolve(); + return new Promise(() => {}); + }), + }); + const visit = await enter(session); + await visit.deliver({ text: 'wait for me' }); + await hangs.promise; + session.abort(); + await session.quiet(); + // the room ended the lease and told the seat, and the seat stopped: the room is idle + expect(cuts).toEqual(['2:solo']); + expect(session.seats().find((s) => s.name === 'solo')).toMatchObject({ status: 'idle' }); + await stopSession(session); + }); + it('answers a commit from a lease that ended stale, before what the record moved past', async () => { const solo = defineAgent({ name: 'solo', @@ -629,7 +675,9 @@ describe('startSession', () => { model: 'scripted/solo', }); // the seats hear no wake, so the test holds the seat's side of the wire itself - const runtime = createRuntime({ transport: { connect: () => ({ wake: async () => {} }) } }); + const runtime = createRuntime({ + transport: { connect: () => ({ wake: async () => {}, cut: async () => {} }) }, + }); const session = startSession({ name: roomName('stale'), assistant, diff --git a/packages/ambion/test/split.test.ts b/packages/ambion/test/split.test.ts index 6f634f61..cc52c144 100644 --- a/packages/ambion/test/split.test.ts +++ b/packages/ambion/test/split.test.ts @@ -16,14 +16,14 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { - createRuntime, - inProcessTransport, resumeSession, type Session, startSession, stopSession, visitSession, } from '../src/index.ts'; +import { createRuntime } from '../src/host.ts'; +import { inProcessTransport } from '../src/protocol.ts'; import type { LogEntry } from '../src/log/log.ts'; import { foldRoom } from '../src/room/fold.ts'; import { diff --git a/packages/ambion/test/support/chaos.ts b/packages/ambion/test/support/chaos.ts index 52092657..b2e3978e 100644 --- a/packages/ambion/test/support/chaos.ts +++ b/packages/ambion/test/support/chaos.ts @@ -15,22 +15,20 @@ */ import { expect } from 'vitest'; import { - createRuntime, type HumanDefinition, - inProcessTransport, isSpoken, isSummary, type Message, - type Runtime, resumeSession, type Session, type SessionEvent, - type SessionOpener, startSession, visitSession, } from '../../src/index.ts'; +import { createRuntime, type Runtime, type SessionOpener } from '../../src/host.ts'; +import { inProcessTransport } from '../../src/protocol.ts'; import { foldLeases, isLive } from '../../src/room/lease.ts'; -import type { LeaseRow } from '../../src/wire.ts'; +import type { LeaseRow } from '../../src/protocol.ts'; import { agents, assistant, diff --git a/packages/ambion/test/support/child.ts b/packages/ambion/test/support/child.ts index fb04c473..392b4c3d 100644 --- a/packages/ambion/test/support/child.ts +++ b/packages/ambion/test/support/child.ts @@ -7,7 +7,8 @@ * * node --experimental-transform-types child.ts */ -import { createRuntime, startSession, visitSession } from '../../src/index.ts'; +import { startSession, visitSession } from '../../src/index.ts'; +import { createRuntime } from '../../src/host.ts'; import { agents, assistant, diff --git a/packages/ambion/test/support/clock.ts b/packages/ambion/test/support/clock.ts index fee23115..ca3ee834 100644 --- a/packages/ambion/test/support/clock.ts +++ b/packages/ambion/test/support/clock.ts @@ -1,4 +1,4 @@ -import type { Clock } from '../../src/index.ts'; +import type { Clock } from '../../src/host.ts'; /** A clock a test moves by hand. Alarms fire inside `advance`, in order. */ export interface FakeClock extends Clock { diff --git a/packages/ambion/test/support/history.ts b/packages/ambion/test/support/history.ts index 2e17a32c..ce82faef 100644 --- a/packages/ambion/test/support/history.ts +++ b/packages/ambion/test/support/history.ts @@ -13,7 +13,9 @@ * is one message, one attempt at a wake or a draft runs at a time, and * nothing is pending once the room drains. */ -import type { Clock, LeaseRow, Message, Seq } from '../../src/index.ts'; +import type { Clock } from '../../src/host.ts'; +import type { Message, Seq } from '../../src/index.ts'; +import type { LeaseRow } from '../../src/protocol.ts'; import type { RoomState } from '../../src/room/fold.ts'; import { activationId, parseId } from '../../src/room/lease.ts'; diff --git a/packages/ambion/test/support/invariants.ts b/packages/ambion/test/support/invariants.ts index 7dec0b57..929598ad 100644 --- a/packages/ambion/test/support/invariants.ts +++ b/packages/ambion/test/support/invariants.ts @@ -3,13 +3,9 @@ * leaves has one shape, and every scenario ends by checking it. */ import { expect } from 'vitest'; -import { - isSummary, - type LeaseRow, - type SessionEvent, - type SessionOpener, - type SessionView, -} from '../../src/index.ts'; +import { isSummary, type SessionEvent, type SessionView } from '../../src/index.ts'; +import { type SessionOpener } from '../../src/host.ts'; +import { type LeaseRow } from '../../src/wire.ts'; import { standing } from './history.ts'; import { rowsOf } from './room.ts'; diff --git a/packages/ambion/test/support/room.ts b/packages/ambion/test/support/room.ts index c4bb0d61..8fccc4ca 100644 --- a/packages/ambion/test/support/room.ts +++ b/packages/ambion/test/support/room.ts @@ -2,12 +2,11 @@ import type { Session as PiSession } from '@earendil-works/pi-agent-core'; import { defineAgent, defineHuman, - type Runtime, type Session, type SessionEvent, - type SessionOpener, visitSession, } from '../../src/index.ts'; +import { type Runtime, type SessionOpener } from '../../src/host.ts'; /** A trivial assistant: every room seats one, and nothing that uses it tests what it writes. */ export const assistant = defineAgent({ diff --git a/packages/ambion/test/support/scenarios.ts b/packages/ambion/test/support/scenarios.ts index 18077c7d..98bd02fc 100644 --- a/packages/ambion/test/support/scenarios.ts +++ b/packages/ambion/test/support/scenarios.ts @@ -12,12 +12,12 @@ import { destroyWorkspace, isSpoken, isSummary, - type Runtime, type Session, startSession, stopSession, visitSession, } from '../../src/index.ts'; +import { type Runtime } from '../../src/host.ts'; import { invariants } from './invariants.ts'; import { collect, deferred } from './room.ts'; import { diff --git a/packages/ambion/test/support/storage.ts b/packages/ambion/test/support/storage.ts index adde3482..05051e2c 100644 --- a/packages/ambion/test/support/storage.ts +++ b/packages/ambion/test/support/storage.ts @@ -19,7 +19,7 @@ import { type SessionOpener, sessionsOver, type WorkspaceBackend, -} from '../../src/index.ts'; +} from '../../src/host.ts'; export interface OpenedStorage { readonly sessions: SessionOpener; diff --git a/packages/ambion/test/support/transport.ts b/packages/ambion/test/support/transport.ts index be235c75..2a8e18ac 100644 --- a/packages/ambion/test/support/transport.ts +++ b/packages/ambion/test/support/transport.ts @@ -2,8 +2,9 @@ * Transports for the tests: one that proves every request and response is * plain JSON, and one that loses, repeats or delays them on purpose. */ -import type { Clock, RunningRoom, SeatPort, Transport } from '../../src/index.ts'; -import { assertWire, roundTrip } from '../../src/index.ts'; +import type { Clock, RunningRoom, Transport } from '../../src/host.ts'; +import type { SeatPort } from '../../src/protocol.ts'; +import { assertWire, roundTrip } from '../../src/protocol.ts'; export interface SerializingTransport extends Transport { /** Every value that would not have survived the wire. Empty when the design holds. */ @@ -44,12 +45,15 @@ export function serializing(transport: Transport): SerializingTransport { lease: async (lease) => check('lease response', await room.lease(check('lease', lease))), }; const port = transport.connect(wrapped, seat, runtime); - return { wake: (wake) => port.wake(check('wake', wake)) }; + return { + wake: (wake) => port.wake(check('wake', wake)), + cut: (activation) => port.cut(check('cut', activation)), + }; }, }; } -export type Operation = 'wake' | 'view' | 'commit' | 'lease'; +export type Operation = 'wake' | 'cut' | 'view' | 'commit' | 'lease'; export interface Fault { on: Operation; @@ -110,7 +114,10 @@ export function faultyTransport(transport: Transport, faults: Fault[], clock: Cl lease: (lease) => through('lease', lease, () => room.lease(lease)), }; const port: SeatPort = transport.connect(wrapped, seat, runtime); - return { wake: (wake) => through('wake', wake, () => port.wake(wake)).catch(() => {}) }; + return { + wake: (wake) => through('wake', wake, () => port.wake(wake)).catch(() => {}), + cut: (activation) => through('cut', activation, () => port.cut(activation)).catch(() => {}), + }; }, }; } diff --git a/packages/ambion/test/wire.test.ts b/packages/ambion/test/wire.test.ts index 817cd397..3e64f80c 100644 --- a/packages/ambion/test/wire.test.ts +++ b/packages/ambion/test/wire.test.ts @@ -3,21 +3,20 @@ * log, is plain JSON: it survives the wire unchanged. */ import { describe, expect, it } from 'vitest'; +import { createRuntime } from '../src/host.ts'; import { type ActivationView, assertWire, - type CloseRow, type Commit, type CommitResponse, - type CompositionRow, - createRuntime, type Lease, type LeaseResponse, type LeaseRow, roundTrip, type ViewResponse, type Wake, -} from '../src/index.ts'; +} from '../src/protocol.ts'; +import type { CloseRow, CompositionRow } from '../src/wire.ts'; import { fakeClock } from './support/clock.ts'; import { roomName, rowsOf } from './support/room.ts'; import { oneExchange } from './support/scenarios.ts'; diff --git a/packages/ambion/test/workspace.test.ts b/packages/ambion/test/workspace.test.ts index bb5bd241..8b32013d 100644 --- a/packages/ambion/test/workspace.test.ts +++ b/packages/ambion/test/workspace.test.ts @@ -13,14 +13,13 @@ import { defineTool, defineWorkspace, destroyWorkspace, - directoryBackend, isSpoken, type Session, startSession, stopSession, type ToolContext, - type WorkspaceBackend, } from '../src/index.ts'; +import { directoryBackend, type WorkspaceBackend } from '../src/host.ts'; import { BashEnv, DEFAULT_TIMEOUT_SECONDS } from '../src/tools/bash-env.ts'; import { MEMORY_LIMIT_BYTES, memoryBackend } from '../src/tools/just-bash.ts'; import { assistant, enter, roomName as name } from './support/room.ts'; diff --git a/packages/ambion/tsdown.config.ts b/packages/ambion/tsdown.config.ts index d8aebc73..3d4b716c 100644 --- a/packages/ambion/tsdown.config.ts +++ b/packages/ambion/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/host.ts', 'src/protocol.ts'], format: ['esm'], dts: true, clean: true, diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index f3220e8f..e028b9da 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -2,12 +2,15 @@ /** * The `ambion` binary. * - * Importing PACKAGE_NAME is the point: it proves turbo built the runtime first - * and that the CLI resolved it across the workspace. + * Importing the application package proves turbo built the runtime first and + * that the CLI resolved it across the workspace. */ -import { PACKAGE_NAME } from '@ambionframework/ambion'; +import { defineAgent } from '@ambionframework/ambion'; import { cliVersion } from './lib/version.ts'; +const PACKAGE_NAME = '@ambionframework/ambion'; +void defineAgent; + function help(version: string): string { return [ `ambion ${version}`, diff --git a/planning/backlog.md b/planning/backlog.md index c3b60913..14d6d7b0 100644 --- a/planning/backlog.md +++ b/planning/backlog.md @@ -727,3 +727,21 @@ and `quiet()` resolve early on a run that is gone. **Fix.** Status reads off the record with a durable cursor, so a client that reconnects reads what it missed. + +### 42. The crash sweep counts one activation end too many + +**What.** `pnpm chaos` fails now and then on the crash sweep. The +invariant that every `activation_end` has a start, or a lease the run +inherited, sees one end more than it counts starts. It reproduced on +`jsonl`, "before the entry lands", at write 5 of 42, in about one run in +three, and only with all five sweep files in one vitest run. The CI gate +never saw it: `pnpm check` runs the sweeps at their small seed count. +Either the room emits a second end for one lease, or the resume counts +what it inherited too low. + +**Where.** `test/support/invariants.ts` line 64; `test/support/chaos.ts` +`inherited`; `session.ts` `end`. + +**Fix.** Print every lease row and every activation event of the failing +run, and say which lease has the extra end. Then fix the room or the +count.