From 6f088a2c76e28e4438bdca41a8646922d5490f3e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:01:37 -0400 Subject: [PATCH 01/19] =?UTF-8?q?=E2=9C=A8=20Add=20the=20terminal=20provid?= =?UTF-8?q?er=20boundary=20and=20pane=20authority=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replaceable seam a terminal grid executes through, before any of the execution that uses it. `packages/runtime/terminal.ts` is the contextual provider: `prepare()` builds the whole composite while it stays hidden, `attach()` shows it once every pane is ready, and `destroy()` gives the root terminal back. The request is provider-neutral — columns, rows, and the authored panes with their derived positions — and names no terminal, socket, process or window. Middleware may observe, narrow, refuse, wrap or delegate; presentation never decides an outcome, so `update()` receives states core has already settled on. `packages/core/src/terminal/authority.ts` mints one-use pane claims for one request's ordinals. A claim admits one interactive operation at a time on its pane and holds that pane's readiness latch. Two claims do not contend, which is what lets panes stay interactive together. `packages/core/src/terminal/pane.ts` is the seam interactive work inside a pane reaches for, so it runs as that pane's owner instead of competing for the root foreground lease. Absence means "not in a pane". Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows. --- packages/core/src/terminal/authority.ts | 184 ++++++++++++ packages/core/src/terminal/pane.ts | 66 ++++ packages/runtime/mod.ts | 17 ++ packages/runtime/terminal.ts | 282 ++++++++++++++++++ .../runtime/tests/terminal-provider.test.ts | 271 +++++++++++++++++ 5 files changed, 820 insertions(+) create mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/pane.ts create mode 100644 packages/runtime/terminal.ts create mode 100644 packages/runtime/tests/terminal-provider.test.ts diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts new file mode 100644 index 000000000..df4feffab --- /dev/null +++ b/packages/core/src/terminal/authority.ts @@ -0,0 +1,184 @@ +/** + * Who is allowed to own a terminal, and what "ready" means (architecture.md + * §Terminal authority). + * + * The provider draws a grid. This decides everything about it that matters: + * which request is live, which provider installation it belongs to, which pane + * ordinals exist, whether an interactive operation may start on one, and when a + * pane has actually started. None of that is reachable by name. There is no + * context holding an authority, no member of a request that carries one, and no + * handler return value that produces one — an authority reachable by name would + * be an authority every same-name context and every loaded copy could reach. + * + * A claim is the unforgeable carrier. It is minted here for one ordinal of one + * request under one installation generation, and a claim from another grid, + * another ordinal, an earlier generation, or a finished expansion authorizes + * nothing at all. Holding one grants terminal ownership and nothing else: it + * says nothing about which Agent session a pane may own, because that is the + * session coordinator's to answer and stays independently authoritative. + */ + +import { all, ensure, withResolvers } from "effection"; +import type { Operation } from "effection"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalAuthorityError extends Error { + override name = "TerminalAuthorityError"; +} + +/** + * One pane's terminal ownership. + * + * `admit` is the whole of it: an interactive operation runs inside one, and a + * second one on the same pane is refused while the first is live. Two claims for + * two ordinals do not contend at all, which is what lets panes be interactive at + * the same time. + */ +export interface TerminalPaneClaim { + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * Refuses while another is live on this pane, and refuses once the grid that + * minted the claim has finished — a claim kept past its expansion is a claim + * to a terminal nobody owns any more. + */ + admit(body: () => Operation): Operation; + /** + * Acknowledge the runtime's successful child-spawn event for this pane. + * + * The one thing that makes a pane ready. Called from the spawn event and + * before anything waits for the child to exit, so a child that starts and + * immediately exits is both ready and settled. Acknowledging twice has no + * effect, and a preparation, reservation or spawn that failed never + * acknowledges at all. + */ + ready(): void; +} + +/** What one pane's readiness is waiting on, from the grid's side. */ +export interface PaneReadiness { + /** Settles when the pane's first interactive child reports its spawn event. */ + reached(): Operation; + /** Whether the latch has been acknowledged. */ + readonly acknowledged: boolean; +} + +/** The claims one grid expansion holds, and what they are waiting on. */ +export interface TerminalGridClaims { + readonly claims: readonly TerminalPaneClaim[]; + readonly readiness: readonly PaneReadiness[]; + /** + * Stop admitting anything on every pane. + * + * Close prevents a later launch before it cancels the live ones, so a pane + * that was about to start one is refused rather than raced. + */ + seal(): void; +} + +/** + * Mint the claims for one grid expansion. + * + * The request is validated against the ordinals it declares before a single + * claim exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { + validate(request); + + let sealed = false; + const claims: TerminalPaneClaim[] = []; + const readiness: PaneReadiness[] = []; + + for (const pane of request.panes) { + const latch = withResolvers(); + let acknowledged = false; + let live = false; + + readiness.push({ + reached: () => latch.operation, + get acknowledged() { + return acknowledged; + }, + }); + + claims.push({ + ordinal: pane.ordinal, + *admit(body: () => Operation): Operation { + if (sealed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(); + } finally { + live = false; + } + }, + ready() { + // Idempotent by construction: readiness is a fact about the pane, and a + // provider that reports the same spawn twice has not started two panes. + if (acknowledged) { + return; + } + acknowledged = true; + latch.resolve(); + }, + }); + } + + return { + claims, + readiness, + seal() { + sealed = true; + }, + }; +} + +function validate(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reaches its latch, so the caller races this against pane failure + * rather than asking the barrier to know about failure. + */ +export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { + return allOf(readiness.map((pane) => pane.reached())); +} + +function* allOf(waits: readonly Operation[]): Operation { + yield* all(waits); +} + +/** Seal the grid as soon as the enclosing scope begins to unwind. */ +export function sealOnTeardown(claims: TerminalGridClaims): Operation { + return ensure(() => { + claims.seal(); + }); +} diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts new file mode 100644 index 000000000..f308de81b --- /dev/null +++ b/packages/core/src/terminal/pane.ts @@ -0,0 +1,66 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the authority. The claim it hands + * out was minted for one ordinal of one grid and cannot be forged, copied + * usefully, or kept past the expansion that owns it — so a replaced context + * yields a pane terminal nobody owns rather than a way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalPaneClaim } from "./authority.ts"; + +/** The pane the current work is running in. */ +export interface PaneTerminal { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * `body` receives the pane's readiness latch and must call it from the + * runtime's successful child-spawn event, before it waits for the child to + * exit. A body that never spawns never reports, and the grid it belongs to + * never attaches — which is what stops a pane that failed to start being + * presented as one that is running. + * + * A second interactive operation while one is live on this pane is refused. + * Two panes do not contend with each other at all. + */ + interactive(body: (spawned: () => void) => Operation): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * Set rather than composed: a pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(claim: TerminalPaneClaim): Operation { + yield* PaneTerminalContext.set({ + ordinal: claim.ordinal, + interactive(body) { + return claim.admit(() => body(() => claim.ready())); + }, + }); +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c41f34206..e9a990c9a 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -146,6 +146,23 @@ export type { NativeLaunchOutcome, NativeLaunchRequest, } from "./launcher.ts"; +export { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "./terminal.ts"; +export type { + ControlledTerminalProviderOptions, + TerminalComposite, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalProviderHandler, + TerminalProviderLog, + TerminalShellOutcome, +} from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts new file mode 100644 index 000000000..1fac60e3d --- /dev/null +++ b/packages/runtime/terminal.ts @@ -0,0 +1,282 @@ +/** + * The terminal provider — how a host presents one grid of interactive panes. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native composite UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * + * 1. `prepare()` builds the whole composite while it is still hidden — every + * pane endpoint and its supervision — and presents nothing. A host that + * cannot open a grid refuses here, before any pane has started work. + * 2. Core starts the authored panes concurrently and waits for every one of + * them to be ready. + * 3. `attach()` shows the composite, once, after that barrier. A failure before + * it discards the hidden composite instead of leaving a partial grid on the + * reader's screen. + * 4. `destroy()` takes it down again and gives the root terminal back. + * + * There is no host default. `xmd run` installs the production provider; a test + * or embedding host installs a controlled one that needs no terminal. Until one + * is installed every operation refuses, which is what keeps writing, inspecting + * and validating a document free of all of this. + * + * **Presentation never decides an outcome.** `update()` receives the pane states + * core has already settled on, so a provider draws them and answers for none of + * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** One pane the provider is asked to present, by its authored ordinal. */ +export interface TerminalPaneRequest { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** The label to display. Two panes may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this pane's work or the host's default shell + * does. A provider reads it to know which panes it must start a shell in. + */ + readonly form: "paired" | "self-closing"; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or pane identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + */ +export interface TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly panes: readonly TerminalPaneRequest[]; +} + +/** + * What core tells a provider about one pane, as it happens. + * + * A closed set, and display only. `running` follows readiness, `succeeded` and + * `failed` follow the pane's own settlement, and `closed` is a live pane + * cancelled solely because the reader closed the grid — which is not a failure + * and is deliberately spelled differently from one. + */ +export type TerminalPaneState = "starting" | "running" | "succeeded" | "failed" | "closed"; + +/** How a pane's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One prepared, still-hidden grid. + * + * Everything here belongs to the one `prepare()` that produced it. A composite + * is never reused across expansions, and a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. + */ +export interface TerminalComposite { + /** + * Show the composite. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * preparation, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Start the host's default interactive shell in one pane and report how it + * ended. + * + * Which shell that is comes from live host policy, never from the document. + * The bytes it exchanges with the reader belong to the pane: nothing captures + * or journals them. + * + * `spawned` is the pane's readiness latch, and calling it is the only thing + * that makes this pane ready. Call it from the runtime's successful + * child-spawn event and before waiting for the child to exit — so a shell + * that starts and exits at once is both ready and settled, while a shell that + * never started leaves the latch alone and the grid never attaches. + */ + shell(ordinal: number, spawned: () => void): Operation; + /** + * Settle when the reader closes or leaves the composite. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; + /** + * Take the composite down and give the root terminal back. + * + * Called exactly once for every composite `prepare()` returned, including one + * discarded before it ever attached. + */ + destroy(): Operation; +} + +export interface TerminalProviderHandler { + /** Build the whole hidden composite for `request`, presenting nothing. */ + prepare(request: TerminalGridRequest): Operation; +} + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive panes. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +/** + * The stable contextual boundary a grid request travels. + * + * Middleware composed here may observe, narrow, refuse, wrap or delegate a + * request — everything composition needs. What it cannot do is authorize one: + * the terminal authority that mints pane claims and takes terminal ownership is + * delivered directly to the installed provider and reachable from nowhere else, + * so a handler that answers without delegating has presented nothing. + */ +export const TerminalProvider: Api = createApi( + "runtime.terminalProvider", + { + // deno-lint-ignore require-yield + *prepare(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, + }, +); + +/** Build the hidden composite for one grid expansion. */ +export function prepareTerminalGrid(request: TerminalGridRequest): Operation { + return TerminalProvider.operations.prepare(request); +} + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` + * decides what a self-closing pane's shell did and how long it took, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. + */ +export interface ControlledTerminalProviderOptions { + /** Appended to as the provider works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * What a pane's shell did. + * + * It receives the readiness latch, so a suite decides whether this shell + * reports a spawn at all — which is how "never started" is told apart from + * "started and exited immediately". + */ + shell?: (ordinal: number, spawned: () => void) => Operation; + close?: () => Operation; +} + +/** + * Install a provider that presents nothing and records everything. + * + * It answers the whole contract — prepare, attach, update, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function* installControlledTerminalProvider( + options: ControlledTerminalProviderOptions = {}, +): Operation { + const log = options.log ?? { events: [] }; + let prepared = 0; + + yield* TerminalProvider.around( + { + *prepare([request]): Operation { + if (options.onPrepare) { + yield* options.onPrepare(request); + } + const generation = prepared++; + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn + // would hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken + // down more times than it was built, which is exactly the ordering + // claim a suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + }, + }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts new file mode 100644 index 000000000..59ee75e89 --- /dev/null +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -0,0 +1,271 @@ +/** + * Tier TG — the terminal provider boundary (architecture.md §Terminal + * authority, spec §6.21). + * + * What a host installs to present a grid, and what composing middleware around + * it may and may not do. Nothing here opens a terminal, looks for a + * multiplexer, or starts a process: the whole point of the boundary is that the + * language does not depend on any of that, so a suite that needed one would be + * testing the wrong thing. + * + * The controlled provider records what it was asked to do, in order. Ordering + * claims are read off that record rather than inferred from timing, because a + * grid that attached too early and a grid that attached on time can take the + * same wall clock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "../terminal.ts"; +import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; + +/** A two-by-one grid: the smallest request that still has two ordinals. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +function log(): TerminalProviderLog { + return { events: [] }; +} + +describe("Tier TG — the provider boundary", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: an installed provider prepares without presenting anything", function* () { + const record = log(); + const events = yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* prepareTerminalGrid(request()); + return [...record.events]; + }); + + // Preparation happened; nothing was shown. A composite the reader can see + // before every pane is ready is the one thing atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { + const record = log(); + const spawns: number[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.update(0, "starting"); + yield* composite.update(0, "running"); + yield* composite.shell(1, () => spawns.push(1)); + yield* composite.attach(); + yield* composite.update(0, "succeeded"); + yield* composite.closed(); + yield* composite.destroy(); + }); + + expect(record.events).toEqual([ + "prepare:0:2x1", + "state:0:0:starting", + "state:0:0:running", + "shell:0:1", + "attach:0", + "state:0:0:succeeded", + "closed:0", + "destroy:0", + ]); + // The default shell starts, and says so through the latch it was handed: + // readiness is reported by the shell rather than assumed by the grid. + expect(spawns).toEqual([1]); + }); + + it("TP5: a shell that never starts never reports a spawn", function* () { + const spawns: number[] = []; + const outcome = yield* scoped(function* () { + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *shell(_ordinal, _spawned) { + // No spawn event: nothing started, so nothing is acknowledged. + return { exitCode: 127 }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + return yield* composite.shell(1, () => spawns.push(1)); + }); + + expect(outcome).toEqual({ exitCode: 127 }); + expect(spawns).toEqual([]); + }); + + it("TP3: middleware observes a delegated request without changing it", function* () { + const record = log(); + const seen: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the provider still saw the same grid. + expect(record.events).toEqual(["prepare:0:3x2"]); + }); + + it("TP3: middleware refuses a request, and no composite is ever built", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + // deno-lint-ignore require-yield + *prepare(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + // Refusing means refusing: the provider below was never reached, so there + // is no hidden composite left needing teardown. + expect(record.events).toEqual([]); + }); + + it("TP3: middleware narrows a request before the provider sees it", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* prepareTerminalGrid(request()); + }); + + expect(record.events).toEqual(["prepare:0:1x2"]); + }); + + it("TP4: middleware wraps the composite it delegated for", function* () { + const record = log(); + const wrapped: string[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + const composite = yield* next(asked); + return { + ...composite, + *attach() { + wrapped.push("before"); + yield* composite.attach(); + wrapped.push("after"); + }, + }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.attach(); + yield* composite.destroy(); + }); + + expect(wrapped).toEqual(["before", "after"]); + expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); + }); + + it("TP5: a preparation failure leaves nothing to tear down", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ + log: record, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the composite existed, so the record shows + // no composite was built and none is owed a destroy. + expect(record.events).toEqual([]); + }); + + it("TP5: a composite refuses to be destroyed twice", function* () { + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider(); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.destroy(); + try { + yield* composite.destroy(); + } catch (error) { + refusal = error; + } + }); + + // Teardown ordering is only readable if a double destroy is loud. A silent + // second destroy would let a suite prove an ordering that never held. + expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + }); + + it("TP6: each preparation is its own composite", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const first = yield* prepareTerminalGrid(request()); + const second = yield* prepareTerminalGrid(request()); + yield* first.destroy(); + yield* second.destroy(); + }); + + // Two expansions are two grids. A provider that handed the same composite + // back would have presented the second expansion's grid as the first's. + expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + }); +}); From 259520eba3e34df0e991bf8a70119ab4933bd659 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:07:19 -0400 Subject: [PATCH 02/19] =?UTF-8?q?=E2=9C=A8=20Run=20a=20terminal=20grid's?= =?UTF-8?q?=20panes=20concurrently=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's one foreground-terminal lease, flushes root output, prepares the composite while it stays hidden, starts every pane concurrently, and attaches only once every pane has reported a spawn through its claim. Ordering is the contract. The lease and the composite are both scope-owned, so success, failure and cancellation all release the terminal and destroy exactly the composite that was prepared — there is no path that skips teardown. A pane that settles without ever reporting a spawn fails startup rather than being presented as a running pane. Before the barrier a pane failure fails the whole grid closed; after it, the failure is that pane's status and its siblings keep running. Close cancels a live pane as `closed`, which is not a failed pane, and the grid fails with the first failed pane in authored order. `display()` and an `onUpdate` hook complete the provider surface: a pane's rendered text goes to that pane, and a suite reacts to a state the grid decided rather than waiting and hoping. Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and `packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness barrier row was verified by removing the barrier: it fails without it. --- packages/core/src/terminal/grid.ts | 207 +++++++ packages/core/tests/terminal-grid.test.ts | 507 ++++++++++++++++++ packages/runtime/terminal.ts | 34 +- .../runtime/tests/terminal-provider.test.ts | 2 +- 4 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal/grid.ts create mode 100644 packages/core/tests/terminal-grid.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts new file mode 100644 index 000000000..9f712da1d --- /dev/null +++ b/packages/core/src/terminal/grid.ts @@ -0,0 +1,207 @@ +/** + * One terminal grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The composite is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier discards the hidden composite + * instead of leaving half a grid on the screen. + * + * Ordering is the contract, not an implementation detail: + * + * ``` + * lease → flush → prepare → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing here decides what a pane *is* — the layout arrived already derived, + * and the work each pane does is supplied by the caller. What this owns is + * whose terminal it is, when a pane counts as started, what happens when one + * fails, and the order in which it all comes apart. + */ + +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; + +import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; +import type { TerminalPaneClaim } from "./authority.ts"; +import type { TerminalGridLayout } from "../terminal-grid.ts"; + +/** How one pane ended. */ +export type PaneOutcome = + | { readonly kind: "succeeded" } + | { readonly kind: "failed"; readonly error: Error } + /** Live when the reader closed the grid. Cancellation, not failure. */ + | { readonly kind: "closed" }; + +/** + * What one pane does once its claim exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both run as the pane's admitted owner, and both are expected + * to report a spawn through the claim before anything can attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; +} + +/** Everything the grid settled, in authored pane order. */ +export interface GridResult { + readonly outcomes: readonly PaneOutcome[]; + /** Why the grid failed, which is the first failed pane in authored order. */ + readonly failure?: Error; +} + +/** + * What a pane that never reported a spawn says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +class PaneStartupError extends Error { + override name = "PaneStartupError"; + readonly ordinal: number; + constructor(ordinal: number, message: string) { + super(message); + this.ordinal = ordinal; + } +} + +/** + * Run one grid to completion and report what its panes settled to. + * + * The foreground lease and the composite are both scope-owned, so every path + * out of here — success, failure, and cancellation alike — releases the + * terminal and destroys exactly the composite that was prepared. That is why + * teardown is not written as a step: there is no path that can skip it. + */ +export function runTerminalGrid( + layout: TerminalGridLayout, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + const request = toRequest(layout); + + // The one foreground-terminal lease. A root and a grid + // contend for exactly this, so neither can begin while the other holds it, + // and a host with no terminal refuses here — before any pane has done work. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + const composite = yield* prepareTerminalGrid(request); + // Registered before a single pane starts: a composite that was prepared is + // owed a destroy even if the next line is what fails. + yield* ensure(() => composite.destroy()); + + const grid = createTerminalGridClaims(request); + // Nothing new is admitted once teardown begins, so a pane that was about to + // start an interactive child is refused rather than racing the close. + yield* ensure(() => { + grid.seal(); + }); + + const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + let attached = false; + + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { + const claim = grid.claims[index]!; + const readiness = grid.readiness[index]!; + yield* composite.update(pane.ordinal, "starting"); + panes.push( + yield* spawn(function* () { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: that is a startup failure even + // though the work itself raised nothing. + throw new PaneStartupError( + pane.ordinal, + paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + ); + } + outcomes[index] = { kind: "succeeded" }; + yield* composite.update(pane.ordinal, "succeeded"); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + outcomes[index] = { kind: "failed", error: failure }; + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status and its siblings + // keep running. + if (!attached) { + startupFailed.reject(failure); + return; + } + yield* composite.update(pane.ordinal, "failed"); + } + }), + ); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for a latch nothing will acknowledge. + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + + for (const pane of work) { + yield* composite.update(pane.ordinal, "running"); + } + yield* composite.attach(); + attached = true; + + // The composite stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* composite.closed(); + + // Close prevents new work first, then takes the live panes down: a pane + // cancelled by the close is `closed`, which is not a failed pane. + grid.seal(); + for (const [index, task] of panes.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(work[index]!.ordinal, "closed"); + outcomes[index] = { kind: "closed" }; + } + yield* task.halt(); + } + + const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); + const failed = settled.find((outcome) => outcome.kind === "failed"); + return { + outcomes: settled, + ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + }; + }); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return { + columns: layout.columns, + rows: layout.rows, + panes: layout.cells.map((cell) => ({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + })), + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts new file mode 100644 index 000000000..19d9e4d73 --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,507 @@ +/** + * Tier TG — running a terminal grid through a replaceable provider + * (spec §6.21, architecture.md §Atomic presentation and settlement). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because something called the latch it was + * handed, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { + installControlledLauncher, + installControlledTerminalProvider, +} from "@executablemd/runtime"; +import type { TerminalProviderLog } from "@executablemd/runtime"; + +import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; +import { runTerminalGrid } from "../src/terminal/grid.ts"; +import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; +import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { TerminalGridLayout } from "../src/terminal-grid.ts"; + +function log(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + +/** A layout of `count` panes across `columns`, titled by ordinal. */ +function layoutOf(columns: number, count: number): TerminalGridLayout { + return terminalGridLayout( + columns, + Array.from({ length: count }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ); +} + +/** A pane that starts, does what `body` says, and settles. */ +function pane(ordinal: number, body?: () => Operation): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + if (body) { + yield* body(); + } + }); + }, + }; +} + +/** Everything a grid run needs installed, with the reader's close under control. */ +function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { + // A grid takes the same one foreground lease a root takes, + // so a host that offers a grid still has to offer that lease. + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ log: record, close }); +} + +/** A pane that records when it started, so ordering is read rather than timed. */ +function readyPane(ordinal: number, timeline: string[]): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + timeline.push(`ready:${ordinal}`); + claim.ready(); + yield* suspend(); + }); + }, + }; +} + +/** Close as soon as the reader is asked, which is the ordinary journey. */ +function immediateClose(): () => Operation { + // deno-lint-ignore require-yield + return function* () {}; +} + +describe("Tier TG — pane claims and readiness", () => { + it("TG8: a claim admits one interactive operation at a time", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; + + yield* scoped(function* () { + yield* first.admit(function* () { + // A second operation on the same pane is refused while this one is live. + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + // A different pane does not contend at all, which is the whole reason a + // grid exists. + yield* second.admit(function* () { + concurrent = true; + }); + }); + }); + + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); + }); + + it("TG8: a pane admits again once its first operation has settled", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let second = false; + + yield* scoped(function* () { + yield* claim.admit(function* () {}); + yield* claim.admit(function* () { + second = true; + }); + }); + + // Sequential work in one pane is ordinary composition, not contention. + expect(second).toBe(true); + }); + + it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + grid.seal(); + let refusal: unknown; + + yield* scoped(function* () { + try { + yield* claim.admit(function* () {}); + } catch (error) { + refusal = error; + } + }); + + // A claim kept past its grid is a claim to a terminal nobody owns. + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + }); + + it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; + + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TG8: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); + }); +}); + +describe("Tier TG — atomic startup", () => { + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const record = log(); + // One ordered record both the panes and the provider write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that would prove nothing — a pane says when it actually started. + const timeline: string[] = []; + const slow = withResolvers(); + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: immediateClose(), + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + }); + return yield* runTerminalGrid(layoutOf(2, 3), [ + readyPane(0, timeline), + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + // Plenty of work before anything starts, and none of it makes the + // grid attachable. The delay is long enough that a grid which + // skipped the barrier would demonstrably attach first. + yield* sleep(25); + timeline.push("ready:1"); + claim.ready(); + yield* slow.operation; + }); + }, + }, + readyPane(2, timeline), + ]); + }); + + expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); + expect(result.failure).toBeUndefined(); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const record = log(); + let failure: unknown; + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0), + { + ordinal: 1, + // Runs, settles, and never reports a spawn. + *run() {}, + }, + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "finished without starting anything interactive", + ); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(record.events).not.toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0, function* () { + started.push(0); + }), + pane(1, function* () { + started.push(1); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toBe( + "no pane endpoint could be created", + ); + expect(started).toEqual([]); + }); + + it("TG9: a grid refuses before preparation when no provider is installed", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + try { + yield* runTerminalGrid(layoutOf(1, 1), [ + pane(0, function* () { + started.push(0); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "no terminal provider is installed", + ); + expect(started).toEqual([]); + }); +}); + +describe("Tier TG — settlement and close", () => { + it("TG10: a pane fails after attach while its siblings stay live", function* () { + const record = log(); + // The reader leaves once the grid has displayed the failure, so the sibling + // is provably still live when that happens rather than probably still live. + const failed = withResolvers(); + let siblingLiveAtFailure = false; + let siblingLive = false; + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: () => failed.operation, + onUpdate(ordinal, state) { + if (ordinal === 0 && state === "failed") { + siblingLiveAtFailure = siblingLive; + failed.resolve(); + } + }, + }); + return yield* runTerminalGrid(layoutOf(2, 2), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* sleep(1); + throw new Error("pane 0 stopped"); + }); + }, + }, + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + siblingLive = true; + try { + yield* suspend(); + } finally { + siblingLive = false; + } + }); + }, + }, + ]); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("state:0:0:failed"); + // The sibling was still running when its neighbour failed: an ordinary pane + // failure after attach is contained as that pane's status. + expect(siblingLiveAtFailure).toBe(true); + expect(result.outcomes[0]?.kind).toBe("failed"); + expect(result.outcomes[1]?.kind).toBe("closed"); + // The grid fails with the first failed pane in authored order. + expect(result.failure?.message).toBe("pane 0 stopped"); + }); + + it("TG12: close cancels a live pane as closed rather than failed", function* () { + const record = log(); + + const result = yield* scoped(function* (): Operation { + yield* useGridHost(record, immediateClose()); + return yield* runTerminalGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + // Still live when the reader leaves. + yield* suspend(); + }); + }, + }, + ]); + }); + + // Teardown cancellation is not a pane failure, and the grid succeeds. + expect(result.outcomes[0]?.kind).toBe("closed"); + expect(result.failure).toBeUndefined(); + expect(record.events).toContain("state:0:0:closed"); + }); + + it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + }); + + const closed = record.events.indexOf("closed:0"); + const destroyed = record.events.indexOf("destroy:0"); + expect(closed).toBeGreaterThan(-1); + expect(destroyed).toBeGreaterThan(closed); + expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); + }); + + it("TG13: parent cancellation tears the grid down completely", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, () => suspend()); + // The grid never closes on its own; the enclosing scope ending is what + // takes it down, and that has to be a complete teardown. + yield* scoped(function* () { + yield* spawnGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* suspend(); + }); + }, + }, + ]); + yield* sleep(2); + }); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — the pane seam", () => { + it("TG6: work inside a pane runs as that pane's owner", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let sawOrdinal: number | undefined; + let acknowledged = false; + + yield* scoped(function* () { + yield* usePaneTerminal(claim); + const seam = yield* paneTerminal(); + sawOrdinal = seam?.ordinal; + yield* seam!.interactive(function* (spawned) { + spawned(); + acknowledged = grid.readiness[0]!.acknowledged; + }); + }); + + expect(sawOrdinal).toBe(0); + // The seam is how anything interactive reports its spawn, so readiness + // travels with the work rather than being asserted around it. + expect(acknowledged).toBe(true); + }); + + it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { + const seam = yield* scoped(function* () { + return yield* paneTerminal(); + }); + expect(seam).toBeUndefined(); + }); +}); + +/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ +function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { + yield* spawn(function* () { + yield* runTerminalGrid(layout, work); + }); +} diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 1fac60e3d..cc34203c0 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -102,6 +102,17 @@ export interface TerminalComposite { * change one. */ update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Show text a pane's own content rendered. + * + * This is where a paired pane's output goes, and the only place it goes: it + * is never copied into the root document output or into a capture written + * around the grid, because the reader is looking at the pane. Terminal bytes + * an interactive child exchanges with the reader never come through here at + * all — those belong to the pane's terminal and are neither captured nor + * journaled. + */ + display(ordinal: number, text: string): Operation; /** * Start the host's default interactive shell in one pane and report how it * ended. @@ -183,6 +194,13 @@ export function prepareTerminalGrid(request: TerminalGridRequest): Operation; } /** @@ -200,6 +218,13 @@ export interface ControlledTerminalProviderOptions { onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting a while and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; /** * What a pane's shell did. * @@ -221,7 +246,8 @@ export interface ControlledTerminalProviderOptions { export function* installControlledTerminalProvider( options: ControlledTerminalProviderOptions = {}, ): Operation { - const log = options.log ?? { events: [] }; + const log = options.log ?? { events: [], shown: new Map() }; + const shown = log.shown; let prepared = 0; yield* TerminalProvider.around( @@ -243,6 +269,12 @@ export function* installControlledTerminalProvider( // deno-lint-ignore require-yield *update(ordinal, state) { log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + const pane = shown.get(ordinal) ?? ""; + shown.set(ordinal, pane + text); }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 59ee75e89..9e01204a7 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -42,7 +42,7 @@ function request(overrides: Partial = {}): TerminalGridRequ } function log(): TerminalProviderLog { - return { events: [] }; + return { events: [], shown: new Map() }; } describe("Tier TG — the provider boundary", () => { From 9bfbf18e7d75a479050c85ce56aee1129e529cdd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:39:27 -0400 Subject: [PATCH 03/19] =?UTF-8?q?=E2=9C=A8=20Run=20a=20document's=20termin?= =?UTF-8?q?al=20grid=20panes=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` now executes. Each authored pane becomes a concurrent child of the grid: a self-closing pane runs the host's default shell through its claim, and a paired pane expands its own content in a scope of its own. A pane inherits the bindings, providers, configuration and working directory visible where the grid was written, and keeps everything it creates afterwards. Its `` has no loop to exit, its `` has no enclosing value body to claim, and its checked failures settle the pane rather than reaching the root or a sibling. A pane's rendered text is displayed in that pane; the grid itself renders `""`, so the root output holds what surrounds the grid and no pane display at all. #729's five execution-dependent rows move here, where they assert the layout against the request the provider actually receives rather than reading it off a refusal's cause — the structural suite keeps the grammar, placement and pure-layout rows it owns. Moving them was authorized rather than assumed. Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in `packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core (350) and runtime (14) suites pass. --- packages/core/src/expand.ts | 155 ++++++-- .../tests/terminal-grid-structure.test.ts | 125 ------- packages/core/tests/terminal-grid.test.ts | 344 +++++++++++++++++- 3 files changed, 468 insertions(+), 156 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 933560f28..cdf65fde8 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,6 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; +import { runTerminalGrid } from "./terminal/grid.ts"; +import type { PaneWork } from "./terminal/grid.ts"; +import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, asExpressionViolation, @@ -143,7 +146,7 @@ import { import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; -import { liveEnvironment } from "./live-env.ts"; +import { derivedEnvironment, liveEnvironment } from "./live-env.ts"; import { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { TestHarnessBinding } from "./test-harness.ts"; @@ -1185,7 +1188,15 @@ function* expandListSegments( if (segment.name === "Terminal.Grid") { // No raise() here, like the branches above: expandTerminalGrid // reports every error it creates. - yield* expandTerminalGrid(segment, result); + yield* expandTerminalGrid(segment, result, { + parentMeta, + parentProps, + hideSet, + counter, + path: elementPath, + checkedFailures, + authority, + }); break; } @@ -2106,7 +2117,22 @@ function* resolveStructuralProp( * does, which is what makes the refusal a closed one rather than a partial grid * left behind. */ -function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { +/** Everything a pane's own content needs to expand where the grid was written. */ +interface GridSite { + readonly parentMeta: Record; + readonly parentProps: Record; + readonly hideSet: Set; + readonly counter: BlockCounter; + readonly path: string; + readonly checkedFailures: CheckedFailures | undefined; + readonly authority: ExpansionAuthority | undefined; +} + +function* expandTerminalGrid( + segment: ComponentElement, + owner: Segment[], + site: GridSite, +): Operation { const structure = terminalGridStructure(segment); if (structure.violations.length > 0) { for (const violation of structure.violations) { @@ -2141,23 +2167,105 @@ function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Opera } const layout = terminalGridLayout(columns.value, placed); - owner.push( - yield* raise({ - type: "error", - message: positioned(noTerminalProviderMessage(), segment), - source: "Terminal.Grid", - // The grid the author asked for, carried beside the sentence so an - // assertion is about the layout that was derived rather than about the - // wording of a refusal. - cause: { - layout: { - columns: layout.columns, - rows: layout.rows, - cells: layout.cells.map((cell) => ({ ...cell })), - }, - }, - }), + // The grid renders nothing into the document: what a pane shows belongs to + // that pane, and the sibling after `` renders to the root + // again only once the provider has restored it. + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site, segment), ); + + try { + const result = yield* runTerminalGrid(layout, work); + if (result.failure !== undefined) { + owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + } + } catch (error) { + owner.push( + yield* raise( + terminalGridError(segment, error instanceof Error ? error.message : String(error)), + ), + ); + } +} + +/** + * What one authored pane does once the grid has minted its claim. + * + * A self-closing pane runs the host's default shell through its claim. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork( + pane: TerminalPane, + title: string, + site: GridSite, + grid: ComponentElement, +): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(claim, composite) { + const outcome = yield* claim.admit(() => + composite.shell(pane.ordinal, () => claim.ready()), + ); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + }, + }; + } + + return { + ordinal: pane.ordinal, + *run(claim, composite) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(claim); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this pane makes is visible to later work in this pane and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + const shown: Segment[] = []; + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + site.counter, + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The pane's own ledger: a checked failure settles this pane and + // cannot reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a pane cannot claim + // one outside the grid. + undefined, + ); + const text = renderSegments(shown); + if (text.length > 0) { + yield* composite.display(pane.ordinal, text); + } + }); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2172,15 +2280,6 @@ function* resolvePaneTitle(pane: TerminalPane): Operation> { return terminalTitle(value.value); } -/** What a complete grid says on a host where nothing can open one. */ -function noTerminalProviderMessage(): string { - return ( - "no terminal provider opened this grid. A host installs the terminal-grid capability " + - "explicitly, and this one installs none, so no pane expanded its content and no default " + - "shell started." - ); -} - function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 76c440cba..626cbf34b 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -130,31 +130,6 @@ const PANE_BODY = [ ].join("\n"); describe("Tier TG — the grid grammar", () => { - it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { - const run = yield* runGrid( - [ - "", - 'Instructions.', - '', - "", - ].join("\n"), - ); - - // The grammar accepted it, so the run reached the one thing this build - // cannot do — and stopped there. - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, - ], - }, - }); - }); - it("TG1: refuses an unknown prop and `as` on the grid", function* () { const unknown = yield* runGrid( '', @@ -330,52 +305,6 @@ describe("Tier TG — structural placement", () => { reachedNothing(alone); reachedNothing(buried); }); - - it("TG2: treats whitespace between panes as nothing at all", function* () { - const run = yield* runGrid( - [ - "", - "", - ' ', - "", - ' ', - "", - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, - ], - }, - }); - }); - - it("TG2: a complete grid refuses before any pane body or default shell", function* () { - const run = yield* runGrid( - [ - "", - '', - "", - PANE_BODY, - "", - '', - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); - // The pane held a component and a command; neither was reached, and the - // grid rendered nothing of its own. - reachedNothing(run); - expect(run.output).toContain("no terminal provider opened this grid"); - }); }); describe("Tier TG — row-major layout", () => { @@ -446,60 +375,6 @@ describe("Tier TG — row-major layout", () => { 1, 1, 1, 2, 2, ]); }); - - it("TG4: an executed grid derives those same positions", function* () { - const run = yield* runGrid( - [ - "", - '', - '', - '', - '', - '', - "", - ].join("\n"), - ); - - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 3, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, - { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, - { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, - ], - }, - }); - }); - - it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { - const run = yield* runGrid( - [ - "", - 'first', - '', - 'third', - "", - ].join("\n"), - ); - - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each one was written in travels with it. - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 2, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, - ], - }, - }); - }); }); /** Panes that differ only in count, for a row about rows. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 19d9e4d73..b360fa3fb 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -16,13 +16,23 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; -import type { Operation } from "effection"; +import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; import { installControlledLauncher, installControlledTerminalProvider, } from "@executablemd/runtime"; -import type { TerminalProviderLog } from "@executablemd/runtime"; +import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; + +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { Json } from "../src/types.ts"; import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; import { runTerminalGrid } from "../src/terminal/grid.ts"; @@ -505,3 +515,331 @@ function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Oper yield* runTerminalGrid(layout, work); }); } + +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; +} + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** + * The controlled interactive child, and a tripwire. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, this is what a suite writes to be that + * something — and it reaches the pane through the same seam a real launch will. + */ +function useGridComponents(ran: string[]): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + ]); +} + +/** + * Run one document against a controlled grid host. + * + * `provider: false` installs no terminal provider, which is how "a host that + * cannot open a grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const record = log(); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + if (options.provider !== false) { + yield* installControlledTerminalProvider({ + log: record, + close: () => settled.operation, + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + yield* sleep(0); + }, + onUpdate(_ordinal, state) { + if (state === "succeeded" || state === "failed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, output, requests, shown: record.shown, ran }; + }); +} + +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each was written in travels with it. + expect(run.requests[0]?.panes).toEqual([ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + "", + 'Instructions.', + "", + '', + "", + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + }); + }); + + it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Each pane's own text went to that pane. + expect(run.shown.get(0)).toContain("left text"); + expect(run.shown.get(1)).toContain("right text"); + // The grid renders "": the root output holds what surrounds it and no pane + // display at all. + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("left text"); + expect(run.output).not.toContain("right text"); + }); + + it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + '', + "", + "", + '', + "sees {shared}", + "", + '', + "", + "then {mine}", + "", + "", + "", + '', + "sees {shared} and {mine}", + "", + "", + "", + "", + "", + "after {mine}", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Inherited from the grid site. + expect(run.shown.get(0)).toContain("sees site"); + expect(run.shown.get(1)).toContain("sees site"); + // Created inside one pane, visible to later work in that pane. + expect(run.shown.get(0)).toContain("then left"); + // Invisible to the sibling and to the document after the grid: an + // unresolved binding stays the literal text it was written as. + expect(run.shown.get(1)).toContain("and {mine}"); + expect(run.output).toContain("after {mine}"); + }); + + it("TG6: a pane's cannot reach a loop outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + ); + + // Refused where it was written. Had the reached the loop around the + // grid it would have exited it quietly and the document would have + // succeeded; instead the pane failed with the stray- rule, which is + // what fails the grid and then the document. + expect(failureOf(run)).toContain(" must be written inside a "); + expect(failureOf(run)).toContain("cannot break the loop that invoked it"); + expect(run.ran).toEqual(["iteration"]); + }); + + it("TG9: with no provider installed, no pane body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no terminal provider is installed"); + // The pane held work; none of it was reached, and nothing was displayed. + expect(run.ran).toEqual([]); + expect(run.shown.size).toBe(0); + }); +}); From 75bdb27d0917eb36082de5e084e48ef43b8cfe91 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:51:58 -0400 Subject: [PATCH 04/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Drop=20an=20unused?= =?UTF-8?q?=20parameter=20from=20the=20grid's=20pane=20work=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paneWork()` never read the grid element it was handed. Its caller has it, and a pane's own diagnostics are positioned at the pane. --- packages/core/src/expand.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index cdf65fde8..6e0f7f53c 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2170,11 +2170,10 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site, segment), - ); - try { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); const result = yield* runTerminalGrid(layout, work); if (result.failure !== undefined) { owner.push(yield* raise(terminalGridError(segment, result.failure.message))); @@ -2199,12 +2198,7 @@ function* expandTerminalGrid( * enclosing body, and a checked failure settles the pane rather than poisoning * the root or a sibling. */ -function paneWork( - pane: TerminalPane, - title: string, - site: GridSite, - grid: ComponentElement, -): PaneWork { +function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, From 08c275f705f605d1bf49191f3c07dc780d341e75 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:19:22 -0400 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20Give=20terminal=20grids=20an?= =?UTF-8?q?=20authority=20boundary=20and=20durable=20pane=20children=20(#7?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the authority boundary on the `AgentProviders` handshake, and puts each pane on its own durable child coroutine. **The boundary.** `TerminalGrids` is routing and only routing: `open()` answers `unknown` and core throws the answer away, so middleware may observe, narrow, refuse, wrap or delegate but can never authorize. The capability that takes the leases, mints pane claims and settles a grid is a non-contextual authority delivered straight to the registered provider through a one-use install handshake. Core mints one identity-bearing request per expansion; presenting a copy, a rebuilt lookalike, a changed request, an already-presented one, or one from a superseded installation generation authorizes nothing, and a handler that answers without presenting settles nothing. **Durable children.** Each pane is a durable child of the grid, allocated in authored order, so a pane's identity follows its ordinal rather than the order the runtime scheduled it in. The layout is recorded in the parent coroutine before the lease and before any provider is contacted. **Ordering.** A pane that settles before attach keeps the status it settled to instead of being overwritten with `running`, and simultaneous startup failures are selected by authored ordinal rather than by whichever rejected first. Each pane also expands under a counter of its own, so two concurrent panes cannot take block identities that depend on which ran first. `durableSpawn` could not be used: the task it returns is spawned inside the ephemeral effect's own scope, which closes as the effect resolves, so awaiting it throws `halted`. It has no call sites or tests upstream. `durableAll` is the exercised primitive and is what the panes and the grid child use. Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in `packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15. --- packages/core/mod.ts | 30 + packages/core/src/expand.ts | 37 +- packages/core/src/terminal/authority.ts | 138 +- packages/core/src/terminal/grid.ts | 397 +++-- packages/core/src/terminal/journal.ts | 140 ++ packages/core/src/terminal/profile.ts | 60 + packages/core/src/terminal/provider-api.ts | 271 +++ packages/core/tests/terminal-grid.test.ts | 1452 ++++++++++------- packages/runtime/mod.ts | 11 +- packages/runtime/terminal.ts | 245 ++- .../runtime/tests/terminal-provider.test.ts | 290 ++-- 11 files changed, 2105 insertions(+), 966 deletions(-) create mode 100644 packages/core/src/terminal/journal.ts create mode 100644 packages/core/src/terminal/profile.ts create mode 100644 packages/core/src/terminal/provider-api.ts diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..568ae1e19 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,36 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; +export { + createTerminalAuthority, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/terminal/authority.ts"; +export type { + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalPaneClaim, +} from "./src/terminal/authority.ts"; +export { + installTerminalProvider, + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/terminal/provider-api.ts"; +export type { + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/terminal/provider-api.ts"; +export { installTerminalGridProfile } from "./src/terminal/profile.ts"; +export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; +export { paneTerminal } from "./src/terminal/pane.ts"; +export type { PaneTerminal } from "./src/terminal/pane.ts"; +export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 6e0f7f53c..ae0899dde 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,8 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; -import { runTerminalGrid } from "./terminal/grid.ts"; +import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; import type { PaneWork } from "./terminal/grid.ts"; +import { recordGridLayout } from "./terminal/journal.ts"; import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, @@ -1192,7 +1193,6 @@ function* expandListSegments( parentMeta, parentProps, hideSet, - counter, path: elementPath, checkedFailures, authority, @@ -2122,7 +2122,6 @@ interface GridSite { readonly parentMeta: Record; readonly parentProps: Record; readonly hideSet: Set; - readonly counter: BlockCounter; readonly path: string; readonly checkedFailures: CheckedFailures | undefined; readonly authority: ExpansionAuthority | undefined; @@ -2170,13 +2169,28 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. + const identity = { + path: site.path, + ...(segment.position === undefined ? {} : { position: segment.position }), + }; + try { - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site), - ); - const result = yield* runTerminalGrid(layout, work); - if (result.failure !== undefined) { - owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + // Recorded in this coroutine, before the lease and before any provider is + // contacted: a resumed run whose grid changed is refused while nothing has + // been opened. It cannot live inside the grid child, because a completed + // child never runs. + yield* recordGridLayout(identity, toRequest(layout)); + + const retained = yield* durableGrid(function* () { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openTerminalGrid(layout, work); + }); + + const failed = retained.panes.find((pane) => pane.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(terminalGridError(segment, failed.reason))); } } catch (error) { owner.push( @@ -2238,7 +2252,10 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { site.parentMeta, site.parentProps, site.hideSet, - site.counter, + // A counter of its own. Panes expand concurrently, and a shared + // mutable counter would hand two of them block identities that depend + // on which happened to run first. + createBlockCounter(), shown, extendPath( site.path, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index df4feffab..64e11fce3 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -4,11 +4,12 @@ * * The provider draws a grid. This decides everything about it that matters: * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, and when a - * pane has actually started. None of that is reachable by name. There is no - * context holding an authority, no member of a request that carries one, and no - * handler return value that produces one — an authority reachable by name would - * be an authority every same-name context and every loaded copy could reach. + * ordinals exist, whether an interactive operation may start on one, when a + * pane has actually started, and what the grid settled to. None of that is + * reachable by name. There is no context holding an authority, no member of a + * request that carries one, and no handler return value that produces one — an + * authority reachable by name would be an authority every same-name context and + * every loaded copy could reach. * * A claim is the unforgeable carrier. It is minted here for one ordinal of one * request under one installation generation, and a claim from another grid, @@ -18,9 +19,9 @@ * session coordinator's to answer and stays independently authoritative. */ -import { all, ensure, withResolvers } from "effection"; -import type { Operation } from "effection"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import { all, createContext, ensure, withResolvers } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; @@ -40,8 +41,8 @@ export interface TerminalPaneClaim { * Run one interactive operation as this pane's owner. * * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has finished — a claim kept past its expansion is a claim - * to a terminal nobody owns any more. + * minted the claim has stopped admitting work — a claim kept past its + * expansion is a claim to a terminal nobody owns any more. */ admit(body: () => Operation): Operation; /** @@ -77,6 +78,123 @@ export interface TerminalGridClaims { seal(): void; } +/** + * What a registered provider must present in order to act. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else. Presenting the exact request core issued is what takes the + * terminal leases, mints the pane claims, and runs the grid; anything else — + * a copy, a rebuilt lookalike, an earlier grid's request, a request already + * presented, or one belonging to a superseded installation — authorizes + * nothing. + */ +export interface TerminalGridAuthority { + present(request: TerminalGridRequest, composite: TerminalComposite): Operation; +} + +/** One grid this execution issued, from the authority's side. */ +export interface LiveGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Run the grid on a presented composite, and keep what it settled to. */ + run(composite: TerminalComposite): Operation; + /** Whether this request has already been presented. */ + used: boolean; + /** Whether the grid actually ran to a settlement. */ + settled: boolean; +} + +/** Every grid this execution has issued and not yet finished. */ +export interface GridRegistry { + live(): readonly LiveGrid[]; + add(grid: LiveGrid): void; + remove(grid: LiveGrid): void; +} + +export function createGridRegistry(): GridRegistry { + const grids = new Set(); + return { + live: () => [...grids], + add: (grid) => { + grids.add(grid); + }, + remove: (grid) => { + grids.delete(grid); + }, + }; +} + +/** + * Build the authority one provider installation is given. + * + * It closes over the installation's generation and its registry, so a factory + * that kept an authority from a superseded installation presents into a + * generation that no longer has the grid it names. + */ +export function createTerminalAuthority( + generation: object, + live: () => readonly LiveGrid[], +): TerminalGridAuthority { + return { + *present(request, composite) { + const grid = live().find((candidate) => Object.is(candidate.request, request)); + if (grid === undefined) { + throw new TerminalAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(grid.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (grid.used) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + grid.used = true; + yield* grid.run(composite); + }, + }; +} + +/** One execution's terminal installation: its registry and its generation. */ +export interface TerminalInstallation { + readonly registry: GridRegistry; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + TerminalInstallation | undefined +>("core.terminal.installation", undefined); + +/** + * Open one terminal installation for a live document, and hand back the + * authority its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The authority does + * not: it is handed to a provider factory directly. A replaced installation + * therefore produces requests the real authority has never heard of, which is a + * refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + const registry = createGridRegistry(); + const generation = {}; + yield* Installation.set({ registry, generation }); + return createTerminalAuthority(generation, () => registry.live()); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} + /** * Mint the claims for one grid expansion. * diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 9f712da1d..3892a43c7 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -1,6 +1,6 @@ /** * One terminal grid, from the lease to the last finalizer (spec §6.21, - * architecture.md §Atomic presentation and settlement). + * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape * of this module. The composite is built while it is still hidden, every pane @@ -8,34 +8,70 @@ * anything appear. A failure before that barrier discards the hidden composite * instead of leaving half a grid on the screen. * - * Ordering is the contract, not an implementation detail: - * * ``` - * lease → flush → prepare → panes start → readiness barrier → attach - * → panes settle independently → reader closes → teardown → lease released + * layout recorded → lease → flush → routed to a provider → composite presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released * ``` * - * Nothing here decides what a pane *is* — the layout arrived already derived, - * and the work each pane does is supplied by the caller. What this owns is - * whose terminal it is, when a pane counts as started, what happens when one - * fails, and the order in which it all comes apart. + * Each pane is a **durable child coroutine** of the grid, allocated in authored + * order. That is not decoration: a completed child short-circuits on replay by + * returning its retained result without running, and claiming a completed + * parent claims every descendant history beneath it. Wrapping the region in one + * durable operation instead would leave the panes' entries unconsumed and + * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; -import type { TerminalPaneClaim } from "./authority.ts"; +import { + awaitReadiness, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, +} from "./authority.ts"; +import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; -/** How one pane ended. */ -export type PaneOutcome = - | { readonly kind: "succeeded" } - | { readonly kind: "failed"; readonly error: Error } - /** Live when the reader closed the grid. Cancellation, not failure. */ - | { readonly kind: "closed" }; +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} /** * What one pane does once its claim exists. @@ -50,13 +86,6 @@ export interface PaneWork { run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; } -/** Everything the grid settled, in authored pane order. */ -export interface GridResult { - readonly outcomes: readonly PaneOutcome[]; - /** Why the grid failed, which is the first failed pane in authored order. */ - readonly failure?: Error; -} - /** * What a pane that never reported a spawn says. * @@ -72,40 +101,117 @@ export function paneNeverStartedMessage(ordinal: number, title: string): string ); } -class PaneStartupError extends Error { - override name = "PaneStartupError"; - readonly ordinal: number; - constructor(ordinal: number, message: string) { - super(message); - this.ordinal = ordinal; - } +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + panes: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} + +/** The retained shape of one request. */ +export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layout"] { + return { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ + ordinal: pane.ordinal, + title: pane.title, + form: pane.form, + row: pane.row, + column: pane.column, + })), + }; } /** - * Run one grid to completion and report what its panes settled to. + * Open one grid and report what it settled to. * - * The foreground lease and the composite are both scope-owned, so every path - * out of here — success, failure, and cancellation alike — releases the - * terminal and destroys exactly the composite that was prepared. That is why - * teardown is not written as a step: there is no path that can skip it. + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * authority settled. The routed answer is discarded on purpose: a handler that + * short-circuits or fabricates a return has presented nothing, and this says so + * rather than letting the document believe a grid opened. */ -export function runTerminalGrid( +export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], -): Operation { - return scoped(function* (): Operation { +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalAuthorityError( + "a terminal grid is available only inside a document execution with an installed " + + "terminal provider — a grid outside one retains nothing and could not be resumed", + ); + } + const request = toRequest(layout); + let settled: RetainedGrid | undefined; + + const grid: LiveGrid = { + request, + generation: installation.generation, + used: false, + settled: false, + *run(composite) { + settled = yield* presentGrid(request, composite, work); + grid.settled = true; + }, + }; + installation.registry.add(grid); + yield* ensure(() => { + installation.registry.remove(grid); + }); - // The one foreground-terminal lease. A root and a grid - // contend for exactly this, so neither can begin while the other holds it, - // and a host with no terminal refuses here — before any pane has done work. + // The one foreground-terminal lease, taken before any provider is asked for + // anything. A root and a grid contend for exactly this, so + // neither can begin while the other holds it. yield* reserveTerminal(); // Everything the document has produced so far reaches the reader before the // grid covers it up. yield* flushOutput(); - const composite = yield* prepareTerminalGrid(request); - // Registered before a single pane starts: a composite that was prepared is + // Routed, and the answer thrown away. + yield* TerminalGrids.operations.open(request); + + if (!grid.settled || settled === undefined) { + throw new TerminalAuthorityError( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid on the composite a provider presented. + * + * The composite is scope-owned, so every path out of here — success, failure, + * and cancellation alike — destroys exactly the composite that was presented. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function presentGrid( + request: TerminalGridRequest, + composite: TerminalComposite, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + // Registered before a single pane starts: a composite that was presented is // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); @@ -116,53 +222,54 @@ export function runTerminalGrid( grid.seal(); }); - const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); let attached = false; - const panes: Task[] = []; - for (const [index, pane] of work.entries()) { + // Every pane's work, in authored order. The children are allocated in this + // order too, so a pane's durable identity follows its ordinal rather than + // the order the runtime happened to schedule it in. + const paneWorkflows = work.map((pane, index) => { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; + return function* (): Operation { + const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + outcomes[index] = outcome; + yield* composite.update(pane.ordinal, outcome.status); + if (outcome.status === "failed" && !attached) { + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + return outcome; + }; + }); + + for (const pane of work) { yield* composite.update(pane.ordinal, "starting"); - panes.push( - yield* spawn(function* () { - try { - yield* pane.run(claim, composite); - if (!readiness.acknowledged) { - // Settled without ever starting: that is a startup failure even - // though the work itself raised nothing. - throw new PaneStartupError( - pane.ordinal, - paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), - ); - } - outcomes[index] = { kind: "succeeded" }; - yield* composite.update(pane.ordinal, "succeeded"); - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - outcomes[index] = { kind: "failed", error: failure }; - // Before the barrier a pane failure is the whole grid's: nothing has - // been shown, so the grid fails closed rather than attaching what is - // left. After it, the failure is this pane's status and its siblings - // keep running. - if (!attached) { - startupFailed.reject(failure); - return; - } - yield* composite.update(pane.ordinal, "failed"); - } - }), - ); } + // Spawned as one task so the coordinator below can reach the readiness + // barrier, attach, and wait for the reader while the panes are still live. + const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + try { + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a terminal grid pane failed to start"); + } - for (const pane of work) { - yield* composite.update(pane.ordinal, "running"); + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "running"); + } } yield* composite.attach(); attached = true; @@ -172,36 +279,118 @@ export function runTerminalGrid( yield* composite.closed(); // Close prevents new work first, then takes the live panes down: a pane - // cancelled by the close is `closed`, which is not a failed pane. + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the composite is destroyed, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. grid.seal(); - for (const [index, task] of panes.entries()) { + for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(work[index]!.ordinal, "closed"); - outcomes[index] = { kind: "closed" }; + yield* composite.update(pane.ordinal, "closed"); + outcomes[index] = { status: "closed", reason: "" }; } - yield* task.halt(); } + yield* panes.halt(); - const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); - const failed = settled.find((outcome) => outcome.kind === "failed"); + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); return { - outcomes: settled, - ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: settled, }; }); } -/** The provider-neutral request one derived layout asks for. */ -export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { - return { - columns: layout.columns, - rows: layout.rows, - panes: layout.cells.map((cell) => ({ - ordinal: cell.ordinal, - title: cell.title, - row: cell.row, - column: cell.column, - form: cell.form, - })), - }; +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + claim: TerminalPaneClaim, + composite: TerminalComposite, + readiness: { readonly acknowledged: boolean }, + request: TerminalGridRequest, + index: number, +): Operation { + return (function* (): Operation { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The first failed pane's sentence in authored order, which is the grid's. */ +function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { + return outcomes.find((outcome) => outcome?.status === "failed")?.reason; +} + +/** + * Run every pane as a durable child of the grid, in authored order. + * + * A pane's identity is derived from the grid's coroutine and its authored + * ordinal, never from a title, a schedule, or a provider identifier — so a + * resumed run restores a completed pane as its outcome without re-running it, + * and continues an incomplete one from its own history. + * + * `durableAll` rather than `durableSpawn`: the latter returns a task spawned + * inside the ephemeral effect's own scope, and that scope closes as the effect + * resolves, so awaiting the task throws `halted`. It has no call sites or tests + * upstream; `durableAll` is the primitive that is exercised. + * + * Without a journal there are no children to derive, and the work simply runs. + */ +function paneChildren( + workflows: readonly (() => Operation)[], +): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* all(workflows.map((workflow) => workflow())); + } + return yield* durableAll( + workflows.map( + (workflow) => + function* (): Workflow { + return yield* ephemeral(workflow()); + }, + ), + ); + })(); +} + +/** + * Run the whole grid as one durable child, and return what it retained. + * + * A completed grid replays by returning its retained result: the child's + * workflow never runs, so no provider is contacted, no pane content expands and + * no shell starts — and claiming the completed child claims every pane history + * beneath it, so a resumed run starts nothing. + */ +export function durableGrid(live: () => Operation): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* live(); + } + const [retained] = yield* durableAll([ + function* (): Workflow { + return yield* ephemeral(live()); + }, + ]); + return retained!; + })(); } diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 000000000..cedd0b39a --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,140 @@ +/** + * Which grid a run opened, and how a resumed run is held to it + * (spec §6.21 Durability and replay). + * + * One entry, appended in the **parent** coroutine and **before** the foreground + * lease is taken or any provider is contacted: the columns and rows, and the + * ordered pane forms, titles and positions. A resumed run compares what it + * derived against what is held and refuses a document whose grid changed while + * nothing has been opened and nothing has started. + * + * It sits in the parent deliberately. The grid itself is a durable child, and a + * completed child short-circuits without running — so a comparison written + * inside it would never happen on the run that most needs it. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or pane identifier, no argv or environment, and no terminal byte is + * written here: none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + DurableContext, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; +import { retainedLayout } from "./grid.ts"; +import type { RetainedGrid } from "./grid.ts"; + +/** A grid's identity within one execution: where it was written. */ +export interface GridIdentity { + /** The structural path that reached this element (§5.6). */ + readonly path: string; + readonly position?: Readonly; +} + +type RetainedLayout = RetainedGrid["layout"]; + +function describe(identity: GridIdentity): EffectDescription { + return { + type: "terminal_grid_layout", + name: `terminal_grid:${identity.path}:layout`, + ...sourceDescription(identity.position), + }; +} + +/** Whether this expansion has a journal to read and append to at all. */ +function* durable(): Operation { + return (yield* DurableContext.get()) !== undefined; +} + +/** + * Append one entry and return what the entry holds. + * + * Live it is the value passed in; on replay it is the value the journal already + * held, which is the only way a caller tells the two apart. + */ +function* append(description: EffectDescription, value: Json): Workflow { + return yield createDurableOperation(description, function* () { + return value; + }); +} + +/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +function readLayout(value: unknown): RetainedLayout | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const fields: Record = Object.fromEntries(Object.entries(value)); + const { columns, rows, panes } = fields; + if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + return undefined; + } + return { columns, rows, panes: panes as RetainedLayout["panes"] }; +} + +/** How two layouts differ, in the words an author can act on. */ +function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.panes.length !== derived.panes.length) { + return `${held.panes.length} panes rather than ${derived.panes.length}`; + } + for (const [index, pane] of derived.panes.entries()) { + const before = held.panes[index]!; + if (before.title !== pane.title) { + return `pane ${index} titled "${before.title}" rather than "${pane.title}"`; + } + if (before.form !== pane.form) { + return `pane ${index} written ${before.form} rather than ${pane.form}`; + } + if (before.row !== pane.row || before.column !== pane.column) { + return ( + `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${pane.row}, column ${pane.column}` + ); + } + } + return undefined; +} + +/** + * Record which grid this is, and refuse a resumed run whose grid changed. + * + * Expansion driven without a journal records nothing and behaves identically. + */ +export function* recordGridLayout( + identity: GridIdentity, + request: TerminalGridRequest, +): Operation { + if (!(yield* durable())) { + return; + } + const derived = retainedLayout(request); + const description = describe(identity); + const stored = yield* append(description, derived); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run the ` + + "document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, derived); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this terminal grid as a grid with ${changed}. A grid whose layout ` + + "changed cannot be replayed onto this run. Re-run the document from the start rather " + + "than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } +} diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 000000000..05919b653 --- /dev/null +++ b/packages/core/src/terminal/profile.ts @@ -0,0 +1,60 @@ +/** + * Opening one terminal installation for a live document. + * + * A grid needs two things before it can be durable at all: this execution's + * installation — which owns the generation every request belongs to and the + * registry of the grids it issued — and a provider installed against the + * authority that installation mints. A grid outside one refuses rather than + * presenting something no replay could resume. + * + * The installation's lifetime has to surround authored work and end while the + * journal is still live, which is what `Execution.document` is. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Execution } from "../execute.ts"; +import { useTerminalInstallation } from "./authority.ts"; +import { installTerminalProvider } from "./provider-api.ts"; + +export interface TerminalGridProfileOptions { + /** + * The registered provider to install for this execution. + * + * Omitted, the installation is opened and no provider is installed — which is + * a host that validates and inspects grids but cannot present one, and + * refuses when a document asks for one. + */ + readonly provider?: string; + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label?: string; +} + +/** + * Install the terminal-grid profile for the executions composed under it. + * + * The authority reaches the named provider's factory and nothing else: it is + * delivered through the installation handshake rather than published, so a + * handler that answers the install request itself installs no provider and the + * document is told so. + */ +export function installTerminalGridProfile( + options: TerminalGridProfileOptions = {}, +): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const authority = yield* useTerminalInstallation(); + if (options.provider !== undefined) { + yield* installTerminalProvider( + options.provider, + { label: options.label ?? options.provider }, + authority, + ); + } + yield* next(request); + }); + }, + }); +} diff --git a/packages/core/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts new file mode 100644 index 000000000..f3537dd99 --- /dev/null +++ b/packages/core/src/terminal/provider-api.ts @@ -0,0 +1,271 @@ +/** + * How a terminal provider is installed, and what installing one grants. + * + * A provider is the only thing that can present a grid, so *selecting* one is + * itself an authority decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's terminal authority and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { TerminalGridAuthority } from "./authority.ts"; + +/** What a host says about the provider it is installing. */ +export interface TerminalProviderOptions { + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label: string; +} + +/** + * A provider factory installs `TerminalGrids` middleware for its scope. + * + * The authority is the second argument because it is delivered, not published: + * there is no reader for it, no context holding one, and no request member + * carrying one. A factory closes over it, and only the handler that closed over + * it can pair a routed grid request with it. + */ +export type TerminalProviderFactory = ( + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +) => Operation; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_PROVIDERS_API = "TerminalProviders"; + +/** What public installation middleware sees: the name, and what it runs under. */ +export interface TerminalProviderInstallRequest { + readonly intent: "install"; + readonly name: string; + readonly options: TerminalProviderOptions; +} + +/** + * One message on the installation operation. + * + * Public middleware only ever receives the install request. The two private + * members are how the registered provider's handler speaks to the invocation's + * own terminal through the continuation it captured; constructing one grants + * nothing, because the terminal is reachable from that continuation alone. + */ +export type TerminalProviderCall = + | TerminalProviderInstallRequest + | { readonly intent: "inspect"; readonly install: TerminalProviderInstallRequest } + | { readonly intent: "acknowledge"; readonly install: TerminalProviderInstallRequest }; + +export interface TerminalProviderApi { + /** + * Install one provider. + * + * Answers nothing: a return value is not evidence a provider was installed, + * and the invocation that issued the request ignores it. + */ + install(call: TerminalProviderCall): Operation; +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +/** + * The public installation surface. Its own default always refuses. + * + * Invoking this descriptor with a captured request outside a live installation + * reaches this default and installs nothing. + */ +export const TerminalProviders: Api = createApi( + TERMINAL_PROVIDERS_API, + { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + const name = call.intent === "install" ? call.name : call.install.name; + throw new TerminalProviderInstallError(`Unknown terminal provider "${name}"`); + }, + }, +); + +/** Make `factory` installable as `name` for the current scope. */ +export function* registerTerminalProvider( + name: string, + factory: TerminalProviderFactory, +): Operation { + let registered = true; + yield* ensure(() => { + registered = false; + }); + yield* TerminalProviders.around( + { + *install([call], next): Operation { + if (call.intent !== "install" || call.name !== name) { + return yield* next(call); + } + if (!registered) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider registration is no longer live`, + ); + } + // Inspection first, and through the captured continuation: the terminal + // refuses a copied, reused or stale request here, before the factory + // installs anything. + const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); + yield* factory(delivery.options, delivery.authority); + yield* next({ intent: "acknowledge", install: call }); + return undefined; + }, + }, + { at: "min" }, + ); +} + +/** + * What the terminal told this handler, or a refusal. + * + * Parsed rather than believed. The terminal that produced it belongs to the + * canonical copy, and this handler may belong to another; what arrives is a + * value, and reading it as a delivery is this side's decision. + */ +function deliveryOf(value: unknown): { + options: TerminalProviderOptions; + authority: TerminalGridAuthority; +} { + if (typeof value !== "object" || value === null) { + throw new TerminalProviderInstallError( + "this terminal provider installation is not live, so nothing was delivered to it", + ); + } + const options = Reflect.get(value, "options"); + const authority = Reflect.get(value, "authority"); + if (typeof options !== "object" || options === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation named no options", + ); + } + if (typeof authority !== "object" || authority === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no authority", + ); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new TerminalProviderInstallError("the live terminal provider options are not readable"); + } + const present = Reflect.get(authority, "present"); + if (typeof present !== "function") { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no grid authority", + ); + } + return { + options: { label }, + authority: { + present: (request, composite) => Reflect.apply(present, authority, [request, composite]), + }, + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * The authority reaches whichever factory answers, and nothing else: a handler + * that short-circuits, fabricates a return, or never acknowledges installs no + * provider, and this refuses rather than leaving the caller believing one is + * there. + */ +export function installTerminalProvider( + name: string, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): Operation { + return (function* (): Operation { + const request: TerminalProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, authority); + // Same stable name, so the shared middleware chain applies; own descriptor, + // so the chain ends in this invocation's terminal rather than in the public + // refusing default. + const invocation = createApi(TERMINAL_PROVIDERS_API, { + install: terminal.install, + }); + yield* invocation.operations.install(request); + if (!terminal.acknowledged()) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider did not install — a handler answered without ` + + `delivering the request to a registered provider`, + ); + } + terminal.close(); + })(); +} + +function installationTerminal( + request: TerminalProviderInstallRequest, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): { + install: (call: TerminalProviderCall) => Operation; + acknowledged: () => boolean; + close: () => void; +} { + let state: "available" | "inspected" | "acknowledged" | "closed" = "available"; + + return { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + if (call.intent === "install") { + // Reaching the terminal means no registered provider consumed it. + throw new TerminalProviderInstallError(`Unknown terminal provider "${call.name}"`); + } + // Object identity, not shape: a request rebuilt with the same members + // describes the same ask and authorizes nothing. + if (!Object.is(call.install, request)) { + throw new TerminalProviderInstallError( + "the live terminal provider installation received a copied, substituted or foreign request", + ); + } + if (call.intent === "inspect") { + if (state !== "available") { + throw new TerminalProviderInstallError( + "this terminal provider installation is reused, completed or stale", + ); + } + state = "inspected"; + return { options, authority }; + } + if (state !== "inspected") { + throw new TerminalProviderInstallError( + "this terminal provider acknowledgement is unsolicited, duplicated or stale", + ); + } + state = "acknowledged"; + return undefined; + }, + acknowledged: () => state === "acknowledged", + close() { + state = "closed"; + }, + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index b360fa3fb..ad44b1e74 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,7 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Atomic presentation and settlement). + * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * settlement, §Durability and replay). * * The provider here is controlled and is not tmux: it opens no terminal, starts * no process, and records what it was asked to do in the order it was asked. @@ -10,87 +11,206 @@ * * Readiness is the claim these rows care about most, so it is always driven * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and - * "did some work" be told apart at all. + * handed, never because it got far enough. That is what lets "started" and "did + * some work" be told apart at all. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, `` is what a suite writes + * to be that something — and it reaches the pane through the same seam a real + * `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; import { forEach } from "@effectionx/stream-helpers"; import { rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, - installControlledTerminalProvider, + prepareControlledComposite, + TerminalGrids, + terminalProviderLog, +} from "@executablemd/runtime"; +import type { + ControlledCompositeOptions, + TerminalComposite, + TerminalGridRequest, + TerminalProviderLog, } from "@executablemd/runtime"; -import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; +import { + createTerminalGridClaims, + TerminalAuthorityError, + useTerminalInstallation, +} from "../src/terminal/authority.ts"; +import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + TerminalProviderInstallError, + TerminalProviders, +} from "../src/terminal/provider-api.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +import { paneTerminal } from "../src/terminal/pane.ts"; import type { Json } from "../src/types.ts"; -import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; -import { runTerminalGrid } from "../src/terminal/grid.ts"; -import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; -import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; -import type { TerminalGridLayout } from "../src/terminal-grid.ts"; - -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Everything the composite did, in order. */ + events: string[]; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; + /** The journal this run read and appended to. */ + journal: DurableEvent[]; } -/** A layout of `count` panes across `columns`, titled by ordinal. */ -function layoutOf(columns: number, count: number): TerminalGridLayout { - return terminalGridLayout( - columns, - Array.from({ length: count }, (_unused, index) => ({ - title: `pane ${index}`, - form: "self-closing" as const, - })), - ); +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); } -/** A pane that starts, does what `body` says, and settles. */ -function pane(ordinal: number, body?: () => Operation): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - if (body) { - yield* body(); +/** The controlled interactive child, and a tripwire. */ +function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); } - }); + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + yield* sleep(25); + slowMarks.push("ready:slow"); + spawned(); + }); + return ""; + }, }, - }; + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); } -/** Everything a grid run needs installed, with the reader's close under control. */ -function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { - // A grid takes the same one foreground lease a root takes, - // so a host that offers a grid still has to offer that lease. - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ log: record, close }); +/** + * Register a controlled provider that presents through the authority it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives the authority + * as an argument, prepares a composite of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledCompositeOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the authority for a later, unrouted use. */ + readonly capture?: (authority: TerminalGridAuthority) => void; + } = {}, +): Operation { + let generation = 0; + return registerTerminalProvider("controlled", function* (_settings, authority) { + options.capture?.(authority); + yield* TerminalGrids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + const composite = yield* prepareControlledComposite(request, options, generation++); + yield* authority.present(options.substitute?.(request) ?? request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); } -/** A pane that records when it started, so ordering is read rather than timed. */ -function readyPane(ordinal: number, timeline: string[]): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - timeline.push(`ready:${ordinal}`); - claim.ready(); - yield* suspend(); - }); - }, - }; +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); } /** Close as soon as the reader is asked, which is the ordinary journey. */ @@ -99,546 +219,463 @@ function immediateClose(): () => Operation { return function* () {}; } -describe("Tier TG — pane claims and readiness", () => { - it("TG8: a claim admits one interactive operation at a time", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; +/** + * Expand one document against a controlled grid host. + * + * `provider: false` registers nothing, which is how "a host that cannot open a + * grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { + provider?: boolean; + stream?: InMemoryStream; + composite?: ControlledCompositeOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + yield* useGridComponents(ran, options.slowMarks ?? []); + yield* installControlledLauncher(); - yield* scoped(function* () { - yield* first.admit(function* () { - // A second operation on the same pane is refused while this one is live. - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - // A different pane does not contend at all, which is the whole reason a - // grid exists. - yield* second.admit(function* () { - concurrent = true; - }); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.composite ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", - ); - expect(concurrent).toBe(true); + const stream = options.stream ?? new InMemoryStream(); + const execution = yield* execute({ path, stream, includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { + outcome, + output, + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a pane admits again once its first operation has settled", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let second = false; +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} - yield* scoped(function* () { - yield* claim.admit(function* () {}); - yield* claim.admit(function* () { - second = true; +/** A grid on its own, which a resumed run can carry to an outcome. */ +function plainDocument(columns: number, panes: string[]): string { + return [``, ...panes, "", ""].join("\n"); +} + +/** A grid, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, panes: string[]): string { + return [ + ``, + ...panes, + "", + "", + "", + "", + ].join("\n"); +} + +/** + * Run a document and interrupt it once the grid has journaled its outcome. + * + * A completed *or failed* root replays wholesale, so a second run of it would + * never reach the grid at all. Only a genuinely interrupted run leaves the + * region to be resumed — which is what every replay row below needs. + */ +function runInterrupted( + dir: string, + source: string, + stream: InMemoryStream, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const opened = withResolvers(); + yield* useGridComponents(ran); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: () => suspend(), + *onPrepare(asked) { + requests.push(asked); + yield* sleep(0); + }, + // Attach is the signal, not `running`: a pane that settles before the + // barrier keeps its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + opened.resolve(); + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - // Sequential work in one pane is ordinary composition, not contention. - expect(second).toBe(true); + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const task: Task = yield* spawn(function* () { + const execution = yield* execute({ path, stream, includes: [dir] }); + yield* execution; + }); + // The grid is open and its panes have settled, so the journal now holds the + // pane children's own entries. A resumed run never attaches at all — the + // region short-circuits — so this is bounded rather than waited on. + yield* race([opened.operation, sleep(120)]); + yield* sleep(5); + yield* task.halt(); + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - grid.seal(); - let refusal: unknown; +const PANES = [ + 'left', + '', +]; - yield* scoped(function* () { - try { - yield* claim.admit(function* () {}); - } catch (error) { - refusal = error; - } +describe("Tier TG — the terminal authority", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + expect(run.outcome.ok).toBe(true); + + // The same document, against a provider that answers the routed request + // itself. A return value is not evidence that a grid opened. + const shorted = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ shortCircuit: true }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, ran }; }); - // A claim kept past its grid is a claim to a terminal nobody owns. - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(shorted.outcome.ok).toBe(false); + expect(shorted.outcome.ok ? "" : shorted.outcome.error.message).toContain( + "a handler answered without delivering the request to a registered provider", + ); + // Nothing beneath the grid ran either. + expect(shorted.ran).toEqual([]); }); - it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + it("TA2: presenting a rebuilt request authorizes nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { + composite: {}, }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; + expect(run.outcome.ok).toBe(true); - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what the authority reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - }); - it("TG8: a request whose ordinals are not its positions is refused", function* () { - let refusal: unknown; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - yield* sleep(0); + expect(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); }); -}); - -describe("Tier TG — atomic startup", () => { - it("TG9: nothing attaches until every pane has reported a spawn", function* () { - const record = log(); - // One ordered record both the panes and the provider write to, so - // "readiness came first" is read rather than assumed. The grid emits - // `running` for every pane immediately before it attaches, so asserting on - // that would prove nothing — a pane says when it actually started. - const timeline: string[] = []; - const slow = withResolvers(); - const result = yield* scoped(function* (): Operation { + it("TA3: presenting a changed request authorizes nothing", function* () { + const dir = yield* useDir(); + const changed = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: immediateClose(), - // deno-lint-ignore require-yield - *onAttach() { - timeline.push("attach"); - }, + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), }); - return yield* runTerminalGrid(layoutOf(2, 3), [ - readyPane(0, timeline), - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - // Plenty of work before anything starts, and none of it makes the - // grid attachable. The delay is long enough that a grid which - // skipped the barrier would demonstrably attach first. - yield* sleep(25); - timeline.push("ready:1"); - claim.ready(); - yield* slow.operation; - }); - }, - }, - readyPane(2, timeline), - ]); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); - expect(result.failure).toBeUndefined(); + expect(changed.ok).toBe(false); + expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { - const record = log(); - let failure: unknown; + it("TA4: an authority kept past its grid authorizes nothing", function* () { + const dir = yield* useDir(); + let kept: TerminalGridAuthority | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + }); + + // The execution has finished, so the request it issued is no longer live. + let refusal: unknown; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0), + yield* kept!.present( { - ordinal: 1, - // Runs, settles, and never reports a spawn. - *run() {}, + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], }, - ]); + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "finished without starting anything interactive", - ); - // No partial grid was ever shown, and the hidden composite was destroyed. - expect(record.events).not.toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a preparation failure starts no pane at all", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA5: an authority from another installation generation authorizes nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, + // Two installations in one scope: the second supersedes the first, so the + // first's authority names a generation the live registry no longer has. + const stale = yield* scoped(function* () { + return yield* useTerminalInstallation(); }); + yield* useTerminalInstallation(); + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0, function* () { - started.push(0); - }), - pane(1, function* () { - started.push(1); - }), - ]); + yield* stale.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toBe( - "no pane endpoint could be created", - ); - expect(started).toEqual([]); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a grid refuses before preparation when no provider is installed", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); + const authority = yield* useTerminalInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerTerminalProvider("real", function* () {}); + yield* TerminalProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); try { - yield* runTerminalGrid(layoutOf(1, 1), [ - pane(0, function* () { - started.push(0); - }), - ]); + yield* installTerminalProvider("real", { label: "real" }, authority); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "no terminal provider is installed", - ); - expect(started).toEqual([]); - }); -}); - -describe("Tier TG — settlement and close", () => { - it("TG10: a pane fails after attach while its siblings stay live", function* () { - const record = log(); - // The reader leaves once the grid has displayed the failure, so the sibling - // is provably still live when that happens rather than probably still live. - const failed = withResolvers(); - let siblingLiveAtFailure = false; - let siblingLive = false; - - const result = yield* scoped(function* (): Operation { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: () => failed.operation, - onUpdate(ordinal, state) { - if (ordinal === 0 && state === "failed") { - siblingLiveAtFailure = siblingLive; - failed.resolve(); - } - }, - }); - return yield* runTerminalGrid(layoutOf(2, 2), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* sleep(1); - throw new Error("pane 0 stopped"); - }); - }, - }, - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - siblingLive = true; - try { - yield* suspend(); - } finally { - siblingLive = false; - } - }); - }, - }, - ]); - }); - - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("state:0:0:failed"); - // The sibling was still running when its neighbour failed: an ordinary pane - // failure after attach is contained as that pane's status. - expect(siblingLiveAtFailure).toBe(true); - expect(result.outcomes[0]?.kind).toBe("failed"); - expect(result.outcomes[1]?.kind).toBe("closed"); - // The grid fails with the first failed pane in authored order. - expect(result.failure?.message).toBe("pane 0 stopped"); + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TG12: close cancels a live pane as closed rather than failed", function* () { - const record = log(); - - const result = yield* scoped(function* (): Operation { - yield* useGridHost(record, immediateClose()); - return yield* runTerminalGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - // Still live when the reader leaves. - yield* suspend(); - }); - }, - }, - ]); - }); - - // Teardown cancellation is not a pane failure, and the grid succeeds. - expect(result.outcomes[0]?.kind).toBe("closed"); - expect(result.failure).toBeUndefined(); - expect(record.events).toContain("state:0:0:closed"); - }); - - it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { - const record = log(); - - yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); - yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + it("TA7: two claims from one grid do not contend; one pane admits one", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], }); - - const closed = record.events.indexOf("closed:0"); - const destroyed = record.events.indexOf("destroy:0"); - expect(closed).toBeGreaterThan(-1); - expect(destroyed).toBeGreaterThan(closed); - expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); - }); - - it("TG13: parent cancellation tears the grid down completely", function* () { - const record = log(); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; yield* scoped(function* () { - yield* useGridHost(record, () => suspend()); - // The grid never closes on its own; the enclosing scope ending is what - // takes it down, and that has to be a complete teardown. - yield* scoped(function* () { - yield* spawnGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* suspend(); - }); - }, - }, - ]); - yield* sleep(2); + yield* first.admit(function* () { + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.admit(function* () { + concurrent = true; + }); }); }); - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); }); -}); -describe("Tier TG — the pane seam", () => { - it("TG6: work inside a pane runs as that pane's owner", function* () { - const grid = createTerminalGridClaims({ + it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { + const request = { columns: 1, rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let sawOrdinal: number | undefined; - let acknowledged = false; + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], + }; + const first = createTerminalGridClaims(request); + const second = createTerminalGridClaims(request); + // Sealing one grid says nothing about the other: claims belong to the grid + // that minted them, not to a request shape. + first.seal(); + let refusal: unknown; + let other = false; yield* scoped(function* () { - yield* usePaneTerminal(claim); - const seam = yield* paneTerminal(); - sawOrdinal = seam?.ordinal; - yield* seam!.interactive(function* (spawned) { - spawned(); - acknowledged = grid.readiness[0]!.acknowledged; + try { + yield* first.claims[0]!.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.claims[0]!.admit(function* () { + other = true; }); }); - expect(sawOrdinal).toBe(0); - // The seam is how anything interactive reports its spawn, so readiness - // travels with the work rather than being asserted around it. - expect(acknowledged).toBe(true); - }); - - it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { - const seam = yield* scoped(function* () { - return yield* paneTerminal(); - }); - expect(seam).toBeUndefined(); - }); -}); - -/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ -function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { - yield* spawn(function* () { - yield* runTerminalGrid(layout, work); - }); -} - -/** One document run against a controlled grid host. */ -interface DocumentRun { - outcome: Result; - /** Text the consumer received — the root document's own output. */ - output: string; - /** The grid the provider was actually asked to present. */ - requests: TerminalGridRequest[]; - /** What each pane displayed. */ - shown: Map; - /** Every mark a tripwire component recorded, in order. */ - ran: string[]; -} - -function useDir(): Operation { - return resource(function* (provide) { - const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); - yield* ensure(function* () { - yield* rm(dir, { recursive: true, force: true }); - }); - yield* provide(dir); + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(other).toBe(true); }); -} -/** - * The controlled interactive child, and a tripwire. - * - * A paired pane is ready only when something in it starts and reports a spawn. - * Until the native-launch Story lands, this is what a suite writes to be that - * something — and it reaches the pane through the same seam a real launch will. - */ -function useGridComponents(ran: string[]): Operation { - return registerComponents([ - { - name: "Interactive", - origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { - const pane = yield* paneTerminal(); - if (pane === undefined) { - throw new Error(" is written inside a pane"); - } - yield* pane.interactive(function* (spawned) { - spawned(); - }); - return ""; - }, - }, - { - name: "Ran", - origin: "tier-tg", - props: { - type: "object", - properties: { mark: { type: "string" } }, - required: ["mark"], - additionalProperties: false, - }, - // deno-lint-ignore require-yield - *fn(props) { - ran.push(String(props.mark)); - return ""; - }, - }, - ]); -} + it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; -/** - * Run one document against a controlled grid host. - * - * `provider: false` installs no terminal provider, which is how "a host that - * cannot open a grid refuses" is asked for. - */ -function runDocument( - dir: string, - source: string, - options: { provider?: boolean } = {}, -): Operation { - return scoped(function* () { - const path = join(dir, "doc.md"); - yield* writeTextFile(path, source); - const requests: TerminalGridRequest[] = []; - const record = log(); - const ran: string[] = []; - yield* useGridComponents(ran); - yield* installControlledLauncher(); - // The reader stays until every pane has settled. Leaving sooner is a real - // thing a reader does — TG12 covers it — but a row about what a pane - // rendered must not race the close that cancels it. - const settled = withResolvers(); - let expected = 0; - let done = 0; - if (options.provider !== false) { - yield* installControlledTerminalProvider({ - log: record, - close: () => settled.operation, - *onPrepare(asked) { - expected = asked.panes.length; - requests.push(asked); - yield* sleep(0); - }, - onUpdate(_ordinal, state) { - if (state === "succeeded" || state === "failed") { - done++; - if (done >= expected) { - settled.resolve(); - } - } - }, + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TA10: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], }); + } catch (error) { + refusal = error; } - const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); - const outcome = yield* execution; - const output = yield* forEach(function* (_chunk: string) {}, execution.output); - return { outcome, output, requests, shown: record.shown, ran }; + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); }); -} - -/** The message a run failed with, failing the test if it completed. */ -function failureOf(run: DocumentRun): string { - if (run.outcome.ok) { - throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); - } - return run.outcome.error.message; -} +}); describe("Tier TG — a grid written in a document", () => { it("TG4: the provider is asked for exactly the authored row-major layout", function* () { @@ -687,8 +724,6 @@ describe("Tier TG — a grid written in a document", () => { ); expect(run.outcome.ok).toBe(true); - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each was written in travels with it. expect(run.requests[0]?.panes).toEqual([ { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, @@ -696,35 +731,9 @@ describe("Tier TG — a grid written in a document", () => { ]); }); - it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { - const dir = yield* useDir(); - const run = yield* runDocument( - dir, - [ - "", - "", - 'Instructions.', - "", - '', - "", - "", - "", - ].join("\n"), - ); - - expect(run.outcome.ok).toBe(true); - expect(run.requests[0]).toEqual({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, - ], - }); - }); - - it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { const dir = yield* useDir(); + const flushed: string[] = []; const run = yield* runDocument( dir, [ @@ -738,9 +747,20 @@ describe("Tier TG — a grid written in a document", () => { "after", "", ].join("\n"), + { + composite: { + // Preparation happens after the lease and the flush, so what the + // reader had already been given is on screen before the grid covers + // it. + *onPrepare() { + flushed.push("prepared"); + }, + }, + }, ); expect(run.outcome.ok).toBe(true); + expect(flushed).toEqual(["prepared"]); // Each pane's own text went to that pane. expect(run.shown.get(0)).toContain("left text"); expect(run.shown.get(1)).toContain("right text"); @@ -793,31 +813,60 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).toContain("after {mine}"); }); - it("TG6: a pane's cannot reach a loop outside the grid", function* () { + it("TG6: a pane's cannot claim a value body outside the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, [ - "", - '', + "---", + "returns:", + " type: string", + "---", "", '', - "", + '', + "", + "", + "", + "", + '', + "", + ].join("\n"), + ); + + // The pane has no enclosing value body to claim, so the written in + // it is refused where it sits rather than becoming the document's value. + expect(failureOf(run)).toContain( + "is not written in the flow of a body that declares `returns`", + ); + expect(failureOf(run)).not.toContain("from the document"); + }); + + it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', "", "", "", - "", "", ].join("\n"), ); - // Refused where it was written. Had the reached the loop around the - // grid it would have exited it quietly and the document would have - // succeeded; instead the pane failed with the stray- rule, which is - // what fails the grid and then the document. - expect(failureOf(run)).toContain(" must be written inside a "); - expect(failureOf(run)).toContain("cannot break the loop that invoked it"); - expect(run.ran).toEqual(["iteration"]); + // Printed inside the pane it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this pane gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this pane gave up"); }); it("TG9: with no provider installed, no pane body or shell runs", function* () { @@ -843,3 +892,286 @@ describe("Tier TG — a grid written in a document", () => { expect(run.shown.size).toBe(0); }); }); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the composite both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + composite: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + timeline.push("ready:shell"); + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join( + "\n", + ), + { + composite: { + // Reports its spawn and returns in the same breath. + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready enough to attach, and settled enough to be `succeeded`. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + // A pane that already settled keeps the status it settled to. + expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); + expect(run.events).not.toContain("state:0:0:running"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onAttach() { + throw new Error("the composite could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the composite could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + composite: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // The following sibling started only after the composite came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // The reader's close operation is where an active provider can fail. + // deno-lint-ignore require-yield + *close() { + throw new Error("the terminal provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the terminal provider lost its server"); + expect(run.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + + /** Every terminal-grid entry the journal holds. */ + function gridEntries(run: DocumentRun): DurableEvent[] { + return run.journal.filter( + (event) => + event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), + ); + } + + it("TG15: a completed grid replays without contacting a provider at all", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // The grid opened and its panes ran; the document was then interrupted, so + // the root reached no outcome and a resumed run reaches the grid again. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // The region's retained result is the answer: no provider was asked for a + // grid, no pane content expanded, and nothing was displayed. + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG15: a completed grid replays even where no provider could open one", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + yield* runInterrupted(dir, GRID, stream); + // This host installs no provider at all. A replay that contacted one would + // refuse here; the retained result does not need one. + const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const ids = closes.map((event) => String(event.coroutineId)).sort(); + // Two pane children beneath one grid child: `..`. + const paneIds = ids.filter((id) => id.split(".").length >= 3); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); + + it("TG17: the layout is recorded before any provider is contacted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const layout = run.journal.find( + (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), + ); + expect(layout).toBeDefined(); + // Written before the grid child that opens anything, so a comparison + // against it happens while nothing has been presented. + const layoutIndex = run.journal.indexOf(layout!); + const opened = run.journal.findIndex( + (event) => event.type === "close" && String(event.coroutineId).includes("."), + ); + expect(layoutIndex).toBeGreaterThan(-1); + if (opened > -1) { + expect(layoutIndex).toBeLessThan(opened); + } + }); + + it("TG17: the retained record holds provider-neutral facts only", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const entries = gridEntries(run); + expect(entries.length).toBeGreaterThan(0); + + const written = JSON.stringify(run.journal); + // The layout the author wrote, and nothing about whatever presented it. + expect(written).toContain('"columns":2'); + expect(written).toContain('"Left"'); + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index e9a990c9a..eba02abb8 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,19 +147,20 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, + TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledTerminalProviderOptions, + ControlledCompositeOptions, TerminalComposite, + TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, TerminalPaneState, - TerminalProviderHandler, TerminalProviderLog, TerminalShellOutcome, } from "./terminal.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index cc34203c0..12827a30b 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -1,5 +1,6 @@ /** - * The terminal provider — how a host presents one grid of interactive panes. + * The terminal grid boundary — how a host presents one grid of interactive + * panes, and what composing middleware around it may do. * * This is not the native launcher. A launch hands **one** child the whole * foreground terminal and waits for it; a grid divides that terminal into @@ -9,26 +10,18 @@ * appears in the document: `` asks for panes and their authored * layout, and the host chooses what presents them. * - * A grid is prepared before it is shown, which is what makes opening one atomic: - * - * 1. `prepare()` builds the whole composite while it is still hidden — every - * pane endpoint and its supervision — and presents nothing. A host that - * cannot open a grid refuses here, before any pane has started work. - * 2. Core starts the authored panes concurrently and waits for every one of - * them to be ready. - * 3. `attach()` shows the composite, once, after that barrier. A failure before - * it discards the hidden composite instead of leaving a partial grid on the - * reader's screen. - * 4. `destroy()` takes it down again and gives the root terminal back. + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases, mints pane claims and settles a + * grid is a non-contextual authority delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. * - * There is no host default. `xmd run` installs the production provider; a test - * or embedding host installs a controlled one that needs no terminal. Until one - * is installed every operation refuses, which is what keeps writing, inspecting - * and validating a document free of all of this. - * - * **Presentation never decides an outcome.** `update()` receives the pane states - * core has already settled on, so a provider draws them and answers for none of - * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole composite while it is hidden, core starts the + * authored panes and waits for every one of them to report a spawn, and only + * then is anything attached. */ import { type Api, createApi } from "@effectionx/context-api"; @@ -57,6 +50,11 @@ export interface TerminalPaneRequest { * Provider-neutral throughout: it names no terminal, multiplexer, socket, * process, window or pane identifier, and carries no command, argv or * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and the authority compares the object it is presented with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. */ export interface TerminalGridRequest { readonly columns: number; @@ -83,7 +81,7 @@ export interface TerminalShellOutcome { /** * One prepared, still-hidden grid. * - * Everything here belongs to the one `prepare()` that produced it. A composite + * Everything here belongs to the one preparation that produced it. A composite * is never reused across expansions, and a provider that hands the same one * back twice has handed back a grid the second expansion did not ask for. */ @@ -118,8 +116,6 @@ export interface TerminalComposite { * ended. * * Which shell that is comes from live host policy, never from the document. - * The bytes it exchanges with the reader belong to the pane: nothing captures - * or journals them. * * `spawned` is the pane's readiness latch, and calling it is the only thing * that makes this pane ready. Call it from the runtime's successful @@ -138,16 +134,14 @@ export interface TerminalComposite { /** * Take the composite down and give the root terminal back. * - * Called exactly once for every composite `prepare()` returned, including one + * Called exactly once for every composite that was prepared, including one * discarded before it ever attached. */ destroy(): Operation; } -export interface TerminalProviderHandler { - /** Build the whole hidden composite for `request`, presenting nothing. */ - prepare(request: TerminalGridRequest): Operation; -} +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; export const TERMINAL_PROVIDER_UNAVAILABLE = "no terminal provider is installed — this host does not present a grid of " + @@ -161,29 +155,30 @@ export class TerminalProviderUnavailableError extends Error { } } +export interface TerminalGridApi { + /** + * Route one grid request to whatever presents it. + * + * Answers `unknown`, and the answer is discarded: a return value is not + * evidence that a grid was opened, and core reads what the authority settled + * instead of what a handler said. + */ + open(request: TerminalGridRequest): Operation; +} + /** - * The stable contextual boundary a grid request travels. + * The public routing surface. Its own default always refuses. * - * Middleware composed here may observe, narrow, refuse, wrap or delegate a - * request — everything composition needs. What it cannot do is authorize one: - * the terminal authority that mints pane claims and takes terminal ownership is - * delivered directly to the installed provider and reachable from nowhere else, - * so a handler that answers without delegating has presented nothing. + * Reaching this default means no registered provider consumed the request, so + * nothing was presented — which is the honest answer for a host that installs + * no provider at all. */ -export const TerminalProvider: Api = createApi( - "runtime.terminalProvider", - { - // deno-lint-ignore require-yield - *prepare(_request: TerminalGridRequest): Operation { - throw new TerminalProviderUnavailableError(); - }, +export const TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); }, -); - -/** Build the hidden composite for one grid expansion. */ -export function prepareTerminalGrid(request: TerminalGridRequest): Operation { - return TerminalProvider.operations.prepare(request); -} +}); /** * Everything one controlled composite did, in the order it did it. @@ -203,17 +198,22 @@ export interface TerminalProviderLog { readonly shown: Map; } +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + /** - * What a controlled provider does instead of opening a terminal. + * What a controlled composite does instead of opening a terminal. * * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` - * decides what a self-closing pane's shell did and how long it took, and - * `close` is the operation the grid waits on, so a suite controls exactly when - * the reader leaves. + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. */ -export interface ControlledTerminalProviderOptions { - /** Appended to as the provider works, so ordering is read rather than timed. */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ readonly log?: TerminalProviderLog; onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; @@ -222,93 +222,78 @@ export interface ControlledTerminalProviderOptions { * Called as each pane state is displayed. * * A suite watches it to react to something the grid decided — a pane that - * failed, a pane that became runnable — instead of waiting a while and hoping. + * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - /** - * What a pane's shell did. - * - * It receives the readiness latch, so a suite decides whether this shell - * reports a spawn at all — which is how "never started" is told apart from - * "started and exited immediately". - */ shell?: (ordinal: number, spawned: () => void) => Operation; close?: () => Operation; } /** - * Install a provider that presents nothing and records everything. + * Prepare one composite that presents nothing and records everything. * - * It answers the whole contract — prepare, attach, update, shell, close, + * It answers the whole contract — attach, update, display, shell, close, * destroy — so a suite exercises core's lifecycle without a terminal, a * multiplexer, or a process anywhere in it. */ -export function* installControlledTerminalProvider( - options: ControlledTerminalProviderOptions = {}, -): Operation { - const log = options.log ?? { events: [], shown: new Map() }; - const shown = log.shown; - let prepared = 0; - - yield* TerminalProvider.around( - { - *prepare([request]): Operation { - if (options.onPrepare) { - yield* options.onPrepare(request); +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); } - const generation = prepared++; - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - let destroyed = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - }, - // deno-lint-ignore require-yield - *update(ordinal, state) { - log.events.push(`state:${generation}:${ordinal}:${state}`); - options.onUpdate?.(ordinal, state); - }, - // deno-lint-ignore require-yield - *display(ordinal, text) { - const pane = shown.get(ordinal) ?? ""; - shown.set(ordinal, pane + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn - // would hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken - // down more times than it was built, which is exactly the ordering - // claim a suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - }, - }; + log.events.push(`destroy:${generation}`); }, - }, - { at: "min" }, - ); + }; + })(); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 9e01204a7..3c88c9d83 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,17 +1,17 @@ /** - * Tier TG — the terminal provider boundary (architecture.md §Terminal - * authority, spec §6.21). + * Tier TG — the terminal grid routing surface and the composite contract + * (architecture.md §Terminal authority, spec §6.21). * - * What a host installs to present a grid, and what composing middleware around - * it may and may not do. Nothing here opens a terminal, looks for a - * multiplexer, or starts a process: the whole point of the boundary is that the - * language does not depend on any of that, so a suite that needed one would be - * testing the wrong thing. + * Two things live here, and neither is an authority. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and core throws the + * answer away. The composite is what a provider prepares, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. * - * The controlled provider records what it was asked to do, in order. Ordering - * claims are read off that record rather than inferred from timing, because a - * grid that attached too early and a grid that attached on time can take the - * same wall clock. + * Who may present a grid, and what presenting one authorizes, is core's, and is + * proved in `packages/core/tests/terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -20,13 +20,13 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; +import type { TerminalGridRequest } from "../terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { @@ -41,16 +41,12 @@ function request(overrides: Partial = {}): TerminalGridRequ }; } -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; -} - -describe("Tier TG — the provider boundary", () => { +describe("Tier TG — the routing surface", () => { it("TP1: refuses when no host has installed a provider", function* () { let refusal: unknown; yield* scoped(function* () { try { - yield* prepareTerminalGrid(request()); + yield* TerminalGrids.operations.open(request()); } catch (error) { refusal = error; } @@ -60,27 +56,118 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); }); - it("TP2: an installed provider prepares without presenting anything", function* () { - const record = log(); + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: TerminalGridRequest[] = []; + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* TerminalGrids.operations.open(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the same object reached the far end. + expect(reached[0]).toBe(seen[0]); + }); + + it("TP2: middleware narrows a request before anything below sees it", function* () { + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* TerminalGrids.operations.open(request()); + }); + + expect(reached[0]?.columns).toBe(1); + expect(reached[0]?.rows).toBe(2); + }); + + it("TP2: middleware refuses a request, and nothing below is reached", function* () { + const reached: TerminalGridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + expect(reached).toEqual([]); + }); +}); + +describe("Tier TG — the composite contract", () => { + it("TP3: a prepared composite presents nothing until it is attached", function* () { + const log = terminalProviderLog(); const events = yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* prepareTerminalGrid(request()); - return [...record.events]; + yield* prepareControlledComposite(request(), { log }); + return [...log.events]; }); - // Preparation happened; nothing was shown. A composite the reader can see - // before every pane is ready is the one thing atomic startup forbids. + // A composite the reader can see before every pane is ready is the one + // thing atomic startup forbids. expect(events).toEqual(["prepare:0:2x1"]); expect(events.some((event) => event.startsWith("attach:"))).toBe(false); }); - it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { - const record = log(); + it("TP3: attach, update, display, shell and destroy record in order", function* () { + const log = terminalProviderLog(); const spawns: number[] = []; yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request(), { log }); yield* composite.update(0, "starting"); + yield* composite.display(0, "pane text"); yield* composite.update(0, "running"); yield* composite.shell(1, () => spawns.push(1)); yield* composite.attach(); @@ -89,7 +176,7 @@ describe("Tier TG — the provider boundary", () => { yield* composite.destroy(); }); - expect(record.events).toEqual([ + expect(log.events).toEqual([ "prepare:0:2x1", "state:0:0:starting", "state:0:0:running", @@ -99,22 +186,22 @@ describe("Tier TG — the provider boundary", () => { "closed:0", "destroy:0", ]); + expect(log.shown.get(0)).toBe("pane text"); // The default shell starts, and says so through the latch it was handed: // readiness is reported by the shell rather than assumed by the grid. expect(spawns).toEqual([1]); }); - it("TP5: a shell that never starts never reports a spawn", function* () { + it("TP4: a shell that never starts never reports a spawn", function* () { const spawns: number[] = []; const outcome = yield* scoped(function* () { - yield* installControlledTerminalProvider({ + const composite = yield* prepareControlledComposite(request(), { // deno-lint-ignore require-yield - *shell(_ordinal, _spawned) { + *shell() { // No spawn event: nothing started, so nothing is acknowledged. return { exitCode: 127 }; }, }); - const composite = yield* prepareTerminalGrid(request()); return yield* composite.shell(1, () => spawns.push(1)); }); @@ -122,107 +209,18 @@ describe("Tier TG — the provider boundary", () => { expect(spawns).toEqual([]); }); - it("TP3: middleware observes a delegated request without changing it", function* () { - const record = log(); - const seen: TerminalGridRequest[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - seen.push(asked); - return yield* next(asked); - }, - }); - yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); - }); - - expect(seen).toHaveLength(1); - expect(seen[0]?.columns).toBe(3); - // Observation is not interference: the provider still saw the same grid. - expect(record.events).toEqual(["prepare:0:3x2"]); - }); - - it("TP3: middleware refuses a request, and no composite is ever built", function* () { - const record = log(); - let refusal: unknown; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - // deno-lint-ignore require-yield - *prepare(): Operation { - throw new Error("this host does not open terminal grids"); - }, - }); - try { - yield* prepareTerminalGrid(request()); - } catch (error) { - refusal = error; - } - }); - - expect(refusal instanceof Error ? refusal.message : "").toBe( - "this host does not open terminal grids", - ); - // Refusing means refusing: the provider below was never reached, so there - // is no hidden composite left needing teardown. - expect(record.events).toEqual([]); - }); - - it("TP3: middleware narrows a request before the provider sees it", function* () { - const record = log(); - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); - }, - }); - yield* prepareTerminalGrid(request()); - }); - - expect(record.events).toEqual(["prepare:0:1x2"]); - }); - - it("TP4: middleware wraps the composite it delegated for", function* () { - const record = log(); - const wrapped: string[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - const composite = yield* next(asked); - return { - ...composite, - *attach() { - wrapped.push("before"); - yield* composite.attach(); - wrapped.push("after"); - }, - }; - }, - }); - const composite = yield* prepareTerminalGrid(request()); - yield* composite.attach(); - yield* composite.destroy(); - }); - - expect(wrapped).toEqual(["before", "after"]); - expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); - }); - - it("TP5: a preparation failure leaves nothing to tear down", function* () { - const record = log(); + it("TP4: a preparation failure leaves no composite to tear down", function* () { + const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider({ - log: record, - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, - }); try { - yield* prepareTerminalGrid(request()); + yield* prepareControlledComposite(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); } catch (error) { refusal = error; } @@ -231,16 +229,15 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so the record shows - // no composite was built and none is owed a destroy. - expect(record.events).toEqual([]); + // The failure happened before the composite existed, so nothing is owed a + // destroy. + expect(log.events).toEqual([]); }); - it("TP5: a composite refuses to be destroyed twice", function* () { + it("TP4: a composite refuses to be destroyed twice", function* () { let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider(); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request()); yield* composite.destroy(); try { yield* composite.destroy(); @@ -254,18 +251,17 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); }); - it("TP6: each preparation is its own composite", function* () { - const record = log(); + it("TP5: each preparation is its own composite", function* () { + const log = terminalProviderLog(); yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const first = yield* prepareTerminalGrid(request()); - const second = yield* prepareTerminalGrid(request()); + const first = yield* prepareControlledComposite(request(), { log }, 0); + const second = yield* prepareControlledComposite(request(), { log }, 1); yield* first.destroy(); yield* second.destroy(); }); // Two expansions are two grids. A provider that handed the same composite // back would have presented the second expansion's grid as the first's. - expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); }); }); From 14ae876c7a161ed345cd72f861daee3321a1c192 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:45:27 -0400 Subject: [PATCH 06/19] =?UTF-8?q?=F0=9F=90=9B=20Repair=20durableSpawn,=20a?= =?UTF-8?q?nd=20put=20the=20grid=20on=20it=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `durableSpawn` returned a task spawned inside the `ephemeral` effect's own scope, and that scope closed as the effect resolved — so every `yield* task` threw `halted`. It had no call sites and no tests. It now starts the child in the routine's own scope, so the task outlives the call and can be awaited or halted by whoever asked for it. A retained `Close(cancelled)` meant one thing to the code and two things in practice. Under `durableRace` and `durableAll` it is a race loser or a fail-fast sibling, and the same combinator cancels it again — those keep DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so suspending hung the resumed run forever. `runDurableChild` now takes an explicit `CancelledChildPolicy`, fixed at each combinator's call site and never chosen by a caller. Resuming uses a new internal `ReplayIndex.reopen()`, which forgets one coroutine's retained Close while keeping its yields — so the child continues its own history rather than restarting, and the divergence guard stops reading the remaining effects as a coroutine continuing past its own close. Neither it nor `disableReplay` is exported. DEC-039 records the policy and marks DEC-024's invariant as superseded in part: it assumed every cancelled child belongs to race or all. The grid uses the repaired primitive: the whole grid is one durable child, each pane is its own durable child allocated in authored ordinal order, and each pane task is observed outside its child — so a replayed pane's retained outcome publishes its status and satisfies the readiness barrier without entering a body, a shell, or a launcher. Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts` (lifetime, completed replay, interrupted resume, retained-history continuation, and both combinators keeping their own policy); 30 rows in `packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349, runtime 15. --- packages/core/src/terminal/grid.ts | 98 ++--- packages/core/tests/terminal-grid.test.ts | 148 ++++--- packages/durable-streams/combinators.ts | 126 ++++-- packages/durable-streams/replay-index.ts | 17 + packages/durable-streams/specs/DECISIONS.md | 39 ++ .../tests/durable-spawn.test.ts | 366 ++++++++++++++++++ 6 files changed, 654 insertions(+), 140 deletions(-) create mode 100644 packages/durable-streams/tests/durable-spawn.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 3892a43c7..fa2a09f0e 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,9 @@ * desynchronise the journal on the next run. */ -import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation } from "effection"; -import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -226,32 +226,45 @@ function presentGrid( const startupFailed = withResolvers(); let attached = false; - // Every pane's work, in authored order. The children are allocated in this - // order too, so a pane's durable identity follows its ordinal rather than - // the order the runtime happened to schedule it in. - const paneWorkflows = work.map((pane, index) => { + for (const pane of work) { + yield* composite.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; - return function* (): Operation { - const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + panes.push( + yield* paneChild(function* (): Operation { + return yield* runPane(pane, claim, composite, readiness, request, index); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a satisfied readiness latch. + for (const [index, task] of panes.entries()) { + yield* spawn(function* () { + const outcome = yield* task; outcomes[index] = outcome; - yield* composite.update(pane.ordinal, outcome.status); + // A pane restored from its retained outcome counts as started: it did + // start, on the run that recorded it. + grid.claims[index]!.ready(); + yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has // been shown, so the grid fails closed rather than attaching what is // left. After it, the failure is this pane's status alone. startupFailed.reject(new Error(outcome.reason)); } - return outcome; - }; - }); - - for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + }); } - // Spawned as one task so the coordinator below can reach the readiness - // barrier, attach, and wait for the reader while the panes are still live. - const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane @@ -290,8 +303,8 @@ function presentGrid( yield* composite.update(pane.ordinal, "closed"); outcomes[index] = { status: "closed", reason: "" }; } + yield* panes[index]!.halt(); } - yield* panes.halt(); const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); @@ -339,36 +352,33 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st } /** - * Run every pane as a durable child of the grid, in authored order. + * Run one pane as a durable child of the grid. * * A pane's identity is derived from the grid's coroutine and its authored * ordinal, never from a title, a schedule, or a provider identifier — so a * resumed run restores a completed pane as its outcome without re-running it, * and continues an incomplete one from its own history. * - * `durableAll` rather than `durableSpawn`: the latter returns a task spawned - * inside the ephemeral effect's own scope, and that scope closes as the effect - * resolves, so awaiting the task throws `halted`. It has no call sites or tests - * upstream; `durableAll` is the primitive that is exercised. + * `durableSpawn` rather than a combinator, because the grid owns the panes + * itself: it has to reach the readiness barrier and attach while they are still + * live, and cancel them one at a time when the reader leaves. A retained + * cancelled pane resumes its remaining work rather than suspending, which is + * `durableSpawn`'s policy for a spawned region. * - * Without a journal there are no children to derive, and the work simply runs. + * Without a journal there is no child to derive, and the work simply runs. */ -function paneChildren( - workflows: readonly (() => Operation)[], -): Operation { - return (function* (): Operation { +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* all(workflows.map((workflow) => workflow())); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableAll( - workflows.map( - (workflow) => - function* (): Workflow { - return yield* ephemeral(workflow()); - }, - ), - ); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); })(); } @@ -386,11 +396,9 @@ export function durableGrid(live: () => Operation): Operation([ - function* (): Workflow { - return yield* ephemeral(live()); - }, - ]); - return retained!; + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(live()); + }); + return yield* task; })(); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index ad44b1e74..1cc5e340f 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -89,6 +89,15 @@ interface DocumentRun { journal: DurableEvent[]; } +/** + * The mark a document records once it is past the grid. + * + * It fires whether the grid ran or replayed, so a harness can stop the run at + * the same point either way — and a replay that hangs never reaches it, which + * is a failure rather than something a deadline would quietly pass. + */ +const PAST_THE_GRID = "past the grid"; + function useDir(): Operation { return resource(function* (provide) { const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); @@ -100,7 +109,11 @@ function useDir(): Operation { } /** The controlled interactive child, and a tripwire. */ -function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, +): Operation { return registerComponents([ { name: "Interactive", @@ -129,6 +142,7 @@ function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; const opened = withResolvers(); - yield* useGridComponents(ran); + // Two signals, neither a deadline: the grid opened on a live run, or the + // document reached the sibling after it — which is what a replayed grid + // does. A replay that hangs reaches neither and hangs the row, rather than + // passing on a timer. + yield* useGridComponents(ran, [], (mark) => { + if (mark === PAST_THE_GRID) { + opened.resolve(); + } + }); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: () => suspend(), + close: options.close === true ? immediateClose() : () => suspend(), + ...(options.shell === undefined ? {} : { shell: options.shell }), *onPrepare(asked) { requests.push(asked); yield* sleep(0); @@ -365,7 +393,7 @@ function runInterrupted( // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* race([opened.operation, sleep(120)]); + yield* opened.operation; yield* sleep(5); yield* task.halt(); return { @@ -974,12 +1002,14 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(run.outcome.ok).toBe(true); - // Ready enough to attach, and settled enough to be `succeeded`. + // Ready at the spawn event, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. expect(run.events).toContain("attach:0"); expect(run.events).toContain("state:0:0:succeeded"); - // A pane that already settled keeps the status it settled to. - expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); - expect(run.events).not.toContain("state:0:0:running"); + expect(run.events.indexOf("state:0:0:succeeded")).toBeGreaterThan( + run.events.indexOf("attach:0"), + ); }); it("TG9: a preparation failure starts no pane at all", function* () { @@ -1080,61 +1110,65 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); - /** Every terminal-grid entry the journal holds. */ - function gridEntries(run: DocumentRun): DurableEvent[] { - return run.journal.filter( - (event) => - event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), - ); - } + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const paneIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order, and both beneath one grid child. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); - it("TG15: a completed grid replays without contacting a provider at all", function* () { + it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - // The grid opened and its panes ran; the document was then interrupted, so - // the root reached no outcome and a resumed run reaches the grid again. + // Interrupted while the grid is open, so its child records a cancelled + // close. Under the repaired spawn policy the resumed run continues that + // region instead of suspending on it forever. const first = yield* runInterrupted(dir, GRID, stream); expect(first.requests).toHaveLength(1); const second = yield* runInterrupted(dir, GRID, stream); - // The region's retained result is the answer: no provider was asked for a - // grid, no pane content expanded, and nothing was displayed. - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); + // A fresh composite, built by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); }); - it("TG15: a completed grid replays even where no provider could open one", function* () { + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { + spawned(); + yield* suspend(); + return { exitCode: 0 }; + }; - yield* runInterrupted(dir, GRID, stream); - // This host installs no provider at all. A replay that contacted one would - // refuse here; the retained result does not need one. - const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + expect(first.ran).toContain("left ran"); - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); - }); - - it("TG16: each pane is a durable child of the grid, in authored order", function* () { - const dir = yield* useDir(); - const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); - const closes = first.journal.filter((event) => event.type === "close"); - const ids = closes.map((event) => String(event.coroutineId)).sort(); - // Two pane children beneath one grid child: `..`. - const paneIds = ids.filter((id) => id.split(".").length >= 3); - expect(paneIds).toHaveLength(2); - const [left, right] = paneIds; - // Authored order, not scheduling order. - expect(left!.endsWith(".0")).toBe(true); - expect(right!.endsWith(".1")).toBe(true); - expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + // The completed pane came back from its retained outcome: its body did not + // run again. + expect(second.ran).not.toContain("left ran"); + // The incomplete shell starts again under current host policy, claiming no + // continuity with the terminal history it had before. + expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); it("TG17: the layout is recorded before any provider is contacted", function* () { @@ -1142,32 +1176,24 @@ describe("Tier TG — durability and replay", () => { const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layout = run.journal.find( + const layoutIndex = run.journal.findIndex( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - expect(layout).toBeDefined(); - // Written before the grid child that opens anything, so a comparison - // against it happens while nothing has been presented. - const layoutIndex = run.journal.indexOf(layout!); - const opened = run.journal.findIndex( + const firstChildClose = run.journal.findIndex( (event) => event.type === "close" && String(event.coroutineId).includes("."), ); expect(layoutIndex).toBeGreaterThan(-1); - if (opened > -1) { - expect(layoutIndex).toBeLessThan(opened); + if (firstChildClose > -1) { + expect(layoutIndex).toBeLessThan(firstChildClose); } }); - it("TG17: the retained record holds provider-neutral facts only", function* () { + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const entries = gridEntries(run); - expect(entries.length).toBeGreaterThan(0); - const written = JSON.stringify(run.journal); - // The layout the author wrote, and nothing about whatever presented it. expect(written).toContain('"columns":2'); expect(written).toContain('"Left"'); for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 179f2cc88..dcd203592 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -36,7 +36,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -53,13 +53,39 @@ import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; * IMPORTANT: This must be called inside a spawn() so it gets its own scope. * The caller is responsible for spawn(). */ +/** + * What a spawned region does with a retained `Close(cancelled)`. + * + * The two answers are not preferences; they follow from who is going to cancel + * the child on this run. + * + * - `"combinator-cancels"` — `durableRace` and `durableAll`. A retained + * cancelled child is a race loser or a fail-fast sibling, and the same + * combinator will cancel it again, so the child reproduces the original run + * by suspending until it does. + * - `"resume"` — `durableSpawn`. The caller owns the task, and a retained + * cancelled child under a parent that never completed means the *run* was + * interrupted, not that a combinator chose against this child. Nothing will + * cancel it a second time, so suspending would hang the resumed run forever. + * It continues its own retained history instead and finishes the work it had + * left, writing the Close its second life actually reached. + * + * The policy belongs to the combinator, not to its caller: it is fixed at each + * call site below and there is no way to ask for another one. + */ +type CancelledChildPolicy = "combinator-cancels" | "resume"; + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, + cancelledPolicy: CancelledChildPolicy = "combinator-cancels", ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); + // Set when this run continued a retained cancelled child, so its teardown + // writes the Close it reached rather than leaving the stale cancelled one. + let resumedFromCancelled = false; // Short-circuit: child already completed in a previous run. // NOTE: Replay guard validation is not bypassed here — the check phase @@ -73,22 +99,22 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else { - // cancelled — this child was cancelled in a previous run (e.g., - // a race loser). Instead of throwing, we suspend forever. The - // parent combinator (race/all) will cancel this child as part of - // normal structured concurrency teardown, just like the original - // run. The Close(cancelled) event already exists in the journal, - // so we skip re-emitting it (the ensure teardown checks for this). - // - // INVARIANT: This branch is only reachable when a parent combinator - // (durableRace or durableAll with a failed sibling) will cancel this - // child. Close(cancelled) in the journal means the child was - // previously cancelled by structured concurrency, so on replay the - // same combinator will cancel it again. This cannot deadlock. + } else if (cancelledPolicy === "combinator-cancels") { + // A race loser, or a sibling `all` cancelled when another failed. The + // same combinator cancels it again on this run, so reproducing the + // original execution means blocking until it does — in the live run this + // child never threw, it simply stopped. The Close(cancelled) event + // already exists, so the teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; + } else { + // A spawned region whose run was interrupted. Nobody is going to cancel + // this child a second time, so suspending would hang the resumed run. + // Forget the retained close — its yields stay replayable, so the child + // continues its own history — and fall through to run the rest. + resumedFromCancelled = true; + replayIndex.reopen(childId); } } @@ -137,8 +163,10 @@ function* runDurableChild( } // Don't re-emit a Close event if one already exists in the journal - // (e.g., a cancelled child being replayed via suspend()). - if (!replayIndex.hasClose(childId)) { + // (e.g., a cancelled child being replayed via suspend()). A child that + // resumed from a retained cancelled Close is the exception: the record it + // reached this time is the one that describes the work that actually ran. + if (resumedFromCancelled || !replayIndex.hasClose(childId)) { yield* appendDurableEvent(childCtx, closeEvent); } }); @@ -209,33 +237,63 @@ function* runDurableChild( } /** - * Spawn a durable child workflow. + * Spawn a durable child workflow, and hand its task back to the caller. * - * Assigns a deterministic coroutine ID (parentId.N), sets up DurableContext - * on the child scope, and ensures Close events are emitted. + * Assigns a deterministic coroutine ID (`parentId.N`) in call order, sets up + * DurableContext on the child scope, and ensures a Close event is emitted. * - * Returns a Task that can be yield*-ed to get the child's result. + * **The task outlives this call.** It is started in the *routine's* own scope + * rather than inside the effect that returns it, so the caller can await it, + * cancel it, or leave it running beside other work. Spawning it through + * `ephemeral()` instead — as this once did — put it in a scope that closed as + * soon as the effect resolved, so every `yield* task` threw `halted`. * - * Returns Workflow> via ephemeral() — the infrastructure effects - * (useScope, spawn) are durable-safe scope setup that doesn't need - * journaling and re-runs correctly on replay. + * A retained `Close(cancelled)` here means the run was interrupted, not that a + * combinator chose against this child, so the child resumes its remaining work. + * See `CancelledChildPolicy`. */ export function durableSpawn( childWorkflow: () => Workflow, ): Workflow> { - return ephemeral( - (function* (): Operation> { - const scope = yield* useScope(); - const ctx = scope.expect(DurableContext); + return (function* (): Workflow> { + // Reading the context and allocating the child id is ordinary scope setup: + // no journal entry, and it re-runs identically on replay. Allocation is + // synchronous and in call order, so ids follow the order children are + // asked for rather than the order they are scheduled. + const ctx = yield* ephemeral(readDurableContext()); + const childIndex = ctx.childCounter++; + const childId = `${ctx.coroutineId}.${childIndex}`; + return (yield createSpawnEffect(() => + runDurableChild(childWorkflow, childId, ctx, "resume"), + )) as Task; + })(); +} - // Assign deterministic child ID - const childIndex = ctx.childCounter++; - const childId = `${ctx.coroutineId}.${childIndex}`; +function* readDurableContext(): Operation { + const scope = yield* useScope(); + return scope.expect(DurableContext); +} - // Spawn the child with durable wrapping - return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); - })(), - ); +/** + * Start `child` in the routine's own scope and resolve with its task. + * + * The routine's scope is the workflow's, so the task lives for as long as the + * workflow does — that is the whole repair. Nothing is journaled: the child + * writes its own entries under its own coroutine id. + * + * A child that fails fails the workflow that spawned it, exactly as an ordinary + * Effection `spawn` does. What replay must not do is reach the child's body + * again to discover that. + */ +function createSpawnEffect(child: () => Operation): DurableEffect> { + return { + description: "durable-spawn", + effectDescription: { type: "ephemeral", name: "durable-spawn" }, + enter(resolve, routine) { + resolve({ ok: true, value: routine.scope.run(child) }); + return (exit) => exit({ ok: true, value: undefined as undefined }); + }, + }; } /** diff --git a/packages/durable-streams/replay-index.ts b/packages/durable-streams/replay-index.ts index 65e850866..7eeeaf672 100644 --- a/packages/durable-streams/replay-index.ts +++ b/packages/durable-streams/replay-index.ts @@ -77,6 +77,23 @@ export class ReplayIndex { this.disabled.add(coroutineId); } + /** + * Forget the retained Close for one coroutine, keeping its retained yields. + * + * A spawned region whose run was interrupted continues the work it had left, + * so its retained history must stay replayable while its retained + * `Close(cancelled)` stops standing in the way — otherwise the divergence + * guard reads the extra effects as a coroutine continuing past its own close. + * + * Deliberately narrower than `disableReplay`, which would throw the history + * away and re-run the child from the beginning. Internal: nothing exports + * this, because deciding that a closed coroutine may continue is the + * combinator's, and never a caller's. + */ + reopen(coroutineId: CoroutineId): void { + this.closes.delete(coroutineId); + } + /** Returns true if replay has been disabled for this coroutine. */ isReplayDisabled(coroutineId: CoroutineId): boolean { return this.disabled.has(coroutineId); diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index cec63a894..6e3cb8479 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -489,6 +489,45 @@ Updated before completion of every phase and committed at the end of each phase. finally block skips re-emitting it (checked via `replayIndex.hasClose()`). - **Consequences:** Replay of race losers is invisible — they block and get cancelled just like the original run. No duplicate Close events. +- **Superseded in part by DEC-039.** The invariant recorded here assumed every + retained `Close(cancelled)` belongs to a child a combinator will cancel + again. That is true of `durableRace` and `durableAll`, and false of + `durableSpawn`. + +## DEC-039: A spawned region resumes a retained cancelled child + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands its task to the caller, so nothing cancels + the child on the caller's behalf. Under DEC-024 a retained + `Close(cancelled)` made such a child `suspend()` forever, and no combinator + was ever going to cancel it a second time — the resumed run hung. The + invariant "this branch is only reachable when a parent combinator will cancel + this child" was simply not true once regions could be spawned. +- **Decision:** `runDurableChild` takes an explicit `CancelledChildPolicy`, + fixed at each combinator's call site and never chosen by a caller: + - `"combinator-cancels"` — `durableRace` and `durableAll` keep DEC-024 + exactly. A retained race loser or fail-fast sibling still suspends until + its combinator cancels it again. + - `"resume"` — `durableSpawn`. A retained `Close(cancelled)` under a parent + that never completed means the *run* was interrupted, not that a combinator + chose against this child, so the child continues the work it had left. +- **Rationale:** The two cases differ in who is going to act next, which is a + fact about the region rather than a preference. Reading a cancelled close as + "interrupted" where nothing will cancel it again is the only answer that + terminates. +- **Mechanism:** Resuming calls the internal `ReplayIndex.reopen(coroutineId)`, + which forgets that coroutine's retained Close while keeping its retained + yields — so the child continues its own history rather than restarting, and + the divergence guard does not read the remaining effects as a coroutine + continuing past its own close. It is deliberately narrower than + `disableReplay`, and neither is exported: deciding that a closed coroutine may + continue belongs to the combinator. +- **Consequences:** A resumed child writes the Close its second life reached, + replacing the retained cancelled one. `durableSpawn` also starts its child in + the routine's own scope rather than inside the `ephemeral` effect that + returns the task, so the task outlives the call and can be awaited or halted; + previously every `yield* task` threw `halted`. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts new file mode 100644 index 000000000..4dcd3a919 --- /dev/null +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -0,0 +1,366 @@ +/** + * `durableSpawn` — a durable child the caller owns. + * + * `durableAll` and `durableRace` own their children: they start them, wait for + * them, and cancel them. `durableSpawn` does not — it hands the task back, and + * everything here follows from that. + * + * Two things are easy to get wrong and are checked directly rather than + * inferred. The task has to outlive the call that produced it, or awaiting it + * throws `halted` before the child has done anything. And a retained + * `Close(cancelled)` means something different here than it does under a + * combinator: nobody is going to cancel this child a second time, so a child + * that suspended waiting for that would hang the resumed run forever. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { sleep, spawn, suspend } from "effection"; +import type { Operation } from "effection"; + +import { durableRun } from "../run.ts"; +import { durableAll, durableRace, durableSpawn } from "../combinators.ts"; +import { durableCall } from "../operations.ts"; +import { ephemeral } from "../ephemeral.ts"; +import { InMemoryStream } from "../stream.ts"; +import type { Workflow } from "../types.ts"; + +/** A workflow that records that it ran and returns `value`. */ +function marking(marks: string[], mark: string, value: string): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push(mark); + return value; + })(), + ); + }; +} + +describe("durableSpawn — lifetime", () => { + it("returns a task that is still live, and awaitable", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "child", "spawned")); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("spawned"); + expect(marks).toEqual(["child"]); + }); + + it("keeps the task running beside its caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* sleep(5); + marks.push("child finished"); + return "late"; + })(), + ); + }); + // The caller does its own work first. A task spawned into a scope that + // closed with the effect would already be dead by now. + yield* ephemeral( + (function* (): Operation { + marks.push("caller working"); + })(), + ); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("late"); + expect(marks).toEqual(["caller working", "child finished"]); + }); + + it("lets the caller cancel the task it was given", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("child started"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + marks.push("caller halted it"); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(marks).toEqual(["child started", "caller halted it"]); + const closes = (yield* stream.readAll()).filter((event) => event.type === "close"); + // Cancelling the task records the child's cancellation, exactly as a + // combinator-cancelled child records one. + expect(closes.some((event) => event.result.status === "cancelled")).toBe(true); + }); + + it("allocates child ids in the order children are asked for", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const first = yield* durableSpawn(marking([], "a", "a")); + const second = yield* durableSpawn(marking([], "b", "b")); + yield* ephemeral(first); + yield* ephemeral(second); + return "done"; + }, + { stream }, + ); + + const ids = (yield* stream.readAll()) + .filter((event) => event.type === "close") + .map((event) => String(event.coroutineId)); + expect(ids).toContain("root.0"); + expect(ids).toContain("root.1"); + }); +}); + +describe("durableSpawn — replay", () => { + it("replays a completed child without running it again", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const first = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + expect(first).toBe("value"); + expect(marks).toEqual(["ran"]); + + const second = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + + // The retained result, and the workflow never entered. + expect(second).toBe("value"); + expect(marks).toEqual(["ran"]); + }); + + it("resumes an interrupted child rather than hanging on its cancelled close", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + // A run interrupted while the child is still working: the whole run is + // halted, so the child records Close(cancelled) and the parent records no + // Close at all. A parent that completed would replay its own result and the + // child would never be reached. + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* interrupted.halt(); + + expect(marks).toEqual(["first life"]); + + // The resumed run. Nothing is going to cancel this child again, so a child + // that suspended on the retained cancelled close would never settle. + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("second life"); + return "finished"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("finished"); + expect(marks).toEqual(["first life", "second life"]); + // The record now describes the life that actually finished. + const closes = (yield* stream.readAll()).filter( + (event) => event.type === "close" && String(event.coroutineId) === "root.0", + ); + expect(closes[closes.length - 1]?.result.status).toBe("ok"); + }); + + it("continues a resumed child's own retained history", function* () { + const calls: string[] = []; + const stream = new InMemoryStream(); + const step = (name: string) => + durableCall(name, function* () { + calls.push(name); + return name; + }); + + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + yield* step("first"); + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* interrupted.halt(); + + expect(calls).toEqual(["first"]); + + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + yield* step("first"); + yield* step("second"); + return "done"; + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("done"); + // `first` came from the child's own retained history; only the work it had + // left ran again. + expect(calls).toEqual(["first", "second"]); + }); +}); + +describe("durableSpawn — the combinators keep their own policy", () => { + it("a retained race loser still suspends until the race cancels it", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + marks.length = 0; + + // The loser's Close(cancelled) is retained. On replay it suspends and the + // race cancels it again, exactly as the first run did — it does not resume. + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); + + it("a retained fail-fast sibling still suspends under all()", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const both = () => + durableAll([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("failing"); + throw new Error("sibling failed"); + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("cancelled sibling"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + let first: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + first = error; + } + expect(first instanceof Error ? first.message : "").toContain("sibling failed"); + + marks.length = 0; + let second: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + second = error; + } + + expect(second instanceof Error ? second.message : "").toContain("sibling failed"); + // Neither child re-ran: the failure replayed and the sibling suspended. + expect(marks).toEqual([]); + }); +}); From 271c6272c9319d1ac1100e9e6d434624077a7dc5 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:52:57 -0400 Subject: [PATCH 07/19] =?UTF-8?q?=F0=9F=93=9D=20Decide=20the=20cancelled-c?= =?UTF-8?q?hild=20contract=20and=20TG17's=20replay=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions exposed while implementing #730, and no implementation. **DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on every retained `Close(cancelled)` under an incomplete parent, which revives work a caller deliberately halted: the record of a deliberate `task.halt()` and the record of an interrupted run are the same event. The cancelled close now carries `cancellation: "caller"` or `"unwound"`, written by whichever path cancelled the child, and `"resume"` continues only `"unwound"`. A deliberate stop suspends, which is DEC-024's reproduction argument applied to a caller instead of a combinator; a legacy record with no reason reads as `"caller"`, because refusing to revive is the safe direction. The reason is retained evidence, not authority: nothing outside `runDurableChild` reads it, and no caller chooses a policy. Terminal grids need nothing wider. A grid halts its pane tasks at close, so those retain `"caller"` — and the grid child completes, so a resumed run short-circuits the region and never reaches them. The case that must resume, an interrupted run, unwinds and retains `"unwound"`. **TG17 narrows to the resolved layout.** A continuation executes the root the journal retained; the supplied source is not read, compared or refused (proved in #722). A grid's authored structure — pane count, order, form — is therefore fixed for the life of a journal and cannot differ between runs, so comparing it compares a value with itself, which is why the refusal never fired. What a fixed retained document still resolves differently is `columns` and each `title`, through prop-borne values, since props are not restored. Those refuse before the lease and before provider contact. Authored-structure change is a root-definition compatibility question, not a grid one. Root-definition authority is preserved rather than overridden by a pre-replay comparison against the current file, and the versioned root boundary that would refuse a changed source stays open work. --- architecture.md | 27 ++++++++++- packages/durable-streams/specs/DECISIONS.md | 47 +++++++++++++++++++ .../durable-streams/specs/durable-streams.md | 8 ++++ specs/executable-mdx-spec.md | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/architecture.md b/architecture.md index a6b73181b..0d8f22608 100644 --- a/architecture.md +++ b/architecture.md @@ -3623,8 +3623,31 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. -Partial replay first compares the exact authored layout and refuses divergence -before provider work. It rebuilds a fresh provider composite: completed pane +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 6e3cb8479..8c422ffbf 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -528,6 +528,53 @@ Updated before completion of every phase and committed at the end of each phase. the routine's own scope rather than inside the `ephemeral` effect that returns the task, so the task outlives the call and can be awaited or halted; previously every `yield* task` threw `halted`. +- **Amended by DEC-040.** As first written, `"resume"` fired on *every* retained + `Close(cancelled)` under an incomplete parent. That is too wide: a caller may + deliberately halt the task it owns, and the record of that is + indistinguishable from the record of an interrupted run. DEC-040 supplies the + missing evidence and narrows `"resume"` to involuntary cancellation. + +## DEC-040: A cancelled child records why it was cancelled + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands the task to its caller, and the caller may + call `task.halt()` on purpose — a region it decided to stop. If the run is + later interrupted before the parent completes, the journal holds + `Close(cancelled)` for that child and nothing else. DEC-039's `"resume"` + policy therefore revives work the caller deliberately cancelled, on every + subsequent resumed run. +- **Decision:** The cancelled close carries **why**, written by whichever path + cancelled the child: + - `cancellation: "caller"` — the owner called `halt()` on the task + `durableSpawn` returned. A deliberate stop. + - `cancellation: "unwound"` — anything else: the routine's scope unwinding, + the run being interrupted, the host going away. Involuntary. + + A record with no `cancellation` member is legacy and reads as `"caller"`, + because refusing to revive is the safe direction: it reproduces the original + run rather than performing work nobody asked for twice. + + `runDurableChild`'s policies then read: + - `"combinator-cancels"` (`durableRace`, `durableAll`) — suspend, whatever the + reason. Unchanged from DEC-024. + - `"resume"` (`durableSpawn`) — resume **only** `"unwound"`. A `"caller"` + cancellation suspends, exactly as a combinator-cancelled child does. +- **Rationale:** Suspending is the faithful reproduction of a deliberate halt: + the caller's control flow is deterministic, so it reaches the same + `task.halt()` again and cancels the child a second time — which is DEC-024's + argument, applied to a caller instead of a combinator. A caller that instead + *awaits* a task it previously halted has diverged, and divergence is the + honest answer there rather than a silent revival. +- **Consequences:** Terminal grids get what they need without reviving anything + deliberately stopped. A grid halts each pane task when the reader closes, so + those panes retain `"caller"` — and the grid child completes, so a resumed run + short-circuits the whole region and never reaches them. The case that must + resume — the run interrupted while the grid is open — unwinds the grid and + pane children, retains `"unwound"`, and continues. +- **Scope:** The reason is retained evidence, not authority. Nothing reads it + from outside `runDurableChild`, no public API exposes it, and no caller + chooses a policy: the policy stays fixed at each combinator's call site. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/specs/durable-streams.md b/packages/durable-streams/specs/durable-streams.md index 5f1b1b5ec..95540ba37 100644 --- a/packages/durable-streams/specs/durable-streams.md +++ b/packages/durable-streams/specs/durable-streams.md @@ -130,6 +130,14 @@ function* runWithDurability(operation, producer) { } ``` +A `cancelled` Close also records **why**, because two very different things +produce one: a caller deliberately halting a task it owns, and a run being +interrupted. `cancellation: "caller"` is the deliberate stop; `"unwound"` is +everything involuntary. A resumed spawned region continues an `"unwound"` child +and reproduces a `"caller"` one by suspending, so nothing deliberately stopped +is silently performed again. A record with no `cancellation` member reads as +`"caller"`. See DEC-040. + For **Close events**, the ordering discipline is: ```typescript diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index e804f6b4c..1b4e690fa 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11511,7 +11511,7 @@ test derives a core result from a provider identifier. | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | -| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | ### Tier CR — Component registration and resolution From de092e773d262459b8c03a668b107081620ff408 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 16:45:40 -0400 Subject: [PATCH 08/19] =?UTF-8?q?=E2=9C=A8=20Implement=20DEC-040,=20and=20?= =?UTF-8?q?complete=20TG15=20and=20TG17=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **DEC-040.** A cancelled Close now records why: `cancellation: "caller"` when the owner halts the task `durableSpawn` returned, `"unwound"` for anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate stop suspends until the caller's deterministic control flow halts it again, and a record with no reason reads as `"caller"` so nothing legacy is revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason says. The halt is intercepted without changing the public `Task` surface: the returned task carries every member the real one defines, copied with its prototype, and only `halt` is replaced. A proxy cannot do this — a task's members are read-only and non-configurable, so a `get` trap is required to hand back exactly what the target holds. The reason had to survive three boundaries that were dropping it: the protocol parser, the observable copy, and — the one that actually mattered — `detachResult`, which froze every cancellation down to `{ status }`. **TG15.** The harness's `attached` and `pastGrid` signals are now separate, and a run that expects its grid to complete waits for the sibling *after* the grid before halting the root at ``. That is what leaves a completed grid child under an incomplete root, which is the only state in which a completed region can be observed replaying at all. Both a successful grid and a contained failed one replay their exact retained result with no provider, pane content, shell or launcher work, and each row asserts the grid child genuinely recorded a terminal close. No timeouts. **TG17.** Prop-borne `columns` and `title` change independently against one fixed retained document — the only things a fixed retained root can still resolve differently — and each refuses with zero provider observation. For supplied-file changes to pane count, order and form, the continuation opens the retained structure rather than the file's, asserted request-for-request. The retained record carries every authored pane's ordinal, title, form and derived position. `readLayout()` parses totally: the layout object and every pane field, with missing, extra, mistyped, out-of-position and self-inconsistent records all refused rather than half-read. Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13, provider 10. Packages: durable-streams 33, core 349, runtime 15, workflow 172. --- packages/core/src/terminal/journal.ts | 82 +++++- packages/core/tests/terminal-grid.test.ts | 277 ++++++++++++++++-- packages/durable-streams/combinators.ts | 112 +++++-- packages/durable-streams/mod.ts | 1 + packages/durable-streams/parse.ts | 16 +- packages/durable-streams/retained.ts | 19 +- .../tests/durable-spawn.test.ts | 202 +++++++++++++ packages/durable-streams/types.ts | 14 +- 8 files changed, 669 insertions(+), 54 deletions(-) diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index cedd0b39a..3ee4fd8d4 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -66,17 +66,87 @@ function* append(description: EffectDescription, value: Json): Workflow }); } -/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +/** + * The layout a journal entry holds, parsed member by member. + * + * Total: every field is read and checked, and anything the record does not say + * exactly — a missing member, a member of the wrong kind, an extra one, a pane + * whose ordinal is not its position, a row or column that does not follow from + * the columns it claims — makes the record unreadable rather than half-read. A + * layout is what a resumed run is held to, so a record that cannot be believed + * in full must not be believed in part. + */ function readLayout(value: unknown): RetainedLayout | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { return undefined; } - const fields: Record = Object.fromEntries(Object.entries(value)); - const { columns, rows, panes } = fields; - if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.panes; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const panes: RetainedLayout["panes"] = []; + for (const [index, entry] of list.entries()) { + const pane = readPane(entry, index, columns); + if (pane === undefined) { + return undefined; + } + panes.push(pane); + } + // The rows a grid claims have to be the rows its panes need, or the record + // describes a grid nothing could have derived. + if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { return undefined; } - return { columns, rows, panes: panes as RetainedLayout["panes"] }; + return { columns, rows, panes }; +} + +/** One retained pane, checked against the position it claims to occupy. */ +function readPane( + value: unknown, + index: number, + columns: number, +): RetainedLayout["panes"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) { + return undefined; + } + const { ordinal, title, form, row, column } = record; + if (ordinal !== index) { + return undefined; + } + if (typeof title !== "string" || title.length === 0) { + return undefined; + } + if (form !== "paired" && form !== "self-closing") { + return undefined; + } + // Derived, not asserted: a position that does not follow from the ordinal and + // the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { ordinal, title, form, row, column }; +} + +/** The members of a JSON object, or `undefined` for anything else. */ +function members(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +/** Whether a record carries exactly these member names, and no others. */ +function onlyNames(record: Record, names: readonly string[]): boolean { + const present = Object.keys(record); + return present.length === names.length && names.every((name) => name in record); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } /** How two layouts differ, in the words an author can act on. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 1cc5e340f..9da63e051 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -248,6 +248,8 @@ function runDocument( composite?: ControlledCompositeOptions; /** Where `` records that it started. */ slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { @@ -292,7 +294,12 @@ function runDocument( yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); const stream = options.stream ?? new InMemoryStream(); - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); const outcome = yield* execution; const output = yield* forEach(function* (_chunk: string) {}, execution.output); return { @@ -327,6 +334,10 @@ function heldDocument(columns: number, panes: string[]): string { ...panes, "", "", + // The sibling after the grid. It runs whether the grid ran or replayed, so + // a harness can wait for the document to have moved past the region. + ``, + "", "", "", ].join("\n"); @@ -348,20 +359,26 @@ function runInterrupted( shell?: ControlledCompositeOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - const opened = withResolvers(); - // Two signals, neither a deadline: the grid opened on a live run, or the - // document reached the sibling after it — which is what a replayed grid - // does. A replay that hangs reaches neither and hangs the row, rather than - // passing on a timer. + // Two signals, kept apart because they mean different things. `attached` + // says a grid opened on this run; `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does and what a + // completed-region journal needs to be waited for. Neither is a deadline: a + // replay that hangs reaches neither and hangs the row rather than passing + // on a timer. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const destroyed = withResolvers(); yield* useGridComponents(ran, [], (mark) => { if (mark === PAST_THE_GRID) { - opened.resolve(); + pastGrid.resolve(); } }); yield* installControlledLauncher(); @@ -374,11 +391,15 @@ function runInterrupted( requests.push(asked); yield* sleep(0); }, - // Attach is the signal, not `running`: a pane that settles before the - // barrier keeps its own status and never becomes runnable. + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. // deno-lint-ignore require-yield *onAttach() { - opened.resolve(); + attached.resolve(); + }, + // deno-lint-ignore require-yield + *onDestroy() { + destroyed.resolve(); }, }); } @@ -387,13 +408,36 @@ function runInterrupted( const path = join(dir, "doc.md"); yield* writeTextFile(path, source); const task: Task = yield* spawn(function* () { - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); yield* execution; }); // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* opened.operation; + // `close: true` means the grid is expected to complete, so the run is + // halted only once the document has moved past it — that is what leaves a + // completed grid child under an incomplete root. Otherwise the grid is + // expected to stay open, and attaching is as far as it gets. + yield* race([ + options.close === true ? pastGrid.operation : attached.operation, + (function* (): Operation { + yield* sleep(1500); + // deno-lint-ignore no-console + const evts = yield* stream.readAll(); + // deno-lint-ignore no-console + console.log( + "PROBE3 closes", + JSON.stringify( + evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), + ), + ); + })(), + ]); yield* sleep(5); yield* task.halt(); return { @@ -1109,6 +1153,81 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); + /** + * A grid whose only pane never starts, with its failure contained. + * + * `` keeps the document going, so the root reaches no outcome of + * its own and a resumed run reaches the region rather than replaying the root + * wholesale. + */ + const CONTAINED_FAILURE = [ + "", + "", + 'nothing interactive here', + "", + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + /** + * Whether the grid child reached a terminal record of its own. + * + * `ok` or `err`: both are outcomes the region settled on. Only a cancelled + * close, or no close at all, means it was interrupted — and that is the + * difference this row exists to depend on. + */ + function completedGrid(run: DocumentRun): boolean { + return run.journal.some( + (event) => + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + (event.result.status === "ok" || event.result.status === "err"), + ); + } + + it("TG15: a completed successful grid replays its exact result, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, GRID, stream, { close: true }); + expect(first.requests).toHaveLength(1); + // The region genuinely completed: without that this row would be about an + // interrupted grid resuming, which is TG16's claim rather than this one. + expect(completedGrid(first)).toBe(true); + + const second = yield* runInterrupted(dir, GRID, stream, { close: true }); + + // No provider was asked for a grid, nothing was prepared or attached, no + // pane content expanded, no shell or launcher ran, and nothing displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG15: a contained failed grid replays the same failure, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).toBe(true); + + // No provider at all on the resumed run: a replay that contacted one would + // refuse, and the retained result does not need one. + const second = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + close: true, + provider: false, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); @@ -1171,21 +1290,137 @@ describe("Tier TG — durability and replay", () => { expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); - it("TG17: the layout is recorded before any provider is contacted", function* () { + /** + * A grid whose `columns` and first `title` come from props. + * + * A continuation executes the retained root, so the document itself cannot + * change between runs — but props are not restored, so these two values are + * exactly what a fixed retained source can still resolve differently. + */ + const PROP_BORNE = [ + "---", + "props:", + " columns:", + " type: number", + " label:", + " type: string", + "---", + "", + "left", + '', + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + it("TG17: a changed prop-borne column count refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 3, label: "Left" }, + }); + + // Refused before the foreground lease and before the provider: nothing was + // prepared, attached or displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + // A replay refusal, not a run that opened something and then failed. The + // sentence is the divergence report's: a refusal raised while retained + // children are still being replayed loses to it, which is established + // behaviour rather than something this row can change. + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 2, label: "Elsewhere" }, + }); + + expect(second.requests).toEqual([]); + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: an unchanged prop-borne layout is admitted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const props = { columns: 2, label: "Left" }; + + yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + const second = yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + + // The discriminator for the two rows above: the same resolved layout + // resumes and opens a grid, so a refusal there is about the change. + expect(second.requests).toHaveLength(1); + }); + + it("TG17: a continuation opens the retained structure, not the file's", function* () { + const structural: [string, string[]][] = [ + ["pane count", [...PANES, '']], + ["pane order", ['', ...PANES.slice(0, 1)]], + ["pane form", ['', '']], + ]; + + for (const [what, panes] of structural) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + const retained = first.requests[0]!; + + // The file now says something else. A continuation executes the root the + // journal retained, so the grid it opens is the one that was recorded. + const second = yield* runInterrupted(dir, heldDocument(2, panes), stream); + + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); + expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( + `${what}: ${JSON.stringify(retained)}`, + ); + } + }); + + it("TG17: the retained record holds the complete authored pane structure", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layoutIndex = run.journal.findIndex( + const layout = run.journal.find( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - const firstChildClose = run.journal.findIndex( - (event) => event.type === "close" && String(event.coroutineId).includes("."), - ); - expect(layoutIndex).toBeGreaterThan(-1); - if (firstChildClose > -1) { - expect(layoutIndex).toBeLessThan(firstChildClose); - } + expect(layout).toBeDefined(); + const value = + layout?.type === "yield" && layout.result.status === "ok" ? layout.result.value : undefined; + // Every authored pane, with its ordinal, title, form and derived position. + expect(value).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }); }); it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index dcd203592..11d410fa5 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -18,14 +18,7 @@ * See protocol spec §7 (structured concurrency), §10 (race semantics). */ -import { - all as effectionAll, - ensure, - race as effectionRace, - spawn, - suspend, - useScope, -} from "effection"; +import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; import type { Operation, Task } from "effection"; import { DurableContext } from "./context.ts"; import { @@ -36,7 +29,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Cancellation, Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -75,11 +68,42 @@ import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./type */ type CancelledChildPolicy = "combinator-cancels" | "resume"; +/** + * Whether the caller deliberately stopped the child this run (DEC-040). + * + * Written by the task `durableSpawn` hands out — the only place a deliberate + * halt can be observed — and read once, when the cancelled Close is built. A + * combinator supplies none: a child it cancels stopped because a scope came + * down, which is what `"unwound"` means. + */ +interface CancellationEvidence { + deliberate: boolean; +} + +/** How a cancelled child's stop is recorded. */ +function cancellationOf(evidence: CancellationEvidence | undefined): Cancellation { + return evidence?.deliberate === true ? "caller" : "unwound"; +} + +/** + * Why a retained cancelled child stopped. + * + * Absent is `"caller"`: a record written before this evidence existed says + * nothing, and reviving work nobody asked to be redone is the worse mistake. + */ +function retainedCancellation(close: Close): Cancellation { + if (close.result.status !== "cancelled") { + return "caller"; + } + return close.result.cancellation === "unwound" ? "unwound" : "caller"; +} + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", + evidence?: CancellationEvidence, ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); @@ -99,18 +123,25 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else if (cancelledPolicy === "combinator-cancels") { - // A race loser, or a sibling `all` cancelled when another failed. The - // same combinator cancels it again on this run, so reproducing the - // original execution means blocking until it does — in the live run this - // child never threw, it simply stopped. The Close(cancelled) event - // already exists, so the teardown below skips re-emitting it. + } else if ( + cancelledPolicy === "combinator-cancels" || + retainedCancellation(closeEvent) === "caller" + ) { + // Either a combinator's child — a race loser, or a sibling `all` + // cancelled when another failed — or a spawned child its own caller + // deliberately halted. Both are reproduced the same way: block until the + // thing that stopped it last time stops it again. A combinator cancels it + // as it did before; a caller reaches the same `halt()` its deterministic + // control flow reached before. In the live run neither child threw, it + // simply stopped. The Close(cancelled) event already exists, so the + // teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; } else { - // A spawned region whose run was interrupted. Nobody is going to cancel - // this child a second time, so suspending would hang the resumed run. + // A spawned region whose run was interrupted — involuntarily, which is + // what `"unwound"` records. Nobody is going to cancel this child a second + // time, so suspending would hang the resumed run. // Forget the retained close — its yields stay replayable, so the child // continues its own history — and fall through to run the rest. resumedFromCancelled = true; @@ -158,7 +189,7 @@ function* runDurableChild( closeEvent = { type: "close", coroutineId: childId, - result: { status: "cancelled" }, + result: { status: "cancelled", cancellation: cancellationOf(evidence) }, }; } @@ -263,8 +294,10 @@ export function durableSpawn( const ctx = yield* ephemeral(readDurableContext()); const childIndex = ctx.childCounter++; const childId = `${ctx.coroutineId}.${childIndex}`; - return (yield createSpawnEffect(() => - runDurableChild(childWorkflow, childId, ctx, "resume"), + const evidence: CancellationEvidence = { deliberate: false }; + return (yield createSpawnEffect( + () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), + evidence, )) as Task; })(); } @@ -285,17 +318,52 @@ function* readDurableContext(): Operation { * Effection `spawn` does. What replay must not do is reach the child's body * again to discover that. */ -function createSpawnEffect(child: () => Operation): DurableEffect> { +function createSpawnEffect( + child: () => Operation, + evidence: CancellationEvidence, +): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: routine.scope.run(child) }); + resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } +/** + * The same task, with a deliberate `halt()` recorded as it happens. + * + * The caller receives every member the task defines — `then`, `catch`, + * `finally`, the async dispose, the iterator — copied from the task itself + * along with its prototype, so the public surface is the one `Task` has always + * had. Only `halt` is replaced, and only to note that someone stopped the child + * on purpose before stopping it. + * + * Copied rather than proxied: a task's members are read-only and + * non-configurable, and a proxy is required to hand back exactly what the + * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * member is the task's own closure and keeps working on the copy. + */ +function observingHalt(task: Task, evidence: CancellationEvidence): Task { + const members = Object.getOwnPropertyDescriptors(task); + // Replaced in the descriptor map rather than on the finished object: the + // task's own members are non-configurable, so redefining one afterwards + // throws. + members.halt = { + value: () => { + evidence.deliberate = true; + return task.halt(); + }, + enumerable: true, + configurable: false, + writable: false, + }; + const observed: Task = Object.create(Object.getPrototypeOf(task), members); + return observed; +} + /** * Run multiple durable workflows concurrently and wait for all to complete. * diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 03c313c0f..581dabd57 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -8,6 +8,7 @@ // Protocol types export type { + Cancellation, Close, CoroutineId, CoroutineView, diff --git a/packages/durable-streams/parse.ts b/packages/durable-streams/parse.ts index 0747bfdcb..aa74b2b6c 100644 --- a/packages/durable-streams/parse.ts +++ b/packages/durable-streams/parse.ts @@ -125,8 +125,20 @@ function parseResult(value: unknown, path: string): Result { return { status: "err", error: parseSerializedError(members.get("error"), `${path}.error`) }; } case "cancelled": { - requireMemberNames(members, ["status"], path); - return { status: "cancelled" }; + requireMemberNames(members, ["status", "cancellation"], path); + const cancellation = members.get("cancellation"); + if (cancellation === undefined) { + // A record written before this evidence existed. DEC-040 reads the + // absence as a deliberate stop, so nothing it left behind is revived. + return { status: "cancelled" }; + } + if (cancellation !== "caller" && cancellation !== "unwound") { + throw new MalformedDurableEventError( + 'expected "caller" or "unwound"', + `${path}.cancellation`, + ); + } + return { status: "cancelled", cancellation }; } default: throw new MalformedDurableEventError('expected "ok", "err" or "cancelled"', `${path}.status`); diff --git a/packages/durable-streams/retained.ts b/packages/durable-streams/retained.ts index 380224464..ab872178b 100644 --- a/packages/durable-streams/retained.ts +++ b/packages/durable-streams/retained.ts @@ -170,7 +170,17 @@ function detachResult(result: Result): Result { } return Object.freeze({ status, error: detachError(result.error) }); } - return Object.freeze({ status }); + // The reason a cancellation carries is retained evidence, not decoration: a + // resumed spawned region reads it to tell a deliberate stop from an + // interrupted run (DEC-040). Dropping it here would make every retained + // cancellation read as deliberate, which is the safe default but the wrong + // answer for a run that was interrupted. A value that is not one of the two + // it may be is not retained at all, so a malformed record reads as the safe + // default rather than as something it never said. + const cancellation = result.cancellation; + return Object.freeze( + cancellation === "caller" || cancellation === "unwound" ? { status, cancellation } : { status }, + ); } /** @@ -448,5 +458,10 @@ export function consumable(result: Result): Result { if (result.status === "err") { return { status: "err", error: { ...result.error } }; } - return { status: "cancelled" }; + // The reason travels with the copy: a resumed spawned region reads it to tell + // a deliberate stop from an interrupted run (DEC-040), and dropping it here + // would make every retained cancellation look deliberate. + return result.cancellation === undefined + ? { status: "cancelled" } + : { status: "cancelled", cancellation: result.cancellation }; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4dcd3a919..07285ce1c 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -364,3 +364,205 @@ describe("durableSpawn — the combinators keep their own policy", () => { expect(marks).toEqual([]); }); }); + +describe("durableSpawn — why a child was cancelled (DEC-040)", () => { + /** Every cancelled close in a journal, with the reason it recorded. */ + function* cancellations(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + return events + .filter((event) => event.type === "close" && event.result.status === "cancelled") + .map((event) => + event.result.status === "cancelled" ? String(event.result.cancellation) : "", + ); + } + + it("records a deliberate halt as caller", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(yield* cancellations(stream)).toEqual(["caller"]); + }); + + it("records a scope unwinding as unwound", function* () { + const stream = new InMemoryStream(); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* run.halt(); + + expect(yield* cancellations(stream)).toEqual(["unwound"]); + }); + + it("does not revive a child the caller deliberately halted", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // The caller halts the child, then the run is interrupted before it + // completes. Both facts are in the journal; only the first decides. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // The resumed run reaches the same deliberate halt, so the child suspends + // until it does rather than performing work nobody asked to redo. + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(10); + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + + it("reads a record with no reason as caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // A journal written before this evidence existed. + yield* stream.append({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled" }, + }); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("would revive"); + return "revived"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + }); + yield* sleep(10); + yield* run.halt(); + + // Absent evidence is the safe direction: nothing is revived. + expect(marks).toEqual([]); + }); + + it("keeps combinator children on DEC-024 whatever the reason says", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + // The loser's cancellation is involuntary, so it records `unwound` — and a + // combinator child suspends regardless of what the reason says. + expect(yield* cancellations(stream)).toEqual(["unwound"]); + + marks.length = 0; + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); +}); diff --git a/packages/durable-streams/types.ts b/packages/durable-streams/types.ts index 2e79846b2..3523ab53d 100644 --- a/packages/durable-streams/types.ts +++ b/packages/durable-streams/types.ts @@ -23,11 +23,23 @@ export interface SerializedError { stack?: string; } +/** + * Why a cancelled coroutine stopped (DEC-040). + * + * Two very different things produce a cancelled Close, and a resumed run has to + * tell them apart: `"caller"` is an owner deliberately halting the task + * `durableSpawn` handed it, and `"unwound"` is anything involuntary — a scope + * coming down, a run interrupted, a host going away. A record written before + * this evidence existed carries neither, and reads as `"caller"`, because + * refusing to revive is the safe direction. + */ +export type Cancellation = "caller" | "unwound"; + /** Result of an effect or coroutine. */ export type Result = | { status: "ok"; value?: Json } | { status: "err"; error: SerializedError } - | { status: "cancelled" }; + | { status: "cancelled"; cancellation?: Cancellation }; /** Dot-delimited hierarchical coroutine path. See spec §3. */ export type CoroutineId = string; From 871c9bda23704e3fd9e4d167c371ca693f5129a3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:04:51 -0400 Subject: [PATCH 09/19] =?UTF-8?q?=F0=9F=90=9B=20Make=20the=20replay=20evid?= =?UTF-8?q?ence=20deterministic,=20and=20pin=20DEC-040's=20boundaries=20(#?= =?UTF-8?q?730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The harness cannot pass a hung replay any more.** `runInterrupted()` had a 1500ms timer racing its signals, so a replay that hung returned a DocumentRun that looked finished; it also slept a fixed 5ms to let records land. Both are gone. It now waits only on events the run produced: `attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane records — a pane's status is published only after its durable child returned, so counting settled panes is also counting durable pane closes. A replay that hangs now reaches none of them and hangs the row. **TG15's failed case is a real contained failure.** A pane that fails before attachment fails the whole region, so the old document could not both fail and continue. The failing pane is now a shell that starts, waits for attachment, and only then exits badly — contained as that pane's status, with the grid settling as failed and the document carrying on. Both runs capture the printed errors, and the row asserts the replayed run produced the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or launcher work. **DEC-040 gets boundary tests where the evidence actually travels.** `parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy absence absent, and refuses an unrecognised reason at `$.result.cancellation`. `retained.test.ts` proves retention and `consumable()` carry both reasons, leave a legacy absence absent, drop an unrecognised one to the safe default, and that the reason reaches the replay index. The DEC-040 rows in `durable-spawn.test.ts` no longer coordinate by delay: a child says when it is running, and the caller says when it has halted. **Malformed retained layouts** are covered by replaying a real journal with only its layout entry replaced — a missing member, an extra one, a mistyped one, a pane out of position, and a record that disagrees with itself. Each refuses with zero provider observation. The `durableSpawn` doc comment no longer says every retained cancellation is an interrupted run. --- packages/core/tests/terminal-grid.test.ts | 271 +++++++++++++++--- packages/durable-streams/combinators.ts | 9 +- .../tests/durable-spawn.test.ts | 2 +- packages/durable-streams/tests/parse.test.ts | 38 +++ .../durable-streams/tests/retained.test.ts | 60 +++- 5 files changed, 329 insertions(+), 51 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 9da63e051..20d004322 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -54,6 +54,7 @@ import type { TerminalProviderLog, } from "@executablemd/runtime"; +import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; import { @@ -85,6 +86,8 @@ interface DocumentRun { events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; + /** Every printed error the run produced, in order. */ + errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; } @@ -113,6 +116,7 @@ function useGridComponents( ran: string[], slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -164,6 +168,18 @@ function useGridComponents( return ""; }, }, + { + // Waits until the grid has attached, so a pane can fail *after* the + // barrier — which is the failure the grid contains as a status rather + // than the startup failure that fails the whole region. + name: "AfterAttach", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterAttach(); + return ""; + }, + }, { name: "Hold", origin: "tier-tg", @@ -258,6 +274,13 @@ function runDocument( const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; + const errors: string[] = []; + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); yield* useGridComponents(ran, options.slowMarks ?? []); yield* installControlledLauncher(); @@ -309,6 +332,7 @@ function runDocument( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -361,35 +385,97 @@ function runInterrupted( close?: boolean; /** Props this run supplies. Props are not restored across a continuation. */ props?: Record; + /** + * Keep the grid open until a pane reports a failure. + * + * A pane that fails *after* attachment is contained as that pane's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the pane as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** + * How many panes must have settled before the run is interrupted. + * + * A pane's status is published only after its durable child has returned, + * so this is also how many pane Closes the journal is known to hold. Rows + * that read those records name the number they need; rows that only need an + * open grid name none. + */ + settled?: number; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - // Two signals, kept apart because they mean different things. `attached` - // says a grid opened on this run; `pastGrid` says the document reached the + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `attached` + // says a grid opened on this run. `pastGrid` says the document reached the // sibling after it, which is what a *replayed* grid does and what a - // completed-region journal needs to be waited for. Neither is a deadline: a - // replay that hangs reaches neither and hangs the row rather than passing - // on a timer. + // completed-region journal has to be waited for. `panesSettled` says the + // pane children the row cares about have written their own records. + // + // Every one of them is an event this run produced. Nothing here waits for a + // duration, so a replay that hangs reaches none of them and hangs the row — + // it can never hand back a run that looks finished but is not. const attached = withResolvers(); const pastGrid = withResolvers(); - const destroyed = withResolvers(); - yield* useGridComponents(ran, [], (mark) => { - if (mark === PAST_THE_GRID) { - pastGrid.resolve(); - } + const panesSettled = withResolvers(); + let settledPanes = 0; + if ((options.settled ?? 0) === 0) { + panesSettled.resolve(); + } + // The printed errors this run produced, which is how a contained failure is + // observable at all — and the same list on a replayed run is how "the same + // result came back" is read rather than assumed. + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, }); + const paneFailed = withResolvers(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + }, + () => attached.operation, + ); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: options.close === true ? immediateClose() : () => suspend(), - ...(options.shell === undefined ? {} : { shell: options.shell }), + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: function* (ordinal: number, spawned: () => void) { + spawned(); + if (ordinal !== options.shellFailsAfterAttach) { + return { exitCode: 0 }; + } + // Started, so the grid attaches; it fails only afterwards, which + // is the failure a grid contains as a pane status. + yield* attached.operation; + return { exitCode: 1 }; + }, + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield *onPrepare(asked) { requests.push(asked); - yield* sleep(0); }, // Attach, not `running`: a pane that settles before the barrier keeps // its own status and never becomes runnable. @@ -397,9 +483,16 @@ function runInterrupted( *onAttach() { attached.resolve(); }, - // deno-lint-ignore require-yield - *onDestroy() { - destroyed.resolve(); + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } }, }); } @@ -416,29 +509,17 @@ function runInterrupted( }); yield* execution; }); - // The grid is open and its panes have settled, so the journal now holds the - // pane children's own entries. A resumed run never attaches at all — the - // region short-circuits — so this is bounded rather than waited on. - // `close: true` means the grid is expected to complete, so the run is - // halted only once the document has moved past it — that is what leaves a - // completed grid child under an incomplete root. Otherwise the grid is - // expected to stay open, and attaching is as far as it gets. - yield* race([ - options.close === true ? pastGrid.operation : attached.operation, - (function* (): Operation { - yield* sleep(1500); - // deno-lint-ignore no-console - const evts = yield* stream.readAll(); - // deno-lint-ignore no-console - console.log( - "PROBE3 closes", - JSON.stringify( - evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), - ), - ); - })(), - ]); - yield* sleep(5); + // `close: true` expects the grid to complete, so the run is interrupted only + // once the document has moved past it — which is what leaves a completed + // grid child under an incomplete root. Otherwise the grid is expected to + // stay open, and the run is interrupted once it has opened and the pane + // records the row reads are durable. + if (options.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } yield* task.halt(); return { outcome: { ok: false, error: new Error("interrupted") } as Result, @@ -447,6 +528,7 @@ function runInterrupted( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -1162,8 +1244,9 @@ describe("Tier TG — durability and replay", () => { */ const CONTAINED_FAILURE = [ "", - "", - 'nothing interactive here', + "", + '', + '', "", "", "", @@ -1213,9 +1296,16 @@ describe("Tier TG — durability and replay", () => { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + closeAfterFailure: true, + shellFailsAfterAttach: 0, + }); expect(first.requests).toHaveLength(1); expect(completedGrid(first)).toBe(true); + // What the failure looked like, as the document reported it. + expect(first.errors.some((message) => message.includes("shell exited with status 1"))).toBe( + true, + ); // No provider at all on the resumed run: a replay that contacted one would // refuse, and the retained result does not need one. @@ -1224,6 +1314,10 @@ describe("Tier TG — durability and replay", () => { provider: false, }); + // The same result came back, rather than being derived again. + expect(second.errors).toEqual(first.errors); + // And the document carried on from it, exactly as it did the first time. + expect(second.ran).toContain(PAST_THE_GRID); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); @@ -1232,7 +1326,8 @@ describe("Tier TG — durability and replay", () => { it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + // Both panes settle, so both pane children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); const closes = first.journal.filter((event) => event.type === "close"); const paneIds = closes @@ -1277,10 +1372,17 @@ describe("Tier TG — durability and replay", () => { return { exitCode: 0 }; }; - const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + // The left pane settles; the shell holds, so only one pane record exists. + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); expect(first.ran).toContain("left ran"); - const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + const second = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); // The completed pane came back from its retained outcome: its body did not // run again. @@ -1423,6 +1525,85 @@ describe("Tier TG — durability and replay", () => { }); }); + it("TG17: a malformed retained layout refuses before provider observation", function* () { + /** The retained layout, replaced by something the record cannot mean. */ + const damaged: [string, Json][] = [ + ["a missing member", { columns: 2, panes: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a pane out of position", + { + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 0, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 3, column: 1 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + ]; + + for (const [what, layout] of damaged) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + yield* runInterrupted(dir, GRID, stream); + + // The same journal with only its layout entry replaced, so nothing else + // about the continuation changes. + const damagedStream = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + const isLayout = + event.type === "yield" && String(event.description.name).endsWith(":layout"); + yield* damagedStream.append( + isLayout && event.result.status === "ok" + ? { ...event, result: { status: "ok", value: layout } } + : event, + ); + } + + const second = yield* runDocument(dir, GRID, { stream: damagedStream }); + + expect(`${what}: ${second.outcome.ok}`).toBe(`${what}: false`); + // Refused while reading the record, before anything was asked for. + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.events.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.shown.size}`).toBe(`${what}: 0`); + } + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 11d410fa5..2a4027020 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -279,9 +279,12 @@ function* runDurableChild( * `ephemeral()` instead — as this once did — put it in a scope that closed as * soon as the effect resolved, so every `yield* task` threw `halted`. * - * A retained `Close(cancelled)` here means the run was interrupted, not that a - * combinator chose against this child, so the child resumes its remaining work. - * See `CancelledChildPolicy`. + * A retained `Close(cancelled)` here is read for *why* it was cancelled, not + * treated as one thing. `"unwound"` — the run was interrupted, and nothing will + * cancel this child again — resumes the work it had left. `"caller"`, and a + * legacy record that says nothing, is a stop this caller chose, and is + * reproduced by suspending until its deterministic control flow chooses it + * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( childWorkflow: () => Workflow, diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 07285ce1c..179ded8ab 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend } from "effection"; +import { sleep, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; diff --git a/packages/durable-streams/tests/parse.test.ts b/packages/durable-streams/tests/parse.test.ts index a30efa8a4..3dbed1007 100644 --- a/packages/durable-streams/tests/parse.test.ts +++ b/packages/durable-streams/tests/parse.test.ts @@ -270,3 +270,41 @@ describe("parseDurableEvent", () => { expect("polluted" in {}).toBe(false); }); }); + +describe("a cancelled close carries why it was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("round-trips both reasons", function* () { + for (const reason of ["caller", "unwound"] as const) { + const event = cancelled(reason); + const record = serializeDurableEvent(event); + expect(accepted(record)).toEqual(event); + // And back to the same bytes, so a backend retains the event rather than + // an approximation of it. + expect(serializeDurableEvent(accepted(record))).toBe(record); + } + }); + + it("keeps a legacy record's absence an absence", function* () { + const parsed = accepted(serializeDurableEvent(cancelled())); + expect(parsed).toEqual(cancelled()); + expect(parsed.result.status === "cancelled" && "cancellation" in parsed.result).toBe(false); + }); + + it("refuses a reason it does not recognise", function* () { + const refused = refusal( + JSON.stringify({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" }, + }), + ); + expect(refused).toBeInstanceOf(MalformedDurableEventError); + expect(refused.message).toContain("$.result.cancellation"); + }); +}); diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index 72f6251c6..fc52ab191 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -15,9 +15,9 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { detachJson, retainEvents } from "../retained.ts"; +import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { DurableEvent, Json } from "../types.ts"; +import type { Close, DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -443,3 +443,59 @@ describe("retained history — detached values stay ordinary JSON", () => { expect(caught).toBeInstanceOf(TypeError); }); }); + +describe("retention keeps why a child was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("retains both reasons through the settled copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + const [retained] = retainEvents([cancelled(reason)]); + expect(retained?.type).toBe("close"); + expect(retained?.result).toEqual({ status: "cancelled", cancellation: reason }); + } + }); + + it("leaves a legacy absence absent", function* () { + const [retained] = retainEvents([cancelled()]); + expect(retained?.result).toEqual({ status: "cancelled" }); + expect( + retained !== undefined && + retained.result.status === "cancelled" && + "cancellation" in retained.result, + ).toBe(false); + }); + + it("carries both reasons through an observable copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + expect(consumable(cancelled(reason).result)).toEqual({ + status: "cancelled", + cancellation: reason, + }); + } + expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); + }); + + it("does not retain a reason it does not recognise", function* () { + // A record that says something else says nothing this reads, and the safe + // default — a deliberate stop — is what an absent reason already means. + const [retained] = retainEvents([ + { + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], + }, + ]); + expect(retained?.result).toEqual({ status: "cancelled" }); + }); + + it("reaches the replay index with its reason intact", function* () { + const index = new ReplayIndex([cancelled("unwound")]); + const close = index.getClose("root.0"); + expect(close?.result).toEqual({ status: "cancelled", cancellation: "unwound" }); + }); +}); From ce6a37a123530e244baef7de0c1d13d1a02d06f1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:21:41 -0400 Subject: [PATCH 10/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Coordinate=20the=20D?= =?UTF-8?q?EC-040=20rows=20by=20signal,=20not=20by=20duration=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEC-040 block still slept where it meant to synchronise — my previous replacements silently failed to match after the file was reformatted, so none of them landed. The block is rewritten rather than patched. Every row now waits on something the run reported. A shared `living()` child resolves a `started` signal and then suspends, so each row halts or unwinds a child that is provably live rather than one a delay happened to reach. The caller resolves `halted` after performing its deliberate halt, so a run is interrupted only once both facts — the deliberate stop and the interruption — are in the journal. Non-revival is established by control flow rather than by waiting: the resumed run reaches its own `task.halt()` and says so, and a revived child would have recorded its mark before the caller could get there. The legacy-absence row signals once the child has been asked for and the request returned. No new timeout, and `sleep` stays imported because the lifetime rows above still use it deliberately. `retained.test.ts` drops the cast and the row it supported: rejecting an unrecognised reason is the parser's, proved there, and retention proves only that `"caller"`, `"unwound"` and a legacy absence survive. --- .../tests/durable-spawn.test.ts | 89 +++++++++++-------- .../durable-streams/tests/retained.test.ts | 15 +--- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 179ded8ab..4d826b846 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -376,22 +376,42 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { ); } + /** + * A child that says when it is running and then waits to be stopped. + * + * Every row below halts or unwinds a *live* child, and `started` is how each + * one knows the child is live. Nothing waits for a duration: a child that + * never started never resolves it, and the row hangs rather than recording a + * cancellation of something that was not running. + */ + function living( + started: { resolve: () => void }, + mark?: (note: string) => void, + ): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + mark?.("first life"); + started.resolve(); + yield* suspend(); + return "never"; + })(), + ); + }; + } + it("records a deliberate halt as caller", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + // The child is running; stopping it now is a deliberate stop of + // live work rather than of whatever a delay happened to reach. + yield* started.operation; yield* task.halt(); })(), ); @@ -405,18 +425,12 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("records a scope unwinding as unwound", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { yield* suspend(); @@ -427,7 +441,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(3); + // Interrupted while the child is live, said by the child. + yield* started.operation; yield* run.halt(); expect(yield* cancellations(stream)).toEqual(["unwound"]); @@ -436,24 +451,20 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("does not revive a child the caller deliberately halted", function* () { const marks: string[] = []; const stream = new InMemoryStream(); - // The caller halts the child, then the run is interrupted before it - // completes. Both facts are in the journal; only the first decides. + const started = withResolvers(); + const halted = withResolvers(); + + // The caller halts the child on purpose, and only then is the run + // interrupted — so the journal holds both facts and only the first decides. const first = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - marks.push("first life"); - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + yield* started.operation; yield* task.halt(); + halted.resolve(); yield* suspend(); })(), ); @@ -462,14 +473,16 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(5); + yield* halted.operation; yield* first.halt(); expect(marks).toEqual(["first life"]); expect(yield* cancellations(stream)).toEqual(["caller"]); - // The resumed run reaches the same deliberate halt, so the child suspends - // until it does rather than performing work nobody asked to redo. + // The resumed run reaches the same deliberate halt. Getting there is the + // proof of non-revival: a revived child would have recorded its mark before + // the caller could halt it, and the mark list is checked after. + const reachedTheHalt = withResolvers(); const second = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -483,8 +496,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { }); yield* ephemeral( (function* (): Operation { - yield* sleep(1); yield* task.halt(); + reachedTheHalt.resolve(); yield* suspend(); })(), ); @@ -493,7 +506,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(10); + yield* reachedTheHalt.operation; yield* second.halt(); expect(marks).toEqual(["first life"]); @@ -509,6 +522,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { result: { status: "cancelled" }, }); + const asked = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -520,12 +534,15 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { })(), ); }); + // The child has been asked for and the request has returned. A + // revived child would have recorded its mark by now. + asked.resolve(); return yield* ephemeral(task); }, { stream }, ); }); - yield* sleep(10); + yield* asked.operation; yield* run.halt(); // Absent evidence is the safe direction: nothing is revived. diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index fc52ab191..b4a57970e 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { Close, DurableEvent, Json } from "../types.ts"; +import type { DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -480,19 +480,6 @@ describe("retention keeps why a child was cancelled (DEC-040)", () => { expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); }); - it("does not retain a reason it does not recognise", function* () { - // A record that says something else says nothing this reads, and the safe - // default — a deliberate stop — is what an absent reason already means. - const [retained] = retainEvents([ - { - type: "close", - coroutineId: "root.0", - result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], - }, - ]); - expect(retained?.result).toEqual({ status: "cancelled" }); - }); - it("reaches the replay index with its reason intact", function* () { const index = new ReplayIndex([cancelled("unwound")]); const close = index.getClose("root.0"); From 64292a41bfc35211ad9613e4a4efa75dc266d876 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:07:57 -0400 Subject: [PATCH 11/19] =?UTF-8?q?=F0=9F=90=9B=20Observe=20every=20disposal?= =?UTF-8?q?=20surface,=20and=20make=20reader=20close=20cooperative=20(#730?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task `durableSpawn` returns copied it from the original unchanged, so `await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded `cancellation: "unwound"` and the next run revived the child. `halt()` and the async dispose are the same decision spelled two ways, and both are now observed. Awaiting a task is not a stop and is left exactly as it was. A regression disposes a live task, asserts the retained reason is `"caller"`, resumes the journal, and proves the body is not entered again. **Reader close no longer halts panes.** It asks them to stop: a pane races its work against a close signal, settles as `closed`, and records that outcome as its own. Nothing on the ordinary close path is a caller-cancelled child any more, so a resumed run restores a pane the reader closed rather than finding a cancelled child it must either re-enter or wait on forever. Statuses are published before anything is awaited, so a pane with slow finalizers cannot delay the outcome the grid already knows. **§6.21 now agrees with architecture.md and TG17.** Partial replay compares the resolved layout — columns and titles. Pane count, order and form come from the retained root and cannot diverge within a continuation, so a changed supplied file is ignored in favour of the retained structure; refusing a changed authored structure is a root-definition boundary this specification does not yet define. DEC-040 is unchanged and nothing deliberately stopped is revived. --- packages/core/src/terminal/grid.ts | 48 ++++++++++++-- packages/core/tests/terminal-grid.test.ts | 49 +++++++++++++- packages/durable-streams/combinators.ts | 38 +++++++---- .../tests/durable-spawn.test.ts | 64 ++++++++++++++++++- specs/executable-mdx-spec.md | 24 +++++-- 5 files changed, 199 insertions(+), 24 deletions(-) diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa2a09f0e..fa91a00f3 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -224,6 +224,11 @@ function presentGrid( const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); + // Reader close asks the panes to stop; it does not halt them. A pane that + // is asked settles as `closed` and records that outcome as its own, so a + // resumed run restores a pane the reader closed rather than finding a + // cancelled child it must either re-enter or wait on forever. + const closing = withResolvers(); let attached = false; for (const pane of work) { @@ -242,7 +247,15 @@ function presentGrid( const readiness = grid.readiness[index]!; panes.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, claim, composite, readiness, request, index); + return yield* runPane( + pane, + claim, + composite, + readiness, + request, + index, + closing.operation, + ); }), ); } @@ -298,12 +311,20 @@ function presentGrid( // lease released and the following sibling started only once nothing a pane // acquired can still act. grid.seal(); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { yield* composite.update(pane.ordinal, "closed"); - outcomes[index] = { status: "closed", reason: "" }; } - yield* panes[index]!.halt(); + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* panes[index]!; + outcomes[index] ??= outcome; } const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); @@ -324,10 +345,29 @@ function runPane( readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, + closing: Operation, ): Operation { return (function* (): Operation { try { - yield* pane.run(claim, composite); + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(claim, composite)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + return { status: "closed", reason: "" }; + } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 20d004322..4c6961c25 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -117,6 +117,7 @@ function useGridComponents( slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -168,6 +169,20 @@ function useGridComponents( return ""; }, }, + { + // Holds the pane open, and blocks its own teardown until released — so a + // row can interrupt a run while reader-close teardown is in progress. + name: "SlowTeardown", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* ensure(function* () { + yield* teardownHeld(); + }); + yield* suspend(); + return ""; + }, + }, { // Waits until the grid has attached, so a pane can fail *after* the // barrier — which is the failure the grid contains as a status rather @@ -395,6 +410,21 @@ function runInterrupted( closeAfterFailure?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** Resolved once a pane's finalizer has been entered and is blocked. */ + onTeardownEntered?: () => void; + /** Interrupt the run when this settles rather than at a lifecycle signal. */ + interruptWhen?: Operation; + /** + * Called once cancellation has begun but before it is awaited. + * + * A row that blocks a finalizer has to release it *after* the parent is + * cancelled, or the cancellation would be waiting on the very thing the row + * is holding. Awaiting the halt afterwards is what proves teardown + * completed rather than merely started. + */ + releaseOnInterrupt?: () => void; /** * How many panes must have settled before the run is interrupted. * @@ -446,6 +476,12 @@ function runInterrupted( } }, () => attached.operation, + function* () { + options.onTeardownEntered?.(); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + }, ); yield* installControlledLauncher(); if (options.provider !== false) { @@ -514,13 +550,22 @@ function runInterrupted( // grid child under an incomplete root. Otherwise the grid is expected to // stay open, and the run is interrupted once it has opened and the pane // records the row reads are durable. - if (options.close === true || options.closeAfterFailure === true) { + if (options.interruptWhen !== undefined) { + yield* options.interruptWhen; + } else if (options.close === true || options.closeAfterFailure === true) { yield* pastGrid.operation; } else { yield* attached.operation; yield* panesSettled.operation; } - yield* task.halt(); + // Cancellation is begun, then released, then awaited. A row that blocks a + // finalizer has to release it after the parent is cancelled, or the + // cancellation would be waiting on the very thing the row is holding; and + // awaiting the halt afterwards is what proves teardown completed rather + // than merely started. + const halting = yield* spawn(() => task.halt()); + options.releaseOnInterrupt?.(); + yield* halting; return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 2a4027020..b6b5b9a12 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -329,40 +329,54 @@ function createSpawnEffect( description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); + resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } /** - * The same task, with a deliberate `halt()` recorded as it happens. + * The same task, with a deliberate stop recorded as it happens. * * The caller receives every member the task defines — `then`, `catch`, - * `finally`, the async dispose, the iterator — copied from the task itself - * along with its prototype, so the public surface is the one `Task` has always - * had. Only `halt` is replaced, and only to note that someone stopped the child - * on purpose before stopping it. + * `finally`, the iterator — copied from the task itself along with its + * prototype, so the public surface is the one `Task` has always had. + * + * **Every** way a caller can stop the task is observed, not just the obvious + * one. `halt()` and `await using` — which reaches `Symbol.asyncDispose` and + * never touches `halt` — are the same decision spelled two ways, and a stop + * recorded as involuntary through either of them would be resumed on the next + * run as work nobody asked to redo. Awaiting the task is not a stop and is left + * exactly as it was. * * Copied rather than proxied: a task's members are read-only and * non-configurable, and a proxy is required to hand back exactly what the - * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * target holds — so a `get` trap cannot substitute either of them. Each copied * member is the task's own closure and keeps working on the copy. */ -function observingHalt(task: Task, evidence: CancellationEvidence): Task { +function observingDisposal(task: Task, evidence: CancellationEvidence): Task { const members = Object.getOwnPropertyDescriptors(task); // Replaced in the descriptor map rather than on the finished object: the // task's own members are non-configurable, so redefining one afterwards // throws. - members.halt = { - value: () => { + const deliberate = (stop: () => R): (() => R) => { + return () => { evidence.deliberate = true; - return task.halt(); - }, + return stop(); + }; + }; + members.halt = { + value: deliberate(() => task.halt()), enumerable: true, configurable: false, writable: false, }; + members[Symbol.asyncDispose] = { + value: deliberate(() => task[Symbol.asyncDispose]()), + enumerable: false, + configurable: false, + writable: false, + }; const observed: Task = Object.create(Object.getPrototypeOf(task), members); return observed; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4d826b846..1f35445f7 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend, withResolvers } from "effection"; +import { sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; @@ -512,6 +512,68 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { expect(marks).toEqual(["first life"]); }); + it("records disposal through Symbol.asyncDispose as caller, and does not revive", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const started = withResolvers(); + const disposed = withResolvers(); + + // `await using` stops a task without ever touching `halt()`. It is the same + // decision spelled another way, so it has to leave the same evidence. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); + yield* ephemeral( + (function* (): Operation { + yield* started.operation; + yield* until(task[Symbol.asyncDispose]()); + disposed.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* disposed.operation; + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // Resuming that journal must not enter the child again. + const reachedTheDisposal = withResolvers(); + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* until(task[Symbol.asyncDispose]()); + reachedTheDisposal.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* reachedTheDisposal.operation; + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + it("reads a record with no reason as caller", function* () { const marks: string[] = []; const stream = new InMemoryStream(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 1b4e690fa..b77b4bee2 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9460,11 +9460,25 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then builds a new live composite. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or From 4cb91ed6a33f666c31661d70d1cd2f586de3e5dd Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:48:27 -0400 Subject: [PATCH 12/19] =?UTF-8?q?=F0=9F=93=9D=20Define=20reader-close=20ca?= =?UTF-8?q?ncellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 58 ++++++++++++++++++--- packages/durable-streams/specs/DECISIONS.md | 12 +++-- specs/executable-mdx-spec.md | 41 ++++++++++++--- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index 0d8f22608..ad0566f89 100644 --- a/architecture.md +++ b/architecture.md @@ -3563,16 +3563,48 @@ The grid runs as one structured scope: 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The composite remains present after all panes settle until the reader closes or leaves it. -7. Closing begins an ordered teardown: prevent new pane launches, cancel live - pane scopes, await every child and finalizer, detach and destroy the exact - provider composite, restore the root terminal, and only then release the - foreground lease and settle the grid. The document never continues while an - observable pane child or provider-owned process can still act through the - grid. +7. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, detach and destroy the exact provider composite, + restore the root terminal, and only then release the foreground lease and + settle the grid. The document never continues while an observable pane child + or provider-owned process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -3623,6 +3655,16 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. +The reader-close handshake makes cancellation during teardown a completed-grid +case rather than a new partial-replay state. When a pane finalizer delays close +and parent cancellation arrives, the first attempt still finishes every pane +and provider finalizer, writes the pane outcomes and completed grid `Close`, and +only then reports cancellation to its parent. A continuation claims that +completed child and resumes after it without recreating the provider or +re-entering pane work. A host loss can still interrupt the unjournaled live +teardown; panes whose `Close` was acknowledged remain complete, while any pane +and grid without a completed record follow the existing partial-replay rules. + Partial replay compares the **resolved** layout and refuses divergence before provider work. diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 8c422ffbf..6be5d3b60 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -567,11 +567,13 @@ Updated before completion of every phase and committed at the end of each phase. *awaits* a task it previously halted has diverged, and divergence is the honest answer there rather than a silent revival. - **Consequences:** Terminal grids get what they need without reviving anything - deliberately stopped. A grid halts each pane task when the reader closes, so - those panes retain `"caller"` — and the grid child completes, so a resumed run - short-circuits the whole region and never reaches them. The case that must - resume — the run interrupted while the grid is open — unwinds the grid and - pane children, retains `"unwound"`, and continues. + deliberately stopped. Reader close cooperatively closes each pane inside its + durable child and waits for that child to retain `closed`; it does not halt + the durable pane task. Once reader close takes effect, later parent + cancellation is deferred through pane and grid completion, so a resumed run + short-circuits the completed grid and never reaches those panes. The case that + must resume — the run interrupted while the grid is still active — unwinds + the grid and pane children, retains `"unwound"`, and continues. - **Scope:** The reason is retained evidence, not authority. Nothing reads it from outside `runDurableChild`, no public API exposes it, and no caller chooses a policy: the policy stays fixed at each combinator's call site. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index b77b4bee2..67d6d996b 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9384,11 +9384,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, destroys the exact composite, restores the root terminal, and +releases the foreground lease. The deferred await ends only after the durable +child has settled and its `Close` has been acknowledged. Only then does the +element settle and a later document sibling begin. There is no implicit timeout; +parent cancellation and an enclosing execution deadline use the same complete +teardown. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -9423,6 +9435,12 @@ continues. A provider or host failure cancels the composite and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. +If it arrives after reader close takes effect, reader close still decides the +grid and pane outcomes: then-live panes retain `closed`, already-settled panes +keep their outcomes, and the grid retains `reader` or `failed` under the normal +authored-order rule. The cancellation remains pending until complete teardown +and the grid's durable close, then reaches the parent before any following +document sibling runs. A fatal or cleanup failure keeps its existing precedence. All acquired resources are finalized even when an earlier failure already decides the result, and the existing fatal-infrastructure and cleanup precedence still applies. Before the first cancellation signal, the provider snapshots @@ -9460,6 +9478,16 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken and before any provider is contacted. It then builds a new live composite. @@ -11526,7 +11554,8 @@ test derives a core result from a provider identifier. | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | -| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | ### Tier CR — Component registration and resolution From 4f22ff4412b2e4d64a71a0fc511b1d8677cbaa7b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:04:51 -0400 Subject: [PATCH 13/19] =?UTF-8?q?=E2=9C=A8=20Implement=20the=20reader-clos?= =?UTF-8?q?e=20cancellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the amendment at 18338707 without revising it. **The live handshake.** `composite.closed()` settling now only *proposes* the boundary. The grid's durable child publishes that proposal and waits; the owner awaiting the child acknowledges it; and only then does the grid seal admission and ask its panes to close. The handshake is one live rendezvous — no provider identity, nothing journaled. **Committing an outcome before the scope finishes unwinding.** A durable child can now declare its terminal value, and `runDurableChild` records that value if the child never reaches a normal ending. That is the piece the contract needs: the grid commits its retained record as the boundary is crossed, and each pane live at that moment commits `closed`, so a cancellation arriving while pane and provider finalizers are still running records what close decided rather than a cancellation. Committing is live state; it reaches the journal only as the ordinary `Close`. A child that returns or throws normally overrides it, and a child that never committed still records the cancellation it actually reached — DEC-040 untouched. Cancellation stays deferred because Effection completes a child's teardown — pane finalizers, provider destroy, terminal restoration, lease release, the `Close` append and the task's settlement — before the halt reaches the owner. **Pane work stays inside its ordinal-derived durable child.** Reader close asks the pane to close; it never halts the pane's durable task. The pane commits `closed`, stops its live nested work through its own scope, and settles only once that work and its finalizers have settled. No durable closing marker was added, and completed replay is unchanged. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 146 +++++++++++++++++++--- packages/core/tests/terminal-grid.test.ts | 9 ++ packages/durable-streams/combinators.ts | 40 +++++- packages/durable-streams/mod.ts | 1 + 5 files changed, 178 insertions(+), 22 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index ae0899dde..8d4107045 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2181,11 +2181,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* () { + const retained = yield* durableGrid(function* (boundary, commit) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work); + return yield* openTerminalGrid(layout, work, boundary, commit); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa91a00f3..2a4677feb 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -38,6 +38,55 @@ import { import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * seal admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + /** How one pane ended, as the journal records it. */ export type PaneStatus = "succeeded" | "failed" | "closed"; @@ -148,6 +197,8 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -167,7 +218,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work); + settled = yield* presentGrid(request, composite, work, boundary, commit); grid.settled = true; }, }; @@ -209,6 +260,8 @@ function presentGrid( request: TerminalGridRequest, composite: TerminalComposite, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -246,7 +299,9 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* (): Operation { + yield* paneChild(function* ( + commitPane: (outcome: RetainedPaneOutcome) => void, + ): Operation { return yield* runPane( pane, claim, @@ -255,6 +310,7 @@ function presentGrid( request, index, closing.operation, + commitPane, ); }), ); @@ -304,6 +360,12 @@ function presentGrid( // what finishes the grid, not the last pane exiting. yield* composite.closed(); + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's @@ -312,6 +374,12 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); + // The outcome is decided the moment the boundary is crossed: every settled + // pane keeps its own, every pane still live is closed. Committed here, so a + // cancellation arriving while pane and provider finalizers are still going + // records what close decided rather than a cancellation. + const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -329,11 +397,7 @@ function presentGrid( const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); - return { - layout: retainedLayout(request), - close: reason === undefined ? "reader" : "failed", - panes: settled, - }; + return retained(request, settled, reason); }); } @@ -346,6 +410,7 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, + commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -366,7 +431,17 @@ function runPane( })(), ]); if (closed) { - return { status: "closed", reason: "" }; + const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; + // Decided at the boundary, so a cancellation arriving while this pane's + // finalizers are still going records the close rather than a + // cancellation — and never a caller-cancelled child a later run would + // have to revive or wait on. + commitPane(outcome); + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles only once they + // have. + yield* running.halt(); + return outcome; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -386,6 +461,19 @@ function runPane( })(); } +/** The record one grid settled to. */ +function retained( + request: TerminalGridRequest, + panes: readonly RetainedPaneOutcome[], + reason: string | undefined, +): RetainedGrid { + return { + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: [...panes], + }; +} + /** The first failed pane's sentence in authored order, which is the grid's. */ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { return outcomes.find((outcome) => outcome?.status === "failed")?.reason; @@ -408,16 +496,19 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: () => Operation, + body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child. - return yield* spawn(body); + // No journal behind this run: an ordinary spawned child, with nothing to + // commit an outcome into. + return yield* spawn(() => body(() => {})); } - return yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(body()); + return yield* durableSpawn(function* ( + commit: (outcome: RetainedPaneOutcome) => void, + ): Workflow { + return yield* ephemeral(body(commit)); }); })(); } @@ -430,14 +521,35 @@ function paneChild( * no shell starts — and claiming the completed child claims every pane history * beneath it, so a resumed run starts nothing. */ -export function durableGrid(live: () => Operation): Operation { +export function durableGrid( + live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, +): Operation { return (function* (): Operation { + const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* live(); + // No journal to commit into, so the boundary is crossed as soon as it is + // proposed and the grid closes in one step. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* live(boundary, () => {}); } - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(live()); + const task = yield* durableSpawn(function* ( + commit: (grid: RetainedGrid) => void, + ): Workflow { + return yield* ephemeral(live(boundary, commit)); + }); + // The owner's cancellation-deferred await. Acknowledging happens here, + // inside it: from this point a cancellation cannot pre-empt the close, + // because the child commits its outcome as the boundary is crossed and + // Effection completes a child's teardown — its pane and provider + // finalizers, its `Close` append and its settlement — before the halt + // reaches whoever asked for it. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); }); return yield* task; })(); diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4c6961c25..f80813595 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -118,6 +118,7 @@ function useGridComponents( onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, ): Operation { return registerComponents([ { @@ -179,6 +180,9 @@ function useGridComponents( yield* ensure(function* () { yield* teardownHeld(); }); + // Armed: the finalizer is installed and this pane is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); yield* suspend(); return ""; }, @@ -408,6 +412,8 @@ function runInterrupted( * would record the pane as cancelled by the close instead. */ closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -467,6 +473,8 @@ function runInterrupted( }, }); const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +490,7 @@ function runInterrupted( yield* options.holdTeardown(); } }, + () => armed.resolve(), ); yield* installControlledLauncher(); if (options.provider !== false) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index b6b5b9a12..ed50a2921 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -98,8 +98,24 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } +/** + * Declare a child's terminal value before its scope has finished unwinding. + * + * A child that has already decided what it settled to — a terminal grid that + * crossed its reader-close boundary, say — must record that outcome even if the + * run is cancelled while its finalizers are still going. Without this, a halt + * arriving during teardown loses the decision and the child records a + * cancellation instead, which is a different thing entirely. + * + * Committing is live state, never journaled on its own: the value reaches the + * journal only as the child's ordinary `Close`, written where it always was. + * A child that goes on to return or throw normally overrides what it committed, + * because that is the outcome it actually reached. + */ +export type CommitOutcome = (value: T) => void; + function* runDurableChild( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -163,12 +179,30 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; + // What the child declared it had settled to before its scope finished coming + // down. Read only when the child never reached a normal ending. + let committed: { value: T } | undefined; + const commit: CommitOutcome = (value) => { + committed = { value }; + }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } + // A child that committed an outcome and was then cancelled mid-teardown + // settled: the decision was made before the cancellation arrived, and the + // record has to say so. The cancellation is still a cancellation for + // whoever asked for it — it is simply delivered after this. + if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { + closeEvent = { + type: "close", + coroutineId: childId, + result: { status: "ok", value: committed.value as Json }, + }; + } + // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -205,7 +239,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(); + const result: T = yield* childWorkflow(commit); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -287,7 +321,7 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 581dabd57..4d2f5747d 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -102,6 +102,7 @@ export { durableAction, durableCall, durableSleep, versionCheck } from "./operat // Structured concurrency combinators export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; +export type { CommitOutcome } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 824d2f04ce20ee3e17752a922b806c235646eb4b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:34:37 -0400 Subject: [PATCH 14/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Defer=20the=20grid?= =?UTF-8?q?=20owner's=20cancellation=20at=20the=20live=20close=20boundary?= =?UTF-8?q?=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture 18338707 at the owner boundary. The grid's durable child now runs in a scope of its own — a child of the owner's, so it inherits every context the document runs under, and its own so that tearing the owner down does not reach it first. A finalizer registered after that scope exists runs before it is destroyed, and that is the cancellation-deferred await: once the owner has acknowledged the provider's close proposal, the grid and its panes finish teardown and append their ordinary completed Close records, and only then does the cancellation carry on to the parent. Removes the exported CommitOutcome/durableSpawn(commit) API. Cancellation is never turned into success inside runDurableChild; durableSpawnIn only says where a child lives, and grants nothing a caller does not already have. TG19 proves the ordering with signals alone: a live pane arms a blocking finalizer, the reader leaves, the finalizer is entered and held, cancellation begins, the finalizer is released, and the run ends with the composite destroyed, a completed grid Close retained, the live pane retained as closed — and the sibling after the grid never reached. The continuation then replays past it with no provider, no pane body and no finalizer re-entered. TG6 isolates paired-pane sequencing on its own: the reader leaves only once the pane's second component has run. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 128 ++++++++++++++-------- packages/core/tests/terminal-grid.test.ts | 117 +++++++++++++++++++- packages/durable-streams/combinators.ts | 75 ++++++------- packages/durable-streams/mod.ts | 3 +- 5 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 8d4107045..a9e97ffd1 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2181,11 +2181,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* (boundary, commit) { + const retained = yield* durableGrid(function* (boundary) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work, boundary, commit); + return yield* openTerminalGrid(layout, work, boundary); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 2a4677feb..f96fb091a 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,25 @@ * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; +import { + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -198,7 +214,6 @@ export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -218,7 +233,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary, commit); + settled = yield* presentGrid(request, composite, work, boundary); grid.settled = true; }, }; @@ -261,7 +276,6 @@ function presentGrid( composite: TerminalComposite, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -299,9 +313,7 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* ( - commitPane: (outcome: RetainedPaneOutcome) => void, - ): Operation { + yield* paneChild(function* (): Operation { return yield* runPane( pane, claim, @@ -310,7 +322,6 @@ function presentGrid( request, index, closing.operation, - commitPane, ); }), ); @@ -374,12 +385,6 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); - // The outcome is decided the moment the boundary is crossed: every settled - // pane keeps its own, every pane still live is closed. Committed here, so a - // cancellation arriving while pane and provider finalizers are still going - // records what close decided rather than a cancellation. - const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); - commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -410,7 +415,6 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, - commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -431,17 +435,11 @@ function runPane( })(), ]); if (closed) { - const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; - // Decided at the boundary, so a cancellation arriving while this pane's - // finalizers are still going records the close rather than a - // cancellation — and never a caller-cancelled child a later run would - // have to revive or wait on. - commitPane(outcome); // The nested work is stopped by this pane's own scope, and its - // finalizers are awaited here: the durable child settles only once they - // have. + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. yield* running.halt(); - return outcome; + return { status: "closed", reason: "" }; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -496,19 +494,16 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, + body: () => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child, with nothing to - // commit an outcome into. - return yield* spawn(() => body(() => {})); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableSpawn(function* ( - commit: (outcome: RetainedPaneOutcome) => void, - ): Workflow { - return yield* ephemeral(body(commit)); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); }); })(); } @@ -522,35 +517,72 @@ function paneChild( * beneath it, so a resumed run starts nothing. */ export function durableGrid( - live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, + live: (boundary: CloseBoundary) => Operation, ): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal to commit into, so the boundary is crossed as soon as it is + // No journal to finish into, so the boundary is crossed as soon as it is // proposed and the grid closes in one step. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* live(boundary, () => {}); + return yield* live(boundary); } - const task = yield* durableSpawn(function* ( - commit: (grid: RetainedGrid) => void, - ): Workflow { - return yield* ephemeral(live(boundary, commit)); + + // The grid's durable child runs in a scope of its own — a child of this one, + // so it inherits every context the document runs under, and its own so that + // tearing this one down does not reach the child first. + // + // That ordering is what makes the await below genuinely deferred. A scope + // runs its finalizers in reverse, so one registered after this scope exists + // runs before this scope is destroyed: the grid and its panes finish their + // own teardown and append their ordinary completed `Close` records, and only + // then does the cancellation carry on to the parent. + const [detached, destroy] = createScope(yield* useScope()); + const held: { + task?: Task; + outcome?: Result; + } = {}; + + // Registered after the scope and before the await, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && boundary.acknowledged && held.outcome === undefined) { + held.outcome = yield* finish(held.task); + } + yield* until(destroy()); + }); + + held.task = yield* durableSpawnIn(detached, function* (): Workflow { + return yield* ephemeral(live(boundary)); }); - // The owner's cancellation-deferred await. Acknowledging happens here, - // inside it: from this point a cancellation cannot pre-empt the close, - // because the child commits its outcome as the boundary is crossed and - // Effection completes a child's teardown — its pane and provider - // finalizers, its `Close` append and its settlement — before the halt - // reaches whoever asked for it. + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* task; + + held.outcome = yield* finish(held.task); + yield* until(destroy()); + if (!held.outcome.ok) { + throw held.outcome.error; + } + return held.outcome.value; })(); } + +/** Await one grid child, keeping how it ended rather than re-throwing it here. */ +function* finish(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index f80813595..52571e69a 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -414,6 +414,8 @@ function runInterrupted( closeAfterFailure?: boolean; /** Let the reader leave only once a `` pane is armed. */ closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -475,6 +477,7 @@ function runInterrupted( const paneFailed = withResolvers(); // Resolved once a `` pane has installed its finalizer. const armed = withResolvers(); + const marked = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +485,9 @@ function runInterrupted( if (mark === PAST_THE_GRID) { pastGrid.resolve(); } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } }, () => attached.operation, function* () { @@ -499,9 +505,13 @@ function runInterrupted( close: options.closeAfterFailure === true ? () => paneFailed.operation - : options.close === true - ? immediateClose() - : () => suspend(), + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { shell: function* (ordinal: number, spawned: () => void) { @@ -1077,6 +1087,25 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).not.toContain("this pane gave up"); }); + it("TG6: a paired pane runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the pane's *second* component has run, so a + // pane body that stopped after the first would never let the grid close — + // a hang rather than a pass. + const run = yield* runInterrupted( + dir, + heldDocument(2, [ + '', + '', + ]), + stream, + { close: true, closeWhenMarked: "second component" }, + ); + + expect(run.ran).toContain("second component"); + }); + it("TG9: with no provider installed, no pane body or shell runs", function* () { const dir = yield* useDir(); const run = yield* runDocument( @@ -1326,6 +1355,26 @@ describe("Tier TG — durability and replay", () => { ); } + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + for (const event of run.journal) { + if ( + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + event.result.status === "ok" + ) { + const value = event.result.value; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const panes = Reflect.get(value, "panes"); + if (Array.isArray(panes)) { + return panes; + } + } + } + } + return []; + } + it("TG15: a completed successful grid replays its exact result, with no work", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -1658,6 +1707,68 @@ describe("Tier TG — durability and replay", () => { } }); + it("TG19: a cancellation during reader-close teardown waits for it, and replays", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + + // Every step below is an event this run produced. Nothing waits for a + // duration, so a lifecycle that never reached a step hangs the row rather + // than passing it. + const entered = withResolvers(); + const release = withResolvers(); + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live pane arms its blocking finalizer, and 2. only then does the + // reader leave. + closeWhenArmed: true, + // 3. Entering the finalizer is observed, and it blocks there. + onTeardownEntered: () => entered.resolve(), + holdTeardown: () => release.operation, + // 4. Cancellation begins while that finalizer is still blocked. + interruptWhen: entered.operation, + // 5. Released afterwards, so the cancellation was not waiting on it. + releaseOnInterrupt: () => release.resolve(), + }); + + // 6. Teardown ran to the end, and the grid recorded a completed close — + // both before the cancellation was observed, because the document never + // reached the sibling after the grid. + expect(first.events).toContain("destroy:0"); + expect(completedGrid(first)).toBe(true); + expect(first.ran).toEqual(["pane body"]); + // The live pane settled as closed rather than cancelled: a cancelled child + // is what a later run would have to revive, and this one has nothing left + // to do. + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // 7. Resumed with three tripwires: no provider at all, so a replay that + // asked for a grid would refuse; a mark inside the pane body, so a pane + // that expanded again would say so; and the finalizer, which would + // report being entered a second time. + let reentered = false; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered = true; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(false); + // The retained grid came back and the document carried on from it. + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index ed50a2921..9aefe654a 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -19,7 +19,7 @@ */ import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; -import type { Operation, Task } from "effection"; +import type { Operation, Scope, Task } from "effection"; import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, @@ -98,24 +98,8 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } -/** - * Declare a child's terminal value before its scope has finished unwinding. - * - * A child that has already decided what it settled to — a terminal grid that - * crossed its reader-close boundary, say — must record that outcome even if the - * run is cancelled while its finalizers are still going. Without this, a halt - * arriving during teardown loses the decision and the child records a - * cancellation instead, which is a different thing entirely. - * - * Committing is live state, never journaled on its own: the value reaches the - * journal only as the child's ordinary `Close`, written where it always was. - * A child that goes on to return or throw normally overrides what it committed, - * because that is the outcome it actually reached. - */ -export type CommitOutcome = (value: T) => void; - function* runDurableChild( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -179,30 +163,12 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; - // What the child declared it had settled to before its scope finished coming - // down. Read only when the child never reached a normal ending. - let committed: { value: T } | undefined; - const commit: CommitOutcome = (value) => { - committed = { value }; - }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } - // A child that committed an outcome and was then cancelled mid-teardown - // settled: the decision was made before the cancellation arrived, and the - // record has to say so. The cancellation is still a cancellation for - // whoever asked for it — it is simply delivered after this. - if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { - closeEvent = { - type: "close", - coroutineId: childId, - result: { status: "ok", value: committed.value as Json }, - }; - } - // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -239,7 +205,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(commit); + const result: T = yield* childWorkflow(); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -321,7 +287,35 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, undefined); +} + +/** + * Spawn a durable child into `scope` rather than into the routine's own. + * + * Same child, same deterministic identity, same cancellation policy — only the + * lifetime differs. A caller that has to finish a region *after* its own + * cancellation has begun needs the child to outlive the scope being torn down, + * and a scope of its own is the only honest way to express that: the child then + * settles normally and writes its ordinary `Close`, and the caller decides when + * to destroy the scope. + * + * It grants nothing a caller does not already have. Placing a child somewhere + * is not replay authority, and the policy stays fixed at the call site. + */ +export function durableSpawnIn( + scope: Scope, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, scope); +} + +/** Both spellings of a durable spawn; `into` is the only thing that differs. */ +function spawnDurableChild( + childWorkflow: () => Workflow, + into: Scope | undefined, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: @@ -335,6 +329,7 @@ export function durableSpawn( return (yield createSpawnEffect( () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), evidence, + into, )) as Task; })(); } @@ -358,12 +353,14 @@ function* readDurableContext(): Operation { function createSpawnEffect( child: () => Operation, evidence: CancellationEvidence, + into?: Scope, ): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); + const host = into ?? routine.scope; + resolve({ ok: true, value: observingDisposal(host.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 4d2f5747d..94a63b438 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -101,8 +101,7 @@ export type { export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts"; // Structured concurrency combinators -export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; -export type { CommitOutcome } from "./combinators.ts"; +export { durableAll, durableRace, durableSpawn, durableSpawnIn } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 5b5198856091dd65a6538c15a9885b88a557ea00 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:44:55 -0400 Subject: [PATCH 15/19] =?UTF-8?q?=E2=9C=85=20Count=20what=20TG19=20proves:?= =?UTF-8?q?=20resources,=20records=20and=20the=20lease=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production lifecycle is unchanged. TG19 now reads counters and journal records rather than a log's shape. The controlled composite keeps live resource counters — composites prepared, composites attached, shells started — each raised when it takes something and lowered when it gives it back, however it left. TG19 reads them once while a pane finalizer is blocked, so it knows they went up, and again when the cancellation has completed, so it knows they came back down. The harness now says when a blocked finalizer *leaves*, not only when it is entered: a finalizer that was entered and then cancelled reaches the first hook and never the second. And after every interrupted run it takes the foreground lease and gives it back twice — the first proves the grid returned it, the second proves the harness did. TG19 adds: one grid Close(ok) retaining close: "reader"; two pane Closes, both completed, with no cancellation recorded at either level; the finalizer entered and left exactly once; destroy:0 exactly once. The first-attempt claim that no following sibling ran and the replay tripwires are unchanged. Every one of these was broken on purpose and re-run: dropping any of the three counter releases, the deferral, the finalizer-exit hook, or double-logging destroy fails TG19, and a second holder of the foreground lease is refused. --- packages/core/tests/terminal-grid.test.ts | 133 ++++++++++++++++++---- packages/runtime/mod.ts | 1 + packages/runtime/terminal.ts | 56 +++++++-- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 52571e69a..4acf29a8d 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -44,6 +44,7 @@ import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, prepareControlledComposite, + reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; @@ -52,6 +53,7 @@ import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog, + TerminalProviderResources, } from "@executablemd/runtime"; import { Component } from "../src/component-api.ts"; @@ -90,6 +92,8 @@ interface DocumentRun { errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; + /** What the controlled provider still held when the run was over. */ + live: TerminalProviderResources; } /** @@ -353,6 +357,7 @@ function runDocument( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -420,8 +425,31 @@ function runInterrupted( shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ holdTeardown?: () => Operation; - /** Resolved once a pane's finalizer has been entered and is blocked. */ - onTeardownEntered?: () => void; + /** + * Called once a pane's finalizer has been entered and is blocked, with what + * the provider is holding at that moment. + * + * A row reads those counters here to know they ever went up, which is what + * makes reading them again at the end mean something. + */ + onTeardownEntered?: (live: TerminalProviderResources) => void; + /** + * Called once that finalizer has left. + * + * Kept apart from entering it deliberately: a finalizer that was entered + * and then cancelled reaches the first hook and never the second, which is + * the difference between teardown starting and teardown finishing. + */ + onTeardownExited?: () => void; + /** + * Called once for each time the foreground lease is taken back after the + * run, which the harness always does twice. + * + * It is the grid's lease that has to come back: a run that stranded it + * would refuse the first of those, and one that never released what this + * harness took would refuse the second. + */ + onLeaseReacquired?: () => void; /** Interrupt the run when this settles rather than at a lifecycle signal. */ interruptWhen?: Operation; /** @@ -491,10 +519,11 @@ function runInterrupted( }, () => attached.operation, function* () { - options.onTeardownEntered?.(); + options.onTeardownEntered?.(log.live); if (options.holdTeardown) { yield* options.holdTeardown(); } + options.onTeardownExited?.(); }, () => armed.resolve(), ); @@ -585,6 +614,16 @@ function runInterrupted( const halting = yield* spawn(() => task.halt()); options.releaseOnInterrupt?.(); yield* halting; + // Taken and given back twice, now that the run is over. The first proves + // the grid returned the foreground lease; the second proves this harness + // gave it back too, so the first cannot have passed against a lease nobody + // was holding in the first place. + for (let attempt = 0; attempt < 2; attempt++) { + yield* scoped(function* () { + yield* reserveTerminal(); + options.onLeaseReacquired?.(); + }); + } return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", @@ -594,6 +633,7 @@ function runInterrupted( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -1355,8 +1395,8 @@ describe("Tier TG — durability and replay", () => { ); } - /** The pane outcomes the grid retained, in authored order. */ - function paneOutcomes(run: DocumentRun): unknown[] { + /** What the grid child retained, read from its own completed `Close`. */ + function retainedGrid(run: DocumentRun): Record | undefined { for (const event of run.journal) { if ( event.type === "close" && @@ -1365,14 +1405,34 @@ describe("Tier TG — durability and replay", () => { ) { const value = event.result.value; if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const panes = Reflect.get(value, "panes"); - if (Array.isArray(panes)) { - return panes; - } + return { ...value }; } } } - return []; + return undefined; + } + + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + const panes = retainedGrid(run)?.panes; + return Array.isArray(panes) ? panes : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * say how many records each level wrote and what each one settled to — + * including whether any of them settled as a cancellation. + */ + function closeStatuses(run: DocumentRun, depth: number): string[] { + const statuses: string[] = []; + for (const event of run.journal) { + if (event.type === "close" && String(event.coroutineId).split(".").length === depth) { + statuses.push(event.result.status); + } + } + return statuses; } it("TG15: a completed successful grid replays its exact result, with no work", function* () { @@ -1715,56 +1775,85 @@ describe("Tier TG — durability and replay", () => { '', ]); - // Every step below is an event this run produced. Nothing waits for a - // duration, so a lifecycle that never reached a step hangs the row rather - // than passing it. + // Signals and counters, and nothing else. Every step below is an event this + // run produced, so a lifecycle that never reached one hangs the row rather + // than passing it, and every "exactly once" claim is a count rather than a + // look at the record. const entered = withResolvers(); const release = withResolvers(); + let entries = 0; + let exits = 0; + let leases = 0; + let heldWhenBlocked: TerminalProviderResources | undefined; const first = yield* runInterrupted(dir, source, stream, { // 1. The live pane arms its blocking finalizer, and 2. only then does the // reader leave. closeWhenArmed: true, // 3. Entering the finalizer is observed, and it blocks there. - onTeardownEntered: () => entered.resolve(), + onTeardownEntered: (live) => { + entries++; + heldWhenBlocked = { ...live }; + entered.resolve(); + }, holdTeardown: () => release.operation, + onTeardownExited: () => { + exits++; + }, // 4. Cancellation begins while that finalizer is still blocked. interruptWhen: entered.operation, // 5. Released afterwards, so the cancellation was not waiting on it. releaseOnInterrupt: () => release.resolve(), + onLeaseReacquired: () => { + leases++; + }, }); // 6. Teardown ran to the end, and the grid recorded a completed close — // both before the cancellation was observed, because the document never // reached the sibling after the grid. - expect(first.events).toContain("destroy:0"); - expect(completedGrid(first)).toBe(true); + expect(entries).toBe(1); + expect(exits).toBe(1); + expect(first.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); expect(first.ran).toEqual(["pane body"]); - // The live pane settled as closed rather than cancelled: a cancelled child - // is what a later run would have to revive, and this one has nothing left - // to do. + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); expect(paneOutcomes(first)).toEqual([ { status: "closed", reason: "" }, { status: "succeeded", reason: "" }, ]); + // The provider's counters went up and came back down. Reading them only at + // the end would be true of counters that never moved. + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); + expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + // And the foreground lease came back: it was taken and given back twice + // over once the run was done. + expect(leases).toBe(2); + // 7. Resumed with three tripwires: no provider at all, so a replay that // asked for a grid would refuse; a mark inside the pane body, so a pane // that expanded again would say so; and the finalizer, which would // report being entered a second time. - let reentered = false; + let reentered = 0; const second = yield* runInterrupted(dir, source, stream, { close: true, provider: false, onTeardownEntered: () => { - reentered = true; + reentered++; }, }); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); - expect(reentered).toBe(false); + expect(reentered).toBe(0); // The retained grid came back and the document carried on from it. expect(second.ran).toEqual([PAST_THE_GRID]); }); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index eba02abb8..c9a9d60d1 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -162,6 +162,7 @@ export type { TerminalPaneRequest, TerminalPaneState, TerminalProviderLog, + TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 12827a30b..b03275a8f 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -196,11 +196,34 @@ export interface TerminalProviderLog { * document output to prove where it did not. */ readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; } /** A fresh, empty record. */ export function terminalProviderLog(): TerminalProviderLog { - return { events: [], shown: new Map() }; + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0 }, + }; } /** @@ -247,13 +270,17 @@ export function prepareControlledComposite( yield* options.onPrepare(request); } log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; let destroyed = false; + let attached = false; return { *attach() { if (options.onAttach) { yield* options.onAttach(); } log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; }, // deno-lint-ignore require-yield *update(ordinal, state) { @@ -266,14 +293,22 @@ export function prepareControlledComposite( }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; }, *closed() { if (options.close) { @@ -293,6 +328,11 @@ export function prepareControlledComposite( yield* options.onDestroy(); } log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } }, }; })(); From 101829187e827bd8d91a3bcce70c77bd81bf0f56 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 10 Sep 2026 22:35:34 -0400 Subject: [PATCH 16/19] =?UTF-8?q?=F0=9F=93=9D=20Make=20PaneTerminal=20the?= =?UTF-8?q?=20pane-work=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep the required per-pane exclusivity, spawn readiness, and close admission inside the grid lifecycle. Pane work receives the concrete PaneTerminal operation; pane-claim, readiness, aggregate-claims, and sealing APIs are no longer public architecture. --- architecture.md | 59 +++++++++++++---------- specs/executable-mdx-spec.md | 19 +++++--- specs/native-agent-session-launch-spec.md | 55 +++++++++++---------- 3 files changed, 77 insertions(+), 56 deletions(-) diff --git a/architecture.md b/architecture.md index ad0566f89..0c74544eb 100644 --- a/architecture.md +++ b/architecture.md @@ -111,7 +111,7 @@ Existing documents and code get aligned to this section retroactively. | foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | | terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown | | terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell | -| pane-terminal lease | the exclusive claim one live interactive operation holds on one terminal pane. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session | +| pane-terminal lease | the exclusive ownership one live interactive operation holds through a pane's concrete `PaneTerminal`. Different panes do not contend; a second operation in one pane does. The grid lifecycle closes admission when that pane is closing, and terminal ownership grants no authority over an Agent session | | native launcher | the host-owned seam that reserves the foreground terminal or the current pane terminal, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | @@ -3479,10 +3479,10 @@ terminal. The host owns one non-contextual terminal authority, built and delivered directly to the installed provider. It validates the exact grid request and -provider installation generation, mints one-use claims for the authored pane -ordinals, and is the only capability that can take or release the root and pane -terminal leases. No context value, prop, binding, provider result, retained -record, diagnostic, or structurally similar request carries that authority. +provider installation generation and permits only that provider to present the +grid core issued. No context value, prop, binding, provider result, retained +record, diagnostic, or structurally similar request carries that presentation +authority. The stable contextual terminal API is request routing only. Middleware may observe, narrow, refuse, wrap, or delegate a one-use request. A handler's @@ -3492,26 +3492,35 @@ provider factory closes over the direct authority and must present that same request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. -A pane claim grants one interactive terminal at that ordinal, not an Agent -session. Core installs a pane-scoped native launcher that closes over the claim. -`` in that pane consequently reserves, flushes, and launches on -the pane terminal instead of competing for the root lease. Launches in -different panes may run concurrently; two interactive launches in one pane -cannot. Sequential launches in one paired pane remain ordinary composition. -The session coordinator is unchanged and independently authoritative, so two -panes attempting to own the same logical Agent session still contend and one -is refused. The provider starts a self-closing pane's host-configured default -shell under the same kind of pane claim. - -Each claim also closes over one host-owned readiness latch. The pane-scoped -native launcher acknowledges it from the runtime's successful child-spawn event -and before it waits for exit; failed preparation, reservation, or spawn never -acknowledges it. Allocation of a PID and the child's first output are not this -event. The self-closing shell path acknowledges the same boundary. The latch is -not a request member, contextual value, provider return, public event, or -process handle, and acknowledging it twice has no effect. A root launch has no -grid readiness latch. This is how the grid observes successful interactive -start without changing `Session.Launch`'s result or exposing a child process. +The grid lifecycle creates one concrete `PaneTerminal` for each authored +ordinal and passes it to that pane's work. Its `interactive()` operation owns +the pane terminal for one live operation, refuses a concurrent operation in the +same pane, and supplies that operation with the one-use `spawned` +acknowledgement that makes the pane ready. Different `PaneTerminal` values do +not contend. Closing the grid prevents every pane terminal from admitting new +work before it asks live work to stop; retaining a pane terminal after its grid +closes grants nothing. + +Core installs that same `PaneTerminal` in the paired pane's scope. A +pane-scoped native launcher reads it and `` consequently +reserves, flushes, and launches on the pane terminal instead of competing for +the root lease. The provider starts a self-closing pane's host-configured +default shell through the same operation. Sequential work in one pane remains +ordinary composition. The session coordinator is unchanged and independently +authoritative, so two panes attempting to own the same logical Agent session +still contend and one is refused. + +Readiness, live-operation tracking, and closing admission are private state of +the grid lifecycle, not a second public capability model. There is no public +pane-claim or readiness interface, no aggregate grid-claims object, and no +factory or sealing operation for another package to coordinate. The lifecycle +passes only the concrete `PaneTerminal` across the pane-work boundary. Its +`spawned` acknowledgement resolves the private readiness latch from the +runtime's successful child-spawn event and before the launcher waits for exit; +failed preparation, reservation, or spawn never acknowledges it. Allocation of +a PID and the child's first output are not this event. The acknowledgement is +idempotent, a root launch receives none, and neither the callback nor the latch +enters a request, provider result, process handle, or durable record. A provider whose pane endpoint is owned by a persistent process routes child creation through that process. The launch's exact argv vector, working diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 67d6d996b..82b33edd5 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9350,12 +9350,19 @@ Opening a grid is atomic from the reader's perspective: that starts and immediately exits is both ready and settled. 5. The provider attaches the complete composite only after every pane is ready. -Successful child start is acknowledged through a private one-use latch held by -the pane terminal claim. The pane-scoped launcher acknowledges from the -runtime's spawn event and before waiting for exit; a startup error never -acknowledges. The self-closing shell does the same. The latch is absent for a -root launch and appears in no prop, binding, contextual API, public request, -provider return, process result, or durable record. +The grid lifecycle owns one private readiness latch for each pane. It passes +that pane's work one concrete `PaneTerminal`; `PaneTerminal.interactive()` +supplies the live operation with a one-use `spawned` acknowledgement. The +pane-scoped launcher calls it from the runtime's spawn event and before waiting +for exit; a startup error never acknowledges. The self-closing shell does the +same. The latch is absent for a root launch and appears in no prop, binding, +public request, provider return, process result, or durable record. + +The same private lifecycle state admits at most one interactive operation in a +pane and stops admitting new work when the grid closes. Different pane +terminals do not contend. No pane-claim, readiness, aggregate claims, or sealing +API crosses the lifecycle boundary; those are implementation details rather +than provider-neutral concepts. When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c422702..06532b23a 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -730,28 +730,27 @@ session coordinator └─ natural key for logical session B ``` -The grid owns the root foreground-terminal lease. The terminal authority mints -one private one-use claim per authored pane ordinal, and core installs a native -launcher in each pane scope that closes over that claim. `Session.Launch` uses -the launcher already in scope; it receives no pane prop, token, identifier, or -mode. The launcher validates the claim through the host's direct terminal -authority and reserves that pane for the launch. A claim from another grid, -provider installation generation, pane ordinal, or completed invocation -authorizes nothing. - -Different pane claims do not contend, so native launches in different panes can -hold their terminals concurrently. One pane remains exclusive: a second launch -cannot begin while the first is live there, and sequential launches work after -the first releases it. Release requires the child, its observable descendants -and process-group members, and every other holder of that pane terminal to be -gone; the pane remains busy if the launcher cannot establish those facts. A -root launch and a terminal grid contend for the root foreground lease, so -neither can overlap the other. +The grid owns the root foreground-terminal lease. Its lifecycle creates one +concrete `PaneTerminal` per authored pane ordinal, passes it to that pane's +work, and owns the private state that admits work, observes readiness, and stops +admission at close. Core installs that same pane terminal in the paired pane's +scope. `Session.Launch` uses the launcher already in scope; it receives no pane +prop, token, identifier, or mode. A constructed lookalike cannot reach a live +pane, and the genuine terminal admits nothing after its grid closes. + +Different pane terminals do not contend, so native launches in different panes +can hold their terminals concurrently. `PaneTerminal.interactive()` keeps one +pane exclusive: a second launch cannot begin while the first is live there, and +sequential launches work after the first releases it. Release requires the +child, its observable descendants and process-group members, and every other +holder of that pane terminal to be gone; the pane remains busy if the launcher +cannot establish those facts. A root launch and a terminal grid contend for the +root foreground lease, so neither can overlap the other. None of that changes the coordinator key or acquisition. Two panes naming the same provider, agent, and logical session still ask for one natural-key owner; one succeeds and the other receives `session-busy` without waiting. Two distinct -sessions may be owned concurrently. A terminal claim grants no permission to +sessions may be owned concurrently. A pane terminal grants no permission to ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. @@ -771,13 +770,19 @@ not roll those phases back. A child that successfully starts and exits before the other panes become ready has nevertheless crossed readiness and retains its ordinary exit outcome. -The pane claim carries a private one-use readiness latch. The native launcher -acknowledges it from the runtime's child-spawn event and before waiting for -exit; allocating a PID or observing output is not readiness, and a startup error -never acknowledges. A root launch carries no such latch. It is not added to -`AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, a retained -launch phase, or a process handle, so readiness composition changes neither the -launch's authored nor durable contract. +`PaneTerminal.interactive()` supplies the native launch with a one-use +`spawned` acknowledgement backed by the grid lifecycle's private readiness +latch. The launcher calls it from the runtime's child-spawn event and before +waiting for exit; allocating a PID or observing output is not readiness, and a +startup error never acknowledges. A root launch receives no such callback. It +is not added to `AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, +a retained launch phase, or a process handle, so readiness composition changes +neither the launch's authored nor durable contract. + +The provider-neutral lifecycle exports `PaneTerminal`, not its readiness, +busy-state, or closing machinery. There is no public pane-claim or readiness +interface and no aggregate grid-claims object. Pane work receives the terminal; +the grid lifecycle alone waits for readiness and closes admission. Under the tmux provider the pane-scoped launcher sends exact argv, cwd, and environment values over a private authenticated socket to the persistent pane From 4b1bf533d8555cab499d78a144551412c04a648b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:00:54 -0400 Subject: [PATCH 17/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Make=20PaneTerminal?= =?UTF-8?q?=20the=20only=20capability=20pane=20work=20receives=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The behaviours the claims carried are required and all still here: one interactive operation owns a pane at a time, different panes never contend, a pane is ready only when something in it reports a child spawn, closing stops new work before it stops live work, and a pane terminal kept past its grid grants nothing. What went is the second capability model they were dressed as. `PaneWork.run()` receives the pane's concrete `PaneTerminal` — the same value `` already reaches through the pane seam — and `usePaneTerminal()` installs what it was handed rather than wrapping a claim in another object. Readiness, live-operation tracking and closing admission are now private state of the grid lifecycle, held per pane beside the terminal they belong to. Nothing exports a pane claim, a readiness view, an aggregate of either, or a factory and sealing pair for another package to coordinate through; the lifecycle asks its own questions of its own state, and the ordinal validation that guarded the minting now guards building the terminals. A self-closing pane's shell runs through `interactive()` like any other pane's work, so one boundary reports every spawn. The rows that drove the removed factory directly now drive the real lifecycle: two panes rendezvous inside their interactive bodies to prove they overlap, one pane refuses an operation while another is live and admits the next after it settles, a terminal kept past its grid refuses, two acknowledgements are one started pane, and work that reports no spawn fails startup. The ordinal guard is asked through `openTerminalGrid()` with a layout whose second cell calls itself pane 0 from position 1: the refusal names both, and no pane work runs and nothing attaches, because the guard runs before a pane terminal exists. Every one of those rows was checked against a deliberately broken lifecycle before being trusted — including the ordinal row, which passes and fails with the guard rather than beside it. Provider presentation authority is untouched: the request object is still the unforgeable carrier, and this only stops that authority from also minting a per-pane one. --- packages/core/mod.ts | 8 +- packages/core/src/expand.ts | 18 +- packages/core/src/terminal/authority.ts | 172 +--------- packages/core/src/terminal/grid.ts | 195 +++++++++-- packages/core/src/terminal/pane.ts | 38 +-- packages/core/tests/terminal-grid.test.ts | 391 +++++++++++++++++----- 6 files changed, 505 insertions(+), 317 deletions(-) diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 568ae1e19..d330284d6 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -154,17 +154,11 @@ export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; export { createTerminalAuthority, - createTerminalGridClaims, TerminalAuthorityError, terminalInstallation, useTerminalInstallation, } from "./src/terminal/authority.ts"; -export type { - PaneReadiness, - TerminalGridAuthority, - TerminalGridClaims, - TerminalPaneClaim, -} from "./src/terminal/authority.ts"; +export type { TerminalGridAuthority } from "./src/terminal/authority.ts"; export { installTerminalProvider, registerTerminalProvider, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index a9e97ffd1..7bc2969f9 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2202,9 +2202,10 @@ function* expandTerminalGrid( } /** - * What one authored pane does once the grid has minted its claim. + * What one authored pane does once the grid has created its terminal. * - * A self-closing pane runs the host's default shell through its claim. A paired + * A self-closing pane runs the host's default shell through that terminal's + * interactive operation, exactly as a paired pane's content does. A paired * pane expands its own content in a scope of its own: it inherits the bindings, * providers, configuration and working directory visible where the grid was * written, and everything it creates afterwards stays inside the pane. Its @@ -2216,9 +2217,12 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, - *run(claim, composite) { - const outcome = yield* claim.admit(() => - composite.shell(pane.ordinal, () => claim.ready()), + *run(terminal, composite) { + // The shell is this pane's one interactive operation, and the spawn it + // reports is what makes the pane ready — the same boundary a paired + // pane's content crosses, rather than a second way in. + const outcome = yield* terminal.interactive((spawned) => + composite.shell(pane.ordinal, spawned), ); if (outcome.signal !== undefined) { throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); @@ -2234,12 +2238,12 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { return { ordinal: pane.ordinal, - *run(claim, composite) { + *run(terminal, composite) { yield* scoped(function* () { // A pane is not inside the loop the grid was written in, so a // in its content has no loop to exit and says so. yield* ActiveLoop.set(undefined); - yield* usePaneTerminal(claim); + yield* usePaneTerminal(terminal); const siteEnv = yield* env; // Starts from what the grid site can see and keeps its own writes: a // binding this pane makes is visible to later work in this pane and to diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index 64e11fce3..a11a99dd1 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -11,15 +11,18 @@ * authority reachable by name would be an authority every same-name context and * every loaded copy could reach. * - * A claim is the unforgeable carrier. It is minted here for one ordinal of one - * request under one installation generation, and a claim from another grid, - * another ordinal, an earlier generation, or a finished expansion authorizes - * nothing at all. Holding one grants terminal ownership and nothing else: it + * The request object is the unforgeable carrier. It is issued here for one grid + * under one installation generation, and a request from another grid, an + * earlier generation, or a finished expansion presents nothing at all. + * Presenting one grants the provider its drawing surface and nothing else: it * says nothing about which Agent session a pane may own, because that is the * session coordinator's to answer and stays independently authoritative. + * + * What a pane's work may do with its terminal is not decided here. The grid + * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. */ -import { all, createContext, ensure, withResolvers } from "effection"; +import { createContext } from "effection"; import type { Context, Operation } from "effection"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -27,63 +30,12 @@ export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; } -/** - * One pane's terminal ownership. - * - * `admit` is the whole of it: an interactive operation runs inside one, and a - * second one on the same pane is refused while the first is live. Two claims for - * two ordinals do not contend at all, which is what lets panes be interactive at - * the same time. - */ -export interface TerminalPaneClaim { - readonly ordinal: number; - /** - * Run one interactive operation as this pane's owner. - * - * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has stopped admitting work — a claim kept past its - * expansion is a claim to a terminal nobody owns any more. - */ - admit(body: () => Operation): Operation; - /** - * Acknowledge the runtime's successful child-spawn event for this pane. - * - * The one thing that makes a pane ready. Called from the spawn event and - * before anything waits for the child to exit, so a child that starts and - * immediately exits is both ready and settled. Acknowledging twice has no - * effect, and a preparation, reservation or spawn that failed never - * acknowledges at all. - */ - ready(): void; -} - -/** What one pane's readiness is waiting on, from the grid's side. */ -export interface PaneReadiness { - /** Settles when the pane's first interactive child reports its spawn event. */ - reached(): Operation; - /** Whether the latch has been acknowledged. */ - readonly acknowledged: boolean; -} - -/** The claims one grid expansion holds, and what they are waiting on. */ -export interface TerminalGridClaims { - readonly claims: readonly TerminalPaneClaim[]; - readonly readiness: readonly PaneReadiness[]; - /** - * Stop admitting anything on every pane. - * - * Close prevents a later launch before it cancels the live ones, so a pane - * that was about to start one is refused rather than raced. - */ - seal(): void; -} - /** * What a registered provider must present in order to act. * * Delivered directly to the provider factory as it installs, and reachable * nowhere else. Presenting the exact request core issued is what takes the - * terminal leases, mints the pane claims, and runs the grid; anything else — + * terminal leases and runs the grid; anything else — * a copy, a rebuilt lookalike, an earlier grid's request, a request already * presented, or one belonging to a superseded installation — authorizes * nothing. @@ -194,109 +146,3 @@ export function* useTerminalInstallation(): Operation { export function terminalInstallation(): Operation { return Installation.get(); } - -/** - * Mint the claims for one grid expansion. - * - * The request is validated against the ordinals it declares before a single - * claim exists: a request whose panes are not exactly `0..n-1` in order - * describes a grid core did not derive, and answering it would be answering for - * a layout nobody authored. - */ -export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { - validate(request); - - let sealed = false; - const claims: TerminalPaneClaim[] = []; - const readiness: PaneReadiness[] = []; - - for (const pane of request.panes) { - const latch = withResolvers(); - let acknowledged = false; - let live = false; - - readiness.push({ - reached: () => latch.operation, - get acknowledged() { - return acknowledged; - }, - }); - - claims.push({ - ordinal: pane.ordinal, - *admit(body: () => Operation): Operation { - if (sealed) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, - ); - } - if (live) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + - `terminal at a time`, - ); - } - live = true; - try { - return yield* body(); - } finally { - live = false; - } - }, - ready() { - // Idempotent by construction: readiness is a fact about the pane, and a - // provider that reports the same spawn twice has not started two panes. - if (acknowledged) { - return; - } - acknowledged = true; - latch.resolve(); - }, - }); - } - - return { - claims, - readiness, - seal() { - sealed = true; - }, - }; -} - -function validate(request: TerminalGridRequest): void { - if (request.panes.length === 0) { - throw new TerminalAuthorityError("a terminal grid request names no panes"); - } - for (const [index, pane] of request.panes.entries()) { - if (pane.ordinal !== index) { - throw new TerminalAuthorityError( - `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + - `a pane's ordinal is its position among the grid's panes`, - ); - } - } -} - -/** - * Settle once every pane has reported its spawn event. - * - * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing - * run deadline or parent cancellation is what bounds it. A pane that fails to - * start never reaches its latch, so the caller races this against pane failure - * rather than asking the barrier to know about failure. - */ -export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { - return allOf(readiness.map((pane) => pane.reached())); -} - -function* allOf(waits: readonly Operation[]): Operation { - yield* all(waits); -} - -/** Seal the grid as soon as the enclosing scope begins to unwind. */ -export function sealOnTeardown(claims: TerminalGridClaims): Operation { - return ensure(() => { - claims.seal(); - }); -} diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index f96fb091a..45c2d6786 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -23,6 +23,7 @@ */ import { + all, createScope, Err, ensure, @@ -45,15 +46,133 @@ import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { - awaitReadiness, - createTerminalGridClaims, - TerminalAuthorityError, - terminalInstallation, -} from "./authority.ts"; -import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; +import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; +import type { LiveGrid } from "./authority.ts"; +import type { PaneTerminal } from "./pane.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * One pane's terminal, and the state only the grid lifecycle may touch. + * + * The terminal is the whole of what crosses into pane work. Everything else + * here answers a question the lifecycle asks about a pane — has it started, + * may it still start anything — and is deliberately not reachable from the + * pane, from a provider, or from a document: a second capability model beside + * `PaneTerminal` would be a second way to own a pane. + */ +interface LivePane { + /** What this pane's work runs on. */ + readonly terminal: PaneTerminal; + /** Settles once this pane has reported its child-spawn event. */ + spawnReported(): Operation; + /** Whether that event has been reported. */ + readonly hasSpawned: boolean; + /** + * Report the event without entering the pane's work. + * + * A pane restored from its retained outcome did start — on the run that + * recorded it — so the barrier is satisfied without a child existing now. + */ + recordSpawn(): void; + /** Refuse any further interactive work in this pane. */ + closeAdmission(): void; +} + +/** + * Build one pane terminal per authored ordinal. + * + * The request is validated against the ordinals it declares before a single + * terminal exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +function livePanes(request: TerminalGridRequest): LivePane[] { + validateOrdinals(request); + + return request.panes.map((pane) => { + const reported = withResolvers(); + let hasSpawned = false; + let live = false; + let closed = false; + + return { + terminal: { + ordinal: pane.ordinal, + *interactive(body: (spawned: () => void) => Operation): Operation { + if (closed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(() => { + // Idempotent by construction: readiness is a fact about the pane, + // and a provider that reported the same spawn twice has not + // started two panes. + if (hasSpawned) { + return; + } + hasSpawned = true; + reported.resolve(); + }); + } finally { + // Released on every ending, so a pane that settled admits the next + // operation written after it. + live = false; + } + }, + }, + spawnReported: () => reported.operation, + get hasSpawned() { + return hasSpawned; + }, + recordSpawn() { + if (hasSpawned) { + return; + } + hasSpawned = true; + reported.resolve(); + }, + closeAdmission() { + closed = true; + }, + }; + }); +} + +function validateOrdinals(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reports, so the caller races this against pane failure rather + * than asking the barrier to know about failure. + */ +function* everyPaneStarted(panes: readonly LivePane[]): Operation { + yield* all(panes.map((pane) => pane.spawnReported())); +} + /** * The live boundary reader close crosses (architecture.md §Atomic presentation * and settlement). @@ -61,7 +180,7 @@ import type { TerminalGridLayout } from "../terminal-grid.ts"; * The provider settling `closed()` only *proposes* the boundary. It is crossed * when the owner awaiting the grid's durable child acknowledges that proposal * from inside its own cancellation-deferred await — and only then may the grid - * seal admission and ask its panes to close. + * close admission and ask its panes to close. * * Nothing here is journaled and nothing here names a provider: it is one live * rendezvous between a durable child and the owner waiting on it. What it buys @@ -139,16 +258,16 @@ export interface RetainedGrid extends Record { } /** - * What one pane does once its claim exists. + * What one pane does once its terminal exists. * * The caller supplies this because a pane's work is the document's: a paired * pane expands its authored content, and a self-closing one runs the host's - * default shell. Both run as the pane's admitted owner, and both are expected - * to report a spawn through the claim before anything can attach. + * default shell. Both run through `terminal.interactive()`, and both are + * expected to report a spawn from inside it before anything can attach. */ export interface PaneWork { readonly ordinal: number; - run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; + run(terminal: PaneTerminal, composite: TerminalComposite): Operation; } /** @@ -282,11 +401,11 @@ function presentGrid( // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); - const grid = createTerminalGridClaims(request); + const panes = livePanes(request); // Nothing new is admitted once teardown begins, so a pane that was about to // start an interactive child is refused rather than racing the close. yield* ensure(() => { - grid.seal(); + closeAdmission(panes); }); const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); @@ -308,34 +427,25 @@ function presentGrid( // completed pane returns its retained outcome without entering a body, a // shell, or a launcher, and that outcome is what publishes its status and // satisfies the readiness barrier. - const panes: Task[] = []; + const children: Task[] = []; for (const [index, pane] of work.entries()) { - const claim = grid.claims[index]!; - const readiness = grid.readiness[index]!; - panes.push( + const live = panes[index]!; + children.push( yield* paneChild(function* (): Operation { - return yield* runPane( - pane, - claim, - composite, - readiness, - request, - index, - closing.operation, - ); + return yield* runPane(pane, live, composite, request, index, closing.operation); }), ); } // Observing each task is what turns a pane's outcome — replayed or live — - // into a published status and a satisfied readiness latch. - for (const [index, task] of panes.entries()) { + // into a published status and a pane the barrier counts as started. + for (const [index, task] of children.entries()) { yield* spawn(function* () { const outcome = yield* task; outcomes[index] = outcome; // A pane restored from its retained outcome counts as started: it did // start, on the run that recorded it. - grid.claims[index]!.ready(); + panes[index]!.recordSpawn(); yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has @@ -350,7 +460,7 @@ function presentGrid( // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. try { - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + yield* race([everyPaneStarted(panes), startupFailed.operation]); } catch { // Simultaneous startup failures are selected by authored ordinal, not by // whichever rejected the race first. @@ -383,7 +493,7 @@ function presentGrid( // own teardown after this returns — so the composite is destroyed, the // lease released and the following sibling started only once nothing a pane // acquired can still act. - grid.seal(); + closeAdmission(panes); closing.resolve(); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own @@ -396,7 +506,7 @@ function presentGrid( for (const [index] of work.entries()) { // Awaited, not halted. Each pane settles on the close signal and records // the outcome it reached, which is what a resumed run reads. - const outcome = yield* panes[index]!; + const outcome = yield* children[index]!; outcomes[index] ??= outcome; } @@ -406,12 +516,23 @@ function presentGrid( }); } +/** + * Stop every pane admitting new interactive work. + * + * Called before the live panes are asked to stop, so a pane that was about to + * start an interactive child is refused rather than racing the close. + */ +function closeAdmission(panes: readonly LivePane[]): void { + for (const pane of panes) { + pane.closeAdmission(); + } +} + /** Run one pane's work and say what it came to. */ function runPane( pane: PaneWork, - claim: TerminalPaneClaim, + live: LivePane, composite: TerminalComposite, - readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, closing: Operation, @@ -423,7 +544,7 @@ function runPane( // comes down in the enclosing scope's own teardown — so a pane whose // finalizers are slow cannot hold up the outcome the grid already knows, // and the record a resumed run reads is written either way. - const running = yield* spawn(() => pane.run(claim, composite)); + const running = yield* spawn(() => pane.run(live.terminal, composite)); const closed = yield* race([ (function* (): Operation { yield* running; @@ -441,7 +562,7 @@ function runPane( yield* running.halt(); return { status: "closed", reason: "" }; } - if (!readiness.acknowledged) { + if (!live.hasSpawned) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. return { diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts index f308de81b..f6039ffef 100644 --- a/packages/core/src/terminal/pane.ts +++ b/packages/core/src/terminal/pane.ts @@ -7,10 +7,11 @@ * is the whole reason a grid exists. So core installs this in each pane's own * scope, and anything interactive asks here first. * - * What travels contextually is the seam, not the authority. The claim it hands - * out was minted for one ordinal of one grid and cannot be forged, copied - * usefully, or kept past the expansion that owns it — so a replaced context - * yields a pane terminal nobody owns rather than a way into one somebody does. + * What travels contextually is the seam, not the authority. The value it holds + * is the one `PaneTerminal` the grid lifecycle created for this ordinal, and it + * grants nothing once that grid stops admitting work — so a replaced context, + * or one kept past the expansion that owns it, yields a pane terminal nobody + * owns rather than a way into one somebody does. * * Absence is the ordinary case and means "not in a pane": work outside a grid * reads nothing here and goes on competing for the root lease exactly as it @@ -19,7 +20,6 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; -import type { TerminalPaneClaim } from "./authority.ts"; /** The pane the current work is running in. */ export interface PaneTerminal { @@ -28,14 +28,15 @@ export interface PaneTerminal { /** * Run one interactive operation as this pane's owner. * - * `body` receives the pane's readiness latch and must call it from the - * runtime's successful child-spawn event, before it waits for the child to - * exit. A body that never spawns never reports, and the grid it belongs to - * never attaches — which is what stops a pane that failed to start being - * presented as one that is running. + * `body` receives this pane's one-use `spawned` acknowledgement and must call + * it from the runtime's successful child-spawn event, before it waits for the + * child to exit. Acknowledging twice is one event. A body that never spawns + * never reports, and the grid it belongs to never attaches — which is what + * stops a pane that failed to start being presented as one that is running. * - * A second interactive operation while one is live on this pane is refused. - * Two panes do not contend with each other at all. + * A second interactive operation while one is live on this pane is refused, + * and so is any operation once the grid has stopped admitting work. Sequential + * operations in one pane are ordinary. Two panes do not contend at all. */ interactive(body: (spawned: () => void) => Operation): Operation; } @@ -52,15 +53,14 @@ export function paneTerminal(): Operation { /** * Install one pane's seam for the scope that runs that pane's work. * + * The terminal is installed as it was given. Wrapping it here would put a + * second object between the pane's work and the one the lifecycle is tracking, + * and the refusals and readiness this seam exists to carry are that object's. + * * Set rather than composed: a pane is not a layer over the enclosing pane, * because panes do not nest. A grid written inside a pane is refused by the * grammar, so the value a pane's scope holds is always its own. */ -export function* usePaneTerminal(claim: TerminalPaneClaim): Operation { - yield* PaneTerminalContext.set({ - ordinal: claim.ordinal, - interactive(body) { - return claim.admit(() => body(() => claim.ready())); - }, - }); +export function* usePaneTerminal(terminal: PaneTerminal): Operation { + yield* PaneTerminalContext.set(terminal); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4acf29a8d..c0d623567 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -59,11 +59,7 @@ import type { import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { - createTerminalGridClaims, - TerminalAuthorityError, - useTerminalInstallation, -} from "../src/terminal/authority.ts"; +import { TerminalAuthorityError, useTerminalInstallation } from "../src/terminal/authority.ts"; import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; import { installTerminalProvider, @@ -73,6 +69,9 @@ import { } from "../src/terminal/provider-api.ts"; import { installTerminalGridProfile } from "../src/terminal/profile.ts"; import { paneTerminal } from "../src/terminal/pane.ts"; +import type { PaneTerminal } from "../src/terminal/pane.ts"; +import { createCloseBoundary, openTerminalGrid } from "../src/terminal/grid.ts"; +import type { PaneWork } from "../src/terminal/grid.ts"; import type { Json } from "../src/types.ts"; /** One document run against a controlled grid host. */ @@ -115,6 +114,48 @@ function useDir(): Operation { }); } +/** + * What the pane-terminal rows read. + * + * The claim factory these rows used to call directly is gone, and rightly: the + * behaviour it carried is the grid's. So each of these is driven from inside a + * real pane, through the same `PaneTerminal` a `` reaches, and + * read back off an ordered record rather than inferred. + */ +interface PaneProbe { + /** Refusals the document's own work collected, in the order they happened. */ + readonly refusals: string[]; + /** Ordered marks: which pane entered and left its interactive work. */ + readonly marks: string[]; + /** Pane terminals kept past their grid on purpose. */ + readonly kept: PaneTerminal[]; + /** Announce that this pane is inside its interactive body. */ + entered(): void; + /** Settles once every pane this probe expects is inside one at the same time. */ + overlapped(): Operation; +} + +function paneProbe(expected = 2): PaneProbe { + const all = withResolvers(); + let inside = 0; + return { + refusals: [], + marks: [], + kept: [], + entered() { + inside += 1; + if (inside >= expected) { + all.resolve(); + } + }, + overlapped: () => all.operation, + }; +} + +function refusalOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** The controlled interactive child, and a tripwire. */ function useGridComponents( ran: string[], @@ -123,6 +164,7 @@ function useGridComponents( afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, teardownArmed: () => void = () => {}, + probe: PaneProbe = paneProbe(), ): Operation { return registerComponents([ { @@ -156,6 +198,104 @@ function useGridComponents( return ""; }, }, + { + // Enters its pane's interactive body and stays there until every other + // pane is inside one too. Two panes that contended could never both be + // inside, so the wait is the proof; the deadline only turns a regression + // into a failed assertion instead of a hung suite. + name: "Concurrent", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + probe.marks.push(`enter:${pane.ordinal}`); + probe.entered(); + const together = yield* race([ + (function* (): Operation { + yield* probe.overlapped(); + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${pane.ordinal}:${together}`); + spawned(); + }); + probe.marks.push(`leave:${pane.ordinal}`); + return ""; + }, + }, + { + // One pane, asked for two interactive operations at once and then for a + // second one after the first settled. + name: "Overlapping", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + try { + yield* pane.interactive(function* () { + probe.marks.push("second entered"); + }); + } catch (error) { + probe.refusals.push(refusalOf(error)); + } + }); + // The pane is free again: one owner at a time is not one owner ever. + yield* pane.interactive(function* () { + probe.marks.push("sequential"); + }); + // Kept deliberately, so a row can ask what it grants after the grid has + // closed. + probe.kept.push(pane); + return ""; + }, + }, + { + // Reports the same spawn twice. One pane started, not two. + name: "TwiceSpawned", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + spawned(); + probe.marks.push("spawned twice"); + }); + return ""; + }, + }, + { + // Interactive work that never reports a spawn: doing work is not starting. + name: "Quiet", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* () { + probe.marks.push("worked without spawning"); + }); + return ""; + }, + }, { // Starts interactively, slowly, and records when it did. name: "Slow", @@ -289,6 +429,8 @@ function runDocument( slowMarks?: string[]; /** Props this run supplies. Props are not restored across a continuation. */ props?: Record; + /** What the pane-terminal rows record through the pane seam. */ + probe?: PaneProbe; } = {}, ): Operation { return scoped(function* () { @@ -304,7 +446,15 @@ function runDocument( return yield* next(segment); }, }); - yield* useGridComponents(ran, options.slowMarks ?? []); + yield* useGridComponents( + ran, + options.slowMarks ?? [], + undefined, + undefined, + undefined, + undefined, + options.probe, + ); yield* installControlledLauncher(); // The reader stays until every pane has settled. Leaving sooner is a real @@ -833,105 +983,178 @@ describe("Tier TG — the terminal authority", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TA7: two claims from one grid do not contend; one pane admits one", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; + it("TA7: two panes are interactive at the same time", function* () { + const dir = yield* useDir(); + const probe = paneProbe(2); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { probe }, + ); - yield* scoped(function* () { - yield* first.admit(function* () { - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - yield* second.admit(function* () { - concurrent = true; - }); - }); - }); + expect(run.outcome.ok).toBe(true); + // Each pane waited inside its own interactive body until the other was + // inside one too. Panes that contended could not both report this. + expect(probe.marks).toContain("together:0:true"); + expect(probe.marks).toContain("together:1:true"); + // And both were inside before either left. + expect(probe.marks.indexOf("enter:1")).toBeLessThan(probe.marks.indexOf("leave:0")); + }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", + it("TA8: one pane refuses overlapping work, and admits the next after it settles", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, ); - expect(concurrent).toBe(true); + + expect(run.outcome.ok).toBe(true); + expect(probe.refusals).toHaveLength(1); + expect(probe.refusals[0]).toContain("one owns a pane terminal at a time"); + // The refused operation never ran, and the one written after the first + // settled did: a pane has one owner at a time, not one owner ever. + expect(probe.marks).not.toContain("second entered"); + expect(probe.marks).toContain("sequential"); }); - it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { - const request = { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], - }; - const first = createTerminalGridClaims(request); - const second = createTerminalGridClaims(request); - // Sealing one grid says nothing about the other: claims belong to the grid - // that minted them, not to a request shape. - first.seal(); + it("TA9: a pane terminal kept past its grid admits nothing", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + expect(run.outcome.ok).toBe(true); + const kept = probe.kept[0]; + expect(kept).toBeDefined(); let refusal: unknown; - let other = false; yield* scoped(function* () { try { - yield* first.claims[0]!.admit(function* () {}); + yield* kept!.interactive(function* () {}); } catch (error) { refusal = error; } - yield* second.claims[0]!.admit(function* () { - other = true; - }); }); - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); - expect(other).toBe(true); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusalOf(refusal)).toContain("its grid has stopped admitting"); }); - it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; - - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); - }); + it("TA10: only a reported spawn is readiness, and reporting twice is one event", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // Two acknowledgements are one started pane: the grid attached once and + // settled, rather than waiting for a second pane nobody authored. + expect(run.outcome.ok).toBe(true); + expect(probe.marks).toContain("spawned twice"); + expect(run.events).toContain("attach:0"); + }); + + it("TA11: interactive work that reports no spawn has not started", function* () { + const dir = yield* useDir(); + const probe = paneProbe(1); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + ].join("\n"), + { probe }, + ); + + // The pane owned its terminal and did work in it. Neither is starting. + expect(probe.marks).toContain("worked without spawning"); + expect(failureOf(run)).toContain("finished without starting anything interactive"); + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); }); - it("TA10: a request whose ordinals are not its positions is refused", function* () { + it("TA12: a layout whose ordinal is not its position is refused before a pane exists", function* () { + // The guard the lifecycle runs before it builds a single pane terminal. + // Asked through the real entry point with a layout core would never derive: + // the second cell calls itself pane 0 while sitting at position 1, so the + // request describes a grid nobody authored. + const attached: string[] = []; + const started: number[] = []; let refusal: unknown; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], + + yield* scoped(function* () { + yield* useGridHost({ + // deno-lint-ignore require-yield + *onAttach() { + attached.push("attach"); + }, }); - } catch (error) { - refusal = error; - } + + const work: PaneWork[] = [0, 1].map((ordinal) => ({ + ordinal, + // deno-lint-ignore require-yield + *run() { + started.push(ordinal); + }, + })); + + try { + yield* openTerminalGrid( + { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }, + work, + createCloseBoundary(), + ); + } catch (error) { + refusal = error; + } + }); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); - yield* sleep(0); + const message = refusal instanceof Error ? refusal.message : ""; + // The refusal says which ordinal, and where it actually sat. + expect(message).toContain("ordinal 0"); + expect(message).toContain("position 1"); + // Refused before anything could own a pane terminal: no pane work ran, and + // nothing was ever shown. + expect(started).toEqual([]); + expect(attached).toEqual([]); }); }); From bb5c1255e471967380b7ab06b2af782ab77bc4df Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:35:48 -0400 Subject: [PATCH 18/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Give=20one=20owner?= =?UTF-8?q?=20the=20grids=20a=20terminal=20installation=20issued=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry was a Set the authority searched. Two things have to meet before a grid exists — the document submits the authored request and its pane work, a provider presents a composite for that exact object — and nothing held both sides or decided when they had met. The supervisor does: it keeps the entries, converges them by object identity and installation generation, and is where every refusal a presentation can meet now lives. Each grid runs as a task the supervisor keeps, in a scope of its own beneath the operation that submitted it. Beneath, and not beneath the supervisor, because a pane's durable identity and the bindings its content reads are the expansion's: parenting grids to the supervisor's own scope loses both, and the suite says so loudly — pane durable order, pane bindings, the startup-failure paths and the provider-failure path all fail together. That parentage is also what makes a grid impossible to strand, which is worth stating plainly: removing either finalizer the supervisor registers changes no test, because the submitting operation unwinds whenever the call that routed it does and takes the grid with it. They are kept as the supervisor saying what it owns rather than as the thing that makes it true, and the comments say which. Six rows drive the ownership directly, each one finite and signalled rather than timed: a provider that answers without presenting opens nothing, one request opens one grid however often it is presented, a settled grid is gone while the next still opens and its composite was destroyed first, a presenting call that is cancelled leaves no pane and no composite behind, an installation teardown stops the grid still live and waits for it, and a second grid cannot be live beside the first — which is why "every remaining grid" is one grid: the foreground-terminal lease admits a single grid at a time. Everything the refactor was meant to preserve is unchanged and still proven by the rows that already existed: readiness and attachment, per-pane exclusivity, close admission, reader close distinct from cancellation, completed and incomplete replay, exact-request and generation refusals, and pane work that still receives only its PaneTerminal. No document names a registry, a live-grid holder or a supervisor, so no stated contract moved with this. --- packages/core/src/terminal/authority.ts | 227 ++++++++++++---- packages/core/src/terminal/grid.ts | 17 +- packages/core/tests/terminal-grid.test.ts | 308 +++++++++++++++++++++- 3 files changed, 489 insertions(+), 63 deletions(-) diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index a11a99dd1..aaf8eba9e 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -22,8 +22,8 @@ * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. */ -import { createContext } from "effection"; -import type { Context, Operation } from "effection"; +import { createContext, createScope, ensure, resource, until } from "effection"; +import type { Context, Operation, Scope, Task } from "effection"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { @@ -44,79 +44,202 @@ export interface TerminalGridAuthority { present(request: TerminalGridRequest, composite: TerminalComposite): Operation; } -/** One grid this execution issued, from the authority's side. */ -export interface LiveGrid { +/** + * One grid the supervisor has been asked to run, before a composite exists. + * + * The scope is the submitting operation's own. A grid keeps the contexts of the + * expansion that wrote it — its durable child above all, which is what gives its + * panes their identities — so the supervisor owns when a grid stops, never what + * it runs under. + */ +export interface SubmittedGrid { /** The exact request object core issued. Compared by identity, never shape. */ readonly request: TerminalGridRequest; /** The installation this grid belongs to. */ readonly generation: object; - /** Run the grid on a presented composite, and keep what it settled to. */ + /** Where the grid runs: a scope of its own, beneath the submitter's. */ + readonly scope: Scope; + /** Run the grid on a presented composite. */ run(composite: TerminalComposite): Operation; - /** Whether this request has already been presented. */ - used: boolean; - /** Whether the grid actually ran to a settlement. */ +} + +/** What the submitting operation can ask about its own grid afterwards. */ +export interface GridSubmission { + /** Whether a provider presented for this request and the grid ran through. */ + readonly settled: boolean; +} + +/** One submitted grid, and whatever of it is currently live. */ +interface Entry extends SubmittedGrid, GridSubmission { + presented: boolean; settled: boolean; + task?: Task; + destroy?: () => Promise; } -/** Every grid this execution has issued and not yet finished. */ -export interface GridRegistry { - live(): readonly LiveGrid[]; - add(grid: LiveGrid): void; - remove(grid: LiveGrid): void; +/** + * Who owns the grids one terminal installation has issued. + * + * Two things have to meet before a grid exists: the document submits the + * authored request and the work its panes do, and a provider presents a + * composite for that exact request. Neither alone starts anything — a + * registration that never routes and a presentation of a request nobody + * submitted both open nothing — and the supervisor is what makes them converge + * by object identity and installation generation rather than by shape. + * + * It holds what it starts. Each grid runs as a task the supervisor keeps, in a + * scope of its own beneath the operation that submitted it — beneath, because a + * pane's durable identity and the bindings its content reads are the + * expansion's, and a grid parented anywhere else is a grid whose panes belong + * to nobody in particular. + * + * That parentage is also what makes a grid impossible to strand: the submitting + * operation unwinds whenever the call that routed it does, and takes the grid + * with it. The supervisor stopping its own entries at installation teardown, + * and stopping one whose presenting call was cancelled, is therefore belt and + * braces rather than the mechanism — deliberately so, because the mechanism is + * a structural property nobody reading this file can see. + * + * Private to core: nothing reachable by importing this package can submit a + * grid, present for one, or ask what is live. + */ +export interface GridSupervisor { + /** + * Register one authored request and its work. + * + * The entry is removed when the submitting operation unwinds — after that + * operation's own finalizers, so the foreground-terminal lease is released + * before the grid stops being something a provider could present for. + */ + submit(grid: SubmittedGrid): Operation; + /** Run the grid this exact request names, under this exact generation. */ + present( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ): Operation; } -export function createGridRegistry(): GridRegistry { - const grids = new Set(); - return { - live: () => [...grids], - add: (grid) => { - grids.add(grid); - }, - remove: (grid) => { - grids.delete(grid); - }, - }; +/** + * Stop one grid and wait for all of it. + * + * Halting the task settles its panes, runs their finalizers and destroys the + * composite; destroying the scope is what releases everything the grid itself + * established. Both are idempotent here, because a grid may be stopped by the + * presenting call that was cancelled, by installation teardown, or by neither. + */ +function* stopGrid(entry: Entry): Operation { + const task = entry.task; + entry.task = undefined; + if (task !== undefined) { + yield* task.halt(); + } + const destroy = entry.destroy; + entry.destroy = undefined; + if (destroy !== undefined) { + yield* until(destroy()); + } +} + +/** Open the supervisor one execution's grids belong to. */ +export function useGridSupervisor(): Operation { + return resource(function* (provide) { + const entries = new Set(); + + // Installation teardown. Every grid still live is stopped here and waited + // for. Scope parentage already reaches each one, so this is the supervisor + // saying so itself rather than the only thing that says it. + yield* ensure(function* () { + for (const entry of [...entries]) { + yield* stopGrid(entry); + } + }); + + yield* provide({ + *submit(grid: SubmittedGrid): Operation { + const entry: Entry = { ...grid, presented: false, settled: false }; + entries.add(entry); + yield* ensure(() => { + entries.delete(entry); + }); + return entry; + }, + *present( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ): Operation { + const entry = [...entries].find((candidate) => Object.is(candidate.request, request)); + if (entry === undefined) { + throw new TerminalAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(entry.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (entry.presented) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + entry.presented = true; + + // A scope of its own beneath the submitter's: the grid inherits the + // expansion's contexts, and the supervisor still holds the task. + const [scope, destroy] = createScope(entry.scope); + entry.destroy = destroy; + entry.task = scope.run(() => entry.run(composite)); + + // A presenting call that unwinds takes its grid with it. The submitter + // unwinding would too, which is why removing this changes no test — + // it is here so the supervisor's ownership does not depend on a + // structural coincidence holding forever. + yield* ensure(function* () { + yield* stopGrid(entry); + }); + + yield* entry.task; + entry.settled = true; + // Settled, so nothing is owed: the scope goes now rather than waiting + // for the provider's own call to end. + entry.task = undefined; + yield* until(destroy()); + entry.destroy = undefined; + }, + }); + }); } /** * Build the authority one provider installation is given. * - * It closes over the installation's generation and its registry, so a factory - * that kept an authority from a superseded installation presents into a - * generation that no longer has the grid it names. + * It closes over the installation's generation, so a factory that kept an + * authority from a superseded installation presents under a generation the + * supervisor no longer has the grid for. Deciding that is the supervisor's, and + * this is the seam that carries the generation to it. */ export function createTerminalAuthority( generation: object, - live: () => readonly LiveGrid[], + present: ( + request: TerminalGridRequest, + composite: TerminalComposite, + generation: object, + ) => Operation, ): TerminalGridAuthority { return { *present(request, composite) { - const grid = live().find((candidate) => Object.is(candidate.request, request)); - if (grid === undefined) { - throw new TerminalAuthorityError( - "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + - "belongs to an execution that has finished", - ); - } - if (!Object.is(grid.generation, generation)) { - throw new TerminalAuthorityError( - "this grid request belongs to another terminal provider installation", - ); - } - if (grid.used) { - throw new TerminalAuthorityError( - "this grid request has already been presented — one request opens one grid", - ); - } - grid.used = true; - yield* grid.run(composite); + yield* present(request, composite, generation); }, }; } -/** One execution's terminal installation: its registry and its generation. */ +/** One execution's terminal installation: its supervisor and its generation. */ export interface TerminalInstallation { - readonly registry: GridRegistry; + readonly supervisor: GridSupervisor; /** Identifies this execution's provider installation, and nothing else. */ readonly generation: object; } @@ -136,10 +259,10 @@ const Installation: Context = createContext< * refusal rather than a way in. */ export function* useTerminalInstallation(): Operation { - const registry = createGridRegistry(); + const supervisor = yield* useGridSupervisor(); const generation = {}; - yield* Installation.set({ registry, generation }); - return createTerminalAuthority(generation, () => registry.live()); + yield* Installation.set({ supervisor, generation }); + return createTerminalAuthority(generation, supervisor.present); } /** This execution's terminal installation, or `undefined` outside one. */ diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 45c2d6786..ecfccf15a 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -47,7 +47,6 @@ import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runti import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; -import type { LiveGrid } from "./authority.ts"; import type { PaneTerminal } from "./pane.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; @@ -346,19 +345,17 @@ export function openTerminalGrid( const request = toRequest(layout); let settled: RetainedGrid | undefined; - const grid: LiveGrid = { + // Submitted, not started. The supervisor holds the request and this work + // until a provider presents a composite for this exact object, and the grid + // runs beneath this operation's own scope so its panes keep the durable + // identity of the expansion that wrote them. + const submission = yield* installation.supervisor.submit({ request, generation: installation.generation, - used: false, - settled: false, + scope: yield* useScope(), *run(composite) { settled = yield* presentGrid(request, composite, work, boundary); - grid.settled = true; }, - }; - installation.registry.add(grid); - yield* ensure(() => { - installation.registry.remove(grid); }); // The one foreground-terminal lease, taken before any provider is asked for @@ -372,7 +369,7 @@ export function openTerminalGrid( // Routed, and the answer thrown away. yield* TerminalGrids.operations.open(request); - if (!grid.settled || settled === undefined) { + if (!submission.settled || settled === undefined) { throw new TerminalAuthorityError( "no terminal provider opened this grid — a handler answered without delivering the " + "request to a registered provider", diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index c0d623567..8204c41e0 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -71,7 +71,7 @@ import { installTerminalGridProfile } from "../src/terminal/profile.ts"; import { paneTerminal } from "../src/terminal/pane.ts"; import type { PaneTerminal } from "../src/terminal/pane.ts"; import { createCloseBoundary, openTerminalGrid } from "../src/terminal/grid.ts"; -import type { PaneWork } from "../src/terminal/grid.ts"; +import type { PaneWork, RetainedGrid } from "../src/terminal/grid.ts"; import type { Json } from "../src/types.ts"; /** One document run against a controlled grid host. */ @@ -156,6 +156,67 @@ function refusalOf(error: unknown): string { return error instanceof Error ? error.message : String(error); } +/** + * Open a grid with an owner that crosses its close boundary. + * + * `durableGrid()` supplies this in the document path: a grid proposes close and + * waits, and something has to acknowledge. A row that drives the lifecycle + * directly owns that side itself, or its grid waits for an owner that never + * arrives. + */ +function supervisedGrid(work: readonly PaneWork[]): Operation { + return (function* (): Operation { + const boundary = createCloseBoundary(); + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* openTerminalGrid(ONE_PANE, work, boundary); + })(); +} + +/** One authored pane, for the rows that drive the lifecycle directly. */ +const ONE_PANE = { + columns: 1, + rows: 1, + cells: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], +}; + +/** Pane work that starts, reports its spawn, and is done. */ +function readyPane(opened: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.interactive(function* (spawned) { + opened.push(mark); + spawned(); + }); + }, + }; +} + +/** + * Pane work that starts and then stays, announcing that it is live. + * + * Its finalizer is what a row reads to know the grid was actually taken down + * rather than left running: a pane nobody stopped never records one. + */ +function holdingPane(live: { resolve(): void }, finalized: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.interactive(function* (spawned) { + yield* ensure(() => { + finalized.push(mark); + }); + spawned(); + live.resolve(); + yield* suspend(); + }); + }, + }; +} + /** The controlled interactive child, and a tripwire. */ function useGridComponents( ran: string[], @@ -1158,6 +1219,251 @@ describe("Tier TG — the terminal authority", () => { }); }); +describe("Tier TG — the grid supervisor", () => { + /** + * A provider that presents exactly what it was routed, with hooks for the + * rows that need to interrupt it. + * + * Written out rather than reusing the document harness because these rows are + * about ownership: they drive `openTerminalGrid()` directly, so the grid's + * only owner is the operation the row is holding. + */ + function useSupervisedHost( + log: TerminalProviderLog, + options: { + readonly close?: () => Operation; + readonly onAttach?: () => Operation; + readonly onPresent?: ( + present: () => Operation, + request: TerminalGridRequest, + authority: TerminalGridAuthority, + ) => Operation; + readonly seen?: TerminalGridRequest[]; + } = {}, + ): Operation { + return (function* (): Operation { + let generation = 0; + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* TerminalGrids.around( + { + *open([request]) { + options.seen?.push(request); + const composite = yield* prepareControlledComposite( + request, + { + log, + ...(options.close === undefined ? {} : { close: options.close }), + ...(options.onAttach === undefined ? {} : { onAttach: options.onAttach }), + }, + generation++, + ); + const present = () => authority.present(request, composite); + if (options.onPresent === undefined) { + yield* present(); + } else { + yield* options.onPresent(present, request, authority); + } + return undefined; + }, + }, + { at: "min" }, + ); + }); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); + } + + it("TS1: a registered provider that is never routed starts nothing", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + + yield* scoped(function* () { + yield* installControlledLauncher(); + // Registered and installed, and it answers the routed request without + // ever presenting: reaching a provider is not opening a grid. + yield* registerTerminalProvider("controlled", function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open() { + return { presented: true }; + }, + }, + { at: "min" }, + ); + }); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + + let refusal: unknown; + try { + yield* supervisedGrid([readyPane(opened, "a")]); + } catch (error) { + refusal = error; + } + expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); + }); + + // Submitted and never presented: no pane ran and no composite existed. + expect(opened).toEqual([]); + expect(log.events).toEqual([]); + expect(log.live.composites).toBe(0); + }); + + it("TS2: one request opens one grid, however often it is presented", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + let second: unknown; + + yield* scoped(function* () { + yield* useSupervisedHost(log, { + *onPresent(present, request, authority) { + yield* present(); + // The same request again, with a composite of its own, once the grid + // it named has already run. + const again = yield* prepareControlledComposite(request, { log }, 9); + try { + yield* authority.present(request, again); + } catch (error) { + second = error; + } + }, + }); + yield* supervisedGrid([readyPane(opened, "a")]); + }); + + // The matching grid ran exactly once, and the second presentation of the + // same request opened nothing. + expect(opened).toEqual(["a"]); + expect(second).toBeInstanceOf(TerminalAuthorityError); + expect(refusalOf(second)).toContain("already been presented"); + }); + + it("TS3: a settled grid is gone, and the next one still opens", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + const seen: TerminalGridRequest[] = []; + let stale: unknown; + + yield* scoped(function* () { + const authority = yield* useSupervisedHost(log, { seen }); + + yield* supervisedGrid([readyPane(opened, "first")]); + yield* supervisedGrid([readyPane(opened, "second")]); + + // The first grid's request is no longer something a provider can present + // for: its entry went when its submitting operation unwound. + const late = yield* prepareControlledComposite(seen[0]!, { log }, 9); + try { + yield* authority.present(seen[0]!, late); + } catch (error) { + stale = error; + } + }); + + expect(opened).toEqual(["first", "second"]); + expect(stale).toBeInstanceOf(TerminalAuthorityError); + expect(refusalOf(stale)).toContain("is not live"); + // Only the settled grid was removed, and only after its own teardown: the + // first composite was destroyed before the second was ever prepared, and + // both grids destroyed theirs. + expect(log.events).toContain("destroy:0"); + expect(log.events).toContain("destroy:1"); + expect(log.events.indexOf("destroy:0")).toBeLessThan(log.events.indexOf("prepare:1:1x1")); + }); + + it("TS4: a presenting call that is cancelled leaves no grid running", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useSupervisedHost(log, { + // The reader never leaves, so the grid stays live until something stops + // it. + close: () => suspend(), + *onPresent(present) { + const presenting = yield* spawn(present); + yield* live.operation; + // The provider's own call goes while its grid is still running. + yield* presenting.halt(); + }, + }); + + try { + yield* supervisedGrid([holdingPane(live, finalized, "pane")]); + } catch (error) { + refusal = error; + } + }); + + // The grid went with the call that owned it rather than carrying on + // without one: its pane ran its finalizer, and the provider holds nothing. + expect(finalized).toEqual(["pane"]); + expect(log.live.composites).toBe(0); + expect(log.live.attached).toBe(0); + expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); + }); + + it("TS5: installation teardown stops the grid still live, and waits for it", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + const shown = withResolvers(); + + yield* scoped(function* () { + yield* useSupervisedHost(log, { + close: () => suspend(), + // deno-lint-ignore require-yield + *onAttach() { + shown.resolve(); + }, + }); + yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "pane")])); + // Held open: the row leaves the scope with an attached grid still running. + yield* live.operation; + yield* shown.operation; + expect(log.live.attached).toBe(1); + }); + + // The installation went, and took the grid with it — awaited, not abandoned. + expect(finalized).toEqual(["pane"]); + expect(log.live.composites).toBe(0); + expect(log.live.attached).toBe(0); + expect(log.live.shells).toBe(0); + }); + + it("TS6: a second grid cannot be live beside the first", function* () { + const log = terminalProviderLog(); + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* useSupervisedHost(log, { close: () => suspend() }); + yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "first")])); + yield* live.operation; + + // Why "every remaining grid" is one grid: the foreground-terminal lease + // admits a single grid at a time, so a second never reaches the + // supervisor at all. + try { + yield* supervisedGrid([readyPane([], "second")]); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("owns the terminal at a time"); + expect(finalized).toEqual(["first"]); + expect(log.live.composites).toBe(0); + }); +}); + describe("Tier TG — a grid written in a document", () => { it("TG4: the provider is asked for exactly the authored row-major layout", function* () { const dir = yield* useDir(); From 08287748fe4d2baaaa823a26ce3d454fd28f35a9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Fri, 11 Sep 2026 07:57:39 -0400 Subject: [PATCH 19/19] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Present=20a=20provid?= =?UTF-8?q?er's=20grid=20as=20a=20resource,=20and=20make=20acquisition=20r?= =?UTF-8?q?eadiness=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The supervisor added at bb5c1255 is gone. Its own evidence disproved it: neither finalizer it registered changed any test, because a grid already runs beneath the expansion that submitted it and structured concurrency already takes it down. What remains is the smallest lookup two sides need to converge, and nothing is named as an owner of anything. Readiness, admission, concurrency and closing are local state and closures inside `runGrid` — no aggregate, no controller, no factory, and `PaneTerminal.use()` is still the only capability pane work receives. `TerminalGridAuthority` was an object standing in for a function. It is now `PresentTerminalGrid`, delivered to the provider factory directly, and `TerminalAuthorityError` is `TerminalGridPresentationError`. Every refusal survives, and all are decided before the provider's grid is acquired. That lookup is now the execution's rather than each installation's. Paired one to one, the generation check could never fire: a superseded installation searched its own empty set and said the request was unknown, which was true of the set and false of the world. Sharing it means a superseded presentation finds the grid it names and is turned away for the reason that actually holds. `TerminalComposite` becomes `TerminalGrid`: this provider's realization of this grid, supplied as a resource. Acquiring it is the grid coming into existence, releasing it is the grid going away — once, after success, startup failure, reader close, provider failure and cancellation alike, each of which now says so. `destroy()` is gone, and with it both the double-destroy guard and the chance of forgetting. The pane capability is one operation. `use()` takes a terminal activity: a resource acquired only after its child has spawned, which is the readiness signal, and whose acquired value settles with that child's outcome. No `spawned` callback, no ordinal, no `LivePane`. A spawn failure fails before acquisition and now says why in its own words. Five refusal rows drive copied, changed, stale, twice-presented and wrong-generation requests against lazy grids that record every effect they could have; each proves the refusal left the record empty. Only what a provider needs is public: the error and the function's shape. Replay is unchanged and still proven: a completed grid contacts no provider, a completed pane restores its outcome without acquiring anything, and only the pane that must resume acquires an activity. --- architecture.md | 127 ++- packages/core/mod.ts | 13 +- packages/core/src/expand.ts | 20 +- packages/core/src/terminal/authority.ts | 271 ----- packages/core/src/terminal/grid.ts | 293 +++--- packages/core/src/terminal/pane.ts | 42 +- packages/core/src/terminal/presentation.ts | 142 +++ packages/core/src/terminal/profile.ts | 17 +- packages/core/src/terminal/provider-api.ts | 40 +- packages/core/tests/terminal-grid.test.ts | 934 +++++++++++++----- packages/runtime/mod.ts | 7 +- packages/runtime/terminal.ts | 220 +++-- .../runtime/tests/terminal-provider.test.ts | 159 +-- specs/executable-mdx-spec.md | 71 +- specs/native-agent-session-launch-spec.md | 45 +- 15 files changed, 1351 insertions(+), 1050 deletions(-) delete mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/presentation.ts diff --git a/architecture.md b/architecture.md index 0c74544eb..be10f394e 100644 --- a/architecture.md +++ b/architecture.md @@ -108,7 +108,7 @@ Existing documents and code get aligned to this section retroactively. | session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | -| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | +| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one grid presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | | terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown | | terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell | | pane-terminal lease | the exclusive ownership one live interactive operation holds through a pane's concrete `PaneTerminal`. Different panes do not contend; a second operation in one pane does. The grid lifecycle closes admission when that pane is closing, and terminal ownership grants no authority over an Agent session | @@ -3433,7 +3433,7 @@ provider-neutral grid of independently interactive terminal panes: `Terminal` names the interactive endpoint the document requires. It does not name the presentation technology: a tmux integration, another terminal -multiplexer, and a host-native composite UI are providers for the same +multiplexer, and a host-native grid UI are providers for the same contract. A component that elicits values through a terminal UI is a different abstraction, just as `` is one presentation for ``; it does not change what an interactive process requires here. @@ -3464,7 +3464,7 @@ outside that pane. Each pane also owns its checked-failure ledger. A checked failure settles that pane without poisoning the root or a sibling; core alone observes the pane outcome and applies the grid's settlement rule after close. -### Terminal authority +### Terminal grid presentation One grid holds the execution's foreground-terminal lease for its whole visible lifetime. A root `` and a grid therefore contend for the same @@ -3477,58 +3477,79 @@ or default shell are never captured or journaled. The grid renders nothing, and root document output resumes only after the provider has restored the root terminal. -The host owns one non-contextual terminal authority, built and delivered -directly to the installed provider. It validates the exact grid request and -provider installation generation and permits only that provider to present the -grid core issued. No context value, prop, binding, provider result, retained -record, diagnostic, or structurally similar request carries that presentation -authority. +The host owns one non-contextual presentation function, `PresentTerminalGrid`, +built and delivered directly to the installed provider. It validates the exact +grid request, the provider installation generation, and that the request has not +already been presented — and it decides all of that *before* the provider's grid +is acquired, so a refused presentation costs the provider nothing and produces +no side effect. No context value, prop, binding, provider result, retained +record, diagnostic, or structurally similar request carries it. + +A provider supplies its grid as a resource rather than an object with a +teardown method. Acquiring it is the grid coming into existence; releasing it is +the grid going away, exactly once, whether the grid succeeded, failed to start, +was closed by the reader, was failed by the provider, or was cancelled. There is +no destroy to call, and so no way to call one twice or to forget one. + +Nothing owns a grid but the expansion that submitted it. A grid runs beneath +that operation, which is what keeps its panes' durable identities and inherited +bindings those of the document position that wrote them, and what takes the grid +down whenever that operation unwinds. There is no execution-wide holder of live +grid tasks; what the installation keeps is the smallest lookup that lets a +submitted request and a presentation converge — the request object, its +generation, whether it has been presented, and the operation that runs it. The stable contextual terminal API is request routing only. Middleware may observe, narrow, refuse, wrap, or delegate a one-use request. A handler's return value is ignored, and answering without delegation authorizes and settles nothing. Core supplies the one request for the exact expansion; the -provider factory closes over the direct authority and must present that same -request to act. This preserves provider composition without letting a document +provider factory closes over the delivered presentation function and must +present that same request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. -The grid lifecycle creates one concrete `PaneTerminal` for each authored -ordinal and passes it to that pane's work. Its `interactive()` operation owns -the pane terminal for one live operation, refuses a concurrent operation in the -same pane, and supplies that operation with the one-use `spawned` -acknowledgement that makes the pane ready. Different `PaneTerminal` values do -not contend. Closing the grid prevents every pane terminal from admitting new -work before it asks live work to stop; retaining a pane terminal after its grid -closes grants nothing. +The grid lifecycle creates one concrete `PaneTerminal` for each authored ordinal +and passes it to that pane's work. It carries one operation, `use()`, and no +identity: core already knows which ordinal it built each one for, and a pane +that could name itself would be a pane something else could name. `use()` runs +one terminal activity as that pane's owner, refuses a concurrent use in the same +pane, permits sequential uses after settlement, and awaits the activity's own +cleanup before the pane is free again. Different `PaneTerminal` values do not +contend. Closing the grid prevents every pane terminal from admitting new work +before it asks live work to stop; retaining a pane terminal after its grid closes +grants nothing. Core installs that same `PaneTerminal` in the paired pane's scope. A pane-scoped native launcher reads it and `` consequently reserves, flushes, and launches on the pane terminal instead of competing for the root lease. The provider starts a self-closing pane's host-configured -default shell through the same operation. Sequential work in one pane remains +default shell as a terminal activity through the same operation. Sequential work in one pane remains ordinary composition. The session coordinator is unchanged and independently authoritative, so two panes attempting to own the same logical Agent session still contend and one is refused. -Readiness, live-operation tracking, and closing admission are private state of -the grid lifecycle, not a second public capability model. There is no public -pane-claim or readiness interface, no aggregate grid-claims object, and no -factory or sealing operation for another package to coordinate. The lifecycle -passes only the concrete `PaneTerminal` across the pane-work boundary. Its -`spawned` acknowledgement resolves the private readiness latch from the -runtime's successful child-spawn event and before the launcher waits for exit; -failed preparation, reservation, or spawn never acknowledges it. Allocation of -a PID and the child's first output are not this event. The acknowledgement is -idempotent, a root launch receives none, and neither the callback nor the latch -enters a request, provider result, process handle, or durable record. +Readiness is not something anybody acknowledges. A terminal activity is a +resource whose acquisition happens only once its child has actually spawned, and +that acquisition *is* the pane becoming ready; the value acquired is the +operation that settles with how the child ended. So preparation, reservation or +spawn failures all fail before acquisition and leave the pane unready, while a +child that spawns and exits immediately is both ready and settled. Allocation of +a PID and the child's first output are not acquisition. Nothing about readiness +enters a request, a provider result, a process handle or a durable record, and a +root launch has no pane activity at all. + +Readiness, live-use tracking, and closing admission are private state of the grid +lifecycle, not a second public capability model. There is no pane-claim or +readiness interface, no pane controller, no aggregate object, and no factory or +sealing operation for another package to coordinate. The lifecycle passes only +the concrete `PaneTerminal` across the pane-work boundary. A provider whose pane endpoint is owned by a persistent process routes child creation through that process. The launch's exact argv vector, working directory, and environment cross a provider-private authenticated channel; they never pass through the presentation provider's command language. The pane owner creates the child with all three standard streams inherited from the pane -terminal, reports the runtime spawn event, and remains only the lifecycle and -display owner. It writes provider display messages to the terminal but never +terminal, provides the activity once that child is running, and remains only the +lifecycle and display owner. It writes provider display messages to the terminal but never reads terminal input, so interactive input belongs to the foreground child. It admits one live launch at a time and releases the pane only after that launch's observable terminal ownership has been swept. Sequential launches use @@ -3551,33 +3572,37 @@ The grid runs as one structured scope: 1. Core validates the whole structural layout, takes the foreground-terminal lease, and flushes root output. -2. The provider checks its live prerequisites and prepares the entire hidden - composite: every pane endpoint, its supervision, and the default shell where - requested. No grid is attached yet. +2. Core admits the presentation — exact request, generation, not already used — + and only then acquires the provider's grid resource. Acquisition prepares the + entire hidden grid: every pane endpoint and its supervision. No grid is + attached yet, and a refused presentation acquires nothing at all. 3. Core starts the pane child operations concurrently, using deterministic durable child identities derived from the grid expansion and authored ordinal. A paired pane begins its document flow and a self-closing pane begins its shell. -4. A pane is ready only when its interactive child emits the runtime's - successful spawn event. Reserving an endpoint, allocating a process - identifier, or receiving output is not readiness. A child that starts and - exits immediately can be both ready and settled. +4. A pane is ready only when its terminal activity is acquired, which happens + only once its child has actually spawned. Reserving an endpoint, allocating a + process identifier, or receiving output is not acquisition. A child that + starts and exits immediately can be both ready and settled. 5. Only after every pane reaches readiness does the provider attach the one - composite presentation. Any preparation or pane-start failure before this - barrier cancels every pane, awaits complete teardown, discards the hidden - composite, and fails without exposing a partial grid. Agent preparation or + grid. Any acquisition or pane-start failure before this barrier cancels every + pane, awaits complete teardown, releases the hidden grid, and fails without + exposing a partial grid. Agent preparation or retained route work that occurred before a failed native spawn remains durable; atomicity covers terminal presentation and lifecycle, not rollback of earlier provider effects. 6. Once attached, each pane settles independently and keeps its final status - visible while siblings continue. The composite remains present after all - panes settle until the reader closes or leaves it. + visible while siblings continue. The grid remains present after all panes + settle until the reader closes or leaves it. 7. Reader close first crosses a live close boundary, then begins an ordered teardown: prevent new pane launches, ask live pane children to close, await - every child and finalizer, detach and destroy the exact provider composite, - restore the root terminal, and only then release the foreground lease and - settle the grid. The document never continues while an observable pane child - or provider-owned process can still act through the grid. + every child and finalizer, release the provider's grid resource — which is + what destroys it, once — restore the root terminal, and only then release the + foreground lease and settle the grid. Reader close, pane failure and parent + cancellation each decide the durable outcome before disposal begins; cleanup + enforces quiescence and never invents or rewrites a retained outcome. The + document never continues while an observable pane child or provider-owned + process can still act through the grid. The provider's `closed()` settlement proposes the live close boundary. The boundary is crossed when the grid owner has entered a cancellation-deferred @@ -3698,7 +3723,7 @@ the retained root stays authoritative, and deciding whether a changed source should be refused rather than ignored belongs to a versioned root boundary that does not exist yet. Until it does, the grid's obligation is the narrower one it can actually discharge: retain the complete authored structure, and open the -structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane +structure it retained rather than the one the file now shows. It acquires a fresh provider grid: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own @@ -5044,7 +5069,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral grid whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the grid, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay acquires a fresh provider grid, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/core/mod.ts b/packages/core/mod.ts index d330284d6..082c8113d 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,13 +152,12 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; -export { - createTerminalAuthority, - TerminalAuthorityError, - terminalInstallation, - useTerminalInstallation, -} from "./src/terminal/authority.ts"; -export type { TerminalGridAuthority } from "./src/terminal/authority.ts"; +// Only what a provider needs: the refusal it can meet, and the shape of the +// function it is handed. Issuing a grid, opening an installation and converging +// the two are core's own, and a host reaches the whole of it through +// `installTerminalGridProfile`. +export { TerminalGridPresentationError } from "./src/terminal/presentation.ts"; +export type { PresentTerminalGrid } from "./src/terminal/presentation.ts"; export { installTerminalProvider, registerTerminalProvider, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 7bc2969f9..ff4e5d401 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2204,8 +2204,8 @@ function* expandTerminalGrid( /** * What one authored pane does once the grid has created its terminal. * - * A self-closing pane runs the host's default shell through that terminal's - * interactive operation, exactly as a paired pane's content does. A paired + * A self-closing pane runs the host's default shell as a terminal activity, + * exactly as a paired pane's content does. A paired * pane expands its own content in a scope of its own: it inherits the bindings, * providers, configuration and working directory visible where the grid was * written, and everything it creates afterwards stays inside the pane. Its @@ -2217,13 +2217,11 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, - *run(terminal, composite) { - // The shell is this pane's one interactive operation, and the spawn it - // reports is what makes the pane ready — the same boundary a paired - // pane's content crosses, rather than a second way in. - const outcome = yield* terminal.interactive((spawned) => - composite.shell(pane.ordinal, spawned), - ); + *run(terminal, grid) { + // The shell is this pane's one terminal activity, and acquiring it is + // what makes the pane ready — the same boundary a paired pane's content + // crosses, rather than a second way in. + const outcome = yield* terminal.use(grid.shell(pane.ordinal)); if (outcome.signal !== undefined) { throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); } @@ -2238,7 +2236,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { return { ordinal: pane.ordinal, - *run(terminal, composite) { + *run(terminal, grid) { yield* scoped(function* () { // A pane is not inside the loop the grid was written in, so a // in its content has no loop to exit and says so. @@ -2276,7 +2274,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { ); const text = renderSegments(shown); if (text.length > 0) { - yield* composite.display(pane.ordinal, text); + yield* grid.display(pane.ordinal, text); } }); }, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts deleted file mode 100644 index aaf8eba9e..000000000 --- a/packages/core/src/terminal/authority.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * Who is allowed to own a terminal, and what "ready" means (architecture.md - * §Terminal authority). - * - * The provider draws a grid. This decides everything about it that matters: - * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, when a - * pane has actually started, and what the grid settled to. None of that is - * reachable by name. There is no context holding an authority, no member of a - * request that carries one, and no handler return value that produces one — an - * authority reachable by name would be an authority every same-name context and - * every loaded copy could reach. - * - * The request object is the unforgeable carrier. It is issued here for one grid - * under one installation generation, and a request from another grid, an - * earlier generation, or a finished expansion presents nothing at all. - * Presenting one grants the provider its drawing surface and nothing else: it - * says nothing about which Agent session a pane may own, because that is the - * session coordinator's to answer and stays independently authoritative. - * - * What a pane's work may do with its terminal is not decided here. The grid - * lifecycle owns that, and hands each pane the one `PaneTerminal` it runs on. - */ - -import { createContext, createScope, ensure, resource, until } from "effection"; -import type { Context, Operation, Scope, Task } from "effection"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; - -export class TerminalAuthorityError extends Error { - override name = "TerminalAuthorityError"; -} - -/** - * What a registered provider must present in order to act. - * - * Delivered directly to the provider factory as it installs, and reachable - * nowhere else. Presenting the exact request core issued is what takes the - * terminal leases and runs the grid; anything else — - * a copy, a rebuilt lookalike, an earlier grid's request, a request already - * presented, or one belonging to a superseded installation — authorizes - * nothing. - */ -export interface TerminalGridAuthority { - present(request: TerminalGridRequest, composite: TerminalComposite): Operation; -} - -/** - * One grid the supervisor has been asked to run, before a composite exists. - * - * The scope is the submitting operation's own. A grid keeps the contexts of the - * expansion that wrote it — its durable child above all, which is what gives its - * panes their identities — so the supervisor owns when a grid stops, never what - * it runs under. - */ -export interface SubmittedGrid { - /** The exact request object core issued. Compared by identity, never shape. */ - readonly request: TerminalGridRequest; - /** The installation this grid belongs to. */ - readonly generation: object; - /** Where the grid runs: a scope of its own, beneath the submitter's. */ - readonly scope: Scope; - /** Run the grid on a presented composite. */ - run(composite: TerminalComposite): Operation; -} - -/** What the submitting operation can ask about its own grid afterwards. */ -export interface GridSubmission { - /** Whether a provider presented for this request and the grid ran through. */ - readonly settled: boolean; -} - -/** One submitted grid, and whatever of it is currently live. */ -interface Entry extends SubmittedGrid, GridSubmission { - presented: boolean; - settled: boolean; - task?: Task; - destroy?: () => Promise; -} - -/** - * Who owns the grids one terminal installation has issued. - * - * Two things have to meet before a grid exists: the document submits the - * authored request and the work its panes do, and a provider presents a - * composite for that exact request. Neither alone starts anything — a - * registration that never routes and a presentation of a request nobody - * submitted both open nothing — and the supervisor is what makes them converge - * by object identity and installation generation rather than by shape. - * - * It holds what it starts. Each grid runs as a task the supervisor keeps, in a - * scope of its own beneath the operation that submitted it — beneath, because a - * pane's durable identity and the bindings its content reads are the - * expansion's, and a grid parented anywhere else is a grid whose panes belong - * to nobody in particular. - * - * That parentage is also what makes a grid impossible to strand: the submitting - * operation unwinds whenever the call that routed it does, and takes the grid - * with it. The supervisor stopping its own entries at installation teardown, - * and stopping one whose presenting call was cancelled, is therefore belt and - * braces rather than the mechanism — deliberately so, because the mechanism is - * a structural property nobody reading this file can see. - * - * Private to core: nothing reachable by importing this package can submit a - * grid, present for one, or ask what is live. - */ -export interface GridSupervisor { - /** - * Register one authored request and its work. - * - * The entry is removed when the submitting operation unwinds — after that - * operation's own finalizers, so the foreground-terminal lease is released - * before the grid stops being something a provider could present for. - */ - submit(grid: SubmittedGrid): Operation; - /** Run the grid this exact request names, under this exact generation. */ - present( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ): Operation; -} - -/** - * Stop one grid and wait for all of it. - * - * Halting the task settles its panes, runs their finalizers and destroys the - * composite; destroying the scope is what releases everything the grid itself - * established. Both are idempotent here, because a grid may be stopped by the - * presenting call that was cancelled, by installation teardown, or by neither. - */ -function* stopGrid(entry: Entry): Operation { - const task = entry.task; - entry.task = undefined; - if (task !== undefined) { - yield* task.halt(); - } - const destroy = entry.destroy; - entry.destroy = undefined; - if (destroy !== undefined) { - yield* until(destroy()); - } -} - -/** Open the supervisor one execution's grids belong to. */ -export function useGridSupervisor(): Operation { - return resource(function* (provide) { - const entries = new Set(); - - // Installation teardown. Every grid still live is stopped here and waited - // for. Scope parentage already reaches each one, so this is the supervisor - // saying so itself rather than the only thing that says it. - yield* ensure(function* () { - for (const entry of [...entries]) { - yield* stopGrid(entry); - } - }); - - yield* provide({ - *submit(grid: SubmittedGrid): Operation { - const entry: Entry = { ...grid, presented: false, settled: false }; - entries.add(entry); - yield* ensure(() => { - entries.delete(entry); - }); - return entry; - }, - *present( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ): Operation { - const entry = [...entries].find((candidate) => Object.is(candidate.request, request)); - if (entry === undefined) { - throw new TerminalAuthorityError( - "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + - "belongs to an execution that has finished", - ); - } - if (!Object.is(entry.generation, generation)) { - throw new TerminalAuthorityError( - "this grid request belongs to another terminal provider installation", - ); - } - if (entry.presented) { - throw new TerminalAuthorityError( - "this grid request has already been presented — one request opens one grid", - ); - } - entry.presented = true; - - // A scope of its own beneath the submitter's: the grid inherits the - // expansion's contexts, and the supervisor still holds the task. - const [scope, destroy] = createScope(entry.scope); - entry.destroy = destroy; - entry.task = scope.run(() => entry.run(composite)); - - // A presenting call that unwinds takes its grid with it. The submitter - // unwinding would too, which is why removing this changes no test — - // it is here so the supervisor's ownership does not depend on a - // structural coincidence holding forever. - yield* ensure(function* () { - yield* stopGrid(entry); - }); - - yield* entry.task; - entry.settled = true; - // Settled, so nothing is owed: the scope goes now rather than waiting - // for the provider's own call to end. - entry.task = undefined; - yield* until(destroy()); - entry.destroy = undefined; - }, - }); - }); -} - -/** - * Build the authority one provider installation is given. - * - * It closes over the installation's generation, so a factory that kept an - * authority from a superseded installation presents under a generation the - * supervisor no longer has the grid for. Deciding that is the supervisor's, and - * this is the seam that carries the generation to it. - */ -export function createTerminalAuthority( - generation: object, - present: ( - request: TerminalGridRequest, - composite: TerminalComposite, - generation: object, - ) => Operation, -): TerminalGridAuthority { - return { - *present(request, composite) { - yield* present(request, composite, generation); - }, - }; -} - -/** One execution's terminal installation: its supervisor and its generation. */ -export interface TerminalInstallation { - readonly supervisor: GridSupervisor; - /** Identifies this execution's provider installation, and nothing else. */ - readonly generation: object; -} - -const Installation: Context = createContext< - TerminalInstallation | undefined ->("core.terminal.installation", undefined); - -/** - * Open one terminal installation for a live document, and hand back the - * authority its providers are installed with. - * - * What travels contextually is the installation — composition data, so a - * document and the components it expands find the same one. The authority does - * not: it is handed to a provider factory directly. A replaced installation - * therefore produces requests the real authority has never heard of, which is a - * refusal rather than a way in. - */ -export function* useTerminalInstallation(): Operation { - const supervisor = yield* useGridSupervisor(); - const generation = {}; - yield* Installation.set({ supervisor, generation }); - return createTerminalAuthority(generation, supervisor.present); -} - -/** This execution's terminal installation, or `undefined` outside one. */ -export function terminalInstallation(): Operation { - return Installation.get(); -} diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index ecfccf15a..5f9a32ec6 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -3,13 +3,13 @@ * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape - * of this module. The composite is built while it is still hidden, every pane + * of this module. The grid is built while it is still hidden, every pane * starts concurrently, and only once all of them have actually started does - * anything appear. A failure before that barrier discards the hidden composite + * anything appear. A failure before that barrier releases the hidden grid * instead of leaving half a grid on the screen. * * ``` - * layout recorded → lease → flush → routed to a provider → composite presented + * layout recorded → lease → flush → routed to a provider → grid presented * → panes start → readiness barrier → attach * → panes settle independently → reader closes → teardown → lease released * ``` @@ -44,115 +44,20 @@ import { } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalActivity, TerminalGrid, TerminalGridRequest } from "@executablemd/runtime"; -import { TerminalAuthorityError, terminalInstallation } from "./authority.ts"; +import { TerminalGridPresentationError, terminalInstallation } from "./presentation.ts"; import type { PaneTerminal } from "./pane.ts"; +import type { IssuedGrid } from "./presentation.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; -/** - * One pane's terminal, and the state only the grid lifecycle may touch. - * - * The terminal is the whole of what crosses into pane work. Everything else - * here answers a question the lifecycle asks about a pane — has it started, - * may it still start anything — and is deliberately not reachable from the - * pane, from a provider, or from a document: a second capability model beside - * `PaneTerminal` would be a second way to own a pane. - */ -interface LivePane { - /** What this pane's work runs on. */ - readonly terminal: PaneTerminal; - /** Settles once this pane has reported its child-spawn event. */ - spawnReported(): Operation; - /** Whether that event has been reported. */ - readonly hasSpawned: boolean; - /** - * Report the event without entering the pane's work. - * - * A pane restored from its retained outcome did start — on the run that - * recorded it — so the barrier is satisfied without a child existing now. - */ - recordSpawn(): void; - /** Refuse any further interactive work in this pane. */ - closeAdmission(): void; -} - -/** - * Build one pane terminal per authored ordinal. - * - * The request is validated against the ordinals it declares before a single - * terminal exists: a request whose panes are not exactly `0..n-1` in order - * describes a grid core did not derive, and answering it would be answering for - * a layout nobody authored. - */ -function livePanes(request: TerminalGridRequest): LivePane[] { - validateOrdinals(request); - - return request.panes.map((pane) => { - const reported = withResolvers(); - let hasSpawned = false; - let live = false; - let closed = false; - - return { - terminal: { - ordinal: pane.ordinal, - *interactive(body: (spawned: () => void) => Operation): Operation { - if (closed) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, - ); - } - if (live) { - throw new TerminalAuthorityError( - `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + - `terminal at a time`, - ); - } - live = true; - try { - return yield* body(() => { - // Idempotent by construction: readiness is a fact about the pane, - // and a provider that reported the same spawn twice has not - // started two panes. - if (hasSpawned) { - return; - } - hasSpawned = true; - reported.resolve(); - }); - } finally { - // Released on every ending, so a pane that settled admits the next - // operation written after it. - live = false; - } - }, - }, - spawnReported: () => reported.operation, - get hasSpawned() { - return hasSpawned; - }, - recordSpawn() { - if (hasSpawned) { - return; - } - hasSpawned = true; - reported.resolve(); - }, - closeAdmission() { - closed = true; - }, - }; - }); -} - function validateOrdinals(request: TerminalGridRequest): void { if (request.panes.length === 0) { - throw new TerminalAuthorityError("a terminal grid request names no panes"); + throw new TerminalGridPresentationError("a terminal grid request names no panes"); } for (const [index, pane] of request.panes.entries()) { if (pane.ordinal !== index) { - throw new TerminalAuthorityError( + throw new TerminalGridPresentationError( `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + `a pane's ordinal is its position among the grid's panes`, ); @@ -160,18 +65,6 @@ function validateOrdinals(request: TerminalGridRequest): void { } } -/** - * Settle once every pane has reported its spawn event. - * - * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing - * run deadline or parent cancellation is what bounds it. A pane that fails to - * start never reports, so the caller races this against pane failure rather - * than asking the barrier to know about failure. - */ -function* everyPaneStarted(panes: readonly LivePane[]): Operation { - yield* all(panes.map((pane) => pane.spawnReported())); -} - /** * The live boundary reader close crosses (architecture.md §Atomic presentation * and settlement). @@ -261,16 +154,17 @@ export interface RetainedGrid extends Record { * * The caller supplies this because a pane's work is the document's: a paired * pane expands its authored content, and a self-closing one runs the host's - * default shell. Both run through `terminal.interactive()`, and both are - * expected to report a spawn from inside it before anything can attach. + * default shell. Both reach their terminal through `PaneTerminal.use()`, and + * both are expected to acquire a terminal activity there before anything can + * attach. */ export interface PaneWork { readonly ordinal: number; - run(terminal: PaneTerminal, composite: TerminalComposite): Operation; + run(terminal: PaneTerminal, grid: TerminalGrid): Operation; } /** - * What a pane that never reported a spawn says. + * What a pane that never acquired a terminal activity says. * * A pane whose work finished without ever starting something interactive has * not started: presenting it as a running pane would be presenting a grid the @@ -324,7 +218,7 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo * Core mints the one request for this expansion, takes the run's foreground * lease, flushes what the document has already produced, registers the request * as live, routes it through the public surface, and then reads what the - * authority settled. The routed answer is discarded on purpose: a handler that + * presentation settled. The routed answer is discarded on purpose: a handler that * short-circuits or fabricates a return has presented nothing, and this says so * rather than letting the document believe a grid opened. */ @@ -336,7 +230,7 @@ export function openTerminalGrid( return scoped(function* (): Operation { const installation = yield* terminalInstallation(); if (installation === undefined) { - throw new TerminalAuthorityError( + throw new TerminalGridPresentationError( "a terminal grid is available only inside a document execution with an installed " + "terminal provider — a grid outside one retains nothing and could not be resumed", ); @@ -345,17 +239,22 @@ export function openTerminalGrid( const request = toRequest(layout); let settled: RetainedGrid | undefined; - // Submitted, not started. The supervisor holds the request and this work - // until a provider presents a composite for this exact object, and the grid - // runs beneath this operation's own scope so its panes keep the durable - // identity of the expansion that wrote them. - const submission = yield* installation.supervisor.submit({ + // Issued, not started. The lookup holds the request and this work until a + // provider presents a grid for this exact object; the grid then runs + // beneath this operation's own scope, so its panes keep the durable + // identity of the expansion that wrote them and this operation owns their + // cancellation and teardown. + const issued: IssuedGrid = { request, generation: installation.generation, - scope: yield* useScope(), - *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary); + used: false, + *run(grid) { + settled = yield* runGrid(request, grid, work, boundary); }, + }; + installation.grids.add(issued); + yield* ensure(() => { + installation.grids.delete(issued); }); // The one foreground-terminal lease, taken before any provider is asked for @@ -369,8 +268,8 @@ export function openTerminalGrid( // Routed, and the answer thrown away. yield* TerminalGrids.operations.open(request); - if (!submission.settled || settled === undefined) { - throw new TerminalAuthorityError( + if (settled === undefined) { + throw new TerminalGridPresentationError( "no terminal provider opened this grid — a handler answered without delivering the " + "request to a registered provider", ); @@ -380,29 +279,79 @@ export function openTerminalGrid( } /** - * Run the grid on the composite a provider presented. + * Run the grid a provider presented, on the resource it supplied. * - * The composite is scope-owned, so every path out of here — success, failure, - * and cancellation alike — destroys exactly the composite that was presented. + * The provider's grid is scope-owned, so every path out of here — success, + * failure, and cancellation alike — releases exactly the grid that was + * presented, exactly once. * That is why teardown is not written as a step: there is no path that can skip * it. */ -function presentGrid( +function runGrid( request: TerminalGridRequest, - composite: TerminalComposite, + provided: Operation, work: readonly PaneWork[], boundary: CloseBoundary, ): Operation { return scoped(function* (): Operation { - // Registered before a single pane starts: a composite that was presented is - // owed a destroy even if the next line is what fails. - yield* ensure(() => composite.destroy()); + // Acquired here, inside the grid's own scope: this is the provider's grid + // coming into existence, and this scope's teardown is what takes it down + // again — once, whether the grid succeeds, fails to start, is closed, is + // failed by the provider, or is cancelled. There is nothing to destroy by + // hand and no way to destroy twice. + const grid = yield* provided; + + // One pane's worth of state per authored ordinal, and nothing else knows it + // exists. A pane gets its `PaneTerminal` and only that; the grid asks these + // closures about an ordinal it already knows. + validateOrdinals(request); + const up = request.panes.map(() => withResolvers()); + const started = request.panes.map(() => false); + const busy = request.panes.map(() => false); + let admitting = true; + + /** Count a pane as started. A replayed pane did start, on the run that recorded it. */ + const markStarted = (ordinal: number): void => { + if (started[ordinal]) { + return; + } + started[ordinal] = true; + up[ordinal]!.resolve(); + }; + + const terminals: PaneTerminal[] = request.panes.map((_pane, ordinal) => ({ + *use(activity: TerminalActivity): Operation { + if (!admitting) { + throw new TerminalGridPresentationError( + `pane ${ordinal} is closed: its grid has stopped admitting terminal activities`, + ); + } + if (busy[ordinal]) { + throw new TerminalGridPresentationError( + `pane ${ordinal} already has a live terminal activity — one owns a pane ` + + `terminal at a time`, + ); + } + busy[ordinal] = true; + try { + // Acquired inside this scope, so its cleanup is awaited before the + // pane is free again — and acquiring it at all is what makes the pane + // ready. + return yield* scoped(function* (): Operation { + const outcome = yield* activity; + markStarted(ordinal); + return yield* outcome; + }); + } finally { + busy[ordinal] = false; + } + }, + })); - const panes = livePanes(request); // Nothing new is admitted once teardown begins, so a pane that was about to - // start an interactive child is refused rather than racing the close. + // start a terminal activity is refused rather than racing the close. yield* ensure(() => { - closeAdmission(panes); + admitting = false; }); const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); @@ -415,7 +364,7 @@ function presentGrid( let attached = false; for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + yield* grid.update(pane.ordinal, "starting"); } // One durable child per pane, allocated here in authored order, so a pane's @@ -426,10 +375,17 @@ function presentGrid( // satisfies the readiness barrier. const children: Task[] = []; for (const [index, pane] of work.entries()) { - const live = panes[index]!; children.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, live, composite, request, index, closing.operation); + return yield* runPane( + pane, + terminals[index]!, + () => started[index] === true, + grid, + request, + index, + closing.operation, + ); }), ); } @@ -440,10 +396,10 @@ function presentGrid( yield* spawn(function* () { const outcome = yield* task; outcomes[index] = outcome; - // A pane restored from its retained outcome counts as started: it did - // start, on the run that recorded it. - panes[index]!.recordSpawn(); - yield* composite.update(work[index]!.ordinal, outcome.status); + // A pane restored from its retained outcome satisfies the barrier + // without acquiring anything: it did start, on the run that recorded it. + markStarted(index); + yield* grid.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has // been shown, so the grid fails closed rather than attaching what is @@ -455,9 +411,9 @@ function presentGrid( // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane - // already failed from waiting forever for a latch nothing will acknowledge. + // already failed from waiting forever for an acquisition that cannot happen. try { - yield* race([everyPaneStarted(panes), startupFailed.operation]); + yield* race([all(up.map((pane) => pane.operation)), startupFailed.operation]); } catch { // Simultaneous startup failures are selected by authored ordinal, not by // whichever rejected the race first. @@ -468,15 +424,15 @@ function presentGrid( // it with `running` would tell the reader a finished pane is live. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(pane.ordinal, "running"); + yield* grid.update(pane.ordinal, "running"); } } - yield* composite.attach(); + yield* grid.attach(); attached = true; - // The composite stays visible after its panes settle. The reader leaving is + // The grid stays visible after its panes settle. The reader leaving is // what finishes the grid, not the last pane exiting. - yield* composite.closed(); + yield* grid.closed(); // Proposed, then acknowledged by the owner from inside its own // cancellation-deferred await. Until it is crossed, a cancellation cancels @@ -487,17 +443,17 @@ function presentGrid( // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's - // own teardown after this returns — so the composite is destroyed, the + // own teardown after this returns — so the provider's grid is released, the // lease released and the following sibling started only once nothing a pane // acquired can still act. - closeAdmission(panes); + admitting = false; closing.resolve(); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(pane.ordinal, "closed"); + yield* grid.update(pane.ordinal, "closed"); } } for (const [index] of work.entries()) { @@ -513,23 +469,12 @@ function presentGrid( }); } -/** - * Stop every pane admitting new interactive work. - * - * Called before the live panes are asked to stop, so a pane that was about to - * start an interactive child is refused rather than racing the close. - */ -function closeAdmission(panes: readonly LivePane[]): void { - for (const pane of panes) { - pane.closeAdmission(); - } -} - /** Run one pane's work and say what it came to. */ function runPane( pane: PaneWork, - live: LivePane, - composite: TerminalComposite, + terminal: PaneTerminal, + started: () => boolean, + grid: TerminalGrid, request: TerminalGridRequest, index: number, closing: Operation, @@ -541,7 +486,7 @@ function runPane( // comes down in the enclosing scope's own teardown — so a pane whose // finalizers are slow cannot hold up the outcome the grid already knows, // and the record a resumed run reads is written either way. - const running = yield* spawn(() => pane.run(live.terminal, composite)); + const running = yield* spawn(() => pane.run(terminal, grid)); const closed = yield* race([ (function* (): Operation { yield* running; @@ -559,7 +504,7 @@ function runPane( yield* running.halt(); return { status: "closed", reason: "" }; } - if (!live.hasSpawned) { + if (!started()) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. return { diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts index f6039ffef..f5f73f165 100644 --- a/packages/core/src/terminal/pane.ts +++ b/packages/core/src/terminal/pane.ts @@ -7,11 +7,11 @@ * is the whole reason a grid exists. So core installs this in each pane's own * scope, and anything interactive asks here first. * - * What travels contextually is the seam, not the authority. The value it holds - * is the one `PaneTerminal` the grid lifecycle created for this ordinal, and it - * grants nothing once that grid stops admitting work — so a replaced context, - * or one kept past the expansion that owns it, yields a pane terminal nobody - * owns rather than a way into one somebody does. + * What travels contextually is the seam, not the capability. The value it holds + * is the one `PaneTerminal` the grid built for this pane, and it grants nothing + * once that grid stops admitting work — so a replaced context, or one kept past + * the expansion that owns it, yields a pane terminal nobody owns rather than a + * way into one somebody does. * * Absence is the ordinary case and means "not in a pane": work outside a grid * reads nothing here and goes on competing for the root lease exactly as it @@ -20,25 +20,31 @@ import { createContext } from "effection"; import type { Context, Operation } from "effection"; +import type { TerminalActivity } from "@executablemd/runtime"; -/** The pane the current work is running in. */ +/** + * The pane the current work is running in. + * + * One operation, because one is all a pane needs: run something interactive + * here, as this pane's owner. There is no identity on it — core knows which + * ordinal it built this for, and a pane that could name itself would be a pane + * something else could name. + */ export interface PaneTerminal { - /** The pane's identity: its position among the grid's panes, from zero. */ - readonly ordinal: number; /** - * Run one interactive operation as this pane's owner. + * Run one terminal activity as this pane's owner. * - * `body` receives this pane's one-use `spawned` acknowledgement and must call - * it from the runtime's successful child-spawn event, before it waits for the - * child to exit. Acknowledging twice is one event. A body that never spawns - * never reports, and the grid it belongs to never attaches — which is what - * stops a pane that failed to start being presented as one that is running. + * The activity is a resource. Acquiring it is the pane becoming ready, which + * is why nothing here takes a callback: a child that could not be prepared or + * spawned fails before acquisition, and a pane whose activity never came up + * never becomes ready — so the grid it belongs to never attaches. * - * A second interactive operation while one is live on this pane is refused, - * and so is any operation once the grid has stopped admitting work. Sequential - * operations in one pane are ordinary. Two panes do not contend at all. + * Settlement is awaited inside the same scope, and the activity's own cleanup + * is awaited before the pane is free again. A second use while one is live on + * this pane is refused, and so is any use once the grid has stopped admitting + * work. Sequential uses are ordinary. Two panes do not contend at all. */ - interactive(body: (spawned: () => void) => Operation): Operation; + use(activity: TerminalActivity): Operation; } const PaneTerminalContext: Context = createContext< diff --git a/packages/core/src/terminal/presentation.ts b/packages/core/src/terminal/presentation.ts new file mode 100644 index 000000000..19c335768 --- /dev/null +++ b/packages/core/src/terminal/presentation.ts @@ -0,0 +1,142 @@ +/** + * Who may present a grid, and for which request (architecture.md §Terminal + * presentation). + * + * The provider draws a grid. This decides one thing about it: whether the + * request being presented is the exact one core issued, under the installation + * that issued it, and not one that has been presented already. Nothing else + * here decides anything — and nothing here owns a grid. + * + * Ownership belongs to the expansion that submitted it. A grid runs beneath + * that operation, so its panes keep the durable identity and the bindings of + * the document position that wrote them, and structured concurrency takes the + * grid down whenever that operation unwinds. + * + * What is kept here is the smallest lookup that lets the two sides converge: + * the exact request object an expansion submitted, the generation it belongs + * to, whether it has been presented, and the operation that runs it. That + * lookup holds no tasks and owns no lifetime — an entry is added and removed by + * the submitting expansion itself, so nothing here can keep a grid running + * after the work that asked for it has gone. A request reaching this from + * anywhere else — copied, rebuilt, kept from another grid, belonging to a + * superseded installation, or already used — presents nothing. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalGrid, TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalGridPresentationError extends Error { + override name = "TerminalGridPresentationError"; +} + +/** + * What a registered provider is handed, and the only way to present. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else: it does not travel through a context, a request, a result, a + * prop, a binding or a durable record. Presenting the exact request core issued + * is what runs the grid; anything else authorizes nothing. + * + * The grid arrives as a resource the provider owns. Core acquires it only once + * the presentation has been admitted, so a refused presentation costs the + * provider nothing at all, and releases it exactly once however the grid ends. + */ +export type PresentTerminalGrid = ( + request: TerminalGridRequest, + grid: Operation, +) => Operation; + +/** One grid an expansion submitted, and what it is waiting to be given. */ +interface IssuedGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Whether this request has already been presented. */ + used: boolean; + /** Run the grid, beneath the operation that submitted it. */ + run(grid: Operation): Operation; +} + +/** + * Build the presentation function one provider installation is given. + * + * It closes over the installation's generation, so a factory that kept one from + * a superseded installation presents under a generation the issued requests no + * longer belong to. + */ +export function createPresentTerminalGrid( + generation: object, + issued: ReadonlySet, +): PresentTerminalGrid { + return function* present(request, grid) { + const found = [...issued].find((candidate) => Object.is(candidate.request, request)); + if (found === undefined) { + throw new TerminalGridPresentationError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(found.generation, generation)) { + throw new TerminalGridPresentationError( + "this grid request belongs to another terminal provider installation", + ); + } + if (found.used) { + throw new TerminalGridPresentationError( + "this grid request has already been presented — one request opens one grid", + ); + } + // Admitted before the provider's grid is touched: a refused presentation + // acquires nothing and leaves the provider holding nothing. + found.used = true; + yield* found.run(grid); + }; +} + +/** One execution's terminal installation: the grids it has issued, and its generation. */ +export interface TerminalInstallation { + /** + * Every grid this execution has issued and not yet finished. + * + * One lookup for the execution rather than one per installation, so a + * superseded installation's presentation function still *finds* the grid it + * names and is turned away for the reason that is actually true — it belongs + * to another installation — instead of being told the request is unknown. + */ + readonly grids: Set; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + TerminalInstallation | undefined +>("core.terminal.installation", undefined); + +/** + * Open one terminal installation for a live document, and hand back the + * presentation function its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The presentation + * function does not: it is handed to a provider factory directly. A replaced + * installation therefore produces requests the real one has never heard of, + * which is a refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + // A nested installation supersedes the one around it but shares its lookup: + // the generation is what tells them apart, and sharing is what lets it. + const existing = yield* Installation.get(); + const grids = existing?.grids ?? new Set(); + const generation = {}; + yield* Installation.set({ grids, generation }); + return createPresentTerminalGrid(generation, grids); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} + +export type { IssuedGrid }; diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts index 05919b653..ed1bd7b13 100644 --- a/packages/core/src/terminal/profile.ts +++ b/packages/core/src/terminal/profile.ts @@ -2,10 +2,11 @@ * Opening one terminal installation for a live document. * * A grid needs two things before it can be durable at all: this execution's - * installation — which owns the generation every request belongs to and the - * registry of the grids it issued — and a provider installed against the - * authority that installation mints. A grid outside one refuses rather than - * presenting something no replay could resume. + * installation — which mints the generation every request belongs to, and adds + * the requests it issues to the private lookup presentation converges through — + * and a provider installed against the presentation function that installation + * builds. A grid outside one refuses rather than presenting something no replay + * could resume. * * The installation's lifetime has to surround authored work and end while the * journal is still live, which is what `Execution.document` is. @@ -15,7 +16,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { Execution } from "../execute.ts"; -import { useTerminalInstallation } from "./authority.ts"; +import { useTerminalInstallation } from "./presentation.ts"; import { installTerminalProvider } from "./provider-api.ts"; export interface TerminalGridProfileOptions { @@ -34,7 +35,7 @@ export interface TerminalGridProfileOptions { /** * Install the terminal-grid profile for the executions composed under it. * - * The authority reaches the named provider's factory and nothing else: it is + * Presentation reaches the named provider's factory and nothing else: it is * delivered through the installation handshake rather than published, so a * handler that answers the install request itself installs no provider and the * document is told so. @@ -45,12 +46,12 @@ export function installTerminalGridProfile( return Execution.around({ *document([request], next) { yield* scoped(function* () { - const authority = yield* useTerminalInstallation(); + const present = yield* useTerminalInstallation(); if (options.provider !== undefined) { yield* installTerminalProvider( options.provider, { label: options.label ?? options.provider }, - authority, + present, ); } yield* next(request); diff --git a/packages/core/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts index f3537dd99..80d2d4377 100644 --- a/packages/core/src/terminal/provider-api.ts +++ b/packages/core/src/terminal/provider-api.ts @@ -2,7 +2,7 @@ * How a terminal provider is installed, and what installing one grants. * * A provider is the only thing that can present a grid, so *selecting* one is - * itself an authority decision. Returning a factory up the public chain would + * itself a presentation decision. Returning a factory up the public chain would * mean any handler could answer with a factory of its own — or take the one it * was given and install it somewhere else. * @@ -12,7 +12,7 @@ * handler sits at the terminal end of that chain and holds its own captured * continuation — a parameter of its generator, carried by no request and no * return value. Through that continuation, and only through it, the invocation - * terminal hands the factory this execution's terminal authority and records + * terminal hands the factory this execution's presentation function and records * that the provider acknowledged installation. * * Registration is scope-local: a nested registration overrides an outer one for @@ -29,7 +29,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import { ensure } from "effection"; import type { Operation } from "effection"; -import type { TerminalGridAuthority } from "./authority.ts"; +import type { PresentTerminalGrid } from "./presentation.ts"; /** What a host says about the provider it is installing. */ export interface TerminalProviderOptions { @@ -40,14 +40,14 @@ export interface TerminalProviderOptions { /** * A provider factory installs `TerminalGrids` middleware for its scope. * - * The authority is the second argument because it is delivered, not published: + * Presentation is the second argument because it is delivered, not published: * there is no reader for it, no context holding one, and no request member * carrying one. A factory closes over it, and only the handler that closed over * it can pair a routed grid request with it. */ export type TerminalProviderFactory = ( options: TerminalProviderOptions, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ) => Operation; /** The stable name every loaded copy composes through. */ @@ -128,7 +128,7 @@ export function* registerTerminalProvider( // refuses a copied, reused or stale request here, before the factory // installs anything. const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); - yield* factory(delivery.options, delivery.authority); + yield* factory(delivery.options, delivery.present); yield* next({ intent: "acknowledge", install: call }); return undefined; }, @@ -146,7 +146,7 @@ export function* registerTerminalProvider( */ function deliveryOf(value: unknown): { options: TerminalProviderOptions; - authority: TerminalGridAuthority; + present: PresentTerminalGrid; } { if (typeof value !== "object" || value === null) { throw new TerminalProviderInstallError( @@ -154,32 +154,24 @@ function deliveryOf(value: unknown): { ); } const options = Reflect.get(value, "options"); - const authority = Reflect.get(value, "authority"); + const present = Reflect.get(value, "present"); if (typeof options !== "object" || options === null) { throw new TerminalProviderInstallError( "the live terminal provider installation named no options", ); } - if (typeof authority !== "object" || authority === null) { + if (typeof present !== "function") { throw new TerminalProviderInstallError( - "the live terminal provider installation carried no authority", + "the live terminal provider installation carried no way to present a grid", ); } const label = Reflect.get(options, "label"); if (typeof label !== "string") { throw new TerminalProviderInstallError("the live terminal provider options are not readable"); } - const present = Reflect.get(authority, "present"); - if (typeof present !== "function") { - throw new TerminalProviderInstallError( - "the live terminal provider installation carried no grid authority", - ); - } return { options: { label }, - authority: { - present: (request, composite) => Reflect.apply(present, authority, [request, composite]), - }, + present: (request, grid) => Reflect.apply(present, undefined, [request, grid]), }; } @@ -187,7 +179,7 @@ function deliveryOf(value: unknown): { * Install the provider registered as `name`, under `options`, for the calling * operation. * - * The authority reaches whichever factory answers, and nothing else: a handler + * Presentation reaches whichever factory answers, and nothing else: a handler * that short-circuits, fabricates a return, or never acknowledges installs no * provider, and this refuses rather than leaving the caller believing one is * there. @@ -195,7 +187,7 @@ function deliveryOf(value: unknown): { export function installTerminalProvider( name: string, options: TerminalProviderOptions, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ): Operation { return (function* (): Operation { const request: TerminalProviderInstallRequest = Object.freeze({ @@ -203,7 +195,7 @@ export function installTerminalProvider( name, options: Object.freeze({ ...options }), }); - const terminal = installationTerminal(request, options, authority); + const terminal = installationTerminal(request, options, present); // Same stable name, so the shared middleware chain applies; own descriptor, // so the chain ends in this invocation's terminal rather than in the public // refusing default. @@ -224,7 +216,7 @@ export function installTerminalProvider( function installationTerminal( request: TerminalProviderInstallRequest, options: TerminalProviderOptions, - authority: TerminalGridAuthority, + present: PresentTerminalGrid, ): { install: (call: TerminalProviderCall) => Operation; acknowledged: () => boolean; @@ -253,7 +245,7 @@ function installationTerminal( ); } state = "inspected"; - return { options, authority }; + return { options, present }; } if (state !== "inspected") { throw new TerminalProviderInstallError( diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 8204c41e0..ec78d0f05 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,6 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * (spec §6.21, architecture.md §Terminal grid presentation, §Atomic presentation and * settlement, §Durability and replay). * * The provider here is controlled and is not tmux: it opens no terminal, starts @@ -10,14 +10,14 @@ * take the same wall clock. * * Readiness is the claim these rows care about most, so it is always driven - * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and "did - * some work" be told apart at all. + * explicitly: a pane becomes ready because work in it acquired a terminal + * activity, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. * - * A paired pane is ready only when something in it starts and reports a spawn. - * Until the native-launch Story lands, `` is what a suite writes - * to be that something — and it reaches the pane through the same seam a real - * `` will. + * A paired pane is ready only once something in it acquires a terminal activity + * through `PaneTerminal.use()`. Until the native-launch Story lands, + * `` is what a suite writes to be that something — and it reaches + * the pane through the same seam a real `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -42,15 +42,17 @@ import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { + controlledTerminalGrid, installControlledLauncher, - prepareControlledComposite, reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; import type { - ControlledCompositeOptions, - TerminalComposite, + ControlledTerminalGridOptions, + TerminalActivity, + TerminalShellOutcome, + TerminalGrid, TerminalGridRequest, TerminalProviderLog, TerminalProviderResources, @@ -59,8 +61,11 @@ import type { import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; -import { TerminalAuthorityError, useTerminalInstallation } from "../src/terminal/authority.ts"; -import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +import { + TerminalGridPresentationError, + useTerminalInstallation, +} from "../src/terminal/presentation.ts"; +import type { PresentTerminalGrid } from "../src/terminal/presentation.ts"; import { installTerminalProvider, registerTerminalProvider, @@ -83,7 +88,7 @@ interface DocumentRun { requests: TerminalGridRequest[]; /** What each pane displayed. */ shown: Map; - /** Everything the composite did, in order. */ + /** Everything the provider's grid did, in order. */ events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; @@ -114,6 +119,48 @@ function useDir(): Operation { }); } +/** An outcome that is already settled. */ +function done(value: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return value; + })(); +} + +/** + * An activity whose child spawned and is already finished. + * + * The ordinary case a row wants when it only needs a pane to be ready: acquired + * at once, settled at once. + */ +function startsAndSettles(onStart?: () => void): TerminalActivity { + return resource(function* (provide) { + onStart?.(); + yield* provide(done(undefined)); + }); +} + +/** An activity whose child spawned and stays until it is released. */ +function startsAndHolds(onStart?: () => void): TerminalActivity { + return resource(function* (provide) { + onStart?.(); + yield* provide(suspend()); + }); +} + +/** + * An activity whose child never spawned. + * + * It fails during acquisition, which is before a pane could be ready — the + * shape of a preparation or spawn failure rather than of work that ran. + */ +function neverStarts(onAttempt?: () => void): TerminalActivity { + return resource(function* () { + onAttempt?.(); + throw new Error("this activity's child never spawned"); + }); +} + /** * What the pane-terminal rows read. * @@ -157,14 +204,14 @@ function refusalOf(error: unknown): string { } /** - * Open a grid with an owner that crosses its close boundary. + * Open a grid and own the other side of its close boundary. * - * `durableGrid()` supplies this in the document path: a grid proposes close and + * `durableGrid()` owns that side in the document path: a grid proposes close and * waits, and something has to acknowledge. A row that drives the lifecycle - * directly owns that side itself, or its grid waits for an owner that never + * directly owns it here instead, or its grid waits for an owner that never * arrives. */ -function supervisedGrid(work: readonly PaneWork[]): Operation { +function openGridWithCloseOwner(work: readonly PaneWork[]): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); yield* spawn(function* () { @@ -175,6 +222,31 @@ function supervisedGrid(work: readonly PaneWork[]): Operation { })(); } +/** + * Pane work that begins a terminal activity and never finishes acquiring one. + * + * The grid therefore sits at the readiness barrier with the provider's grid + * held, which is a live grid a row can cancel without parking on a reader that + * will never leave. + */ +function startingPane(live: { resolve(): void }, finalized: string[], mark: string): PaneWork { + return { + ordinal: 0, + *run(terminal) { + yield* terminal.use( + resource>(function* (provide) { + yield* ensure(() => { + finalized.push(mark); + }); + live.resolve(); + yield* suspend(); + yield* provide(done(undefined)); + }), + ); + }, + }; +} + /** One authored pane, for the rows that drive the lifecycle directly. */ const ONE_PANE = { columns: 1, @@ -182,15 +254,12 @@ const ONE_PANE = { cells: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], }; -/** Pane work that starts, reports its spawn, and is done. */ +/** Pane work that acquires a terminal activity whose child is already finished. */ function readyPane(opened: string[], mark: string): PaneWork { return { ordinal: 0, *run(terminal) { - yield* terminal.interactive(function* (spawned) { - opened.push(mark); - spawned(); - }); + yield* terminal.use(startsAndSettles(() => opened.push(mark))); }, }; } @@ -205,14 +274,17 @@ function holdingPane(live: { resolve(): void }, finalized: string[], mark: strin return { ordinal: 0, *run(terminal) { - yield* terminal.interactive(function* (spawned) { - yield* ensure(() => { - finalized.push(mark); - }); - spawned(); - live.resolve(); - yield* suspend(); - }); + yield* terminal.use( + resource>(function* (provide) { + // Acquired, so the pane is ready. Released only when something stops + // the grid, which is what the finalizer records. + yield* ensure(() => { + finalized.push(mark); + }); + live.resolve(); + yield* provide(suspend()); + }), + ); }, }; } @@ -237,9 +309,7 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - }); + yield* pane.use(startsAndSettles()); return ""; }, }, @@ -266,29 +336,45 @@ function useGridComponents( // into a failed assertion instead of a hung suite. name: "Concurrent", origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + *fn(props) { const pane = yield* paneTerminal(); if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - probe.marks.push(`enter:${pane.ordinal}`); - probe.entered(); - const together = yield* race([ - (function* (): Operation { - yield* probe.overlapped(); - return true; - })(), - (function* (): Operation { - yield* sleep(2000); - return false; - })(), - ]); - probe.marks.push(`together:${pane.ordinal}:${together}`); - spawned(); - }); - probe.marks.push(`leave:${pane.ordinal}`); + const mark = String(props.mark); + yield* pane.use( + resource>(function* (provide) { + // Acquired: this pane is ready and is holding its activity. + probe.marks.push(`enter:${mark}`); + probe.entered(); + yield* provide( + (function* (): Operation { + // Settlement waits for every other pane to be holding one too. + // Panes that contended could never all be here at once; the + // deadline only turns a regression into a failed assertion + // instead of a hung suite. + const together = yield* race([ + (function* (): Operation { + yield* probe.overlapped(); + return true; + })(), + (function* (): Operation { + yield* sleep(2000); + return false; + })(), + ]); + probe.marks.push(`together:${mark}:${together}`); + })(), + ); + }), + ); + probe.marks.push(`leave:${mark}`); return ""; }, }, @@ -303,20 +389,21 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - try { - yield* pane.interactive(function* () { - probe.marks.push("second entered"); - }); - } catch (error) { - probe.refusals.push(refusalOf(error)); - } - }); + yield* pane.use( + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + try { + yield* pane.use(startsAndSettles(() => probe.marks.push("second entered"))); + } catch (error) { + probe.refusals.push(refusalOf(error)); + } + })(), + ); + }), + ); // The pane is free again: one owner at a time is not one owner ever. - yield* pane.interactive(function* () { - probe.marks.push("sequential"); - }); + yield* pane.use(startsAndSettles(() => probe.marks.push("sequential"))); // Kept deliberately, so a row can ask what it grants after the grid has // closed. probe.kept.push(pane); @@ -324,8 +411,8 @@ function useGridComponents( }, }, { - // Reports the same spawn twice. One pane started, not two. - name: "TwiceSpawned", + // Acquires two activities in turn. One pane started, not two. + name: "SettlesAtOnce", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, *fn() { @@ -333,16 +420,13 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - spawned(); - spawned(); - probe.marks.push("spawned twice"); - }); + // Spawned and finished in the same breath: ready and settled at once. + yield* pane.use(startsAndSettles(() => probe.marks.push("started and settled"))); return ""; }, }, { - // Interactive work that never reports a spawn: doing work is not starting. + // Interactive work that never acquires an activity: doing work is not starting. name: "Quiet", origin: "tier-tg", props: { type: "object", properties: {}, additionalProperties: false }, @@ -351,9 +435,8 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* () { - probe.marks.push("worked without spawning"); - }); + // Fails before acquisition: a child that never spawned. + yield* pane.use(neverStarts(() => probe.marks.push("tried to start"))); return ""; }, }, @@ -367,11 +450,15 @@ function useGridComponents( if (pane === undefined) { throw new Error(" is written inside a pane"); } - yield* pane.interactive(function* (spawned) { - yield* sleep(25); - slowMarks.push("ready:slow"); - spawned(); - }); + // Slow to *start*: readiness is the acquisition, so the grid waits for + // this pane to come up rather than for it to finish. + yield* pane.use( + resource>(function* (provide) { + yield* sleep(25); + slowMarks.push("ready:slow"); + yield* provide(done(undefined)); + }), + ); return ""; }, }, @@ -417,26 +504,26 @@ function useGridComponents( } /** - * Register a controlled provider that presents through the authority it was + * Register a controlled provider that presents through the function it was * delivered. * - * This is the whole handshake in miniature: the factory receives the authority - * as an argument, prepares a composite of its own, and presents the exact + * This is the whole handshake in miniature: the factory receives presentation + * as an argument, supplies a grid resource of its own, and presents the exact * request it was routed. Nothing it returns reaches core. */ function useControlledProvider( - options: ControlledCompositeOptions & { + options: ControlledTerminalGridOptions & { /** Present something other than the request that was routed. */ readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; /** Answer the routed request without presenting anything at all. */ readonly shortCircuit?: boolean; - /** Keep the authority for a later, unrouted use. */ - readonly capture?: (authority: TerminalGridAuthority) => void; + /** Keep the presentation function for a later, unrouted use. */ + readonly capture?: (present: PresentTerminalGrid) => void; } = {}, ): Operation { let generation = 0; - return registerTerminalProvider("controlled", function* (_settings, authority) { - options.capture?.(authority); + return registerTerminalProvider("controlled", function* (_settings, present) { + options.capture?.(present); yield* TerminalGrids.around( { *open([request]) { @@ -444,8 +531,10 @@ function useControlledProvider( // Answers, presents nothing. Core must not believe this. return { presented: true }; } - const composite = yield* prepareControlledComposite(request, options, generation++); - yield* authority.present(options.substitute?.(request) ?? request, composite); + yield* present( + options.substitute?.(request) ?? request, + controlledTerminalGrid(request, options, generation++), + ); return undefined; }, }, @@ -457,13 +546,13 @@ function useControlledProvider( /** Everything a controlled grid host installs, for an in-process grid. */ function useGridHost( options: Parameters[0] = {}, -): Operation { - return (function* (): Operation { +): Operation { + return (function* (): Operation { yield* installControlledLauncher(); yield* useControlledProvider(options); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); - return authority; + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; })(); } @@ -485,7 +574,7 @@ function runDocument( options: { provider?: boolean; stream?: InMemoryStream; - composite?: ControlledCompositeOptions; + grid?: ControlledTerminalGridOptions; /** Where `` records that it started. */ slowMarks?: string[]; /** Props this run supplies. Props are not restored across a continuation. */ @@ -524,7 +613,7 @@ function runDocument( const settled = withResolvers(); let expected = 0; let done = 0; - const supplied = options.composite ?? {}; + const supplied = options.grid ?? {}; if (options.provider !== false) { yield* useControlledProvider({ ...supplied, @@ -615,7 +704,7 @@ function runInterrupted( stream: InMemoryStream, options: { provider?: boolean; - shell?: ControlledCompositeOptions["shell"]; + shell?: ControlledTerminalGridOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; /** Props this run supplies. Props are not restored across a continuation. */ @@ -754,16 +843,22 @@ function runInterrupted( : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { - shell: function* (ordinal: number, spawned: () => void) { - spawned(); - if (ordinal !== options.shellFailsAfterAttach) { - return { exitCode: 0 }; - } - // Started, so the grid attaches; it fails only afterwards, which - // is the failure a grid contains as a pane status. - yield* attached.operation; - return { exitCode: 1 }; - }, + shell: (ordinal: number) => + resource>(function* (provide) { + // Acquired, so the pane is ready and the grid attaches; the + // failure is in the settlement afterwards, which is the + // failure a grid contains as a pane status. + if (ordinal !== options.shellFailsAfterAttach) { + yield* provide(done({ exitCode: 0 })); + return; + } + yield* provide( + (function* (): Operation { + yield* attached.operation; + return { exitCode: 1 }; + })(), + ); + }), } : options.shell === undefined ? {} @@ -854,12 +949,12 @@ const PANES = [ '', ]; -describe("Tier TG — the terminal authority", () => { +describe("Tier TG — presenting a grid", () => { const GRID = ["", ...PANES, "", ""].join("\n"); it("TA1: a handler that answers without presenting opens nothing", function* () { const dir = yield* useDir(); - const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + const run = yield* runDocument(dir, GRID, { grid: {} as ControlledTerminalGridOptions }); expect(run.outcome.ok).toBe(true); // The same document, against a provider that answers the routed request @@ -888,7 +983,7 @@ describe("Tier TG — the terminal authority", () => { it("TA2: presenting a rebuilt request authorizes nothing", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, GRID, { - composite: {}, + grid: {}, }); expect(run.outcome.ok).toBe(true); @@ -897,7 +992,7 @@ describe("Tier TG — the terminal authority", () => { const ran: string[] = []; yield* useGridComponents(ran); yield* installControlledLauncher(); - // Same members, different object. Identity is what the authority reads. + // Same members, different object. Identity is what presentation reads. yield* useControlledProvider({ substitute: (request) => ({ columns: request.columns, @@ -938,9 +1033,9 @@ describe("Tier TG — the terminal authority", () => { expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TA4: an authority kept past its grid authorizes nothing", function* () { + it("TA4: a presentation function kept past its grid presents nothing", function* () { const dir = yield* useDir(); - let kept: TerminalGridAuthority | undefined; + let kept: PresentTerminalGrid | undefined; const run = yield* runDocument(dir, GRID, {}); expect(run.outcome.ok).toBe(true); @@ -949,7 +1044,7 @@ describe("Tier TG — the terminal authority", () => { const ran: string[] = []; yield* useGridComponents(ran); yield* installControlledLauncher(); - yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* useControlledProvider({ capture: (present) => (kept = present) }); yield* installTerminalGridProfile({ provider: "controlled" }); const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); yield* execution; @@ -959,71 +1054,51 @@ describe("Tier TG — the terminal authority", () => { // The execution has finished, so the request it issued is no longer live. let refusal: unknown; yield* scoped(function* () { - const composite = yield* prepareControlledComposite( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - {}, - ); + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; try { - yield* kept!.present( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - composite, - ); + yield* kept!(asked, controlledTerminalGrid(asked, {})); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TA5: an authority from another installation generation authorizes nothing", function* () { + it("TA5: a presentation function from another installation generation presents nothing", function* () { let refusal: unknown; yield* scoped(function* () { // Two installations in one scope: the second supersedes the first, so the - // first's authority names a generation the live registry no longer has. + // first's function names a generation the shared lookup no longer matches. const stale = yield* scoped(function* () { return yield* useTerminalInstallation(); }); yield* useTerminalInstallation(); - const composite = yield* prepareControlledComposite( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - {}, - ); + const asked = { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" as const }], + }; try { - yield* stale.present( - { - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], - }, - composite, - ); + yield* stale(asked, controlledTerminalGrid(asked, {})); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); it("TA6: a provider that never acknowledges installs nothing", function* () { let refusal: unknown; yield* scoped(function* () { - const authority = yield* useTerminalInstallation(); + const present = yield* useTerminalInstallation(); // A handler that answers the install request without delivering it to a // registered provider. yield* registerTerminalProvider("real", function* () {}); @@ -1034,7 +1109,7 @@ describe("Tier TG — the terminal authority", () => { }, }); try { - yield* installTerminalProvider("real", { label: "real" }, authority); + yield* installTerminalProvider("real", { label: "real" }, present); } catch (error) { refusal = error; } @@ -1051,8 +1126,8 @@ describe("Tier TG — the terminal authority", () => { dir, [ "", - '', - '', + '', + '', "", "", ].join("\n"), @@ -1060,12 +1135,12 @@ describe("Tier TG — the terminal authority", () => { ); expect(run.outcome.ok).toBe(true); - // Each pane waited inside its own interactive body until the other was - // inside one too. Panes that contended could not both report this. - expect(probe.marks).toContain("together:0:true"); - expect(probe.marks).toContain("together:1:true"); - // And both were inside before either left. - expect(probe.marks.indexOf("enter:1")).toBeLessThan(probe.marks.indexOf("leave:0")); + // Each pane held its own acquired activity until the other was holding one + // too. Panes that contended could not both report this. + expect(probe.marks).toContain("together:a:true"); + expect(probe.marks).toContain("together:b:true"); + // And both were holding before either let go. + expect(probe.marks.indexOf("enter:b")).toBeLessThan(probe.marks.indexOf("leave:a")); }); it("TA8: one pane refuses overlapping work, and admits the next after it settles", function* () { @@ -1112,38 +1187,38 @@ describe("Tier TG — the terminal authority", () => { let refusal: unknown; yield* scoped(function* () { try { - yield* kept!.interactive(function* () {}); + yield* kept!.use(startsAndSettles()); } catch (error) { refusal = error; } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); expect(refusalOf(refusal)).toContain("its grid has stopped admitting"); }); - it("TA10: only a reported spawn is readiness, and reporting twice is one event", function* () { + it("TA10: a child that spawns and settles at once is both ready and settled", function* () { const dir = yield* useDir(); const probe = paneProbe(1); const run = yield* runDocument( dir, [ "", - '', + '', "", "", ].join("\n"), { probe }, ); - // Two acknowledgements are one started pane: the grid attached once and - // settled, rather than waiting for a second pane nobody authored. + // Acquired and settled in the same breath: the grid attached rather than + // waiting for a pane that had already finished. expect(run.outcome.ok).toBe(true); - expect(probe.marks).toContain("spawned twice"); + expect(probe.marks).toContain("started and settled"); expect(run.events).toContain("attach:0"); }); - it("TA11: interactive work that reports no spawn has not started", function* () { + it("TA11: an activity that fails before acquisition never makes a pane ready", function* () { const dir = yield* useDir(); const probe = paneProbe(1); const run = yield* runDocument( @@ -1157,9 +1232,11 @@ describe("Tier TG — the terminal authority", () => { { probe }, ); - // The pane owned its terminal and did work in it. Neither is starting. - expect(probe.marks).toContain("worked without spawning"); - expect(failureOf(run)).toContain("finished without starting anything interactive"); + // The pane owned its terminal and tried. Neither is starting. + expect(probe.marks).toContain("tried to start"); + // The pane fails with the reason its activity could not start, rather than + // with the generic "never started anything" — a spawn that failed says why. + expect(failureOf(run)).toContain("this activity's child never spawned"); expect(run.events).not.toContain("attach:0"); expect(run.events).toContain("destroy:0"); }); @@ -1207,7 +1284,7 @@ describe("Tier TG — the terminal authority", () => { } }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); const message = refusal instanceof Error ? refusal.message : ""; // The refusal says which ordinal, and where it actually sat. expect(message).toContain("ordinal 0"); @@ -1219,16 +1296,248 @@ describe("Tier TG — the terminal authority", () => { }); }); -describe("Tier TG — the grid supervisor", () => { +/** + * What every completed journey must be able to say. + * + * The provider's grid is released exactly once — not zero times, and not twice — + * and nothing it handed out is still held. Both halves matter: a count alone + * would pass for a run that released one grid and stranded another. + */ +function expectReleasedOnce(run: DocumentRun, generation = 0): void { + expect(run.events.filter((event) => event === `destroy:${generation}`)).toEqual([ + `destroy:${generation}`, + ]); + expect(run.live).toEqual({ grids: 0, attached: 0, shells: 0 }); +} + +/** + * A provider grid that records every effect it could possibly have. + * + * Lazy on purpose: nothing in here runs until something acquires it. A refusal + * that happens first therefore leaves the record empty, which is the only way + * to tell "refused before the provider was touched" from "refused after". + */ +function watchedGrid(effects: string[], label: string): Operation { + return resource(function* (provide) { + effects.push(`acquired:${label}`); + yield* ensure(() => { + effects.push(`released:${label}`); + }); + yield* provide({ + // deno-lint-ignore require-yield + *attach() { + effects.push(`attach:${label}`); + }, + // deno-lint-ignore require-yield + *update() {}, + // deno-lint-ignore require-yield + *display() {}, + shell: () => + resource>(function* (provideOutcome) { + effects.push(`shell:${label}`); + yield* provideOutcome(done({ exitCode: 0 })); + }), + // deno-lint-ignore require-yield + *closed() {}, + }); + }); +} + +describe("Tier TG — refusing a presentation before the provider is touched", () => { + /** Drive one grid, letting the row decide what the provider presents. */ + function underProvider( + present: (present: PresentTerminalGrid, request: TerminalGridRequest) => Operation, + work: readonly PaneWork[], + ): Operation { + return scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + yield* present(presentGrid, request); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + try { + return yield* openGridWithCloseOwner(work); + } catch (error) { + return error; + } + }); + } + + it("TR1: a copied request is refused, and the copy's grid is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider( + function* (present, request) { + // Same members, a different object. Identity is what is read. + const copy = { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }; + try { + yield* present(copy, watchedGrid(effects, "copy")); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR2: a changed request is refused, and its grid is never acquired", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider( + function* (present, request) { + try { + yield* present( + { ...request, columns: request.columns + 1 }, + watchedGrid(effects, "changed"), + ); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + expect(refusalOf(refusal)).toContain("is not live"); + expect(effects).toEqual([]); + expect(opened).toEqual([]); + }); + + it("TR3: the exact request is refused once it is stale", function* () { + const effects: string[] = []; + const opened: string[] = []; + let kept: { present: PresentTerminalGrid; request: TerminalGridRequest } | undefined; + + yield* scoped(function* () { + yield* underProvider( + function* (present, request) { + kept = { present, request }; + yield* present(request, watchedGrid(effects, "live")); + }, + [readyPane(opened, "a")], + ); + }); + + // The grid ran and finished, so its submitting operation has unwound and + // the request it issued is no longer anything to present for. + expect(opened).toEqual(["a"]); + expect(effects).toEqual(["acquired:live", "attach:live", "released:live"]); + + let refusal: unknown; + yield* scoped(function* () { + try { + yield* kept!.present(kept!.request, watchedGrid(effects, "stale")); + } catch (error) { + refusal = error; + } + }); + + expect(refusalOf(refusal)).toContain("is not live"); + // Nothing new: the stale grid was never acquired. + expect(effects).toEqual(["acquired:live", "attach:live", "released:live"]); + }); + + it("TR4: a second presentation of the exact live request is refused", function* () { + const effects: string[] = []; + const opened: string[] = []; + let refusal: unknown; + + yield* scoped(function* () { + yield* underProvider( + function* (present, request) { + yield* present(request, watchedGrid(effects, "first")); + try { + yield* present(request, watchedGrid(effects, "second")); + } catch (error) { + refusal = error; + } + }, + [readyPane(opened, "a")], + ); + }); + + expect(refusalOf(refusal)).toContain("already been presented"); + // One grid acquired and released; the second was never touched. + expect(effects).toEqual(["acquired:first", "attach:first", "released:first"]); + }); + + it("TR5: the exact live request is refused under another installation generation", function* () { + const effects: string[] = []; + const finalized: string[] = []; + const live = withResolvers(); + let refusal: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* registerTerminalProvider("controlled", function* (_settings, presentGrid) { + yield* TerminalGrids.around( + { + *open([request]) { + // A second installation supersedes the one this grid was issued + // under. It shares the lookup, so it *finds* this request — and + // turns it away for belonging to another installation. + const superseding = yield* useTerminalInstallation(); + try { + yield* superseding(request, watchedGrid(effects, "wrong-generation")); + } catch (error) { + refusal = error; + } + // Then the right one presents, so the grid still settles. + yield* presentGrid(request, watchedGrid(effects, "right-generation")); + return undefined; + }, + }, + { at: "min" }, + ); + }); + const installed = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, installed); + yield* openGridWithCloseOwner([holdingPane(live, finalized, "pane")]); + }); + + expect(refusal).toBeInstanceOf(TerminalGridPresentationError); + expect(refusalOf(refusal)).toContain("belongs to another terminal provider installation"); + // The refused generation's grid was never acquired; only the admitted one. + expect(effects.filter((effect) => effect.includes("wrong-generation"))).toEqual([]); + expect(effects).toContain("acquired:right-generation"); + expect(effects).toContain("released:right-generation"); + }); +}); + +describe("Tier TG — issuing, presenting and settling one grid", () => { /** * A provider that presents exactly what it was routed, with hooks for the * rows that need to interrupt it. * - * Written out rather than reusing the document harness because these rows are - * about ownership: they drive `openTerminalGrid()` directly, so the grid's - * only owner is the operation the row is holding. + * Written out rather than reusing the document harness because these rows + * drive `openTerminalGrid()` directly, so the grid's only owner is the + * operation the row is holding. */ - function useSupervisedHost( + function usePresentingHost( log: TerminalProviderLog, options: { readonly close?: () => Operation; @@ -1236,20 +1545,20 @@ describe("Tier TG — the grid supervisor", () => { readonly onPresent?: ( present: () => Operation, request: TerminalGridRequest, - authority: TerminalGridAuthority, + presentAny: PresentTerminalGrid, ) => Operation; readonly seen?: TerminalGridRequest[]; } = {}, - ): Operation { - return (function* (): Operation { + ): Operation { + return (function* (): Operation { let generation = 0; yield* installControlledLauncher(); - yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* registerTerminalProvider("controlled", function* (_settings, present) { yield* TerminalGrids.around( { *open([request]) { options.seen?.push(request); - const composite = yield* prepareControlledComposite( + const grid = controlledTerminalGrid( request, { log, @@ -1258,11 +1567,11 @@ describe("Tier TG — the grid supervisor", () => { }, generation++, ); - const present = () => authority.present(request, composite); + const presentThis = () => present(request, grid); if (options.onPresent === undefined) { - yield* present(); + yield* presentThis(); } else { - yield* options.onPresent(present, request, authority); + yield* options.onPresent(presentThis, request, present); } return undefined; }, @@ -1270,9 +1579,9 @@ describe("Tier TG — the grid supervisor", () => { { at: "min" }, ); }); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); - return authority; + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); + return present; })(); } @@ -1295,22 +1604,22 @@ describe("Tier TG — the grid supervisor", () => { { at: "min" }, ); }); - const authority = yield* useTerminalInstallation(); - yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + const present = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, present); let refusal: unknown; try { - yield* supervisedGrid([readyPane(opened, "a")]); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); } catch (error) { refusal = error; } expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); }); - // Submitted and never presented: no pane ran and no composite existed. + // Submitted and never presented: no pane ran and no grid existed. expect(opened).toEqual([]); expect(log.events).toEqual([]); - expect(log.live.composites).toBe(0); + expect(log.live.grids).toBe(0); }); it("TS2: one request opens one grid, however often it is presented", function* () { @@ -1319,26 +1628,25 @@ describe("Tier TG — the grid supervisor", () => { let second: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { - *onPresent(present, request, authority) { + yield* usePresentingHost(log, { + *onPresent(present, request, presentAny) { yield* present(); - // The same request again, with a composite of its own, once the grid + // The same request again, with a grid of its own, once the grid // it named has already run. - const again = yield* prepareControlledComposite(request, { log }, 9); try { - yield* authority.present(request, again); + yield* presentAny(request, controlledTerminalGrid(request, { log }, 9)); } catch (error) { second = error; } }, }); - yield* supervisedGrid([readyPane(opened, "a")]); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); }); // The matching grid ran exactly once, and the second presentation of the // same request opened nothing. expect(opened).toEqual(["a"]); - expect(second).toBeInstanceOf(TerminalAuthorityError); + expect(second).toBeInstanceOf(TerminalGridPresentationError); expect(refusalOf(second)).toContain("already been presented"); }); @@ -1349,26 +1657,25 @@ describe("Tier TG — the grid supervisor", () => { let stale: unknown; yield* scoped(function* () { - const authority = yield* useSupervisedHost(log, { seen }); + const present = yield* usePresentingHost(log, { seen }); - yield* supervisedGrid([readyPane(opened, "first")]); - yield* supervisedGrid([readyPane(opened, "second")]); + yield* openGridWithCloseOwner([readyPane(opened, "first")]); + yield* openGridWithCloseOwner([readyPane(opened, "second")]); // The first grid's request is no longer something a provider can present // for: its entry went when its submitting operation unwound. - const late = yield* prepareControlledComposite(seen[0]!, { log }, 9); try { - yield* authority.present(seen[0]!, late); + yield* present(seen[0]!, controlledTerminalGrid(seen[0]!, { log }, 9)); } catch (error) { stale = error; } }); expect(opened).toEqual(["first", "second"]); - expect(stale).toBeInstanceOf(TerminalAuthorityError); + expect(stale).toBeInstanceOf(TerminalGridPresentationError); expect(refusalOf(stale)).toContain("is not live"); // Only the settled grid was removed, and only after its own teardown: the - // first composite was destroyed before the second was ever prepared, and + // first grid was released before the second was ever prepared, and // both grids destroyed theirs. expect(log.events).toContain("destroy:0"); expect(log.events).toContain("destroy:1"); @@ -1382,7 +1689,7 @@ describe("Tier TG — the grid supervisor", () => { let refusal: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { + yield* usePresentingHost(log, { // The reader never leaves, so the grid stays live until something stops // it. close: () => suspend(), @@ -1395,7 +1702,7 @@ describe("Tier TG — the grid supervisor", () => { }); try { - yield* supervisedGrid([holdingPane(live, finalized, "pane")]); + yield* openGridWithCloseOwner([holdingPane(live, finalized, "pane")]); } catch (error) { refusal = error; } @@ -1404,37 +1711,108 @@ describe("Tier TG — the grid supervisor", () => { // The grid went with the call that owned it rather than carrying on // without one: its pane ran its finalizer, and the provider holds nothing. expect(finalized).toEqual(["pane"]); - expect(log.live.composites).toBe(0); + expect(log.live.grids).toBe(0); expect(log.live.attached).toBe(0); expect(refusalOf(refusal)).toContain("no terminal provider opened this grid"); }); - it("TS5: installation teardown stops the grid still live, and waits for it", function* () { + it("TS5: cancelling the submitting operation takes its grid down, installation and all still live", function* () { const log = terminalProviderLog(); const finalized: string[] = []; const live = withResolvers(); - const shown = withResolvers(); + let heldWhileLive = -1; yield* scoped(function* () { - yield* useSupervisedHost(log, { - close: () => suspend(), - // deno-lint-ignore require-yield - *onAttach() { - shown.resolve(); + yield* usePresentingHost(log); + + // The grid is live — its provider grid acquired, its pane starting — and + // the row's own branch then wins the race, cancelling the submitting + // operation and nothing else. The installation is untouched: this is what + // owns a grid, structured concurrency beneath the expansion rather than + // anything holding tasks for the execution. + yield* race([ + (function* (): Operation { + yield* openGridWithCloseOwner([startingPane(live, finalized, "pane")]); + })(), + (function* (): Operation { + yield* live.operation; + heldWhileLive = log.live.grids; + })(), + ]); + + expect(heldWhileLive).toBe(1); + // The pane's activity was released and the provider's grid with it. + expect(finalized).toEqual(["pane"]); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); + + // And the installation is still live: it issues and settles another grid. + const opened: string[] = []; + yield* openGridWithCloseOwner([readyPane(opened, "after")]); + expect(opened).toEqual(["after"]); + }); + }); + + it("TS7: presenting stays blocked until the grid has settled and been released", function* () { + const log = terminalProviderLog(); + const opened: string[] = []; + const order: string[] = []; + + yield* scoped(function* () { + yield* usePresentingHost(log, { + *onPresent(present) { + yield* present(); + // Read the moment presentation returns: the grid must already be + // settled and released, not merely started. + order.push(`returned:${log.live.grids}:${log.live.attached}`); + order.push(...log.events.filter((event) => event.startsWith("destroy:"))); }, }); - yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "pane")])); - // Held open: the row leaves the scope with an attached grid still running. - yield* live.operation; - yield* shown.operation; - expect(log.live.attached).toBe(1); + yield* openGridWithCloseOwner([readyPane(opened, "a")]); }); - // The installation went, and took the grid with it — awaited, not abandoned. - expect(finalized).toEqual(["pane"]); - expect(log.live.composites).toBe(0); - expect(log.live.attached).toBe(0); - expect(log.live.shells).toBe(0); + expect(opened).toEqual(["a"]); + // Nothing was still held when the provider's call came back, and the + // release had already been recorded. + expect(order).toEqual(["returned:0:0", "destroy:0"]); + }); + + it("TS8: an incomplete replay acquires only the activity that must resume", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = [ + "", + '', + '', + "", + "", + ``, + "", + ].join("\n"); + + // The shell holds, so the run is interrupted with the left pane complete + // and the shell pane incomplete. + const holdingShell: ControlledTerminalGridOptions["shell"] = () => + resource>(function* (provide) { + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }); + + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); + expect(first.ran).toContain("left ran"); + + // Resumed: the completed pane restores its outcome without acquiring + // anything, and only the shell that must resume acquires an activity. + const second = yield* runInterrupted(dir, source, stream, { settled: 1 }); + expect(second.ran).not.toContain("left ran"); + expect(second.events.filter((event) => event.startsWith("shell:"))).toHaveLength(1); }); it("TS6: a second grid cannot be live beside the first", function* () { @@ -1444,15 +1822,15 @@ describe("Tier TG — the grid supervisor", () => { let refusal: unknown; yield* scoped(function* () { - yield* useSupervisedHost(log, { close: () => suspend() }); - yield* spawn(() => supervisedGrid([holdingPane(live, finalized, "first")])); + yield* usePresentingHost(log, { close: () => suspend() }); + yield* spawn(() => openGridWithCloseOwner([holdingPane(live, finalized, "first")])); yield* live.operation; // Why "every remaining grid" is one grid: the foreground-terminal lease // admits a single grid at a time, so a second never reaches the - // supervisor at all. + // lookup at all. try { - yield* supervisedGrid([readyPane([], "second")]); + yield* openGridWithCloseOwner([readyPane([], "second")]); } catch (error) { refusal = error; } @@ -1460,7 +1838,7 @@ describe("Tier TG — the grid supervisor", () => { expect(refusalOf(refusal)).toContain("owns the terminal at a time"); expect(finalized).toEqual(["first"]); - expect(log.live.composites).toBe(0); + expect(log.live.grids).toBe(0); }); }); @@ -1494,6 +1872,8 @@ describe("Tier TG — a grid written in a document", () => { { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, ], }); + // A grid that succeeded released its provider's grid once, holding nothing. + expectReleasedOnce(run); }); it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { @@ -1535,7 +1915,7 @@ describe("Tier TG — a grid written in a document", () => { "", ].join("\n"), { - composite: { + grid: { // Preparation happens after the lease and the flush, so what the // reader had already been given is on screen before the grid covers // it. @@ -1702,9 +2082,9 @@ describe("Tier TG — a grid written in a document", () => { describe("Tier TG — startup, settlement and teardown", () => { const TWO = ["", ...PANES, "", ""].join("\n"); - it("TG9: nothing attaches until every pane has reported a spawn", function* () { + it("TG9: nothing attaches until every pane has acquired a terminal activity", function* () { const dir = yield* useDir(); - // One ordered record the pane and the composite both write to, so + // One ordered record the pane and the grid both write to, so // "readiness came first" is read rather than assumed. The grid emits // `running` for every pane immediately before it attaches, so asserting on // that alone would prove nothing. @@ -1720,17 +2100,16 @@ describe("Tier TG — startup, settlement and teardown", () => { ].join("\n"), { slowMarks: timeline, - composite: { + grid: { // deno-lint-ignore require-yield *onAttach() { timeline.push("attach"); }, - // deno-lint-ignore require-yield - *shell(_ordinal, spawned) { - timeline.push("ready:shell"); - spawned(); - return { exitCode: 0 }; - }, + shell: () => + resource>(function* (provide) { + timeline.push("ready:shell"); + yield* provide(done({ exitCode: 0 })); + }), }, }, ); @@ -1755,9 +2134,10 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(failureOf(run)).toContain("finished without starting anything interactive"); - // No partial grid was ever shown, and the hidden composite was destroyed. + // No partial grid was ever shown, and the hidden grid was released — once, + // with nothing of the provider's still held. expect(run.events).not.toContain("attach:0"); - expect(run.events).toContain("destroy:0"); + expectReleasedOnce(run); }); it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { @@ -1768,19 +2148,18 @@ describe("Tier TG — startup, settlement and teardown", () => { "\n", ), { - composite: { - // Reports its spawn and returns in the same breath. - // deno-lint-ignore require-yield - *shell(_ordinal, spawned) { - spawned(); - return { exitCode: 0 }; - }, + grid: { + // Spawns and is finished in the same breath: ready and settled. + shell: () => + resource>(function* (provide) { + yield* provide(done({ exitCode: 0 })); + }), }, }, ); expect(run.outcome.ok).toBe(true); - // Ready at the spawn event, so the grid attached; settled straight after, + // Ready the moment the activity was acquired, so the grid attached; settled straight after, // so its final status is its own. Both, from one child that started and // stopped in the same breath. expect(run.events).toContain("attach:0"); @@ -1793,7 +2172,7 @@ describe("Tier TG — startup, settlement and teardown", () => { it("TG9: a preparation failure starts no pane at all", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { - composite: { + grid: { // deno-lint-ignore require-yield *onPrepare() { throw new Error("no pane endpoint could be created"); @@ -1805,18 +2184,18 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.shown.size).toBe(0); }); - it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + it("TG9: an attach failure shows no partial grid and releases it", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { - composite: { + grid: { // deno-lint-ignore require-yield *onAttach() { - throw new Error("the composite could not be shown"); + throw new Error("the grid could not be shown"); }, }, }); - expect(failureOf(run)).toContain("the composite could not be shown"); + expect(failureOf(run)).toContain("the grid could not be shown"); expect(run.events).toContain("destroy:0"); }); @@ -1852,7 +2231,7 @@ describe("Tier TG — startup, settlement and teardown", () => { "", ].join("\n"), { - composite: { + grid: { // The reader leaves while the pane is still live. close: immediateClose(), }, @@ -1864,14 +2243,16 @@ describe("Tier TG — startup, settlement and teardown", () => { expect(run.events).toContain("state:0:0:closed"); const destroyed = run.events.indexOf("destroy:0"); expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); - // The following sibling started only after the composite came down. + // Released once, after the reader left, with nothing still held. + expectReleasedOnce(run); + // The following sibling started only after the grid came down. expect(run.ran).toEqual(["after the grid"]); }); it("TG13: an active provider failure cancels every pane and fails the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument(dir, TWO, { - composite: { + grid: { // The reader's close operation is where an active provider can fail. // deno-lint-ignore require-yield *close() { @@ -1881,7 +2262,8 @@ describe("Tier TG — startup, settlement and teardown", () => { }); expect(failureOf(run)).toContain("the terminal provider lost its server"); - expect(run.events).toContain("destroy:0"); + // A provider that failed mid-grid still had its grid released exactly once. + expectReleasedOnce(run); }); }); @@ -2034,7 +2416,7 @@ describe("Tier TG — durability and replay", () => { expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); }); - it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { + it("TG16: an interrupted grid acquires a fresh provider grid rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -2046,7 +2428,7 @@ describe("Tier TG — durability and replay", () => { const second = yield* runInterrupted(dir, GRID, stream); - // A fresh composite, built by this run. + // A fresh provider grid, acquired by this run. expect(second.requests).toHaveLength(1); expect(second.events).toContain("prepare:0:2x1"); }); @@ -2058,11 +2440,17 @@ describe("Tier TG — durability and replay", () => { '', '', ]); - const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { - spawned(); - yield* suspend(); - return { exitCode: 0 }; - }; + const holdingShell: ControlledTerminalGridOptions["shell"] = () => + resource>(function* (provide) { + // Started, and never finishes on its own. + yield* provide( + (function* (): Operation { + yield* suspend(); + // Unreachable: the shell is released rather than returning. + return { exitCode: 0 }; + })(), + ); + }); // The left pane settles; the shell holds, so only one pane record exists. const first = yield* runInterrupted(dir, source, stream, { @@ -2360,8 +2748,8 @@ describe("Tier TG — durability and replay", () => { // The provider's counters went up and came back down. Reading them only at // the end would be true of counters that never moved. - expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); - expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + expect(heldWhenBlocked).toEqual({ grids: 1, attached: 1, shells: 0 }); + expect(first.live).toEqual({ grids: 0, attached: 0, shells: 0 }); // And the foreground lease came back: it was taken and given back twice // over once the run was done. expect(leases).toBe(2); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c9a9d60d1..9db92312b 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,7 +147,7 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - prepareControlledComposite, + controlledTerminalGrid, TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, @@ -155,8 +155,9 @@ export { TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledCompositeOptions, - TerminalComposite, + ControlledTerminalGridOptions, + TerminalActivity, + TerminalGrid, TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index b03275a8f..93e573e7b 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -5,7 +5,7 @@ * This is not the native launcher. A launch hands **one** child the whole * foreground terminal and waits for it; a grid divides that terminal into * several panes that stay interactive at the same time, each with its own - * lifetime. tmux is one way to do that, a host-native composite UI is another, + * lifetime. tmux is one way to do that, a host-native grid UI is another, * and a test surface that opens no terminal at all is a third. None of them * appears in the document: `` asks for panes and their authored * layout, and the host chooses what presents them. @@ -13,18 +13,19 @@ * **This surface is routing, and only routing.** Middleware here may observe, * narrow, refuse, wrap or delegate one grid request. What it cannot do is open * a grid: `open()` answers `unknown`, and the answer is thrown away. The - * capability that takes the terminal leases, mints pane claims and settles a - * grid is a non-contextual authority delivered straight to the registered + * capability that takes the terminal leases and settles a grid is a + * non-contextual presentation function delivered straight to the registered * provider, and a handler that answers without delegating has therefore * presented nothing and settled nothing. * * A grid is prepared before it is shown, which is what makes opening one atomic: - * the provider builds the whole composite while it is hidden, core starts the - * authored panes and waits for every one of them to report a spawn, and only - * then is anything attached. + * the provider builds the whole grid while it is hidden, core starts the + * authored panes and waits for every one of them to acquire a terminal + * activity, and only then is anything attached. */ import { type Api, createApi } from "@effectionx/context-api"; +import { ensure, resource } from "effection"; import type { Operation } from "effection"; /** One pane the provider is asked to present, by its authored ordinal. */ @@ -52,7 +53,7 @@ export interface TerminalPaneRequest { * environment. It is what the author wrote, resolved. * * It is also **one-use and identity-bearing**. Core mints exactly one of these - * per grid expansion and the authority compares the object it is presented with + * per grid expansion and presentation compares the object it is given with * against the one it issued, so a request that was copied, rebuilt with the same * members, kept from an earlier grid, or already used authorizes nothing. */ @@ -79,18 +80,37 @@ export interface TerminalShellOutcome { } /** - * One prepared, still-hidden grid. + * One terminal activity: something interactive a pane runs. * - * Everything here belongs to the one preparation that produced it. A composite - * is never reused across expansions, and a provider that hands the same one - * back twice has handed back a grid the second expansion did not ask for. + * A resource, and the acquisition is the whole point. Preparing a child and + * spawning it happen before the value exists, so a provider that could not + * start one never yields — and the pane it belongs to never becomes ready. + * Acquiring it means the child is running; the value acquired is the operation + * that settles with how that child ended; releasing it kills and reaps whatever + * is left. + * + * A child that starts and exits immediately is therefore both ready and + * settled. + */ +export type TerminalActivity = Operation>; + +/** + * One provider's realization of one complete grid. + * + * This *is* the grid the provider drew, for the one request it was presented, + * and it belongs to that one preparation: a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. It is + * supplied as a resource, so acquiring it is how a grid comes to exist and + * releasing it is how it goes — exactly once, whether the grid succeeded, + * failed to start, was closed by the reader, was failed by the provider, or was + * cancelled. There is no destroy to call and no way to call it twice. */ -export interface TerminalComposite { +export interface TerminalGrid { /** - * Show the composite. Called once, and only after every pane is ready. + * Show the grid. Called once, and only after every pane is ready. * * A provider that has to place panes does it here rather than during - * preparation, so the reader never sees a grid fill in. + * acquisition, so the reader never sees a grid fill in. */ attach(): Operation; /** @@ -112,32 +132,21 @@ export interface TerminalComposite { */ display(ordinal: number, text: string): Operation; /** - * Start the host's default interactive shell in one pane and report how it - * ended. + * The host's default interactive shell in one pane, as a terminal activity. * * Which shell that is comes from live host policy, never from the document. - * - * `spawned` is the pane's readiness latch, and calling it is the only thing - * that makes this pane ready. Call it from the runtime's successful - * child-spawn event and before waiting for the child to exit — so a shell - * that starts and exits at once is both ready and settled, while a shell that - * never started leaves the latch alone and the grid never attaches. + * Acquiring it means the shell started, which is what makes a self-closing + * pane ready; a shell that could not start is a failure before acquisition + * and leaves the pane unready. */ - shell(ordinal: number, spawned: () => void): Operation; + shell(ordinal: number): TerminalActivity; /** - * Settle when the reader closes or leaves the composite. + * Settle when the reader closes or leaves the grid. * * A grid stays visible after its panes have settled, so this is what tells * core the reader is finished with it. */ closed(): Operation; - /** - * Take the composite down and give the root terminal back. - * - * Called exactly once for every composite that was prepared, including one - * discarded before it ever attached. - */ - destroy(): Operation; } /** The stable name every loaded copy composes through. */ @@ -160,7 +169,7 @@ export interface TerminalGridApi { * Route one grid request to whatever presents it. * * Answers `unknown`, and the answer is discarded: a return value is not - * evidence that a grid was opened, and core reads what the authority settled + * evidence that a grid was opened, and core reads what presentation settled * instead of what a handler said. */ open(request: TerminalGridRequest): Operation; @@ -181,11 +190,11 @@ export const TerminalGrids: Api = createApi(TE }); /** - * Everything one controlled composite did, in the order it did it. + * Everything one controlled grid did, in the order it did it. * * The record is the evidence: a suite reads it to prove that preparation came * before every pane started, that nothing attached before the readiness - * barrier, and that teardown destroyed exactly the composite it prepared. + * barrier, and that release took down exactly the grid it prepared. */ export interface TerminalProviderLog { readonly events: string[]; @@ -199,7 +208,7 @@ export interface TerminalProviderLog { /** * What the provider still holds, counted rather than described. * - * Each one goes up when the composite takes something and down when it gives + * Each one goes up when the grid takes something and down when it gives * it back, so a suite reads it after a run to prove nothing was stranded — * including after a cancellation, where the ordering of the record alone * would not say whether teardown finished. @@ -207,13 +216,13 @@ export interface TerminalProviderLog { readonly live: TerminalProviderResources; } -/** What one controlled composite holds at a moment, by kind. */ +/** What one controlled provider holds at a moment, by kind. */ export interface TerminalProviderResources { - /** Composites prepared and not yet destroyed. */ - composites: number; - /** Composites attached and not yet destroyed. */ + /** Grids acquired and not yet released. */ + grids: number; + /** Grids attached and not yet released. */ attached: number; - /** Shells started whose outcome has not been returned. */ + /** Shell activities acquired and not yet released. */ shells: number; } @@ -222,21 +231,21 @@ export function terminalProviderLog(): TerminalProviderLog { return { events: [], shown: new Map(), - live: { composites: 0, attached: 0, shells: 0 }, + live: { grids: 0, attached: 0, shells: 0 }, }; } /** - * What a controlled composite does instead of opening a terminal. + * What a controlled grid does instead of opening a terminal. * * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * refuses before a composite exists, `onAttach` fails the barrier, `shell` - * decides what a self-closing pane's shell did and whether it started at all, - * and `close` is the operation the grid waits on, so a suite controls exactly - * when the reader leaves. + * refuses before a grid exists, `onAttach` fails the barrier, `shell` decides + * what a self-closing pane's shell did and whether it started at all, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. */ -export interface ControlledCompositeOptions { - /** Appended to as the composite works, so ordering is read rather than timed. */ +export interface ControlledTerminalGridOptions { + /** Appended to as the grid works, so ordering is read rather than timed. */ readonly log?: TerminalProviderLog; onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; @@ -248,32 +257,64 @@ export interface ControlledCompositeOptions { * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * The shell activity for one pane. + * + * A suite that wants a shell which never starts supplies one that throws + * before it provides: the pane then never becomes ready, exactly as a real + * spawn failure leaves it. + */ + shell?: (ordinal: number) => TerminalActivity; close?: () => Operation; } +/** An outcome that is already settled, for a child that needed no waiting. */ +function settled(outcome: T): Operation { + // deno-lint-ignore require-yield + return (function* (): Operation { + return outcome; + })(); +} + /** - * Prepare one composite that presents nothing and records everything. + * One controlled grid that presents nothing and records everything. * - * It answers the whole contract — attach, update, display, shell, close, - * destroy — so a suite exercises core's lifecycle without a terminal, a - * multiplexer, or a process anywhere in it. + * A resource, like a real provider's: acquiring it is the grid coming into + * existence and releasing it is the grid going away, so a suite reads the + * record to prove that happened exactly once. It answers the whole contract — + * attach, update, display, shell, close — without a terminal, a multiplexer, or + * a process anywhere in it. */ -export function prepareControlledComposite( +export function controlledTerminalGrid( request: TerminalGridRequest, - options: ControlledCompositeOptions = {}, + options: ControlledTerminalGridOptions = {}, generation = 0, -): Operation { - return (function* (): Operation { +): Operation { + return resource(function* (provide) { const log = options.log ?? terminalProviderLog(); if (options.onPrepare) { yield* options.onPrepare(request); } log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - log.live.composites++; - let destroyed = false; + log.live.grids++; let attached = false; - return { + + // Registered before the grid is provided, so every way out of the resource + // runs it once: settled, failed to start, closed, failed by the provider, + // or cancelled. + yield* ensure(function* () { + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + log.live.grids--; + if (attached) { + attached = false; + log.live.attached--; + } + }); + + yield* provide({ *attach() { if (options.onAttach) { yield* options.onAttach(); @@ -291,24 +332,29 @@ export function prepareControlledComposite( *display(ordinal, text) { log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - log.live.shells++; - try { + shell(ordinal) { + return resource(function* (provideOutcome) { if (options.shell) { - return yield* options.shell(ordinal, spawned); + // Whatever the suite supplies: it may refuse before providing, + // which is a shell that never started. + const outcome = yield* options.shell(ordinal); + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + yield* ensure(() => { + log.live.shells--; + }); + yield* provideOutcome(outcome); + return; } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - } finally { - // Counted down however the shell left — returned, thrown, or - // cancelled — because a shell a suite can still find is a shell the - // provider is still holding. - log.live.shells--; - } + // The default shell starts and is done: a suite that says nothing + // about a pane wants a pane that works. + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + yield* ensure(() => { + log.live.shells--; + }); + yield* provideOutcome(settled({ exitCode: 0 })); + }); }, *closed() { if (options.close) { @@ -316,24 +362,6 @@ export function prepareControlledComposite( } log.events.push(`closed:${generation}`); }, - *destroy() { - // Destroying twice would make the record say a composite was taken down - // more times than it was built, which is exactly the ordering claim a - // suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - log.live.composites--; - if (attached) { - attached = false; - log.live.attached--; - } - }, - }; - })(); + }); + }); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 3c88c9d83..9dbbe09aa 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,11 +1,11 @@ /** - * Tier TG — the terminal grid routing surface and the composite contract - * (architecture.md §Terminal authority, spec §6.21). + * Tier TG — the terminal grid routing surface and the provider grid contract + * (architecture.md §Terminal grid presentation, spec §6.21). * - * Two things live here, and neither is an authority. The routing surface is + * Two things live here, and neither decides anything. The routing surface is * where middleware composes around a grid request, and its whole contract is * that it decides nothing: `open()` answers `unknown`, and core throws the - * answer away. The composite is what a provider prepares, and its contract is + * answer away. The grid is what a provider supplies as a resource, and its contract is * ordering — prepared hidden, attached once, destroyed exactly once. * * Who may present a grid, and what presenting one authorizes, is core's, and is @@ -16,17 +16,17 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped } from "effection"; +import { resource, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { - prepareControlledComposite, + controlledTerminalGrid, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalGridRequest } from "../terminal.ts"; +import type { TerminalGridRequest, TerminalShellOutcome } from "../terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { @@ -147,35 +147,39 @@ describe("Tier TG — the routing surface", () => { }); }); -describe("Tier TG — the composite contract", () => { - it("TP3: a prepared composite presents nothing until it is attached", function* () { +describe("Tier TG — the provider grid contract", () => { + it("TP3: an acquired grid presents nothing until it is attached", function* () { const log = terminalProviderLog(); const events = yield* scoped(function* () { - yield* prepareControlledComposite(request(), { log }); + yield* controlledTerminalGrid(request(), { log }); return [...log.events]; }); - // A composite the reader can see before every pane is ready is the one - // thing atomic startup forbids. + // A grid the reader can see before every pane is ready is the one thing + // atomic startup forbids. expect(events).toEqual(["prepare:0:2x1"]); expect(events.some((event) => event.startsWith("attach:"))).toBe(false); }); - it("TP3: attach, update, display, shell and destroy record in order", function* () { + it("TP3: attach, update, display, shell and release record in order", function* () { const log = terminalProviderLog(); - const spawns: number[] = []; + let outcome: TerminalShellOutcome | undefined; yield* scoped(function* () { - const composite = yield* prepareControlledComposite(request(), { log }); - yield* composite.update(0, "starting"); - yield* composite.display(0, "pane text"); - yield* composite.update(0, "running"); - yield* composite.shell(1, () => spawns.push(1)); - yield* composite.attach(); - yield* composite.update(0, "succeeded"); - yield* composite.closed(); - yield* composite.destroy(); + const grid = yield* controlledTerminalGrid(request(), { log }); + yield* grid.update(0, "starting"); + yield* grid.display(0, "pane text"); + yield* grid.update(0, "running"); + yield* scoped(function* () { + // Acquiring the activity is the shell starting. + outcome = yield* yield* grid.shell(1); + }); + yield* grid.attach(); + yield* grid.update(0, "succeeded"); + yield* grid.closed(); }); + // The destroy is the resource's own release, recorded without anyone + // calling one. expect(log.events).toEqual([ "prepare:0:2x1", "state:0:0:starting", @@ -187,34 +191,43 @@ describe("Tier TG — the composite contract", () => { "destroy:0", ]); expect(log.shown.get(0)).toBe("pane text"); - // The default shell starts, and says so through the latch it was handed: - // readiness is reported by the shell rather than assumed by the grid. - expect(spawns).toEqual([1]); + expect(outcome).toEqual({ exitCode: 0 }); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); }); - it("TP4: a shell that never starts never reports a spawn", function* () { - const spawns: number[] = []; - const outcome = yield* scoped(function* () { - const composite = yield* prepareControlledComposite(request(), { - // deno-lint-ignore require-yield - *shell() { - // No spawn event: nothing started, so nothing is acknowledged. - return { exitCode: 127 }; - }, + it("TP4: a shell that never starts is never acquired", function* () { + const log = terminalProviderLog(); + let refusal: unknown; + yield* scoped(function* () { + const grid = yield* controlledTerminalGrid(request(), { + log, + shell: () => + resource>(function* () { + // Fails before it provides: nothing started, so nothing is owed an + // outcome and no pane could call this ready. + throw new Error("no child could be spawned"); + }), }); - return yield* composite.shell(1, () => spawns.push(1)); + try { + yield* yield* grid.shell(1); + } catch (error) { + refusal = error; + } }); - expect(outcome).toEqual({ exitCode: 127 }); - expect(spawns).toEqual([]); + expect(refusal instanceof Error ? refusal.message : "").toBe("no child could be spawned"); + // An activity that never came up was never counted as held, and left no + // shell record behind. + expect(log.events.some((event) => event.startsWith("shell:"))).toBe(false); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); }); - it("TP4: a preparation failure leaves no composite to tear down", function* () { + it("TP4: a preparation failure leaves no grid to release", function* () { const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { try { - yield* prepareControlledComposite(request(), { + yield* controlledTerminalGrid(request(), { log, // deno-lint-ignore require-yield *onPrepare() { @@ -229,39 +242,65 @@ describe("Tier TG — the composite contract", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so nothing is owed a - // destroy. + // The failure happened before the grid existed, so nothing is owed a + // release. expect(log.events).toEqual([]); }); - it("TP4: a composite refuses to be destroyed twice", function* () { - let refusal: unknown; + it("TP4: release happens once, whatever ended the grid", function* () { + const log = terminalProviderLog(); + + // Settled normally. + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 0); + }); + // Cancelled while live. The child says when it is actually holding a grid, + // so the halt lands on a live one rather than on a task that never began. + yield* scoped(function* () { + const holding = withResolvers(); + const task = yield* spawn(function* () { + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 1); + holding.resolve(); + yield* suspend(); + }); + }); + yield* holding.operation; + yield* task.halt(); + }); + // Failed after acquisition. yield* scoped(function* () { - const composite = yield* prepareControlledComposite(request()); - yield* composite.destroy(); try { - yield* composite.destroy(); - } catch (error) { - refusal = error; + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 2); + throw new Error("the provider failed"); + }); + } catch { + // The failure is the point; the release is what is being counted. } }); - // Teardown ordering is only readable if a double destroy is loud. A silent - // second destroy would let a suite prove an ordering that never held. - expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + // One destroy each, and nothing left holding anything. A resource cannot be + // released twice, which is why there is no way to call one by hand. + expect(log.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); + expect(log.events.filter((event) => event === "destroy:1")).toEqual(["destroy:1"]); + expect(log.events.filter((event) => event === "destroy:2")).toEqual(["destroy:2"]); + expect(log.live).toEqual({ grids: 0, attached: 0, shells: 0 }); }); - it("TP5: each preparation is its own composite", function* () { + it("TP5: each acquisition is its own grid", function* () { const log = terminalProviderLog(); yield* scoped(function* () { - const first = yield* prepareControlledComposite(request(), { log }, 0); - const second = yield* prepareControlledComposite(request(), { log }, 1); - yield* first.destroy(); - yield* second.destroy(); + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 0); + }); + yield* scoped(function* () { + yield* controlledTerminalGrid(request(), { log }, 1); + }); }); - // Two expansions are two grids. A provider that handed the same composite - // back would have presented the second expansion's grid as the first's. - expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + // Two expansions are two grids. A provider that handed the same grid back + // would have presented the second expansion's grid as the first's. + expect(log.events).toEqual(["prepare:0:2x1", "destroy:0", "prepare:1:2x1", "destroy:1"]); }); }); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 82b33edd5..a8f86f5b4 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9328,7 +9328,7 @@ containment. Before the grid opens, root output is flushed. Display produced by pane content is routed to that pane and is not copied into the root document output or a capture around the grid. The grid itself renders `""`. Only after the provider -has torn down the composite and restored the root terminal can a following +has released the provider grid and restored the root terminal can a following sibling render to the root again. #### Readiness and the visible lifetime @@ -9339,42 +9339,45 @@ Opening a grid is atomic from the reader's perspective: lease. Another root native launch or terminal grid cannot hold it at the same time. 2. The provider validates its live prerequisites and prepares every terminal - endpoint in a hidden composite. It presents nothing yet. + endpoint in a hidden grid. It presents nothing yet. 3. All authored pane children begin concurrently. A self-closing pane starts its shell. A paired pane expands until it starts its first interactive child, normally ``. -4. A pane reaches readiness only when that interactive child emits the - runtime's child-spawn event. A paired pane that settles without starting one - fails startup. Merely allocating an endpoint or process identifier, or - receiving the child's first output, is not readiness; an interactive child - that starts and immediately exits is both ready and settled. -5. The provider attaches the complete composite only after every pane is ready. - -The grid lifecycle owns one private readiness latch for each pane. It passes -that pane's work one concrete `PaneTerminal`; `PaneTerminal.interactive()` -supplies the live operation with a one-use `spawned` acknowledgement. The -pane-scoped launcher calls it from the runtime's spawn event and before waiting -for exit; a startup error never acknowledges. The self-closing shell does the -same. The latch is absent for a root launch and appears in no prop, binding, -public request, provider return, process result, or durable record. - -The same private lifecycle state admits at most one interactive operation in a -pane and stops admitting new work when the grid closes. Different pane -terminals do not contend. No pane-claim, readiness, aggregate claims, or sealing -API crosses the lifecycle boundary; those are implementation details rather -than provider-neutral concepts. +4. A pane reaches readiness only when its terminal activity is acquired, which + happens only once that interactive child has spawned. A paired pane that + settles without acquiring one fails startup. Merely allocating an endpoint or + process identifier, or receiving the child's first output, is not + acquisition; an interactive child that starts and immediately exits is both + ready and settled. +5. The provider attaches the complete grid only after every pane is ready. + +The lifecycle passes that pane's work one concrete `PaneTerminal`, carrying one +operation and no identity: `PaneTerminal.use()` runs a terminal activity as the +pane's owner. An activity is a resource acquired only after its child has +spawned, so acquisition is the readiness and no acknowledgement is handed to +anybody; the acquired value is the operation that settles with the child's +outcome, and the activity's cleanup is awaited before the pane is free. A +startup error fails before acquisition. The self-closing shell uses the same +path. Readiness appears in no prop, binding, public request, provider return, +process result, or durable record, and a root launch has no pane activity. + +The same private lifecycle state admits at most one activity in a pane and stops +admitting new work when the grid closes. Different pane terminals do not +contend. No pane-claim, readiness, controller, aggregate, callback or sealing API +crosses the lifecycle boundary; those are implementation details rather than +provider-neutral concepts. When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private authenticated channel to that pane owner. The presentation provider's command parser never sees those values. The pane owner creates the child with -stdin/stdout/stderr inherited from the pane terminal, forwards the runtime -spawn event to the readiness latch, and never reads terminal input itself. +stdin/stdout/stderr inherited from the pane terminal, provides the activity once +that child is running, and never reads terminal input itself. Provider display text is written to the pane without becoming child input. A provider preparation failure, or a pane failure before every pane is ready, -cancels all pane scopes, awaits their finalizers, discards the hidden composite, -restores the root terminal, and fails without showing a partial grid. Effects +cancels all pane scopes, awaits their finalizers, releases the hidden provider +grid, restores the root terminal, and fails without showing a partial grid. Effects that finished before an interactive start failed keep their ordinary durable records. Grid atomicity is not a transaction that rolls back Agent preparation, files, commands, or other completed work. @@ -9382,7 +9385,7 @@ files, commands, or other completed work. After attachment, a pane's normal exit or failure changes that pane's visible status and does not cancel its siblings. Paired content may continue with later sequential work after one interactive child exits, including another launch on -the same pane. The composite remains visible when all panes have settled. The +the same pane. The grid remains visible when all panes have settled. The reader closes or leaves it to finish the grid. The provider receives presentation updates only as `starting`, `running`, @@ -9396,7 +9399,7 @@ effect when the grid owner has entered a cancellation-deferred await of the grid's durable child and acknowledges that proposal. Before that acknowledgement reaches the child, no close signal reaches a pane. Close then prevents new pane launches, asks every live pane child to close, awaits every child and provider -finalizer, destroys the exact composite, restores the root terminal, and +finalizer, releases the exact provider grid, restores the root terminal, and releases the foreground lease. The deferred await ends only after the durable child has settled and its `Close` has been acknowledged. Only then does the element settle and a later document sibling begin. There is no implicit timeout; @@ -9440,7 +9443,7 @@ order fails the element; cancellation caused solely by closing the grid is not counted as a failed pane. With no failed pane, close succeeds and the document continues. -A provider or host failure cancels the composite and is the grid failure. +A provider or host failure cancels the grid and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. If it arrives after reader close takes effect, reader close still decides the grid and pane outcomes: then-live panes retain `closed`, already-settled panes @@ -9482,7 +9485,7 @@ ordinal, never from its title, scheduling order, or provider layout. The completed region retains its provider-neutral layout, how it closed, and the ordered pane outcomes after the ordinary secret gate. Completed replay claims that whole region and restores its result without contacting a terminal -provider, creating a composite, starting a shell, expanding pane content, +provider, acquiring a provider grid, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. Reader-close intent has no separate durable `closing` state. It becomes durable @@ -9497,7 +9500,7 @@ whose completed `Close` was acknowledged remains settled. Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken -and before any provider is contacted. It then builds a new live composite. +and before any provider is contacted. It then acquires a new live provider grid. Completed pane children appear as already-settled statuses and perform no effects; incomplete children continue from their own durable records. @@ -11552,14 +11555,14 @@ test derives a core result from a provider identifier. | TG6 | Isolated pane scopes | Every pane inherits the grid site's values, cwd, repository selection and providers; one pane's new bindings and contextual changes reach later work in that pane only, and its `Break` or `Return` cannot escape the pane | | TG7 | Pane output | Rendered pane text reaches only that pane and the grid renders `""`; a surrounding capture gets no pane display; nested executable effects retain their ordinary results; native UI and shell bytes enter no capture, process journal or transcript | | TG8 | Readiness barrier | Endpoint allocation, PID allocation, preparation, route publication, detach and first output are not ready; the runtime child-spawn event is. A paired pane that settles without one fails startup, and a child that spawns then exits immediately is ready and settled | -| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no composite attaches, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous pane failures report the first authored ordinal | +| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no provider grid attaches, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous pane failures report the first authored ordinal | | TG10 | Independent settlement | After attach, one pane can exit successfully or fail while siblings remain live and usable; its status stays visible. Closing a grid with failed panes reports the first failed authored ordinal, while teardown cancellation itself does not create a pane failure | | TG11 | Terminal versus session ownership | Distinct pane leases permit concurrent native launches, one pane refuses overlapping launches, and a sequential launch is admitted only after the previous child, its observable descendants and group members, and every other holder of that pane terminal are gone; two panes naming one logical Agent session still contend through the unchanged non-waiting coordinator | -| TG12 | Reader close | Close prevents a later launch, cancels every live pane scope, awaits each child, shell and provider finalizer, destroys the exact composite, restores the root terminal, releases the foreground lease, and only then starts the following document sibling | +| TG12 | Reader close | Close prevents a later launch, cancels every live pane scope, awaits each child, shell and provider finalizer, releases the exact provider grid, restores the root terminal, releases the foreground lease, and only then starts the following document sibling | | TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; an active provider failure cancels every pane and fails the grid; cleanup is attempted for all resources under existing failure precedence | | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | -| TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | +| TG16 | Partial replay | Exact layout acquires a fresh provider grid; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 06532b23a..e40802108 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -739,8 +739,8 @@ prop, token, identifier, or mode. A constructed lookalike cannot reach a live pane, and the genuine terminal admits nothing after its grid closes. Different pane terminals do not contend, so native launches in different panes -can hold their terminals concurrently. `PaneTerminal.interactive()` keeps one -pane exclusive: a second launch cannot begin while the first is live there, and +can hold their terminals concurrently. `PaneTerminal.use()` keeps one pane +exclusive: a second launch cannot begin while the first is live there, and sequential launches work after the first releases it. Release requires the child, its observable descendants and process-group members, and every other holder of that pane terminal to be gone; the pane remains busy if the launcher @@ -761,7 +761,7 @@ or pane identities never enter the `AgentLaunchRequest`, terminal result, `agent_session_launch` record, construction route, ownership key, diagnostic, or private instruction file. -The grid's readiness barrier observes the launch only at the existing successful +The grid's readiness barrier observes the launch only at the successful interactive-child start boundary. Session preparation, route publication, private-file creation, and detach do not make a pane ready. If spawn fails, the launch keeps the durable phases its contract already completed, fails the pane's @@ -770,26 +770,30 @@ not roll those phases back. A child that successfully starts and exits before the other panes become ready has nevertheless crossed readiness and retains its ordinary exit outcome. -`PaneTerminal.interactive()` supplies the native launch with a one-use -`spawned` acknowledgement backed by the grid lifecycle's private readiness -latch. The launcher calls it from the runtime's child-spawn event and before -waiting for exit; allocating a PID or observing output is not readiness, and a -startup error never acknowledges. A root launch receives no such callback. It -is not added to `AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, -a retained launch phase, or a process handle, so readiness composition changes -neither the launch's authored nor durable contract. +A native launch runs through `PaneTerminal.use()` as a terminal activity: a +resource whose acquisition happens only once the child has actually spawned. +Acquisition is the readiness, so nobody is handed an acknowledgement to call — +allocating a PID or observing output is not acquisition, and a preparation, +reservation or spawn error fails before it. The acquired value is the operation +that settles with the child's outcome, and the activity's cleanup is what sweeps +whatever the launch still holds. A root launch has no pane activity at all. +Readiness is not added to `AgentLaunchRequest`, `AgentLaunchResult`, the public +Agent Api, a retained launch phase, or a process handle, so it changes neither +the launch's authored nor its durable contract. The provider-neutral lifecycle exports `PaneTerminal`, not its readiness, -busy-state, or closing machinery. There is no public pane-claim or readiness -interface and no aggregate grid-claims object. Pane work receives the terminal; -the grid lifecycle alone waits for readiness and closes admission. +busy-state, or closing machinery. There is no pane-claim or readiness interface, +no pane controller, and no aggregate object. Pane work receives the terminal; the +grid lifecycle alone waits for readiness and closes admission. Under the tmux provider the pane-scoped launcher sends exact argv, cwd, and environment values over a private authenticated socket to the persistent pane worker. The worker, not a tmux command line, creates the native child with all -three standard streams inherited from the pane terminal. It forwards the spawn -event, writes pane display without reading input, and refuses a concurrent -launch. It uses Effection's `run()` rather than `main()` so Effection does not +three standard streams inherited from the pane terminal. It provides the +activity once that child is running — its own observation of the child starting +is provider-private input to that acquisition, not a callback, an +acknowledgement, or a second readiness protocol — writes pane display without +reading input, and refuses a concurrent launch. It uses Effection's `run()` rather than `main()` so Effection does not convert terminal `SIGINT` into worker exit 130 while the foreground child is handling job control. @@ -1442,9 +1446,10 @@ Implementation review checks these frozen invariants: holders from the prior launch are gone. 25. Pane terminal ownership never replaces or weakens natural-key Agent-session ownership, so two panes naming one session still contend without waiting. -26. A pane is ready only at the runtime child-spawn event; preparation, PID - allocation, route publication, detach, private-file creation and first - output are not readiness, and a failed spawn rolls none of them back. +26. A pane is ready only when its terminal activity is acquired, which happens + at the runtime child-spawn event; preparation, PID allocation, route + publication, detach, private-file creation and first output are not + readiness, and a failed spawn rolls none of them back. 27. Grid cancellation reaches every live launch, awaits its child teardown and session quiescence, and exposes no provider-specific layout identity in an authored, durable, result, or diagnostic surface.