From fd8953a7f7e470b75e091324696a1f625db6f277 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 21:08:10 +0000 Subject: [PATCH] Move quiescence out of the room: Exchanges owns the whole span The room held three pieces of lifecycle state of its own: whether a seat worked since the last settle, and the two waiter lists behind settled() and quiet(). Their transitions sat across eight methods, and the three-way branch in ended() was the widest context in the file. Exchanges now holds the open exchange, the stirred flag, and both promises. The room reads what is running off the seats and passes two booleans in at every transition. Every transition is synchronous and takes no model, so exchange.test.ts proves the lifecycle with no room: what opens one, what a settle closes and reports, when each promise resolves, and what the assistant's scheduler makes of a settle where a seat worked and one where none did. The room keeps one boolean, stopped. docs/exchange.md section 5 names the module and its transitions. Behaviour is unchanged; every existing test passes as it was. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BL6FaVKirZDMQmcL61mEUr --- docs/exchange.md | 29 +++++ packages/ambion/src/exchange.ts | 84 +++++++++++-- packages/ambion/src/session.ts | 87 +++++-------- packages/ambion/test/exchange.test.ts | 168 ++++++++++++++++++++++++++ planning/backlog.md | 23 ++-- 5 files changed, 321 insertions(+), 70 deletions(-) create mode 100644 packages/ambion/test/exchange.test.ts diff --git a/docs/exchange.md b/docs/exchange.md index 241e530..0133cc0 100644 --- a/docs/exchange.md +++ b/docs/exchange.md @@ -137,6 +137,29 @@ mid-question: the record keeps what was said, and nobody is mid-question after a restart. A person whose question the room was working on asks again, and that question opens a new exchange. +**`Exchanges` owns the whole span.** It holds the open exchange, whether an +activation that counts as work began since the seats last settled, and the +two promises of §6. The room holds none of that. What is running is a fact +about the seats, so the room reads it off them and passes two booleans in +at every transition: whether a seat that speaks for itself is working, and +whether nothing at all is. Every transition is synchronous and takes no +model, so [`exchange.test.ts`](../packages/ambion/test/exchange.test.ts) +proves the lifecycle with no room around it. + +The transitions, in the order the room calls them: + +| Call | When | What it decides | +| --------------------------- | ----------------------------------------- | -------------------------------------------------------------------------- | +| `note(message, fromPerson)` | A message landed | Whether it opened an exchange | +| `stir()` | An activation that counts as work began | The next settle is the seats stopping | +| `settle(working, through)` | An activation ended, or a question routed | Nothing while working; else who waited, what closed, whether a seat worked | +| `quiesce(idle)` | After every settle | Whether the room went quiet, once | +| `drain()` | The run stops | Nobody waits on a room that never goes quiet | + +The assistant drafting stirs nothing, so a settle at a draft's end reports +that no seat worked. That is the one fact a failed draft waits on: the next +settle where a seat did ([`assistant.md`](assistant.md) §5). + A closed exchange is an owner and a range, so it is derivable from the record. Nothing derives it today; a host that wants a history of exchanges records the `exchange_closed` events as they arrive. @@ -250,6 +273,12 @@ The exchange is proved beside the assistant that first reads one, in All in-process, in vitest, on a scripted stream. +The lifecycle alone is proved in +[`exchange.test.ts`](../packages/ambion/test/exchange.test.ts), with no +room and no stream: what opens one, what a settle closes and reports, when +each promise resolves, and what the assistant's scheduler makes of a settle +where a seat worked and one where none did (§5). + The live run is [`demos/2026-08-31-one-exchange-one-message.html`](../demos/2026-08-31-one-exchange-one-message.html): four questions opened four exchanges, and each one closed into one message. diff --git a/packages/ambion/src/exchange.ts b/packages/ambion/src/exchange.ts index ed3f7ce..13adb71 100644 --- a/packages/ambion/src/exchange.ts +++ b/packages/ambion/src/exchange.ts @@ -22,6 +22,12 @@ * - **What lands while it is open steers it and changes nothing.** Not the * owner, not the range, not who the answer belongs to. * + * This module also holds the two ends the room reports, `settled` and + * `quiet`, because they are the two edges of this span. What is running is + * a fact about the seats, so the room reads it off them and passes it in. + * Every transition here is synchronous and takes no model, so the whole + * lifecycle is provable with no room around it. + * * The design contract is `docs/exchange.md`; `docs/assistant.md` says what an * assistant makes of one. */ @@ -43,13 +49,38 @@ export interface ClosedExchange extends Exchange { readonly through: Seq; } +/** The seats settled: what that closed, and whether a seat worked since they last settled. */ +export interface Settled { + /** The exchange the room was working on, or nothing when it worked on its own account. */ + readonly closed: ClosedExchange | undefined; + /** + * Whether an activation that counts as work began since the last settle. + * A second settle at one quiescence — a question that woke nobody, an + * aborted activation ending after the exchange closed — is not the seats + * stopping again, and the assistant reads the difference. + */ + readonly worked: boolean; +} + /** - * The open exchange, if there is one. Run state: an exchange belongs to a - * running room, and a restart begins with none — the record keeps what was - * said, and nobody is mid-question after a restart. + * The room's exchanges: the open one, and the two ends the room reports. + * + * Run state: an exchange belongs to a running room, and a restart begins with + * none — the record keeps what was said, and nobody is mid-question after a + * restart. + * + * Two facts arrive from the room at every transition, and this holds neither: + * whether a seat that speaks for itself is taking an activation (*working*), + * and whether nothing at all is (*idle*). The room draws that one distinction + * about its assistant, and the seats hold the activations, so there is no + * count here to keep in step. */ export class Exchanges { private open: Exchange | undefined; + /** Whether an activation that counts as work began since the seats last settled. */ + private stirred = false; + private readonly settledWaiters: (() => void)[] = []; + private readonly quietWaiters: (() => void)[] = []; /** What the room is working on, or nothing when nobody has asked. */ current(): Exchange | undefined { @@ -72,13 +103,52 @@ export class Exchanges { return this.open; } + /** An activation that counts as work began: the next settle is the seats stopping. */ + stir(): void { + this.stirred = true; + } + /** - * The room went quiet. Closes whatever was open and returns it with the - * range it held, or nothing when the room was working on its own account. + * Something stopped. While a seat is working nothing settles, and the + * result says so. Otherwise the seats have settled: whoever waited hears + * it first, then the open exchange closes with the range it reached, and + * the result says whether a seat worked since the last settle. */ - close(through: Seq): ClosedExchange | undefined { + settle(working: boolean, through: Seq): Settled | undefined { + if (working) return undefined; + for (const resolve of this.settledWaiters.splice(0)) resolve(); + const worked = this.stirred; + this.stirred = false; const open = this.open; this.open = undefined; - return open === undefined ? undefined : { ...open, through }; + return { closed: open === undefined ? undefined : { ...open, through }, worked }; + } + + /** + * Nothing at all is taking an activation, so the room is quiet: whoever + * waited hears it, and the result says the room should say so. An active + * room is not quiet, and the result says nothing. + */ + quiesce(idle: boolean): boolean { + if (!idle) return false; + for (const resolve of this.quietWaiters.splice(0)) resolve(); + return true; + } + + /** Resolves when no seat that speaks for itself is taking an activation. */ + settled(working: boolean): Promise { + if (!working) return Promise.resolve(); + return new Promise((resolve) => this.settledWaiters.push(resolve)); + } + + /** Resolves when nothing at all is taking an activation. */ + quiet(idle: boolean): Promise { + if (idle) return Promise.resolve(); + return new Promise((resolve) => this.quietWaiters.push(resolve)); + } + + /** A stopped room never goes quiet on its own, so nobody waits on it. */ + drain(): void { + for (const resolve of this.quietWaiters.splice(0)) resolve(); } } diff --git a/packages/ambion/src/session.ts b/packages/ambion/src/session.ts index 398f06b..ae9dabe 100644 --- a/packages/ambion/src/session.ts +++ b/packages/ambion/src/session.ts @@ -15,7 +15,9 @@ * - **Route.** Who hears a message, and who wakes for it. * - **Give an activation what only the room knows.** The model, the prompt, the * hands, and the room as it stands at that moment. - * - **Say when it has stopped.** An exchange closed, and nothing running. + * + * When the room has stopped is the exchange's own fact, and `exchange.ts` + * holds it: the room reads what is running off the seats and hands it over. */ import type { AgentTool, @@ -37,7 +39,7 @@ import { summariseTool, } from './assistant.ts'; import { seated } from './define.ts'; -import { type ClosedExchange, type Exchange, Exchanges } from './exchange.ts'; +import { type Exchange, Exchanges, type Settled } from './exchange.ts'; import { Attendance, type VisitRuntime } from './presence.ts'; import { openOrCreate, persistTurns, RecordStore } from './record.ts'; import { @@ -256,19 +258,10 @@ class SessionImpl implements Session { private readonly assistant: Assistant; private readonly here = new Attendance(() => this.record); private readonly listeners = new Set<(event: SessionEvent) => void>(); - private readonly settledWaiters: (() => void)[] = []; - private readonly quietWaiters: (() => void)[] = []; private readonly streamFn: StreamFn; private readonly customStream: boolean; private stopped = false; - /** - * Whether a seat has worked since the room last settled. A failed draft - * waits for the seats to stop again, and a second settle at one quiescence - * — an aborted activation ending after an unseat closed the exchange, a - * question that woke nobody — is not the seats stopping again. - */ - private stirred = false; - /** The room's exchanges: what a question opened, and what quiescence closes. */ + /** The room's exchanges: what a question opened, what quiescence closes, and both ends. */ private readonly exchanges = new Exchanges(); constructor(options: StartSessionOptions) { @@ -393,8 +386,7 @@ class SessionImpl implements Session { } settled(): Promise { - if (!this.working()) return Promise.resolve(); - return new Promise((resolve) => this.settledWaiters.push(resolve)); + return this.exchanges.settled(this.working()); } /** @@ -425,8 +417,7 @@ class SessionImpl implements Session { // The same condition the `quiet` event reports. A summary a race left // owed is not work in flight: it waits for the next quiet room, and the // room is quiet in the meantime. - if (this.idle()) return Promise.resolve(); - return new Promise((resolve) => this.quietWaiters.push(resolve)); + return this.exchanges.quiet(this.idle()); } abort(): void { @@ -514,10 +505,7 @@ class SessionImpl implements Session { // A question that wakes no seat has no seat to stop, so the exchange it // opened would never close. The same check an ending activation runs, // and the same last word: the room was quiet, and it says so. - if (this.exchanges.current() !== undefined && !this.working()) { - this.settle(); - if (this.idle()) this.markQuiet(); - } + if (this.exchanges.current() !== undefined) this.rest(false); } /** @@ -592,7 +580,7 @@ class SessionImpl implements Session { // not leave a room that can never be started again. if (running.get(this.name) === this) running.delete(this.name); // A stopped room never goes quiet on its own, so nobody waits on it. - for (const resolve of this.quietWaiters.splice(0)) resolve(); + this.exchanges.drain(); } } @@ -693,7 +681,7 @@ class SessionImpl implements Session { // The seat holding it is what makes the room busy: there is no count to // keep in step, and so none to drift. seat.activation = activation; - if (this.closingOf(seat) === undefined) this.stirred = true; + if (this.closingOf(seat) === undefined) this.exchanges.stir(); this.emit({ type: 'activation_start', agent: seat.def.name }); // The assistant's activations are one pass: a summary answers a room that // moved with a redraft inside its own tool, and a composition decides on @@ -708,28 +696,26 @@ class SessionImpl implements Session { private ended(seat: SeatRuntime, activation: Activation): void { seat.activation = undefined; const assistant = this.assistant.is(seat.def.name); - const drafted = assistant && this.assistant.composing() === undefined; if (assistant) { this.assistant.activationEnded({ wrote: activation.spoke, failed: activation.failed }); } this.emit({ type: 'activation_end', agent: seat.def.name, spoke: activation.spoke }); - // An exchange ends when the seats stop, and a composing assistant is one - // of them. The assistant writing about an exchange is not the room still - // working on it, so a draft's end closes none — which also keeps a failing - // assistant from retrying for ever. What a draft's end frees is the seat, - // for whoever was owed while it drafted. - if (drafted) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); - else if (!this.working()) this.settle(); - else if (assistant) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); - if (this.idle()) this.markQuiet(); + this.rest(assistant); } - /** The seats stopped: whoever waited hears it, and the exchange closes. */ - private settle(): void { - for (const resolve of this.settledWaiters.splice(0)) resolve(); - const worked = this.stirred; - this.stirred = false; - this.closeExchange(worked); + /** + * Something stopped, so the room may have too. The exchange decides: the + * seats settle when none that speaks for itself is working, and the room + * is quiet when nothing at all is. The assistant writing about an exchange + * is not the room still working on it, so a draft's end closes none — which + * also keeps a failing assistant from retrying for ever. What a draft's end + * frees is the seat, for whoever was owed while it drafted. + */ + private rest(freed: boolean): void { + const settled = this.exchanges.settle(this.working(), this.store.lastSeq); + if (settled) this.closed(settled); + else if (freed) this.draftNext(this.assistant.dueAfterDraft(...this.dueArgs())); + this.markQuiet(); } /** The range the assistant is closing, when this seat is the assistant and it is closing one. */ @@ -956,18 +942,9 @@ class SessionImpl implements Session { // -- the assistant ------------------------------------------------------------ /** - * The room went quiet, so the exchange it was working on is over. The host + * The seats settled, so the exchange they were working on is over. The host * hears that before anything is written about it: the assistant is the first - * reader of a closed exchange and not the only one. - */ - private closeExchange(worked: boolean): void { - const closing = this.exchanges.close(this.store.lastSeq); - if (closing) this.emit({ type: 'exchange_closed', exchange: closing }); - this.summariseClosed(closing, worked); - } - - /** - * What the assistant makes of a closed exchange: its owner is owed the one + * reader of a closed exchange and not the only one. Its owner is owed the one * message that stands for it, and the room activates the assistant for it. * Nothing else in the room wakes for a close — the assistant is seated `none`, * and the close is the one thing that reaches it. @@ -977,8 +954,11 @@ class SessionImpl implements Session { * waits for the next time the seats stop rather than retrying on itself — * and a settle that no seat worked before is not the seats stopping again. */ - private summariseClosed(closing: ClosedExchange | undefined, worked: boolean): void { - if (closing) this.assistant.owe(closing.owner, closing.from); + private closed({ closed, worked }: Settled): void { + if (closed) { + this.emit({ type: 'exchange_closed', exchange: closed }); + this.assistant.owe(closed.owner, closed.from); + } const due = worked ? this.assistant.dueAtQuiescence(...this.dueArgs()) : this.assistant.dueAfterDraft(...this.dueArgs()); @@ -1017,9 +997,8 @@ class SessionImpl implements Session { * has gone quiet. */ private markQuiet(): void { - if (this.stopped || !this.idle()) return; - this.emit({ type: 'quiet' }); - for (const resolve of this.quietWaiters.splice(0)) resolve(); + if (this.stopped) return; + if (this.exchanges.quiesce(this.idle())) this.emit({ type: 'quiet' }); } // -- what an agent reads ------------------------------------------------- diff --git a/packages/ambion/test/exchange.test.ts b/packages/ambion/test/exchange.test.ts new file mode 100644 index 0000000..c4babce --- /dev/null +++ b/packages/ambion/test/exchange.test.ts @@ -0,0 +1,168 @@ +/** + * The exchange lifecycle on its own: no room, no seats, no model. `Exchanges` + * holds the open exchange and the two ends the room reports, and every + * transition is synchronous, so each claim in docs/exchange.md §3 and §6 is + * one call and one assertion. The assistant's scheduler is proved beside it, + * because the one fact it reads at a settle is the one `Exchanges` reports. + */ +import { describe, expect, it } from 'vitest'; +import { Assistant } from '../src/assistant.ts'; +import { defineAgent } from '../src/define.ts'; +import { Exchanges } from '../src/exchange.ts'; +import type { Message } from '../src/types.ts'; + +const at = new Date().toISOString(); +const said = (seq: number, from: string, text = 'Thursday.'): Message => ({ + kind: 'said', + seq, + at, + from, + text, +}); +const question = (seq: number, from = 'priya'): Message => said(seq, from, 'Thursday?'); +const arrival = (seq: number, from = 'priya'): Message => ({ + kind: 'arrived', + seq, + at, + from, + identity: 'Site manager.', +}); + +/** Whether a promise has resolved by the next microtask. */ +async function resolved(promise: Promise): Promise { + let done = false; + void promise.then(() => { + done = true; + }); + await Promise.resolve(); + return done; +} + +describe('an exchange', () => { + it('opens on a question from a person, and closes with the range it reached', () => { + const exchanges = new Exchanges(); + expect(exchanges.note(question(3), true)).toMatchObject({ owner: 'priya', from: 3 }); + expect(exchanges.current()?.owner).toBe('priya'); + + // the seats work, so nothing settles + exchanges.stir(); + expect(exchanges.settle(true, 5)).toBeUndefined(); + expect(exchanges.current()?.owner).toBe('priya'); + + // the seats stop, and the range is the record as it stands + expect(exchanges.settle(false, 7)).toEqual({ + closed: { owner: 'priya', from: 3, at, through: 7 }, + worked: true, + }); + expect(exchanges.current()).toBeUndefined(); + }); + + it('opens for nobody but a person, and never twice at once', () => { + const exchanges = new Exchanges(); + expect(exchanges.note(arrival(1), true)).toBeUndefined(); + expect(exchanges.note(question(2, 'product'), false)).toBeUndefined(); + expect(exchanges.current()).toBeUndefined(); + + const opened = exchanges.note(question(3), true); + // a second question steers the open one and changes nothing + expect(exchanges.note(question(4, 'sam'), true)).toBeUndefined(); + expect(exchanges.current()).toBe(opened); + }); + + it('settles with nothing to close when the room worked on its own account', () => { + const exchanges = new Exchanges(); + exchanges.stir(); + expect(exchanges.settle(false, 2)).toEqual({ closed: undefined, worked: true }); + }); + + it('reports work once per settle, and a second settle at one quiescence reports none', () => { + const exchanges = new Exchanges(); + exchanges.stir(); + expect(exchanges.settle(false, 1)?.worked).toBe(true); + // a question that woke nobody, an aborted activation ending late + expect(exchanges.settle(false, 2)?.worked).toBe(false); + // the assistant drafting stirs nothing, so its end reports none either + expect(exchanges.settle(false, 3)?.worked).toBe(false); + exchanges.stir(); + expect(exchanges.settle(false, 4)?.worked).toBe(true); + }); +}); + +describe('the two ends', () => { + it('settles whoever waited before it closes, and at once when nothing works', async () => { + const exchanges = new Exchanges(); + expect(await resolved(exchanges.settled(false))).toBe(true); + + exchanges.note(question(1), true); + const waited = exchanges.settled(true); + expect(await resolved(waited)).toBe(false); + exchanges.settle(false, 2); + expect(await resolved(waited)).toBe(true); + }); + + it('is quiet only when nothing at all is active, and says so once per quiescence', async () => { + const exchanges = new Exchanges(); + expect(await resolved(exchanges.quiet(true))).toBe(true); + + const waited = exchanges.quiet(false); + expect(exchanges.quiesce(false)).toBe(false); + expect(await resolved(waited)).toBe(false); + expect(exchanges.quiesce(true)).toBe(true); + expect(await resolved(waited)).toBe(true); + }); + + it('drains whoever waited on quiet when the room stops', async () => { + const exchanges = new Exchanges(); + const waited = exchanges.quiet(false); + exchanges.drain(); + expect(await resolved(waited)).toBe(true); + }); +}); + +describe('what the assistant makes of a settle', () => { + const def = defineAgent({ + name: 'assistant', + identity: 'Writes the one message a person reads.', + instructions: 'stay quiet', + model: 'scripted/assistant', + }); + /** Two answers from a seat: enough that one message would serve. */ + const record: Message[] = [question(1), said(2, 'product'), said(3, 'planner')]; + const fromSeat = (name: string) => name !== 'priya' && name !== 'assistant'; + + it('drafts for the owner at the settle that closed the exchange', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toMatchObject({ + person: 'priya', + from: 1, + through: 3, + }); + }); + + /** A failed draft waits for the next settle where a seat worked, and for nothing less. */ + it('holds a failed draft until the seats stop again', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: false, failed: true }); + + // the draft's own end, and a settle no seat worked before: not yet + expect(assistant.dueAfterDraft(record, 3, fromSeat)).toBeUndefined(); + // the seats worked and stopped: now + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toMatchObject({ person: 'priya' }); + }); + + it('owes nothing for a draft that stood down, and nothing for one that wrote', () => { + const assistant = new Assistant({ def, attention: 'none' }); + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: false, failed: false }); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toBeUndefined(); + + assistant.owe('priya', 1); + assistant.dueAtQuiescence(record, 3, fromSeat); + assistant.activationEnded({ wrote: true, failed: false }); + expect(assistant.dueAtQuiescence(record, 3, fromSeat)).toBeUndefined(); + }); +}); diff --git a/planning/backlog.md b/planning/backlog.md index 2b6e546..10b3017 100644 --- a/planning/backlog.md +++ b/planning/backlog.md @@ -51,15 +51,20 @@ without limit. `docs/agent.md` §8 says Ambion owns no context window, and on append. Long term: a window policy on `RoomView.record`, and a decision in the contract about which module owns it. -### 3. `session.ts` holds six jobs in 1063 lines - -**What.** `SessionImpl` has 61 methods. Its header lists compose, commit, -route, hands, and quiescence. The reserve, `seat` and `unseat` joined it in -the last change. The `say` tool sits inline at line 858, while the -assistant's `summarise` and `seat` tools live in `assistant.ts` behind -small room interfaces. The commit path (`claim`, `publish`, -`commitPresence`, `deliverFrom`) and the assistant scheduling -(`closeExchange` through `draftNext`) are two more concerns. +### 3. `session.ts` holds five jobs in 1042 lines + +**What.** `SessionImpl` has about 60 methods. Its header lists compose, +commit, route, and hands. The reserve, `seat` and `unseat` joined it in +the change that seats agents from a reserve. The `say` tool sits inline, +while the assistant's `summarise` and `seat` tools live in `assistant.ts` +behind small room interfaces. The commit path (`claim`, `publish`, +`commitPresence`, `deliverFrom`) and the assistant scheduling (`closed` +through `draftNext`) are two more concerns. + +Quiescence left the file on 2026-09-06: `Exchanges` owns the open +exchange, whether a seat worked since the last settle, and the `settled` +and `quiet` promises, and `exchange.test.ts` proves the lifecycle with no +room. The room holds one boolean of its own, `stopped`. **Why.** Every feature lands in one file. `dispatch` sits at the complexity cap by design, and the file around it has no cap.