diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba9..e6e39d70 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 7c3df497..3984b860 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/core/src/expand.ts b/packages/core/src/expand.ts index 16b03c1e..88635900 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 00000000..68c01daf --- /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 cc87cbfa..ade5e05b 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"; @@ -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,221 @@ 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); + }); + + /** + * 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 childLive = withResolvers(); + const childMayExit = withResolvers(); + const unwinding = withResolvers(); + const release = withResolvers(); + const asked: string[] = []; + + yield* scoped(function* () { + yield* installControlledLauncher({ + wait: () => + (function* () { + 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: "." }); + }); + }); + + // 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(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); + }); +}); + /** * 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 dc875fad..f719ba94 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 cd6bc1b5..9ba5d1e1 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 00000000..78811170 --- /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 00000000..6b250408 --- /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.reviewer.md b/packages/test-agent/src/TerminalGridNativeLaunch.reviewer.md new file mode 100644 index 00000000..61ff6c2c --- /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 new file mode 100644 index 00000000..0996ea3a --- /dev/null +++ b/packages/test-agent/src/TerminalGridNativeLaunch.test.md @@ -0,0 +1,72 @@ +# 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, and the fourth pane's shell is the same kind of +fiction. + + + + + + +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. + + + + + +You are the repository planner. + + + + +You are the repository implementor. + + + + +You are the repository reviewer. + + + + + +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? + + + +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 00000000..497749a8 --- /dev/null +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -0,0 +1,838 @@ +/** + * 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 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 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 { beforeAll, describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +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"; +import * as os from "node:os"; +import * as path from "node:path"; +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 { + 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"; +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"; + +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); + +/** 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[]; + /** 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[]; + /** 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 child does once it has started. + * + * 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. + */ + 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 { + const launches: NativeLaunchRequest[] = []; + 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 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* () { + yield* everyChild.operation; + })()); + + /** 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) { + 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); + } + + return yield* scoped(function* () { + yield* API.Env.around({ + // deno-lint-ignore require-yield + *cwd() { + return docDir; + }, + }); + 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: (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: (asked) => hostLaunches.push(asked), + outcome: () => ({ exitCode: 0 }), + }); + + if (options.provider !== false) { + // 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([asked]) { + grids++; + const composite = yield* prepareControlledComposite(asked, { + log: providerLog, + 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 + *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); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= panes) { + settled.resolve(); + } + } + }, + // 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(asked, 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() }, + ]); + + 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: Err(new Error("interrupted")), + 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) { + next = yield* subscription.next(); + } + return { + result: yield* execution, + results: yield* testing.results, + launches, + hostLaunches, + sessions, + events: yield* stream.readAll(), + composite: providerLog.events, + states, + ...(request === undefined ? {} : { request }), + grids, + order, + }; + }); + }); +} + +/** 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" + ? [{ 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 = [ + "", + '', + "", + '', + "", + '', + 'You are the repository planner.', + "", + '', + 'You are the repository planner.', + "", + "", + "", + "", + "", +].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.', + "", + "", + "", + // 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.', + "", + "", + "", +].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: 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"]); + + // 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); + }); + + 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).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", "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, 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(preparations(run.events).length).toBe(3); + }); + + 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(3); + }); + + 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, children: 2 }); + + 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.value).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([]); + }); + + 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 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* () { + 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(); + } + try { + // 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 { + // 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* bothStarted.operation; + marks.push("close"); + })(), + }); + + // 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([]); + expect(new Set(run.states.filter((state) => state.endsWith(":closed")))).toEqual( + new Set(["0:closed", "1:closed"]), + ); + // 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. 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([]); + }); + + 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); + }); + }, +);