From 2b28753931b72cf134997b0529e58e82e141b6af Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:10:42 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Launch=20native=20Agent=20sessi?= =?UTF-8?q?ons=20in=20independent=20terminal=20panes=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `` written at the root takes the run's one foreground terminal, so native UIs are sequential. Inside a `` that would defeat the point of a grid, where every pane is interactive at the same time. So core installs a native launcher in each paired pane's scope, closed over that pane's claim. `` finds it by being written there: it is handed no pane, ordinal, token or mode, and its request, result and retained phases are the ones a root launch would have. What changes is which terminal answers `reserve` and `flush` — the pane's, through its claim, so two panes do not contend and one pane admits one live launch at a time. A pane also flushes what it has rendered before the UI draws over it, which is the root rule in the one place a pane's text goes. Readiness now has a boundary a launch can report. `NativeLauncherHandler.launch` takes the runtime's child-start event as a parameter — not a request member, not a context, not a result — and the foreground launcher reports it from the child's own `spawn` event, before it waits for the exit. The pane launcher listens and trips its claim's latch there and nowhere else: preparation, the reservation, the flush and an allocated PID are not a start, and a child that never ran never reports one. `nativeLaunch()` is unchanged for adapters, which hear nothing about the start. Terminal ownership and Agent-session ownership stay independent. Nothing pane- derived enters the coordinator key, the launch request, the retained record or a diagnostic, and two panes naming one logical session still contend through the existing non-waiting coordinator. No tmux, no new Agent advertisement, and root launch behavior is unchanged. Evidence: SP1–SP5 in the core launch suite (pane lease, concurrency, readiness, a failure before the start, one-live-launch-per-pane), FL8–FL9 in the runtime launcher (the start event, and a child that never starts), and Tier GN over the checked-in journey `TerminalGridNativeLaunch.test.md` through the whole TestAgent stack. Removing the pane launcher fails SP1–SP4 and GN1–GN4; never reporting readiness fails SP1, SP2, SP3 and SP5. --- packages/core/src/expand.ts | 23 +- packages/core/src/terminal/pane-launcher.ts | 73 ++++ .../core/tests/agent-session-launch.test.ts | 243 ++++++++++++ packages/runtime/launcher.ts | 58 ++- .../runtime/tests/native-launcher.test.ts | 43 ++ .../TerminalGridNativeLaunch.implementor.md | 3 + .../src/TerminalGridNativeLaunch.planner.md | 3 + .../src/TerminalGridNativeLaunch.test.md | 58 +++ .../tests/terminal-grid-native-launch.test.ts | 368 ++++++++++++++++++ 9 files changed, 859 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/terminal/pane-launcher.ts create mode 100644 packages/test-agent/src/TerminalGridNativeLaunch.implementor.md create mode 100644 packages/test-agent/src/TerminalGridNativeLaunch.planner.md create mode 100644 packages/test-agent/src/TerminalGridNativeLaunch.test.md create mode 100644 packages/test-agent/tests/terminal-grid-native-launch.test.ts diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 16b03c1ee..886359005 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -72,6 +72,7 @@ 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 { usePaneNativeLauncher } from "./terminal/pane-launcher.ts"; import { asBindingViolation, asExpressionViolation, @@ -2236,13 +2237,28 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { // in its content has no loop to exit and says so. yield* ActiveLoop.set(undefined); yield* usePaneTerminal(claim); + const shown: Segment[] = []; + // What this pane has rendered and not yet shown. A native UI is about + // to draw over the pane, so the same rule the root flush follows holds + // here: everything the pane has said reaches the reader first. + const flushPane = function* (): Operation { + const pending = renderSegments(shown); + shown.length = 0; + if (pending.length > 0) { + yield* composite.display(pane.ordinal, pending); + } + }; + // A `` written in this pane finds this launcher simply + // by being here: it reserves and flushes this pane instead of competing + // for the run's one foreground lease, and the child it starts is what + // makes this pane ready. + yield* usePaneNativeLauncher(claim, flushPane); 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, @@ -2266,10 +2282,7 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { // one outside the grid. undefined, ); - const text = renderSegments(shown); - if (text.length > 0) { - yield* composite.display(pane.ordinal, text); - } + yield* flushPane(); }); }, }; diff --git a/packages/core/src/terminal/pane-launcher.ts b/packages/core/src/terminal/pane-launcher.ts new file mode 100644 index 000000000..68c01daf0 --- /dev/null +++ b/packages/core/src/terminal/pane-launcher.ts @@ -0,0 +1,73 @@ +/** + * How a native UI reaches a pane's terminal instead of the run's + * (architecture.md §Terminal authority, spec §Terminal-grid composition). + * + * `` written at the root takes the one foreground-terminal + * lease, and every other launch waits for it. Written inside a pane it must + * not: panes stay interactive at the same time, which is the whole reason a + * grid exists. So core installs this in the pane's own scope, and the launch + * finds it simply by being there. + * + * Nothing about the launch changes. It is handed no pane prop, token, + * identifier or mode; its request, its result and its retained phases are the + * ones a root launch would have. What changes is which terminal answers + * `reserve` and `flush`, and that is a composition fact rather than something + * the document or the provider can see. + * + * The claim is the authority, and it is closed over rather than passed on. A + * pane claim buys one interactive terminal at one ordinal — it says nothing + * about which Agent session that pane may own, which stays the session + * coordinator's to answer. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; +import { NativeLauncher } from "@executablemd/runtime"; + +import type { TerminalPaneClaim } from "./authority.ts"; + +/** + * Install one pane's native launcher for the scope that runs that pane's work. + * + * `flush` is how this pane catches the reader up. A pane's rendered text + * belongs to the pane, so it goes where the pane's text goes rather than to the + * root's streams — which the native UI is not drawing over. + */ +export function* usePaneNativeLauncher( + claim: TerminalPaneClaim, + flush: () => Operation, +): Operation { + yield* NativeLauncher.around({ + /** + * This pane, for as long as the launch holds it. + * + * Deliberately not delegated: delegating would ask for the root lease, + * which the grid itself is already holding, and two panes would contend + * over a terminal neither of them is using. The claim refuses a second live + * launch on *this* pane and does not contend with any other, which is + * exactly the exclusivity a pane has. + * + * It is released when the launch's scope ends, so the pane is free only + * after the launcher has finished with the child it started. + */ + reserve() { + return resource(function* (provide) { + yield* claim.admit(function* () { + yield* provide(); + }); + }); + }, + *flush() { + yield* flush(); + }, + *launch([request, spawned], next) { + // The exact request, untouched, to whichever host launcher is installed. + // What this adds is a listener: the pane is ready when the runtime says + // the child started, and at no earlier moment. + return yield* next(request, () => { + claim.ready(); + spawned(); + }); + }, + }); +} diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index cc87cbfa1..685021fd3 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -38,9 +38,17 @@ import { installControlledLauncher, NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch, + prepareControlledComposite, + reserveTerminal, + TerminalGrids, + terminalProviderLog, useHostFiles, } from "@executablemd/runtime"; import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import { createTerminalGridClaims } from "../src/terminal/authority.ts"; +import { usePaneNativeLauncher } from "../src/terminal/pane-launcher.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +import { registerTerminalProvider } from "../src/terminal/provider-api.ts"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; @@ -216,6 +224,19 @@ interface RunOptions { next: (request: AgentLaunchRequest) => Operation, ) => Operation; secretDetection?: boolean; + /** + * Install a controlled terminal provider, so the document can open a grid. + * + * The reader stays until every pane has settled, so a row about what a pane + * launched is not racing the close that would cancel it. + */ + grid?: boolean; + /** Start the native child, in place of a runtime that would. */ + start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; + /** Called as the composite shows each pane state, in order. */ + onPaneState?: (ordinal: number, state: string) => void; + /** Called when the composite is shown to the reader. */ + onAttach?: () => void; } interface Run { @@ -224,6 +245,8 @@ interface Run { stub: LaunchStub; launcher: LauncherLog; events: DurableEvent[]; + /** Everything the controlled composite did, in order. */ + composite: string[]; } function* runDoc(doc: string, options: RunOptions = {}): Operation { @@ -277,10 +300,54 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { })(), } : {}), + ...(options.start === undefined ? {} : { start: options.start }), outcome: () => options.outcome ?? { exitCode: 0 }, }); } + const providerLog = terminalProviderLog(); + if (options.grid === true) { + // The reader leaves once every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 owns that — but a row about what a pane + // launched must not race the close that cancels it. + const settled = withResolvers(); + let panes = 0; + let done = 0; + yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* TerminalGrids.around( + { + *open([request]) { + const composite = yield* prepareControlledComposite(request, { + log: providerLog, + close: () => settled.operation, + // deno-lint-ignore require-yield + *onPrepare(asked) { + panes = asked.panes.length; + }, + // deno-lint-ignore require-yield + *onAttach() { + options.onAttach?.(); + }, + onUpdate(ordinal, state) { + options.onPaneState?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= panes) { + settled.resolve(); + } + } + }, + }); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); + yield* installTerminalGridProfile({ provider: "controlled" }); + } + yield* installAgentComponents({ rootProvider: { factory: stub.factory, @@ -317,6 +384,7 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { stub, launcher, events: yield* stream.readAll(), + composite: providerLog.events, }; }); } @@ -768,6 +836,181 @@ describe("Tier SL — native session launch", () => { }); }); +/** + * Tier SP — `` inside a terminal pane + * (specs/native-agent-session-launch-spec.md §Terminal-grid composition). + * + * The launch is the same launch. Nothing here passes a pane to it, and its + * request, result and retained phases are the ones a root launch would have. + * What changes is which terminal answers, and these rows are about that: a + * pane's own lease instead of the run's, panes that do not contend with each + * other, one that is exclusive to itself, and a readiness latch nothing but a + * started child can trip. + */ +describe("Tier SP — a launch inside a terminal pane", () => { + /** Two panes, each launching a session of its own. */ + const PANES = [ + "", + '', + 'left work', + "", + '', + 'right work', + "", + "", + "", + ].join("\n"); + + it("SP1: a pane launch takes that pane, not the run's foreground lease", function* () { + const run = yield* runDoc(PANES, { grid: true }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + // The grid took the one root lease, and the two launches inside it did not + // ask for it. Had either delegated, the host launcher would have refused + // the second holder and the document would have failed here. + expect(run.launcher.reserved).toBe(1); + expect(run.launcher.requests.length).toBe(2); + // Both went to the provider unchanged: same argv a root launch builds, and + // nothing about a pane in it. + for (const request of run.launcher.requests) { + expect(request.command).toEqual(["stub-ui", "--resume", run.stub.nativeSessionId]); + expect(JSON.stringify(request)).not.toContain("pane"); + expect(JSON.stringify(request)).not.toContain("ordinal"); + } + }); + + it("SP2: launches in distinct panes hold their terminals at the same time", function* () { + // Each launch waits for the other to have started. Two launches sharing one + // lease would serialise, and the first would wait for a second that cannot + // begin — so this row hangs rather than passing if they contend. + const both = withResolvers(); + let started = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + spawned(); + started++; + if (started === 2) { + both.resolve(); + } + yield* both.operation; + }, + }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(started).toBe(2); + expect(run.launcher.requests.length).toBe(2); + }); + + it("SP3: a pane is ready only once its native child has started", function* () { + const order: string[] = []; + const bothPrepared = withResolvers(); + let prepared = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + // Prepared, reserved, flushed and routed to the provider — and none of + // that is a start. Both launches get this far before either child does. + order.push("prepare"); + prepared++; + if (prepared === 2) { + bothPrepared.resolve(); + } + yield* bothPrepared.operation; + order.push("spawn"); + spawned(); + }, + onPaneState: (_ordinal, state) => { + if (state === "running") { + order.push("running"); + } + }, + onAttach: () => order.push("attach"), + }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + // Neither pane was running, and nothing was shown, while both launches sat + // one step short of starting a child. + expect(order.slice(0, 4)).toEqual(["prepare", "prepare", "spawn", "spawn"]); + expect(order.filter((event) => event === "running").length).toBe(2); + expect(order.indexOf("attach")).toBeGreaterThan(order.lastIndexOf("spawn")); + }); + + it("SP4: a launch that fails before the spawn keeps its phases and shows nothing", function* () { + let started = 0; + const run = yield* runDoc(PANES, { + grid: true, + start: function* (_request, spawned) { + // One pane's child never starts, and nothing is reported: the readiness + // latch belongs to a child that started. + started++; + if (started === 1) { + yield* until(Promise.resolve()); + throw new Error("the native UI could not be started"); + } + spawned(); + }, + }); + + expect(run.result.ok).toBe(false); + // Nothing was ever shown: a grid whose pane failed to start attaches no + // partial composite. + expect(run.composite.includes("attach:0")).toBe(false); + // And what the launch had already made durable is still there. The grid + // does not roll a completed preparation back. + expect(retainedPhases(run.events)).toContain("prepared"); + expect(preparedRecord(run.events).nativeSessionId).toBe(run.stub.nativeSessionId); + }); + + it("SP5: one pane admits one live launch, and the next only after it is done", function* () { + const claims = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + const claim = claims.claims[0]!; + const held = withResolvers(); + const holding = withResolvers(); + const refusals: string[] = []; + + yield* scoped(function* () { + yield* installControlledLauncher({ + wait: () => + (function* () { + holding.resolve(); + yield* held.operation; + })(), + }); + yield* usePaneNativeLauncher(claim, function* () {}); + + const first = yield* spawn(function* () { + yield* scoped(function* () { + yield* reserveTerminal(); + yield* nativeLaunch({ command: ["ui"], cwd: "." }); + }); + }); + // Only once the first launch is provably holding the pane. + yield* holding.operation; + try { + yield* scoped(() => reserveTerminal()); + } catch (error) { + refusals.push(error instanceof Error ? error.message : String(error)); + } + held.resolve(); + yield* first; + + // The first is done, so the pane is free again and a sequential launch is + // ordinary composition. + yield* scoped(() => reserveTerminal()); + }); + + expect(refusals.length).toBe(1); + expect(refusals[0]).toContain("already has a live interactive operation"); + // The child that started is what made the pane ready, and it did. + expect(claims.readiness[0]?.acknowledged).toBe(true); + }); +}); + /** * Tier FS — the final public launch surface * (issue-518-authority-lease-architect-amendment.md §Launch authority). diff --git a/packages/runtime/launcher.ts b/packages/runtime/launcher.ts index dc875fad0..f719ba946 100644 --- a/packages/runtime/launcher.ts +++ b/packages/runtime/launcher.ts @@ -62,7 +62,21 @@ export interface NativeLaunchOutcome { export interface NativeLauncherHandler { reserve(): Operation; flush(): Operation; - launch(request: NativeLaunchRequest): Operation; + /** + * Start the native UI, wait for it, and report how it ended. + * + * `spawned` is the runtime's child-start event, reported as a parameter + * rather than through the request or the result. A host calls it once the + * child has actually started and before it waits for the exit, so a UI that + * starts and closes at once has still started. Preparation, a reservation, an + * allocated PID and the child's first output are not that event, and a launch + * that never starts never calls it. + * + * At the root nobody is listening and it does nothing. Composed middleware — + * a terminal pane's launcher — is what gives it a meaning, which is why it + * travels here instead of in `NativeLaunchRequest`. + */ + launch(request: NativeLaunchRequest, spawned: () => void): Operation; } export const NATIVE_LAUNCHER_UNAVAILABLE = @@ -88,7 +102,7 @@ export const NativeLauncher: Api = createApi { + *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { throw new NativeLauncherUnavailableError(); }, }, @@ -104,9 +118,15 @@ export function flushOutput(): Operation { return NativeLauncher.operations.flush(); } -/** Run one native UI as a foreground child and report how it ended. */ +/** + * Run one native UI as a foreground child and report how it ended. + * + * A provider adapter calls this and hears nothing about the child's start: the + * spawn event is the host's to report and a pane's to act on, and an adapter + * that could observe it could also fake it. + */ export function nativeLaunch(request: NativeLaunchRequest): Operation { - return NativeLauncher.operations.launch(request); + return NativeLauncher.operations.launch(request, () => {}); } export const NO_TERMINAL = @@ -179,8 +199,8 @@ export function* installForegroundLauncher( yield* drainStream(process.stdout); yield* drainStream(process.stderr); }, - *launch([request]) { - return yield* runForeground(request); + *launch([request, spawned]) { + return yield* runForeground(request, spawned); }, }, { at: "min" }, @@ -212,7 +232,10 @@ function drainStream(stream: DrainableStream): Operation { ); } -function runForeground(request: NativeLaunchRequest): Operation { +function runForeground( + request: NativeLaunchRequest, + spawned: () => void, +): Operation { return scoped(function* (): Operation { const [command, ...args] = request.command; if (command === undefined) { @@ -239,6 +262,10 @@ function runForeground(request: NativeLaunchRequest): Operation spawned()); child.once("error", (error: Error) => failed.reject(error)); child.once("exit", (code: number | null, signal: string | null) => { const outcome: NativeLaunchOutcome = {}; @@ -396,6 +423,16 @@ export interface ControlledLauncherOptions { record?: (request: NativeLaunchRequest) => void; outcome?: (request: NativeLaunchRequest) => NativeLaunchOutcome; wait?: (request: NativeLaunchRequest) => Operation; + /** + * Start the child, in place of a runtime that would. + * + * It receives the spawn report, so a test decides whether this launch starts + * at all: reporting is what a successful start does, and throwing without + * reporting is what a failure before the start does. Left out, the child + * starts at once — a test that says nothing about starting wants a launch + * that started. + */ + start?: (request: NativeLaunchRequest, spawned: () => void) => Operation; onReserve?: () => void; onFlush?: () => void; } @@ -427,8 +464,13 @@ export function* installControlledLauncher( *flush() { options.onFlush?.(); }, - *launch([request]) { + *launch([request, spawned]) { options.record?.(request); + if (options.start) { + yield* options.start(request, spawned); + } else { + spawned(); + } if (options.wait) { yield* options.wait(request); } diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/runtime/tests/native-launcher.test.ts index cd6bc1b53..9ba5d1e1f 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/runtime/tests/native-launcher.test.ts @@ -26,6 +26,7 @@ import { flushOutput, installForegroundLauncher, nativeLaunch, + NativeLauncher, NO_TERMINAL, reserveTerminal, } from "../launcher.ts"; @@ -203,6 +204,48 @@ describe("Tier FL — the foreground native launcher", () => { expect(order).toEqual(["drain", "launch"]); }); + it("FL8: the runtime's start event is reported once, before the child is waited on", function* () { + const dir = yield* useTempDir(); + const fake = yield* useFake(dir, "claude"); + const order: string[] = []; + yield* installForegroundLauncher({ isTerminal: () => true }); + yield* reserveTerminal(); + + const outcome = yield* NativeLauncher.operations.launch( + { command: [fake.command, "--resume", "session-abc"], cwd: dir }, + () => order.push("started"), + ); + order.push("exited"); + + expect(outcome.exitCode).toBe(0); + // A start, then an exit. Reported from the runtime's own spawn event, so a + // child that starts and closes at once has still started. + expect(order).toEqual(["started", "exited"]); + expect((yield* fake.read()).argv).toEqual(["--resume", "session-abc"]); + }); + + it("FL9: a child that never starts never reports a start", function* () { + const dir = yield* useTempDir(); + const order: string[] = []; + yield* installForegroundLauncher({ isTerminal: () => true }); + yield* reserveTerminal(); + + let message = ""; + try { + yield* NativeLauncher.operations.launch( + { command: [path.join(dir, "not-a-program")], cwd: dir }, + () => order.push("started"), + ); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).not.toBe(""); + // Nothing ran, so nothing started — which is what keeps a pane whose launch + // failed from being presented as one that is running. + expect(order).toEqual([]); + }); + it("FL7: cancellation stops a child that ignores the interrupt", function* () { const dir = yield* useTempDir(); const heartbeat = path.join(dir, "heartbeat"); diff --git a/packages/test-agent/src/TerminalGridNativeLaunch.implementor.md b/packages/test-agent/src/TerminalGridNativeLaunch.implementor.md new file mode 100644 index 000000000..788111705 --- /dev/null +++ b/packages/test-agent/src/TerminalGridNativeLaunch.implementor.md @@ -0,0 +1,3 @@ + + +the implementor pane diff --git a/packages/test-agent/src/TerminalGridNativeLaunch.planner.md b/packages/test-agent/src/TerminalGridNativeLaunch.planner.md new file mode 100644 index 000000000..6b250408f --- /dev/null +++ b/packages/test-agent/src/TerminalGridNativeLaunch.planner.md @@ -0,0 +1,3 @@ + + +the planner pane diff --git a/packages/test-agent/src/TerminalGridNativeLaunch.test.md b/packages/test-agent/src/TerminalGridNativeLaunch.test.md new file mode 100644 index 000000000..7ff45f707 --- /dev/null +++ b/packages/test-agent/src/TerminalGridNativeLaunch.test.md @@ -0,0 +1,58 @@ +# Native sessions in terminal panes + +A `` written at the root takes the run's one foreground +terminal, so native UIs are sequential: the second waits for the first to +close. Inside a `` that would defeat the point of a grid, where every +pane is interactive at the same time. + +So a pane comes with a launcher of its own. `` finds it simply +by being written there — it is handed no pane, no ordinal and no mode, and the +session it prepares, the argv it hands the UI and the phases it retains are the +ones a root launch would have. What changes is which terminal answers. + +Terminal ownership and session ownership stay separate. Holding a pane says +nothing about which Agent session that pane may own, which is still the session +coordinator's to answer. + +Everything below runs against the deterministic test agent and a terminal +provider that presents nothing, so the "native UI" in each pane is a recorded +request rather than a process. + + + + + +Two panes, two sessions. Neither launch names the other, and neither waits for +it: they hold their own pane terminals at the same time, and the grid is shown +only once both native children have started. + + + + + +You are the repository planner. + + + + +You are the repository implementor. + + + + +Neither launch was a turn. Each scenario still holds its one stage, and the +answers say which conversation replied — so the two panes prepared two +sessions rather than one shared between them. + + +which pane are you in? + + + +which pane are you in? + + + + + + diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts new file mode 100644 index 000000000..ceadb15e6 --- /dev/null +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -0,0 +1,368 @@ +/** + * Tier GN — native Agent sessions in terminal panes + * (specs/native-agent-session-launch-spec.md §Terminal-grid composition). + * + * The journey is `packages/test-agent/src/TerminalGridNativeLaunch.test.md`, + * and it runs here against the whole TestAgent stack: a real worker over a real + * ACP connection, the deterministic session coordinator, and two panes each + * launching a session of its own. Two things are substituted, and only two — + * the launcher, which records what it was asked to start, and the terminal + * provider, which presents nothing. + * + * The document says what a reader can read. What a document cannot say is + * *when*: whether the two launches held their pane terminals at the same time, + * and whether the grid waited for both children before it showed anything. So + * the harness supplies those as signals — each launch waits for the other to + * have started — and a pair that contended would wait for a launch that cannot + * begin, which hangs rather than passes. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { randomUUID } from "node:crypto"; +import * as path from "node:path"; +import * as os from "node:os"; +import { ensureDir } from "@effectionx/fs"; +import { + agentIdentityComponents, + installAgentComponents, + installTerminalGridProfile, + registerTerminalProvider, + useTempFileCompiler, +} from "@executablemd/core"; +import { executeInstalled } from "@executablemd/core/host"; +import type { Json } from "@executablemd/core"; +import { + API, + installControlledLauncher, + prepareControlledComposite, + TerminalGrids, + terminalProviderLog, + useHostFiles, +} from "@executablemd/runtime"; +import type { NativeLaunchRequest, TerminalPaneState } from "@executablemd/runtime"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { installTestAgentComponents } from "../src/components.ts"; +import { NativeLaunchObserver, NativeSessionObserver } from "../src/controller.ts"; +import type { NativeSessionReport } from "../src/controller.ts"; +import { useTesting } from "@executablemd/testing"; +import type { TestResult } from "@executablemd/testing"; +import { useCommand } from "./command.ts"; +import { cliBase } from "@executablemd/test-support/launch"; +import { beforeAll } from "@executablemd/test-support/bdd"; + +const WORKER = cliBase(); + +/** The checked-in journey, and the directory its `src=` paths resolve against. */ +const JOURNEY = path.resolve("packages/test-agent/src/TerminalGridNativeLaunch.test.md"); +const JOURNEY_DIR = path.dirname(JOURNEY); + +interface Run { + result: Result; + results: readonly TestResult[]; + /** Every native launch the component's launcher was asked to start. */ + launches: NativeLaunchRequest[]; + /** Every launch the *host's* launcher was asked to start. */ + hostLaunches: NativeLaunchRequest[]; + sessions: NativeSessionReport[]; + events: DurableEvent[]; + /** Everything the controlled composite did, in order. */ + composite: string[]; + /** Whether a terminal provider was asked for a grid at all. */ + grids: number; +} + +interface RunOptions { + /** The document to run. Defaults to the checked-in journey. */ + source?: string; + stream?: InMemoryStream; + /** Install a terminal provider; omit for a host that cannot present one. */ + provider?: false; + /** + * What each launch does once its child has started. + * + * The default holds every launch until every pane has one, which is the + * concurrency claim: a launch that had to wait for its sibling's terminal + * would wait forever instead. + */ + hold?: (request: NativeLaunchRequest) => Operation; +} + +function* runJourney(options: RunOptions = {}): Operation { + const launches: NativeLaunchRequest[] = []; + const hostLaunches: NativeLaunchRequest[] = []; + const sessions: NativeSessionReport[] = []; + const providerLog = terminalProviderLog(); + const stream = options.stream ?? new InMemoryStream(); + let grids = 0; + + // Every pane has launched. Resolved from the launches themselves, so nothing + // here waits for a duration. + const everyPane = withResolvers(); + const PANES = 2; + const hold = + options.hold ?? + ((_request: NativeLaunchRequest) => + (function* () { + if (launches.length >= PANES) { + everyPane.resolve(); + } + yield* everyPane.operation; + })()); + + return yield* scoped(function* () { + // The document is read from the repository, so only what it needs written + // is written: a directory for the journal-bearing runs to call their own. + const dir = path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); + yield* ensureDir(dir); + yield* ensure(() => rm(dir, { recursive: true, force: true })); + + let docPath = JOURNEY; + if (options.source !== undefined) { + docPath = path.join(JOURNEY_DIR, `generated-${randomUUID()}.test.md`); + yield* writeTextFile(docPath, options.source); + yield* ensure(() => rm(docPath, { force: true })); + } + + return yield* scoped(function* () { + yield* API.Env.around({ + // deno-lint-ignore require-yield + *cwd() { + return JOURNEY_DIR; + }, + }); + yield* useHostFiles(); + yield* NativeSessionObserver.set((report) => sessions.push(report)); + // The launcher `` installs for its own scope. A pane's + // launcher composes in front of it, so this is what a pane launch + // reaches once the pane has answered for the terminal. + yield* NativeLaunchObserver.set({ + record: (request) => launches.push(request), + wait: hold, + outcome: () => ({ exitCode: 0 }), + }); + // A host launcher too, which is the wrong one for any of this to reach: + // the terminal it would hand over belongs to whoever is running the + // tests, and under `xmd test` there is no host launcher at all. + yield* installControlledLauncher({ + record: (request) => hostLaunches.push(request), + outcome: () => ({ exitCode: 0 }), + }); + + if (options.provider !== false) { + // The reader stays until every pane has settled, so a row about what a + // pane launched is not racing the close that would cancel it. + const settled = withResolvers(); + let panes = 0; + let done = 0; + yield* registerTerminalProvider("controlled", function* (_settings, authority) { + yield* TerminalGrids.around( + { + *open([request]) { + grids++; + const composite = yield* prepareControlledComposite(request, { + log: providerLog, + close: () => settled.operation, + // deno-lint-ignore require-yield + *onPrepare(asked) { + panes = asked.panes.length; + }, + onUpdate(_ordinal: number, state: TerminalPaneState) { + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= panes) { + settled.resolve(); + } + } + }, + }); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); + yield* installTerminalGridProfile({ provider: "controlled" }); + } + + const testing = yield* useTesting(); + yield* useCommand(WORKER); + yield* installTestAgentComponents(); + yield* installAgentComponents(); + + const execution = yield* executeInstalled({ path: docPath, stream }, [ + { components: agentIdentityComponents() }, + ]); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + return { + result: yield* execution, + results: yield* testing.results, + launches, + hostLaunches, + sessions, + events: yield* stream.readAll(), + composite: providerLog.events, + grids, + }; + }); + }); +} + +/** Every `agent_session_launch` record the run retained, in order. */ +function launchRecords(events: DurableEvent[]): (Json | undefined)[] { + return events.flatMap((event) => + event.type === "yield" && + event.description.type === "agent_session_launch" && + event.result.status === "ok" + ? [event.result.value] + : [], + ); +} + +/** One document that launches the same logical session from both panes. */ +const ONE_SESSION = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'You are the repository planner.', + "", + "", + "", + "", + "", +].join("\n"); + +describe( + "Tier GN — native sessions in terminal panes", + { sanitizeOps: false, sanitizeResources: false }, + () => { + beforeAll(() => useTempFileCompiler()); + + it("GN1: two panes launch two sessions, concurrently, before anything is shown", function* () { + const run = yield* runJourney(); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + + // Two launches, two distinct provider-native identities: two sessions, + // not one shared between the panes. + expect(run.launches.length).toBe(2); + const identities = new Set(run.launches.map((request) => request.command.at(-1))); + expect(identities.size).toBe(2); + // Both held their pane terminals at once. Each launch waited for the + // other to have started, which a serialised pair could never do. + for (const request of run.launches) { + expect(request.command[0]).toBe("xmd-test-agent-ui"); + } + // Nothing was shown until both children had started, and one composite + // presented the whole grid. + expect(run.composite[0]).toBe("prepare:0:2x1"); + expect(run.composite).toContain("attach:0"); + expect(run.composite).toContain("destroy:0"); + expect(run.grids).toBe(1); + }); + + it("GN2: no pane identity reaches the launch request or the retained record", function* () { + const run = yield* runJourney(); + + // The launch's own surfaces: what the provider was asked to start, and + // what the launch retained. The grid's layout record is a different thing + // and legitimately names its panes — this is about what the *launch* + // carries. + const written = JSON.stringify({ + launches: run.launches, + records: launchRecords(run.events), + }); + // The authored pane titles, the ordinal a layout is keyed by, and the + // structural names a grid is written with. Not the bare word "pane": the + // instruction layer is the author's prose and may legitimately say it. + for (const leak of ["ordinal", "Planner", "Implementor", "Terminal.Grid", "columns"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + // What is there instead is what a root launch would have had: the + // document's own working directory. + for (const request of run.launches) { + expect(request.cwd).toBe(JOURNEY_DIR); + } + // And the argv is the resume vector a root launch builds, unchanged. + for (const request of run.launches) { + expect(request.command.length).toBe(3); + expect(request.command[1]).toBe("--resume"); + } + expect(launchRecords(run.events).length).toBeGreaterThan(0); + }); + + it("GN3: a pane launch never reaches the host's launcher", function* () { + const run = yield* runJourney(); + + expect(run.result.ok).toBe(true); + expect(run.hostLaunches).toEqual([]); + expect(run.launches.length).toBe(2); + }); + + it("GN4: two panes naming one session contend, and one is refused", function* () { + // Both panes name the same agent, session and directory, so the natural + // key is one key — and nothing about a pane is in it. One pane takes + // ownership; the other asks while it is held and is told so rather than + // queueing behind a UI that may be there for hours. + const run = yield* runJourney({ source: ONE_SESSION }); + + const failures = run.results.filter((result) => result.status === "fail"); + expect(failures.length).toBe(1); + const refusal = JSON.stringify(failures[0]); + expect(refusal).toContain("another owner is using session"); + // The refusal names the session, not the pane that asked for it. + expect(refusal).not.toContain("Left"); + expect(refusal).not.toContain("Right"); + // Exactly one owner was refused: the other held the session, which is + // what "one owner at a time" means. Two refusals would mean neither did. + const busy = launchRecords(run.events).filter((record) => + JSON.stringify(record).includes("session-busy"), + ); + expect(busy.length).toBe(1); + // A pane that never started is a startup failure, so the grid was never + // shown — the reader sees no half-built composite. + expect(run.composite).not.toContain("attach:0"); + }); + + it("GN5: with no terminal provider, a pane launch starts nothing at all", function* () { + const run = yield* runJourney({ provider: false }); + + expect(run.result.ok).toBe(false); + // Refused where a grid is refused — before a pane, so before a launch. + expect(run.launches).toEqual([]); + expect(run.hostLaunches).toEqual([]); + expect(run.grids).toBe(0); + }); + + it("GN6: a completed grid replays with no provider, launcher or agent contact", function* () { + const stream = new InMemoryStream(); + const first = yield* runJourney({ stream }); + expect(first.result.ok ? "" : first.result.error.message).toBe(""); + + const second = yield* runJourney({ stream }); + + expect(second.result.ok).toBe(true); + // Nothing was presented, nothing was started, and no session was touched. + expect(second.grids).toBe(0); + expect(second.composite).toEqual([]); + expect(second.launches).toEqual([]); + expect(second.hostLaunches).toEqual([]); + expect(second.sessions).toEqual([]); + }); + }, +); From 6b3e75f4aa2c7c2655cd4d172122150446983d2a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:43:41 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=85=20Complete=20#731's=20controlled?= =?UTF-8?q?=20integration=20evidence=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No production change. The pane-scoped launcher, the runtime spawn callback, the authority boundaries and root-launch behavior are exactly as reviewed. The checked-in journey is now the 2×2 grid TG5 asks for: three native Agent sessions and the host's default shell. All four children report their start before the composite attaches, each waits for its siblings while holding its own pane, and the row reads back the authored row-major positions and forms. Four rows added, all driven by signals this run produced: - GN7: after attachment one native UI exits nonzero while its sibling is live. Only that pane fails; the sibling is observed alive on the far side of the failure and stops only when the reader leaves; the grid ends on the pane that failed, and the close's cancellation is not a second failure. - GN8: the reader leaves with both launches live. Both are cancelled where they stood, neither pane fails, the composite comes down — and a root launch after the grid, naming a session a pane held, proves both leases came back. Which refusal it gets is the proof: not "already holds this run's terminal", not "another owner is using session", but the #517 recovery tombstone a cancelled native UI leaves behind. - GN9: a pane admits its next user only once the last one is wholly done, with the launch and the prompt that follows it going through the real coordinator. - GN10: a grid interrupted with a live pane launch, resumed on the same journal. It rebuilds the composite, starts the native child on the identity the first attempt retained, prepares nothing, and the retained record comes back unchanged — identity, route, binding and phase alike. SP5 now proves the pane stays held through both halves: refused while the child is live, refused again once the child has gone but the lease around it is still unwinding, admitted only after both. A launch that merely returned showed only the first. Two harness repairs. Pane states are read as a set of panes rather than a count of messages — a pane still live when the reader leaves is told twice, once from the outcome close decided and once from its own settlement, and that is display rather than a second settlement. And a generated variant is written to a directory of its own with copies of the scenarios it names, so a killed run leaves nothing in the repository. --- .../core/tests/agent-session-launch.test.ts | 82 ++- .../src/TerminalGridNativeLaunch.reviewer.md | 3 + .../src/TerminalGridNativeLaunch.test.md | 30 +- .../tests/terminal-grid-native-launch.test.ts | 635 +++++++++++++++--- 4 files changed, 634 insertions(+), 116 deletions(-) create mode 100644 packages/test-agent/src/TerminalGridNativeLaunch.reviewer.md diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index 685021fd3..ade5e05b2 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -13,7 +13,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; -import { ensure, scoped, spawn, until, withResolvers } from "effection"; +import { ensure, resource, scoped, spawn, until, withResolvers } from "effection"; import type { Operation, Result, WithResolvers } from "effection"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { createHash, randomUUID } from "node:crypto"; @@ -962,50 +962,90 @@ describe("Tier SP — a launch inside a terminal pane", () => { expect(preparedRecord(run.events).nativeSessionId).toBe(run.stub.nativeSessionId); }); - it("SP5: one pane admits one live launch, and the next only after it is done", function* () { + /** + * A lease that outlives the child it protects, and unwinds slowly. + * + * This is the shape a session acquisition has: the provider takes ownership, + * performs the whole launch inside it, and releases it as the launch's scope + * comes down — *inside* the terminal reservation, so the pane is still held + * while it happens. `entered` says the unwinding has begun; `release` lets it + * finish. + */ + function heldLease(entered: WithResolvers, release: WithResolvers): Operation { + return resource(function* (provide) { + yield* ensure(function* () { + entered.resolve(); + yield* release.operation; + }); + yield* provide(); + }); + } + + /** Ask this pane for its terminal, and report the refusal if there is one. */ + function reserveOnce(): Operation { + return (function* (): Operation { + try { + yield* scoped(() => reserveTerminal()); + return "admitted"; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + })(); + } + + it("SP5: a pane is held until both the child and the lease around it are done", function* () { const claims = createTerminalGridClaims({ columns: 1, rows: 1, panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], }); const claim = claims.claims[0]!; - const held = withResolvers(); - const holding = withResolvers(); - const refusals: string[] = []; + const childLive = withResolvers(); + const childMayExit = withResolvers(); + const unwinding = withResolvers(); + const release = withResolvers(); + const asked: string[] = []; yield* scoped(function* () { yield* installControlledLauncher({ wait: () => (function* () { - holding.resolve(); - yield* held.operation; + childLive.resolve(); + yield* childMayExit.operation; })(), }); yield* usePaneNativeLauncher(claim, function* () {}); const first = yield* spawn(function* () { + // The order a launch composes in: this pane, then the lease, then the + // child. Which is also the order they come back in, reversed. yield* scoped(function* () { yield* reserveTerminal(); + yield* heldLease(unwinding, release); yield* nativeLaunch({ command: ["ui"], cwd: "." }); }); }); - // Only once the first launch is provably holding the pane. - yield* holding.operation; - try { - yield* scoped(() => reserveTerminal()); - } catch (error) { - refusals.push(error instanceof Error ? error.message : String(error)); - } - held.resolve(); - yield* first; - // The first is done, so the pane is free again and a sequential launch is - // ordinary composition. - yield* scoped(() => reserveTerminal()); + // 1. The native child is live. + yield* childLive.operation; + asked.push(yield* reserveOnce()); + + // 2. The child has gone, but the lease around it is still unwinding — + // which is the half a launch that merely returned would never show. + childMayExit.resolve(); + yield* unwinding.operation; + asked.push(yield* reserveOnce()); + + // 3. Both are done. + release.resolve(); + yield* first; + asked.push(yield* reserveOnce()); }); - expect(refusals.length).toBe(1); - expect(refusals[0]).toContain("already has a live interactive operation"); + expect(asked.length).toBe(3); + expect(asked[0]).toContain("already has a live interactive operation"); + expect(asked[1]).toContain("already has a live interactive operation"); + expect(asked[2]).toBe("admitted"); // The child that started is what made the pane ready, and it did. expect(claims.readiness[0]?.acknowledged).toBe(true); }); diff --git a/packages/test-agent/src/TerminalGridNativeLaunch.reviewer.md b/packages/test-agent/src/TerminalGridNativeLaunch.reviewer.md new file mode 100644 index 000000000..61ff6c2c1 --- /dev/null +++ b/packages/test-agent/src/TerminalGridNativeLaunch.reviewer.md @@ -0,0 +1,3 @@ + + +the reviewer pane diff --git a/packages/test-agent/src/TerminalGridNativeLaunch.test.md b/packages/test-agent/src/TerminalGridNativeLaunch.test.md index 7ff45f707..0996ea3a6 100644 --- a/packages/test-agent/src/TerminalGridNativeLaunch.test.md +++ b/packages/test-agent/src/TerminalGridNativeLaunch.test.md @@ -16,17 +16,20 @@ coordinator's to answer. Everything below runs against the deterministic test agent and a terminal provider that presents nothing, so the "native UI" in each pane is a recorded -request rather than a process. +request rather than a process, and the fourth pane's shell is the same kind of +fiction. + -Two panes, two sessions. Neither launch names the other, and neither waits for -it: they hold their own pane terminals at the same time, and the grid is shown -only once both native children have started. +Four panes in two rows: three native Agent sessions and the host's default +shell. None of the four names another, and none waits for one. They start +together, the grid is shown only once all four have started, and they stay +interactive side by side until the reader leaves. - + @@ -38,11 +41,17 @@ You are the repository planner. You are the repository implementor. + + +You are the repository reviewer. + + + -Neither launch was a turn. Each scenario still holds its one stage, and the -answers say which conversation replied — so the two panes prepared two -sessions rather than one shared between them. +None of the three launches was a turn. Each scenario still holds its one stage, +and the answers say which conversation replied — so the panes prepared three +sessions rather than sharing one between them. which pane are you in? @@ -52,7 +61,12 @@ sessions rather than one shared between them. which pane are you in? + +which pane are you in? + + + diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts index ceadb15e6..9d8907001 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -4,27 +4,28 @@ * * The journey is `packages/test-agent/src/TerminalGridNativeLaunch.test.md`, * and it runs here against the whole TestAgent stack: a real worker over a real - * ACP connection, the deterministic session coordinator, and two panes each - * launching a session of its own. Two things are substituted, and only two — - * the launcher, which records what it was asked to start, and the terminal - * provider, which presents nothing. + * ACP connection, the deterministic session coordinator, and four panes — three + * launching a native Agent session of their own, one running the host's default + * shell. Two things are substituted, and only two: the launcher, which records + * what it was asked to start, and the terminal provider, which presents + * nothing. * * The document says what a reader can read. What a document cannot say is - * *when*: whether the two launches held their pane terminals at the same time, - * and whether the grid waited for both children before it showed anything. So - * the harness supplies those as signals — each launch waits for the other to - * have started — and a pair that contended would wait for a launch that cannot - * begin, which hangs rather than passes. + * *when* — whether four children held their pane terminals at the same time, + * whether the grid waited for all of them before it showed anything, and + * whether a cancelled launch had finished with its session before the document + * carried on. So the harness supplies those as signals, and every one of them + * is an event this run produced. Nothing here waits for a duration: a lifecycle + * that never reached a step hangs its row rather than passing it. */ -import { describe, it } from "@executablemd/test-support/bdd"; +import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; -import { rm, writeTextFile } from "@effectionx/fs"; +import { ensure, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Result, Task } from "effection"; +import { copyFile, ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; -import * as path from "node:path"; import * as os from "node:os"; -import { ensureDir } from "@effectionx/fs"; +import * as path from "node:path"; import { agentIdentityComponents, installAgentComponents, @@ -42,7 +43,12 @@ import { terminalProviderLog, useHostFiles, } from "@executablemd/runtime"; -import type { NativeLaunchRequest, TerminalPaneState } from "@executablemd/runtime"; +import type { + NativeLaunchOutcome, + NativeLaunchRequest, + TerminalGridRequest, + TerminalPaneState, +} from "@executablemd/runtime"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; @@ -52,7 +58,6 @@ import { useTesting } from "@executablemd/testing"; import type { TestResult } from "@executablemd/testing"; import { useCommand } from "./command.ts"; import { cliBase } from "@executablemd/test-support/launch"; -import { beforeAll } from "@executablemd/test-support/bdd"; const WORKER = cliBase(); @@ -60,6 +65,16 @@ const WORKER = cliBase(); const JOURNEY = path.resolve("packages/test-agent/src/TerminalGridNativeLaunch.test.md"); const JOURNEY_DIR = path.dirname(JOURNEY); +/** The scenario documents a generated variant resolves `src=` against. */ +const SCENARIOS = [ + "TerminalGridNativeLaunch.planner.md", + "TerminalGridNativeLaunch.implementor.md", + "TerminalGridNativeLaunch.reviewer.md", +]; + +/** How many interactive children the checked-in journey starts. */ +const JOURNEY_CHILDREN = 4; + interface Run { result: Result; results: readonly TestResult[]; @@ -71,24 +86,72 @@ interface Run { events: DurableEvent[]; /** Everything the controlled composite did, in order. */ composite: string[]; + /** Each pane state the composite was told to show, as `ordinal:state`. */ + states: string[]; + /** The layout the provider was asked to present. */ + request?: TerminalGridRequest; /** Whether a terminal provider was asked for a grid at all. */ grids: number; + /** The lifecycle marks this run produced, in the order they happened. */ + order: string[]; } +/** + * What one interactive child does, once it has started. + * + * `marker` is the pane's own word for itself, read back from the session the + * launch prepared; the shell pane's is `shell`. A row keys its signals by that + * rather than by an ordinal, because a launch request carries no ordinal and + * must not. + */ +type Child = (marker: string, order: string[]) => Operation; + interface RunOptions { /** The document to run. Defaults to the checked-in journey. */ source?: string; + /** + * Where a generated document lives. + * + * A launch retains the directory it was asked for, so two runs that share a + * journal have to share this one — a second directory replays nothing. + */ + dir?: string; stream?: InMemoryStream; /** Install a terminal provider; omit for a host that cannot present one. */ provider?: false; + /** How many interactive children the document starts. */ + children?: number; /** - * What each launch does once its child has started. + * What each child does once it has started. * - * The default holds every launch until every pane has one, which is the - * concurrency claim: a launch that had to wait for its sibling's terminal - * would wait forever instead. + * The default holds every one of them until every pane has one, which is the + * concurrency claim: a child that had to wait for a sibling's terminal would + * be waiting for a start that cannot happen. */ - hold?: (request: NativeLaunchRequest) => Operation; + child?: Child; + /** How a named pane's native UI ended. Others exit successfully. */ + exits?: Record; + /** Called as each pane state is shown, so a row can signal on one. */ + onState?: (ordinal: number, state: TerminalPaneState) => void; + /** Let the reader leave; the default waits for every pane to settle. */ + close?: (order: string[], states: string[]) => Operation; + /** Interrupt the run when this settles, instead of letting it finish. */ + interruptWhen?: (order: string[]) => Operation; +} + +/** The word a launch's own instruction layer uses for its pane. */ +function markerOf(request: NativeLaunchRequest, sessions: NativeSessionReport[]): string { + const native = request.command.at(-1); + const report = sessions.find( + (candidate) => candidate.nativeSessionId === native && candidate.systemPrompt !== undefined, + ); + const instructions = report?.systemPrompt ?? ""; + for (const marker of ["planner", "implementor", "reviewer", "failing", "surviving"]) { + if (instructions.includes(marker)) { + return marker; + } + } + return "unknown"; } function* runJourney(options: RunOptions = {}): Operation { @@ -96,42 +159,57 @@ function* runJourney(options: RunOptions = {}): Operation { const hostLaunches: NativeLaunchRequest[] = []; const sessions: NativeSessionReport[] = []; const providerLog = terminalProviderLog(); + const states: string[] = []; + const order: string[] = []; const stream = options.stream ?? new InMemoryStream(); let grids = 0; + let request: TerminalGridRequest | undefined; - // Every pane has launched. Resolved from the launches themselves, so nothing - // here waits for a duration. - const everyPane = withResolvers(); - const PANES = 2; - const hold = - options.hold ?? - ((_request: NativeLaunchRequest) => + // Every interactive child has started. Resolved by the starts themselves, so + // nothing here waits for a duration. + const children = options.children ?? JOURNEY_CHILDREN; + const everyChild = withResolvers(); + let started = 0; + const child: Child = + options.child ?? + (() => (function* () { - if (launches.length >= PANES) { - everyPane.resolve(); - } - yield* everyPane.operation; + yield* everyChild.operation; })()); - return yield* scoped(function* () { - // The document is read from the repository, so only what it needs written - // is written: a directory for the journal-bearing runs to call their own. - const dir = path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); - yield* ensureDir(dir); - yield* ensure(() => rm(dir, { recursive: true, force: true })); + /** Record a start, and settle the barrier once every pane has one. */ + const startedOne = (marker: string): void => { + order.push(`start:${marker}`); + started++; + if (started >= children) { + everyChild.resolve(); + } + }; + return yield* scoped(function* () { + // A variant is written to a directory of its own, with copies of the + // scenarios its `src=` paths name. Nothing a row generates is ever written + // into the repository, so a run that is killed leaves nothing behind. let docPath = JOURNEY; + let docDir = JOURNEY_DIR; if (options.source !== undefined) { - docPath = path.join(JOURNEY_DIR, `generated-${randomUUID()}.test.md`); + docDir = options.dir ?? path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); + yield* ensureDir(docDir); + if (options.dir === undefined) { + yield* ensure(() => rm(docDir, { recursive: true, force: true })); + } + for (const scenario of SCENARIOS) { + yield* copyFile(path.join(JOURNEY_DIR, scenario), path.join(docDir, scenario)); + } + docPath = path.join(docDir, "generated.test.md"); yield* writeTextFile(docPath, options.source); - yield* ensure(() => rm(docPath, { force: true })); } return yield* scoped(function* () { yield* API.Env.around({ // deno-lint-ignore require-yield *cwd() { - return JOURNEY_DIR; + return docDir; }, }); yield* useHostFiles(); @@ -140,37 +218,56 @@ function* runJourney(options: RunOptions = {}): Operation { // launcher composes in front of it, so this is what a pane launch // reaches once the pane has answered for the terminal. yield* NativeLaunchObserver.set({ - record: (request) => launches.push(request), - wait: hold, - outcome: () => ({ exitCode: 0 }), + record: (asked) => launches.push(asked), + wait: (asked) => + (function* () { + const marker = markerOf(asked, sessions); + startedOne(marker); + try { + yield* child(marker, order); + } finally { + // Reached however the launch left — returned, or cancelled by the + // reader closing the grid. + order.push(`left:${marker}`); + } + })(), + outcome: (asked) => options.exits?.[markerOf(asked, sessions)] ?? { exitCode: 0 }, }); // A host launcher too, which is the wrong one for any of this to reach: // the terminal it would hand over belongs to whoever is running the // tests, and under `xmd test` there is no host launcher at all. yield* installControlledLauncher({ - record: (request) => hostLaunches.push(request), + record: (asked) => hostLaunches.push(asked), outcome: () => ({ exitCode: 0 }), }); if (options.provider !== false) { - // The reader stays until every pane has settled, so a row about what a - // pane launched is not racing the close that would cancel it. + // The reader stays until every pane has settled. Leaving sooner is a + // real thing a reader does, and the rows about it say so themselves. const settled = withResolvers(); let panes = 0; let done = 0; yield* registerTerminalProvider("controlled", function* (_settings, authority) { yield* TerminalGrids.around( { - *open([request]) { + *open([asked]) { grids++; - const composite = yield* prepareControlledComposite(request, { + const composite = yield* prepareControlledComposite(asked, { log: providerLog, - close: () => settled.operation, + close: () => + options.close === undefined ? settled.operation : options.close(order, states), + // deno-lint-ignore require-yield + *onPrepare(seen) { + request = seen; + panes = seen.panes.length; + }, // deno-lint-ignore require-yield - *onPrepare(asked) { - panes = asked.panes.length; + *onAttach() { + order.push("attach"); }, - onUpdate(_ordinal: number, state: TerminalPaneState) { + onUpdate(ordinal: number, state: TerminalPaneState) { + states.push(`${ordinal}:${state}`); + options.onState?.(ordinal, state); if (state === "succeeded" || state === "failed" || state === "closed") { done++; if (done >= panes) { @@ -178,8 +275,22 @@ function* runJourney(options: RunOptions = {}): Operation { } } }, + // The host's default shell, a fiction here in exactly the way + // the native UI is. It reports its start the same way and then + // stays live, so the fourth pane is as concurrent as the three + // that launched. + *shell(_ordinal, spawned) { + spawned(); + startedOne("shell"); + try { + yield* child("shell", order); + } finally { + order.push("left:shell"); + } + return { exitCode: 0 }; + }, }); - yield* authority.present(request, composite); + yield* authority.present(asked, composite); return undefined; }, }, @@ -197,6 +308,35 @@ function* runJourney(options: RunOptions = {}): Operation { const execution = yield* executeInstalled({ path: docPath, stream }, [ { components: agentIdentityComponents() }, ]); + + if (options.interruptWhen !== undefined) { + // Halted with the child still going, which is the state a crashed run + // leaves its journal in. + const running: Task = yield* spawn(function* () { + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + yield* execution; + }); + yield* options.interruptWhen(order); + yield* running.halt(); + return { + result: { ok: false, error: new Error("interrupted") } as Result, + results: yield* testing.results, + launches, + hostLaunches, + sessions, + events: yield* stream.readAll(), + composite: providerLog.events, + states, + ...(request === undefined ? {} : { request }), + grids, + order, + }; + } + const subscription = yield* execution.output; let next = yield* subscription.next(); while (!next.done) { @@ -210,23 +350,45 @@ function* runJourney(options: RunOptions = {}): Operation { sessions, events: yield* stream.readAll(), composite: providerLog.events, + states, + ...(request === undefined ? {} : { request }), grids, + order, }; }); }); } -/** Every `agent_session_launch` record the run retained, in order. */ -function launchRecords(events: DurableEvent[]): (Json | undefined)[] { +/** Every `agent_session_launch` record the run retained, with its phase name. */ +function launchRecords(events: DurableEvent[]): { name: string; value: Json | undefined }[] { return events.flatMap((event) => event.type === "yield" && event.description.type === "agent_session_launch" && event.result.status === "ok" - ? [event.result.value] + ? [{ name: event.description.name, value: event.result.value }] : [], ); } +/** The members of one retained record, or nothing when it is not readable. */ +function members(value: Json | undefined): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return { ...value }; +} + +/** Every retained `prepared` record, in order. */ +function preparations(events: DurableEvent[]): Record[] { + return launchRecords(events).flatMap((entry) => { + if (!entry.name.endsWith("/prepared")) { + return []; + } + const record = members(entry.value); + return record === undefined ? [] : [record]; + }); +} + /** One document that launches the same logical session from both panes. */ const ONE_SESSION = [ "", @@ -246,32 +408,132 @@ const ONE_SESSION = [ "", ].join("\n"); +/** Two panes: one whose native UI ends badly, and one that stays live. */ +const FAILING_AND_SURVIVING = [ + "", + '', + '', + "", + '', + "", + '', + 'You are the failing pane.', + "", + '', + 'You are the surviving pane.', + "", + "", + "", + "", + "", +].join("\n"); + +/** Two live panes, and the sessions they used, asked for again afterwards. */ +const CLOSE_THEN_CONTINUE = [ + "", + '', + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'You are the repository implementor.', + "", + "", + "", + '', + "You are the repository planner.", + "", + "", + "", + "", +].join("\n"); + +/** One pane, launching the same session twice in a row. */ +const SEQUENTIAL = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'which pane are you in?', + "", + "", + "", + "", + "", + "", +].join("\n"); + +/** One pane whose launch is interrupted while the native child is still live. */ +const ONE_PANE = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + "", + "", + "", + "", +].join("\n"); + describe( "Tier GN — native sessions in terminal panes", { sanitizeOps: false, sanitizeResources: false }, () => { beforeAll(() => useTempFileCompiler()); - it("GN1: two panes launch two sessions, concurrently, before anything is shown", function* () { + it("GN1: four panes start together and stay live, in the authored positions", function* () { const run = yield* runJourney(); expect(run.result.ok ? "" : run.result.error.message).toBe(""); expect(run.results.map((result) => result.status)).toEqual(["pass"]); - // Two launches, two distinct provider-native identities: two sessions, - // not one shared between the panes. - expect(run.launches.length).toBe(2); - const identities = new Set(run.launches.map((request) => request.command.at(-1))); - expect(identities.size).toBe(2); - // Both held their pane terminals at once. Each launch waited for the - // other to have started, which a serialised pair could never do. - for (const request of run.launches) { - expect(request.command[0]).toBe("xmd-test-agent-ui"); - } - // Nothing was shown until both children had started, and one composite - // presented the whole grid. - expect(run.composite[0]).toBe("prepare:0:2x1"); - expect(run.composite).toContain("attach:0"); + // Three launches, three distinct provider-native identities: three + // sessions, not one shared between the panes. + expect(run.launches.length).toBe(3); + const identities = new Set(run.launches.map((asked) => asked.command.at(-1))); + expect(identities.size).toBe(3); + + // Every one of the four children started before anything was shown, and + // each was waiting for its siblings while it did — a serialised set could + // never have reached the barrier at all. + const attached = run.order.indexOf("attach"); + expect(attached).toBeGreaterThan(-1); + const starts = run.order.slice(0, attached).filter((mark) => mark.startsWith("start:")); + expect(new Set(starts)).toEqual( + new Set(["start:planner", "start:implementor", "start:reviewer", "start:shell"]), + ); + // None of them had left by then, so all four held their terminals at once. + expect(run.order.slice(0, attached).some((mark) => mark.startsWith("left:"))).toBe(false); + + // The authored row-major layout, as the provider was asked for it. + expect(run.request?.columns).toBe(2); + expect(run.request?.rows).toBe(2); + expect(run.request?.panes.map((pane) => `${pane.row},${pane.column} ${pane.title}`)).toEqual([ + "0,0 Planner", + "0,1 Implementor", + "1,0 Reviewer", + "1,1 Shell", + ]); + expect(run.request?.panes.map((pane) => pane.form)).toEqual([ + "paired", + "paired", + "paired", + "self-closing", + ]); + expect(run.composite[0]).toBe("prepare:0:2x2"); expect(run.composite).toContain("destroy:0"); expect(run.grids).toBe(1); }); @@ -285,25 +547,22 @@ describe( // carries. const written = JSON.stringify({ launches: run.launches, - records: launchRecords(run.events), + records: launchRecords(run.events).map((entry) => entry.value), }); // The authored pane titles, the ordinal a layout is keyed by, and the // structural names a grid is written with. Not the bare word "pane": the // instruction layer is the author's prose and may legitimately say it. - for (const leak of ["ordinal", "Planner", "Implementor", "Terminal.Grid", "columns"]) { + for (const leak of ["ordinal", "Planner", "Implementor", "Reviewer", "columns"]) { expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); } // What is there instead is what a root launch would have had: the - // document's own working directory. - for (const request of run.launches) { - expect(request.cwd).toBe(JOURNEY_DIR); - } - // And the argv is the resume vector a root launch builds, unchanged. - for (const request of run.launches) { - expect(request.command.length).toBe(3); - expect(request.command[1]).toBe("--resume"); + // document's own working directory, and the resume vector. + for (const asked of run.launches) { + expect(asked.cwd).toBe(JOURNEY_DIR); + expect(asked.command.length).toBe(3); + expect(asked.command[1]).toBe("--resume"); } - expect(launchRecords(run.events).length).toBeGreaterThan(0); + expect(preparations(run.events).length).toBe(3); }); it("GN3: a pane launch never reaches the host's launcher", function* () { @@ -311,7 +570,7 @@ describe( expect(run.result.ok).toBe(true); expect(run.hostLaunches).toEqual([]); - expect(run.launches.length).toBe(2); + expect(run.launches.length).toBe(3); }); it("GN4: two panes naming one session contend, and one is refused", function* () { @@ -319,7 +578,7 @@ describe( // key is one key — and nothing about a pane is in it. One pane takes // ownership; the other asks while it is held and is told so rather than // queueing behind a UI that may be there for hours. - const run = yield* runJourney({ source: ONE_SESSION }); + const run = yield* runJourney({ source: ONE_SESSION, children: 2 }); const failures = run.results.filter((result) => result.status === "fail"); expect(failures.length).toBe(1); @@ -331,7 +590,7 @@ describe( // Exactly one owner was refused: the other held the session, which is // what "one owner at a time" means. Two refusals would mean neither did. const busy = launchRecords(run.events).filter((record) => - JSON.stringify(record).includes("session-busy"), + JSON.stringify(record.value).includes("session-busy"), ); expect(busy.length).toBe(1); // A pane that never started is a startup failure, so the grid was never @@ -364,5 +623,207 @@ describe( expect(second.hostLaunches).toEqual([]); expect(second.sessions).toEqual([]); }); + + it("GN7: one pane's native exit fails that pane, and the sibling lives on", function* () { + const bothLive = withResolvers(); + const paneFailed = withResolvers(); + const survivedIt = withResolvers(); + const closeNow = withResolvers(); + let live = 0; + const run = yield* runJourney({ + source: FAILING_AND_SURVIVING, + children: 2, + exits: { failing: { exitCode: 4 } }, + child: (marker, marks) => + (function* () { + live++; + if (live === 2) { + bothLive.resolve(); + } + // Both are live and shown before either of them ends. + yield* bothLive.operation; + if (marker === "failing") { + return; + } + // The sibling outlives the failure, and says so from the far side + // of it rather than from before. + yield* paneFailed.operation; + marks.push("surviving:still live"); + survivedIt.resolve(); + yield* closeNow.operation; + })(), + onState: (ordinal, state) => { + if (ordinal === 0 && state === "failed") { + paneFailed.resolve(); + } + }, + close: (marks) => + (function* () { + // The reader leaves only once the sibling has been observed alive + // after the failure, so nothing here is a race. + yield* survivedIt.operation; + marks.push("close"); + closeNow.resolve(); + })(), + }); + + // The failing pane's exit is its own status, and it did not cancel the + // pane beside it: the sibling was still live afterwards and stopped only + // when the reader left. + expect(run.states).toContain("0:failed"); + expect(run.states).toContain("1:closed"); + // Which panes, not how many messages: a pane that had not settled when + // the reader left is told twice — once from the outcome close decided, + // once from its own settlement — and that is display, not a second + // settlement. + expect(new Set(run.states.filter((state) => state.endsWith(":failed")))).toEqual( + new Set(["0:failed"]), + ); + expect(run.order).toContain("surviving:still live"); + expect(run.order.indexOf("close")).toBeGreaterThan(run.order.indexOf("surviving:still live")); + // The grid ends on the pane that failed — the cancellation the close + // caused is not a second failure. + const message = run.result.ok ? "" : run.result.error.message; + expect(message).toContain("status 4"); + expect(run.results.filter((result) => result.status === "fail").length).toBe(1); + }); + + it("GN8: reader close cancels every live launch and gives both leases back", function* () { + const closing = withResolvers(); + const bothLive = withResolvers(); + let live = 0; + const run = yield* runJourney({ + source: CLOSE_THEN_CONTINUE, + children: 2, + child: (_marker, marks) => + (function* () { + live++; + if (live === 2) { + bothLive.resolve(); + } + // Held until the reader leaves, and then cancelled through the + // ordinary launch path rather than returning an outcome. + try { + yield* closing.operation; + } finally { + marks.push("cancelled"); + } + })(), + close: (marks) => + (function* () { + yield* bothLive.operation; + marks.push("close"); + closing.resolve(); + })(), + }); + + // Both launches were cancelled where they stood, and neither pane failed: + // a reader leaving is not a pane failure. + expect(run.order.filter((mark) => mark === "cancelled").length).toBe(2); + expect(run.states.filter((state) => state.endsWith(":failed"))).toEqual([]); + // Which panes, not how many messages — see GN7. + expect(new Set(run.states.filter((state) => state.endsWith(":closed")))).toEqual( + new Set(["0:closed", "1:closed"]), + ); + // The composite came down before the document went on. + expect(run.composite).toContain("destroy:0"); + + // The sibling after the grid is a *root* launch naming a session one of + // those panes was holding, so it needs both leases back: the run's + // foreground terminal, and that session's ownership. + // + // Which refusal it gets is what proves it got them. A grid still holding + // the terminal refuses with "already holds this run's terminal"; a + // session still held refuses with "another owner is using session". It + // reaches neither. What it reaches is the #517 recovery tombstone — a + // native UI that was cancelled never proved it stopped, so the record + // stays active and the next owner is told to recover it deliberately + // rather than inferring safety from a lock being free. + const message = run.result.ok ? "" : run.result.error.message; + expect(message).toContain("was left owned by work that did not finish"); + expect(message).not.toContain("already holds this run's terminal"); + expect(message).not.toContain("another owner is using session"); + // And it started nothing: the refusal comes before a native child. + expect(run.launches.length).toBe(2); + expect(run.hostLaunches).toEqual([]); + }); + + it("GN9: a pane admits the next user only once the last one is wholly done", function* () { + // Sequential composition in one pane, through the real coordinator. The + // prompt after the launch needs two things the launch was holding: that + // pane's terminal, and that session's ownership. It gets an answer, so + // the launch released both — and GN4 is the other half of the same claim, + // where a second owner asking while the first still holds it is refused. + const run = yield* runJourney({ source: SEQUENTIAL, children: 1 }); + + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + expect(run.launches.length).toBe(1); + // The launch had wholly left before the session was used again: a pane + // admits one live user, and the next only once that one is done. + expect(run.order).toContain("left:planner"); + expect(run.order.indexOf("left:planner")).toBeGreaterThan(run.order.indexOf("start:planner")); + // The same conversation the launch prepared answered afterwards. + const native = run.launches[0]?.command.at(-1); + expect(run.sessions.at(-1)?.nativeSessionId).toBe(native); + }); + + it("GN10: an interrupted pane launch resumes its own conversation", function* () { + const stream = new InMemoryStream(); + const live = withResolvers(); + const never = withResolvers(); + // One journal, and one directory for both attempts: a launch retains the + // directory it was asked for, and a second one would replay nothing. + const dir = path.join(os.tmpdir(), `xmd-gn-${randomUUID()}`); + yield* ensure(() => rm(dir, { recursive: true, force: true })); + + const interrupted = yield* runJourney({ + source: ONE_PANE, + stream, + dir, + children: 1, + child: () => + (function* () { + live.resolve(); + // Never returns: the run is halted with the child still going. + yield* never.operation; + })(), + interruptWhen: () => live.operation, + }); + + expect(interrupted.launches.length).toBe(1); + const native = interrupted.launches[0]?.command.at(-1); + expect(native).toBeDefined(); + // The launch got as far as handing the session over, and no further. + const crashed = launchRecords(interrupted.events).map((entry) => entry.name); + expect(crashed.some((name) => name.endsWith("/prepared"))).toBe(true); + expect(crashed.some((name) => name.endsWith("/detached"))).toBe(true); + expect(crashed.some((name) => name.endsWith("/exited"))).toBe(false); + const before = preparations(interrupted.events)[0]; + expect(before).toBeDefined(); + + const resumed = yield* runJourney({ source: ONE_PANE, stream, dir, children: 1 }); + + expect(resumed.result.ok ? "" : resumed.result.error.message).toBe(""); + // A fresh composite was built for the pane that had not finished. + expect(resumed.grids).toBe(1); + expect(resumed.composite[0]).toBe("prepare:0:1x1"); + // The native child started again, on the identity the first attempt + // retained — not on a conversation this run made. + expect(resumed.launches.length).toBe(1); + expect(resumed.launches[0]?.command.at(-1)).toBe(native); + expect(resumed.sessions.filter((report) => report.systemPrompt !== undefined)).toEqual([]); + // Nothing was prepared a second time, and everything the first attempt + // retained about how this session was made came back unchanged — the + // provider-native identity, the construction route, the executable + // binding and the phase itself. + const after = preparations(resumed.events); + expect(after.length).toBe(1); + expect(after[0]).toEqual(before); + // The resumed attempt is what added the exit. + const names = launchRecords(resumed.events).map((entry) => entry.name); + expect(names.filter((name) => name.endsWith("/prepared")).length).toBe(1); + expect(names.filter((name) => name.endsWith("/exited")).length).toBe(1); + }); }, ); From 1d9609d82e9ab37f82e65b2e8b40427e7b9055c0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:59:26 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Build=20the=20interrup?= =?UTF-8?q?ted=20run's=20outcome=20with=20Err=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness's interrupted branch built a `Result` as an object literal and cast it. Effection has a constructor for exactly that, so it uses it: no cast, and the type is the constructor's rather than an assertion's. Behavior and evidence are unchanged. --- packages/test-agent/tests/terminal-grid-native-launch.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts index 9d8907001..5fa6ba218 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -20,7 +20,7 @@ */ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, scoped, spawn, withResolvers } from "effection"; +import { ensure, Err, scoped, spawn, withResolvers } from "effection"; import type { Operation, Result, Task } from "effection"; import { copyFile, ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; @@ -323,7 +323,7 @@ function* runJourney(options: RunOptions = {}): Operation { yield* options.interruptWhen(order); yield* running.halt(); return { - result: { ok: false, error: new Error("interrupted") } as Result, + result: Err(new Error("interrupted")), results: yield* testing.results, launches, hostLaunches, From 8b8936c25c5b1c934f7ee1a4b003b8324c2e3452 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 21:34:39 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Acknowledge=20session=20quie?= =?UTF-8?q?scence=20from=20the=20launch's=20own=20cleanup=20(#731)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An orderly cancellation that finished proves everything a normal return proves, and must say so. It did not: `ownership.quiesced()` was a statement after `authority.perform()`, and cancellation unwinds past every statement after the operation it cancels. A reader closing a terminal grid therefore left every session its panes had launched carrying a recovery tombstone, and the next owner was told to recover a session nothing was using. The launch now runs in a scope the ownership body owns, and the acknowledgement is that scope's cleanup — reached on every path there is, cancellation included. It brings the launch down deliberately and reads the outcome of doing so, so the two facts it needs are facts rather than inferences: the native child and its cleanup settled, and this provider holds no handle for the session. A teardown that could not prove the child stopped throws out of `destroy()` and is not quiescence — and is still a failure, so it propagates rather than passing quietly. Nothing grid-specific reaches the provider. Reader close is the ordinary launch cancellation path, and this is the ordinary launch cancellation path's rule. The conservative cases keep their tombstone: a detach that failed or a session prepared and never handed over leaves a handle, and a child or provider cleanup that failed leaves the acknowledgement unmade. Cancellation, a released lease, a PID and elapsed time still prove nothing on their own. CX1 asserted the behavior this replaces — that a cancelled launch stays owned — so it now asserts the accepted one. CX2 is new and holds the other half: a cleanup that could not finish withholds quiescence, and the record stays active. GN8 is rebuilt as directed: two pane children held on unresolved operations, signals from each child's own teardown, teardown proven to finish after both, and a root launch afterwards on one of the same logical sessions that acquires ownership and starts — receiving neither session-busy nor session-recovery-required, and reclaiming the root foreground lease as it goes. Broken on purpose and re-run: acknowledging only on a normal return fails CX1 and GN8; acknowledging without proving the cleanup settled fails CX2, and only CX2. --- packages/acp/src/provider.ts | 66 ++++++++++---- packages/acp/tests/native-launch.test.ts | 75 +++++++++++++++- .../tests/terminal-grid-native-launch.test.ts | 87 ++++++++++--------- 3 files changed, 169 insertions(+), 59 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba90..e6e39d709 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -22,6 +22,7 @@ import { createChannel, + createScope, ensure, Err, Ok, @@ -2756,24 +2757,57 @@ function* useAcpxProviderState( // the reader's terminal while offering no way to reach the owner it // was waiting for. It refuses instead, and the coordinator is what // refuses it. - yield* authority.perform(request, { - prepare: () => - withSessionRoute(context, () => - prepareLaunch(invocation, agentName, callerCwd, request.instructions, placement), - ), - detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), - exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), + // + // The launch runs in a scope of its own so that this owner can bring + // it down deliberately and watch how that goes. A cancelled launch — + // the reader closing a terminal grid is one — unwinds past every + // statement after it, so a decision written down here would never be + // reached; written as this scope's cleanup, it is reached on every + // path there is. + const [running, stop] = createScope(yield* useScope()); + let stopped = false; + + yield* ensure(function* () { + // Registered after the scope exists, so it runs before the scope + // is destroyed on its own: the launch comes down here, and + // `destroy()` carries the outcome of its teardown. A child that + // could not be proven stopped, or a cleanup that failed, throws + // out of it — and is not quiescence, and is still a failure. + try { + yield* until(stop()); + stopped = true; + } finally { + // Everything this owner started has to be finished with the + // session, and that is two facts rather than one: the native + // child and its cleanup settled, and this provider holds no + // handle for the session — a detach that failed, or a session + // prepared and never handed over, leaves one. Either one + // missing leaves the session owned rather than looking + // finished, which is what the next owner is told to recover + // deliberately. + if (stopped && !holding(placement.sessionKey)) { + ownership.quiesced(); + } + } }); - // Only here, and only once this provider is holding nothing. By the - // time `perform` returns the native child has exited and been reaped, - // so what is left to check is the ACP handle: a handoff that released - // it quiesces, and one that could not — a detach that failed, a - // session prepared but never handed over — leaves the session owned - // rather than looking finished. - if (!holding(placement.sessionKey)) { - ownership.quiesced(); - } + yield* running.run(() => + authority.perform(request, { + prepare: () => + withSessionRoute(context, () => + prepareLaunch( + invocation, + agentName, + callerCwd, + request.instructions, + placement, + ), + ), + detach: (prepared) => + detachSession(invocation, prepared, agentCommandOf(placement)), + exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), + }), + ); }, ); } catch (error) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 7c3df4976..3984b860e 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -27,7 +27,12 @@ import type { PreparedLaunchRecord, Session, } from "@executablemd/core"; -import { flushOutput, installControlledLauncher, reserveTerminal } from "@executablemd/runtime"; +import { + flushOutput, + installControlledLauncher, + NativeLauncher, + reserveTerminal, +} from "@executablemd/runtime"; import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; @@ -206,6 +211,14 @@ interface ProviderOptions { withSessionRoute?: AcpxProviderDependencies["withSessionRoute"]; /** Blocks the native child until this resolves. */ hold?: Operation; + /** + * Make the launch's own teardown fail, in place of a child that cannot be + * proven stopped. + * + * Composed in front of the launcher rather than replacing it, so what fails + * is the cleanup of a launch that was otherwise ordinary. + */ + cleanupFails?: string; onLaunch?: () => void; exitCode?: number; /** @@ -328,6 +341,20 @@ function* installLaunchStack( outcome: () => ({ exitCode: options.exitCode ?? 0 }), }); + if (options.cleanupFails !== undefined) { + const reason = options.cleanupFails; + yield* NativeLauncher.around({ + *launch([request, spawned], next) { + // Registered inside the launch, so it unwinds with it — and refuses to + // say the child is gone. + yield* ensure(function* () { + throw new Error(reason); + }); + return yield* next(request, spawned); + }, + }); + } + const factory = createAcpxProvider({ createRuntime: harness.create, sessionStore: options.store ?? makeStore(), @@ -2518,11 +2545,51 @@ describe("Tier CX — cancellation before ownership ends", () => { ), ), ).toBe(false); - const released = trace.ownership.events.indexOf("released-active"); + const released = trace.ownership.events.indexOf("released-idle"); expect(trace.ownership.events.indexOf("cancelling") < released).toBe(true); - // A launch that stopped on the way never proved the session stopped, so it - // stays owned rather than looking finished. + // An orderly stop that finished is a stop. The child was proven gone, its + // cleanup settled, and this provider held no handle for the session — so + // nothing this owner started can still act on it, which is exactly what + // quiescence acknowledges. Withholding it here would leave a recovery + // tombstone for a cancellation that had already proved everything a normal + // return proves. + expect(trace.ownership.events).toContain("quiesced"); + expect(trace.ownership.events).not.toContain("released-active"); + }); + + it("CX2: a cancellation whose cleanup could not finish stays owned", function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const hold = withResolvers(); + const started = withResolvers(); + let halting = ""; + + yield* scoped(function* () { + yield* installLaunchStack(harness, trace, { + routeStore: createMemorySessionRouteStore(), + cleanupFails: "the native child could not be proven stopped", + hold: (function* () { + started.resolve(); + yield* hold.operation; + })(), + }); + + const launching = yield* spawn(() => Agent.operations.launch(launchRequest(INSTRUCTIONS))); + yield* started.operation; + try { + yield* launching.halt(); + } catch (error) { + halting = error instanceof Error ? error.message : String(error); + } + }); + + // The teardown failed, and said so rather than passing quietly. + expect(halting).toContain("could not be proven stopped"); + // So nothing was acknowledged: a cancellation is not evidence on its own, + // and neither is the lease coming back. The session stays owned, and the + // next owner is told to recover it deliberately. expect(trace.ownership.events).not.toContain("quiesced"); + expect(trace.ownership.events).toContain("released-active"); }); }); diff --git a/packages/test-agent/tests/terminal-grid-native-launch.test.ts b/packages/test-agent/tests/terminal-grid-native-launch.test.ts index 5fa6ba218..497749a80 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -20,7 +20,7 @@ */ import { beforeAll, describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, Err, scoped, spawn, withResolvers } from "effection"; +import { ensure, Err, scoped, spawn, suspend, withResolvers } from "effection"; import type { Operation, Result, Task } from "effection"; import { copyFile, ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; @@ -265,6 +265,10 @@ function* runJourney(options: RunOptions = {}): Operation { *onAttach() { order.push("attach"); }, + // deno-lint-ignore require-yield + *onDestroy() { + order.push("destroy"); + }, onUpdate(ordinal: number, state: TerminalPaneState) { states.push(`${ordinal}:${state}`); options.onState?.(ordinal, state); @@ -444,9 +448,9 @@ const CLOSE_THEN_CONTINUE = [ "", "", "", - '', - "You are the repository planner.", - "", + // The same prepared instructions the pane launched, so this is the same + // conversation continuing rather than a second one asking for the name. + 'You are the repository planner.', "", "", "", @@ -688,63 +692,68 @@ describe( expect(run.results.filter((result) => result.status === "fail").length).toBe(1); }); - it("GN8: reader close cancels every live launch and gives both leases back", function* () { - const closing = withResolvers(); - const bothLive = withResolvers(); - let live = 0; + it("GN8: reader close finishes both launches, and the document goes on", function* () { + const bothStarted = withResolvers(); + let started = 0; const run = yield* runJourney({ source: CLOSE_THEN_CONTINUE, children: 2, child: (_marker, marks) => (function* () { - live++; - if (live === 2) { - bothLive.resolve(); + started++; + if (started > 2) { + // The launch after the grid. It is the sibling this row is + // waiting to see run, so it runs. + return; + } + if (started === 2) { + bothStarted.resolve(); } - // Held until the reader leaves, and then cancelled through the - // ordinary launch path rather than returning an outcome. try { - yield* closing.operation; + // Nothing here ever completes it. The only thing that stops this + // child is the reader closing the grid, so a close that did not + // cancel it would hang this row rather than pass it. + yield* suspend(); } finally { - marks.push("cancelled"); + // Reached as the child is torn down: this is the child actually + // being gone, not the request that it stop. + marks.push("gone"); } })(), close: (marks) => (function* () { - yield* bothLive.operation; + yield* bothStarted.operation; marks.push("close"); - closing.resolve(); })(), }); - // Both launches were cancelled where they stood, and neither pane failed: - // a reader leaving is not a pane failure. - expect(run.order.filter((mark) => mark === "cancelled").length).toBe(2); + // Both children were cancelled and both are gone, and neither pane + // failed: a reader leaving is not a pane failure. + expect(run.order.filter((mark) => mark === "gone").length).toBe(2); expect(run.states.filter((state) => state.endsWith(":failed"))).toEqual([]); - // Which panes, not how many messages — see GN7. expect(new Set(run.states.filter((state) => state.endsWith(":closed")))).toEqual( new Set(["0:closed", "1:closed"]), ); - // The composite came down before the document went on. - expect(run.composite).toContain("destroy:0"); + // Teardown finished after they were gone, not merely after they were + // asked to stop. + const destroyed = run.order.indexOf("destroy"); + expect(destroyed).toBeGreaterThan(-1); + expect(run.order.lastIndexOf("gone")).toBeLessThan(destroyed); // The sibling after the grid is a *root* launch naming a session one of - // those panes was holding, so it needs both leases back: the run's - // foreground terminal, and that session's ownership. - // - // Which refusal it gets is what proves it got them. A grid still holding - // the terminal refuses with "already holds this run's terminal"; a - // session still held refuses with "another owner is using session". It - // reaches neither. What it reaches is the #517 recovery tombstone — a - // native UI that was cancelled never proved it stopped, so the record - // stays active and the next owner is told to recover it deliberately - // rather than inferring safety from a lock being free. - const message = run.result.ok ? "" : run.result.error.message; - expect(message).toContain("was left owned by work that did not finish"); - expect(message).not.toContain("already holds this run's terminal"); - expect(message).not.toContain("another owner is using session"); - // And it started nothing: the refusal comes before a native child. - expect(run.launches.length).toBe(2); + // those panes was holding. It needs three things back: the run's + // foreground terminal, that pane's terminal, and that session's + // ownership — and it gets them, so the grid released every one. + expect(run.result.ok ? "" : run.result.error.message).toBe(""); + expect(run.results.map((result) => result.status)).toEqual(["pass"]); + expect(run.launches.length).toBe(3); + // Neither refusal: not one still held by another owner, and not one left + // owned by work that did not finish. An orderly close that finished is a + // finish, and the session it used is ordinarily usable afterwards. + const written = JSON.stringify(run.results); + expect(written).not.toContain("another owner is using session"); + expect(written).not.toContain("was left owned by work that did not finish"); + expect(written).not.toContain("already holds this run's terminal"); expect(run.hostLaunches).toEqual([]); });