From ce5f52bd393de0a58b7453bdd059bccca1b4144e Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:41:35 -0400 Subject: [PATCH 01/62] =?UTF-8?q?=F0=9F=93=9D=20Define=20interactive=20ter?= =?UTF-8?q?minal=20grid=20architecture=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 277 +++++++++++++++++- specs/executable-mdx-spec.md | 330 +++++++++++++++++++++- specs/native-agent-session-launch-spec.md | 204 +++++++++++-- 3 files changed, 777 insertions(+), 34 deletions(-) diff --git a/architecture.md b/architecture.md index 99f35f838..97dbfc71d 100644 --- a/architecture.md +++ b/architecture.md @@ -108,8 +108,11 @@ Existing documents and code get aligned to this section retroactively. | session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | -| foreground-terminal lease | the one exclusive claim on the run's terminal a native launch holds. A host with no terminal refuses it, and two launches cannot hold it at once even when they name different sessions | -| native launcher | the host-owned seam that reserves that lease, flushes what the document has produced, spawns one native UI with the terminal inherited, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | +| foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | +| terminal grid | one provider-neutral foreground region whose direct terminal panes begin concurrently, remain independently interactive, and settle under one scope after complete provider and pane teardown | +| terminal pane | one authored position in a terminal grid, identified structurally by its grid and ordinal and presented by its authored title. It owns one interactive terminal at a time; a paired pane expands its own document flow and a self-closing pane runs the host's default shell | +| pane-terminal lease | the exclusive claim one live interactive operation holds on one terminal pane. Claims in different panes do not contend; two claims in one pane do. It is minted and validated by the host's terminal authority and grants no authority over an Agent session | +| native launcher | the host-owned seam that reserves the foreground terminal or the current pane terminal, flushes what that terminal has pending, starts one native UI there, and reports its terminal status and nothing else. It is not `exec`, whose children are piped, captured and journaled | | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | @@ -2870,6 +2873,273 @@ inside the run's existing deadline rather than opening a lifecycle of its own, and a waiting read is cancellable: cancellation tears the reader down and stays cancellation, never a read failure. +## Interactive terminal grids + +An executable document can replace its one foreground terminal with one +provider-neutral grid of independently interactive terminal panes: + +```md + + + + + Implement the accepted plan. + + + + + + + Review the implementation against the plan. + + + + + +``` + +`Terminal` names the interactive endpoint the document requires. It does not +name the presentation technology: a tmux integration, another terminal +multiplexer, and a host-native composite UI are providers for the same +contract. A component that elicits values through a terminal UI is a different +abstraction, just as `` is one presentation for ``; it does not +change what an interactive process requires here. + +The grid and its panes are core-owned structural syntax. `` is +paired, requires a positive integer `columns`, and contains at least one direct +`` child. Whitespace may separate those children, but ordinary text, +dynamic control structures, and every other direct element are invalid. A +pane requires a non-empty `title`; titles are display labels and need not be +unique. Its ordinal among the direct children is its structural identity. +Rows are derived in row-major order from the pane count and columns. A paired +pane expands ordinary document flow; a self-closing pane runs the host's +default shell. A nested grid and a `` outside a grid are invalid. +Neither form accepts a provider, executable, shell, layout identifier, or +`as`, and neither renders or returns document content. + +Core ownership is necessary here. An ordinary function component receives its +content as one rendered string, after the effects in that content have already +run; it cannot inspect direct authored children or begin them concurrently. +Core instead validates the complete static grid before any provider contact, +then creates one durable child operation per pane in authored order. Each pane +gets an isolated binding and evaluation scope. It inherits the bindings, +contextual providers, working directory, and configuration visible at the grid +site, while bindings and contextual changes made in one pane remain there and +are discarded when it settles. A pane cannot observe a sibling's writes, and +`` or `` inside a pane cannot transfer control to a construct +outside that pane. Each pane also owns its checked-failure ledger. A checked +failure settles that pane without poisoning the root or a sibling; core alone +observes the pane outcome and applies the grid's settlement rule after close. + +### Terminal authority + +One grid holds the execution's foreground-terminal lease for its whole visible +lifetime. A root `` and a grid therefore contend for the same +host resource; neither can begin while the other holds it. After taking the +lease, core flushes root document output before provider preparation. Output +displayed by a pane is routed to that pane's terminal and is not copied into the +root document output or a capture around the grid. Nested effects keep their own +ordinary result and retention contracts; terminal bytes from a native Agent UI +or default shell are never captured or journaled. The grid renders nothing, and +root document output resumes only after the provider has restored the root +terminal. + +The host owns one non-contextual terminal authority, built and delivered +directly to the installed provider. It validates the exact grid request and +provider installation generation, mints one-use claims for the authored pane +ordinals, and is the only capability that can take or release the root and pane +terminal leases. No context value, prop, binding, provider result, retained +record, diagnostic, or structurally similar request carries that authority. + +The stable contextual terminal API is request routing only. Middleware may +observe, narrow, refuse, wrap, or delegate a one-use request. A handler's +return value is ignored, and answering without delegation authorizes and +settles nothing. Core supplies the one request for the exact expansion; the +provider factory closes over the direct authority and must present that same +request to act. This preserves provider composition without letting a document +or replacement context mint terminal ownership. + +A pane claim grants one interactive terminal at that ordinal, not an Agent +session. Core installs a pane-scoped native launcher that closes over the claim. +`` in that pane consequently reserves, flushes, and launches on +the pane terminal instead of competing for the root lease. Launches in +different panes may run concurrently; two interactive launches in one pane +cannot. Sequential launches in one paired pane remain ordinary composition. +The session coordinator is unchanged and independently authoritative, so two +panes attempting to own the same logical Agent session still contend and one +is refused. The provider starts a self-closing pane's host-configured default +shell under the same kind of pane claim. + +Each claim also closes over one host-owned readiness latch. The pane-scoped +native launcher acknowledges it from the runtime's successful child-spawn event +and before it waits for exit; failed preparation, reservation, or spawn never +acknowledges it. Allocation of a PID and the child's first output are not this +event. The self-closing shell path acknowledges the same boundary. The latch is +not a request member, contextual value, provider return, public event, or +process handle, and acknowledging it twice has no effect. A root launch has no +grid readiness latch. This is how the grid observes successful interactive +start without changing `Session.Launch`'s result or exposing a child process. + +A provider whose pane endpoint is owned by a persistent process routes child +creation through that process. The launch's exact argv vector, working +directory, and environment cross a provider-private authenticated channel; +they never pass through the presentation provider's command language. The pane +owner creates the child with all three standard streams inherited from the pane +terminal, reports the runtime spawn event, and remains only the lifecycle and +display owner. It writes provider display messages to the terminal but never +reads terminal input, so interactive input belongs to the foreground child. +It admits one live launch at a time and releases the pane only after that +launch's observable terminal ownership has been swept. Sequential launches use +the same pane owner and endpoint rather than replacing the pane. + +Provider-specific commands, socket paths, session names, window identifiers, +pane identifiers, attach keys, and process topology remain private inside the +provider closure. They appear in no authored surface, durable identity, result, +or diagnostic. Provider-neutral diagnostics identify a grid expansion and pane +ordinal or title only. A provider may show sanitized pane status, but core owns +the operation result; presentation never decides whether a pane or grid +succeeded. Core sends the provider only the closed presentation states +`starting`, `running`, `succeeded`, `failed`, and `closed`: readiness moves a +pane to `running`, pane settlement supplies `succeeded` or `failed`, and a live +pane cancelled solely by reader close becomes `closed` rather than failed. + +### Atomic presentation and settlement + +The grid runs as one structured scope: + +1. Core validates the whole structural layout, takes the foreground-terminal + lease, and flushes root output. +2. The provider checks its live prerequisites and prepares the entire hidden + composite: every pane endpoint, its supervision, and the default shell where + requested. No grid is attached yet. +3. Core starts the pane child operations concurrently, using deterministic + durable child identities derived from the grid expansion and authored + ordinal. A paired pane begins its document flow and a self-closing pane + begins its shell. +4. A pane is ready only when its interactive child emits the runtime's + successful spawn event. Reserving an endpoint, allocating a process + identifier, or receiving output is not readiness. A child that starts and + exits immediately can be both ready and settled. +5. Only after every pane reaches readiness does the provider attach the one + composite presentation. Any preparation or pane-start failure before this + barrier cancels every pane, awaits complete teardown, discards the hidden + composite, and fails without exposing a partial grid. Agent preparation or + retained route work that occurred before a failed native spawn remains + durable; atomicity covers terminal presentation and lifecycle, not rollback + of earlier provider effects. +6. Once attached, each pane settles independently and keeps its final status + visible while siblings continue. The composite remains present after all + panes settle until the reader closes or leaves it. +7. Closing begins an ordered teardown: prevent new pane launches, cancel live + pane scopes, await every child and finalizer, detach and destroy the exact + provider composite, restore the root terminal, and only then release the + foreground lease and settle the grid. The document never continues while an + observable pane child or provider-owned process can still act through the + grid. + +Parent cancellation follows the same teardown from preparation, readiness, or +the active grid and remains cancellation. A provider or host failure cancels +the whole grid and is the grid's canonical failure. An ordinary pane failure +after attachment is contained as that pane's status and does not cancel its +siblings. When the reader closes the grid, core fails it with the first failed +pane in authored order; cancellation initiated by grid teardown is not a pane +failure. If no pane failed, the grid succeeds and document execution continues. +Where several startup failures are observed together, authored pane order also +selects the reported one. Existing fatal infrastructure and cleanup precedence +still applies, and teardown is attempted for every acquired resource regardless +of the selected result. + +There is no implicit grid timeout. An enclosing run deadline or parent +cancellation bounds it. Before sending the first cancellation signal for a +launch, the provider snapshots the child descendants and every other member of +the pane process group that the host can observe. It establishes that the +child, those descendants, and those group members stopped. Before reporting +that launch settled and admitting a sequential launch, the pane owner also +establishes that no other process holds the pane terminal. Grid teardown repeats +that terminal-holder sweep while the pane owner still keeps the terminal +observable, then establishes that every pane owner, attachment, control client, +and multiplexer server stopped and that the provider's private paths were +removed. A PID, elapsed timeout, disappearance of one attach client, or a +successful signal delivery establishes none of those facts. A failed proof is +a teardown failure and the document does not continue. + +There is one deliberate boundary to that claim. On macOS, a descendant that +creates a new session, closes every descriptor for the pane terminal, and +outlives the parent that connected it to the launch is no longer discoverable +from process ancestry, process group, or terminal holders. The provider does +not claim to find or terminate such a detached daemon. It does prove that +nothing remains in the pane's process group, nothing remains descended from a +child that was alive when cancellation began, and nothing holds the pane +terminal. A launch that needs stronger descendant accountability must retain an +observable ownership mechanism of its own instead of severing all three links. + +### Durability and replay + +A terminal grid is a core-owned structured durable region. Its layout identity +contains the columns and the ordered pane forms and titles, never a provider or +live terminal identifier. Each pane is a deterministic durable child coroutine, +so effects in paired content retain and replay under the same rules they use +outside a grid. A self-closing shell is a terminal child effect that retains +only provider-neutral start and exit status; its executable, argv, environment, +terminal bytes, and conversation history are live-only. + +The completed grid record retains the provider-neutral layout, close kind, and +ordered pane outcomes after the normal secret gate. Completed replay claims the +whole region and returns its retained outcome without installing or contacting +a terminal provider, starting a shell, expanding pane content, acquiring an +Agent session, or launching a native UI. The structured durable boundary owns +that short circuit; a public replay context does not. + +Partial replay first compares the exact authored layout and refuses divergence +before provider work. It rebuilds a fresh provider composite: completed pane +children are restored as settled statuses without re-running their effects, +while incomplete children replay or start their remaining work. An incomplete +`` preserves the prepared/detached identity rules of its own +contract; placing it in a pane neither allocates a replacement session nor +weakens session ownership. An incomplete self-closing shell starts the current +authorized default shell and makes no claim to resume its prior terminal +history. Provider identifiers are recreated live and are never reconciled with +a journal. + +The first production provider uses tmux where the Deno or compiled host has a +foreground terminal and the required tmux capability. It prepares one private +tmux server per grid and starts one persistent pane worker as each pane's +initial process and session leader. Each worker connects over its pane's Unix +socket in an invocation-private mode-0700 directory, authenticates once with a +mode-0600 token that is then removed, and receives only provider lifecycle, +display, and exact child-launch messages. The directory path stays short enough +for the host's Unix-socket limit. A worker runs under Effection's `run()`, not +`main()`: `main()` consumes `SIGINT`, while the worker must remain alive as +terminal job-control signals reach its foreground child. Loss of the root +host's terminal is translated from `SIGHUP` into ordinary grid cancellation so +the hidden server cannot outlive its supervising run. + +The provider creates the panes, installs the exact explicit row-major layout, +and then swaps pane positions into authored order; tmux layout strings do not +honor the pane IDs written in their leaves. One no-output control-mode client +observes server and client events independently of the inherited-stdio visible +attach client. This lets reader detach, control-channel loss, and server stop +remain three different events. The visible client attaches only after the +readiness barrier and is detached before cancellation signals are sent so it +can restore the root terminal. Teardown quiesces every worker, performs the +per-pane terminal-holder proof, closes the private channels, stops the server, +establishes both server-process disappearance and session refusal, and removes +the private directory. + +The proof enumerates terminal holders with `lsof -t`. That establishes the +required boundary but is not the provider contract: on the measured macOS host +it costs about 0.4 seconds per call, and the median exit-to-`exited` handoff grew +from 616 milliseconds at two panes to 1.5 seconds at eight. Terminal-holder +enumeration therefore belongs behind the host process adapter so a cheaper +primitive can replace it without changing readiness, pane reuse, or teardown +semantics. Optimization may remove that latency; it may not admit the next +child before the same quiescence fact is established. + +The same authored syntax remains provider-neutral: a host without that +provider, including Node and Bun until they install one, refuses before pane +start. Tests install a controlled non-tmux provider to prove that core grammar, +authority, lifecycle, settlement, and replay do not depend on tmux behavior. + ## Contextual run configuration Nothing has a timeout by default. Three contextual values bound three different @@ -4134,7 +4404,8 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; the run's one foreground-terminal lease is taken before an agent is resolved, so a host with no terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 35d134826..313aae121 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2652,11 +2652,11 @@ A component name is resolved in tiers, and the first tier that answers wins: 1. **structural syntax** — ``, ``, ``, ``, ``, ``/``, ``/``, ``/``, - ``, ``/``. These are the language's own - constructs. They are reserved: a registration cannot claim one, and a - repository file named after one never stands in for it. A structural name - written where its construct gives it no meaning is a printed error, not a - missing component. + ``, ``/``, and + ``/``. These are the language's own constructs. + They are reserved: a registration cannot claim one, and a repository file + named after one never stands in for it. A structural name written where its + construct gives it no meaning is a printed error, not a missing component. 2. **a component canonical core protects** — the engine's own claim rather than a host's, so the name means the same thing in every execution. `` (§5.3.1) is the one member. The table is the resolver's own and is consulted @@ -8918,6 +8918,297 @@ Its skipped body is absence, not a retained decision. Replay of a completed root is unchanged, and a live or partial expansion reads the value that applies to that execution. +### 6.21 Opening concurrent terminal panes: `` and `` + +Use a terminal grid when several interactive tools must remain available at the +same time in one foreground view: + +```md + + + + + Implement the accepted plan. + + + + + + + Review the implementation. + + + + + + + Run the focused verification and repair failures. + + + + + +``` + +The example opens a two-column, two-row foreground grid. The first three panes +expand their document content concurrently and the fourth runs the host's +default shell. A `` keeps the provider's native Agent UI; the +grid does not proxy prompts or replace it with an XMD chat surface. Each pane +can finish while the others keep running, and its final status stays visible +until the reader closes the grid. + +`Terminal` names an interactive terminal endpoint, not tmux. The document asks +for panes and their authored layout; the host chooses the presentation provider. +There is no provider, multiplexer, executable, shell, socket, session, window, +pane-ID, attach-key, or teardown prop. A terminal-native input component may be +a presentation for `` in its own right; it does not change this process +terminal contract. + +#### Forms and props + +`` has exactly one paired form: + +```md + + + + + +``` + +Its closed props schema contains one required `columns` value, which must +resolve to a positive integer. The grid requires at least one pane. Columns do +not have to divide the pane count; rows are derived by placing direct panes in +authored row-major order and leaving unused positions at the end of the last +row. + +`` has two forms and one required prop: + +```md +... + +``` + +`title` must resolve to a non-empty string. It is a display label rather than +identity, so duplicate titles are allowed. A paired pane expands its content as +ordinary sequential document flow. A self-closing pane starts the default +interactive shell configured by the host. The shell choice is live host policy, +not document data. + +Neither element accepts `as`. Neither renders content into the surrounding +document or returns a value. Text that a paired pane renders is displayed in +that pane; it is not concatenated into the root's rendered result. Effects +inside paired content still keep their own ordinary return and durability +contracts. In particular, an executable block retains the process result its +execution profile normally retains, while bytes from a native Agent UI or +default shell are never captured or journaled by the grid. + +#### Structural placement + +Both names are reserved core structural syntax rather than registered or +repository-overridable components. A function component receives rendered +content after its effects have happened and therefore cannot define this +concurrent direct-child boundary. + +Only direct `` children may appear in a grid. Whitespace between panes +is allowed; ordinary Markdown text and every other direct element are refused. +A control structure such as `` or `` cannot dynamically produce the +direct panes. Put control flow inside a paired pane instead. `` outside +a grid, a nested ``, a self-closing grid, paired content on the +self-closing pane form, and a grid with no pane are invalid. + +Syntax validation checks the two names, closed props, authored forms, placement, +and direct-child structure without opening a terminal, looking for tmux, +choosing a shell, resolving an Agent, or installing a provider. Runtime resolves +prop expressions and validates the complete concrete layout before the terminal +provider is contacted. The syntax catalog includes both structural entries and +their two distinct forms even on a host that has no operational grid provider; +catalog inspection performs no availability probe. + +#### Pane scope and output + +Each pane begins as a deterministic child of the grid, in direct-child order. +The children are started concurrently. Scheduling order does not change their +identity or the order in which simultaneous failures are reported. + +A paired pane inherits the bindings, contextual providers, configuration, +working directory, and repository selection visible where the grid is written. +It then gets its own binding and evaluation overlay. A binding or contextual +change made by one pane is visible to later work in that pane and not to any +sibling; all such changes are discarded when the pane settles. Pane content +cannot `` a loop outside the pane or `` from an enclosing +component. A component invoked inside the pane still sees the pane's inherited +and local scope through ordinary content projection. Each pane has its own +checked-failure ledger as well: a checked failure settles that pane and cannot +poison the root or a sibling. Core reads those isolated outcomes when it applies +the grid's close rule; no provider or ordinary component can grant that +containment. + +Before the grid opens, root output is flushed. Display produced by pane content +is routed to that pane and is not copied into the root document output or a +capture around the grid. The grid itself renders `""`. Only after the provider +has torn down the composite and restored the root terminal can a following +sibling render to the root again. + +#### Readiness and the visible lifetime + +Opening a grid is atomic from the reader's perspective: + +1. Core validates the whole layout and acquires the root foreground-terminal + lease. Another root native launch or terminal grid cannot hold it at the same + time. +2. The provider validates its live prerequisites and prepares every terminal + endpoint in a hidden composite. It presents nothing yet. +3. All authored pane children begin concurrently. A self-closing pane starts its + shell. A paired pane expands until it starts its first interactive child, + normally ``. +4. A pane reaches readiness only when that interactive child emits the + runtime's child-spawn event. A paired pane that settles without starting one + fails startup. Merely allocating an endpoint or process identifier, or + receiving the child's first output, is not readiness; an interactive child + that starts and immediately exits is both ready and settled. +5. The provider attaches the complete composite only after every pane is ready. + +Successful child start is acknowledged through a private one-use latch held by +the pane terminal claim. The pane-scoped launcher acknowledges from the +runtime's spawn event and before waiting for exit; a startup error never +acknowledges. The self-closing shell does the same. The latch is absent for a +root launch and appears in no prop, binding, contextual API, public request, +provider return, process result, or durable record. + +When a persistent process owns a pane endpoint, the launcher sends the exact +argv vector, working directory, and environment over the provider's private +authenticated channel to that pane owner. The presentation provider's command +parser never sees those values. The pane owner creates the child with +stdin/stdout/stderr inherited from the pane terminal, forwards the runtime +spawn event to the readiness latch, and never reads terminal input itself. +Provider display text is written to the pane without becoming child input. + +A provider preparation failure, or a pane failure before every pane is ready, +cancels all pane scopes, awaits their finalizers, discards the hidden composite, +restores the root terminal, and fails without showing a partial grid. Effects +that finished before an interactive start failed keep their ordinary durable +records. Grid atomicity is not a transaction that rolls back Agent preparation, +files, commands, or other completed work. + +After attachment, a pane's normal exit or failure changes that pane's visible +status and does not cancel its siblings. Paired content may continue with later +sequential work after one interactive child exits, including another launch on +the same pane. The composite remains visible when all panes have settled. The +reader closes or leaves it to finish the grid. + +The provider receives presentation updates only as `starting`, `running`, +`succeeded`, `failed`, or `closed`. Readiness selects `running`; final pane flow +selects success or failure; a live pane cancelled only because the reader +closed the grid becomes `closed`. These states display core's result and never +author it. + +Close first prevents new pane launches, then cancels live pane scopes, awaits +every child and provider finalizer, destroys the exact composite, restores the +root terminal, and releases the foreground lease. Only then does the element +settle and a later document sibling begin. There is no implicit timeout; parent +cancellation and an enclosing execution deadline use the same complete teardown. + +#### Native launch ownership inside a pane + +Each pane receives a pane-scoped native launcher. A `` there +reserves and flushes that pane terminal rather than the root foreground lease. +Different pane terminals do not contend, so their native UIs may run +concurrently. Two live interactive launches in one pane contend; sequential +launches in it are allowed. + +A launch releases the pane only after its child and every observable process +related to that launch have stopped and no other process holds the pane +terminal. The release precedes the pane's terminal exit status and admission of +a sequential launch. A failure to establish that quiescence fails teardown and +keeps the pane unavailable. + +Terminal ownership does not grant Agent-session ownership. The native session +coordinator keeps its natural provider, agent, and logical-session key. If two +panes launch the same logical Agent session, one owns it and the other is +refused as busy exactly as it would be outside a grid. Grid placement does not +change Agent resolution, construction route, retained identity, instruction +layer, attachment, or cancellation semantics. + +#### Failure result + +Before attachment, simultaneous pane-start failures are selected in authored +pane order after every sibling has been cancelled and torn down. After +attachment, ordinary pane failures are contained as statuses so siblings can +continue. When the reader closes the grid, the first failed pane in authored +order fails the element; cancellation caused solely by closing the grid is not +counted as a failed pane. With no failed pane, close succeeds and the document +continues. + +A provider or host failure cancels the composite and is the grid failure. +Parent cancellation remains cancellation rather than becoming a pane failure. +All acquired resources are finalized even when an earlier failure already +decides the result, and the existing fatal-infrastructure and cleanup precedence +still applies. Before the first cancellation signal, the provider snapshots +every observable descendant of a live child and every member of its pane +process group. It establishes that those processes stopped, and while the pane +owner still makes its terminal observable it establishes that no other process +holds the terminal. It also establishes that its pane owners, attachment, +control client, and multiplexer server stopped. Disappearance of the attach +client, one process ID, a successful signal delivery, or a bounded amount of +time is insufficient proof. + +On a host that exposes only process ancestry, process groups, and open terminal +descriptors, this proof has an exact limit: a descendant that creates a new +session, closes the pane terminal, and outlives its parent has severed every +observable link. The provider makes no claim to discover or terminate that +detached daemon. It does prove that no process remains in a pane process group, +no process remains descended from a child alive when teardown began, and no +process holds a pane terminal. A provider that cannot establish those bounded +facts fails teardown. + +Diagnostics identify the source grid and a provider-neutral pane ordinal or +authored title. They contain no provider command, socket, server, session, +window, pane identifier, executable path, argv, environment, or terminal bytes. + +#### Durability and replay + +The grid is one structured durable region. Its identity includes the resolved +column count and ordered pane forms and titles. Each direct pane receives a +deterministic child-coroutine identity derived from the grid expansion and its +ordinal, never from its title, scheduling order, or provider layout. + +The completed region retains its provider-neutral layout, how it closed, and +the ordered pane outcomes after the ordinary secret gate. Completed replay +claims that whole region and restores its result without contacting a terminal +provider, creating a composite, starting a shell, expanding pane content, +resolving an Agent, taking session ownership, or launching a native UI. + +Partial replay compares the complete resolved layout first and refuses a +changed column count, title, form, count, or order before provider work. It then +builds a new live composite. Completed pane children appear as already-settled +statuses and perform no effects; incomplete children continue from their own +durable records. An incomplete `` keeps the exact +`prepared`/`detached` replay and logical-session identity rules defined by the +native launch specification. An incomplete self-closing pane starts the current +authorized default shell and does not claim continuity of shell process or +terminal history. + +No provider-specific layout identity is retained or reconciled. A tmux-backed +continuation, for example, creates fresh live tmux identifiers even if the prior +attempt used tmux too. The first production provider is installed for the Deno +and compiled foreground hosts when their terminal and tmux prerequisites are +available. It owns one private tmux server per grid and one persistent worker as +each pane's initial process. The workers authenticate once over per-pane Unix +sockets in a short mode-0700 private directory with mode-0600 tokens, run under +Effection's `run()` so the worker does not consume foreground `SIGINT`, and +accept only lifecycle, display, and exact launch messages. The provider uses an +explicit tmux layout and swaps panes into authored row-major order rather than +trusting layout-leaf pane identifiers. A no-output control-mode client observes +reader detach, control loss, and server stop independently from the visible +inherited-stdio attach client. It attaches visibly only after readiness, +detaches that client before sending cancellation signals, and translates root +host `SIGHUP` into structured grid cancellation. + +Node and Bun accept and validate the same syntax but install no provider and +therefore refuse before pane start. A controlled provider that is not tmux +exercises the same core contract in tests. + ## 7. Entry point @@ -10904,6 +11195,33 @@ Each row names the derivation it kills. | AF24 | A Session pins the exact value it was issued | A fresh `` calls `session()` once and hands the same object — by identity, not by key — to every `` nested inside it. A provider decides whether a session may be acted on by that identity, so a rebuilt look-alike is a value nobody issued | | AF25 | A fresh Session performs no provider effect | A self-closing `` places one and renders nothing: no prompt is started, and nothing about the placement appears in the document where the element stood | +### Tier TG — Terminal grids (§6.21) + +Core lifecycle rows use a controlled provider that is not tmux. Production +adapter rows use fake tmux processes and exact invocation-private handles; no +test derives a core result from a provider identifier. + +| # | Test | Verify | +|---|------|--------| +| TG1 | Frozen grammar | `Terminal.Grid` accepts only paired form with a positive integer `columns`; `Terminal` accepts paired and self-closing forms with a non-empty `title`; both reject unknown props and `as` | +| TG2 | Structural placement | An empty grid, direct text or non-pane element, a dynamically produced direct pane, a nested grid, and a pane outside a grid are refused before a provider call or body effect; whitespace between direct panes is inert | +| TG3 | Catalog and validation are inert | Both reserved entries and exact forms appear under structural syntax on every runtime; syntax and document validation contact no terminal provider, tmux, shell, Agent registry, or session coordinator | +| TG4 | Row-major layout | One through five authored panes under two and three columns produce the exact derived positions, keep duplicate titles, and derive identity from ordinal rather than title or scheduling | +| TG5 | Representative 2×2 journey | Three controlled native Agent sessions and one controlled default shell all start before the grid attaches, remain concurrently interactive, and use the authored row-major positions | +| TG6 | Isolated pane scopes | Every pane inherits the grid site's values, cwd, repository selection and providers; one pane's new bindings and contextual changes reach later work in that pane only, and its `Break` or `Return` cannot escape the pane | +| TG7 | Pane output | Rendered pane text reaches only that pane and the grid renders `""`; a surrounding capture gets no pane display; nested executable effects retain their ordinary results; native UI and shell bytes enter no capture, process journal or transcript | +| TG8 | Readiness barrier | Endpoint allocation, PID allocation, preparation, route publication, detach and first output are not ready; the runtime child-spawn event is. A paired pane that settles without one fails startup, and a child that spawns then exits immediately is ready and settled | +| TG9 | Atomic startup failure | Each provider-preparation position and each authored pane start can fail; no composite attaches, all started siblings and finalizers settle, completed earlier effects remain durable, the root terminal is restored, and simultaneous pane failures report the first authored ordinal | +| TG10 | Independent settlement | After attach, one pane can exit successfully or fail while siblings remain live and usable; its status stays visible. Closing a grid with failed panes reports the first failed authored ordinal, while teardown cancellation itself does not create a pane failure | +| TG11 | Terminal versus session ownership | Distinct pane leases permit concurrent native launches, one pane refuses overlapping launches, and a sequential launch is admitted only after the previous child, its observable descendants and group members, and every other holder of that pane terminal are gone; two panes naming one logical Agent session still contend through the unchanged non-waiting coordinator | +| TG12 | Reader close | Close prevents a later launch, cancels every live pane scope, awaits each child, shell and provider finalizer, destroys the exact composite, restores the root terminal, releases the foreground lease, and only then starts the following document sibling | +| TG13 | Cancellation and provider failure | Parent cancellation during prepare, readiness and active presentation follows complete teardown and remains cancellation; an active provider failure cancels every pane and fails the grid; cleanup is attempted for all resources under existing failure precedence | +| TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | +| TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | +| TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | +| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | + ### Tier CR — Component registration and resolution | # | Test | Verify | @@ -10942,7 +11260,7 @@ so the include-boundary rows are the same on every host. Defined in §5.3. | # | Test | Verify | |---|------|--------| | SY1/SY2 | Versioned shape | `version` is 1, the categories are the fixed tuple, and one structural, one registered and one repository entry appear together | -| SY3/SY4 | Structural vocabulary | The declarations are exactly the reserved names, each with authored forms and a description; `Let`, `Content`, `Else`, `Break`, `Answers` and `Answer` carry the frozen forms, and `as` applies to `Let` and `Each` alone | +| SY3/SY4 | Structural vocabulary | The declarations are exactly the reserved names, each with authored forms and a description; `Let`, `Content`, `Else`, `Break`, `Answers`, `Answer`, `Terminal.Grid` and `Terminal` carry the frozen forms, and `as` applies to `Let` and `Each` alone | | SY5 | Structural stays structural | A repository file named after a construct never moves it out of the structural category | | SY6/SY7 | Repository mapping | Direct `.md`/`.ts`, direct `index`, nested dotted and nested index paths describe names; a lowercase segment, an empty stem, a dotted stem and a dotted directory describe none, and the inversion is held to the single-segment grammar directly | | SY7c | Pruning | A lower-case, hidden or dotted directory is never read — at the top level or deeper — while the direct, nested and index candidates beside it stay discoverable; every skipped directory throws if it is read, and the recorded reads name only the ones a name reaches | diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index e7e076774..74c422702 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -439,10 +439,12 @@ Given `xmd AGENTS.md#Implementor`: 3. Target projection excludes sibling roles. 4. XMD expands the target and renders `Session.Launch` content completely. 5. File reads, captures, parsing, and deterministic evaluation finish or fail. -6. `Session.Launch` takes the run's one foreground-terminal lease. A host with - no terminal refuses here — before an agent is resolved, so learning that - this invocation cannot launch anything costs no availability probe. -7. The host flushes what the document has produced, so the native UI does not +6. `Session.Launch` takes its applicable terminal lease. At the document root + this is the run's foreground-terminal lease; inside `` it is that + pane's lease through the pane-scoped native launcher. A host with no + applicable terminal refuses here — before an agent is resolved, so learning + that this invocation cannot launch anything costs no availability probe. +7. The host flushes what that terminal has pending, so the native UI does not open over half-written output. 8. The provider resolves the logical Agent and Session against the contextual cwd, and takes exclusive ownership of that session. @@ -502,25 +504,26 @@ owner to release — what has to be settled first is which conversation this is: From there both rejoin: -13. The provider spawns the native UI as a foreground child with the terminal - inherited — resuming the native session ID for a provider-returned adapter, - and for a client-allocated one creating it under the allocated identity from - the private file, or resuming it by the same name when the route already - named it. +13. The provider spawns the native UI as an interactive child with the selected + root or pane terminal inherited — resuming the native session ID for a + provider-returned adapter, and for a client-allocated one creating it under + the allocated identity from the private file, or resuming it by the same + name when the route already named it. 14. `Session.Launch` suspends while the child runs. 15. The child handles prompts, tools, permission dialogs, rendering, and native transcript persistence directly. 16. When the child exits, XMD records its terminal outcome, removes the private file and its directory while ownership is still held, and releases the - terminal lease. The route and the retained phases stay: they are what the - next invocation resumes from. + selected terminal lease. The route and the retained phases stay: they are + what the next invocation resumes from. 17. Later Agent work depends on the route again. On an `acp-first` session it lazily reattaches through ACP to the same provider session. On a bound `client-native` route advertised for attachment, `` and `` follow *ACP attachment on a bound route* below and join the same provider conversation. A legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. -18. Document execution continues after `Session.Launch`. +18. Execution continues after `Session.Launch`: in root document flow outside a + grid, or in the sequential flow of its paired pane inside one. An enclosing `` or `` resolves by its own contract, before anything inside it runs, so a document that wraps a launch has already reached @@ -711,6 +714,88 @@ refuses an advertised agent that names its own sessions, on the same terms and before any provider effect; an agent whose provider returns the identity is unaffected, because it constructs nothing a route governs. +### Terminal-grid composition + +Terminal ownership and Agent-session ownership remain independent when a launch +is written inside ``: + +```text +grid foreground lease + ├─ pane 0 lease ─ native launch for logical session A + ├─ pane 1 lease ─ native launch for logical session B + └─ pane 2 lease ─ default shell + +session coordinator + ├─ natural key for logical session A + └─ natural key for logical session B +``` + +The grid owns the root foreground-terminal lease. The terminal authority mints +one private one-use claim per authored pane ordinal, and core installs a native +launcher in each pane scope that closes over that claim. `Session.Launch` uses +the launcher already in scope; it receives no pane prop, token, identifier, or +mode. The launcher validates the claim through the host's direct terminal +authority and reserves that pane for the launch. A claim from another grid, +provider installation generation, pane ordinal, or completed invocation +authorizes nothing. + +Different pane claims do not contend, so native launches in different panes can +hold their terminals concurrently. One pane remains exclusive: a second launch +cannot begin while the first is live there, and sequential launches work after +the first releases it. Release requires the child, its observable descendants +and process-group members, and every other holder of that pane terminal to be +gone; the pane remains busy if the launcher cannot establish those facts. A +root launch and a terminal grid contend for the root foreground lease, so +neither can overlap the other. + +None of that changes the coordinator key or acquisition. Two panes naming the +same provider, agent, and logical session still ask for one natural-key owner; +one succeeds and the other receives `session-busy` without waiting. Two distinct +sessions may be owned concurrently. A terminal claim grants no permission to +ensure, detach, create, resume, prompt, or attach to an Agent session, and a +session lease grants no terminal. + +The pane-scoped launcher keeps the same launch request and provider authority +division as the root launcher. Public middleware can route or refuse a request +but cannot settle it, replace the pane, or mint a launch. Provider-specific grid +or pane identities never enter the `AgentLaunchRequest`, terminal result, +`agent_session_launch` record, construction route, ownership key, diagnostic, +or private instruction file. + +The grid's readiness barrier observes the launch only at the existing successful +interactive-child start boundary. Session preparation, route publication, +private-file creation, and detach do not make a pane ready. If spawn fails, the +launch keeps the durable phases its contract already completed, fails the pane's +startup, and participates in the grid's atomic hidden teardown. The grid does +not roll those phases back. A child that successfully starts and exits before +the other panes become ready has nevertheless crossed readiness and retains its +ordinary exit outcome. + +The pane claim carries a private one-use readiness latch. The native launcher +acknowledges it from the runtime's child-spawn event and before waiting for +exit; allocating a PID or observing output is not readiness, and a startup error +never acknowledges. A root launch carries no such latch. It is not added to +`AgentLaunchRequest`, `AgentLaunchResult`, the public Agent Api, a retained +launch phase, or a process handle, so readiness composition changes neither the +launch's authored nor durable contract. + +Under the tmux provider the pane-scoped launcher sends exact argv, cwd, and +environment values over a private authenticated socket to the persistent pane +worker. The worker, not a tmux command line, creates the native child with all +three standard streams inherited from the pane terminal. It forwards the spawn +event, writes pane display without reading input, and refuses a concurrent +launch. It uses Effection's `run()` rather than `main()` so Effection does not +convert terminal `SIGINT` into worker exit 130 while the foreground child is +handling job control. + +After the grid is visible, a nonzero native exit fails its pane flow but does +not cancel sibling panes. Core keeps that failure as the pane's status and +selects the first failed pane in authored order when the reader closes the grid. +Grid-initiated close cancels a still-live launch through the ordinary launch +cancellation path, awaits session quiescence and native-child teardown, and does +not reclassify that cancellation as an independent pane failure. Parent +cancellation remains parent cancellation for the entire grid. + ## Construction route Ownership and construction answer different questions, and this contract keeps @@ -822,14 +907,17 @@ key, the route key and provider work. It is never resolved a second time inside ownership, because a registry free to answer differently would name a different session than the one this operation prepared. -V1 also holds one foreground-terminal lease for the root CLI execution. Two -native launches cannot concurrently own the same terminal, even when they name -different sessions. Sequential launches are ordinary document composition. - -Cancellation interrupts the native foreground process, establishes that it can -no longer execute or hold the terminal, restores terminal state, and runs every -provider finalizer. A process that ignores the initial interruption is -terminated according to the host process adapter's bounded shutdown policy. +At the root, V1 holds the foreground-terminal lease for the CLI execution. In a +terminal grid, the grid holds that root lease and a launch holds only its current +pane lease. Two launches cannot concurrently own the same root or pane terminal, +even when they name different sessions. Launches on distinct panes may run +concurrently, and sequential launches on one terminal are ordinary composition. + +Cancellation interrupts the native interactive process, establishes that it can +no longer execute or hold its root or pane terminal, restores that terminal's +state, and runs every provider finalizer. A process that ignores the initial +interruption is terminated according to the host process adapter's bounded +shutdown policy. The adapter attempts to collect the native exit status, but a runtime-retained defunct PID or a lost exit event is not live process ownership. After a fatal signal was accepted, or the process was already absent, bounded settlement may @@ -953,6 +1041,14 @@ reuses the recorded native session, and continues at the first incomplete phase. was interrupted reattaches the native UI to that same session; it never creates a replacement or reconstructs state from a transcript. +When the launch is a pane child, completed replay of the enclosing completed +grid claims the whole structured region before this operation is reached, so it +also contacts nothing. Partial grid replay restores a completed launch as a +settled pane status. An incomplete launch is reached under a newly created live +pane terminal and follows the same phase rules above; neither the new provider +layout nor the pane ordinal changes its retained launch or logical-session +identity. + Those are operation/runtime replay semantics: they define how an execution behaves when an embedder, a test, or a future retained execution host supplies the launch's durable history again. They do not create a public continuation @@ -1037,6 +1133,25 @@ hosts can install a controlled launcher that needs no terminal; a host that installs none — `xmd test`, document inspection, an embedder — refuses every launch, which is what keeps help and inspection free of any of this. +The Deno source host and compiled binary install the first terminal-grid +provider for an ordinary foreground run when a TTY and the required tmux +capability are available. The provider prepares one invocation-private tmux +server and one persistent initial worker per pane. Its per-pane sockets live in +a short mode-0700 directory and admit one connection through a mode-0600 token +that is removed after authentication. It keeps tmux commands, socket paths, +tokens, session, window, pane, process, and control-client identifiers private. +It derives an explicit layout and swaps panes into authored row-major order, +because tmux does not honor pane IDs embedded in layout leaves. Its visible +inherited-stdio client and no-output control client remain distinct, and loss of +the root terminal becomes structured cancellation. A missing prerequisite +refuses the grid before pane start. + +Node and Bun validate and catalog the same `` and `` +syntax but install no grid provider. Installing a grid provider advertises no +new Agent, launch adapter, session-construction mechanism, or attachment +capability; each `` still passes the existing independent +advertisement gates. + `` installs a controlled launcher for its own body, because a scripted agent's native UI does not exist and the terminal a host would hand it belongs to whoever is running the tests. That is what lets an authored @@ -1069,6 +1184,20 @@ launcher records the request, claims a known provider-native session ID, waits on a test-controlled operation, and exits with a selected status. It never starts Claude, Codex, or a model. +Terminal-grid tests additionally install a controlled provider that is not +tmux. It exposes readiness, independent pane settlement, reader close, provider +failure, parent cancellation, and teardown completion as test-controlled +operations while using the same core terminal authority and pane-scoped native +launchers. Separate tmux integration evidence exercises the production adapter; +core semantics are not inferred from tmux identifiers or process behavior. The +tmux evidence covers exact argv over private IPC, the runtime spawn boundary, +display that cannot become child input, real terminal job control, explicit +layout, atomic attach, independent close signals, cancellation phases, and the +bounded descendant, process-group, and terminal-holder teardown proof. It also +exercises pane reuse after terminal-holder quiescence; a process that has +already started a new session, closed the terminal, and lost its parent is +recorded as outside the observable host boundary. + Focused tests prove: 1. help discovers roles and performs no preparation or launch; @@ -1119,7 +1248,12 @@ Focused tests prove: what it answers with before acknowledging quiescence, and a close that failed releases nothing and withholds quiescence; and 23. a canonical version parse accepts exactly one matching line, and refuses - zero or several without repeating the output. + zero or several without repeating the output; and +24. launches on distinct pane terminals run concurrently while launches in one + pane remain exclusive, the same logical Agent session still contends across + panes, pane readiness occurs only after successful native-child start, grid + close awaits launch cancellation and session quiescence, and completed and + partial grid replay preserve the launch's existing identity rules. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1198,7 +1332,9 @@ in a released unbound form and a bound one; the host-owned executable observer and the build binding it produces; ACP attachment to a bound client-native session under its exact retained identity, through runtime partitions keyed by agent command and build; -an inherited-terminal foreground child with cancellation and bounded reaping; +an inherited root- or pane-terminal interactive child with cancellation and +bounded reaping; composition with the terminal grid's independent pane leases +without changing session ownership or durable launch identity; and the controlled TestAgent fixture that proves all of it without starting a model. @@ -1236,9 +1372,10 @@ Additional provider adapters land independently against the same core contract. An adapter that cannot prove instruction injection before the first user turn stays unsupported rather than weakening `Session.Launch` semantics. -Native UI event mirroring, XMD-rendered interactive chat, simultaneous terminal -sessions, automatic nested `AGENTS.md` discovery, bootstrap model turns, and -workflow role scheduling are outside this contract. +Native UI event mirroring, XMD-rendered interactive chat, simultaneous root +foreground sessions outside a terminal grid, automatic nested `AGENTS.md` +discovery, bootstrap model turns, and workflow role scheduling are outside this +contract. ## Structural checklist @@ -1294,6 +1431,23 @@ Implementation review checks these frozen invariants: moment its handle exists; a cancellation observes and settles an ensure it already started before quiescence; quiescence is answered from that account; and a close that failed releases nothing and acknowledges none. +24. A terminal grid holds the root foreground lease while each launch holds only + its current pane lease; distinct panes do not contend for terminal ownership, + and one pane remains exclusive until observable processes and terminal + holders from the prior launch are gone. +25. Pane terminal ownership never replaces or weakens natural-key Agent-session + ownership, so two panes naming one session still contend without waiting. +26. A pane is ready only at the runtime child-spawn event; preparation, PID + allocation, route publication, detach, private-file creation and first + output are not readiness, and a failed spawn rolls none of them back. +27. Grid cancellation reaches every live launch, awaits its child teardown and + session quiescence, and exposes no provider-specific layout identity in an + authored, durable, result, or diagnostic surface. +28. The tmux provider creates native children only through authenticated + persistent pane workers, preserves byte-exact argv outside tmux parsing, + keeps worker display out of child input, distinguishes reader detach from + control loss and server stop, and proves the bounded process and terminal + teardown before pane reuse and grid settlement. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 3a87e878fe4f05dd407d3d8ef6057c6053ecbf46 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:12:41 -0400 Subject: [PATCH 02/62] =?UTF-8?q?=E2=9C=A8=20Describe=20terminal=20grids?= =?UTF-8?q?=20as=20executable=20document=20structure=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` and `` become reserved core structural syntax. This Story owns the authored structure alone: the grammar, the placement rules, and the row-major layout a grid derives. No terminal authority, provider, pane execution, shell, durability or replay is built here. The grid's closed props hold one required positive-integer `columns`; a pane's hold one required non-empty `title`. Titles are display labels and identify nothing — a pane's structural identity is its ordinal among the grid's direct children, and rows are derived in authored row-major order with the last row left short. `structural-rules.ts` decides what the source says, so expansion and document validation cannot disagree about it: only whitespace and direct `` panes may appear in a grid, and an empty grid, direct text, a non-pane element, a control structure that would produce panes, a nested grid, and a pane written anywhere else are refused. `terminal-grid.ts` places the panes once `columns` and each `title` are known. A grid the grammar accepts runs until a terminal provider would be asked for one. This build installs none, so it refuses there, before any pane body expands or a default shell starts, and carries the layout it derived beside the refusal. Evidence rows TG1-TG4: the new structural suite owns TG1, TG2 and TG4; the catalog, validation and `xmd syntax` suites own TG3. --- packages/cli/tests/syntax-cli.test.ts | 46 ++ packages/core/src/document-validation.ts | 24 + packages/core/src/expand.ts | 156 +++++- packages/core/src/structural-rules.ts | 244 +++++++++ packages/core/src/structural.ts | 23 + packages/core/src/terminal-grid.ts | 69 +++ .../core/tests/document-validation.test.ts | 154 ++++++ packages/core/tests/syntax-catalog.test.ts | 76 +++ .../tests/terminal-grid-structure.test.ts | 511 ++++++++++++++++++ 9 files changed, 1301 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal-grid.ts create mode 100644 packages/core/tests/terminal-grid-structure.test.ts diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 03a493a60..bf719c5ec 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -295,6 +295,36 @@ describe("Tier SX — the run profile the command describes", () => { ]); }); + it("TG3: describes both terminal-grid constructs without probing for a terminal", function* () { + // Whatever this runtime can or cannot open, the language is the same, so + // the one boundary a capability probe would cross is a trap here. + const catalog = yield* scoped(function* () { + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options]): Operation { + throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); + }, + }); + return yield* syntaxCatalog([]); + }); + const [structural, builtIn] = catalog.categories; + + const grid = structural.entries.find((entry) => entry.name === "Terminal.Grid"); + const pane = structural.entries.find((entry) => entry.name === "Terminal"); + expect(grid?.origin).toEqual({ kind: "structural", construct: "Terminal.Grid" }); + expect(pane?.origin).toEqual({ kind: "structural", construct: "Terminal" }); + expect(grid?.syntax).toEqual([""]); + expect(pane?.syntax).toEqual([ + '', + '', + ]); + expect(grid?.description ?? "").not.toBe(""); + expect(pane?.description ?? "").not.toBe(""); + // Reserved syntax, so neither name is a component this profile offers. + expect(names(builtIn.entries)).not.toContain("Terminal.Grid"); + expect(names(builtIn.entries)).not.toContain("Terminal"); + }); + it("SX3: describes without minting an execution claimant", function* () { const catalog = yield* syntaxSymbols([]); const session = catalog.categories[1].entries.find((entry) => entry.name === "Session"); @@ -533,6 +563,22 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("TG3: prints both terminal-grid constructs, in markdown and in JSON", function* () { + yield* useWorkspace(WORKSPACE, function* (cwd) { + const markdown = yield* runCli(["syntax"], { cwd }).expect(); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain(""); + expect(markdown.stdout).toContain(''); + expect(markdown.stdout).toContain(''); + + const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); + const structural = parseCatalog(json.stdout).categories[0].entries; + expect(names(structural)).toContain("Terminal.Grid"); + expect(names(structural)).toContain("Terminal"); + }); + }); + it("SX12: succeeds with the defaults in a package tree full of directory links", function* () { yield* useWorkspace( { diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 15350db98..9605a39ad 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -74,7 +74,9 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalGridStructure, } from "./structural-rules.ts"; import type { StructuralViolation } from "./structural-rules.ts"; import type { @@ -314,6 +316,8 @@ interface LexicalContext { readonly insideIf: boolean; /** Whether a `` in this source lexically encloses this point. */ readonly insideSwitch: boolean; + /** Whether a `` in this source lexically encloses this point. */ + readonly insideTerminalGrid: boolean; /** Whether the immediate parent is an ``. */ readonly underAnswers: boolean; } @@ -492,6 +496,7 @@ class ValidationState { insideLoop: false, insideIf: false, insideSwitch: false, + insideTerminalGrid: false, underAnswers: false, }); } @@ -1102,6 +1107,24 @@ class ValidationState { return context.insideSwitch ? [] : [{ code: "structural-usage-invalid", source: "Case", message: strayCaseMessage() }]; + case "Terminal.Grid": + // The whole layout is decided from source, so every pane's own mistake + // is reported where it was written — and so is a construct written + // below the grid that the grid does not lay out. + return terminalGridStructure(segment).violations; + case "Terminal": + // A well-placed `` is its grid's, and one placed wrongly + // under a grid is already reported by that grid's own structure. What + // is left is a pane with no grid above it at all. + return context.insideTerminalGrid + ? [] + : [ + { + code: "structural-usage-invalid", + source: "Terminal", + message: strayTerminalMessage(), + }, + ]; case "Else": // A well-placed `` is its ``'s, and one placed wrongly under // an `` is already reported by that ``'s own structure. What is @@ -1272,6 +1295,7 @@ function childContext(segment: ComponentElement, context: LexicalContext): Lexic insideLoop: context.insideLoop || segment.name === "Loop", insideIf: context.insideIf || segment.name === "If", insideSwitch: context.insideSwitch || segment.name === "Switch", + insideTerminalGrid: context.insideTerminalGrid || segment.name === "Terminal.Grid", underAnswers: segment.name === "Answers", }; } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 8f5b5b631..a6aef2839 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,7 +13,7 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, scoped, useScope, withResolvers } from "effection"; +import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import type { FunctionComponent, @@ -57,9 +57,17 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalColumns, + terminalColumnsMissingMessage, + terminalGridStructure, + terminalTitle, + terminalTitleMissingMessage, } from "./structural-rules.ts"; -import type { StructuralViolation, SwitchCase } from "./structural-rules.ts"; +import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; +import { terminalGridLayout } from "./terminal-grid.ts"; +import type { PlacedPane } from "./terminal-grid.ts"; import { asBindingViolation, asExpressionViolation, @@ -1173,6 +1181,28 @@ function* expandListSegments( break; } + if (segment.name === "Terminal.Grid") { + // No raise() here, like the branches above: expandTerminalGrid + // reports every error it creates. + yield* expandTerminalGrid(segment, result); + break; + } + + if (segment.name === "Terminal") { + // A well-placed is consumed by its and + // never expanded on its own. Reaching this branch means the pane sits + // outside every grid, so it names no component and is diagnosed + // rather than resolved from the filesystem. + result.push( + yield* raise({ + type: "error", + message: positioned(strayTerminalMessage(), segment), + source: "Terminal", + }), + ); + break; + } + if (segment.name === "Break") { result.push(...(yield* expandBreak(segment, loop))); break; @@ -2028,6 +2058,128 @@ function* expandSwitch( ); } +function terminalGridError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal.Grid" }; +} + +function terminalPaneError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal" }; +} + +/** + * The value one prop of a terminal-grid construct produced, or why evaluating + * it failed. A missing prop is `undefined`, which is also what an expression + * evaluating to `undefined` leaves behind (§6.5) — absence either way, and the + * caller says what its construct requires instead. + */ +function* resolveStructuralProp( + segment: ComponentElement, + construct: string, + prop: string, +): Operation> { + const expression = segment.expressions[prop]; + if (expression === undefined) { + return Ok(segment.props[prop]); + } + try { + const resolved = yield* resolveExpressionProps( + {}, + { [prop]: expression }, + construct, + segment.projectedEnv, + ); + return Ok(resolved[prop]); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +/** + * Open the grid the author wrote (spec §6.21). + * + * The whole layout is decided before anything opens: the panes and their forms + * from source, then `columns` and each pane's `title` from the values the + * document computes. Only once the concrete grid is complete is a terminal + * provider anything's business — and this build has none, so the grid refuses + * there. Nothing beneath a pane has expanded and no shell has started when it + * does, which is what makes the refusal a closed one rather than a partial grid + * left behind. + */ +function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { + const structure = terminalGridStructure(segment); + if (structure.violations.length > 0) { + for (const violation of structure.violations) { + owner.push(yield* raise(structuralErrorSegment(violation, segment))); + } + return; + } + + const columnsValue = yield* resolveStructuralProp(segment, "Terminal.Grid", "columns"); + if (!columnsValue.ok) { + owner.push(yield* raise(terminalGridError(segment, columnsValue.error.message))); + return; + } + if (columnsValue.value === undefined) { + owner.push(yield* raise(terminalGridError(segment, terminalColumnsMissingMessage()))); + return; + } + const columns = terminalColumns(columnsValue.value); + if (!columns.ok) { + owner.push(yield* raise(terminalGridError(segment, columns.error.message))); + return; + } + + const placed: PlacedPane[] = []; + for (const pane of structure.panes) { + const title = yield* resolvePaneTitle(pane); + if (!title.ok) { + owner.push(yield* raise(terminalPaneError(pane.element, title.error.message))); + return; + } + placed.push({ title: title.value, form: pane.form }); + } + + const layout = terminalGridLayout(columns.value, placed); + owner.push( + yield* raise({ + type: "error", + message: positioned(noTerminalProviderMessage(), segment), + source: "Terminal.Grid", + // The grid the author asked for, carried beside the sentence so an + // assertion is about the layout that was derived rather than about the + // wording of a refusal. + cause: { + layout: { + columns: layout.columns, + rows: layout.rows, + cells: layout.cells.map((cell) => ({ ...cell })), + }, + }, + }), + ); +} + +/** The label one pane displays, from the value its own `title` prop produced. */ +function* resolvePaneTitle(pane: TerminalPane): Operation> { + const value = yield* resolveStructuralProp(pane.element, "Terminal", "title"); + if (!value.ok) { + return value; + } + if (value.value === undefined) { + return Err(new Error(terminalTitleMissingMessage())); + } + return terminalTitle(value.value); +} + +/** What a complete grid says on a host where nothing can open one. */ +function noTerminalProviderMessage(): string { + return ( + "no terminal provider opened this grid. A host installs the terminal-grid capability " + + "explicitly, and this one installs none, so no pane expanded its content and no default " + + "shell started." + ); +} + function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts index e343ad872..24aa676bb 100644 --- a/packages/core/src/structural-rules.ts +++ b/packages/core/src/structural-rules.ts @@ -1034,3 +1034,247 @@ export function answerViolations(segment: ComponentElement): StructuralViolation } return found; } + +const TERMINAL_GRID_PROPS = new Set(["columns"]); +const TERMINAL_PROPS = new Set(["title"]); + +/** What a `` written outside the grid that lays it out says. */ +export function strayTerminalMessage(): string { + return ( + " must be a direct child of . is reserved: it never " + + "resolves a component, and only the grid it belongs to can place it." + ); +} + +/** What a `` written inside another grid says. */ +export function nestedTerminalGridMessage(): string { + return ( + " cannot be written inside another . A grid lays out the " + + "panes it is written with, so one pane cannot become a grid of its own." + ); +} + +/** + * How many columns a grid lays its panes across, or why `columns` rejects it. + * + * The same rule wherever the value came from: a literal is checked while the + * document is only being read, and an expression's answer is checked here too + * once expansion has evaluated it. + */ +export function terminalColumns(columns: Json): Result { + if (typeof columns !== "number") { + return Err( + new Error( + `Prop "columns" on must be a positive integer, not ${jsonKind(columns)}.`, + ), + ); + } + if (!Number.isInteger(columns) || columns < 1) { + return Err( + new Error( + `Prop "columns" on must be a positive integer. Got: ` + + `${JSON.stringify(columns)}.`, + ), + ); + } + return Ok(columns); +} + +/** What a `` naming no column count at all says. */ +export function terminalColumnsMissingMessage(): string { + return ' requires a "columns" prop (a positive integer).'; +} + +/** The label one pane displays, or why `title` rejects it. */ +export function terminalTitle(title: Json): Result { + if (typeof title !== "string") { + return Err( + new Error(`Prop "title" on must be a non-empty string, not ${jsonKind(title)}.`), + ); + } + if (title.length === 0) { + return Err(new Error('Prop "title" on must be a non-empty string. Got: "".')); + } + return Ok(title); +} + +/** What a `` naming no title at all says. */ +export function terminalTitleMissingMessage(): string { + return ' requires a "title" prop (the label the pane displays).'; +} + +/** One pane a grid lays out, and where it sat among its siblings. */ +export interface TerminalPane { + readonly element: ComponentElement; + /** The child index the pane was written at. */ + readonly index: number; + /** + * The pane's structural identity: its position among the grid's panes, + * counting from zero. A title is a display label and identifies nothing. + */ + readonly ordinal: number; + /** Whether the pane runs the markdown it holds or the host's default shell. */ + readonly form: "paired" | "self-closing"; +} + +/** How a `` body divides into panes, and what the division got wrong. */ +export interface TerminalGridStructure { + readonly violations: StructuralViolation[]; + /** The direct panes, in authored order. */ + readonly panes: TerminalPane[]; +} + +/** Which of a pane's two forms was written: its own markdown, or a shell. */ +function paneForm(segment: ComponentElement): TerminalPane["form"] { + return segment.selfClosing ? "self-closing" : "paired"; +} + +/** Everything one `` pane decides from what the author wrote (spec §6.21). */ +function terminalPaneViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_PROPS.has(name)); + if (unknownProp !== undefined) { + found.push( + violation( + "structural-usage-invalid", + "Terminal", + ` only accepts a "title" prop. Got: "${unknownProp}".`, + segment, + ), + ); + } + + if ("title" in segment.props) { + const title = terminalTitle(segment.props.title); + if (!title.ok) { + found.push(violation("structural-usage-invalid", "Terminal", title.error.message, segment)); + } + } else if (!("title" in segment.expressions)) { + found.push( + violation("structural-usage-invalid", "Terminal", terminalTitleMissingMessage(), segment), + ); + } + return found; +} + +/** + * Every `` and `` below a grid that the grid does not + * lay out. The walk stops at a nested grid, which is reported where it sits and + * owns whatever is written beneath it. + */ +function misplacedTerminalViolations(children: Segment[]): StructuralViolation[] { + const found: StructuralViolation[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Terminal.Grid") { + if (depth > 0) { + found.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + nestedTerminalGridMessage(), + segment, + ), + ); + } + continue; + } + if (segment.name === "Terminal" && depth > 0) { + found.push( + violation("structural-usage-invalid", "Terminal", strayTerminalMessage(), segment), + ); + } + walk(segment.children, depth + 1); + } + }; + + walk(children, 0); + return found; +} + +/** + * Divide a `` body into its panes and validate the division + * (spec §6.21). Everything here is read from source, so a grid whose layout the + * author got wrong is refused before `columns` is evaluated, before a pane's + * content expands, and before any terminal provider is asked for anything. + * + * The panes are the grid's direct children and only they: a control structure + * that would produce panes as it ran cannot be one, because which panes exist + * is what the grid must know before it opens anything. + */ +export function terminalGridStructure(segment: ComponentElement): TerminalGridStructure { + const violations: StructuralViolation[] = []; + const panes: TerminalPane[] = []; + + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_GRID_PROPS.has(name)); + if (unknownProp !== undefined) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` only accepts a "columns" prop. Got: "${unknownProp}".`, + ), + ); + } + if ("columns" in segment.props) { + const columns = terminalColumns(segment.props.columns); + if (!columns.ok) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", columns.error.message), + ); + } + } else if (!("columns" in segment.expressions)) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", terminalColumnsMissingMessage()), + ); + } + if (segment.selfClosing) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " holds the panes it lays out, so it is written paired: " + + '.', + ), + ); + } + + let substantive = 0; + for (const [index, child] of segment.children.entries()) { + if (isBlankText(child)) { + continue; + } + substantive++; + if (child.type !== "component" || child.name !== "Terminal") { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` holds only panes. Found ${describeSegment(child)} ` + + "directly inside it. Write control flow inside a pane instead.", + child.type === "component" ? child : undefined, + ), + ); + continue; + } + violations.push(...terminalPaneViolations(child)); + panes.push({ element: child, index, ordinal: panes.length, form: paneForm(child) }); + } + + if (!segment.selfClosing && substantive === 0) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " requires at least one pane.", + ), + ); + } + + violations.push(...misplacedTerminalViolations(segment.children)); + return { violations, panes }; +} diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index ad36a80ae..0740a8e62 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -169,6 +169,29 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ as: null, context: "A multiline template, in place of the single-line `template` prop.", }, + { + name: "Terminal.Grid", + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + as: null, + context: "The `` panes the grid lays out.", + }, + { + name: "Terminal", + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + as: null, + context: "Markdown the pane runs, in the paired form.", + }, ]; /** diff --git a/packages/core/src/terminal-grid.ts b/packages/core/src/terminal-grid.ts new file mode 100644 index 000000000..59a08a920 --- /dev/null +++ b/packages/core/src/terminal-grid.ts @@ -0,0 +1,69 @@ +/** + * The concrete grid an authored `` derives (spec §6.21). + * + * `structural-rules.ts` decides what the source says: which panes were written, + * in what order, and what is wrong with the way they were written. What it + * cannot decide is where each pane sits, because that also depends on `columns` + * — a value the document may compute. This module is where the two meet, once + * both are known and before anything is opened. + * + * A layout is provider-neutral data. It names no terminal, multiplexer, socket, + * process or window: it says how many columns the author asked for, how many + * rows that many panes fill, and which cell each pane occupies. + */ + +import type { TerminalPane } from "./structural-rules.ts"; + +/** One pane, placed. */ +export interface TerminalGridCell { + /** The pane's structural identity: its position among the panes, from zero. */ + readonly ordinal: number; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** The label it displays. Two cells may carry the same one. */ + readonly title: string; + /** Whether it runs the markdown the pane holds or the host's default shell. */ + readonly form: TerminalPane["form"]; +} + +/** The complete grid one `` asked for. */ +export interface TerminalGridLayout { + readonly columns: number; + /** How many rows those columns take to hold every pane. */ + readonly rows: number; + /** Every pane, in authored order, which is also row-major order. */ + readonly cells: readonly TerminalGridCell[]; +} + +/** One pane's placeable facts, once its title has been resolved. */ +export interface PlacedPane { + readonly title: string; + readonly form: TerminalPane["form"]; +} + +/** + * Place the panes across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` panes fill the first row, the next fill the + * second, and a count that does not divide leaves the positions at the end of + * the last row unused. Nothing is reordered, padded, or balanced — the author's + * order is the layout, and a pane's ordinal is its identity wherever it lands. + */ +export function terminalGridLayout( + columns: number, + panes: readonly PlacedPane[], +): TerminalGridLayout { + return { + columns, + rows: Math.ceil(panes.length / columns), + cells: panes.map((pane, ordinal) => ({ + ordinal, + row: Math.floor(ordinal / columns), + column: ordinal % columns, + title: pane.title, + form: pane.form, + })), + }; +} diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index 6c5f6fd91..c37f8c92b 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -681,6 +681,160 @@ describe("Tier DV: branch selection", () => { }); }); +describe("Tier DV: terminal grids", () => { + const GRID_DOC = [ + "", + '', + '', + "", + '', + "", + "", + ].join("\n"); + + it("TG3: a well-formed grid is valid, and nothing beneath it runs", function* () { + const { result, seen } = yield* validateText(GRID_DOC, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(names(result)).toEqual(["Terminal.Grid", "Terminal", "Widget", "Terminal"]); + expect(named(result, "Terminal.Grid").origin).toEqual({ + kind: "structural", + construct: "Terminal.Grid", + }); + expect(named(result, "Terminal").origin).toEqual({ + kind: "structural", + construct: "Terminal", + }); + // A pane's body is walked like any other region, and none of it — no + // shell, no command, no agent, no terminal — was reached to walk it. + expect(seen.effects).toEqual([]); + // Reserved means selection never looked for a file that could supply + // either construct. + expect(seen.reads.some((read) => read.includes("Terminal"))).toBe(false); + }); + + it("TG3: reports each invalid authored form, with no execution", function* () { + const invalid: [string, string, string][] = [ + [ + "an unknown prop on the grid", + '\n', + ' only accepts a "columns" prop. Got: "layout".', + ], + [ + "a capture on the grid", + '\n', + ' only accepts a "columns" prop. Got: "as".', + ], + [ + "no column count", + '\n', + ' requires a "columns" prop (a positive integer).', + ], + [ + "a column count that is not a positive integer", + '\n', + 'Prop "columns" on must be a positive integer. Got: 0.', + ], + [ + "an unknown prop on a pane", + '\n', + ' only accepts a "title" prop. Got: "shell".', + ], + [ + "no title on a pane", + "\n", + ' requires a "title" prop (the label the pane displays).', + ], + [ + "an empty title", + '\n', + 'Prop "title" on must be a non-empty string. Got: "".', + ], + [ + "a self-closing grid", + "\n", + " holds the panes it lays out", + ], + [ + "a grid with no pane", + "\n", + " requires at least one pane.", + ], + [ + "text written directly in a grid", + 'a note\n', + ' holds only panes. Found text "a note" directly inside it.', + ], + [ + "a direct element that is not a pane", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a pane produced by control flow", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a nested grid", + '' + + '\n', + " cannot be written inside another .", + ], + [ + "a pane outside every grid", + 'alone\n', + " must be a direct child of .", + ], + [ + "a pane below a grid that is not one of its panes", + '' + + "\n", + " must be a direct child of .", + ], + ]; + + for (const [form, source, message] of invalid) { + const { result, seen } = yield* validateText(source, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(`${form}: ${result.outcome}`).toBe(`${form}: invalid`); + expect(`${form}: ${codes(result).includes("structural-usage-invalid")}`).toBe( + `${form}: true`, + ); + const said = result.diagnostics.some((diagnostic) => diagnostic.message.includes(message)); + expect(`${form}: ${said}`).toBe(`${form}: true`); + expect(`${form}: ${JSON.stringify(seen.effects)}`).toBe(`${form}: []`); + } + }); + + it("TG3: answers the same way twice", function* () { + const first = yield* validateText("\n"); + const second = yield* validateText("\n"); + + expect(JSON.stringify(second.result)).toBe(JSON.stringify(first.result)); + }); + + it("TG3: a dynamic column count and title are decided by expansion, not here", function* () { + const { result, seen } = yield* validateText( + ["", "", "", ""].join( + "\n", + ), + ); + + // Whether those expressions produce a positive integer and a non-empty + // string is a value the document computes, and evaluating one is + // expansion's alone. + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(seen.effects).toEqual([]); + }); +}); + describe("Tier DV: source, target and declaration failures", () => { const ROWS: { readonly id: string; diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 8339704bb..20a8932cb 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -348,6 +348,82 @@ describe("Tier SY: structural vocabulary", () => { expect(find(entries, "Case").as).toBeUndefined(); }); + it("TG3: freezes the and entries the catalog publishes", function* () { + const catalog = yield* catalogFor({}, []); + const entries = structural(catalog); + + expect(catalog.version).toBe(1); + expect(find(entries, "Terminal.Grid")).toEqual({ + kind: "structural", + name: "Terminal.Grid", + origin: { kind: "structural", construct: "Terminal.Grid" }, + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + context: "The `` panes the grid lays out.", + }); + expect(find(entries, "Terminal")).toEqual({ + kind: "structural", + name: "Terminal", + origin: { kind: "structural", construct: "Terminal" }, + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + context: "Markdown the pane runs, in the paired form.", + }); + // Neither construct binds, so neither carries an `as` sentence at all. + expect(find(entries, "Terminal.Grid").as).toBeUndefined(); + expect(find(entries, "Terminal").as).toBeUndefined(); + }); + + it("TG3: a repository file cannot supply the grid or a pane, and neither can a registration", function* () { + const catalog = yield* catalogFor( + { + components: { kind: "directory" }, + "components/Terminal.md": markdown("a repository terminal\n"), + "components/Terminal": { kind: "directory" }, + "components/Terminal/Grid.md": markdown("a repository grid\n"), + }, + ["components"], + ); + + for (const name of ["Terminal.Grid", "Terminal"]) { + expect(names(structural(catalog))).toContain(name); + expect(names(userProvided(catalog))).not.toContain(name); + expect(names(builtIn(catalog))).not.toContain(name); + } + + for (const name of ["Terminal.Grid", "Terminal"]) { + let refused: unknown; + yield* scoped(function* () { + try { + yield* registerComponents([ + { + name, + origin: "tier-tg", + props: {}, + *fn() { + return ""; + }, + }, + ]); + } catch (error) { + refused = error; + } + }); + expect(refused instanceof Error ? refused.message : "").toContain( + `cannot register "${name}": it is structural syntax the engine owns`, + ); + } + }); + it("SY5b: a repository file cannot supply or , and neither can a registration", function* () { const catalog = yield* catalogFor( { diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts new file mode 100644 index 000000000..76c440cba --- /dev/null +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -0,0 +1,511 @@ +/** + * Tier TG — the authored structure of a terminal grid (spec §6.21). + * + * What an author may write, and where each pane lands, decided before anything + * opens. These rows drive the real expansion path: a grid the grammar accepts + * runs until the point a terminal provider would be asked for one, and this + * build installs none, so it refuses there and carries the layout it derived + * beside the refusal. + * + * Provider non-observation is asserted rather than assumed. Every run traps the + * two boundaries a pane's body would cross — resolving a component and running + * a code block — and a row is evidence only when both stayed empty. That the + * machine running these tests has no tmux is not evidence of anything: nothing + * here would look for one. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Component } from "../src/component-api.ts"; +import { expandSegments } from "../src/expand.ts"; +import { renderSegments } from "../src/render.ts"; +import { scanSegments } from "../src/scanner.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { Json, Segment } from "../src/types.ts"; + +interface GridRun { + segments: Segment[]; + output: string; + /** Every component the run tried to resolve, in order. */ + imports: string[]; + /** The source of every code block the run ran, in order. */ + blocks: string[]; + /** Every expression the document evaluated, by label, in order. */ + calls: string[]; +} + +/** + * Expand one document with every effect a pane could have trapped. + * + * A component this run resolves, a code block it runs, or an expression it + * evaluates is recorded rather than performed, so "nothing beneath the grid + * happened" is something the row reads back instead of assuming. + */ +function runGrid(source: string, values: Record = {}): Operation { + return scoped(function* () { + const imports: string[] = []; + const blocks: string[] = []; + const calls: string[] = []; + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + imports.push(name); + throw new Error(`Component not found: ${name}`); + }, + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, context], _next) { + blocks.push(context.content); + return { output: "", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + const testEnv = { + values: { + ...values, + seen: (label: string, value: unknown) => { + calls.push(label); + return value; + }, + }, + }; + yield* Component.around({ env: () => testEnv }, { at: "min" }); + const segments = yield* expandSegments(scanSegments(source), {}, {}, new Set()); + return { segments, output: renderSegments(segments), imports, blocks, calls }; + }); +} + +function errorMessages(segments: Segment[]): string[] { + return segments.filter((segment) => segment.type === "error").map((segment) => segment.message); +} + +/** The one message a run that refused for a single reason reports. */ +function soleError(run: GridRun): string { + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(1); + return messages[0]!; +} + +/** + * The grid a run derived, read from the refusal that carries it. + * + * A run that refused for a grammar or placement reason never derived one, so + * asking for it is also how a row states that the grid was complete. + */ +function derivedLayout(run: GridRun): Json { + const refusal = run.segments.find( + (segment) => segment.type === "error" && segment.source === "Terminal.Grid", + ); + if (refusal === undefined || refusal.type !== "error" || refusal.cause === undefined) { + throw new Error(`no terminal-grid refusal carrying a layout: ${errorMessages(run.segments)}`); + } + return refusal.cause; +} + +/** Every boundary a pane's body would have crossed, and none of them did. */ +function reachedNothing(run: GridRun): void { + expect(run.imports).toEqual([]); + expect(run.blocks).toEqual([]); + expect(run.calls).toEqual([]); +} + +/** + * Work a pane's body would do, so a body that expanded would be recorded. + * + * One of each boundary `reachedNothing()` reads: a component to resolve, an + * expression to evaluate, and a command to run. + */ +const PANE_BODY = [ + "", + "", + 'reached', + "", + "```bash exec", + "echo ran", + "```", +].join("\n"); + +describe("Tier TG — the grid grammar", () => { + it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { + const run = yield* runGrid( + [ + "", + 'Instructions.', + '', + "", + ].join("\n"), + ); + + // The grammar accepted it, so the run reached the one thing this build + // cannot do — and stopped there. + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, + ], + }, + }); + }); + + it("TG1: refuses an unknown prop and `as` on the grid", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain( + ' only accepts a "columns" prop. Got: "layout".', + ); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain( + ' only accepts a "columns" prop. Got: "as".', + ); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: refuses an unknown prop and `as` on a pane", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain(' only accepts a "title" prop. Got: "shell".'); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain(' only accepts a "title" prop. Got: "as".'); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: requires columns to be a positive integer, however it was written", function* () { + const missing = yield* runGrid(''); + expect(soleError(missing)).toContain( + ' requires a "columns" prop (a positive integer).', + ); + + for (const literal of ["{0}", "{-1}", "{2.5}", '"2"', "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "columns" on must be a positive'); + reachedNothing(run); + } + + // The same rule reaches a value the document computes, which the source + // could not have decided about. + const computed = yield* runGrid( + '', + { size: 0 }, + ); + expect(soleError(computed)).toContain( + 'Prop "columns" on must be a positive integer. Got: 0.', + ); + reachedNothing(computed); + }); + + it("TG1: requires a non-empty title on every pane, however it was written", function* () { + const missing = yield* runGrid(""); + expect(soleError(missing)).toContain( + ' requires a "title" prop (the label the pane displays).', + ); + + for (const literal of ['""', "{3}", "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "title" on must be a non-empty string'); + reachedNothing(run); + } + + const computed = yield* runGrid( + "", + { label: "" }, + ); + expect(soleError(computed)).toContain( + 'Prop "title" on must be a non-empty string. Got: "".', + ); + reachedNothing(computed); + }); + + it("TG1: refuses a self-closing grid", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" holds the panes it lays out"); + reachedNothing(run); + }); +}); + +describe("Tier TG — structural placement", () => { + it("TG2: refuses a grid with no pane", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" requires at least one pane."); + reachedNothing(run); + }); + + it("TG2: refuses ordinary text written directly in a grid", function* () { + const run = yield* runGrid( + 'a note', + ); + expect(soleError(run)).toContain( + ' holds only panes. Found text "a note" directly inside it.', + ); + reachedNothing(run); + }); + + it("TG2: refuses a direct element that is not a pane", function* () { + const run = yield* runGrid( + '', + ); + expect(soleError(run)).toContain( + " holds only panes. Found directly inside it.", + ); + // The element was refused as authored structure, so it was never resolved. + reachedNothing(run); + }); + + it("TG2: refuses a control structure that would produce the panes", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(2); + expect(messages[0]).toContain( + " holds only panes. Found directly inside it.", + ); + expect(messages[0]).toContain("Write control flow inside a pane instead."); + expect(messages[1]).toContain(" must be a direct child of ."); + // The condition decides which panes would exist, and the grid must know + // that from the source, so it is never evaluated. + reachedNothing(run); + }); + + it("TG2: refuses a grid nested inside a pane", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(run)).toContain( + " cannot be written inside another .", + ); + reachedNothing(run); + }); + + it("TG2: refuses a pane written outside every grid", function* () { + const alone = yield* runGrid('Instructions.'); + expect(soleError(alone)).toContain(" must be a direct child of ."); + + // Below a grid but not one of its panes is the same mistake, reported where + // the pane was written. + const buried = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(buried)).toContain(" must be a direct child of ."); + reachedNothing(alone); + reachedNothing(buried); + }); + + it("TG2: treats whitespace between panes as nothing at all", function* () { + const run = yield* runGrid( + [ + "", + "", + ' ', + "", + ' ', + "", + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, + ], + }, + }); + }); + + it("TG2: a complete grid refuses before any pane body or default shell", function* () { + const run = yield* runGrid( + [ + "", + '', + "", + PANE_BODY, + "", + '', + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); + // The pane held a component and a command; neither was reached, and the + // grid rendered nothing of its own. + reachedNothing(run); + expect(run.output).toContain("no terminal provider opened this grid"); + }); +}); + +describe("Tier TG — row-major layout", () => { + const positions = (columns: number, panes: number) => + terminalGridLayout( + columns, + Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ).cells.map((cell) => [cell.row, cell.column]); + + it("TG4: places one through five panes row-major across two columns", function* () { + expect(positions(2, 1)).toEqual([[0, 0]]); + expect(positions(2, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(2, 3)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + ]); + expect(positions(2, 4)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]); + expect(positions(2, 5)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + ]); + // The last row is left short rather than balanced or padded. + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(2, filler(panes)).rows)).toEqual([ + 1, 1, 2, 2, 3, + ]); + }); + + it("TG4: places one through five panes row-major across three columns", function* () { + expect(positions(3, 1)).toEqual([[0, 0]]); + expect(positions(3, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(3, 3)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + ]); + expect(positions(3, 4)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + ]); + expect(positions(3, 5)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + ]); + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(3, filler(panes)).rows)).toEqual([ + 1, 1, 1, 2, 2, + ]); + }); + + it("TG4: an executed grid derives those same positions", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + '', + '', + '', + "", + ].join("\n"), + ); + + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 3, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, + { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, + { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, + ], + }, + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const run = yield* runGrid( + [ + "", + 'first', + '', + 'third', + "", + ].join("\n"), + ); + + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each one was written in travels with it. + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 2, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, + ], + }, + }); + }); +}); + +/** Panes that differ only in count, for a row about rows. */ +function filler(panes: number): { title: string; form: "self-closing" }[] { + return Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })); +} From cf6c7e68196c9c50a3211ced1838900a35fec813 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:45:27 -0400 Subject: [PATCH 03/62] =?UTF-8?q?=F0=9F=93=9D=20Shorten=20the=20terminal-g?= =?UTF-8?q?rid=20component=20descriptions=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both descriptions lead with what the author can do and show the invocation to copy. The placement rule, the row-major detail and the title's display role are the catalog's structured fields and §6.21's to state, not a second reference entry beside the forms. The frozen catalog entries in `syntax-catalog.test.ts` mirror the two declarations, so they move with them. --- packages/core/src/structural.ts | 14 +++++--------- packages/core/tests/syntax-catalog.test.ts | 14 +++++--------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index 0740a8e62..4823aca3d 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -173,11 +173,8 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal.Grid", syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', as: null, context: "The `` panes the grid lays out.", }, @@ -185,10 +182,9 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal", syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', as: null, context: "Markdown the pane runs, in the paired form.", }, diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 20a8932cb..83baec843 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -359,11 +359,8 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal.Grid" }, syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', context: "The `` panes the grid lays out.", }); expect(find(entries, "Terminal")).toEqual({ @@ -372,10 +369,9 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal" }, syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', context: "Markdown the pane runs, in the paired form.", }); // Neither construct binds, so neither carries an `as` sentence at all. From feb47d3af7b09253c61321dd6f9f0a2f9faf7497 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:01:37 -0400 Subject: [PATCH 04/62] =?UTF-8?q?=E2=9C=A8=20Add=20the=20terminal=20provid?= =?UTF-8?q?er=20boundary=20and=20pane=20authority=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replaceable seam a terminal grid executes through, before any of the execution that uses it. `packages/runtime/terminal.ts` is the contextual provider: `prepare()` builds the whole composite while it stays hidden, `attach()` shows it once every pane is ready, and `destroy()` gives the root terminal back. The request is provider-neutral — columns, rows, and the authored panes with their derived positions — and names no terminal, socket, process or window. Middleware may observe, narrow, refuse, wrap or delegate; presentation never decides an outcome, so `update()` receives states core has already settled on. `packages/core/src/terminal/authority.ts` mints one-use pane claims for one request's ordinals. A claim admits one interactive operation at a time on its pane and holds that pane's readiness latch. Two claims do not contend, which is what lets panes stay interactive together. `packages/core/src/terminal/pane.ts` is the seam interactive work inside a pane reaches for, so it runs as that pane's owner instead of competing for the root foreground lease. Absence means "not in a pane". Evidence: `packages/runtime/tests/terminal-provider.test.ts`, 11 rows. --- packages/core/src/terminal/authority.ts | 184 ++++++++++++ packages/core/src/terminal/pane.ts | 66 ++++ packages/runtime/mod.ts | 17 ++ packages/runtime/terminal.ts | 282 ++++++++++++++++++ .../runtime/tests/terminal-provider.test.ts | 271 +++++++++++++++++ 5 files changed, 820 insertions(+) create mode 100644 packages/core/src/terminal/authority.ts create mode 100644 packages/core/src/terminal/pane.ts create mode 100644 packages/runtime/terminal.ts create mode 100644 packages/runtime/tests/terminal-provider.test.ts diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts new file mode 100644 index 000000000..df4feffab --- /dev/null +++ b/packages/core/src/terminal/authority.ts @@ -0,0 +1,184 @@ +/** + * Who is allowed to own a terminal, and what "ready" means (architecture.md + * §Terminal authority). + * + * The provider draws a grid. This decides everything about it that matters: + * which request is live, which provider installation it belongs to, which pane + * ordinals exist, whether an interactive operation may start on one, and when a + * pane has actually started. None of that is reachable by name. There is no + * context holding an authority, no member of a request that carries one, and no + * handler return value that produces one — an authority reachable by name would + * be an authority every same-name context and every loaded copy could reach. + * + * A claim is the unforgeable carrier. It is minted here for one ordinal of one + * request under one installation generation, and a claim from another grid, + * another ordinal, an earlier generation, or a finished expansion authorizes + * nothing at all. Holding one grants terminal ownership and nothing else: it + * says nothing about which Agent session a pane may own, because that is the + * session coordinator's to answer and stays independently authoritative. + */ + +import { all, ensure, withResolvers } from "effection"; +import type { Operation } from "effection"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +export class TerminalAuthorityError extends Error { + override name = "TerminalAuthorityError"; +} + +/** + * One pane's terminal ownership. + * + * `admit` is the whole of it: an interactive operation runs inside one, and a + * second one on the same pane is refused while the first is live. Two claims for + * two ordinals do not contend at all, which is what lets panes be interactive at + * the same time. + */ +export interface TerminalPaneClaim { + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * Refuses while another is live on this pane, and refuses once the grid that + * minted the claim has finished — a claim kept past its expansion is a claim + * to a terminal nobody owns any more. + */ + admit(body: () => Operation): Operation; + /** + * Acknowledge the runtime's successful child-spawn event for this pane. + * + * The one thing that makes a pane ready. Called from the spawn event and + * before anything waits for the child to exit, so a child that starts and + * immediately exits is both ready and settled. Acknowledging twice has no + * effect, and a preparation, reservation or spawn that failed never + * acknowledges at all. + */ + ready(): void; +} + +/** What one pane's readiness is waiting on, from the grid's side. */ +export interface PaneReadiness { + /** Settles when the pane's first interactive child reports its spawn event. */ + reached(): Operation; + /** Whether the latch has been acknowledged. */ + readonly acknowledged: boolean; +} + +/** The claims one grid expansion holds, and what they are waiting on. */ +export interface TerminalGridClaims { + readonly claims: readonly TerminalPaneClaim[]; + readonly readiness: readonly PaneReadiness[]; + /** + * Stop admitting anything on every pane. + * + * Close prevents a later launch before it cancels the live ones, so a pane + * that was about to start one is refused rather than raced. + */ + seal(): void; +} + +/** + * Mint the claims for one grid expansion. + * + * The request is validated against the ordinals it declares before a single + * claim exists: a request whose panes are not exactly `0..n-1` in order + * describes a grid core did not derive, and answering it would be answering for + * a layout nobody authored. + */ +export function createTerminalGridClaims(request: TerminalGridRequest): TerminalGridClaims { + validate(request); + + let sealed = false; + const claims: TerminalPaneClaim[] = []; + const readiness: PaneReadiness[] = []; + + for (const pane of request.panes) { + const latch = withResolvers(); + let acknowledged = false; + let live = false; + + readiness.push({ + reached: () => latch.operation, + get acknowledged() { + return acknowledged; + }, + }); + + claims.push({ + ordinal: pane.ordinal, + *admit(body: () => Operation): Operation { + if (sealed) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} is closed: its grid has stopped admitting interactive work`, + ); + } + if (live) { + throw new TerminalAuthorityError( + `pane ${pane.ordinal} already has a live interactive operation — one owns a pane ` + + `terminal at a time`, + ); + } + live = true; + try { + return yield* body(); + } finally { + live = false; + } + }, + ready() { + // Idempotent by construction: readiness is a fact about the pane, and a + // provider that reports the same spawn twice has not started two panes. + if (acknowledged) { + return; + } + acknowledged = true; + latch.resolve(); + }, + }); + } + + return { + claims, + readiness, + seal() { + sealed = true; + }, + }; +} + +function validate(request: TerminalGridRequest): void { + if (request.panes.length === 0) { + throw new TerminalAuthorityError("a terminal grid request names no panes"); + } + for (const [index, pane] of request.panes.entries()) { + if (pane.ordinal !== index) { + throw new TerminalAuthorityError( + `a terminal grid request names pane ordinal ${pane.ordinal} at position ${index}: ` + + `a pane's ordinal is its position among the grid's panes`, + ); + } + } +} + +/** + * Settle once every pane has reported its spawn event. + * + * Deliberately not a timeout: a grid has no implicit deadline, and an enclosing + * run deadline or parent cancellation is what bounds it. A pane that fails to + * start never reaches its latch, so the caller races this against pane failure + * rather than asking the barrier to know about failure. + */ +export function awaitReadiness(readiness: readonly PaneReadiness[]): Operation { + return allOf(readiness.map((pane) => pane.reached())); +} + +function* allOf(waits: readonly Operation[]): Operation { + yield* all(waits); +} + +/** Seal the grid as soon as the enclosing scope begins to unwind. */ +export function sealOnTeardown(claims: TerminalGridClaims): Operation { + return ensure(() => { + claims.seal(); + }); +} diff --git a/packages/core/src/terminal/pane.ts b/packages/core/src/terminal/pane.ts new file mode 100644 index 000000000..f308de81b --- /dev/null +++ b/packages/core/src/terminal/pane.ts @@ -0,0 +1,66 @@ +/** + * How work written inside a pane reaches that pane's terminal. + * + * A `` written at the root reserves the run's one foreground + * terminal and competes with every other launch for it. The same element + * written inside a pane must not: panes are interactive at the same time, which + * is the whole reason a grid exists. So core installs this in each pane's own + * scope, and anything interactive asks here first. + * + * What travels contextually is the seam, not the authority. The claim it hands + * out was minted for one ordinal of one grid and cannot be forged, copied + * usefully, or kept past the expansion that owns it — so a replaced context + * yields a pane terminal nobody owns rather than a way into one somebody does. + * + * Absence is the ordinary case and means "not in a pane": work outside a grid + * reads nothing here and goes on competing for the root lease exactly as it + * always has. + */ + +import { createContext } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalPaneClaim } from "./authority.ts"; + +/** The pane the current work is running in. */ +export interface PaneTerminal { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** + * Run one interactive operation as this pane's owner. + * + * `body` receives the pane's readiness latch and must call it from the + * runtime's successful child-spawn event, before it waits for the child to + * exit. A body that never spawns never reports, and the grid it belongs to + * never attaches — which is what stops a pane that failed to start being + * presented as one that is running. + * + * A second interactive operation while one is live on this pane is refused. + * Two panes do not contend with each other at all. + */ + interactive(body: (spawned: () => void) => Operation): Operation; +} + +const PaneTerminalContext: Context = createContext< + PaneTerminal | undefined +>("core.terminal.pane", undefined); + +/** The pane the current work is running in, or `undefined` outside a grid. */ +export function paneTerminal(): Operation { + return PaneTerminalContext.get(); +} + +/** + * Install one pane's seam for the scope that runs that pane's work. + * + * Set rather than composed: a pane is not a layer over the enclosing pane, + * because panes do not nest. A grid written inside a pane is refused by the + * grammar, so the value a pane's scope holds is always its own. + */ +export function* usePaneTerminal(claim: TerminalPaneClaim): Operation { + yield* PaneTerminalContext.set({ + ordinal: claim.ordinal, + interactive(body) { + return claim.admit(() => body(() => claim.ready())); + }, + }); +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c41f34206..e9a990c9a 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -146,6 +146,23 @@ export type { NativeLaunchOutcome, NativeLaunchRequest, } from "./launcher.ts"; +export { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "./terminal.ts"; +export type { + ControlledTerminalProviderOptions, + TerminalComposite, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalProviderHandler, + TerminalProviderLog, + TerminalShellOutcome, +} from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts new file mode 100644 index 000000000..1fac60e3d --- /dev/null +++ b/packages/runtime/terminal.ts @@ -0,0 +1,282 @@ +/** + * The terminal provider — how a host presents one grid of interactive panes. + * + * This is not the native launcher. A launch hands **one** child the whole + * foreground terminal and waits for it; a grid divides that terminal into + * several panes that stay interactive at the same time, each with its own + * lifetime. tmux is one way to do that, a host-native composite UI is another, + * and a test surface that opens no terminal at all is a third. None of them + * appears in the document: `` asks for panes and their authored + * layout, and the host chooses what presents them. + * + * A grid is prepared before it is shown, which is what makes opening one atomic: + * + * 1. `prepare()` builds the whole composite while it is still hidden — every + * pane endpoint and its supervision — and presents nothing. A host that + * cannot open a grid refuses here, before any pane has started work. + * 2. Core starts the authored panes concurrently and waits for every one of + * them to be ready. + * 3. `attach()` shows the composite, once, after that barrier. A failure before + * it discards the hidden composite instead of leaving a partial grid on the + * reader's screen. + * 4. `destroy()` takes it down again and gives the root terminal back. + * + * There is no host default. `xmd run` installs the production provider; a test + * or embedding host installs a controlled one that needs no terminal. Until one + * is installed every operation refuses, which is what keeps writing, inspecting + * and validating a document free of all of this. + * + * **Presentation never decides an outcome.** `update()` receives the pane states + * core has already settled on, so a provider draws them and answers for none of + * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** One pane the provider is asked to present, by its authored ordinal. */ +export interface TerminalPaneRequest { + /** The pane's identity: its position among the grid's panes, from zero. */ + readonly ordinal: number; + /** The label to display. Two panes may carry the same one. */ + readonly title: string; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** + * Whether the document supplies this pane's work or the host's default shell + * does. A provider reads it to know which panes it must start a shell in. + */ + readonly form: "paired" | "self-closing"; +} + +/** + * The grid one expansion asks for. + * + * Provider-neutral throughout: it names no terminal, multiplexer, socket, + * process, window or pane identifier, and carries no command, argv or + * environment. It is what the author wrote, resolved. + */ +export interface TerminalGridRequest { + readonly columns: number; + readonly rows: number; + readonly panes: readonly TerminalPaneRequest[]; +} + +/** + * What core tells a provider about one pane, as it happens. + * + * A closed set, and display only. `running` follows readiness, `succeeded` and + * `failed` follow the pane's own settlement, and `closed` is a live pane + * cancelled solely because the reader closed the grid — which is not a failure + * and is deliberately spelled differently from one. + */ +export type TerminalPaneState = "starting" | "running" | "succeeded" | "failed" | "closed"; + +/** How a pane's default shell ended. */ +export interface TerminalShellOutcome { + exitCode?: number; + signal?: string; +} + +/** + * One prepared, still-hidden grid. + * + * Everything here belongs to the one `prepare()` that produced it. A composite + * is never reused across expansions, and a provider that hands the same one + * back twice has handed back a grid the second expansion did not ask for. + */ +export interface TerminalComposite { + /** + * Show the composite. Called once, and only after every pane is ready. + * + * A provider that has to place panes does it here rather than during + * preparation, so the reader never sees a grid fill in. + */ + attach(): Operation; + /** + * Display one pane's state. Called with states core has already decided. + * + * Its return value is ignored on purpose: drawing a status is not a chance to + * change one. + */ + update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Start the host's default interactive shell in one pane and report how it + * ended. + * + * Which shell that is comes from live host policy, never from the document. + * The bytes it exchanges with the reader belong to the pane: nothing captures + * or journals them. + * + * `spawned` is the pane's readiness latch, and calling it is the only thing + * that makes this pane ready. Call it from the runtime's successful + * child-spawn event and before waiting for the child to exit — so a shell + * that starts and exits at once is both ready and settled, while a shell that + * never started leaves the latch alone and the grid never attaches. + */ + shell(ordinal: number, spawned: () => void): Operation; + /** + * Settle when the reader closes or leaves the composite. + * + * A grid stays visible after its panes have settled, so this is what tells + * core the reader is finished with it. + */ + closed(): Operation; + /** + * Take the composite down and give the root terminal back. + * + * Called exactly once for every composite `prepare()` returned, including one + * discarded before it ever attached. + */ + destroy(): Operation; +} + +export interface TerminalProviderHandler { + /** Build the whole hidden composite for `request`, presenting nothing. */ + prepare(request: TerminalGridRequest): Operation; +} + +export const TERMINAL_PROVIDER_UNAVAILABLE = + "no terminal provider is installed — this host does not present a grid of " + + "interactive panes. `xmd run` installs one; a test or embedding host installs " + + "its own."; + +export class TerminalProviderUnavailableError extends Error { + override name = "TerminalProviderUnavailableError"; + constructor(message: string = TERMINAL_PROVIDER_UNAVAILABLE) { + super(message); + } +} + +/** + * The stable contextual boundary a grid request travels. + * + * Middleware composed here may observe, narrow, refuse, wrap or delegate a + * request — everything composition needs. What it cannot do is authorize one: + * the terminal authority that mints pane claims and takes terminal ownership is + * delivered directly to the installed provider and reachable from nowhere else, + * so a handler that answers without delegating has presented nothing. + */ +export const TerminalProvider: Api = createApi( + "runtime.terminalProvider", + { + // deno-lint-ignore require-yield + *prepare(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); + }, + }, +); + +/** Build the hidden composite for one grid expansion. */ +export function prepareTerminalGrid(request: TerminalGridRequest): Operation { + return TerminalProvider.operations.prepare(request); +} + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; +} + +/** + * What a controlled provider does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` + * decides what a self-closing pane's shell did and how long it took, and + * `close` is the operation the grid waits on, so a suite controls exactly when + * the reader leaves. + */ +export interface ControlledTerminalProviderOptions { + /** Appended to as the provider works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * What a pane's shell did. + * + * It receives the readiness latch, so a suite decides whether this shell + * reports a spawn at all — which is how "never started" is told apart from + * "started and exited immediately". + */ + shell?: (ordinal: number, spawned: () => void) => Operation; + close?: () => Operation; +} + +/** + * Install a provider that presents nothing and records everything. + * + * It answers the whole contract — prepare, attach, update, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function* installControlledTerminalProvider( + options: ControlledTerminalProviderOptions = {}, +): Operation { + const log = options.log ?? { events: [] }; + let prepared = 0; + + yield* TerminalProvider.around( + { + *prepare([request]): Operation { + if (options.onPrepare) { + yield* options.onPrepare(request); + } + const generation = prepared++; + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn + // would hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken + // down more times than it was built, which is exactly the ordering + // claim a suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + }, + }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts new file mode 100644 index 000000000..59ee75e89 --- /dev/null +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -0,0 +1,271 @@ +/** + * Tier TG — the terminal provider boundary (architecture.md §Terminal + * authority, spec §6.21). + * + * What a host installs to present a grid, and what composing middleware around + * it may and may not do. Nothing here opens a terminal, looks for a + * multiplexer, or starts a process: the whole point of the boundary is that the + * language does not depend on any of that, so a suite that needed one would be + * testing the wrong thing. + * + * The controlled provider records what it was asked to do, in order. Ordering + * claims are read off that record rather than inferred from timing, because a + * grid that attached too early and a grid that attached on time can take the + * same wall clock. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { + installControlledTerminalProvider, + prepareTerminalGrid, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalProvider, + TerminalProviderUnavailableError, +} from "../terminal.ts"; +import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; + +/** A two-by-one grid: the smallest request that still has two ordinals. */ +function request(overrides: Partial = {}): TerminalGridRequest { + return { + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + ...overrides, + }; +} + +function log(): TerminalProviderLog { + return { events: [] }; +} + +describe("Tier TG — the provider boundary", () => { + it("TP1: refuses when no host has installed a provider", function* () { + let refusal: unknown; + yield* scoped(function* () { + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal).toBeInstanceOf(TerminalProviderUnavailableError); + expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); + }); + + it("TP2: an installed provider prepares without presenting anything", function* () { + const record = log(); + const events = yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* prepareTerminalGrid(request()); + return [...record.events]; + }); + + // Preparation happened; nothing was shown. A composite the reader can see + // before every pane is ready is the one thing atomic startup forbids. + expect(events).toEqual(["prepare:0:2x1"]); + expect(events.some((event) => event.startsWith("attach:"))).toBe(false); + }); + + it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { + const record = log(); + const spawns: number[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.update(0, "starting"); + yield* composite.update(0, "running"); + yield* composite.shell(1, () => spawns.push(1)); + yield* composite.attach(); + yield* composite.update(0, "succeeded"); + yield* composite.closed(); + yield* composite.destroy(); + }); + + expect(record.events).toEqual([ + "prepare:0:2x1", + "state:0:0:starting", + "state:0:0:running", + "shell:0:1", + "attach:0", + "state:0:0:succeeded", + "closed:0", + "destroy:0", + ]); + // The default shell starts, and says so through the latch it was handed: + // readiness is reported by the shell rather than assumed by the grid. + expect(spawns).toEqual([1]); + }); + + it("TP5: a shell that never starts never reports a spawn", function* () { + const spawns: number[] = []; + const outcome = yield* scoped(function* () { + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *shell(_ordinal, _spawned) { + // No spawn event: nothing started, so nothing is acknowledged. + return { exitCode: 127 }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + return yield* composite.shell(1, () => spawns.push(1)); + }); + + expect(outcome).toEqual({ exitCode: 127 }); + expect(spawns).toEqual([]); + }); + + it("TP3: middleware observes a delegated request without changing it", function* () { + const record = log(); + const seen: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the provider still saw the same grid. + expect(record.events).toEqual(["prepare:0:3x2"]); + }); + + it("TP3: middleware refuses a request, and no composite is ever built", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + // deno-lint-ignore require-yield + *prepare(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + // Refusing means refusing: the provider below was never reached, so there + // is no hidden composite left needing teardown. + expect(record.events).toEqual([]); + }); + + it("TP3: middleware narrows a request before the provider sees it", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* prepareTerminalGrid(request()); + }); + + expect(record.events).toEqual(["prepare:0:1x2"]); + }); + + it("TP4: middleware wraps the composite it delegated for", function* () { + const record = log(); + const wrapped: string[] = []; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + yield* TerminalProvider.around({ + *prepare([asked], next) { + const composite = yield* next(asked); + return { + ...composite, + *attach() { + wrapped.push("before"); + yield* composite.attach(); + wrapped.push("after"); + }, + }; + }, + }); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.attach(); + yield* composite.destroy(); + }); + + expect(wrapped).toEqual(["before", "after"]); + expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); + }); + + it("TP5: a preparation failure leaves nothing to tear down", function* () { + const record = log(); + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider({ + log: record, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* prepareTerminalGrid(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "no pane endpoint could be created", + ); + // The failure happened before the composite existed, so the record shows + // no composite was built and none is owed a destroy. + expect(record.events).toEqual([]); + }); + + it("TP5: a composite refuses to be destroyed twice", function* () { + let refusal: unknown; + yield* scoped(function* () { + yield* installControlledTerminalProvider(); + const composite = yield* prepareTerminalGrid(request()); + yield* composite.destroy(); + try { + yield* composite.destroy(); + } catch (error) { + refusal = error; + } + }); + + // Teardown ordering is only readable if a double destroy is loud. A silent + // second destroy would let a suite prove an ordering that never held. + expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); + }); + + it("TP6: each preparation is its own composite", function* () { + const record = log(); + yield* scoped(function* () { + yield* installControlledTerminalProvider({ log: record }); + const first = yield* prepareTerminalGrid(request()); + const second = yield* prepareTerminalGrid(request()); + yield* first.destroy(); + yield* second.destroy(); + }); + + // Two expansions are two grids. A provider that handed the same composite + // back would have presented the second expansion's grid as the first's. + expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + }); +}); From 4ba3f068d54b0951cb410043d02a55d760a0cc5a Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:07:19 -0400 Subject: [PATCH 05/62] =?UTF-8?q?=E2=9C=A8=20Run=20a=20terminal=20grid's?= =?UTF-8?q?=20panes=20concurrently=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runTerminalGrid()` owns the lifecycle the reader sees: it takes the run's one foreground-terminal lease, flushes root output, prepares the composite while it stays hidden, starts every pane concurrently, and attaches only once every pane has reported a spawn through its claim. Ordering is the contract. The lease and the composite are both scope-owned, so success, failure and cancellation all release the terminal and destroy exactly the composite that was prepared — there is no path that skips teardown. A pane that settles without ever reporting a spawn fails startup rather than being presented as a running pane. Before the barrier a pane failure fails the whole grid closed; after it, the failure is that pane's status and its siblings keep running. Close cancels a live pane as `closed`, which is not a failed pane, and the grid fails with the first failed pane in authored order. `display()` and an `onUpdate` hook complete the provider surface: a pane's rendered text goes to that pane, and a suite reacts to a state the grid decided rather than waiting and hoping. Evidence: `packages/core/tests/terminal-grid.test.ts` (15 rows) and `packages/runtime/tests/terminal-provider.test.ts` (11 rows). The readiness barrier row was verified by removing the barrier: it fails without it. --- packages/core/src/terminal/grid.ts | 207 +++++++ packages/core/tests/terminal-grid.test.ts | 507 ++++++++++++++++++ packages/runtime/terminal.ts | 34 +- .../runtime/tests/terminal-provider.test.ts | 2 +- 4 files changed, 748 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal/grid.ts create mode 100644 packages/core/tests/terminal-grid.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts new file mode 100644 index 000000000..9f712da1d --- /dev/null +++ b/packages/core/src/terminal/grid.ts @@ -0,0 +1,207 @@ +/** + * One terminal grid, from the lease to the last finalizer (spec §6.21, + * architecture.md §Atomic presentation and settlement). + * + * Opening a grid is atomic from the reader's side, and that is the whole shape + * of this module. The composite is built while it is still hidden, every pane + * starts concurrently, and only once all of them have actually started does + * anything appear. A failure before that barrier discards the hidden composite + * instead of leaving half a grid on the screen. + * + * Ordering is the contract, not an implementation detail: + * + * ``` + * lease → flush → prepare → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released + * ``` + * + * Nothing here decides what a pane *is* — the layout arrived already derived, + * and the work each pane does is supplied by the caller. What this owns is + * whose terminal it is, when a pane counts as started, what happens when one + * fails, and the order in which it all comes apart. + */ + +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; + +import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; +import type { TerminalPaneClaim } from "./authority.ts"; +import type { TerminalGridLayout } from "../terminal-grid.ts"; + +/** How one pane ended. */ +export type PaneOutcome = + | { readonly kind: "succeeded" } + | { readonly kind: "failed"; readonly error: Error } + /** Live when the reader closed the grid. Cancellation, not failure. */ + | { readonly kind: "closed" }; + +/** + * What one pane does once its claim exists. + * + * The caller supplies this because a pane's work is the document's: a paired + * pane expands its authored content, and a self-closing one runs the host's + * default shell. Both run as the pane's admitted owner, and both are expected + * to report a spawn through the claim before anything can attach. + */ +export interface PaneWork { + readonly ordinal: number; + run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; +} + +/** Everything the grid settled, in authored pane order. */ +export interface GridResult { + readonly outcomes: readonly PaneOutcome[]; + /** Why the grid failed, which is the first failed pane in authored order. */ + readonly failure?: Error; +} + +/** + * What a pane that never reported a spawn says. + * + * A pane whose work finished without ever starting something interactive has + * not started: presenting it as a running pane would be presenting a grid the + * reader cannot use. + */ +export function paneNeverStartedMessage(ordinal: number, title: string): string { + return ( + `pane ${ordinal} ("${title}") finished without starting anything interactive, so the ` + + `grid never opened. A pane runs an interactive child — a , or the ` + + `default shell a self-closing starts.` + ); +} + +class PaneStartupError extends Error { + override name = "PaneStartupError"; + readonly ordinal: number; + constructor(ordinal: number, message: string) { + super(message); + this.ordinal = ordinal; + } +} + +/** + * Run one grid to completion and report what its panes settled to. + * + * The foreground lease and the composite are both scope-owned, so every path + * out of here — success, failure, and cancellation alike — releases the + * terminal and destroys exactly the composite that was prepared. That is why + * teardown is not written as a step: there is no path that can skip it. + */ +export function runTerminalGrid( + layout: TerminalGridLayout, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + const request = toRequest(layout); + + // The one foreground-terminal lease. A root and a grid + // contend for exactly this, so neither can begin while the other holds it, + // and a host with no terminal refuses here — before any pane has done work. + yield* reserveTerminal(); + // Everything the document has produced so far reaches the reader before the + // grid covers it up. + yield* flushOutput(); + + const composite = yield* prepareTerminalGrid(request); + // Registered before a single pane starts: a composite that was prepared is + // owed a destroy even if the next line is what fails. + yield* ensure(() => composite.destroy()); + + const grid = createTerminalGridClaims(request); + // Nothing new is admitted once teardown begins, so a pane that was about to + // start an interactive child is refused rather than racing the close. + yield* ensure(() => { + grid.seal(); + }); + + const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const startupFailed = withResolvers(); + let attached = false; + + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { + const claim = grid.claims[index]!; + const readiness = grid.readiness[index]!; + yield* composite.update(pane.ordinal, "starting"); + panes.push( + yield* spawn(function* () { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: that is a startup failure even + // though the work itself raised nothing. + throw new PaneStartupError( + pane.ordinal, + paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + ); + } + outcomes[index] = { kind: "succeeded" }; + yield* composite.update(pane.ordinal, "succeeded"); + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + outcomes[index] = { kind: "failed", error: failure }; + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status and its siblings + // keep running. + if (!attached) { + startupFailed.reject(failure); + return; + } + yield* composite.update(pane.ordinal, "failed"); + } + }), + ); + } + + // Every pane must actually have started before anything is shown. Racing + // the barrier against startup failure is what stops a grid whose pane + // already failed from waiting forever for a latch nothing will acknowledge. + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + + for (const pane of work) { + yield* composite.update(pane.ordinal, "running"); + } + yield* composite.attach(); + attached = true; + + // The composite stays visible after its panes settle. The reader leaving is + // what finishes the grid, not the last pane exiting. + yield* composite.closed(); + + // Close prevents new work first, then takes the live panes down: a pane + // cancelled by the close is `closed`, which is not a failed pane. + grid.seal(); + for (const [index, task] of panes.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(work[index]!.ordinal, "closed"); + outcomes[index] = { kind: "closed" }; + } + yield* task.halt(); + } + + const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); + const failed = settled.find((outcome) => outcome.kind === "failed"); + return { + outcomes: settled, + ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + }; + }); +} + +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return { + columns: layout.columns, + rows: layout.rows, + panes: layout.cells.map((cell) => ({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + })), + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts new file mode 100644 index 000000000..19d9e4d73 --- /dev/null +++ b/packages/core/tests/terminal-grid.test.ts @@ -0,0 +1,507 @@ +/** + * Tier TG — running a terminal grid through a replaceable provider + * (spec §6.21, architecture.md §Atomic presentation and settlement). + * + * The provider here is controlled and is not tmux: it opens no terminal, starts + * no process, and records what it was asked to do in the order it was asked. + * Every ordering claim is read off that record. Nothing is inferred from + * timing, because a grid that attached too early and one that attached on time + * take the same wall clock. + * + * Readiness is the claim these rows care about most, so it is always driven + * explicitly: a pane becomes ready because something called the latch it was + * handed, never because it got far enough. That is what lets "started" and + * "did some work" be told apart at all. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { + installControlledLauncher, + installControlledTerminalProvider, +} from "@executablemd/runtime"; +import type { TerminalProviderLog } from "@executablemd/runtime"; + +import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; +import { runTerminalGrid } from "../src/terminal/grid.ts"; +import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; +import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { TerminalGridLayout } from "../src/terminal-grid.ts"; + +function log(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + +/** A layout of `count` panes across `columns`, titled by ordinal. */ +function layoutOf(columns: number, count: number): TerminalGridLayout { + return terminalGridLayout( + columns, + Array.from({ length: count }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ); +} + +/** A pane that starts, does what `body` says, and settles. */ +function pane(ordinal: number, body?: () => Operation): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + if (body) { + yield* body(); + } + }); + }, + }; +} + +/** Everything a grid run needs installed, with the reader's close under control. */ +function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { + // A grid takes the same one foreground lease a root takes, + // so a host that offers a grid still has to offer that lease. + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ log: record, close }); +} + +/** A pane that records when it started, so ordering is read rather than timed. */ +function readyPane(ordinal: number, timeline: string[]): PaneWork { + return { + ordinal, + *run(claim) { + yield* claim.admit(function* () { + timeline.push(`ready:${ordinal}`); + claim.ready(); + yield* suspend(); + }); + }, + }; +} + +/** Close as soon as the reader is asked, which is the ordinary journey. */ +function immediateClose(): () => Operation { + // deno-lint-ignore require-yield + return function* () {}; +} + +describe("Tier TG — pane claims and readiness", () => { + it("TG8: a claim admits one interactive operation at a time", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; + + yield* scoped(function* () { + yield* first.admit(function* () { + // A second operation on the same pane is refused while this one is live. + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + // A different pane does not contend at all, which is the whole reason a + // grid exists. + yield* second.admit(function* () { + concurrent = true; + }); + }); + }); + + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); + }); + + it("TG8: a pane admits again once its first operation has settled", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let second = false; + + yield* scoped(function* () { + yield* claim.admit(function* () {}); + yield* claim.admit(function* () { + second = true; + }); + }); + + // Sequential work in one pane is ordinary composition, not contention. + expect(second).toBe(true); + }); + + it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + grid.seal(); + let refusal: unknown; + + yield* scoped(function* () { + try { + yield* claim.admit(function* () {}); + } catch (error) { + refusal = error; + } + }); + + // A claim kept past its grid is a claim to a terminal nobody owns. + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + }); + + it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; + + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TG8: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], + }); + } catch (error) { + refusal = error; + } + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); + }); +}); + +describe("Tier TG — atomic startup", () => { + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const record = log(); + // One ordered record both the panes and the provider write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that would prove nothing — a pane says when it actually started. + const timeline: string[] = []; + const slow = withResolvers(); + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: immediateClose(), + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + }); + return yield* runTerminalGrid(layoutOf(2, 3), [ + readyPane(0, timeline), + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + // Plenty of work before anything starts, and none of it makes the + // grid attachable. The delay is long enough that a grid which + // skipped the barrier would demonstrably attach first. + yield* sleep(25); + timeline.push("ready:1"); + claim.ready(); + yield* slow.operation; + }); + }, + }, + readyPane(2, timeline), + ]); + }); + + expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); + expect(result.failure).toBeUndefined(); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const record = log(); + let failure: unknown; + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0), + { + ordinal: 1, + // Runs, settles, and never reports a spawn. + *run() {}, + }, + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "finished without starting anything interactive", + ); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(record.events).not.toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); + try { + yield* runTerminalGrid(layoutOf(2, 2), [ + pane(0, function* () { + started.push(0); + }), + pane(1, function* () { + started.push(1); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toBe( + "no pane endpoint could be created", + ); + expect(started).toEqual([]); + }); + + it("TG9: a grid refuses before preparation when no provider is installed", function* () { + const started: number[] = []; + let failure: unknown; + + yield* scoped(function* () { + yield* installControlledLauncher(); + try { + yield* runTerminalGrid(layoutOf(1, 1), [ + pane(0, function* () { + started.push(0); + }), + ]); + } catch (error) { + failure = error; + } + }); + + expect(failure instanceof Error ? failure.message : "").toContain( + "no terminal provider is installed", + ); + expect(started).toEqual([]); + }); +}); + +describe("Tier TG — settlement and close", () => { + it("TG10: a pane fails after attach while its siblings stay live", function* () { + const record = log(); + // The reader leaves once the grid has displayed the failure, so the sibling + // is provably still live when that happens rather than probably still live. + const failed = withResolvers(); + let siblingLiveAtFailure = false; + let siblingLive = false; + + const result = yield* scoped(function* (): Operation { + yield* installControlledLauncher(); + yield* installControlledTerminalProvider({ + log: record, + close: () => failed.operation, + onUpdate(ordinal, state) { + if (ordinal === 0 && state === "failed") { + siblingLiveAtFailure = siblingLive; + failed.resolve(); + } + }, + }); + return yield* runTerminalGrid(layoutOf(2, 2), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* sleep(1); + throw new Error("pane 0 stopped"); + }); + }, + }, + { + ordinal: 1, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + siblingLive = true; + try { + yield* suspend(); + } finally { + siblingLive = false; + } + }); + }, + }, + ]); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("state:0:0:failed"); + // The sibling was still running when its neighbour failed: an ordinary pane + // failure after attach is contained as that pane's status. + expect(siblingLiveAtFailure).toBe(true); + expect(result.outcomes[0]?.kind).toBe("failed"); + expect(result.outcomes[1]?.kind).toBe("closed"); + // The grid fails with the first failed pane in authored order. + expect(result.failure?.message).toBe("pane 0 stopped"); + }); + + it("TG12: close cancels a live pane as closed rather than failed", function* () { + const record = log(); + + const result = yield* scoped(function* (): Operation { + yield* useGridHost(record, immediateClose()); + return yield* runTerminalGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + // Still live when the reader leaves. + yield* suspend(); + }); + }, + }, + ]); + }); + + // Teardown cancellation is not a pane failure, and the grid succeeds. + expect(result.outcomes[0]?.kind).toBe("closed"); + expect(result.failure).toBeUndefined(); + expect(record.events).toContain("state:0:0:closed"); + }); + + it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, immediateClose()); + yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + }); + + const closed = record.events.indexOf("closed:0"); + const destroyed = record.events.indexOf("destroy:0"); + expect(closed).toBeGreaterThan(-1); + expect(destroyed).toBeGreaterThan(closed); + expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); + }); + + it("TG13: parent cancellation tears the grid down completely", function* () { + const record = log(); + + yield* scoped(function* () { + yield* useGridHost(record, () => suspend()); + // The grid never closes on its own; the enclosing scope ending is what + // takes it down, and that has to be a complete teardown. + yield* scoped(function* () { + yield* spawnGrid(layoutOf(1, 1), [ + { + ordinal: 0, + *run(claim) { + yield* claim.admit(function* () { + claim.ready(); + yield* suspend(); + }); + }, + }, + ]); + yield* sleep(2); + }); + }); + + expect(record.events).toContain("attach:0"); + expect(record.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — the pane seam", () => { + it("TG6: work inside a pane runs as that pane's owner", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + let sawOrdinal: number | undefined; + let acknowledged = false; + + yield* scoped(function* () { + yield* usePaneTerminal(claim); + const seam = yield* paneTerminal(); + sawOrdinal = seam?.ordinal; + yield* seam!.interactive(function* (spawned) { + spawned(); + acknowledged = grid.readiness[0]!.acknowledged; + }); + }); + + expect(sawOrdinal).toBe(0); + // The seam is how anything interactive reports its spawn, so readiness + // travels with the work rather than being asserted around it. + expect(acknowledged).toBe(true); + }); + + it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { + const seam = yield* scoped(function* () { + return yield* paneTerminal(); + }); + expect(seam).toBeUndefined(); + }); +}); + +/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ +function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { + yield* spawn(function* () { + yield* runTerminalGrid(layout, work); + }); +} diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 1fac60e3d..cc34203c0 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -102,6 +102,17 @@ export interface TerminalComposite { * change one. */ update(ordinal: number, state: TerminalPaneState): Operation; + /** + * Show text a pane's own content rendered. + * + * This is where a paired pane's output goes, and the only place it goes: it + * is never copied into the root document output or into a capture written + * around the grid, because the reader is looking at the pane. Terminal bytes + * an interactive child exchanges with the reader never come through here at + * all — those belong to the pane's terminal and are neither captured nor + * journaled. + */ + display(ordinal: number, text: string): Operation; /** * Start the host's default interactive shell in one pane and report how it * ended. @@ -183,6 +194,13 @@ export function prepareTerminalGrid(request: TerminalGridRequest): Operation; } /** @@ -200,6 +218,13 @@ export interface ControlledTerminalProviderOptions { onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting a while and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; /** * What a pane's shell did. * @@ -221,7 +246,8 @@ export interface ControlledTerminalProviderOptions { export function* installControlledTerminalProvider( options: ControlledTerminalProviderOptions = {}, ): Operation { - const log = options.log ?? { events: [] }; + const log = options.log ?? { events: [], shown: new Map() }; + const shown = log.shown; let prepared = 0; yield* TerminalProvider.around( @@ -243,6 +269,12 @@ export function* installControlledTerminalProvider( // deno-lint-ignore require-yield *update(ordinal, state) { log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + const pane = shown.get(ordinal) ?? ""; + shown.set(ordinal, pane + text); }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 59ee75e89..9e01204a7 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -42,7 +42,7 @@ function request(overrides: Partial = {}): TerminalGridRequ } function log(): TerminalProviderLog { - return { events: [] }; + return { events: [], shown: new Map() }; } describe("Tier TG — the provider boundary", () => { From df61e96f99f6350bcb0125b9a43ad0177e54b65e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:39:27 -0400 Subject: [PATCH 06/62] =?UTF-8?q?=E2=9C=A8=20Run=20a=20document's=20termin?= =?UTF-8?q?al=20grid=20panes=20through=20the=20provider=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` now executes. Each authored pane becomes a concurrent child of the grid: a self-closing pane runs the host's default shell through its claim, and a paired pane expands its own content in a scope of its own. A pane inherits the bindings, providers, configuration and working directory visible where the grid was written, and keeps everything it creates afterwards. Its `` has no loop to exit, its `` has no enclosing value body to claim, and its checked failures settle the pane rather than reaching the root or a sibling. A pane's rendered text is displayed in that pane; the grid itself renders `""`, so the root output holds what surrounds the grid and no pane display at all. #729's five execution-dependent rows move here, where they assert the layout against the request the provider actually receives rather than reading it off a refusal's cause — the structural suite keeps the grammar, placement and pure-layout rows it owns. Moving them was authorized rather than assumed. Evidence: 22 rows in `packages/core/tests/terminal-grid.test.ts` and 11 in `packages/runtime/tests/terminal-provider.test.ts`; the whole Deno core (350) and runtime (14) suites pass. --- packages/core/src/expand.ts | 155 ++++++-- .../tests/terminal-grid-structure.test.ts | 125 ------- packages/core/tests/terminal-grid.test.ts | 344 +++++++++++++++++- 3 files changed, 468 insertions(+), 156 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index a6aef2839..738b3569a 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,6 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; +import { runTerminalGrid } from "./terminal/grid.ts"; +import type { PaneWork } from "./terminal/grid.ts"; +import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, asExpressionViolation, @@ -142,7 +145,7 @@ import { import { remark } from "remark"; import { select as cssSelect } from "unist-util-select"; import { toString as mdastToString } from "mdast-util-to-string"; -import { liveEnvironment } from "./live-env.ts"; +import { derivedEnvironment, liveEnvironment } from "./live-env.ts"; import { TestHarnessComponentDefinition } from "./test-harness.ts"; import type { TestHarnessBinding } from "./test-harness.ts"; @@ -1184,7 +1187,15 @@ function* expandListSegments( if (segment.name === "Terminal.Grid") { // No raise() here, like the branches above: expandTerminalGrid // reports every error it creates. - yield* expandTerminalGrid(segment, result); + yield* expandTerminalGrid(segment, result, { + parentMeta, + parentProps, + hideSet, + counter, + path: elementPath, + checkedFailures, + authority, + }); break; } @@ -2105,7 +2116,22 @@ function* resolveStructuralProp( * does, which is what makes the refusal a closed one rather than a partial grid * left behind. */ -function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { +/** Everything a pane's own content needs to expand where the grid was written. */ +interface GridSite { + readonly parentMeta: Record; + readonly parentProps: Record; + readonly hideSet: Set; + readonly counter: BlockCounter; + readonly path: string; + readonly checkedFailures: CheckedFailures | undefined; + readonly authority: ExpansionAuthority | undefined; +} + +function* expandTerminalGrid( + segment: ComponentElement, + owner: Segment[], + site: GridSite, +): Operation { const structure = terminalGridStructure(segment); if (structure.violations.length > 0) { for (const violation of structure.violations) { @@ -2140,23 +2166,105 @@ function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Opera } const layout = terminalGridLayout(columns.value, placed); - owner.push( - yield* raise({ - type: "error", - message: positioned(noTerminalProviderMessage(), segment), - source: "Terminal.Grid", - // The grid the author asked for, carried beside the sentence so an - // assertion is about the layout that was derived rather than about the - // wording of a refusal. - cause: { - layout: { - columns: layout.columns, - rows: layout.rows, - cells: layout.cells.map((cell) => ({ ...cell })), - }, - }, - }), + // The grid renders nothing into the document: what a pane shows belongs to + // that pane, and the sibling after `` renders to the root + // again only once the provider has restored it. + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site, segment), ); + + try { + const result = yield* runTerminalGrid(layout, work); + if (result.failure !== undefined) { + owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + } + } catch (error) { + owner.push( + yield* raise( + terminalGridError(segment, error instanceof Error ? error.message : String(error)), + ), + ); + } +} + +/** + * What one authored pane does once the grid has minted its claim. + * + * A self-closing pane runs the host's default shell through its claim. A paired + * pane expands its own content in a scope of its own: it inherits the bindings, + * providers, configuration and working directory visible where the grid was + * written, and everything it creates afterwards stays inside the pane. Its + * `` cannot reach a loop outside the grid, its `` cannot claim an + * enclosing body, and a checked failure settles the pane rather than poisoning + * the root or a sibling. + */ +function paneWork( + pane: TerminalPane, + title: string, + site: GridSite, + grid: ComponentElement, +): PaneWork { + if (pane.form === "self-closing") { + return { + ordinal: pane.ordinal, + *run(claim, composite) { + const outcome = yield* claim.admit(() => + composite.shell(pane.ordinal, () => claim.ready()), + ); + if (outcome.signal !== undefined) { + throw new Error(`pane ${pane.ordinal} ("${title}") shell ended on ${outcome.signal}`); + } + if (outcome.exitCode !== undefined && outcome.exitCode !== 0) { + throw new Error( + `pane ${pane.ordinal} ("${title}") shell exited with status ${outcome.exitCode}`, + ); + } + }, + }; + } + + return { + ordinal: pane.ordinal, + *run(claim, composite) { + yield* scoped(function* () { + // A pane is not inside the loop the grid was written in, so a + // in its content has no loop to exit and says so. + yield* ActiveLoop.set(undefined); + yield* usePaneTerminal(claim); + const siteEnv = yield* env; + // Starts from what the grid site can see and keeps its own writes: a + // binding this pane makes is visible to later work in this pane and to + // nothing else. + yield* provideEnv(derivedEnvironment(siteEnv, { ...(siteEnv?.values ?? {}) })); + + const shown: Segment[] = []; + yield* expandSegmentsWithin( + pane.element.children, + site.parentMeta, + site.parentProps, + site.hideSet, + site.counter, + shown, + extendPath( + site.path, + elementFrame(pane.element.name, elementSite(pane.element.position, pane.index)), + ), + 0, + // The pane's own ledger: a checked failure settles this pane and + // cannot reach the root or a sibling. + containedLedger(site.checkedFailures), + site.authority, + // No enclosing value body: a written in a pane cannot claim + // one outside the grid. + undefined, + ); + const text = renderSegments(shown); + if (text.length > 0) { + yield* composite.display(pane.ordinal, text); + } + }); + }, + }; } /** The label one pane displays, from the value its own `title` prop produced. */ @@ -2171,15 +2279,6 @@ function* resolvePaneTitle(pane: TerminalPane): Operation> { return terminalTitle(value.value); } -/** What a complete grid says on a host where nothing can open one. */ -function noTerminalProviderMessage(): string { - return ( - "no terminal provider opened this grid. A host installs the terminal-grid capability " + - "explicitly, and this one installs none, so no pane expanded its content and no default " + - "shell started." - ); -} - function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 76c440cba..626cbf34b 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -130,31 +130,6 @@ const PANE_BODY = [ ].join("\n"); describe("Tier TG — the grid grammar", () => { - it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { - const run = yield* runGrid( - [ - "", - 'Instructions.', - '', - "", - ].join("\n"), - ); - - // The grammar accepted it, so the run reached the one thing this build - // cannot do — and stopped there. - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, - ], - }, - }); - }); - it("TG1: refuses an unknown prop and `as` on the grid", function* () { const unknown = yield* runGrid( '', @@ -330,52 +305,6 @@ describe("Tier TG — structural placement", () => { reachedNothing(alone); reachedNothing(buried); }); - - it("TG2: treats whitespace between panes as nothing at all", function* () { - const run = yield* runGrid( - [ - "", - "", - ' ', - "", - ' ', - "", - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no terminal provider opened this grid"); - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 1, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, - ], - }, - }); - }); - - it("TG2: a complete grid refuses before any pane body or default shell", function* () { - const run = yield* runGrid( - [ - "", - '', - "", - PANE_BODY, - "", - '', - "", - ].join("\n"), - ); - - expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); - // The pane held a component and a command; neither was reached, and the - // grid rendered nothing of its own. - reachedNothing(run); - expect(run.output).toContain("no terminal provider opened this grid"); - }); }); describe("Tier TG — row-major layout", () => { @@ -446,60 +375,6 @@ describe("Tier TG — row-major layout", () => { 1, 1, 1, 2, 2, ]); }); - - it("TG4: an executed grid derives those same positions", function* () { - const run = yield* runGrid( - [ - "", - '', - '', - '', - '', - '', - "", - ].join("\n"), - ); - - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 3, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, - { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, - { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, - { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, - ], - }, - }); - }); - - it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { - const run = yield* runGrid( - [ - "", - 'first', - '', - 'third', - "", - ].join("\n"), - ); - - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each one was written in travels with it. - expect(derivedLayout(run)).toEqual({ - layout: { - columns: 2, - rows: 2, - cells: [ - { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, - { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, - { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, - ], - }, - }); - }); }); /** Panes that differ only in count, for a row about rows. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 19d9e4d73..b360fa3fb 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -16,13 +16,23 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep, spawn, suspend, withResolvers } from "effection"; -import type { Operation } from "effection"; +import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; import { installControlledLauncher, installControlledTerminalProvider, } from "@executablemd/runtime"; -import type { TerminalProviderLog } from "@executablemd/runtime"; +import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; + +import { execute } from "../src/execute.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { Json } from "../src/types.ts"; import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; import { runTerminalGrid } from "../src/terminal/grid.ts"; @@ -505,3 +515,331 @@ function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Oper yield* runTerminalGrid(layout, work); }); } + +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; +} + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** + * The controlled interactive child, and a tripwire. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, this is what a suite writes to be that + * something — and it reaches the pane through the same seam a real launch will. + */ +function useGridComponents(ran: string[]): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + ]); +} + +/** + * Run one document against a controlled grid host. + * + * `provider: false` installs no terminal provider, which is how "a host that + * cannot open a grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const record = log(); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + if (options.provider !== false) { + yield* installControlledTerminalProvider({ + log: record, + close: () => settled.operation, + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + yield* sleep(0); + }, + onUpdate(_ordinal, state) { + if (state === "succeeded" || state === "failed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, + }); + } + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, output, requests, shown: record.shown, ran }; + }); +} + +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} + +describe("Tier TG — a grid written in a document", () => { + it("TG4: the provider is asked for exactly the authored row-major layout", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + '', + '', + '', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests).toHaveLength(1); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 3, + panes: [ + { ordinal: 0, title: "One", row: 0, column: 0, form: "self-closing" }, + { ordinal: 1, title: "Two", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Three", row: 1, column: 0, form: "self-closing" }, + { ordinal: 3, title: "Four", row: 1, column: 1, form: "self-closing" }, + { ordinal: 4, title: "Five", row: 2, column: 0, form: "self-closing" }, + ], + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'first', + '', + 'third', + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each was written in travels with it. + expect(run.requests[0]?.panes).toEqual([ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, + { ordinal: 2, title: "Agent", row: 1, column: 0, form: "paired" }, + ]); + }); + + it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + "", + 'Instructions.', + "", + '', + "", + "", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + expect(run.requests[0]).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, + ], + }); + }); + + it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "before", + "", + "", + 'left text', + 'right text', + "", + "", + "after", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Each pane's own text went to that pane. + expect(run.shown.get(0)).toContain("left text"); + expect(run.shown.get(1)).toContain("right text"); + // The grid renders "": the root output holds what surrounds it and no pane + // display at all. + expect(run.output).toContain("before"); + expect(run.output).toContain("after"); + expect(run.output).not.toContain("left text"); + expect(run.output).not.toContain("right text"); + }); + + it("TG6: a pane inherits the grid site's bindings and keeps its own", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + '', + "", + "", + '', + "sees {shared}", + "", + '', + "", + "then {mine}", + "", + "", + "", + '', + "sees {shared} and {mine}", + "", + "", + "", + "", + "", + "after {mine}", + "", + ].join("\n"), + ); + + expect(run.outcome.ok).toBe(true); + // Inherited from the grid site. + expect(run.shown.get(0)).toContain("sees site"); + expect(run.shown.get(1)).toContain("sees site"); + // Created inside one pane, visible to later work in that pane. + expect(run.shown.get(0)).toContain("then left"); + // Invisible to the sibling and to the document after the grid: an + // unresolved binding stays the literal text it was written as. + expect(run.shown.get(1)).toContain("and {mine}"); + expect(run.output).toContain("after {mine}"); + }); + + it("TG6: a pane's cannot reach a loop outside the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + "", + "", + "", + ].join("\n"), + ); + + // Refused where it was written. Had the reached the loop around the + // grid it would have exited it quietly and the document would have + // succeeded; instead the pane failed with the stray- rule, which is + // what fails the grid and then the document. + expect(failureOf(run)).toContain(" must be written inside a "); + expect(failureOf(run)).toContain("cannot break the loop that invoked it"); + expect(run.ran).toEqual(["iteration"]); + }); + + it("TG9: with no provider installed, no pane body or shell runs", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + '', + "", + "", + ].join("\n"), + { provider: false }, + ); + + expect(failureOf(run)).toContain("no terminal provider is installed"); + // The pane held work; none of it was reached, and nothing was displayed. + expect(run.ran).toEqual([]); + expect(run.shown.size).toBe(0); + }); +}); From 8ab49529c50810476918191e40766c2113fecd75 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 13:51:58 -0400 Subject: [PATCH 07/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Drop=20an=20unused?= =?UTF-8?q?=20parameter=20from=20the=20grid's=20pane=20work=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `paneWork()` never read the grid element it was handed. Its caller has it, and a pane's own diagnostics are positioned at the pane. --- packages/core/src/expand.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 738b3569a..822f5aa87 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2169,11 +2169,10 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site, segment), - ); - try { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); const result = yield* runTerminalGrid(layout, work); if (result.failure !== undefined) { owner.push(yield* raise(terminalGridError(segment, result.failure.message))); @@ -2198,12 +2197,7 @@ function* expandTerminalGrid( * enclosing body, and a checked failure settles the pane rather than poisoning * the root or a sibling. */ -function paneWork( - pane: TerminalPane, - title: string, - site: GridSite, - grid: ComponentElement, -): PaneWork { +function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { if (pane.form === "self-closing") { return { ordinal: pane.ordinal, From c0f7e52d79c28334e8eef2af90ec20bcd55778bc Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:19:22 -0400 Subject: [PATCH 08/62] =?UTF-8?q?=E2=9C=A8=20Give=20terminal=20grids=20an?= =?UTF-8?q?=20authority=20boundary=20and=20durable=20pane=20children=20(#7?= =?UTF-8?q?30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the authority boundary on the `AgentProviders` handshake, and puts each pane on its own durable child coroutine. **The boundary.** `TerminalGrids` is routing and only routing: `open()` answers `unknown` and core throws the answer away, so middleware may observe, narrow, refuse, wrap or delegate but can never authorize. The capability that takes the leases, mints pane claims and settles a grid is a non-contextual authority delivered straight to the registered provider through a one-use install handshake. Core mints one identity-bearing request per expansion; presenting a copy, a rebuilt lookalike, a changed request, an already-presented one, or one from a superseded installation generation authorizes nothing, and a handler that answers without presenting settles nothing. **Durable children.** Each pane is a durable child of the grid, allocated in authored order, so a pane's identity follows its ordinal rather than the order the runtime scheduled it in. The layout is recorded in the parent coroutine before the lease and before any provider is contacted. **Ordering.** A pane that settles before attach keeps the status it settled to instead of being overwritten with `running`, and simultaneous startup failures are selected by authored ordinal rather than by whichever rejected first. Each pane also expands under a counter of its own, so two concurrent panes cannot take block identities that depend on which ran first. `durableSpawn` could not be used: the task it returns is spawned inside the ephemeral effect's own scope, which closes as the effect resolves, so awaiting it throws `halted`. It has no call sites or tests upstream. `durableAll` is the exercised primitive and is what the panes and the grid child use. Evidence: 30 rows in `packages/core/tests/terminal-grid.test.ts` and 10 in `packages/runtime/tests/terminal-provider.test.ts`; core 349, runtime 15. --- packages/core/mod.ts | 30 + packages/core/src/expand.ts | 37 +- packages/core/src/terminal/authority.ts | 138 +- packages/core/src/terminal/grid.ts | 397 +++-- packages/core/src/terminal/journal.ts | 140 ++ packages/core/src/terminal/profile.ts | 60 + packages/core/src/terminal/provider-api.ts | 271 +++ packages/core/tests/terminal-grid.test.ts | 1452 ++++++++++------- packages/runtime/mod.ts | 11 +- packages/runtime/terminal.ts | 245 ++- .../runtime/tests/terminal-provider.test.ts | 290 ++-- 11 files changed, 2105 insertions(+), 966 deletions(-) create mode 100644 packages/core/src/terminal/journal.ts create mode 100644 packages/core/src/terminal/profile.ts create mode 100644 packages/core/src/terminal/provider-api.ts diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 3567808e5..568ae1e19 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,6 +152,36 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; +export { + createTerminalAuthority, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/terminal/authority.ts"; +export type { + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalPaneClaim, +} from "./src/terminal/authority.ts"; +export { + installTerminalProvider, + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/terminal/provider-api.ts"; +export type { + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/terminal/provider-api.ts"; +export { installTerminalGridProfile } from "./src/terminal/profile.ts"; +export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; +export { paneTerminal } from "./src/terminal/pane.ts"; +export type { PaneTerminal } from "./src/terminal/pane.ts"; +export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 822f5aa87..6124da42b 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -68,8 +68,9 @@ import { import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; import { terminalGridLayout } from "./terminal-grid.ts"; import type { PlacedPane } from "./terminal-grid.ts"; -import { runTerminalGrid } from "./terminal/grid.ts"; +import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; import type { PaneWork } from "./terminal/grid.ts"; +import { recordGridLayout } from "./terminal/journal.ts"; import { usePaneTerminal } from "./terminal/pane.ts"; import { asBindingViolation, @@ -1191,7 +1192,6 @@ function* expandListSegments( parentMeta, parentProps, hideSet, - counter, path: elementPath, checkedFailures, authority, @@ -2121,7 +2121,6 @@ interface GridSite { readonly parentMeta: Record; readonly parentProps: Record; readonly hideSet: Set; - readonly counter: BlockCounter; readonly path: string; readonly checkedFailures: CheckedFailures | undefined; readonly authority: ExpansionAuthority | undefined; @@ -2169,13 +2168,28 @@ function* expandTerminalGrid( // The grid renders nothing into the document: what a pane shows belongs to // that pane, and the sibling after `` renders to the root // again only once the provider has restored it. + const identity = { + path: site.path, + ...(segment.position === undefined ? {} : { position: segment.position }), + }; + try { - const work = structure.panes.map((pane, index) => - paneWork(pane, layout.cells[index]!.title, site), - ); - const result = yield* runTerminalGrid(layout, work); - if (result.failure !== undefined) { - owner.push(yield* raise(terminalGridError(segment, result.failure.message))); + // Recorded in this coroutine, before the lease and before any provider is + // contacted: a resumed run whose grid changed is refused while nothing has + // been opened. It cannot live inside the grid child, because a completed + // child never runs. + yield* recordGridLayout(identity, toRequest(layout)); + + const retained = yield* durableGrid(function* () { + const work = structure.panes.map((pane, index) => + paneWork(pane, layout.cells[index]!.title, site), + ); + return yield* openTerminalGrid(layout, work); + }); + + const failed = retained.panes.find((pane) => pane.status === "failed"); + if (failed !== undefined) { + owner.push(yield* raise(terminalGridError(segment, failed.reason))); } } catch (error) { owner.push( @@ -2237,7 +2251,10 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { site.parentMeta, site.parentProps, site.hideSet, - site.counter, + // A counter of its own. Panes expand concurrently, and a shared + // mutable counter would hand two of them block identities that depend + // on which happened to run first. + createBlockCounter(), shown, extendPath( site.path, diff --git a/packages/core/src/terminal/authority.ts b/packages/core/src/terminal/authority.ts index df4feffab..64e11fce3 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/core/src/terminal/authority.ts @@ -4,11 +4,12 @@ * * The provider draws a grid. This decides everything about it that matters: * which request is live, which provider installation it belongs to, which pane - * ordinals exist, whether an interactive operation may start on one, and when a - * pane has actually started. None of that is reachable by name. There is no - * context holding an authority, no member of a request that carries one, and no - * handler return value that produces one — an authority reachable by name would - * be an authority every same-name context and every loaded copy could reach. + * ordinals exist, whether an interactive operation may start on one, when a + * pane has actually started, and what the grid settled to. None of that is + * reachable by name. There is no context holding an authority, no member of a + * request that carries one, and no handler return value that produces one — an + * authority reachable by name would be an authority every same-name context and + * every loaded copy could reach. * * A claim is the unforgeable carrier. It is minted here for one ordinal of one * request under one installation generation, and a claim from another grid, @@ -18,9 +19,9 @@ * session coordinator's to answer and stays independently authoritative. */ -import { all, ensure, withResolvers } from "effection"; -import type { Operation } from "effection"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import { all, createContext, ensure, withResolvers } from "effection"; +import type { Context, Operation } from "effection"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; @@ -40,8 +41,8 @@ export interface TerminalPaneClaim { * Run one interactive operation as this pane's owner. * * Refuses while another is live on this pane, and refuses once the grid that - * minted the claim has finished — a claim kept past its expansion is a claim - * to a terminal nobody owns any more. + * minted the claim has stopped admitting work — a claim kept past its + * expansion is a claim to a terminal nobody owns any more. */ admit(body: () => Operation): Operation; /** @@ -77,6 +78,123 @@ export interface TerminalGridClaims { seal(): void; } +/** + * What a registered provider must present in order to act. + * + * Delivered directly to the provider factory as it installs, and reachable + * nowhere else. Presenting the exact request core issued is what takes the + * terminal leases, mints the pane claims, and runs the grid; anything else — + * a copy, a rebuilt lookalike, an earlier grid's request, a request already + * presented, or one belonging to a superseded installation — authorizes + * nothing. + */ +export interface TerminalGridAuthority { + present(request: TerminalGridRequest, composite: TerminalComposite): Operation; +} + +/** One grid this execution issued, from the authority's side. */ +export interface LiveGrid { + /** The exact request object core issued. Compared by identity, never shape. */ + readonly request: TerminalGridRequest; + /** The installation this grid belongs to. */ + readonly generation: object; + /** Run the grid on a presented composite, and keep what it settled to. */ + run(composite: TerminalComposite): Operation; + /** Whether this request has already been presented. */ + used: boolean; + /** Whether the grid actually ran to a settlement. */ + settled: boolean; +} + +/** Every grid this execution has issued and not yet finished. */ +export interface GridRegistry { + live(): readonly LiveGrid[]; + add(grid: LiveGrid): void; + remove(grid: LiveGrid): void; +} + +export function createGridRegistry(): GridRegistry { + const grids = new Set(); + return { + live: () => [...grids], + add: (grid) => { + grids.add(grid); + }, + remove: (grid) => { + grids.delete(grid); + }, + }; +} + +/** + * Build the authority one provider installation is given. + * + * It closes over the installation's generation and its registry, so a factory + * that kept an authority from a superseded installation presents into a + * generation that no longer has the grid it names. + */ +export function createTerminalAuthority( + generation: object, + live: () => readonly LiveGrid[], +): TerminalGridAuthority { + return { + *present(request, composite) { + const grid = live().find((candidate) => Object.is(candidate.request, request)); + if (grid === undefined) { + throw new TerminalAuthorityError( + "this grid request is not live: it was copied, rebuilt, kept from another grid, or " + + "belongs to an execution that has finished", + ); + } + if (!Object.is(grid.generation, generation)) { + throw new TerminalAuthorityError( + "this grid request belongs to another terminal provider installation", + ); + } + if (grid.used) { + throw new TerminalAuthorityError( + "this grid request has already been presented — one request opens one grid", + ); + } + grid.used = true; + yield* grid.run(composite); + }, + }; +} + +/** One execution's terminal installation: its registry and its generation. */ +export interface TerminalInstallation { + readonly registry: GridRegistry; + /** Identifies this execution's provider installation, and nothing else. */ + readonly generation: object; +} + +const Installation: Context = createContext< + TerminalInstallation | undefined +>("core.terminal.installation", undefined); + +/** + * Open one terminal installation for a live document, and hand back the + * authority its providers are installed with. + * + * What travels contextually is the installation — composition data, so a + * document and the components it expands find the same one. The authority does + * not: it is handed to a provider factory directly. A replaced installation + * therefore produces requests the real authority has never heard of, which is a + * refusal rather than a way in. + */ +export function* useTerminalInstallation(): Operation { + const registry = createGridRegistry(); + const generation = {}; + yield* Installation.set({ registry, generation }); + return createTerminalAuthority(generation, () => registry.live()); +} + +/** This execution's terminal installation, or `undefined` outside one. */ +export function terminalInstallation(): Operation { + return Installation.get(); +} + /** * Mint the claims for one grid expansion. * diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 9f712da1d..3892a43c7 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -1,6 +1,6 @@ /** * One terminal grid, from the lease to the last finalizer (spec §6.21, - * architecture.md §Atomic presentation and settlement). + * architecture.md §Atomic presentation and settlement, §Durability and replay). * * Opening a grid is atomic from the reader's side, and that is the whole shape * of this module. The composite is built while it is still hidden, every pane @@ -8,34 +8,70 @@ * anything appear. A failure before that barrier discards the hidden composite * instead of leaving half a grid on the screen. * - * Ordering is the contract, not an implementation detail: - * * ``` - * lease → flush → prepare → panes start → readiness barrier → attach - * → panes settle independently → reader closes → teardown → lease released + * layout recorded → lease → flush → routed to a provider → composite presented + * → panes start → readiness barrier → attach + * → panes settle independently → reader closes → teardown → lease released * ``` * - * Nothing here decides what a pane *is* — the layout arrived already derived, - * and the work each pane does is supplied by the caller. What this owns is - * whose terminal it is, when a pane counts as started, what happens when one - * fails, and the order in which it all comes apart. + * Each pane is a **durable child coroutine** of the grid, allocated in authored + * order. That is not decoration: a completed child short-circuits on replay by + * returning its retained result without running, and claiming a completed + * parent claims every descendant history beneath it. Wrapping the region in one + * durable operation instead would leave the panes' entries unconsumed and + * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { flushOutput, prepareTerminalGrid, reserveTerminal } from "@executablemd/runtime"; +import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import type { Json, Workflow } from "@executablemd/durable-streams"; +import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; -import { awaitReadiness, createTerminalGridClaims } from "./authority.ts"; -import type { TerminalPaneClaim } from "./authority.ts"; +import { + awaitReadiness, + createTerminalGridClaims, + TerminalAuthorityError, + terminalInstallation, +} from "./authority.ts"; +import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; -/** How one pane ended. */ -export type PaneOutcome = - | { readonly kind: "succeeded" } - | { readonly kind: "failed"; readonly error: Error } - /** Live when the reader closed the grid. Cancellation, not failure. */ - | { readonly kind: "closed" }; +/** How one pane ended, as the journal records it. */ +export type PaneStatus = "succeeded" | "failed" | "closed"; + +/** How a grid ended. */ +export type GridCloseKind = "reader" | "failed"; + +/** One pane's retained outcome: what it came to, and why when it failed. */ +export interface RetainedPaneOutcome extends Record { + status: PaneStatus; + reason: string; +} + +export interface RetainedPane extends Record { + ordinal: number; + title: string; + form: string; + row: number; + column: number; +} + +/** + * What a grid retains: the provider-neutral layout, how it closed, and each + * pane's outcome in authored order. + * + * Nothing here names a provider. No command, socket, path, process identifier, + * session, window or pane identifier, no argv or environment, and no terminal + * byte — none of that describes the document, it describes whichever provider + * happened to present it, and a resumed run builds a fresh one. + */ +export interface RetainedGrid extends Record { + layout: { columns: number; rows: number; panes: RetainedPane[] }; + close: GridCloseKind; + panes: RetainedPaneOutcome[]; +} /** * What one pane does once its claim exists. @@ -50,13 +86,6 @@ export interface PaneWork { run(claim: TerminalPaneClaim, composite: TerminalComposite): Operation; } -/** Everything the grid settled, in authored pane order. */ -export interface GridResult { - readonly outcomes: readonly PaneOutcome[]; - /** Why the grid failed, which is the first failed pane in authored order. */ - readonly failure?: Error; -} - /** * What a pane that never reported a spawn says. * @@ -72,40 +101,117 @@ export function paneNeverStartedMessage(ordinal: number, title: string): string ); } -class PaneStartupError extends Error { - override name = "PaneStartupError"; - readonly ordinal: number; - constructor(ordinal: number, message: string) { - super(message); - this.ordinal = ordinal; - } +/** The provider-neutral request one derived layout asks for. */ +export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { + return Object.freeze({ + columns: layout.columns, + rows: layout.rows, + panes: Object.freeze( + layout.cells.map((cell) => + Object.freeze({ + ordinal: cell.ordinal, + title: cell.title, + row: cell.row, + column: cell.column, + form: cell.form, + }), + ), + ), + }); +} + +/** The retained shape of one request. */ +export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layout"] { + return { + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ + ordinal: pane.ordinal, + title: pane.title, + form: pane.form, + row: pane.row, + column: pane.column, + })), + }; } /** - * Run one grid to completion and report what its panes settled to. + * Open one grid and report what it settled to. * - * The foreground lease and the composite are both scope-owned, so every path - * out of here — success, failure, and cancellation alike — releases the - * terminal and destroys exactly the composite that was prepared. That is why - * teardown is not written as a step: there is no path that can skip it. + * Core mints the one request for this expansion, takes the run's foreground + * lease, flushes what the document has already produced, registers the request + * as live, routes it through the public surface, and then reads what the + * authority settled. The routed answer is discarded on purpose: a handler that + * short-circuits or fabricates a return has presented nothing, and this says so + * rather than letting the document believe a grid opened. */ -export function runTerminalGrid( +export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], -): Operation { - return scoped(function* (): Operation { +): Operation { + return scoped(function* (): Operation { + const installation = yield* terminalInstallation(); + if (installation === undefined) { + throw new TerminalAuthorityError( + "a terminal grid is available only inside a document execution with an installed " + + "terminal provider — a grid outside one retains nothing and could not be resumed", + ); + } + const request = toRequest(layout); + let settled: RetainedGrid | undefined; + + const grid: LiveGrid = { + request, + generation: installation.generation, + used: false, + settled: false, + *run(composite) { + settled = yield* presentGrid(request, composite, work); + grid.settled = true; + }, + }; + installation.registry.add(grid); + yield* ensure(() => { + installation.registry.remove(grid); + }); - // The one foreground-terminal lease. A root and a grid - // contend for exactly this, so neither can begin while the other holds it, - // and a host with no terminal refuses here — before any pane has done work. + // The one foreground-terminal lease, taken before any provider is asked for + // anything. A root and a grid contend for exactly this, so + // neither can begin while the other holds it. yield* reserveTerminal(); // Everything the document has produced so far reaches the reader before the // grid covers it up. yield* flushOutput(); - const composite = yield* prepareTerminalGrid(request); - // Registered before a single pane starts: a composite that was prepared is + // Routed, and the answer thrown away. + yield* TerminalGrids.operations.open(request); + + if (!grid.settled || settled === undefined) { + throw new TerminalAuthorityError( + "no terminal provider opened this grid — a handler answered without delivering the " + + "request to a registered provider", + ); + } + return settled; + }); +} + +/** + * Run the grid on the composite a provider presented. + * + * The composite is scope-owned, so every path out of here — success, failure, + * and cancellation alike — destroys exactly the composite that was presented. + * That is why teardown is not written as a step: there is no path that can skip + * it. + */ +function presentGrid( + request: TerminalGridRequest, + composite: TerminalComposite, + work: readonly PaneWork[], +): Operation { + return scoped(function* (): Operation { + // Registered before a single pane starts: a composite that was presented is // owed a destroy even if the next line is what fails. yield* ensure(() => composite.destroy()); @@ -116,53 +222,54 @@ export function runTerminalGrid( grid.seal(); }); - const outcomes: (PaneOutcome | undefined)[] = work.map(() => undefined); + const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); let attached = false; - const panes: Task[] = []; - for (const [index, pane] of work.entries()) { + // Every pane's work, in authored order. The children are allocated in this + // order too, so a pane's durable identity follows its ordinal rather than + // the order the runtime happened to schedule it in. + const paneWorkflows = work.map((pane, index) => { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; + return function* (): Operation { + const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + outcomes[index] = outcome; + yield* composite.update(pane.ordinal, outcome.status); + if (outcome.status === "failed" && !attached) { + // Before the barrier a pane failure is the whole grid's: nothing has + // been shown, so the grid fails closed rather than attaching what is + // left. After it, the failure is this pane's status alone. + startupFailed.reject(new Error(outcome.reason)); + } + return outcome; + }; + }); + + for (const pane of work) { yield* composite.update(pane.ordinal, "starting"); - panes.push( - yield* spawn(function* () { - try { - yield* pane.run(claim, composite); - if (!readiness.acknowledged) { - // Settled without ever starting: that is a startup failure even - // though the work itself raised nothing. - throw new PaneStartupError( - pane.ordinal, - paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), - ); - } - outcomes[index] = { kind: "succeeded" }; - yield* composite.update(pane.ordinal, "succeeded"); - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); - outcomes[index] = { kind: "failed", error: failure }; - // Before the barrier a pane failure is the whole grid's: nothing has - // been shown, so the grid fails closed rather than attaching what is - // left. After it, the failure is this pane's status and its siblings - // keep running. - if (!attached) { - startupFailed.reject(failure); - return; - } - yield* composite.update(pane.ordinal, "failed"); - } - }), - ); } + // Spawned as one task so the coordinator below can reach the readiness + // barrier, attach, and wait for the reader while the panes are still live. + const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane // already failed from waiting forever for a latch nothing will acknowledge. - yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + try { + yield* race([awaitReadiness(grid.readiness), startupFailed.operation]); + } catch { + // Simultaneous startup failures are selected by authored ordinal, not by + // whichever rejected the race first. + throw new Error(firstReason(outcomes) ?? "a terminal grid pane failed to start"); + } - for (const pane of work) { - yield* composite.update(pane.ordinal, "running"); + // A pane that already settled keeps the status it settled to: overwriting + // it with `running` would tell the reader a finished pane is live. + for (const [index, pane] of work.entries()) { + if (outcomes[index] === undefined) { + yield* composite.update(pane.ordinal, "running"); + } } yield* composite.attach(); attached = true; @@ -172,36 +279,118 @@ export function runTerminalGrid( yield* composite.closed(); // Close prevents new work first, then takes the live panes down: a pane - // cancelled by the close is `closed`, which is not a failed pane. + // cancelled by the close is `closed`, which is not a failed pane. Every + // child is awaited here, and the provider's finalizers run in the scope's + // own teardown after this returns — so the composite is destroyed, the + // lease released and the following sibling started only once nothing a pane + // acquired can still act. grid.seal(); - for (const [index, task] of panes.entries()) { + for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { - yield* composite.update(work[index]!.ordinal, "closed"); - outcomes[index] = { kind: "closed" }; + yield* composite.update(pane.ordinal, "closed"); + outcomes[index] = { status: "closed", reason: "" }; } - yield* task.halt(); } + yield* panes.halt(); - const settled = outcomes.map((outcome) => outcome ?? { kind: "closed" as const }); - const failed = settled.find((outcome) => outcome.kind === "failed"); + const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + const reason = firstReason(settled); return { - outcomes: settled, - ...(failed?.kind === "failed" ? { failure: failed.error } : {}), + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: settled, }; }); } -/** The provider-neutral request one derived layout asks for. */ -export function toRequest(layout: TerminalGridLayout): TerminalGridRequest { - return { - columns: layout.columns, - rows: layout.rows, - panes: layout.cells.map((cell) => ({ - ordinal: cell.ordinal, - title: cell.title, - row: cell.row, - column: cell.column, - form: cell.form, - })), - }; +/** Run one pane's work and say what it came to. */ +function runPane( + pane: PaneWork, + claim: TerminalPaneClaim, + composite: TerminalComposite, + readiness: { readonly acknowledged: boolean }, + request: TerminalGridRequest, + index: number, +): Operation { + return (function* (): Operation { + try { + yield* pane.run(claim, composite); + if (!readiness.acknowledged) { + // Settled without ever starting: a startup failure even though the work + // itself raised nothing. + return { + status: "failed", + reason: paneNeverStartedMessage(pane.ordinal, request.panes[index]!.title), + }; + } + return { status: "succeeded", reason: "" }; + } catch (error) { + return { + status: "failed", + reason: error instanceof Error ? error.message : String(error), + }; + } + })(); +} + +/** The first failed pane's sentence in authored order, which is the grid's. */ +function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { + return outcomes.find((outcome) => outcome?.status === "failed")?.reason; +} + +/** + * Run every pane as a durable child of the grid, in authored order. + * + * A pane's identity is derived from the grid's coroutine and its authored + * ordinal, never from a title, a schedule, or a provider identifier — so a + * resumed run restores a completed pane as its outcome without re-running it, + * and continues an incomplete one from its own history. + * + * `durableAll` rather than `durableSpawn`: the latter returns a task spawned + * inside the ephemeral effect's own scope, and that scope closes as the effect + * resolves, so awaiting the task throws `halted`. It has no call sites or tests + * upstream; `durableAll` is the primitive that is exercised. + * + * Without a journal there are no children to derive, and the work simply runs. + */ +function paneChildren( + workflows: readonly (() => Operation)[], +): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* all(workflows.map((workflow) => workflow())); + } + return yield* durableAll( + workflows.map( + (workflow) => + function* (): Workflow { + return yield* ephemeral(workflow()); + }, + ), + ); + })(); +} + +/** + * Run the whole grid as one durable child, and return what it retained. + * + * A completed grid replays by returning its retained result: the child's + * workflow never runs, so no provider is contacted, no pane content expands and + * no shell starts — and claiming the completed child claims every pane history + * beneath it, so a resumed run starts nothing. + */ +export function durableGrid(live: () => Operation): Operation { + return (function* (): Operation { + const durable = yield* DurableContext.get(); + if (durable === undefined) { + return yield* live(); + } + const [retained] = yield* durableAll([ + function* (): Workflow { + return yield* ephemeral(live()); + }, + ]); + return retained!; + })(); } diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts new file mode 100644 index 000000000..cedd0b39a --- /dev/null +++ b/packages/core/src/terminal/journal.ts @@ -0,0 +1,140 @@ +/** + * Which grid a run opened, and how a resumed run is held to it + * (spec §6.21 Durability and replay). + * + * One entry, appended in the **parent** coroutine and **before** the foreground + * lease is taken or any provider is contacted: the columns and rows, and the + * ordered pane forms, titles and positions. A resumed run compares what it + * derived against what is held and refuses a document whose grid changed while + * nothing has been opened and nothing has started. + * + * It sits in the parent deliberately. The grid itself is a durable child, and a + * completed child short-circuits without running — so a comparison written + * inside it would never happen on the run that most needs it. + * + * Provider-neutral throughout. No command, socket, path, process, session, + * window or pane identifier, no argv or environment, and no terminal byte is + * written here: none of that describes the document, it describes whichever + * provider happened to present it, and a resumed run builds a fresh one. + */ + +import type { Operation } from "effection"; +import { + createDurableOperation, + DurableContext, + StaleInputError, +} from "@executablemd/durable-streams"; +import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; +import type { TerminalGridRequest } from "@executablemd/runtime"; + +import { sourceDescription } from "../source-position.ts"; +import type { SourcePosition } from "../types.ts"; +import { retainedLayout } from "./grid.ts"; +import type { RetainedGrid } from "./grid.ts"; + +/** A grid's identity within one execution: where it was written. */ +export interface GridIdentity { + /** The structural path that reached this element (§5.6). */ + readonly path: string; + readonly position?: Readonly; +} + +type RetainedLayout = RetainedGrid["layout"]; + +function describe(identity: GridIdentity): EffectDescription { + return { + type: "terminal_grid_layout", + name: `terminal_grid:${identity.path}:layout`, + ...sourceDescription(identity.position), + }; +} + +/** Whether this expansion has a journal to read and append to at all. */ +function* durable(): Operation { + return (yield* DurableContext.get()) !== undefined; +} + +/** + * Append one entry and return what the entry holds. + * + * Live it is the value passed in; on replay it is the value the journal already + * held, which is the only way a caller tells the two apart. + */ +function* append(description: EffectDescription, value: Json): Workflow { + return yield createDurableOperation(description, function* () { + return value; + }); +} + +/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +function readLayout(value: unknown): RetainedLayout | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + const fields: Record = Object.fromEntries(Object.entries(value)); + const { columns, rows, panes } = fields; + if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + return undefined; + } + return { columns, rows, panes: panes as RetainedLayout["panes"] }; +} + +/** How two layouts differ, in the words an author can act on. */ +function divergence(held: RetainedLayout, derived: RetainedLayout): string | undefined { + if (held.columns !== derived.columns) { + return `columns ${held.columns} rather than ${derived.columns}`; + } + if (held.panes.length !== derived.panes.length) { + return `${held.panes.length} panes rather than ${derived.panes.length}`; + } + for (const [index, pane] of derived.panes.entries()) { + const before = held.panes[index]!; + if (before.title !== pane.title) { + return `pane ${index} titled "${before.title}" rather than "${pane.title}"`; + } + if (before.form !== pane.form) { + return `pane ${index} written ${before.form} rather than ${pane.form}`; + } + if (before.row !== pane.row || before.column !== pane.column) { + return ( + `pane ${index} at row ${before.row}, column ${before.column} rather than row ` + + `${pane.row}, column ${pane.column}` + ); + } + } + return undefined; +} + +/** + * Record which grid this is, and refuse a resumed run whose grid changed. + * + * Expansion driven without a journal records nothing and behaves identically. + */ +export function* recordGridLayout( + identity: GridIdentity, + request: TerminalGridRequest, +): Operation { + if (!(yield* durable())) { + return; + } + const derived = retainedLayout(request); + const description = describe(identity); + const stored = yield* append(description, derived); + const held = readLayout(stored); + if (held === undefined) { + throw new StaleInputError( + `The journal's record of "${description.name}" is not a terminal-grid layout. Re-run the ` + + "document from the start rather than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } + const changed = divergence(held, derived); + if (changed !== undefined) { + throw new StaleInputError( + `The journal records this terminal grid as a grid with ${changed}. A grid whose layout ` + + "changed cannot be replayed onto this run. Re-run the document from the start rather " + + "than resuming from this journal.", + { coroutineId: identity.path, description }, + ); + } +} diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts new file mode 100644 index 000000000..05919b653 --- /dev/null +++ b/packages/core/src/terminal/profile.ts @@ -0,0 +1,60 @@ +/** + * Opening one terminal installation for a live document. + * + * A grid needs two things before it can be durable at all: this execution's + * installation — which owns the generation every request belongs to and the + * registry of the grids it issued — and a provider installed against the + * authority that installation mints. A grid outside one refuses rather than + * presenting something no replay could resume. + * + * The installation's lifetime has to surround authored work and end while the + * journal is still live, which is what `Execution.document` is. + */ + +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Execution } from "../execute.ts"; +import { useTerminalInstallation } from "./authority.ts"; +import { installTerminalProvider } from "./provider-api.ts"; + +export interface TerminalGridProfileOptions { + /** + * The registered provider to install for this execution. + * + * Omitted, the installation is opened and no provider is installed — which is + * a host that validates and inspects grids but cannot present one, and + * refuses when a document asks for one. + */ + readonly provider?: string; + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label?: string; +} + +/** + * Install the terminal-grid profile for the executions composed under it. + * + * The authority reaches the named provider's factory and nothing else: it is + * delivered through the installation handshake rather than published, so a + * handler that answers the install request itself installs no provider and the + * document is told so. + */ +export function installTerminalGridProfile( + options: TerminalGridProfileOptions = {}, +): Operation { + return Execution.around({ + *document([request], next) { + yield* scoped(function* () { + const authority = yield* useTerminalInstallation(); + if (options.provider !== undefined) { + yield* installTerminalProvider( + options.provider, + { label: options.label ?? options.provider }, + authority, + ); + } + yield* next(request); + }); + }, + }); +} diff --git a/packages/core/src/terminal/provider-api.ts b/packages/core/src/terminal/provider-api.ts new file mode 100644 index 000000000..f3537dd99 --- /dev/null +++ b/packages/core/src/terminal/provider-api.ts @@ -0,0 +1,271 @@ +/** + * How a terminal provider is installed, and what installing one grants. + * + * A provider is the only thing that can present a grid, so *selecting* one is + * itself an authority decision. Returning a factory up the public chain would + * mean any handler could answer with a factory of its own — or take the one it + * was given and install it somewhere else. + * + * So nothing is returned. Public middleware receives one frozen, one-use + * install request naming the provider and its normalized options, and may + * inspect it, refuse by throwing, or delegate it. The registered provider's + * handler sits at the terminal end of that chain and holds its own captured + * continuation — a parameter of its generator, carried by no request and no + * return value. Through that continuation, and only through it, the invocation + * terminal hands the factory this execution's terminal authority and records + * that the provider acknowledged installation. + * + * Registration is scope-local: a nested registration overrides an outer one for + * its own name without touching siblings or process-global state. + * + * This is the same handshake `AgentProviders` uses, deliberately. The two + * capabilities are different — one hands a child the whole terminal, one + * divides it into panes — but the question "who may install the thing that + * performs it" has one right answer, and two spellings of it would be two + * chances to get it wrong. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { ensure } from "effection"; +import type { Operation } from "effection"; + +import type { TerminalGridAuthority } from "./authority.ts"; + +/** What a host says about the provider it is installing. */ +export interface TerminalProviderOptions { + /** How the provider names itself in provider-neutral diagnostics. */ + readonly label: string; +} + +/** + * A provider factory installs `TerminalGrids` middleware for its scope. + * + * The authority is the second argument because it is delivered, not published: + * there is no reader for it, no context holding one, and no request member + * carrying one. A factory closes over it, and only the handler that closed over + * it can pair a routed grid request with it. + */ +export type TerminalProviderFactory = ( + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +) => Operation; + +/** The stable name every loaded copy composes through. */ +export const TERMINAL_PROVIDERS_API = "TerminalProviders"; + +/** What public installation middleware sees: the name, and what it runs under. */ +export interface TerminalProviderInstallRequest { + readonly intent: "install"; + readonly name: string; + readonly options: TerminalProviderOptions; +} + +/** + * One message on the installation operation. + * + * Public middleware only ever receives the install request. The two private + * members are how the registered provider's handler speaks to the invocation's + * own terminal through the continuation it captured; constructing one grants + * nothing, because the terminal is reachable from that continuation alone. + */ +export type TerminalProviderCall = + | TerminalProviderInstallRequest + | { readonly intent: "inspect"; readonly install: TerminalProviderInstallRequest } + | { readonly intent: "acknowledge"; readonly install: TerminalProviderInstallRequest }; + +export interface TerminalProviderApi { + /** + * Install one provider. + * + * Answers nothing: a return value is not evidence a provider was installed, + * and the invocation that issued the request ignores it. + */ + install(call: TerminalProviderCall): Operation; +} + +export class TerminalProviderInstallError extends Error { + override name = "TerminalProviderInstallError"; +} + +/** + * The public installation surface. Its own default always refuses. + * + * Invoking this descriptor with a captured request outside a live installation + * reaches this default and installs nothing. + */ +export const TerminalProviders: Api = createApi( + TERMINAL_PROVIDERS_API, + { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + const name = call.intent === "install" ? call.name : call.install.name; + throw new TerminalProviderInstallError(`Unknown terminal provider "${name}"`); + }, + }, +); + +/** Make `factory` installable as `name` for the current scope. */ +export function* registerTerminalProvider( + name: string, + factory: TerminalProviderFactory, +): Operation { + let registered = true; + yield* ensure(() => { + registered = false; + }); + yield* TerminalProviders.around( + { + *install([call], next): Operation { + if (call.intent !== "install" || call.name !== name) { + return yield* next(call); + } + if (!registered) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider registration is no longer live`, + ); + } + // Inspection first, and through the captured continuation: the terminal + // refuses a copied, reused or stale request here, before the factory + // installs anything. + const delivery = deliveryOf(yield* next({ intent: "inspect", install: call })); + yield* factory(delivery.options, delivery.authority); + yield* next({ intent: "acknowledge", install: call }); + return undefined; + }, + }, + { at: "min" }, + ); +} + +/** + * What the terminal told this handler, or a refusal. + * + * Parsed rather than believed. The terminal that produced it belongs to the + * canonical copy, and this handler may belong to another; what arrives is a + * value, and reading it as a delivery is this side's decision. + */ +function deliveryOf(value: unknown): { + options: TerminalProviderOptions; + authority: TerminalGridAuthority; +} { + if (typeof value !== "object" || value === null) { + throw new TerminalProviderInstallError( + "this terminal provider installation is not live, so nothing was delivered to it", + ); + } + const options = Reflect.get(value, "options"); + const authority = Reflect.get(value, "authority"); + if (typeof options !== "object" || options === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation named no options", + ); + } + if (typeof authority !== "object" || authority === null) { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no authority", + ); + } + const label = Reflect.get(options, "label"); + if (typeof label !== "string") { + throw new TerminalProviderInstallError("the live terminal provider options are not readable"); + } + const present = Reflect.get(authority, "present"); + if (typeof present !== "function") { + throw new TerminalProviderInstallError( + "the live terminal provider installation carried no grid authority", + ); + } + return { + options: { label }, + authority: { + present: (request, composite) => Reflect.apply(present, authority, [request, composite]), + }, + }; +} + +/** + * Install the provider registered as `name`, under `options`, for the calling + * operation. + * + * The authority reaches whichever factory answers, and nothing else: a handler + * that short-circuits, fabricates a return, or never acknowledges installs no + * provider, and this refuses rather than leaving the caller believing one is + * there. + */ +export function installTerminalProvider( + name: string, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): Operation { + return (function* (): Operation { + const request: TerminalProviderInstallRequest = Object.freeze({ + intent: "install", + name, + options: Object.freeze({ ...options }), + }); + const terminal = installationTerminal(request, options, authority); + // Same stable name, so the shared middleware chain applies; own descriptor, + // so the chain ends in this invocation's terminal rather than in the public + // refusing default. + const invocation = createApi(TERMINAL_PROVIDERS_API, { + install: terminal.install, + }); + yield* invocation.operations.install(request); + if (!terminal.acknowledged()) { + throw new TerminalProviderInstallError( + `the "${name}" terminal provider did not install — a handler answered without ` + + `delivering the request to a registered provider`, + ); + } + terminal.close(); + })(); +} + +function installationTerminal( + request: TerminalProviderInstallRequest, + options: TerminalProviderOptions, + authority: TerminalGridAuthority, +): { + install: (call: TerminalProviderCall) => Operation; + acknowledged: () => boolean; + close: () => void; +} { + let state: "available" | "inspected" | "acknowledged" | "closed" = "available"; + + return { + // deno-lint-ignore require-yield + *install(call: TerminalProviderCall): Operation { + if (call.intent === "install") { + // Reaching the terminal means no registered provider consumed it. + throw new TerminalProviderInstallError(`Unknown terminal provider "${call.name}"`); + } + // Object identity, not shape: a request rebuilt with the same members + // describes the same ask and authorizes nothing. + if (!Object.is(call.install, request)) { + throw new TerminalProviderInstallError( + "the live terminal provider installation received a copied, substituted or foreign request", + ); + } + if (call.intent === "inspect") { + if (state !== "available") { + throw new TerminalProviderInstallError( + "this terminal provider installation is reused, completed or stale", + ); + } + state = "inspected"; + return { options, authority }; + } + if (state !== "inspected") { + throw new TerminalProviderInstallError( + "this terminal provider acknowledgement is unsolicited, duplicated or stale", + ); + } + state = "acknowledged"; + return undefined; + }, + acknowledged: () => state === "acknowledged", + close() { + state = "closed"; + }, + }; +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index b360fa3fb..ad44b1e74 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1,6 +1,7 @@ /** * Tier TG — running a terminal grid through a replaceable provider - * (spec §6.21, architecture.md §Atomic presentation and settlement). + * (spec §6.21, architecture.md §Terminal authority, §Atomic presentation and + * settlement, §Durability and replay). * * The provider here is controlled and is not tmux: it opens no terminal, starts * no process, and records what it was asked to do in the order it was asked. @@ -10,87 +11,206 @@ * * Readiness is the claim these rows care about most, so it is always driven * explicitly: a pane becomes ready because something called the latch it was - * handed, never because it got far enough. That is what lets "started" and - * "did some work" be told apart at all. + * handed, never because it got far enough. That is what lets "started" and "did + * some work" be told apart at all. + * + * A paired pane is ready only when something in it starts and reports a spawn. + * Until the native-launch Story lands, `` is what a suite writes + * to be that something — and it reaches the pane through the same seam a real + * `` will. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, resource, scoped, sleep, spawn, suspend, until, withResolvers } from "effection"; -import type { Operation, Result } from "effection"; +import { + ensure, + race, + resource, + scoped, + sleep, + spawn, + suspend, + until, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; import { forEach } from "@effectionx/stream-helpers"; import { rm, writeTextFile } from "@effectionx/fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, - installControlledTerminalProvider, + prepareControlledComposite, + TerminalGrids, + terminalProviderLog, +} from "@executablemd/runtime"; +import type { + ControlledCompositeOptions, + TerminalComposite, + TerminalGridRequest, + TerminalProviderLog, } from "@executablemd/runtime"; -import type { TerminalGridRequest, TerminalProviderLog } from "@executablemd/runtime"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; +import { + createTerminalGridClaims, + TerminalAuthorityError, + useTerminalInstallation, +} from "../src/terminal/authority.ts"; +import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + TerminalProviderInstallError, + TerminalProviders, +} from "../src/terminal/provider-api.ts"; +import { installTerminalGridProfile } from "../src/terminal/profile.ts"; +import { paneTerminal } from "../src/terminal/pane.ts"; import type { Json } from "../src/types.ts"; -import { createTerminalGridClaims, TerminalAuthorityError } from "../src/terminal/authority.ts"; -import { runTerminalGrid } from "../src/terminal/grid.ts"; -import type { GridResult, PaneWork } from "../src/terminal/grid.ts"; -import { paneTerminal, usePaneTerminal } from "../src/terminal/pane.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; -import type { TerminalGridLayout } from "../src/terminal-grid.ts"; - -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; +/** One document run against a controlled grid host. */ +interface DocumentRun { + outcome: Result; + /** Text the consumer received — the root document's own output. */ + output: string; + /** The grid the provider was actually asked to present. */ + requests: TerminalGridRequest[]; + /** What each pane displayed. */ + shown: Map; + /** Everything the composite did, in order. */ + events: string[]; + /** Every mark a tripwire component recorded, in order. */ + ran: string[]; + /** The journal this run read and appended to. */ + journal: DurableEvent[]; } -/** A layout of `count` panes across `columns`, titled by ordinal. */ -function layoutOf(columns: number, count: number): TerminalGridLayout { - return terminalGridLayout( - columns, - Array.from({ length: count }, (_unused, index) => ({ - title: `pane ${index}`, - form: "self-closing" as const, - })), - ); +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); } -/** A pane that starts, does what `body` says, and settles. */ -function pane(ordinal: number, body?: () => Operation): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - if (body) { - yield* body(); +/** The controlled interactive child, and a tripwire. */ +function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { + return registerComponents([ + { + name: "Interactive", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); } - }); + yield* pane.interactive(function* (spawned) { + spawned(); + }); + return ""; + }, + }, + { + name: "Ran", + origin: "tier-tg", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + { + // Starts interactively, slowly, and records when it did. + name: "Slow", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + const pane = yield* paneTerminal(); + if (pane === undefined) { + throw new Error(" is written inside a pane"); + } + yield* pane.interactive(function* (spawned) { + yield* sleep(25); + slowMarks.push("ready:slow"); + spawned(); + }); + return ""; + }, }, - }; + { + name: "Hold", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* suspend(); + return ""; + }, + }, + ]); } -/** Everything a grid run needs installed, with the reader's close under control. */ -function* useGridHost(record: TerminalProviderLog, close: () => Operation): Operation { - // A grid takes the same one foreground lease a root takes, - // so a host that offers a grid still has to offer that lease. - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ log: record, close }); +/** + * Register a controlled provider that presents through the authority it was + * delivered. + * + * This is the whole handshake in miniature: the factory receives the authority + * as an argument, prepares a composite of its own, and presents the exact + * request it was routed. Nothing it returns reaches core. + */ +function useControlledProvider( + options: ControlledCompositeOptions & { + /** Present something other than the request that was routed. */ + readonly substitute?: (request: TerminalGridRequest) => TerminalGridRequest; + /** Answer the routed request without presenting anything at all. */ + readonly shortCircuit?: boolean; + /** Keep the authority for a later, unrouted use. */ + readonly capture?: (authority: TerminalGridAuthority) => void; + } = {}, +): Operation { + let generation = 0; + return registerTerminalProvider("controlled", function* (_settings, authority) { + options.capture?.(authority); + yield* TerminalGrids.around( + { + *open([request]) { + if (options.shortCircuit === true) { + // Answers, presents nothing. Core must not believe this. + return { presented: true }; + } + const composite = yield* prepareControlledComposite(request, options, generation++); + yield* authority.present(options.substitute?.(request) ?? request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }); } -/** A pane that records when it started, so ordering is read rather than timed. */ -function readyPane(ordinal: number, timeline: string[]): PaneWork { - return { - ordinal, - *run(claim) { - yield* claim.admit(function* () { - timeline.push(`ready:${ordinal}`); - claim.ready(); - yield* suspend(); - }); - }, - }; +/** Everything a controlled grid host installs, for an in-process grid. */ +function useGridHost( + options: Parameters[0] = {}, +): Operation { + return (function* (): Operation { + yield* installControlledLauncher(); + yield* useControlledProvider(options); + const authority = yield* useTerminalInstallation(); + yield* installTerminalProvider("controlled", { label: "controlled" }, authority); + return authority; + })(); } /** Close as soon as the reader is asked, which is the ordinary journey. */ @@ -99,546 +219,463 @@ function immediateClose(): () => Operation { return function* () {}; } -describe("Tier TG — pane claims and readiness", () => { - it("TG8: a claim admits one interactive operation at a time", function* () { - const grid = createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - const first = grid.claims[0]!; - const second = grid.claims[1]!; - let refusal: unknown; - let concurrent = false; +/** + * Expand one document against a controlled grid host. + * + * `provider: false` registers nothing, which is how "a host that cannot open a + * grid refuses" is asked for. + */ +function runDocument( + dir: string, + source: string, + options: { + provider?: boolean; + stream?: InMemoryStream; + composite?: ControlledCompositeOptions; + /** Where `` records that it started. */ + slowMarks?: string[]; + } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + yield* useGridComponents(ran, options.slowMarks ?? []); + yield* installControlledLauncher(); - yield* scoped(function* () { - yield* first.admit(function* () { - // A second operation on the same pane is refused while this one is live. - try { - yield* first.admit(function* () {}); - } catch (error) { - refusal = error; - } - // A different pane does not contend at all, which is the whole reason a - // grid exists. - yield* second.admit(function* () { - concurrent = true; - }); + // The reader stays until every pane has settled. Leaving sooner is a real + // thing a reader does — TG12 covers it — but a row about what a pane + // rendered must not race the close that cancels it. + const settled = withResolvers(); + let expected = 0; + let done = 0; + const supplied = options.composite ?? {}; + if (options.provider !== false) { + yield* useControlledProvider({ + ...supplied, + log, + close: supplied.close ?? (() => settled.operation), + *onPrepare(asked) { + expected = asked.panes.length; + requests.push(asked); + if (supplied.onPrepare) { + yield* supplied.onPrepare(asked); + } + }, + onUpdate(ordinal, state) { + supplied.onUpdate?.(ordinal, state); + if (state === "succeeded" || state === "failed" || state === "closed") { + done++; + if (done >= expected) { + settled.resolve(); + } + } + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - expect(refusal instanceof Error ? refusal.message : "").toContain( - "one owns a pane terminal at a time", - ); - expect(concurrent).toBe(true); + const stream = options.stream ?? new InMemoryStream(); + const execution = yield* execute({ path, stream, includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { + outcome, + output, + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a pane admits again once its first operation has settled", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let second = false; +/** The message a run failed with, failing the test if it completed. */ +function failureOf(run: DocumentRun): string { + if (run.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); + } + return run.outcome.error.message; +} - yield* scoped(function* () { - yield* claim.admit(function* () {}); - yield* claim.admit(function* () { - second = true; +/** A grid on its own, which a resumed run can carry to an outcome. */ +function plainDocument(columns: number, panes: string[]): string { + return [``, ...panes, "", ""].join("\n"); +} + +/** A grid, then a component that holds the run open so the root never settles. */ +function heldDocument(columns: number, panes: string[]): string { + return [ + ``, + ...panes, + "", + "", + "", + "", + ].join("\n"); +} + +/** + * Run a document and interrupt it once the grid has journaled its outcome. + * + * A completed *or failed* root replays wholesale, so a second run of it would + * never reach the grid at all. Only a genuinely interrupted run leaves the + * region to be resumed — which is what every replay row below needs. + */ +function runInterrupted( + dir: string, + source: string, + stream: InMemoryStream, + options: { provider?: boolean } = {}, +): Operation { + return scoped(function* () { + const requests: TerminalGridRequest[] = []; + const log = terminalProviderLog(); + const ran: string[] = []; + const opened = withResolvers(); + yield* useGridComponents(ran); + yield* installControlledLauncher(); + if (options.provider !== false) { + yield* useControlledProvider({ + log, + close: () => suspend(), + *onPrepare(asked) { + requests.push(asked); + yield* sleep(0); + }, + // Attach is the signal, not `running`: a pane that settles before the + // barrier keeps its own status and never becomes runnable. + // deno-lint-ignore require-yield + *onAttach() { + opened.resolve(); + }, }); - }); + } + yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); - // Sequential work in one pane is ordinary composition, not contention. - expect(second).toBe(true); + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + const task: Task = yield* spawn(function* () { + const execution = yield* execute({ path, stream, includes: [dir] }); + yield* execution; + }); + // The grid is open and its panes have settled, so the journal now holds the + // pane children's own entries. A resumed run never attaches at all — the + // region short-circuits — so this is bounded rather than waited on. + yield* race([opened.operation, sleep(120)]); + yield* sleep(5); + yield* task.halt(); + return { + outcome: { ok: false, error: new Error("interrupted") } as Result, + output: "", + requests, + shown: log.shown, + events: log.events, + ran, + journal: yield* stream.readAll(), + }; }); +} - it("TG8: a sealed grid admits nothing, however the claim was obtained", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - grid.seal(); - let refusal: unknown; +const PANES = [ + 'left', + '', +]; - yield* scoped(function* () { - try { - yield* claim.admit(function* () {}); - } catch (error) { - refusal = error; - } +describe("Tier TG — the terminal authority", () => { + const GRID = ["", ...PANES, "", ""].join("\n"); + + it("TA1: a handler that answers without presenting opens nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { composite: {} as ControlledCompositeOptions }); + expect(run.outcome.ok).toBe(true); + + // The same document, against a provider that answers the routed request + // itself. A return value is not evidence that a grid opened. + const shorted = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ shortCircuit: true }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, ran }; }); - // A claim kept past its grid is a claim to a terminal nobody owns. - expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(shorted.outcome.ok).toBe(false); + expect(shorted.outcome.ok ? "" : shorted.outcome.error.message).toContain( + "a handler answered without delivering the request to a registered provider", + ); + // Nothing beneath the grid ran either. + expect(shorted.ran).toEqual([]); }); - it("TG8: readiness is the acknowledgement, and acknowledging twice is one event", function* () { - const grid = createTerminalGridClaims({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + it("TA2: presenting a rebuilt request authorizes nothing", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, GRID, { + composite: {}, }); - const claim = grid.claims[0]!; - const readiness = grid.readiness[0]!; + expect(run.outcome.ok).toBe(true); - // Doing work is not being ready. - expect(readiness.acknowledged).toBe(false); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - claim.ready(); - expect(readiness.acknowledged).toBe(true); - yield* scoped(function* () { - yield* readiness.reached(); + const forged = yield* scoped(function* () { + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + // Same members, different object. Identity is what the authority reads. + yield* useControlledProvider({ + substitute: (request) => ({ + columns: request.columns, + rows: request.rows, + panes: request.panes.map((pane) => ({ ...pane })), + }), + }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - }); - it("TG8: a request whose ordinals are not its positions is refused", function* () { - let refusal: unknown; - try { - createTerminalGridClaims({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, - { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, - ], - }); - } catch (error) { - refusal = error; - } - expect(refusal).toBeInstanceOf(TerminalAuthorityError); - yield* sleep(0); + expect(forged.ok).toBe(false); + expect(forged.ok ? "" : forged.error.message).toContain("this grid request is not live"); }); -}); - -describe("Tier TG — atomic startup", () => { - it("TG9: nothing attaches until every pane has reported a spawn", function* () { - const record = log(); - // One ordered record both the panes and the provider write to, so - // "readiness came first" is read rather than assumed. The grid emits - // `running` for every pane immediately before it attaches, so asserting on - // that would prove nothing — a pane says when it actually started. - const timeline: string[] = []; - const slow = withResolvers(); - const result = yield* scoped(function* (): Operation { + it("TA3: presenting a changed request authorizes nothing", function* () { + const dir = yield* useDir(); + const changed = yield* scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, GRID); + const ran: string[] = []; + yield* useGridComponents(ran); yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: immediateClose(), - // deno-lint-ignore require-yield - *onAttach() { - timeline.push("attach"); - }, + yield* useControlledProvider({ + substitute: (request) => ({ ...request, columns: request.columns + 1 }), }); - return yield* runTerminalGrid(layoutOf(2, 3), [ - readyPane(0, timeline), - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - // Plenty of work before anything starts, and none of it makes the - // grid attachable. The delay is long enough that a grid which - // skipped the barrier would demonstrably attach first. - yield* sleep(25); - timeline.push("ready:1"); - claim.ready(); - yield* slow.operation; - }); - }, - }, - readyPane(2, timeline), - ]); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + const outcome = yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + return outcome; }); - expect(timeline).toEqual(["ready:0", "ready:2", "ready:1", "attach"]); - expect(result.failure).toBeUndefined(); + expect(changed.ok).toBe(false); + expect(changed.ok ? "" : changed.error.message).toContain("this grid request is not live"); }); - it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { - const record = log(); - let failure: unknown; + it("TA4: an authority kept past its grid authorizes nothing", function* () { + const dir = yield* useDir(); + let kept: TerminalGridAuthority | undefined; + const run = yield* runDocument(dir, GRID, {}); + expect(run.outcome.ok).toBe(true); yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); + const path = join(dir, "doc.md"); + const ran: string[] = []; + yield* useGridComponents(ran); + yield* installControlledLauncher(); + yield* useControlledProvider({ capture: (authority) => (kept = authority) }); + yield* installTerminalGridProfile({ provider: "controlled" }); + const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); + yield* execution; + yield* forEach(function* (_chunk: string) {}, execution.output); + }); + + // The execution has finished, so the request it issued is no longer live. + let refusal: unknown; + yield* scoped(function* () { + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0), + yield* kept!.present( { - ordinal: 1, - // Runs, settles, and never reports a spawn. - *run() {}, + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], }, - ]); + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "finished without starting anything interactive", - ); - // No partial grid was ever shown, and the hidden composite was destroyed. - expect(record.events).not.toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a preparation failure starts no pane at all", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA5: an authority from another installation generation authorizes nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, + // Two installations in one scope: the second supersedes the first, so the + // first's authority names a generation the live registry no longer has. + const stale = yield* scoped(function* () { + return yield* useTerminalInstallation(); }); + yield* useTerminalInstallation(); + const composite = yield* prepareControlledComposite( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + {}, + ); try { - yield* runTerminalGrid(layoutOf(2, 2), [ - pane(0, function* () { - started.push(0); - }), - pane(1, function* () { - started.push(1); - }), - ]); + yield* stale.present( + { + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "x", row: 0, column: 0, form: "self-closing" }], + }, + composite, + ); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toBe( - "no pane endpoint could be created", - ); - expect(started).toEqual([]); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain("is not live"); }); - it("TG9: a grid refuses before preparation when no provider is installed", function* () { - const started: number[] = []; - let failure: unknown; - + it("TA6: a provider that never acknowledges installs nothing", function* () { + let refusal: unknown; yield* scoped(function* () { - yield* installControlledLauncher(); + const authority = yield* useTerminalInstallation(); + // A handler that answers the install request without delivering it to a + // registered provider. + yield* registerTerminalProvider("real", function* () {}); + yield* TerminalProviders.around({ + // deno-lint-ignore require-yield + *install() { + return undefined; + }, + }); try { - yield* runTerminalGrid(layoutOf(1, 1), [ - pane(0, function* () { - started.push(0); - }), - ]); + yield* installTerminalProvider("real", { label: "real" }, authority); } catch (error) { - failure = error; + refusal = error; } }); - expect(failure instanceof Error ? failure.message : "").toContain( - "no terminal provider is installed", - ); - expect(started).toEqual([]); - }); -}); - -describe("Tier TG — settlement and close", () => { - it("TG10: a pane fails after attach while its siblings stay live", function* () { - const record = log(); - // The reader leaves once the grid has displayed the failure, so the sibling - // is provably still live when that happens rather than probably still live. - const failed = withResolvers(); - let siblingLiveAtFailure = false; - let siblingLive = false; - - const result = yield* scoped(function* (): Operation { - yield* installControlledLauncher(); - yield* installControlledTerminalProvider({ - log: record, - close: () => failed.operation, - onUpdate(ordinal, state) { - if (ordinal === 0 && state === "failed") { - siblingLiveAtFailure = siblingLive; - failed.resolve(); - } - }, - }); - return yield* runTerminalGrid(layoutOf(2, 2), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* sleep(1); - throw new Error("pane 0 stopped"); - }); - }, - }, - { - ordinal: 1, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - siblingLive = true; - try { - yield* suspend(); - } finally { - siblingLive = false; - } - }); - }, - }, - ]); - }); - - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("state:0:0:failed"); - // The sibling was still running when its neighbour failed: an ordinary pane - // failure after attach is contained as that pane's status. - expect(siblingLiveAtFailure).toBe(true); - expect(result.outcomes[0]?.kind).toBe("failed"); - expect(result.outcomes[1]?.kind).toBe("closed"); - // The grid fails with the first failed pane in authored order. - expect(result.failure?.message).toBe("pane 0 stopped"); + expect(refusal).toBeInstanceOf(TerminalProviderInstallError); + expect(refusal instanceof Error ? refusal.message : "").toContain("did not install"); }); - it("TG12: close cancels a live pane as closed rather than failed", function* () { - const record = log(); - - const result = yield* scoped(function* (): Operation { - yield* useGridHost(record, immediateClose()); - return yield* runTerminalGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - // Still live when the reader leaves. - yield* suspend(); - }); - }, - }, - ]); - }); - - // Teardown cancellation is not a pane failure, and the grid succeeds. - expect(result.outcomes[0]?.kind).toBe("closed"); - expect(result.failure).toBeUndefined(); - expect(record.events).toContain("state:0:0:closed"); - }); - - it("TG12: the composite is destroyed exactly once, after the reader closes", function* () { - const record = log(); - - yield* scoped(function* () { - yield* useGridHost(record, immediateClose()); - yield* runTerminalGrid(layoutOf(2, 2), [pane(0), pane(1)]); + it("TA7: two claims from one grid do not contend; one pane admits one", function* () { + const grid = createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 1, title: "b", row: 0, column: 1, form: "paired" }, + ], }); - - const closed = record.events.indexOf("closed:0"); - const destroyed = record.events.indexOf("destroy:0"); - expect(closed).toBeGreaterThan(-1); - expect(destroyed).toBeGreaterThan(closed); - expect(record.events.filter((event) => event === "destroy:0")).toHaveLength(1); - }); - - it("TG13: parent cancellation tears the grid down completely", function* () { - const record = log(); + const first = grid.claims[0]!; + const second = grid.claims[1]!; + let refusal: unknown; + let concurrent = false; yield* scoped(function* () { - yield* useGridHost(record, () => suspend()); - // The grid never closes on its own; the enclosing scope ending is what - // takes it down, and that has to be a complete teardown. - yield* scoped(function* () { - yield* spawnGrid(layoutOf(1, 1), [ - { - ordinal: 0, - *run(claim) { - yield* claim.admit(function* () { - claim.ready(); - yield* suspend(); - }); - }, - }, - ]); - yield* sleep(2); + yield* first.admit(function* () { + try { + yield* first.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.admit(function* () { + concurrent = true; + }); }); }); - expect(record.events).toContain("attach:0"); - expect(record.events).toContain("destroy:0"); + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + expect(refusal instanceof Error ? refusal.message : "").toContain( + "one owns a pane terminal at a time", + ); + expect(concurrent).toBe(true); }); -}); -describe("Tier TG — the pane seam", () => { - it("TG6: work inside a pane runs as that pane's owner", function* () { - const grid = createTerminalGridClaims({ + it("TA8: a claim from another grid, or a sealed one, admits nothing", function* () { + const request = { columns: 1, rows: 1, - panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], - }); - const claim = grid.claims[0]!; - let sawOrdinal: number | undefined; - let acknowledged = false; + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" as const }], + }; + const first = createTerminalGridClaims(request); + const second = createTerminalGridClaims(request); + // Sealing one grid says nothing about the other: claims belong to the grid + // that minted them, not to a request shape. + first.seal(); + let refusal: unknown; + let other = false; yield* scoped(function* () { - yield* usePaneTerminal(claim); - const seam = yield* paneTerminal(); - sawOrdinal = seam?.ordinal; - yield* seam!.interactive(function* (spawned) { - spawned(); - acknowledged = grid.readiness[0]!.acknowledged; + try { + yield* first.claims[0]!.admit(function* () {}); + } catch (error) { + refusal = error; + } + yield* second.claims[0]!.admit(function* () { + other = true; }); }); - expect(sawOrdinal).toBe(0); - // The seam is how anything interactive reports its spawn, so readiness - // travels with the work rather than being asserted around it. - expect(acknowledged).toBe(true); - }); - - it("TG6: outside a grid there is no pane, and nothing pretends otherwise", function* () { - const seam = yield* scoped(function* () { - return yield* paneTerminal(); - }); - expect(seam).toBeUndefined(); - }); -}); - -/** Run a grid in a spawned task, so the enclosing scope can cancel it. */ -function* spawnGrid(layout: TerminalGridLayout, work: readonly PaneWork[]): Operation { - yield* spawn(function* () { - yield* runTerminalGrid(layout, work); - }); -} - -/** One document run against a controlled grid host. */ -interface DocumentRun { - outcome: Result; - /** Text the consumer received — the root document's own output. */ - output: string; - /** The grid the provider was actually asked to present. */ - requests: TerminalGridRequest[]; - /** What each pane displayed. */ - shown: Map; - /** Every mark a tripwire component recorded, in order. */ - ran: string[]; -} - -function useDir(): Operation { - return resource(function* (provide) { - const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); - yield* ensure(function* () { - yield* rm(dir, { recursive: true, force: true }); - }); - yield* provide(dir); + expect(refusal instanceof Error ? refusal.message : "").toContain("its grid has stopped"); + expect(other).toBe(true); }); -} -/** - * The controlled interactive child, and a tripwire. - * - * A paired pane is ready only when something in it starts and reports a spawn. - * Until the native-launch Story lands, this is what a suite writes to be that - * something — and it reaches the pane through the same seam a real launch will. - */ -function useGridComponents(ran: string[]): Operation { - return registerComponents([ - { - name: "Interactive", - origin: "tier-tg", - props: { type: "object", properties: {}, additionalProperties: false }, - *fn() { - const pane = yield* paneTerminal(); - if (pane === undefined) { - throw new Error(" is written inside a pane"); - } - yield* pane.interactive(function* (spawned) { - spawned(); - }); - return ""; - }, - }, - { - name: "Ran", - origin: "tier-tg", - props: { - type: "object", - properties: { mark: { type: "string" } }, - required: ["mark"], - additionalProperties: false, - }, - // deno-lint-ignore require-yield - *fn(props) { - ran.push(String(props.mark)); - return ""; - }, - }, - ]); -} + it("TA9: readiness is the acknowledgement, and acknowledging twice is one event", function* () { + const grid = createTerminalGridClaims({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "a", row: 0, column: 0, form: "paired" }], + }); + const claim = grid.claims[0]!; + const readiness = grid.readiness[0]!; -/** - * Run one document against a controlled grid host. - * - * `provider: false` installs no terminal provider, which is how "a host that - * cannot open a grid refuses" is asked for. - */ -function runDocument( - dir: string, - source: string, - options: { provider?: boolean } = {}, -): Operation { - return scoped(function* () { - const path = join(dir, "doc.md"); - yield* writeTextFile(path, source); - const requests: TerminalGridRequest[] = []; - const record = log(); - const ran: string[] = []; - yield* useGridComponents(ran); - yield* installControlledLauncher(); - // The reader stays until every pane has settled. Leaving sooner is a real - // thing a reader does — TG12 covers it — but a row about what a pane - // rendered must not race the close that cancels it. - const settled = withResolvers(); - let expected = 0; - let done = 0; - if (options.provider !== false) { - yield* installControlledTerminalProvider({ - log: record, - close: () => settled.operation, - *onPrepare(asked) { - expected = asked.panes.length; - requests.push(asked); - yield* sleep(0); - }, - onUpdate(_ordinal, state) { - if (state === "succeeded" || state === "failed") { - done++; - if (done >= expected) { - settled.resolve(); - } - } - }, + // Doing work is not being ready. + expect(readiness.acknowledged).toBe(false); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + claim.ready(); + expect(readiness.acknowledged).toBe(true); + yield* scoped(function* () { + yield* readiness.reached(); + }); + }); + + it("TA10: a request whose ordinals are not its positions is refused", function* () { + let refusal: unknown; + try { + createTerminalGridClaims({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "a", row: 0, column: 0, form: "paired" }, + { ordinal: 0, title: "b", row: 0, column: 1, form: "paired" }, + ], }); + } catch (error) { + refusal = error; } - const execution = yield* execute({ path, stream: new InMemoryStream(), includes: [dir] }); - const outcome = yield* execution; - const output = yield* forEach(function* (_chunk: string) {}, execution.output); - return { outcome, output, requests, shown: record.shown, ran }; + expect(refusal).toBeInstanceOf(TerminalAuthorityError); + yield* sleep(0); }); -} - -/** The message a run failed with, failing the test if it completed. */ -function failureOf(run: DocumentRun): string { - if (run.outcome.ok) { - throw new Error(`expected the document to fail, but it completed: ${run.outcome.value}`); - } - return run.outcome.error.message; -} +}); describe("Tier TG — a grid written in a document", () => { it("TG4: the provider is asked for exactly the authored row-major layout", function* () { @@ -687,8 +724,6 @@ describe("Tier TG — a grid written in a document", () => { ); expect(run.outcome.ok).toBe(true); - // Three panes sharing one label are three panes: the ordinal separates - // them, and the form each was written in travels with it. expect(run.requests[0]?.panes).toEqual([ { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, { ordinal: 1, title: "Agent", row: 0, column: 1, form: "self-closing" }, @@ -696,35 +731,9 @@ describe("Tier TG — a grid written in a document", () => { ]); }); - it("TG1: both pane forms run, and whitespace between panes is nothing", function* () { - const dir = yield* useDir(); - const run = yield* runDocument( - dir, - [ - "", - "", - 'Instructions.', - "", - '', - "", - "", - "", - ].join("\n"), - ); - - expect(run.outcome.ok).toBe(true); - expect(run.requests[0]).toEqual({ - columns: 2, - rows: 1, - panes: [ - { ordinal: 0, title: "Agent", row: 0, column: 0, form: "paired" }, - { ordinal: 1, title: "Shell", row: 0, column: 1, form: "self-closing" }, - ], - }); - }); - - it("TG7: a pane's text reaches that pane, and the grid renders nothing", function* () { + it("TG7: root output is flushed before the grid, and pane text stays in its pane", function* () { const dir = yield* useDir(); + const flushed: string[] = []; const run = yield* runDocument( dir, [ @@ -738,9 +747,20 @@ describe("Tier TG — a grid written in a document", () => { "after", "", ].join("\n"), + { + composite: { + // Preparation happens after the lease and the flush, so what the + // reader had already been given is on screen before the grid covers + // it. + *onPrepare() { + flushed.push("prepared"); + }, + }, + }, ); expect(run.outcome.ok).toBe(true); + expect(flushed).toEqual(["prepared"]); // Each pane's own text went to that pane. expect(run.shown.get(0)).toContain("left text"); expect(run.shown.get(1)).toContain("right text"); @@ -793,31 +813,60 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).toContain("after {mine}"); }); - it("TG6: a pane's cannot reach a loop outside the grid", function* () { + it("TG6: a pane's cannot claim a value body outside the grid", function* () { const dir = yield* useDir(); const run = yield* runDocument( dir, [ - "", - '', + "---", + "returns:", + " type: string", + "---", "", '', - "", + '', + "", + "", + "", + "", + '', + "", + ].join("\n"), + ); + + // The pane has no enclosing value body to claim, so the written in + // it is refused where it sits rather than becoming the document's value. + expect(failureOf(run)).toContain( + "is not written in the flow of a body that declares `returns`", + ); + expect(failureOf(run)).not.toContain("from the document"); + }); + + it("TG6: a pane's checked failure settles that pane and not its sibling", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + '', + "", + "", + "", + '', + '', "", "", "", - "", "", ].join("\n"), ); - // Refused where it was written. Had the reached the loop around the - // grid it would have exited it quietly and the document would have - // succeeded; instead the pane failed with the stray- rule, which is - // what fails the grid and then the document. - expect(failureOf(run)).toContain(" must be written inside a "); - expect(failureOf(run)).toContain("cannot break the loop that invoked it"); - expect(run.ran).toEqual(["iteration"]); + // Printed inside the pane it happened in, and the sibling ran regardless. + expect(run.shown.get(0)).toContain("this pane gave up"); + expect(run.ran).toEqual(["sibling"]); + expect(run.output).not.toContain("this pane gave up"); }); it("TG9: with no provider installed, no pane body or shell runs", function* () { @@ -843,3 +892,286 @@ describe("Tier TG — a grid written in a document", () => { expect(run.shown.size).toBe(0); }); }); + +describe("Tier TG — startup, settlement and teardown", () => { + const TWO = ["", ...PANES, "", ""].join("\n"); + + it("TG9: nothing attaches until every pane has reported a spawn", function* () { + const dir = yield* useDir(); + // One ordered record the pane and the composite both write to, so + // "readiness came first" is read rather than assumed. The grid emits + // `running` for every pane immediately before it attaches, so asserting on + // that alone would prove nothing. + const timeline: string[] = []; + const run = yield* runDocument( + dir, + [ + "", + '', + '', + "", + "", + ].join("\n"), + { + slowMarks: timeline, + composite: { + // deno-lint-ignore require-yield + *onAttach() { + timeline.push("attach"); + }, + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + timeline.push("ready:shell"); + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // The slow pane started last, and the grid still waited for it. + expect(timeline[timeline.length - 1]).toBe("attach"); + expect(timeline).toContain("ready:slow"); + }); + + it("TG9: a pane that never starts fails the grid, and nothing attaches", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'nothing interactive here', + '', + "", + "", + ].join("\n"), + ); + + expect(failureOf(run)).toContain("finished without starting anything interactive"); + // No partial grid was ever shown, and the hidden composite was destroyed. + expect(run.events).not.toContain("attach:0"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: an immediate spawn-and-exit is both ready and settled", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + ["", '', "", ""].join( + "\n", + ), + { + composite: { + // Reports its spawn and returns in the same breath. + // deno-lint-ignore require-yield + *shell(_ordinal, spawned) { + spawned(); + return { exitCode: 0 }; + }, + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Ready enough to attach, and settled enough to be `succeeded`. + expect(run.events).toContain("attach:0"); + expect(run.events).toContain("state:0:0:succeeded"); + // A pane that already settled keeps the status it settled to. + expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); + expect(run.events).not.toContain("state:0:0:running"); + }); + + it("TG9: a preparation failure starts no pane at all", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }, + }); + + expect(failureOf(run)).toContain("no pane endpoint could be created"); + expect(run.shown.size).toBe(0); + }); + + it("TG9: an attach failure shows no partial grid and tears the composite down", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // deno-lint-ignore require-yield + *onAttach() { + throw new Error("the composite could not be shown"); + }, + }, + }); + + expect(failureOf(run)).toContain("the composite could not be shown"); + expect(run.events).toContain("destroy:0"); + }); + + it("TG9: simultaneous startup failures report the first authored ordinal", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + 'no interactive child', + 'no interactive child either', + "", + "", + ].join("\n"), + ); + + // Both panes fail to start. The one reported is the first authored, not + // whichever settled first. + expect(failureOf(run)).toContain('pane 0 ("First")'); + expect(failureOf(run)).not.toContain('pane 1 ("Second")'); + }); + + it("TG12: close cancels a live pane as closed, then destroys and continues", function* () { + const dir = yield* useDir(); + const run = yield* runDocument( + dir, + [ + "", + '', + "", + "", + '', + "", + ].join("\n"), + { + composite: { + // The reader leaves while the pane is still live. + close: immediateClose(), + }, + }, + ); + + expect(run.outcome.ok).toBe(true); + // Teardown cancellation is not a pane failure. + expect(run.events).toContain("state:0:0:closed"); + const destroyed = run.events.indexOf("destroy:0"); + expect(run.events.indexOf("closed:0")).toBeLessThan(destroyed); + // The following sibling started only after the composite came down. + expect(run.ran).toEqual(["after the grid"]); + }); + + it("TG13: an active provider failure cancels every pane and fails the grid", function* () { + const dir = yield* useDir(); + const run = yield* runDocument(dir, TWO, { + composite: { + // The reader's close operation is where an active provider can fail. + // deno-lint-ignore require-yield + *close() { + throw new Error("the terminal provider lost its server"); + }, + }, + }); + + expect(failureOf(run)).toContain("the terminal provider lost its server"); + expect(run.events).toContain("destroy:0"); + }); +}); + +describe("Tier TG — durability and replay", () => { + const GRID = heldDocument(2, PANES); + + /** Every terminal-grid entry the journal holds. */ + function gridEntries(run: DocumentRun): DurableEvent[] { + return run.journal.filter( + (event) => + event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), + ); + } + + it("TG15: a completed grid replays without contacting a provider at all", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + // The grid opened and its panes ran; the document was then interrupted, so + // the root reached no outcome and a resumed run reaches the grid again. + const first = yield* runInterrupted(dir, GRID, stream); + expect(first.requests).toHaveLength(1); + + const second = yield* runInterrupted(dir, GRID, stream); + + // The region's retained result is the answer: no provider was asked for a + // grid, no pane content expanded, and nothing was displayed. + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG15: a completed grid replays even where no provider could open one", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + yield* runInterrupted(dir, GRID, stream); + // This host installs no provider at all. A replay that contacted one would + // refuse here; the retained result does not need one. + const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + + expect(second.requests).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.events).toEqual([]); + }); + + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const ids = closes.map((event) => String(event.coroutineId)).sort(); + // Two pane children beneath one grid child: `..`. + const paneIds = ids.filter((id) => id.split(".").length >= 3); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); + + it("TG17: the layout is recorded before any provider is contacted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const layout = run.journal.find( + (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), + ); + expect(layout).toBeDefined(); + // Written before the grid child that opens anything, so a comparison + // against it happens while nothing has been presented. + const layoutIndex = run.journal.indexOf(layout!); + const opened = run.journal.findIndex( + (event) => event.type === "close" && String(event.coroutineId).includes("."), + ); + expect(layoutIndex).toBeGreaterThan(-1); + if (opened > -1) { + expect(layoutIndex).toBeLessThan(opened); + } + }); + + it("TG17: the retained record holds provider-neutral facts only", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const run = yield* runInterrupted(dir, GRID, stream); + + const entries = gridEntries(run); + expect(entries.length).toBeGreaterThan(0); + + const written = JSON.stringify(run.journal); + // The layout the author wrote, and nothing about whatever presented it. + expect(written).toContain('"columns":2'); + expect(written).toContain('"Left"'); + for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { + expect(`${leak}: ${written.includes(leak)}`).toBe(`${leak}: false`); + } + }); +}); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index e9a990c9a..eba02abb8 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -147,19 +147,20 @@ export type { NativeLaunchRequest, } from "./launcher.ts"; export { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, + TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "./terminal.ts"; export type { - ControlledTerminalProviderOptions, + ControlledCompositeOptions, TerminalComposite, + TerminalGridApi, TerminalGridRequest, TerminalPaneRequest, TerminalPaneState, - TerminalProviderHandler, TerminalProviderLog, TerminalShellOutcome, } from "./terminal.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index cc34203c0..12827a30b 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -1,5 +1,6 @@ /** - * The terminal provider — how a host presents one grid of interactive panes. + * The terminal grid boundary — how a host presents one grid of interactive + * panes, and what composing middleware around it may do. * * This is not the native launcher. A launch hands **one** child the whole * foreground terminal and waits for it; a grid divides that terminal into @@ -9,26 +10,18 @@ * appears in the document: `` asks for panes and their authored * layout, and the host chooses what presents them. * - * A grid is prepared before it is shown, which is what makes opening one atomic: - * - * 1. `prepare()` builds the whole composite while it is still hidden — every - * pane endpoint and its supervision — and presents nothing. A host that - * cannot open a grid refuses here, before any pane has started work. - * 2. Core starts the authored panes concurrently and waits for every one of - * them to be ready. - * 3. `attach()` shows the composite, once, after that barrier. A failure before - * it discards the hidden composite instead of leaving a partial grid on the - * reader's screen. - * 4. `destroy()` takes it down again and gives the root terminal back. + * **This surface is routing, and only routing.** Middleware here may observe, + * narrow, refuse, wrap or delegate one grid request. What it cannot do is open + * a grid: `open()` answers `unknown`, and the answer is thrown away. The + * capability that takes the terminal leases, mints pane claims and settles a + * grid is a non-contextual authority delivered straight to the registered + * provider, and a handler that answers without delegating has therefore + * presented nothing and settled nothing. * - * There is no host default. `xmd run` installs the production provider; a test - * or embedding host installs a controlled one that needs no terminal. Until one - * is installed every operation refuses, which is what keeps writing, inspecting - * and validating a document free of all of this. - * - * **Presentation never decides an outcome.** `update()` receives the pane states - * core has already settled on, so a provider draws them and answers for none of - * them. Nothing a handler returns can make a pane succeed, fail, or be ready. + * A grid is prepared before it is shown, which is what makes opening one atomic: + * the provider builds the whole composite while it is hidden, core starts the + * authored panes and waits for every one of them to report a spawn, and only + * then is anything attached. */ import { type Api, createApi } from "@effectionx/context-api"; @@ -57,6 +50,11 @@ export interface TerminalPaneRequest { * Provider-neutral throughout: it names no terminal, multiplexer, socket, * process, window or pane identifier, and carries no command, argv or * environment. It is what the author wrote, resolved. + * + * It is also **one-use and identity-bearing**. Core mints exactly one of these + * per grid expansion and the authority compares the object it is presented with + * against the one it issued, so a request that was copied, rebuilt with the same + * members, kept from an earlier grid, or already used authorizes nothing. */ export interface TerminalGridRequest { readonly columns: number; @@ -83,7 +81,7 @@ export interface TerminalShellOutcome { /** * One prepared, still-hidden grid. * - * Everything here belongs to the one `prepare()` that produced it. A composite + * Everything here belongs to the one preparation that produced it. A composite * is never reused across expansions, and a provider that hands the same one * back twice has handed back a grid the second expansion did not ask for. */ @@ -118,8 +116,6 @@ export interface TerminalComposite { * ended. * * Which shell that is comes from live host policy, never from the document. - * The bytes it exchanges with the reader belong to the pane: nothing captures - * or journals them. * * `spawned` is the pane's readiness latch, and calling it is the only thing * that makes this pane ready. Call it from the runtime's successful @@ -138,16 +134,14 @@ export interface TerminalComposite { /** * Take the composite down and give the root terminal back. * - * Called exactly once for every composite `prepare()` returned, including one + * Called exactly once for every composite that was prepared, including one * discarded before it ever attached. */ destroy(): Operation; } -export interface TerminalProviderHandler { - /** Build the whole hidden composite for `request`, presenting nothing. */ - prepare(request: TerminalGridRequest): Operation; -} +/** The stable name every loaded copy composes through. */ +export const TERMINAL_GRIDS_API = "TerminalGrids"; export const TERMINAL_PROVIDER_UNAVAILABLE = "no terminal provider is installed — this host does not present a grid of " + @@ -161,29 +155,30 @@ export class TerminalProviderUnavailableError extends Error { } } +export interface TerminalGridApi { + /** + * Route one grid request to whatever presents it. + * + * Answers `unknown`, and the answer is discarded: a return value is not + * evidence that a grid was opened, and core reads what the authority settled + * instead of what a handler said. + */ + open(request: TerminalGridRequest): Operation; +} + /** - * The stable contextual boundary a grid request travels. + * The public routing surface. Its own default always refuses. * - * Middleware composed here may observe, narrow, refuse, wrap or delegate a - * request — everything composition needs. What it cannot do is authorize one: - * the terminal authority that mints pane claims and takes terminal ownership is - * delivered directly to the installed provider and reachable from nowhere else, - * so a handler that answers without delegating has presented nothing. + * Reaching this default means no registered provider consumed the request, so + * nothing was presented — which is the honest answer for a host that installs + * no provider at all. */ -export const TerminalProvider: Api = createApi( - "runtime.terminalProvider", - { - // deno-lint-ignore require-yield - *prepare(_request: TerminalGridRequest): Operation { - throw new TerminalProviderUnavailableError(); - }, +export const TerminalGrids: Api = createApi(TERMINAL_GRIDS_API, { + // deno-lint-ignore require-yield + *open(_request: TerminalGridRequest): Operation { + throw new TerminalProviderUnavailableError(); }, -); - -/** Build the hidden composite for one grid expansion. */ -export function prepareTerminalGrid(request: TerminalGridRequest): Operation { - return TerminalProvider.operations.prepare(request); -} +}); /** * Everything one controlled composite did, in the order it did it. @@ -203,17 +198,22 @@ export interface TerminalProviderLog { readonly shown: Map; } +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { events: [], shown: new Map() }; +} + /** - * What a controlled provider does instead of opening a terminal. + * What a controlled composite does instead of opening a terminal. * * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * can refuse before a composite exists, `onAttach` can fail the barrier, `shell` - * decides what a self-closing pane's shell did and how long it took, and - * `close` is the operation the grid waits on, so a suite controls exactly when - * the reader leaves. + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. */ -export interface ControlledTerminalProviderOptions { - /** Appended to as the provider works, so ordering is read rather than timed. */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ readonly log?: TerminalProviderLog; onPrepare?: (request: TerminalGridRequest) => Operation; onAttach?: () => Operation; @@ -222,93 +222,78 @@ export interface ControlledTerminalProviderOptions { * Called as each pane state is displayed. * * A suite watches it to react to something the grid decided — a pane that - * failed, a pane that became runnable — instead of waiting a while and hoping. + * failed, a pane that became runnable — instead of waiting and hoping. */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - /** - * What a pane's shell did. - * - * It receives the readiness latch, so a suite decides whether this shell - * reports a spawn at all — which is how "never started" is told apart from - * "started and exited immediately". - */ shell?: (ordinal: number, spawned: () => void) => Operation; close?: () => Operation; } /** - * Install a provider that presents nothing and records everything. + * Prepare one composite that presents nothing and records everything. * - * It answers the whole contract — prepare, attach, update, shell, close, + * It answers the whole contract — attach, update, display, shell, close, * destroy — so a suite exercises core's lifecycle without a terminal, a * multiplexer, or a process anywhere in it. */ -export function* installControlledTerminalProvider( - options: ControlledTerminalProviderOptions = {}, -): Operation { - const log = options.log ?? { events: [], shown: new Map() }; - const shown = log.shown; - let prepared = 0; - - yield* TerminalProvider.around( - { - *prepare([request]): Operation { - if (options.onPrepare) { - yield* options.onPrepare(request); +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + let destroyed = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); } - const generation = prepared++; - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - let destroyed = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - }, - // deno-lint-ignore require-yield - *update(ordinal, state) { - log.events.push(`state:${generation}:${ordinal}:${state}`); - options.onUpdate?.(ordinal, state); - }, - // deno-lint-ignore require-yield - *display(ordinal, text) { - const pane = shown.get(ordinal) ?? ""; - shown.set(ordinal, pane + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn - // would hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken - // down more times than it was built, which is exactly the ordering - // claim a suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - }, - }; + log.events.push(`destroy:${generation}`); }, - }, - { at: "min" }, - ); + }; + })(); } diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/runtime/tests/terminal-provider.test.ts index 9e01204a7..3c88c9d83 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/runtime/tests/terminal-provider.test.ts @@ -1,17 +1,17 @@ /** - * Tier TG — the terminal provider boundary (architecture.md §Terminal - * authority, spec §6.21). + * Tier TG — the terminal grid routing surface and the composite contract + * (architecture.md §Terminal authority, spec §6.21). * - * What a host installs to present a grid, and what composing middleware around - * it may and may not do. Nothing here opens a terminal, looks for a - * multiplexer, or starts a process: the whole point of the boundary is that the - * language does not depend on any of that, so a suite that needed one would be - * testing the wrong thing. + * Two things live here, and neither is an authority. The routing surface is + * where middleware composes around a grid request, and its whole contract is + * that it decides nothing: `open()` answers `unknown`, and core throws the + * answer away. The composite is what a provider prepares, and its contract is + * ordering — prepared hidden, attached once, destroyed exactly once. * - * The controlled provider records what it was asked to do, in order. Ordering - * claims are read off that record rather than inferred from timing, because a - * grid that attached too early and a grid that attached on time can take the - * same wall clock. + * Who may present a grid, and what presenting one authorizes, is core's, and is + * proved in `packages/core/tests/terminal-grid.test.ts`. + * + * Nothing here opens a terminal, looks for a multiplexer, or starts a process. */ import { describe, it } from "@executablemd/test-support/bdd"; @@ -20,13 +20,13 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - installControlledTerminalProvider, - prepareTerminalGrid, + prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, - TerminalProvider, + TerminalGrids, + terminalProviderLog, TerminalProviderUnavailableError, } from "../terminal.ts"; -import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog } from "../terminal.ts"; +import type { TerminalGridRequest } from "../terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { @@ -41,16 +41,12 @@ function request(overrides: Partial = {}): TerminalGridRequ }; } -function log(): TerminalProviderLog { - return { events: [], shown: new Map() }; -} - -describe("Tier TG — the provider boundary", () => { +describe("Tier TG — the routing surface", () => { it("TP1: refuses when no host has installed a provider", function* () { let refusal: unknown; yield* scoped(function* () { try { - yield* prepareTerminalGrid(request()); + yield* TerminalGrids.operations.open(request()); } catch (error) { refusal = error; } @@ -60,27 +56,118 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe(TERMINAL_PROVIDER_UNAVAILABLE); }); - it("TP2: an installed provider prepares without presenting anything", function* () { - const record = log(); + it("TP2: middleware observes a delegated request without changing it", function* () { + const seen: TerminalGridRequest[] = []; + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + seen.push(asked); + return yield* next(asked); + }, + }); + yield* TerminalGrids.operations.open(request({ columns: 3, rows: 2 })); + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.columns).toBe(3); + // Observation is not interference: the same object reached the far end. + expect(reached[0]).toBe(seen[0]); + }); + + it("TP2: middleware narrows a request before anything below sees it", function* () { + const reached: TerminalGridRequest[] = []; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + *open([asked], next) { + return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); + }, + }); + yield* TerminalGrids.operations.open(request()); + }); + + expect(reached[0]?.columns).toBe(1); + expect(reached[0]?.rows).toBe(2); + }); + + it("TP2: middleware refuses a request, and nothing below is reached", function* () { + const reached: TerminalGridRequest[] = []; + let refusal: unknown; + yield* scoped(function* () { + yield* TerminalGrids.around( + { + // deno-lint-ignore require-yield + *open([asked]) { + reached.push(asked); + return undefined; + }, + }, + // The terminal end of the chain, where a registered provider sits. + { at: "min" }, + ); + yield* TerminalGrids.around({ + // deno-lint-ignore require-yield + *open(): Operation { + throw new Error("this host does not open terminal grids"); + }, + }); + try { + yield* TerminalGrids.operations.open(request()); + } catch (error) { + refusal = error; + } + }); + + expect(refusal instanceof Error ? refusal.message : "").toBe( + "this host does not open terminal grids", + ); + expect(reached).toEqual([]); + }); +}); + +describe("Tier TG — the composite contract", () => { + it("TP3: a prepared composite presents nothing until it is attached", function* () { + const log = terminalProviderLog(); const events = yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* prepareTerminalGrid(request()); - return [...record.events]; + yield* prepareControlledComposite(request(), { log }); + return [...log.events]; }); - // Preparation happened; nothing was shown. A composite the reader can see - // before every pane is ready is the one thing atomic startup forbids. + // A composite the reader can see before every pane is ready is the one + // thing atomic startup forbids. expect(events).toEqual(["prepare:0:2x1"]); expect(events.some((event) => event.startsWith("attach:"))).toBe(false); }); - it("TP2: attach, update, shell and destroy are recorded in the order they happen", function* () { - const record = log(); + it("TP3: attach, update, display, shell and destroy record in order", function* () { + const log = terminalProviderLog(); const spawns: number[] = []; yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request(), { log }); yield* composite.update(0, "starting"); + yield* composite.display(0, "pane text"); yield* composite.update(0, "running"); yield* composite.shell(1, () => spawns.push(1)); yield* composite.attach(); @@ -89,7 +176,7 @@ describe("Tier TG — the provider boundary", () => { yield* composite.destroy(); }); - expect(record.events).toEqual([ + expect(log.events).toEqual([ "prepare:0:2x1", "state:0:0:starting", "state:0:0:running", @@ -99,22 +186,22 @@ describe("Tier TG — the provider boundary", () => { "closed:0", "destroy:0", ]); + expect(log.shown.get(0)).toBe("pane text"); // The default shell starts, and says so through the latch it was handed: // readiness is reported by the shell rather than assumed by the grid. expect(spawns).toEqual([1]); }); - it("TP5: a shell that never starts never reports a spawn", function* () { + it("TP4: a shell that never starts never reports a spawn", function* () { const spawns: number[] = []; const outcome = yield* scoped(function* () { - yield* installControlledTerminalProvider({ + const composite = yield* prepareControlledComposite(request(), { // deno-lint-ignore require-yield - *shell(_ordinal, _spawned) { + *shell() { // No spawn event: nothing started, so nothing is acknowledged. return { exitCode: 127 }; }, }); - const composite = yield* prepareTerminalGrid(request()); return yield* composite.shell(1, () => spawns.push(1)); }); @@ -122,107 +209,18 @@ describe("Tier TG — the provider boundary", () => { expect(spawns).toEqual([]); }); - it("TP3: middleware observes a delegated request without changing it", function* () { - const record = log(); - const seen: TerminalGridRequest[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - seen.push(asked); - return yield* next(asked); - }, - }); - yield* prepareTerminalGrid(request({ columns: 3, rows: 2 })); - }); - - expect(seen).toHaveLength(1); - expect(seen[0]?.columns).toBe(3); - // Observation is not interference: the provider still saw the same grid. - expect(record.events).toEqual(["prepare:0:3x2"]); - }); - - it("TP3: middleware refuses a request, and no composite is ever built", function* () { - const record = log(); - let refusal: unknown; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - // deno-lint-ignore require-yield - *prepare(): Operation { - throw new Error("this host does not open terminal grids"); - }, - }); - try { - yield* prepareTerminalGrid(request()); - } catch (error) { - refusal = error; - } - }); - - expect(refusal instanceof Error ? refusal.message : "").toBe( - "this host does not open terminal grids", - ); - // Refusing means refusing: the provider below was never reached, so there - // is no hidden composite left needing teardown. - expect(record.events).toEqual([]); - }); - - it("TP3: middleware narrows a request before the provider sees it", function* () { - const record = log(); - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - return yield* next({ ...asked, columns: 1, rows: asked.panes.length }); - }, - }); - yield* prepareTerminalGrid(request()); - }); - - expect(record.events).toEqual(["prepare:0:1x2"]); - }); - - it("TP4: middleware wraps the composite it delegated for", function* () { - const record = log(); - const wrapped: string[] = []; - yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - yield* TerminalProvider.around({ - *prepare([asked], next) { - const composite = yield* next(asked); - return { - ...composite, - *attach() { - wrapped.push("before"); - yield* composite.attach(); - wrapped.push("after"); - }, - }; - }, - }); - const composite = yield* prepareTerminalGrid(request()); - yield* composite.attach(); - yield* composite.destroy(); - }); - - expect(wrapped).toEqual(["before", "after"]); - expect(record.events).toEqual(["prepare:0:2x1", "attach:0", "destroy:0"]); - }); - - it("TP5: a preparation failure leaves nothing to tear down", function* () { - const record = log(); + it("TP4: a preparation failure leaves no composite to tear down", function* () { + const log = terminalProviderLog(); let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider({ - log: record, - // deno-lint-ignore require-yield - *onPrepare() { - throw new Error("no pane endpoint could be created"); - }, - }); try { - yield* prepareTerminalGrid(request()); + yield* prepareControlledComposite(request(), { + log, + // deno-lint-ignore require-yield + *onPrepare() { + throw new Error("no pane endpoint could be created"); + }, + }); } catch (error) { refusal = error; } @@ -231,16 +229,15 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toBe( "no pane endpoint could be created", ); - // The failure happened before the composite existed, so the record shows - // no composite was built and none is owed a destroy. - expect(record.events).toEqual([]); + // The failure happened before the composite existed, so nothing is owed a + // destroy. + expect(log.events).toEqual([]); }); - it("TP5: a composite refuses to be destroyed twice", function* () { + it("TP4: a composite refuses to be destroyed twice", function* () { let refusal: unknown; yield* scoped(function* () { - yield* installControlledTerminalProvider(); - const composite = yield* prepareTerminalGrid(request()); + const composite = yield* prepareControlledComposite(request()); yield* composite.destroy(); try { yield* composite.destroy(); @@ -254,18 +251,17 @@ describe("Tier TG — the provider boundary", () => { expect(refusal instanceof Error ? refusal.message : "").toContain("destroyed twice"); }); - it("TP6: each preparation is its own composite", function* () { - const record = log(); + it("TP5: each preparation is its own composite", function* () { + const log = terminalProviderLog(); yield* scoped(function* () { - yield* installControlledTerminalProvider({ log: record }); - const first = yield* prepareTerminalGrid(request()); - const second = yield* prepareTerminalGrid(request()); + const first = yield* prepareControlledComposite(request(), { log }, 0); + const second = yield* prepareControlledComposite(request(), { log }, 1); yield* first.destroy(); yield* second.destroy(); }); // Two expansions are two grids. A provider that handed the same composite // back would have presented the second expansion's grid as the first's. - expect(record.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); + expect(log.events).toEqual(["prepare:0:2x1", "prepare:1:2x1", "destroy:0", "destroy:1"]); }); }); From 5ea12499835dc3c71d9e50d4227e5b9614878fa4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:45:27 -0400 Subject: [PATCH 09/62] =?UTF-8?q?=F0=9F=90=9B=20Repair=20durableSpawn,=20a?= =?UTF-8?q?nd=20put=20the=20grid=20on=20it=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `durableSpawn` returned a task spawned inside the `ephemeral` effect's own scope, and that scope closed as the effect resolved — so every `yield* task` threw `halted`. It had no call sites and no tests. It now starts the child in the routine's own scope, so the task outlives the call and can be awaited or halted by whoever asked for it. A retained `Close(cancelled)` meant one thing to the code and two things in practice. Under `durableRace` and `durableAll` it is a race loser or a fail-fast sibling, and the same combinator cancels it again — those keep DEC-024 exactly. Under `durableSpawn` nobody cancels it a second time, so suspending hung the resumed run forever. `runDurableChild` now takes an explicit `CancelledChildPolicy`, fixed at each combinator's call site and never chosen by a caller. Resuming uses a new internal `ReplayIndex.reopen()`, which forgets one coroutine's retained Close while keeping its yields — so the child continues its own history rather than restarting, and the divergence guard stops reading the remaining effects as a coroutine continuing past its own close. Neither it nor `disableReplay` is exported. DEC-039 records the policy and marks DEC-024's invariant as superseded in part: it assumed every cancelled child belongs to race or all. The grid uses the repaired primitive: the whole grid is one durable child, each pane is its own durable child allocated in authored ordinal order, and each pane task is observed outside its child — so a replayed pane's retained outcome publishes its status and satisfies the readiness barrier without entering a body, a shell, or a launcher. Evidence: 9 rows in `packages/durable-streams/tests/durable-spawn.test.ts` (lifetime, completed replay, interrupted resume, retained-history continuation, and both combinators keeping their own policy); 30 rows in `packages/core/tests/terminal-grid.test.ts`. durable-streams 32, core 349, runtime 15. --- packages/core/src/terminal/grid.ts | 98 ++--- packages/core/tests/terminal-grid.test.ts | 148 ++++--- packages/durable-streams/combinators.ts | 126 ++++-- packages/durable-streams/replay-index.ts | 17 + packages/durable-streams/specs/DECISIONS.md | 39 ++ .../tests/durable-spawn.test.ts | 366 ++++++++++++++++++ 6 files changed, 654 insertions(+), 140 deletions(-) create mode 100644 packages/durable-streams/tests/durable-spawn.test.ts diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 3892a43c7..fa2a09f0e 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,9 @@ * desynchronise the journal on the next run. */ -import { all, ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation } from "effection"; -import { DurableContext, durableAll, ephemeral } from "@executablemd/durable-streams"; +import { ensure, race, scoped, spawn, withResolvers } from "effection"; +import type { Operation, Task } from "effection"; +import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -226,32 +226,45 @@ function presentGrid( const startupFailed = withResolvers(); let attached = false; - // Every pane's work, in authored order. The children are allocated in this - // order too, so a pane's durable identity follows its ordinal rather than - // the order the runtime happened to schedule it in. - const paneWorkflows = work.map((pane, index) => { + for (const pane of work) { + yield* composite.update(pane.ordinal, "starting"); + } + + // One durable child per pane, allocated here in authored order, so a pane's + // identity follows its ordinal rather than the order the runtime happened + // to schedule it in. Each task is observed *outside* its child: a replayed + // completed pane returns its retained outcome without entering a body, a + // shell, or a launcher, and that outcome is what publishes its status and + // satisfies the readiness barrier. + const panes: Task[] = []; + for (const [index, pane] of work.entries()) { const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; - return function* (): Operation { - const outcome = yield* runPane(pane, claim, composite, readiness, request, index); + panes.push( + yield* paneChild(function* (): Operation { + return yield* runPane(pane, claim, composite, readiness, request, index); + }), + ); + } + + // Observing each task is what turns a pane's outcome — replayed or live — + // into a published status and a satisfied readiness latch. + for (const [index, task] of panes.entries()) { + yield* spawn(function* () { + const outcome = yield* task; outcomes[index] = outcome; - yield* composite.update(pane.ordinal, outcome.status); + // A pane restored from its retained outcome counts as started: it did + // start, on the run that recorded it. + grid.claims[index]!.ready(); + yield* composite.update(work[index]!.ordinal, outcome.status); if (outcome.status === "failed" && !attached) { // Before the barrier a pane failure is the whole grid's: nothing has // been shown, so the grid fails closed rather than attaching what is // left. After it, the failure is this pane's status alone. startupFailed.reject(new Error(outcome.reason)); } - return outcome; - }; - }); - - for (const pane of work) { - yield* composite.update(pane.ordinal, "starting"); + }); } - // Spawned as one task so the coordinator below can reach the readiness - // barrier, attach, and wait for the reader while the panes are still live. - const panes = yield* spawn(() => paneChildren(paneWorkflows)); // Every pane must actually have started before anything is shown. Racing // the barrier against startup failure is what stops a grid whose pane @@ -290,8 +303,8 @@ function presentGrid( yield* composite.update(pane.ordinal, "closed"); outcomes[index] = { status: "closed", reason: "" }; } + yield* panes[index]!.halt(); } - yield* panes.halt(); const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); @@ -339,36 +352,33 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st } /** - * Run every pane as a durable child of the grid, in authored order. + * Run one pane as a durable child of the grid. * * A pane's identity is derived from the grid's coroutine and its authored * ordinal, never from a title, a schedule, or a provider identifier — so a * resumed run restores a completed pane as its outcome without re-running it, * and continues an incomplete one from its own history. * - * `durableAll` rather than `durableSpawn`: the latter returns a task spawned - * inside the ephemeral effect's own scope, and that scope closes as the effect - * resolves, so awaiting the task throws `halted`. It has no call sites or tests - * upstream; `durableAll` is the primitive that is exercised. + * `durableSpawn` rather than a combinator, because the grid owns the panes + * itself: it has to reach the readiness barrier and attach while they are still + * live, and cancel them one at a time when the reader leaves. A retained + * cancelled pane resumes its remaining work rather than suspending, which is + * `durableSpawn`'s policy for a spawned region. * - * Without a journal there are no children to derive, and the work simply runs. + * Without a journal there is no child to derive, and the work simply runs. */ -function paneChildren( - workflows: readonly (() => Operation)[], -): Operation { - return (function* (): Operation { +function paneChild( + body: () => Operation, +): Operation> { + return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* all(workflows.map((workflow) => workflow())); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableAll( - workflows.map( - (workflow) => - function* (): Workflow { - return yield* ephemeral(workflow()); - }, - ), - ); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); + }); })(); } @@ -386,11 +396,9 @@ export function durableGrid(live: () => Operation): Operation([ - function* (): Workflow { - return yield* ephemeral(live()); - }, - ]); - return retained!; + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(live()); + }); + return yield* task; })(); } diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index ad44b1e74..1cc5e340f 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -89,6 +89,15 @@ interface DocumentRun { journal: DurableEvent[]; } +/** + * The mark a document records once it is past the grid. + * + * It fires whether the grid ran or replayed, so a harness can stop the run at + * the same point either way — and a replay that hangs never reaches it, which + * is a failure rather than something a deadline would quietly pass. + */ +const PAST_THE_GRID = "past the grid"; + function useDir(): Operation { return resource(function* (provide) { const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-tg-"))); @@ -100,7 +109,11 @@ function useDir(): Operation { } /** The controlled interactive child, and a tripwire. */ -function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { +function useGridComponents( + ran: string[], + slowMarks: string[] = [], + onMark: (mark: string) => void = () => {}, +): Operation { return registerComponents([ { name: "Interactive", @@ -129,6 +142,7 @@ function useGridComponents(ran: string[], slowMarks: string[] = []): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; const opened = withResolvers(); - yield* useGridComponents(ran); + // Two signals, neither a deadline: the grid opened on a live run, or the + // document reached the sibling after it — which is what a replayed grid + // does. A replay that hangs reaches neither and hangs the row, rather than + // passing on a timer. + yield* useGridComponents(ran, [], (mark) => { + if (mark === PAST_THE_GRID) { + opened.resolve(); + } + }); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: () => suspend(), + close: options.close === true ? immediateClose() : () => suspend(), + ...(options.shell === undefined ? {} : { shell: options.shell }), *onPrepare(asked) { requests.push(asked); yield* sleep(0); @@ -365,7 +393,7 @@ function runInterrupted( // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* race([opened.operation, sleep(120)]); + yield* opened.operation; yield* sleep(5); yield* task.halt(); return { @@ -974,12 +1002,14 @@ describe("Tier TG — startup, settlement and teardown", () => { ); expect(run.outcome.ok).toBe(true); - // Ready enough to attach, and settled enough to be `succeeded`. + // Ready at the spawn event, so the grid attached; settled straight after, + // so its final status is its own. Both, from one child that started and + // stopped in the same breath. expect(run.events).toContain("attach:0"); expect(run.events).toContain("state:0:0:succeeded"); - // A pane that already settled keeps the status it settled to. - expect(run.events.indexOf("state:0:0:succeeded")).toBeLessThan(run.events.indexOf("attach:0")); - expect(run.events).not.toContain("state:0:0:running"); + expect(run.events.indexOf("state:0:0:succeeded")).toBeGreaterThan( + run.events.indexOf("attach:0"), + ); }); it("TG9: a preparation failure starts no pane at all", function* () { @@ -1080,61 +1110,65 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); - /** Every terminal-grid entry the journal holds. */ - function gridEntries(run: DocumentRun): DurableEvent[] { - return run.journal.filter( - (event) => - event.type === "yield" && String(event.description.name).startsWith("terminal_grid:"), - ); - } + it("TG16: each pane is a durable child of the grid, in authored order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + + const closes = first.journal.filter((event) => event.type === "close"); + const paneIds = closes + .map((event) => String(event.coroutineId)) + .filter((id) => id.split(".").length >= 3) + .sort(); + expect(paneIds).toHaveLength(2); + const [left, right] = paneIds; + // Authored order, not scheduling order, and both beneath one grid child. + expect(left!.endsWith(".0")).toBe(true); + expect(right!.endsWith(".1")).toBe(true); + expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + }); - it("TG15: a completed grid replays without contacting a provider at all", function* () { + it("TG16: an interrupted grid rebuilds a fresh composite rather than hanging", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - // The grid opened and its panes ran; the document was then interrupted, so - // the root reached no outcome and a resumed run reaches the grid again. + // Interrupted while the grid is open, so its child records a cancelled + // close. Under the repaired spawn policy the resumed run continues that + // region instead of suspending on it forever. const first = yield* runInterrupted(dir, GRID, stream); expect(first.requests).toHaveLength(1); const second = yield* runInterrupted(dir, GRID, stream); - // The region's retained result is the answer: no provider was asked for a - // grid, no pane content expanded, and nothing was displayed. - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); + // A fresh composite, built by this run. + expect(second.requests).toHaveLength(1); + expect(second.events).toContain("prepare:0:2x1"); }); - it("TG15: a completed grid replays even where no provider could open one", function* () { + it("TG16: a completed pane is restored; an incomplete shell starts again", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + const holdingShell: ControlledCompositeOptions["shell"] = function* (_ordinal, spawned) { + spawned(); + yield* suspend(); + return { exitCode: 0 }; + }; - yield* runInterrupted(dir, GRID, stream); - // This host installs no provider at all. A replay that contacted one would - // refuse here; the retained result does not need one. - const second = yield* runInterrupted(dir, GRID, stream, { provider: false }); + const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + expect(first.ran).toContain("left ran"); - expect(second.requests).toEqual([]); - expect(second.shown.size).toBe(0); - expect(second.events).toEqual([]); - }); - - it("TG16: each pane is a durable child of the grid, in authored order", function* () { - const dir = yield* useDir(); - const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); - const closes = first.journal.filter((event) => event.type === "close"); - const ids = closes.map((event) => String(event.coroutineId)).sort(); - // Two pane children beneath one grid child: `..`. - const paneIds = ids.filter((id) => id.split(".").length >= 3); - expect(paneIds).toHaveLength(2); - const [left, right] = paneIds; - // Authored order, not scheduling order. - expect(left!.endsWith(".0")).toBe(true); - expect(right!.endsWith(".1")).toBe(true); - expect(left!.slice(0, left!.lastIndexOf("."))).toBe(right!.slice(0, right!.lastIndexOf("."))); + // The completed pane came back from its retained outcome: its body did not + // run again. + expect(second.ran).not.toContain("left ran"); + // The incomplete shell starts again under current host policy, claiming no + // continuity with the terminal history it had before. + expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); it("TG17: the layout is recorded before any provider is contacted", function* () { @@ -1142,32 +1176,24 @@ describe("Tier TG — durability and replay", () => { const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layout = run.journal.find( + const layoutIndex = run.journal.findIndex( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - expect(layout).toBeDefined(); - // Written before the grid child that opens anything, so a comparison - // against it happens while nothing has been presented. - const layoutIndex = run.journal.indexOf(layout!); - const opened = run.journal.findIndex( + const firstChildClose = run.journal.findIndex( (event) => event.type === "close" && String(event.coroutineId).includes("."), ); expect(layoutIndex).toBeGreaterThan(-1); - if (opened > -1) { - expect(layoutIndex).toBeLessThan(opened); + if (firstChildClose > -1) { + expect(layoutIndex).toBeLessThan(firstChildClose); } }); - it("TG17: the retained record holds provider-neutral facts only", function* () { + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const entries = gridEntries(run); - expect(entries.length).toBeGreaterThan(0); - const written = JSON.stringify(run.journal); - // The layout the author wrote, and nothing about whatever presented it. expect(written).toContain('"columns":2'); expect(written).toContain('"Left"'); for (const leak of ["socket", "tmux", "attach-key", "argv", "multiplexer"]) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 179f2cc88..dcd203592 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -36,7 +36,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -53,13 +53,39 @@ import type { Close, Json, Workflow, WorkflowValue } from "./types.ts"; * IMPORTANT: This must be called inside a spawn() so it gets its own scope. * The caller is responsible for spawn(). */ +/** + * What a spawned region does with a retained `Close(cancelled)`. + * + * The two answers are not preferences; they follow from who is going to cancel + * the child on this run. + * + * - `"combinator-cancels"` — `durableRace` and `durableAll`. A retained + * cancelled child is a race loser or a fail-fast sibling, and the same + * combinator will cancel it again, so the child reproduces the original run + * by suspending until it does. + * - `"resume"` — `durableSpawn`. The caller owns the task, and a retained + * cancelled child under a parent that never completed means the *run* was + * interrupted, not that a combinator chose against this child. Nothing will + * cancel it a second time, so suspending would hang the resumed run forever. + * It continues its own retained history instead and finishes the work it had + * left, writing the Close its second life actually reached. + * + * The policy belongs to the combinator, not to its caller: it is fixed at each + * call site below and there is no way to ask for another one. + */ +type CancelledChildPolicy = "combinator-cancels" | "resume"; + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, + cancelledPolicy: CancelledChildPolicy = "combinator-cancels", ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); + // Set when this run continued a retained cancelled child, so its teardown + // writes the Close it reached rather than leaving the stale cancelled one. + let resumedFromCancelled = false; // Short-circuit: child already completed in a previous run. // NOTE: Replay guard validation is not bypassed here — the check phase @@ -73,22 +99,22 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else { - // cancelled — this child was cancelled in a previous run (e.g., - // a race loser). Instead of throwing, we suspend forever. The - // parent combinator (race/all) will cancel this child as part of - // normal structured concurrency teardown, just like the original - // run. The Close(cancelled) event already exists in the journal, - // so we skip re-emitting it (the ensure teardown checks for this). - // - // INVARIANT: This branch is only reachable when a parent combinator - // (durableRace or durableAll with a failed sibling) will cancel this - // child. Close(cancelled) in the journal means the child was - // previously cancelled by structured concurrency, so on replay the - // same combinator will cancel it again. This cannot deadlock. + } else if (cancelledPolicy === "combinator-cancels") { + // A race loser, or a sibling `all` cancelled when another failed. The + // same combinator cancels it again on this run, so reproducing the + // original execution means blocking until it does — in the live run this + // child never threw, it simply stopped. The Close(cancelled) event + // already exists, so the teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; + } else { + // A spawned region whose run was interrupted. Nobody is going to cancel + // this child a second time, so suspending would hang the resumed run. + // Forget the retained close — its yields stay replayable, so the child + // continues its own history — and fall through to run the rest. + resumedFromCancelled = true; + replayIndex.reopen(childId); } } @@ -137,8 +163,10 @@ function* runDurableChild( } // Don't re-emit a Close event if one already exists in the journal - // (e.g., a cancelled child being replayed via suspend()). - if (!replayIndex.hasClose(childId)) { + // (e.g., a cancelled child being replayed via suspend()). A child that + // resumed from a retained cancelled Close is the exception: the record it + // reached this time is the one that describes the work that actually ran. + if (resumedFromCancelled || !replayIndex.hasClose(childId)) { yield* appendDurableEvent(childCtx, closeEvent); } }); @@ -209,33 +237,63 @@ function* runDurableChild( } /** - * Spawn a durable child workflow. + * Spawn a durable child workflow, and hand its task back to the caller. * - * Assigns a deterministic coroutine ID (parentId.N), sets up DurableContext - * on the child scope, and ensures Close events are emitted. + * Assigns a deterministic coroutine ID (`parentId.N`) in call order, sets up + * DurableContext on the child scope, and ensures a Close event is emitted. * - * Returns a Task that can be yield*-ed to get the child's result. + * **The task outlives this call.** It is started in the *routine's* own scope + * rather than inside the effect that returns it, so the caller can await it, + * cancel it, or leave it running beside other work. Spawning it through + * `ephemeral()` instead — as this once did — put it in a scope that closed as + * soon as the effect resolved, so every `yield* task` threw `halted`. * - * Returns Workflow> via ephemeral() — the infrastructure effects - * (useScope, spawn) are durable-safe scope setup that doesn't need - * journaling and re-runs correctly on replay. + * A retained `Close(cancelled)` here means the run was interrupted, not that a + * combinator chose against this child, so the child resumes its remaining work. + * See `CancelledChildPolicy`. */ export function durableSpawn( childWorkflow: () => Workflow, ): Workflow> { - return ephemeral( - (function* (): Operation> { - const scope = yield* useScope(); - const ctx = scope.expect(DurableContext); + return (function* (): Workflow> { + // Reading the context and allocating the child id is ordinary scope setup: + // no journal entry, and it re-runs identically on replay. Allocation is + // synchronous and in call order, so ids follow the order children are + // asked for rather than the order they are scheduled. + const ctx = yield* ephemeral(readDurableContext()); + const childIndex = ctx.childCounter++; + const childId = `${ctx.coroutineId}.${childIndex}`; + return (yield createSpawnEffect(() => + runDurableChild(childWorkflow, childId, ctx, "resume"), + )) as Task; + })(); +} - // Assign deterministic child ID - const childIndex = ctx.childCounter++; - const childId = `${ctx.coroutineId}.${childIndex}`; +function* readDurableContext(): Operation { + const scope = yield* useScope(); + return scope.expect(DurableContext); +} - // Spawn the child with durable wrapping - return yield* spawn(() => runDurableChild(childWorkflow, childId, ctx)); - })(), - ); +/** + * Start `child` in the routine's own scope and resolve with its task. + * + * The routine's scope is the workflow's, so the task lives for as long as the + * workflow does — that is the whole repair. Nothing is journaled: the child + * writes its own entries under its own coroutine id. + * + * A child that fails fails the workflow that spawned it, exactly as an ordinary + * Effection `spawn` does. What replay must not do is reach the child's body + * again to discover that. + */ +function createSpawnEffect(child: () => Operation): DurableEffect> { + return { + description: "durable-spawn", + effectDescription: { type: "ephemeral", name: "durable-spawn" }, + enter(resolve, routine) { + resolve({ ok: true, value: routine.scope.run(child) }); + return (exit) => exit({ ok: true, value: undefined as undefined }); + }, + }; } /** diff --git a/packages/durable-streams/replay-index.ts b/packages/durable-streams/replay-index.ts index 65e850866..7eeeaf672 100644 --- a/packages/durable-streams/replay-index.ts +++ b/packages/durable-streams/replay-index.ts @@ -77,6 +77,23 @@ export class ReplayIndex { this.disabled.add(coroutineId); } + /** + * Forget the retained Close for one coroutine, keeping its retained yields. + * + * A spawned region whose run was interrupted continues the work it had left, + * so its retained history must stay replayable while its retained + * `Close(cancelled)` stops standing in the way — otherwise the divergence + * guard reads the extra effects as a coroutine continuing past its own close. + * + * Deliberately narrower than `disableReplay`, which would throw the history + * away and re-run the child from the beginning. Internal: nothing exports + * this, because deciding that a closed coroutine may continue is the + * combinator's, and never a caller's. + */ + reopen(coroutineId: CoroutineId): void { + this.closes.delete(coroutineId); + } + /** Returns true if replay has been disabled for this coroutine. */ isReplayDisabled(coroutineId: CoroutineId): boolean { return this.disabled.has(coroutineId); diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index cec63a894..6e3cb8479 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -489,6 +489,45 @@ Updated before completion of every phase and committed at the end of each phase. finally block skips re-emitting it (checked via `replayIndex.hasClose()`). - **Consequences:** Replay of race losers is invisible — they block and get cancelled just like the original run. No duplicate Close events. +- **Superseded in part by DEC-039.** The invariant recorded here assumed every + retained `Close(cancelled)` belongs to a child a combinator will cancel + again. That is true of `durableRace` and `durableAll`, and false of + `durableSpawn`. + +## DEC-039: A spawned region resumes a retained cancelled child + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands its task to the caller, so nothing cancels + the child on the caller's behalf. Under DEC-024 a retained + `Close(cancelled)` made such a child `suspend()` forever, and no combinator + was ever going to cancel it a second time — the resumed run hung. The + invariant "this branch is only reachable when a parent combinator will cancel + this child" was simply not true once regions could be spawned. +- **Decision:** `runDurableChild` takes an explicit `CancelledChildPolicy`, + fixed at each combinator's call site and never chosen by a caller: + - `"combinator-cancels"` — `durableRace` and `durableAll` keep DEC-024 + exactly. A retained race loser or fail-fast sibling still suspends until + its combinator cancels it again. + - `"resume"` — `durableSpawn`. A retained `Close(cancelled)` under a parent + that never completed means the *run* was interrupted, not that a combinator + chose against this child, so the child continues the work it had left. +- **Rationale:** The two cases differ in who is going to act next, which is a + fact about the region rather than a preference. Reading a cancelled close as + "interrupted" where nothing will cancel it again is the only answer that + terminates. +- **Mechanism:** Resuming calls the internal `ReplayIndex.reopen(coroutineId)`, + which forgets that coroutine's retained Close while keeping its retained + yields — so the child continues its own history rather than restarting, and + the divergence guard does not read the remaining effects as a coroutine + continuing past its own close. It is deliberately narrower than + `disableReplay`, and neither is exported: deciding that a closed coroutine may + continue belongs to the combinator. +- **Consequences:** A resumed child writes the Close its second life reached, + replacing the retained cancelled one. `durableSpawn` also starts its child in + the routine's own scope rather than inside the `ephemeral` effect that + returns the task, so the task outlives the call and can be awaited or halted; + previously every `yield* task` threw `halted`. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts new file mode 100644 index 000000000..4dcd3a919 --- /dev/null +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -0,0 +1,366 @@ +/** + * `durableSpawn` — a durable child the caller owns. + * + * `durableAll` and `durableRace` own their children: they start them, wait for + * them, and cancel them. `durableSpawn` does not — it hands the task back, and + * everything here follows from that. + * + * Two things are easy to get wrong and are checked directly rather than + * inferred. The task has to outlive the call that produced it, or awaiting it + * throws `halted` before the child has done anything. And a retained + * `Close(cancelled)` means something different here than it does under a + * combinator: nobody is going to cancel this child a second time, so a child + * that suspended waiting for that would hang the resumed run forever. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { sleep, spawn, suspend } from "effection"; +import type { Operation } from "effection"; + +import { durableRun } from "../run.ts"; +import { durableAll, durableRace, durableSpawn } from "../combinators.ts"; +import { durableCall } from "../operations.ts"; +import { ephemeral } from "../ephemeral.ts"; +import { InMemoryStream } from "../stream.ts"; +import type { Workflow } from "../types.ts"; + +/** A workflow that records that it ran and returns `value`. */ +function marking(marks: string[], mark: string, value: string): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push(mark); + return value; + })(), + ); + }; +} + +describe("durableSpawn — lifetime", () => { + it("returns a task that is still live, and awaitable", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "child", "spawned")); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("spawned"); + expect(marks).toEqual(["child"]); + }); + + it("keeps the task running beside its caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const value = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* sleep(5); + marks.push("child finished"); + return "late"; + })(), + ); + }); + // The caller does its own work first. A task spawned into a scope that + // closed with the effect would already be dead by now. + yield* ephemeral( + (function* (): Operation { + marks.push("caller working"); + })(), + ); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(value).toBe("late"); + expect(marks).toEqual(["caller working", "child finished"]); + }); + + it("lets the caller cancel the task it was given", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("child started"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + marks.push("caller halted it"); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(marks).toEqual(["child started", "caller halted it"]); + const closes = (yield* stream.readAll()).filter((event) => event.type === "close"); + // Cancelling the task records the child's cancellation, exactly as a + // combinator-cancelled child records one. + expect(closes.some((event) => event.result.status === "cancelled")).toBe(true); + }); + + it("allocates child ids in the order children are asked for", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const first = yield* durableSpawn(marking([], "a", "a")); + const second = yield* durableSpawn(marking([], "b", "b")); + yield* ephemeral(first); + yield* ephemeral(second); + return "done"; + }, + { stream }, + ); + + const ids = (yield* stream.readAll()) + .filter((event) => event.type === "close") + .map((event) => String(event.coroutineId)); + expect(ids).toContain("root.0"); + expect(ids).toContain("root.1"); + }); +}); + +describe("durableSpawn — replay", () => { + it("replays a completed child without running it again", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + const first = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + expect(first).toBe("value"); + expect(marks).toEqual(["ran"]); + + const second = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(marking(marks, "ran", "value")); + return yield* ephemeral(task); + }, + { stream }, + ); + + // The retained result, and the workflow never entered. + expect(second).toBe("value"); + expect(marks).toEqual(["ran"]); + }); + + it("resumes an interrupted child rather than hanging on its cancelled close", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + + // A run interrupted while the child is still working: the whole run is + // halted, so the child records Close(cancelled) and the parent records no + // Close at all. A parent that completed would replay its own result and the + // child would never be reached. + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* interrupted.halt(); + + expect(marks).toEqual(["first life"]); + + // The resumed run. Nothing is going to cancel this child again, so a child + // that suspended on the retained cancelled close would never settle. + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("second life"); + return "finished"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("finished"); + expect(marks).toEqual(["first life", "second life"]); + // The record now describes the life that actually finished. + const closes = (yield* stream.readAll()).filter( + (event) => event.type === "close" && String(event.coroutineId) === "root.0", + ); + expect(closes[closes.length - 1]?.result.status).toBe("ok"); + }); + + it("continues a resumed child's own retained history", function* () { + const calls: string[] = []; + const stream = new InMemoryStream(); + const step = (name: string) => + durableCall(name, function* () { + calls.push(name); + return name; + }); + + const interrupted = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + yield* step("first"); + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* interrupted.halt(); + + expect(calls).toEqual(["first"]); + + const resumed = yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + yield* step("first"); + yield* step("second"); + return "done"; + }); + return yield* ephemeral(task); + }, + { stream }, + ); + + expect(resumed).toBe("done"); + // `first` came from the child's own retained history; only the work it had + // left ran again. + expect(calls).toEqual(["first", "second"]); + }); +}); + +describe("durableSpawn — the combinators keep their own policy", () => { + it("a retained race loser still suspends until the race cancels it", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + marks.length = 0; + + // The loser's Close(cancelled) is retained. On replay it suspends and the + // race cancels it again, exactly as the first run did — it does not resume. + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); + + it("a retained fail-fast sibling still suspends under all()", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const both = () => + durableAll([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("failing"); + throw new Error("sibling failed"); + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("cancelled sibling"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + let first: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + first = error; + } + expect(first instanceof Error ? first.message : "").toContain("sibling failed"); + + marks.length = 0; + let second: unknown; + try { + yield* durableRun(both, { stream }); + } catch (error) { + second = error; + } + + expect(second instanceof Error ? second.message : "").toContain("sibling failed"); + // Neither child re-ran: the failure replayed and the sibling suspended. + expect(marks).toEqual([]); + }); +}); From 64ee684df16f7366763b1bc1a7a491f3ebb55992 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 15:52:57 -0400 Subject: [PATCH 10/62] =?UTF-8?q?=F0=9F=93=9D=20Decide=20the=20cancelled-c?= =?UTF-8?q?hild=20contract=20and=20TG17's=20replay=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two decisions exposed while implementing #730, and no implementation. **DEC-040 — a cancelled child records why.** DEC-039's `"resume"` fired on every retained `Close(cancelled)` under an incomplete parent, which revives work a caller deliberately halted: the record of a deliberate `task.halt()` and the record of an interrupted run are the same event. The cancelled close now carries `cancellation: "caller"` or `"unwound"`, written by whichever path cancelled the child, and `"resume"` continues only `"unwound"`. A deliberate stop suspends, which is DEC-024's reproduction argument applied to a caller instead of a combinator; a legacy record with no reason reads as `"caller"`, because refusing to revive is the safe direction. The reason is retained evidence, not authority: nothing outside `runDurableChild` reads it, and no caller chooses a policy. Terminal grids need nothing wider. A grid halts its pane tasks at close, so those retain `"caller"` — and the grid child completes, so a resumed run short-circuits the region and never reaches them. The case that must resume, an interrupted run, unwinds and retains `"unwound"`. **TG17 narrows to the resolved layout.** A continuation executes the root the journal retained; the supplied source is not read, compared or refused (proved in #722). A grid's authored structure — pane count, order, form — is therefore fixed for the life of a journal and cannot differ between runs, so comparing it compares a value with itself, which is why the refusal never fired. What a fixed retained document still resolves differently is `columns` and each `title`, through prop-borne values, since props are not restored. Those refuse before the lease and before provider contact. Authored-structure change is a root-definition compatibility question, not a grid one. Root-definition authority is preserved rather than overridden by a pre-replay comparison against the current file, and the versioned root boundary that would refuse a changed source stays open work. --- architecture.md | 27 ++++++++++- packages/durable-streams/specs/DECISIONS.md | 47 +++++++++++++++++++ .../durable-streams/specs/durable-streams.md | 8 ++++ specs/executable-mdx-spec.md | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/architecture.md b/architecture.md index 97dbfc71d..e293f825f 100644 --- a/architecture.md +++ b/architecture.md @@ -3090,8 +3090,31 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. -Partial replay first compares the exact authored layout and refuses divergence -before provider work. It rebuilds a fresh provider composite: completed pane +Partial replay compares the **resolved** layout and refuses divergence before +provider work. + +What that can and cannot cover follows from where a resumed run gets its +document. A continuation executes the root the journal retained: the source the +new invocation supplies is not read, not compared and not refused. So the +authored structure of a grid — how many panes it has, their order, and whether +each was written paired or self-closing — is fixed for the whole life of a +journal, and cannot differ between runs. Comparing it would compare a value with +itself. + +What can still differ is everything the retained source *resolves*: `columns` +and each `title` are expressions, and props are not restored across a +continuation, so a prop-borne or otherwise live value produces a different +resolved layout from the same retained document. Those are what the comparison +is for, and a change in either refuses before the foreground lease is taken and +before any provider is contacted. + +Authored-structure change is therefore not a grid concern. A document whose body +changed under an existing journal is a root-definition compatibility question — +the retained root stays authoritative, and deciding whether a changed source +should be refused rather than ignored belongs to a versioned root boundary that +does not exist yet. Until it does, the grid's obligation is the narrower one it +can actually discharge: retain the complete authored structure, and open the +structure it retained rather than the one the file now shows. It rebuilds a fresh provider composite: completed pane children are restored as settled statuses without re-running their effects, while incomplete children replay or start their remaining work. An incomplete `` preserves the prepared/detached identity rules of its own diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 6e3cb8479..8c422ffbf 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -528,6 +528,53 @@ Updated before completion of every phase and committed at the end of each phase. the routine's own scope rather than inside the `ephemeral` effect that returns the task, so the task outlives the call and can be awaited or halted; previously every `yield* task` threw `halted`. +- **Amended by DEC-040.** As first written, `"resume"` fired on *every* retained + `Close(cancelled)` under an incomplete parent. That is too wide: a caller may + deliberately halt the task it owns, and the record of that is + indistinguishable from the record of an interrupted run. DEC-040 supplies the + missing evidence and narrows `"resume"` to involuntary cancellation. + +## DEC-040: A cancelled child records why it was cancelled + +- **Phase:** 4 (Structured Concurrency) +- **Date:** 2026-09-02 +- **Context:** `durableSpawn` hands the task to its caller, and the caller may + call `task.halt()` on purpose — a region it decided to stop. If the run is + later interrupted before the parent completes, the journal holds + `Close(cancelled)` for that child and nothing else. DEC-039's `"resume"` + policy therefore revives work the caller deliberately cancelled, on every + subsequent resumed run. +- **Decision:** The cancelled close carries **why**, written by whichever path + cancelled the child: + - `cancellation: "caller"` — the owner called `halt()` on the task + `durableSpawn` returned. A deliberate stop. + - `cancellation: "unwound"` — anything else: the routine's scope unwinding, + the run being interrupted, the host going away. Involuntary. + + A record with no `cancellation` member is legacy and reads as `"caller"`, + because refusing to revive is the safe direction: it reproduces the original + run rather than performing work nobody asked for twice. + + `runDurableChild`'s policies then read: + - `"combinator-cancels"` (`durableRace`, `durableAll`) — suspend, whatever the + reason. Unchanged from DEC-024. + - `"resume"` (`durableSpawn`) — resume **only** `"unwound"`. A `"caller"` + cancellation suspends, exactly as a combinator-cancelled child does. +- **Rationale:** Suspending is the faithful reproduction of a deliberate halt: + the caller's control flow is deterministic, so it reaches the same + `task.halt()` again and cancels the child a second time — which is DEC-024's + argument, applied to a caller instead of a combinator. A caller that instead + *awaits* a task it previously halted has diverged, and divergence is the + honest answer there rather than a silent revival. +- **Consequences:** Terminal grids get what they need without reviving anything + deliberately stopped. A grid halts each pane task when the reader closes, so + those panes retain `"caller"` — and the grid child completes, so a resumed run + short-circuits the whole region and never reaches them. The case that must + resume — the run interrupted while the grid is open — unwinds the grid and + pane children, retains `"unwound"`, and continues. +- **Scope:** The reason is retained evidence, not authority. Nothing reads it + from outside `runDurableChild`, no public API exposes it, and no caller + chooses a policy: the policy stays fixed at each combinator's call site. ## DEC-025: Test 27 — dynamic spawn count is not a divergence error diff --git a/packages/durable-streams/specs/durable-streams.md b/packages/durable-streams/specs/durable-streams.md index 5f1b1b5ec..95540ba37 100644 --- a/packages/durable-streams/specs/durable-streams.md +++ b/packages/durable-streams/specs/durable-streams.md @@ -130,6 +130,14 @@ function* runWithDurability(operation, producer) { } ``` +A `cancelled` Close also records **why**, because two very different things +produce one: a caller deliberately halting a task it owns, and a run being +interrupted. `cancellation: "caller"` is the deliberate stop; `"unwound"` is +everything involuntary. A resumed spawned region continues an `"unwound"` child +and reproduces a `"caller"` one by suspending, so nothing deliberately stopped +is silently performed again. A record with no `cancellation` member reads as +`"caller"`. See DEC-040. + For **Close events**, the ordering discipline is: ```typescript diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 313aae121..53a739460 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11219,7 +11219,7 @@ test derives a core result from a provider identifier. | TG14 | Bounded teardown proof | Before cancellation signals, the provider snapshots the live child's observable descendants and pane process-group members; before pane reuse and again before its worker exits it proves those processes and all other terminal holders gone. Grid teardown also proves every worker, attachment, control client and server gone and removes private paths. An attach exit, one PID, signal delivery or timeout is not proof. A descendant that already started a new session, closed the pane terminal and lost its parent is recorded as outside the host's observable boundary rather than falsely claimed stopped | | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | -| TG17 | Replay divergence and retained shape | A changed column count, pane count, order, form or title refuses before provider work; retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | +| TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | ### Tier CR — Component registration and resolution From b15d1e4082b1419cb3d0e032c02a737e3ce10693 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 16:45:40 -0400 Subject: [PATCH 11/62] =?UTF-8?q?=E2=9C=A8=20Implement=20DEC-040,=20and=20?= =?UTF-8?q?complete=20TG15=20and=20TG17=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **DEC-040.** A cancelled Close now records why: `cancellation: "caller"` when the owner halts the task `durableSpawn` returned, `"unwound"` for anything involuntary. `durableSpawn` resumes only `"unwound"`; a deliberate stop suspends until the caller's deterministic control flow halts it again, and a record with no reason reads as `"caller"` so nothing legacy is revived. `durableAll` and `durableRace` keep DEC-024 whatever the reason says. The halt is intercepted without changing the public `Task` surface: the returned task carries every member the real one defines, copied with its prototype, and only `halt` is replaced. A proxy cannot do this — a task's members are read-only and non-configurable, so a `get` trap is required to hand back exactly what the target holds. The reason had to survive three boundaries that were dropping it: the protocol parser, the observable copy, and — the one that actually mattered — `detachResult`, which froze every cancellation down to `{ status }`. **TG15.** The harness's `attached` and `pastGrid` signals are now separate, and a run that expects its grid to complete waits for the sibling *after* the grid before halting the root at ``. That is what leaves a completed grid child under an incomplete root, which is the only state in which a completed region can be observed replaying at all. Both a successful grid and a contained failed one replay their exact retained result with no provider, pane content, shell or launcher work, and each row asserts the grid child genuinely recorded a terminal close. No timeouts. **TG17.** Prop-borne `columns` and `title` change independently against one fixed retained document — the only things a fixed retained root can still resolve differently — and each refuses with zero provider observation. For supplied-file changes to pane count, order and form, the continuation opens the retained structure rather than the file's, asserted request-for-request. The retained record carries every authored pane's ordinal, title, form and derived position. `readLayout()` parses totally: the layout object and every pane field, with missing, extra, mistyped, out-of-position and self-inconsistent records all refused rather than half-read. Evidence: durable-spawn 14 rows, terminal-grid 36 rows, structural 13, provider 10. Packages: durable-streams 33, core 349, runtime 15, workflow 172. --- packages/core/src/terminal/journal.ts | 82 +++++- packages/core/tests/terminal-grid.test.ts | 277 ++++++++++++++++-- packages/durable-streams/combinators.ts | 112 +++++-- packages/durable-streams/mod.ts | 1 + packages/durable-streams/parse.ts | 16 +- packages/durable-streams/retained.ts | 19 +- .../tests/durable-spawn.test.ts | 202 +++++++++++++ packages/durable-streams/types.ts | 14 +- 8 files changed, 669 insertions(+), 54 deletions(-) diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index cedd0b39a..3ee4fd8d4 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -66,17 +66,87 @@ function* append(description: EffectDescription, value: Json): Workflow }); } -/** The retained layout a journal entry holds, or undefined if it holds anything else. */ +/** + * The layout a journal entry holds, parsed member by member. + * + * Total: every field is read and checked, and anything the record does not say + * exactly — a missing member, a member of the wrong kind, an extra one, a pane + * whose ordinal is not its position, a row or column that does not follow from + * the columns it claims — makes the record unreadable rather than half-read. A + * layout is what a resumed run is held to, so a record that cannot be believed + * in full must not be believed in part. + */ function readLayout(value: unknown): RetainedLayout | undefined { - if (typeof value !== "object" || value === null || Array.isArray(value)) { + const record = members(value); + if (record === undefined || !onlyNames(record, ["columns", "rows", "panes"])) { return undefined; } - const fields: Record = Object.fromEntries(Object.entries(value)); - const { columns, rows, panes } = fields; - if (typeof columns !== "number" || typeof rows !== "number" || !Array.isArray(panes)) { + const columns = positiveInteger(record.columns); + const rows = positiveInteger(record.rows); + const list = record.panes; + if (columns === undefined || rows === undefined || !Array.isArray(list)) { + return undefined; + } + const panes: RetainedLayout["panes"] = []; + for (const [index, entry] of list.entries()) { + const pane = readPane(entry, index, columns); + if (pane === undefined) { + return undefined; + } + panes.push(pane); + } + // The rows a grid claims have to be the rows its panes need, or the record + // describes a grid nothing could have derived. + if (panes.length === 0 || Math.ceil(panes.length / columns) !== rows) { return undefined; } - return { columns, rows, panes: panes as RetainedLayout["panes"] }; + return { columns, rows, panes }; +} + +/** One retained pane, checked against the position it claims to occupy. */ +function readPane( + value: unknown, + index: number, + columns: number, +): RetainedLayout["panes"][number] | undefined { + const record = members(value); + if (record === undefined || !onlyNames(record, ["ordinal", "title", "form", "row", "column"])) { + return undefined; + } + const { ordinal, title, form, row, column } = record; + if (ordinal !== index) { + return undefined; + } + if (typeof title !== "string" || title.length === 0) { + return undefined; + } + if (form !== "paired" && form !== "self-closing") { + return undefined; + } + // Derived, not asserted: a position that does not follow from the ordinal and + // the column count is a record that disagrees with itself. + if (row !== Math.floor(index / columns) || column !== index % columns) { + return undefined; + } + return { ordinal, title, form, row, column }; +} + +/** The members of a JSON object, or `undefined` for anything else. */ +function members(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return Object.fromEntries(Object.entries(value)); +} + +/** Whether a record carries exactly these member names, and no others. */ +function onlyNames(record: Record, names: readonly string[]): boolean { + const present = Object.keys(record); + return present.length === names.length && names.every((name) => name in record); +} + +function positiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; } /** How two layouts differ, in the words an author can act on. */ diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 1cc5e340f..9da63e051 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -248,6 +248,8 @@ function runDocument( composite?: ControlledCompositeOptions; /** Where `` records that it started. */ slowMarks?: string[]; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { @@ -292,7 +294,12 @@ function runDocument( yield* installTerminalGridProfile(options.provider === false ? {} : { provider: "controlled" }); const stream = options.stream ?? new InMemoryStream(); - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); const outcome = yield* execution; const output = yield* forEach(function* (_chunk: string) {}, execution.output); return { @@ -327,6 +334,10 @@ function heldDocument(columns: number, panes: string[]): string { ...panes, "", "", + // The sibling after the grid. It runs whether the grid ran or replayed, so + // a harness can wait for the document to have moved past the region. + ``, + "", "", "", ].join("\n"); @@ -348,20 +359,26 @@ function runInterrupted( shell?: ControlledCompositeOptions["shell"]; /** Let the reader leave, so the grid completes rather than staying open. */ close?: boolean; + /** Props this run supplies. Props are not restored across a continuation. */ + props?: Record; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - const opened = withResolvers(); - // Two signals, neither a deadline: the grid opened on a live run, or the - // document reached the sibling after it — which is what a replayed grid - // does. A replay that hangs reaches neither and hangs the row, rather than - // passing on a timer. + // Two signals, kept apart because they mean different things. `attached` + // says a grid opened on this run; `pastGrid` says the document reached the + // sibling after it, which is what a *replayed* grid does and what a + // completed-region journal needs to be waited for. Neither is a deadline: a + // replay that hangs reaches neither and hangs the row rather than passing + // on a timer. + const attached = withResolvers(); + const pastGrid = withResolvers(); + const destroyed = withResolvers(); yield* useGridComponents(ran, [], (mark) => { if (mark === PAST_THE_GRID) { - opened.resolve(); + pastGrid.resolve(); } }); yield* installControlledLauncher(); @@ -374,11 +391,15 @@ function runInterrupted( requests.push(asked); yield* sleep(0); }, - // Attach is the signal, not `running`: a pane that settles before the - // barrier keeps its own status and never becomes runnable. + // Attach, not `running`: a pane that settles before the barrier keeps + // its own status and never becomes runnable. // deno-lint-ignore require-yield *onAttach() { - opened.resolve(); + attached.resolve(); + }, + // deno-lint-ignore require-yield + *onDestroy() { + destroyed.resolve(); }, }); } @@ -387,13 +408,36 @@ function runInterrupted( const path = join(dir, "doc.md"); yield* writeTextFile(path, source); const task: Task = yield* spawn(function* () { - const execution = yield* execute({ path, stream, includes: [dir] }); + const execution = yield* execute({ + path, + stream, + includes: [dir], + ...(options.props === undefined ? {} : { props: options.props }), + }); yield* execution; }); // The grid is open and its panes have settled, so the journal now holds the // pane children's own entries. A resumed run never attaches at all — the // region short-circuits — so this is bounded rather than waited on. - yield* opened.operation; + // `close: true` means the grid is expected to complete, so the run is + // halted only once the document has moved past it — that is what leaves a + // completed grid child under an incomplete root. Otherwise the grid is + // expected to stay open, and attaching is as far as it gets. + yield* race([ + options.close === true ? pastGrid.operation : attached.operation, + (function* (): Operation { + yield* sleep(1500); + // deno-lint-ignore no-console + const evts = yield* stream.readAll(); + // deno-lint-ignore no-console + console.log( + "PROBE3 closes", + JSON.stringify( + evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), + ), + ); + })(), + ]); yield* sleep(5); yield* task.halt(); return { @@ -1109,6 +1153,81 @@ describe("Tier TG — startup, settlement and teardown", () => { describe("Tier TG — durability and replay", () => { const GRID = heldDocument(2, PANES); + /** + * A grid whose only pane never starts, with its failure contained. + * + * `` keeps the document going, so the root reaches no outcome of + * its own and a resumed run reaches the region rather than replaying the root + * wholesale. + */ + const CONTAINED_FAILURE = [ + "", + "", + 'nothing interactive here', + "", + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + /** + * Whether the grid child reached a terminal record of its own. + * + * `ok` or `err`: both are outcomes the region settled on. Only a cancelled + * close, or no close at all, means it was interrupted — and that is the + * difference this row exists to depend on. + */ + function completedGrid(run: DocumentRun): boolean { + return run.journal.some( + (event) => + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + (event.result.status === "ok" || event.result.status === "err"), + ); + } + + it("TG15: a completed successful grid replays its exact result, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, GRID, stream, { close: true }); + expect(first.requests).toHaveLength(1); + // The region genuinely completed: without that this row would be about an + // interrupted grid resuming, which is TG16's claim rather than this one. + expect(completedGrid(first)).toBe(true); + + const second = yield* runInterrupted(dir, GRID, stream, { close: true }); + + // No provider was asked for a grid, nothing was prepared or attached, no + // pane content expanded, no shell or launcher ran, and nothing displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + + it("TG15: a contained failed grid replays the same failure, with no work", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + expect(first.requests).toHaveLength(1); + expect(completedGrid(first)).toBe(true); + + // No provider at all on the resumed run: a replay that contacted one would + // refuse, and the retained result does not need one. + const second = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + close: true, + provider: false, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); @@ -1171,21 +1290,137 @@ describe("Tier TG — durability and replay", () => { expect(second.events.some((event) => event.startsWith("shell:"))).toBe(true); }); - it("TG17: the layout is recorded before any provider is contacted", function* () { + /** + * A grid whose `columns` and first `title` come from props. + * + * A continuation executes the retained root, so the document itself cannot + * change between runs — but props are not restored, so these two values are + * exactly what a fixed retained source can still resolve differently. + */ + const PROP_BORNE = [ + "---", + "props:", + " columns:", + " type: number", + " label:", + " type: string", + "---", + "", + "left", + '', + "", + "", + ``, + "", + "", + "", + ].join("\n"); + + it("TG17: a changed prop-borne column count refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 3, label: "Left" }, + }); + + // Refused before the foreground lease and before the provider: nothing was + // prepared, attached or displayed. + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + // A replay refusal, not a run that opened something and then failed. The + // sentence is the divergence report's: a refusal raised while retained + // children are still being replayed loses to it, which is established + // behaviour rather than something this row can change. + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: a changed prop-borne title refuses with zero provider observation", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + + const first = yield* runInterrupted(dir, PROP_BORNE, stream, { + props: { columns: 2, label: "Left" }, + }); + expect(first.requests).toHaveLength(1); + + const second = yield* runDocument(dir, PROP_BORNE, { + stream, + props: { columns: 2, label: "Elsewhere" }, + }); + + expect(second.requests).toEqual([]); + expect(failureOf(second)).toContain("Divergence"); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + }); + + it("TG17: an unchanged prop-borne layout is admitted", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const props = { columns: 2, label: "Left" }; + + yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + const second = yield* runInterrupted(dir, PROP_BORNE, stream, { props }); + + // The discriminator for the two rows above: the same resolved layout + // resumes and opens a grid, so a refusal there is about the change. + expect(second.requests).toHaveLength(1); + }); + + it("TG17: a continuation opens the retained structure, not the file's", function* () { + const structural: [string, string[]][] = [ + ["pane count", [...PANES, '']], + ["pane order", ['', ...PANES.slice(0, 1)]], + ["pane form", ['', '']], + ]; + + for (const [what, panes] of structural) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const first = yield* runInterrupted(dir, GRID, stream); + const retained = first.requests[0]!; + + // The file now says something else. A continuation executes the root the + // journal retained, so the grid it opens is the one that was recorded. + const second = yield* runInterrupted(dir, heldDocument(2, panes), stream); + + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 1`); + expect(`${what}: ${JSON.stringify(second.requests[0])}`).toBe( + `${what}: ${JSON.stringify(retained)}`, + ); + } + }); + + it("TG17: the retained record holds the complete authored pane structure", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); const run = yield* runInterrupted(dir, GRID, stream); - const layoutIndex = run.journal.findIndex( + const layout = run.journal.find( (event) => event.type === "yield" && String(event.description.name).endsWith(":layout"), ); - const firstChildClose = run.journal.findIndex( - (event) => event.type === "close" && String(event.coroutineId).includes("."), - ); - expect(layoutIndex).toBeGreaterThan(-1); - if (firstChildClose > -1) { - expect(layoutIndex).toBeLessThan(firstChildClose); - } + expect(layout).toBeDefined(); + const value = + layout?.type === "yield" && layout.result.status === "ok" ? layout.result.value : undefined; + // Every authored pane, with its ordinal, title, form and derived position. + expect(value).toEqual({ + columns: 2, + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }); }); it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index dcd203592..11d410fa5 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -18,14 +18,7 @@ * See protocol spec §7 (structured concurrency), §10 (race semantics). */ -import { - all as effectionAll, - ensure, - race as effectionRace, - spawn, - suspend, - useScope, -} from "effection"; +import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; import type { Operation, Task } from "effection"; import { DurableContext } from "./context.ts"; import { @@ -36,7 +29,7 @@ import { import { ephemeral } from "./ephemeral.ts"; import { EarlyReturnDivergenceError, TerminalDivergenceError } from "./errors.ts"; import { deserializeError, serializeError } from "./serialize.ts"; -import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; +import type { Cancellation, Close, DurableEffect, Json, Workflow, WorkflowValue } from "./types.ts"; /** * Run a child workflow within a spawned scope, setting up its own @@ -75,11 +68,42 @@ import type { Close, DurableEffect, Json, Workflow, WorkflowValue } from "./type */ type CancelledChildPolicy = "combinator-cancels" | "resume"; +/** + * Whether the caller deliberately stopped the child this run (DEC-040). + * + * Written by the task `durableSpawn` hands out — the only place a deliberate + * halt can be observed — and read once, when the cancelled Close is built. A + * combinator supplies none: a child it cancels stopped because a scope came + * down, which is what `"unwound"` means. + */ +interface CancellationEvidence { + deliberate: boolean; +} + +/** How a cancelled child's stop is recorded. */ +function cancellationOf(evidence: CancellationEvidence | undefined): Cancellation { + return evidence?.deliberate === true ? "caller" : "unwound"; +} + +/** + * Why a retained cancelled child stopped. + * + * Absent is `"caller"`: a record written before this evidence existed says + * nothing, and reviving work nobody asked to be redone is the worse mistake. + */ +function retainedCancellation(close: Close): Cancellation { + if (close.result.status !== "cancelled") { + return "caller"; + } + return close.result.cancellation === "unwound" ? "unwound" : "caller"; +} + function* runDurableChild( childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", + evidence?: CancellationEvidence, ): Operation { const { replayIndex, stream } = parentCtx; replayIndex.claim(childId); @@ -99,18 +123,25 @@ function* runDurableChild( return closeEvent.result.value as T; } else if (closeEvent.result.status === "err") { throw deserializeError(closeEvent.result.error); - } else if (cancelledPolicy === "combinator-cancels") { - // A race loser, or a sibling `all` cancelled when another failed. The - // same combinator cancels it again on this run, so reproducing the - // original execution means blocking until it does — in the live run this - // child never threw, it simply stopped. The Close(cancelled) event - // already exists, so the teardown below skips re-emitting it. + } else if ( + cancelledPolicy === "combinator-cancels" || + retainedCancellation(closeEvent) === "caller" + ) { + // Either a combinator's child — a race loser, or a sibling `all` + // cancelled when another failed — or a spawned child its own caller + // deliberately halted. Both are reproduced the same way: block until the + // thing that stopped it last time stops it again. A combinator cancels it + // as it did before; a caller reaches the same `halt()` its deterministic + // control flow reached before. In the live run neither child threw, it + // simply stopped. The Close(cancelled) event already exists, so the + // teardown below skips re-emitting it. yield* suspend(); // unreachable — suspend blocks until cancelled return undefined as T; } else { - // A spawned region whose run was interrupted. Nobody is going to cancel - // this child a second time, so suspending would hang the resumed run. + // A spawned region whose run was interrupted — involuntarily, which is + // what `"unwound"` records. Nobody is going to cancel this child a second + // time, so suspending would hang the resumed run. // Forget the retained close — its yields stay replayable, so the child // continues its own history — and fall through to run the rest. resumedFromCancelled = true; @@ -158,7 +189,7 @@ function* runDurableChild( closeEvent = { type: "close", coroutineId: childId, - result: { status: "cancelled" }, + result: { status: "cancelled", cancellation: cancellationOf(evidence) }, }; } @@ -263,8 +294,10 @@ export function durableSpawn( const ctx = yield* ephemeral(readDurableContext()); const childIndex = ctx.childCounter++; const childId = `${ctx.coroutineId}.${childIndex}`; - return (yield createSpawnEffect(() => - runDurableChild(childWorkflow, childId, ctx, "resume"), + const evidence: CancellationEvidence = { deliberate: false }; + return (yield createSpawnEffect( + () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), + evidence, )) as Task; })(); } @@ -285,17 +318,52 @@ function* readDurableContext(): Operation { * Effection `spawn` does. What replay must not do is reach the child's body * again to discover that. */ -function createSpawnEffect(child: () => Operation): DurableEffect> { +function createSpawnEffect( + child: () => Operation, + evidence: CancellationEvidence, +): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: routine.scope.run(child) }); + resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } +/** + * The same task, with a deliberate `halt()` recorded as it happens. + * + * The caller receives every member the task defines — `then`, `catch`, + * `finally`, the async dispose, the iterator — copied from the task itself + * along with its prototype, so the public surface is the one `Task` has always + * had. Only `halt` is replaced, and only to note that someone stopped the child + * on purpose before stopping it. + * + * Copied rather than proxied: a task's members are read-only and + * non-configurable, and a proxy is required to hand back exactly what the + * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * member is the task's own closure and keeps working on the copy. + */ +function observingHalt(task: Task, evidence: CancellationEvidence): Task { + const members = Object.getOwnPropertyDescriptors(task); + // Replaced in the descriptor map rather than on the finished object: the + // task's own members are non-configurable, so redefining one afterwards + // throws. + members.halt = { + value: () => { + evidence.deliberate = true; + return task.halt(); + }, + enumerable: true, + configurable: false, + writable: false, + }; + const observed: Task = Object.create(Object.getPrototypeOf(task), members); + return observed; +} + /** * Run multiple durable workflows concurrently and wait for all to complete. * diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 03c313c0f..581dabd57 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -8,6 +8,7 @@ // Protocol types export type { + Cancellation, Close, CoroutineId, CoroutineView, diff --git a/packages/durable-streams/parse.ts b/packages/durable-streams/parse.ts index 0747bfdcb..aa74b2b6c 100644 --- a/packages/durable-streams/parse.ts +++ b/packages/durable-streams/parse.ts @@ -125,8 +125,20 @@ function parseResult(value: unknown, path: string): Result { return { status: "err", error: parseSerializedError(members.get("error"), `${path}.error`) }; } case "cancelled": { - requireMemberNames(members, ["status"], path); - return { status: "cancelled" }; + requireMemberNames(members, ["status", "cancellation"], path); + const cancellation = members.get("cancellation"); + if (cancellation === undefined) { + // A record written before this evidence existed. DEC-040 reads the + // absence as a deliberate stop, so nothing it left behind is revived. + return { status: "cancelled" }; + } + if (cancellation !== "caller" && cancellation !== "unwound") { + throw new MalformedDurableEventError( + 'expected "caller" or "unwound"', + `${path}.cancellation`, + ); + } + return { status: "cancelled", cancellation }; } default: throw new MalformedDurableEventError('expected "ok", "err" or "cancelled"', `${path}.status`); diff --git a/packages/durable-streams/retained.ts b/packages/durable-streams/retained.ts index 380224464..ab872178b 100644 --- a/packages/durable-streams/retained.ts +++ b/packages/durable-streams/retained.ts @@ -170,7 +170,17 @@ function detachResult(result: Result): Result { } return Object.freeze({ status, error: detachError(result.error) }); } - return Object.freeze({ status }); + // The reason a cancellation carries is retained evidence, not decoration: a + // resumed spawned region reads it to tell a deliberate stop from an + // interrupted run (DEC-040). Dropping it here would make every retained + // cancellation read as deliberate, which is the safe default but the wrong + // answer for a run that was interrupted. A value that is not one of the two + // it may be is not retained at all, so a malformed record reads as the safe + // default rather than as something it never said. + const cancellation = result.cancellation; + return Object.freeze( + cancellation === "caller" || cancellation === "unwound" ? { status, cancellation } : { status }, + ); } /** @@ -448,5 +458,10 @@ export function consumable(result: Result): Result { if (result.status === "err") { return { status: "err", error: { ...result.error } }; } - return { status: "cancelled" }; + // The reason travels with the copy: a resumed spawned region reads it to tell + // a deliberate stop from an interrupted run (DEC-040), and dropping it here + // would make every retained cancellation look deliberate. + return result.cancellation === undefined + ? { status: "cancelled" } + : { status: "cancelled", cancellation: result.cancellation }; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4dcd3a919..07285ce1c 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -364,3 +364,205 @@ describe("durableSpawn — the combinators keep their own policy", () => { expect(marks).toEqual([]); }); }); + +describe("durableSpawn — why a child was cancelled (DEC-040)", () => { + /** Every cancelled close in a journal, with the reason it recorded. */ + function* cancellations(stream: InMemoryStream): Operation { + const events = yield* stream.readAll(); + return events + .filter((event) => event.type === "close" && event.result.status === "cancelled") + .map((event) => + event.result.status === "cancelled" ? String(event.result.cancellation) : "", + ); + } + + it("records a deliberate halt as caller", function* () { + const stream = new InMemoryStream(); + + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + })(), + ); + return "done"; + }, + { stream }, + ); + + expect(yield* cancellations(stream)).toEqual(["caller"]); + }); + + it("records a scope unwinding as unwound", function* () { + const stream = new InMemoryStream(); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(3); + yield* run.halt(); + + expect(yield* cancellations(stream)).toEqual(["unwound"]); + }); + + it("does not revive a child the caller deliberately halted", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // The caller halts the child, then the run is interrupted before it + // completes. Both facts are in the journal; only the first decides. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("first life"); + yield* suspend(); + return "never"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(5); + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // The resumed run reaches the same deliberate halt, so the child suspends + // until it does rather than performing work nobody asked to redo. + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* sleep(1); + yield* task.halt(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* sleep(10); + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + + it("reads a record with no reason as caller", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + // A journal written before this evidence existed. + yield* stream.append({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled" }, + }); + + const run = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("would revive"); + return "revived"; + })(), + ); + }); + return yield* ephemeral(task); + }, + { stream }, + ); + }); + yield* sleep(10); + yield* run.halt(); + + // Absent evidence is the safe direction: nothing is revived. + expect(marks).toEqual([]); + }); + + it("keeps combinator children on DEC-024 whatever the reason says", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const race = () => + durableRace([ + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("winner"); + return "winner"; + })(), + ); + }, + function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("loser"); + yield* suspend(); + return "never"; + })(), + ); + }, + ]); + + expect(yield* durableRun(race, { stream })).toBe("winner"); + // The loser's cancellation is involuntary, so it records `unwound` — and a + // combinator child suspends regardless of what the reason says. + expect(yield* cancellations(stream)).toEqual(["unwound"]); + + marks.length = 0; + expect(yield* durableRun(race, { stream })).toBe("winner"); + expect(marks).toEqual([]); + }); +}); diff --git a/packages/durable-streams/types.ts b/packages/durable-streams/types.ts index 2e79846b2..3523ab53d 100644 --- a/packages/durable-streams/types.ts +++ b/packages/durable-streams/types.ts @@ -23,11 +23,23 @@ export interface SerializedError { stack?: string; } +/** + * Why a cancelled coroutine stopped (DEC-040). + * + * Two very different things produce a cancelled Close, and a resumed run has to + * tell them apart: `"caller"` is an owner deliberately halting the task + * `durableSpawn` handed it, and `"unwound"` is anything involuntary — a scope + * coming down, a run interrupted, a host going away. A record written before + * this evidence existed carries neither, and reads as `"caller"`, because + * refusing to revive is the safe direction. + */ +export type Cancellation = "caller" | "unwound"; + /** Result of an effect or coroutine. */ export type Result = | { status: "ok"; value?: Json } | { status: "err"; error: SerializedError } - | { status: "cancelled" }; + | { status: "cancelled"; cancellation?: Cancellation }; /** Dot-delimited hierarchical coroutine path. See spec §3. */ export type CoroutineId = string; From 6dde63931d8c57c25baf9c92a3651a1d85da986f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:04:51 -0400 Subject: [PATCH 12/62] =?UTF-8?q?=F0=9F=90=9B=20Make=20the=20replay=20evid?= =?UTF-8?q?ence=20deterministic,=20and=20pin=20DEC-040's=20boundaries=20(#?= =?UTF-8?q?730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The harness cannot pass a hung replay any more.** `runInterrupted()` had a 1500ms timer racing its signals, so a replay that hung returned a DocumentRun that looked finished; it also slept a fixed 5ms to let records land. Both are gone. It now waits only on events the run produced: `attached`, `pastGrid`, and a new `panesSettled` for the rows that read pane records — a pane's status is published only after its durable child returned, so counting settled panes is also counting durable pane closes. A replay that hangs now reaches none of them and hangs the row. **TG15's failed case is a real contained failure.** A pane that fails before attachment fails the whole region, so the old document could not both fail and continue. The failing pane is now a shell that starts, waits for attachment, and only then exits badly — contained as that pane's status, with the grid settling as failed and the document carrying on. Both runs capture the printed errors, and the row asserts the replayed run produced the same ones, reached `PAST_THE_GRID`, and did no provider, pane, shell or launcher work. **DEC-040 gets boundary tests where the evidence actually travels.** `parse.test.ts` round-trips both reasons to the same bytes, keeps a legacy absence absent, and refuses an unrecognised reason at `$.result.cancellation`. `retained.test.ts` proves retention and `consumable()` carry both reasons, leave a legacy absence absent, drop an unrecognised one to the safe default, and that the reason reaches the replay index. The DEC-040 rows in `durable-spawn.test.ts` no longer coordinate by delay: a child says when it is running, and the caller says when it has halted. **Malformed retained layouts** are covered by replaying a real journal with only its layout entry replaced — a missing member, an extra one, a mistyped one, a pane out of position, and a record that disagrees with itself. Each refuses with zero provider observation. The `durableSpawn` doc comment no longer says every retained cancellation is an interrupted run. --- packages/core/tests/terminal-grid.test.ts | 271 +++++++++++++++--- packages/durable-streams/combinators.ts | 9 +- .../tests/durable-spawn.test.ts | 2 +- packages/durable-streams/tests/parse.test.ts | 38 +++ .../durable-streams/tests/retained.test.ts | 60 +++- 5 files changed, 329 insertions(+), 51 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 9da63e051..20d004322 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -54,6 +54,7 @@ import type { TerminalProviderLog, } from "@executablemd/runtime"; +import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; import { registerComponents } from "../src/components/registration.ts"; import { @@ -85,6 +86,8 @@ interface DocumentRun { events: string[]; /** Every mark a tripwire component recorded, in order. */ ran: string[]; + /** Every printed error the run produced, in order. */ + errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; } @@ -113,6 +116,7 @@ function useGridComponents( ran: string[], slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, + afterAttach: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -164,6 +168,18 @@ function useGridComponents( return ""; }, }, + { + // Waits until the grid has attached, so a pane can fail *after* the + // barrier — which is the failure the grid contains as a status rather + // than the startup failure that fails the whole region. + name: "AfterAttach", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* afterAttach(); + return ""; + }, + }, { name: "Hold", origin: "tier-tg", @@ -258,6 +274,13 @@ function runDocument( const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; + const errors: string[] = []; + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, + }); yield* useGridComponents(ran, options.slowMarks ?? []); yield* installControlledLauncher(); @@ -309,6 +332,7 @@ function runDocument( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -361,35 +385,97 @@ function runInterrupted( close?: boolean; /** Props this run supplies. Props are not restored across a continuation. */ props?: Record; + /** + * Keep the grid open until a pane reports a failure. + * + * A pane that fails *after* attachment is contained as that pane's status, + * and the grid settles as failed rather than throwing. Closing before that + * would record the pane as cancelled by the close instead. + */ + closeAfterFailure?: boolean; + /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ + shellFailsAfterAttach?: number; + /** + * How many panes must have settled before the run is interrupted. + * + * A pane's status is published only after its durable child has returned, + * so this is also how many pane Closes the journal is known to hold. Rows + * that read those records name the number they need; rows that only need an + * open grid name none. + */ + settled?: number; } = {}, ): Operation { return scoped(function* () { const requests: TerminalGridRequest[] = []; const log = terminalProviderLog(); const ran: string[] = []; - // Two signals, kept apart because they mean different things. `attached` - // says a grid opened on this run; `pastGrid` says the document reached the + const errors: string[] = []; + // Three signals, kept apart because they mean different things. `attached` + // says a grid opened on this run. `pastGrid` says the document reached the // sibling after it, which is what a *replayed* grid does and what a - // completed-region journal needs to be waited for. Neither is a deadline: a - // replay that hangs reaches neither and hangs the row rather than passing - // on a timer. + // completed-region journal has to be waited for. `panesSettled` says the + // pane children the row cares about have written their own records. + // + // Every one of them is an event this run produced. Nothing here waits for a + // duration, so a replay that hangs reaches none of them and hangs the row — + // it can never hand back a run that looks finished but is not. const attached = withResolvers(); const pastGrid = withResolvers(); - const destroyed = withResolvers(); - yield* useGridComponents(ran, [], (mark) => { - if (mark === PAST_THE_GRID) { - pastGrid.resolve(); - } + const panesSettled = withResolvers(); + let settledPanes = 0; + if ((options.settled ?? 0) === 0) { + panesSettled.resolve(); + } + // The printed errors this run produced, which is how a contained failure is + // observable at all — and the same list on a replayed run is how "the same + // result came back" is read rather than assumed. + yield* Component.around({ + *raise([segment], next) { + errors.push(segment.message); + return yield* next(segment); + }, }); + const paneFailed = withResolvers(); + yield* useGridComponents( + ran, + [], + (mark) => { + if (mark === PAST_THE_GRID) { + pastGrid.resolve(); + } + }, + () => attached.operation, + ); yield* installControlledLauncher(); if (options.provider !== false) { yield* useControlledProvider({ log, - close: options.close === true ? immediateClose() : () => suspend(), - ...(options.shell === undefined ? {} : { shell: options.shell }), + close: + options.closeAfterFailure === true + ? () => paneFailed.operation + : options.close === true + ? immediateClose() + : () => suspend(), + ...(options.shellFailsAfterAttach !== undefined + ? { + shell: function* (ordinal: number, spawned: () => void) { + spawned(); + if (ordinal !== options.shellFailsAfterAttach) { + return { exitCode: 0 }; + } + // Started, so the grid attaches; it fails only afterwards, which + // is the failure a grid contains as a pane status. + yield* attached.operation; + return { exitCode: 1 }; + }, + } + : options.shell === undefined + ? {} + : { shell: options.shell }), + // deno-lint-ignore require-yield *onPrepare(asked) { requests.push(asked); - yield* sleep(0); }, // Attach, not `running`: a pane that settles before the barrier keeps // its own status and never becomes runnable. @@ -397,9 +483,16 @@ function runInterrupted( *onAttach() { attached.resolve(); }, - // deno-lint-ignore require-yield - *onDestroy() { - destroyed.resolve(); + onUpdate(_ordinal, state) { + if (state === "failed") { + paneFailed.resolve(); + } + if (state === "succeeded" || state === "failed" || state === "closed") { + settledPanes++; + if (settledPanes >= (options.settled ?? 0)) { + panesSettled.resolve(); + } + } }, }); } @@ -416,29 +509,17 @@ function runInterrupted( }); yield* execution; }); - // The grid is open and its panes have settled, so the journal now holds the - // pane children's own entries. A resumed run never attaches at all — the - // region short-circuits — so this is bounded rather than waited on. - // `close: true` means the grid is expected to complete, so the run is - // halted only once the document has moved past it — that is what leaves a - // completed grid child under an incomplete root. Otherwise the grid is - // expected to stay open, and attaching is as far as it gets. - yield* race([ - options.close === true ? pastGrid.operation : attached.operation, - (function* (): Operation { - yield* sleep(1500); - // deno-lint-ignore no-console - const evts = yield* stream.readAll(); - // deno-lint-ignore no-console - console.log( - "PROBE3 closes", - JSON.stringify( - evts.filter((e) => e.type === "close").map((e) => [e.coroutineId, e.result.status]), - ), - ); - })(), - ]); - yield* sleep(5); + // `close: true` expects the grid to complete, so the run is interrupted only + // once the document has moved past it — which is what leaves a completed + // grid child under an incomplete root. Otherwise the grid is expected to + // stay open, and the run is interrupted once it has opened and the pane + // records the row reads are durable. + if (options.close === true || options.closeAfterFailure === true) { + yield* pastGrid.operation; + } else { + yield* attached.operation; + yield* panesSettled.operation; + } yield* task.halt(); return { outcome: { ok: false, error: new Error("interrupted") } as Result, @@ -447,6 +528,7 @@ function runInterrupted( shown: log.shown, events: log.events, ran, + errors, journal: yield* stream.readAll(), }; }); @@ -1162,8 +1244,9 @@ describe("Tier TG — durability and replay", () => { */ const CONTAINED_FAILURE = [ "", - "", - 'nothing interactive here', + "", + '', + '', "", "", "", @@ -1213,9 +1296,16 @@ describe("Tier TG — durability and replay", () => { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { close: true }); + const first = yield* runInterrupted(dir, CONTAINED_FAILURE, stream, { + closeAfterFailure: true, + shellFailsAfterAttach: 0, + }); expect(first.requests).toHaveLength(1); expect(completedGrid(first)).toBe(true); + // What the failure looked like, as the document reported it. + expect(first.errors.some((message) => message.includes("shell exited with status 1"))).toBe( + true, + ); // No provider at all on the resumed run: a replay that contacted one would // refuse, and the retained result does not need one. @@ -1224,6 +1314,10 @@ describe("Tier TG — durability and replay", () => { provider: false, }); + // The same result came back, rather than being derived again. + expect(second.errors).toEqual(first.errors); + // And the document carried on from it, exactly as it did the first time. + expect(second.ran).toContain(PAST_THE_GRID); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); @@ -1232,7 +1326,8 @@ describe("Tier TG — durability and replay", () => { it("TG16: each pane is a durable child of the grid, in authored order", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); - const first = yield* runInterrupted(dir, GRID, stream); + // Both panes settle, so both pane children have written their records. + const first = yield* runInterrupted(dir, GRID, stream, { settled: 2 }); const closes = first.journal.filter((event) => event.type === "close"); const paneIds = closes @@ -1277,10 +1372,17 @@ describe("Tier TG — durability and replay", () => { return { exitCode: 0 }; }; - const first = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + // The left pane settles; the shell holds, so only one pane record exists. + const first = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); expect(first.ran).toContain("left ran"); - const second = yield* runInterrupted(dir, source, stream, { shell: holdingShell }); + const second = yield* runInterrupted(dir, source, stream, { + shell: holdingShell, + settled: 1, + }); // The completed pane came back from its retained outcome: its body did not // run again. @@ -1423,6 +1525,85 @@ describe("Tier TG — durability and replay", () => { }); }); + it("TG17: a malformed retained layout refuses before provider observation", function* () { + /** The retained layout, replaced by something the record cannot mean. */ + const damaged: [string, Json][] = [ + ["a missing member", { columns: 2, panes: [] }], + [ + "an extra member", + { + columns: 2, + rows: 1, + extra: true, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a mistyped member", + { + columns: "two", + rows: 1, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a pane out of position", + { + columns: 2, + rows: 1, + panes: [ + { ordinal: 1, title: "Left", form: "paired", row: 0, column: 0 }, + { ordinal: 0, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + [ + "a record that disagrees with itself", + { + columns: 2, + rows: 5, + panes: [ + { ordinal: 0, title: "Left", form: "paired", row: 3, column: 1 }, + { ordinal: 1, title: "Right", form: "self-closing", row: 0, column: 1 }, + ], + }, + ], + ]; + + for (const [what, layout] of damaged) { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + yield* runInterrupted(dir, GRID, stream); + + // The same journal with only its layout entry replaced, so nothing else + // about the continuation changes. + const damagedStream = new InMemoryStream(); + for (const event of yield* stream.readAll()) { + const isLayout = + event.type === "yield" && String(event.description.name).endsWith(":layout"); + yield* damagedStream.append( + isLayout && event.result.status === "ok" + ? { ...event, result: { status: "ok", value: layout } } + : event, + ); + } + + const second = yield* runDocument(dir, GRID, { stream: damagedStream }); + + expect(`${what}: ${second.outcome.ok}`).toBe(`${what}: false`); + // Refused while reading the record, before anything was asked for. + expect(`${what}: ${second.requests.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.events.length}`).toBe(`${what}: 0`); + expect(`${what}: ${second.shown.size}`).toBe(`${what}: 0`); + } + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 11d410fa5..2a4027020 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -279,9 +279,12 @@ function* runDurableChild( * `ephemeral()` instead — as this once did — put it in a scope that closed as * soon as the effect resolved, so every `yield* task` threw `halted`. * - * A retained `Close(cancelled)` here means the run was interrupted, not that a - * combinator chose against this child, so the child resumes its remaining work. - * See `CancelledChildPolicy`. + * A retained `Close(cancelled)` here is read for *why* it was cancelled, not + * treated as one thing. `"unwound"` — the run was interrupted, and nothing will + * cancel this child again — resumes the work it had left. `"caller"`, and a + * legacy record that says nothing, is a stop this caller chose, and is + * reproduced by suspending until its deterministic control flow chooses it + * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( childWorkflow: () => Workflow, diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 07285ce1c..179ded8ab 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend } from "effection"; +import { sleep, spawn, suspend, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; diff --git a/packages/durable-streams/tests/parse.test.ts b/packages/durable-streams/tests/parse.test.ts index a30efa8a4..3dbed1007 100644 --- a/packages/durable-streams/tests/parse.test.ts +++ b/packages/durable-streams/tests/parse.test.ts @@ -270,3 +270,41 @@ describe("parseDurableEvent", () => { expect("polluted" in {}).toBe(false); }); }); + +describe("a cancelled close carries why it was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("round-trips both reasons", function* () { + for (const reason of ["caller", "unwound"] as const) { + const event = cancelled(reason); + const record = serializeDurableEvent(event); + expect(accepted(record)).toEqual(event); + // And back to the same bytes, so a backend retains the event rather than + // an approximation of it. + expect(serializeDurableEvent(accepted(record))).toBe(record); + } + }); + + it("keeps a legacy record's absence an absence", function* () { + const parsed = accepted(serializeDurableEvent(cancelled())); + expect(parsed).toEqual(cancelled()); + expect(parsed.result.status === "cancelled" && "cancellation" in parsed.result).toBe(false); + }); + + it("refuses a reason it does not recognise", function* () { + const refused = refusal( + JSON.stringify({ + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" }, + }), + ); + expect(refused).toBeInstanceOf(MalformedDurableEventError); + expect(refused.message).toContain("$.result.cancellation"); + }); +}); diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index 72f6251c6..fc52ab191 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -15,9 +15,9 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { detachJson, retainEvents } from "../retained.ts"; +import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { DurableEvent, Json } from "../types.ts"; +import type { Close, DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -443,3 +443,59 @@ describe("retained history — detached values stay ordinary JSON", () => { expect(caught).toBeInstanceOf(TypeError); }); }); + +describe("retention keeps why a child was cancelled (DEC-040)", () => { + const cancelled = (cancellation?: "caller" | "unwound"): DurableEvent => ({ + type: "close", + coroutineId: "root.0", + result: + cancellation === undefined ? { status: "cancelled" } : { status: "cancelled", cancellation }, + }); + + it("retains both reasons through the settled copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + const [retained] = retainEvents([cancelled(reason)]); + expect(retained?.type).toBe("close"); + expect(retained?.result).toEqual({ status: "cancelled", cancellation: reason }); + } + }); + + it("leaves a legacy absence absent", function* () { + const [retained] = retainEvents([cancelled()]); + expect(retained?.result).toEqual({ status: "cancelled" }); + expect( + retained !== undefined && + retained.result.status === "cancelled" && + "cancellation" in retained.result, + ).toBe(false); + }); + + it("carries both reasons through an observable copy", function* () { + for (const reason of ["caller", "unwound"] as const) { + expect(consumable(cancelled(reason).result)).toEqual({ + status: "cancelled", + cancellation: reason, + }); + } + expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); + }); + + it("does not retain a reason it does not recognise", function* () { + // A record that says something else says nothing this reads, and the safe + // default — a deliberate stop — is what an absent reason already means. + const [retained] = retainEvents([ + { + type: "close", + coroutineId: "root.0", + result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], + }, + ]); + expect(retained?.result).toEqual({ status: "cancelled" }); + }); + + it("reaches the replay index with its reason intact", function* () { + const index = new ReplayIndex([cancelled("unwound")]); + const close = index.getClose("root.0"); + expect(close?.result).toEqual({ status: "cancelled", cancellation: "unwound" }); + }); +}); From 0418d9c1ba93cdbfb7a2adfe5c3919e1ed352362 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 17:21:41 -0400 Subject: [PATCH 13/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Coordinate=20the=20D?= =?UTF-8?q?EC-040=20rows=20by=20signal,=20not=20by=20duration=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEC-040 block still slept where it meant to synchronise — my previous replacements silently failed to match after the file was reformatted, so none of them landed. The block is rewritten rather than patched. Every row now waits on something the run reported. A shared `living()` child resolves a `started` signal and then suspends, so each row halts or unwinds a child that is provably live rather than one a delay happened to reach. The caller resolves `halted` after performing its deliberate halt, so a run is interrupted only once both facts — the deliberate stop and the interruption — are in the journal. Non-revival is established by control flow rather than by waiting: the resumed run reaches its own `task.halt()` and says so, and a revived child would have recorded its mark before the caller could get there. The legacy-absence row signals once the child has been asked for and the request returned. No new timeout, and `sleep` stays imported because the lifetime rows above still use it deliberately. `retained.test.ts` drops the cast and the row it supported: rejecting an unrecognised reason is the parser's, proved there, and retention proves only that `"caller"`, `"unwound"` and a legacy absence survive. --- .../tests/durable-spawn.test.ts | 89 +++++++++++-------- .../durable-streams/tests/retained.test.ts | 15 +--- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 179ded8ab..4d826b846 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -376,22 +376,42 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { ); } + /** + * A child that says when it is running and then waits to be stopped. + * + * Every row below halts or unwinds a *live* child, and `started` is how each + * one knows the child is live. Nothing waits for a duration: a child that + * never started never resolves it, and the row hangs rather than recording a + * cancellation of something that was not running. + */ + function living( + started: { resolve: () => void }, + mark?: (note: string) => void, + ): () => Workflow { + return function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + mark?.("first life"); + started.resolve(); + yield* suspend(); + return "never"; + })(), + ); + }; + } + it("records a deliberate halt as caller", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + // The child is running; stopping it now is a deliberate stop of + // live work rather than of whatever a delay happened to reach. + yield* started.operation; yield* task.halt(); })(), ); @@ -405,18 +425,12 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("records a scope unwinding as unwound", function* () { const stream = new InMemoryStream(); + const started = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - yield* suspend(); - return "never"; - })(), - ); - }); + yield* durableSpawn(living(started)); yield* ephemeral( (function* (): Operation { yield* suspend(); @@ -427,7 +441,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(3); + // Interrupted while the child is live, said by the child. + yield* started.operation; yield* run.halt(); expect(yield* cancellations(stream)).toEqual(["unwound"]); @@ -436,24 +451,20 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { it("does not revive a child the caller deliberately halted", function* () { const marks: string[] = []; const stream = new InMemoryStream(); - // The caller halts the child, then the run is interrupted before it - // completes. Both facts are in the journal; only the first decides. + const started = withResolvers(); + const halted = withResolvers(); + + // The caller halts the child on purpose, and only then is the run + // interrupted — so the journal holds both facts and only the first decides. const first = yield* spawn(function* () { yield* durableRun( function* (): Workflow { - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral( - (function* (): Operation { - marks.push("first life"); - yield* suspend(); - return "never"; - })(), - ); - }); + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); yield* ephemeral( (function* (): Operation { - yield* sleep(1); + yield* started.operation; yield* task.halt(); + halted.resolve(); yield* suspend(); })(), ); @@ -462,14 +473,16 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(5); + yield* halted.operation; yield* first.halt(); expect(marks).toEqual(["first life"]); expect(yield* cancellations(stream)).toEqual(["caller"]); - // The resumed run reaches the same deliberate halt, so the child suspends - // until it does rather than performing work nobody asked to redo. + // The resumed run reaches the same deliberate halt. Getting there is the + // proof of non-revival: a revived child would have recorded its mark before + // the caller could halt it, and the mark list is checked after. + const reachedTheHalt = withResolvers(); const second = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -483,8 +496,8 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { }); yield* ephemeral( (function* (): Operation { - yield* sleep(1); yield* task.halt(); + reachedTheHalt.resolve(); yield* suspend(); })(), ); @@ -493,7 +506,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { { stream }, ); }); - yield* sleep(10); + yield* reachedTheHalt.operation; yield* second.halt(); expect(marks).toEqual(["first life"]); @@ -509,6 +522,7 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { result: { status: "cancelled" }, }); + const asked = withResolvers(); const run = yield* spawn(function* () { yield* durableRun( function* (): Workflow { @@ -520,12 +534,15 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { })(), ); }); + // The child has been asked for and the request has returned. A + // revived child would have recorded its mark by now. + asked.resolve(); return yield* ephemeral(task); }, { stream }, ); }); - yield* sleep(10); + yield* asked.operation; yield* run.halt(); // Absent evidence is the safe direction: nothing is revived. diff --git a/packages/durable-streams/tests/retained.test.ts b/packages/durable-streams/tests/retained.test.ts index fc52ab191..b4a57970e 100644 --- a/packages/durable-streams/tests/retained.test.ts +++ b/packages/durable-streams/tests/retained.test.ts @@ -17,7 +17,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { consumable, detachJson, retainEvents } from "../retained.ts"; import { ReplayIndex } from "../replay-index.ts"; -import type { Close, DurableEvent, Json } from "../types.ts"; +import type { DurableEvent, Json } from "../types.ts"; /** An event whose members answer from a list, counting reads per member. */ function shifting( @@ -480,19 +480,6 @@ describe("retention keeps why a child was cancelled (DEC-040)", () => { expect(consumable(cancelled().result)).toEqual({ status: "cancelled" }); }); - it("does not retain a reason it does not recognise", function* () { - // A record that says something else says nothing this reads, and the safe - // default — a deliberate stop — is what an absent reason already means. - const [retained] = retainEvents([ - { - type: "close", - coroutineId: "root.0", - result: { status: "cancelled", cancellation: "somebody" } as unknown as Close["result"], - }, - ]); - expect(retained?.result).toEqual({ status: "cancelled" }); - }); - it("reaches the replay index with its reason intact", function* () { const index = new ReplayIndex([cancelled("unwound")]); const close = index.getClose("root.0"); From bb5f14bc203a967fa2406bdab07b5c859639d6b1 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:07:57 -0400 Subject: [PATCH 14/62] =?UTF-8?q?=F0=9F=90=9B=20Observe=20every=20disposal?= =?UTF-8?q?=20surface,=20and=20make=20reader=20close=20cooperative=20(#730?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`Symbol.asyncDispose` bypassed the deliberate-stop evidence.** The task `durableSpawn` returns copied it from the original unchanged, so `await using` — or an explicit `task[Symbol.asyncDispose]()` — recorded `cancellation: "unwound"` and the next run revived the child. `halt()` and the async dispose are the same decision spelled two ways, and both are now observed. Awaiting a task is not a stop and is left exactly as it was. A regression disposes a live task, asserts the retained reason is `"caller"`, resumes the journal, and proves the body is not entered again. **Reader close no longer halts panes.** It asks them to stop: a pane races its work against a close signal, settles as `closed`, and records that outcome as its own. Nothing on the ordinary close path is a caller-cancelled child any more, so a resumed run restores a pane the reader closed rather than finding a cancelled child it must either re-enter or wait on forever. Statuses are published before anything is awaited, so a pane with slow finalizers cannot delay the outcome the grid already knows. **§6.21 now agrees with architecture.md and TG17.** Partial replay compares the resolved layout — columns and titles. Pane count, order and form come from the retained root and cannot diverge within a continuation, so a changed supplied file is ignored in favour of the retained structure; refusing a changed authored structure is a root-definition boundary this specification does not yet define. DEC-040 is unchanged and nothing deliberately stopped is revived. --- packages/core/src/terminal/grid.ts | 48 ++++++++++++-- packages/core/tests/terminal-grid.test.ts | 49 +++++++++++++- packages/durable-streams/combinators.ts | 38 +++++++---- .../tests/durable-spawn.test.ts | 64 ++++++++++++++++++- specs/executable-mdx-spec.md | 24 +++++-- 5 files changed, 199 insertions(+), 24 deletions(-) diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa2a09f0e..fa91a00f3 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -224,6 +224,11 @@ function presentGrid( const outcomes: (RetainedPaneOutcome | undefined)[] = work.map(() => undefined); const startupFailed = withResolvers(); + // Reader close asks the panes to stop; it does not halt them. A pane that + // is asked settles as `closed` and records that outcome as its own, so a + // resumed run restores a pane the reader closed rather than finding a + // cancelled child it must either re-enter or wait on forever. + const closing = withResolvers(); let attached = false; for (const pane of work) { @@ -242,7 +247,15 @@ function presentGrid( const readiness = grid.readiness[index]!; panes.push( yield* paneChild(function* (): Operation { - return yield* runPane(pane, claim, composite, readiness, request, index); + return yield* runPane( + pane, + claim, + composite, + readiness, + request, + index, + closing.operation, + ); }), ); } @@ -298,12 +311,20 @@ function presentGrid( // lease released and the following sibling started only once nothing a pane // acquired can still act. grid.seal(); + closing.resolve(); + // Published before anything is awaited: once the reader has left, a pane + // that had not settled is closed, and that is true whether or not its own + // finalizers are quick about it. for (const [index, pane] of work.entries()) { if (outcomes[index] === undefined) { yield* composite.update(pane.ordinal, "closed"); - outcomes[index] = { status: "closed", reason: "" }; } - yield* panes[index]!.halt(); + } + for (const [index] of work.entries()) { + // Awaited, not halted. Each pane settles on the close signal and records + // the outcome it reached, which is what a resumed run reads. + const outcome = yield* panes[index]!; + outcomes[index] ??= outcome; } const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); @@ -324,10 +345,29 @@ function runPane( readiness: { readonly acknowledged: boolean }, request: TerminalGridRequest, index: number, + closing: Operation, ): Operation { return (function* (): Operation { try { - yield* pane.run(claim, composite); + // The pane's work runs beside the close signal rather than under it. When + // the reader leaves, this settles as `closed` straight away and the work + // comes down in the enclosing scope's own teardown — so a pane whose + // finalizers are slow cannot hold up the outcome the grid already knows, + // and the record a resumed run reads is written either way. + const running = yield* spawn(() => pane.run(claim, composite)); + const closed = yield* race([ + (function* (): Operation { + yield* running; + return false; + })(), + (function* (): Operation { + yield* closing; + return true; + })(), + ]); + if (closed) { + return { status: "closed", reason: "" }; + } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work // itself raised nothing. diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 20d004322..4c6961c25 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -117,6 +117,7 @@ function useGridComponents( slowMarks: string[] = [], onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, + teardownHeld: () => Operation = function* () {}, ): Operation { return registerComponents([ { @@ -168,6 +169,20 @@ function useGridComponents( return ""; }, }, + { + // Holds the pane open, and blocks its own teardown until released — so a + // row can interrupt a run while reader-close teardown is in progress. + name: "SlowTeardown", + origin: "tier-tg", + props: { type: "object", properties: {}, additionalProperties: false }, + *fn() { + yield* ensure(function* () { + yield* teardownHeld(); + }); + yield* suspend(); + return ""; + }, + }, { // Waits until the grid has attached, so a pane can fail *after* the // barrier — which is the failure the grid contains as a status rather @@ -395,6 +410,21 @@ function runInterrupted( closeAfterFailure?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; + /** Holds a `` pane's finalizer until this settles. */ + holdTeardown?: () => Operation; + /** Resolved once a pane's finalizer has been entered and is blocked. */ + onTeardownEntered?: () => void; + /** Interrupt the run when this settles rather than at a lifecycle signal. */ + interruptWhen?: Operation; + /** + * Called once cancellation has begun but before it is awaited. + * + * A row that blocks a finalizer has to release it *after* the parent is + * cancelled, or the cancellation would be waiting on the very thing the row + * is holding. Awaiting the halt afterwards is what proves teardown + * completed rather than merely started. + */ + releaseOnInterrupt?: () => void; /** * How many panes must have settled before the run is interrupted. * @@ -446,6 +476,12 @@ function runInterrupted( } }, () => attached.operation, + function* () { + options.onTeardownEntered?.(); + if (options.holdTeardown) { + yield* options.holdTeardown(); + } + }, ); yield* installControlledLauncher(); if (options.provider !== false) { @@ -514,13 +550,22 @@ function runInterrupted( // grid child under an incomplete root. Otherwise the grid is expected to // stay open, and the run is interrupted once it has opened and the pane // records the row reads are durable. - if (options.close === true || options.closeAfterFailure === true) { + if (options.interruptWhen !== undefined) { + yield* options.interruptWhen; + } else if (options.close === true || options.closeAfterFailure === true) { yield* pastGrid.operation; } else { yield* attached.operation; yield* panesSettled.operation; } - yield* task.halt(); + // Cancellation is begun, then released, then awaited. A row that blocks a + // finalizer has to release it after the parent is cancelled, or the + // cancellation would be waiting on the very thing the row is holding; and + // awaiting the halt afterwards is what proves teardown completed rather + // than merely started. + const halting = yield* spawn(() => task.halt()); + options.releaseOnInterrupt?.(); + yield* halting; return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index 2a4027020..b6b5b9a12 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -329,40 +329,54 @@ function createSpawnEffect( description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingHalt(routine.scope.run(child), evidence) }); + resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; } /** - * The same task, with a deliberate `halt()` recorded as it happens. + * The same task, with a deliberate stop recorded as it happens. * * The caller receives every member the task defines — `then`, `catch`, - * `finally`, the async dispose, the iterator — copied from the task itself - * along with its prototype, so the public surface is the one `Task` has always - * had. Only `halt` is replaced, and only to note that someone stopped the child - * on purpose before stopping it. + * `finally`, the iterator — copied from the task itself along with its + * prototype, so the public surface is the one `Task` has always had. + * + * **Every** way a caller can stop the task is observed, not just the obvious + * one. `halt()` and `await using` — which reaches `Symbol.asyncDispose` and + * never touches `halt` — are the same decision spelled two ways, and a stop + * recorded as involuntary through either of them would be resumed on the next + * run as work nobody asked to redo. Awaiting the task is not a stop and is left + * exactly as it was. * * Copied rather than proxied: a task's members are read-only and * non-configurable, and a proxy is required to hand back exactly what the - * target holds — so a `get` trap cannot substitute `halt` at all. Each copied + * target holds — so a `get` trap cannot substitute either of them. Each copied * member is the task's own closure and keeps working on the copy. */ -function observingHalt(task: Task, evidence: CancellationEvidence): Task { +function observingDisposal(task: Task, evidence: CancellationEvidence): Task { const members = Object.getOwnPropertyDescriptors(task); // Replaced in the descriptor map rather than on the finished object: the // task's own members are non-configurable, so redefining one afterwards // throws. - members.halt = { - value: () => { + const deliberate = (stop: () => R): (() => R) => { + return () => { evidence.deliberate = true; - return task.halt(); - }, + return stop(); + }; + }; + members.halt = { + value: deliberate(() => task.halt()), enumerable: true, configurable: false, writable: false, }; + members[Symbol.asyncDispose] = { + value: deliberate(() => task[Symbol.asyncDispose]()), + enumerable: false, + configurable: false, + writable: false, + }; const observed: Task = Object.create(Object.getPrototypeOf(task), members); return observed; } diff --git a/packages/durable-streams/tests/durable-spawn.test.ts b/packages/durable-streams/tests/durable-spawn.test.ts index 4d826b846..1f35445f7 100644 --- a/packages/durable-streams/tests/durable-spawn.test.ts +++ b/packages/durable-streams/tests/durable-spawn.test.ts @@ -15,7 +15,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { sleep, spawn, suspend, withResolvers } from "effection"; +import { sleep, spawn, suspend, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { durableRun } from "../run.ts"; @@ -512,6 +512,68 @@ describe("durableSpawn — why a child was cancelled (DEC-040)", () => { expect(marks).toEqual(["first life"]); }); + it("records disposal through Symbol.asyncDispose as caller, and does not revive", function* () { + const marks: string[] = []; + const stream = new InMemoryStream(); + const started = withResolvers(); + const disposed = withResolvers(); + + // `await using` stops a task without ever touching `halt()`. It is the same + // decision spelled another way, so it has to leave the same evidence. + const first = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(living(started, (note) => marks.push(note))); + yield* ephemeral( + (function* (): Operation { + yield* started.operation; + yield* until(task[Symbol.asyncDispose]()); + disposed.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* disposed.operation; + yield* first.halt(); + + expect(marks).toEqual(["first life"]); + expect(yield* cancellations(stream)).toEqual(["caller"]); + + // Resuming that journal must not enter the child again. + const reachedTheDisposal = withResolvers(); + const second = yield* spawn(function* () { + yield* durableRun( + function* (): Workflow { + const task = yield* durableSpawn(function* (): Workflow { + return yield* ephemeral( + (function* (): Operation { + marks.push("revived"); + return "revived"; + })(), + ); + }); + yield* ephemeral( + (function* (): Operation { + yield* until(task[Symbol.asyncDispose]()); + reachedTheDisposal.resolve(); + yield* suspend(); + })(), + ); + return "never"; + }, + { stream }, + ); + }); + yield* reachedTheDisposal.operation; + yield* second.halt(); + + expect(marks).toEqual(["first life"]); + }); + it("reads a record with no reason as caller", function* () { const marks: string[] = []; const stream = new InMemoryStream(); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 53a739460..d7000dd27 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9179,11 +9179,25 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. -Partial replay compares the complete resolved layout first and refuses a -changed column count, title, form, count, or order before provider work. It then -builds a new live composite. Completed pane children appear as already-settled -statuses and perform no effects; incomplete children continue from their own -durable records. An incomplete `` keeps the exact +Partial replay compares the **resolved** layout first — the column count and +each pane's title — and refuses a change before the foreground lease is taken +and before any provider is contacted. It then builds a new live composite. +Completed pane children appear as already-settled statuses and perform no +effects; incomplete children continue from their own durable records. + +Pane count, order and form are not compared, because they cannot differ. A +continuation executes the root document the journal retained: the source the new +invocation supplies is not read, not compared and not refused, so a grid's +authored structure is fixed for the life of a journal and comparing it would +compare a value with itself. A supplied file that says something else is +ignored in favour of the retained structure, and the grid a continuation opens +is the one that was recorded. What a fixed retained document can still resolve +differently is `columns` and each `title` — props are not restored across a +continuation — and those are exactly what the comparison covers. + +Refusing a changed authored structure is a root-definition compatibility +question rather than a grid one, and belongs to a versioned root boundary this +specification does not yet define. An incomplete `` keeps the exact `prepared`/`detached` replay and logical-session identity rules defined by the native launch specification. An incomplete self-closing pane starts the current authorized default shell and does not claim continuity of shell process or From 36206d5d07d4b09e2a8804227479fb1f227bc279 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 18:48:27 -0400 Subject: [PATCH 15/62] =?UTF-8?q?=F0=9F=93=9D=20Define=20reader-close=20ca?= =?UTF-8?q?ncellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 58 ++++++++++++++++++--- packages/durable-streams/specs/DECISIONS.md | 12 +++-- specs/executable-mdx-spec.md | 41 ++++++++++++--- 3 files changed, 92 insertions(+), 19 deletions(-) diff --git a/architecture.md b/architecture.md index e293f825f..3bf3bcdd1 100644 --- a/architecture.md +++ b/architecture.md @@ -3030,16 +3030,48 @@ The grid runs as one structured scope: 6. Once attached, each pane settles independently and keeps its final status visible while siblings continue. The composite remains present after all panes settle until the reader closes or leaves it. -7. Closing begins an ordered teardown: prevent new pane launches, cancel live - pane scopes, await every child and finalizer, detach and destroy the exact - provider composite, restore the root terminal, and only then release the - foreground lease and settle the grid. The document never continues while an - observable pane child or provider-owned process can still act through the - grid. +7. Reader close first crosses a live close boundary, then begins an ordered + teardown: prevent new pane launches, ask live pane children to close, await + every child and finalizer, detach and destroy the exact provider composite, + restore the root terminal, and only then release the foreground lease and + settle the grid. The document never continues while an observable pane child + or provider-owned process can still act through the grid. + +The provider's `closed()` settlement proposes the live close boundary. The +boundary is crossed when the grid owner has entered a cancellation-deferred +await of the grid's durable child and acknowledges that proposal; only then may +the child signal pane close. That await ends only when the task has settled and +its durable `Close` has been acknowledged, not when the grid body has merely +chosen an outcome. This handshake has no provider identity and is not itself +journaled. + +Reader-close intent becomes durable only as that completed grid `Close`, after +pane and provider teardown. There is no standalone durable "closing" state. The +gap between observing close and committing it is safe because ordinary parent +cancellation is held pending across the whole gap. A cancellation that arrives +before the owner acknowledges the close boundary cancels the active grid. One that arrives +afterward does not rewrite grid or pane outcomes: panes already settled keep +their outcomes, each then-live pane completes its own scope and retains +`closed`, and the grid retains the same `reader` or `failed` result it would +have retained without the cancellation. Once the grid child is durably closed, +the pending cancellation is delivered to the parent, so no following document +sibling runs in that attempt. A fatal or cleanup failure still takes its +existing precedence over cancellation. + +Pane work and every finalizer it installs live inside that pane's durable child +scope. Reader close is cooperative at the durable boundary: it asks the pane to +close and awaits it; it never halts the pane's durable task. The pane may stop +its live nested work as part of its own scope teardown, but its durable child +does not settle as `closed` or write `Close(ok)` until that work and its finalizers +have settled. This preserves the pane's ordinal-derived identity and never +turns a deliberate reader close into a caller-cancelled durable child that a +later run could revive or wait on forever. Parent cancellation follows the same teardown from preparation, readiness, or -the active grid and remains cancellation. A provider or host failure cancels -the whole grid and is the grid's canonical failure. An ordinary pane failure +the active grid and remains cancellation. Once reader close has crossed its +live boundary, the close result is committed first and that cancellation is +observed by the parent afterward. A provider or host failure cancels the whole +grid and is the grid's canonical failure. An ordinary pane failure after attachment is contained as that pane's status and does not cancel its siblings. When the reader closes the grid, core fails it with the first failed pane in authored order; cancellation initiated by grid teardown is not a pane @@ -3090,6 +3122,16 @@ a terminal provider, starting a shell, expanding pane content, acquiring an Agent session, or launching a native UI. The structured durable boundary owns that short circuit; a public replay context does not. +The reader-close handshake makes cancellation during teardown a completed-grid +case rather than a new partial-replay state. When a pane finalizer delays close +and parent cancellation arrives, the first attempt still finishes every pane +and provider finalizer, writes the pane outcomes and completed grid `Close`, and +only then reports cancellation to its parent. A continuation claims that +completed child and resumes after it without recreating the provider or +re-entering pane work. A host loss can still interrupt the unjournaled live +teardown; panes whose `Close` was acknowledged remain complete, while any pane +and grid without a completed record follow the existing partial-replay rules. + Partial replay compares the **resolved** layout and refuses divergence before provider work. diff --git a/packages/durable-streams/specs/DECISIONS.md b/packages/durable-streams/specs/DECISIONS.md index 8c422ffbf..6be5d3b60 100644 --- a/packages/durable-streams/specs/DECISIONS.md +++ b/packages/durable-streams/specs/DECISIONS.md @@ -567,11 +567,13 @@ Updated before completion of every phase and committed at the end of each phase. *awaits* a task it previously halted has diverged, and divergence is the honest answer there rather than a silent revival. - **Consequences:** Terminal grids get what they need without reviving anything - deliberately stopped. A grid halts each pane task when the reader closes, so - those panes retain `"caller"` — and the grid child completes, so a resumed run - short-circuits the whole region and never reaches them. The case that must - resume — the run interrupted while the grid is open — unwinds the grid and - pane children, retains `"unwound"`, and continues. + deliberately stopped. Reader close cooperatively closes each pane inside its + durable child and waits for that child to retain `closed`; it does not halt + the durable pane task. Once reader close takes effect, later parent + cancellation is deferred through pane and grid completion, so a resumed run + short-circuits the completed grid and never reaches those panes. The case that + must resume — the run interrupted while the grid is still active — unwinds + the grid and pane children, retains `"unwound"`, and continues. - **Scope:** The reason is retained evidence, not authority. Nothing reads it from outside `runDurableChild`, no public API exposes it, and no caller chooses a policy: the policy stays fixed at each combinator's call site. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index d7000dd27..496d437bf 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9103,11 +9103,23 @@ selects success or failure; a live pane cancelled only because the reader closed the grid becomes `closed`. These states display core's result and never author it. -Close first prevents new pane launches, then cancels live pane scopes, awaits -every child and provider finalizer, destroys the exact composite, restores the -root terminal, and releases the foreground lease. Only then does the element -settle and a later document sibling begin. There is no implicit timeout; parent -cancellation and an enclosing execution deadline use the same complete teardown. +The provider's `closed()` operation proposes reader close. Reader close takes +effect when the grid owner has entered a cancellation-deferred await of the +grid's durable child and acknowledges that proposal. Before that acknowledgement +reaches the child, no close signal reaches a pane. Close then prevents new pane +launches, asks every live pane child to close, awaits every child and provider +finalizer, destroys the exact composite, restores the root terminal, and +releases the foreground lease. The deferred await ends only after the durable +child has settled and its `Close` has been acknowledged. Only then does the +element settle and a later document sibling begin. There is no implicit timeout; +parent cancellation and an enclosing execution deadline use the same complete +teardown. + +Pane work and the finalizers it installs are scoped inside that pane's durable +child. Reader close does not halt that durable child. It cooperatively closes +the pane's live work and the child retains `closed` only after its work and +finalizers have settled. A pane that had already succeeded or failed keeps that +outcome. #### Native launch ownership inside a pane @@ -9142,6 +9154,12 @@ continues. A provider or host failure cancels the composite and is the grid failure. Parent cancellation remains cancellation rather than becoming a pane failure. +If it arrives after reader close takes effect, reader close still decides the +grid and pane outcomes: then-live panes retain `closed`, already-settled panes +keep their outcomes, and the grid retains `reader` or `failed` under the normal +authored-order rule. The cancellation remains pending until complete teardown +and the grid's durable close, then reaches the parent before any following +document sibling runs. A fatal or cleanup failure keeps its existing precedence. All acquired resources are finalized even when an earlier failure already decides the result, and the existing fatal-infrastructure and cleanup precedence still applies. Before the first cancellation signal, the provider snapshots @@ -9179,6 +9197,16 @@ claims that whole region and restores its result without contacting a terminal provider, creating a composite, starting a shell, expanding pane content, resolving an Agent, taking session ownership, or launching a native UI. +Reader-close intent has no separate durable `closing` state. It becomes durable +as the completed grid `Close`, after all pane and provider teardown. The live +handshake described above holds later parent cancellation across that interval, +so cancellation during a blocked pane finalizer still produces completed pane +and grid records before it reaches the parent. A continuation therefore claims +the completed grid and proceeds without waiting on or re-entering a pane the +reader closed. If the host itself disappears before completion, the journal has +no completed grid close and the ordinary partial-replay rules apply; any pane +whose completed `Close` was acknowledged remains settled. + Partial replay compares the **resolved** layout first — the column count and each pane's title — and refuses a change before the foreground lease is taken and before any provider is contacted. It then builds a new live composite. @@ -11234,7 +11262,8 @@ test derives a core result from a provider identifier. | TG15 | Completed replay | A completed successful or failed grid restores its exact result while contacting no terminal provider, shell, Agent provider, coordinator, pane content or native launcher | | TG16 | Partial replay | Exact layout rebuilds a fresh provider composite; completed pane children appear settled without effects, incomplete paired children follow their durable records, incomplete native launches preserve prepared/detached session identity, and an incomplete shell starts current host policy without terminal-history continuity | | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | -| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | +| TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | ### Tier CR — Component registration and resolution From 6508ec289d72ea79f5a45098a54e2af6d71f42bb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:04:51 -0400 Subject: [PATCH 16/62] =?UTF-8?q?=E2=9C=A8=20Implement=20the=20reader-clos?= =?UTF-8?q?e=20cancellation=20commit=20boundary=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the amendment at 18338707 without revising it. **The live handshake.** `composite.closed()` settling now only *proposes* the boundary. The grid's durable child publishes that proposal and waits; the owner awaiting the child acknowledges it; and only then does the grid seal admission and ask its panes to close. The handshake is one live rendezvous — no provider identity, nothing journaled. **Committing an outcome before the scope finishes unwinding.** A durable child can now declare its terminal value, and `runDurableChild` records that value if the child never reaches a normal ending. That is the piece the contract needs: the grid commits its retained record as the boundary is crossed, and each pane live at that moment commits `closed`, so a cancellation arriving while pane and provider finalizers are still running records what close decided rather than a cancellation. Committing is live state; it reaches the journal only as the ordinary `Close`. A child that returns or throws normally overrides it, and a child that never committed still records the cancellation it actually reached — DEC-040 untouched. Cancellation stays deferred because Effection completes a child's teardown — pane finalizers, provider destroy, terminal restoration, lease release, the `Close` append and the task's settlement — before the halt reaches the owner. **Pane work stays inside its ordinal-derived durable child.** Reader close asks the pane to close; it never halts the pane's durable task. The pane commits `closed`, stops its live nested work through its own scope, and settles only once that work and its finalizers have settled. No durable closing marker was added, and completed replay is unchanged. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 146 +++++++++++++++++++--- packages/core/tests/terminal-grid.test.ts | 9 ++ packages/durable-streams/combinators.ts | 40 +++++- packages/durable-streams/mod.ts | 1 + 5 files changed, 178 insertions(+), 22 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 6124da42b..25182fde2 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2180,11 +2180,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* () { + const retained = yield* durableGrid(function* (boundary, commit) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work); + return yield* openTerminalGrid(layout, work, boundary, commit); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index fa91a00f3..2a4677feb 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -38,6 +38,55 @@ import { import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; import type { TerminalGridLayout } from "../terminal-grid.ts"; +/** + * The live boundary reader close crosses (architecture.md §Atomic presentation + * and settlement). + * + * The provider settling `closed()` only *proposes* the boundary. It is crossed + * when the owner awaiting the grid's durable child acknowledges that proposal + * from inside its own cancellation-deferred await — and only then may the grid + * seal admission and ask its panes to close. + * + * Nothing here is journaled and nothing here names a provider: it is one live + * rendezvous between a durable child and the owner waiting on it. What it buys + * is the ordering the contract needs — a cancellation arriving before the + * acknowledgement cancels the active grid, and one arriving after it waits for + * the grid to finish closing. + */ +export interface CloseBoundary { + /** The child: publish the proposal and wait for it to be acknowledged. */ + propose(): Operation; + /** The owner: settle once close has been proposed. */ + proposed(): Operation; + /** The owner: cross the boundary. */ + acknowledge(): void; + /** Whether the boundary has been crossed. */ + readonly acknowledged: boolean; +} + +export function createCloseBoundary(): CloseBoundary { + const proposal = withResolvers(); + const acknowledgement = withResolvers(); + let crossed = false; + return { + *propose() { + proposal.resolve(); + yield* acknowledgement.operation; + }, + proposed: () => proposal.operation, + acknowledge() { + if (crossed) { + return; + } + crossed = true; + acknowledgement.resolve(); + }, + get acknowledged() { + return crossed; + }, + }; +} + /** How one pane ended, as the journal records it. */ export type PaneStatus = "succeeded" | "failed" | "closed"; @@ -148,6 +197,8 @@ export function retainedLayout(request: TerminalGridRequest): RetainedGrid["layo export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -167,7 +218,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work); + settled = yield* presentGrid(request, composite, work, boundary, commit); grid.settled = true; }, }; @@ -209,6 +260,8 @@ function presentGrid( request: TerminalGridRequest, composite: TerminalComposite, work: readonly PaneWork[], + boundary: CloseBoundary, + commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -246,7 +299,9 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* (): Operation { + yield* paneChild(function* ( + commitPane: (outcome: RetainedPaneOutcome) => void, + ): Operation { return yield* runPane( pane, claim, @@ -255,6 +310,7 @@ function presentGrid( request, index, closing.operation, + commitPane, ); }), ); @@ -304,6 +360,12 @@ function presentGrid( // what finishes the grid, not the last pane exiting. yield* composite.closed(); + // Proposed, then acknowledged by the owner from inside its own + // cancellation-deferred await. Until it is crossed, a cancellation cancels + // the active grid under the ordinary rules; once crossed, the close result + // is committed first and the cancellation waits for it. + yield* boundary.propose(); + // Close prevents new work first, then takes the live panes down: a pane // cancelled by the close is `closed`, which is not a failed pane. Every // child is awaited here, and the provider's finalizers run in the scope's @@ -312,6 +374,12 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); + // The outcome is decided the moment the boundary is crossed: every settled + // pane keeps its own, every pane still live is closed. Committed here, so a + // cancellation arriving while pane and provider finalizers are still going + // records what close decided rather than a cancellation. + const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); + commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -329,11 +397,7 @@ function presentGrid( const settled = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); const reason = firstReason(settled); - return { - layout: retainedLayout(request), - close: reason === undefined ? "reader" : "failed", - panes: settled, - }; + return retained(request, settled, reason); }); } @@ -346,6 +410,7 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, + commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -366,7 +431,17 @@ function runPane( })(), ]); if (closed) { - return { status: "closed", reason: "" }; + const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; + // Decided at the boundary, so a cancellation arriving while this pane's + // finalizers are still going records the close rather than a + // cancellation — and never a caller-cancelled child a later run would + // have to revive or wait on. + commitPane(outcome); + // The nested work is stopped by this pane's own scope, and its + // finalizers are awaited here: the durable child settles only once they + // have. + yield* running.halt(); + return outcome; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -386,6 +461,19 @@ function runPane( })(); } +/** The record one grid settled to. */ +function retained( + request: TerminalGridRequest, + panes: readonly RetainedPaneOutcome[], + reason: string | undefined, +): RetainedGrid { + return { + layout: retainedLayout(request), + close: reason === undefined ? "reader" : "failed", + panes: [...panes], + }; +} + /** The first failed pane's sentence in authored order, which is the grid's. */ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): string | undefined { return outcomes.find((outcome) => outcome?.status === "failed")?.reason; @@ -408,16 +496,19 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: () => Operation, + body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child. - return yield* spawn(body); + // No journal behind this run: an ordinary spawned child, with nothing to + // commit an outcome into. + return yield* spawn(() => body(() => {})); } - return yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(body()); + return yield* durableSpawn(function* ( + commit: (outcome: RetainedPaneOutcome) => void, + ): Workflow { + return yield* ephemeral(body(commit)); }); })(); } @@ -430,14 +521,35 @@ function paneChild( * no shell starts — and claiming the completed child claims every pane history * beneath it, so a resumed run starts nothing. */ -export function durableGrid(live: () => Operation): Operation { +export function durableGrid( + live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, +): Operation { return (function* (): Operation { + const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - return yield* live(); + // No journal to commit into, so the boundary is crossed as soon as it is + // proposed and the grid closes in one step. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); + }); + return yield* live(boundary, () => {}); } - const task = yield* durableSpawn(function* (): Workflow { - return yield* ephemeral(live()); + const task = yield* durableSpawn(function* ( + commit: (grid: RetainedGrid) => void, + ): Workflow { + return yield* ephemeral(live(boundary, commit)); + }); + // The owner's cancellation-deferred await. Acknowledging happens here, + // inside it: from this point a cancellation cannot pre-empt the close, + // because the child commits its outcome as the boundary is crossed and + // Effection completes a child's teardown — its pane and provider + // finalizers, its `Close` append and its settlement — before the halt + // reaches whoever asked for it. + yield* spawn(function* () { + yield* boundary.proposed(); + boundary.acknowledge(); }); return yield* task; })(); diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4c6961c25..f80813595 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -118,6 +118,7 @@ function useGridComponents( onMark: (mark: string) => void = () => {}, afterAttach: () => Operation = function* () {}, teardownHeld: () => Operation = function* () {}, + teardownArmed: () => void = () => {}, ): Operation { return registerComponents([ { @@ -179,6 +180,9 @@ function useGridComponents( yield* ensure(function* () { yield* teardownHeld(); }); + // Armed: the finalizer is installed and this pane is live, which is + // what a row waits for before letting the reader leave. + teardownArmed(); yield* suspend(); return ""; }, @@ -408,6 +412,8 @@ function runInterrupted( * would record the pane as cancelled by the close instead. */ closeAfterFailure?: boolean; + /** Let the reader leave only once a `` pane is armed. */ + closeWhenArmed?: boolean; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -467,6 +473,8 @@ function runInterrupted( }, }); const paneFailed = withResolvers(); + // Resolved once a `` pane has installed its finalizer. + const armed = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +490,7 @@ function runInterrupted( yield* options.holdTeardown(); } }, + () => armed.resolve(), ); yield* installControlledLauncher(); if (options.provider !== false) { diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index b6b5b9a12..ed50a2921 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -98,8 +98,24 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } +/** + * Declare a child's terminal value before its scope has finished unwinding. + * + * A child that has already decided what it settled to — a terminal grid that + * crossed its reader-close boundary, say — must record that outcome even if the + * run is cancelled while its finalizers are still going. Without this, a halt + * arriving during teardown loses the decision and the child records a + * cancellation instead, which is a different thing entirely. + * + * Committing is live state, never journaled on its own: the value reaches the + * journal only as the child's ordinary `Close`, written where it always was. + * A child that goes on to return or throw normally overrides what it committed, + * because that is the outcome it actually reached. + */ +export type CommitOutcome = (value: T) => void; + function* runDurableChild( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -163,12 +179,30 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; + // What the child declared it had settled to before its scope finished coming + // down. Read only when the child never reached a normal ending. + let committed: { value: T } | undefined; + const commit: CommitOutcome = (value) => { + committed = { value }; + }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } + // A child that committed an outcome and was then cancelled mid-teardown + // settled: the decision was made before the cancellation arrived, and the + // record has to say so. The cancellation is still a cancellation for + // whoever asked for it — it is simply delivered after this. + if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { + closeEvent = { + type: "close", + coroutineId: childId, + result: { status: "ok", value: committed.value as Json }, + }; + } + // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -205,7 +239,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(); + const result: T = yield* childWorkflow(commit); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -287,7 +321,7 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: () => Workflow, + childWorkflow: (commit: CommitOutcome) => Workflow, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 581dabd57..4d2f5747d 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -102,6 +102,7 @@ export { durableAction, durableCall, durableSleep, versionCheck } from "./operat // Structured concurrency combinators export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; +export type { CommitOutcome } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 9ccfd299cab6a7cdf3c05dc67b7d94ce23fd29df Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:34:37 -0400 Subject: [PATCH 17/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Defer=20the=20grid?= =?UTF-8?q?=20owner's=20cancellation=20at=20the=20live=20close=20boundary?= =?UTF-8?q?=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture 18338707 at the owner boundary. The grid's durable child now runs in a scope of its own — a child of the owner's, so it inherits every context the document runs under, and its own so that tearing the owner down does not reach it first. A finalizer registered after that scope exists runs before it is destroyed, and that is the cancellation-deferred await: once the owner has acknowledged the provider's close proposal, the grid and its panes finish teardown and append their ordinary completed Close records, and only then does the cancellation carry on to the parent. Removes the exported CommitOutcome/durableSpawn(commit) API. Cancellation is never turned into success inside runDurableChild; durableSpawnIn only says where a child lives, and grants nothing a caller does not already have. TG19 proves the ordering with signals alone: a live pane arms a blocking finalizer, the reader leaves, the finalizer is entered and held, cancellation begins, the finalizer is released, and the run ends with the composite destroyed, a completed grid Close retained, the live pane retained as closed — and the sibling after the grid never reached. The continuation then replays past it with no provider, no pane body and no finalizer re-entered. TG6 isolates paired-pane sequencing on its own: the reader leaves only once the pane's second component has run. --- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/grid.ts | 128 ++++++++++++++-------- packages/core/tests/terminal-grid.test.ts | 117 +++++++++++++++++++- packages/durable-streams/combinators.ts | 75 ++++++------- packages/durable-streams/mod.ts | 3 +- 5 files changed, 233 insertions(+), 94 deletions(-) diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 25182fde2..6a3b96992 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2180,11 +2180,11 @@ function* expandTerminalGrid( // child never runs. yield* recordGridLayout(identity, toRequest(layout)); - const retained = yield* durableGrid(function* (boundary, commit) { + const retained = yield* durableGrid(function* (boundary) { const work = structure.panes.map((pane, index) => paneWork(pane, layout.cells[index]!.title, site), ); - return yield* openTerminalGrid(layout, work, boundary, commit); + return yield* openTerminalGrid(layout, work, boundary); }); const failed = retained.panes.find((pane) => pane.status === "failed"); diff --git a/packages/core/src/terminal/grid.ts b/packages/core/src/terminal/grid.ts index 2a4677feb..f96fb091a 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/core/src/terminal/grid.ts @@ -22,9 +22,25 @@ * desynchronise the journal on the next run. */ -import { ensure, race, scoped, spawn, withResolvers } from "effection"; -import type { Operation, Task } from "effection"; -import { DurableContext, durableSpawn, ephemeral } from "@executablemd/durable-streams"; +import { + createScope, + Err, + ensure, + race, + scoped, + Ok, + spawn, + until, + useScope, + withResolvers, +} from "effection"; +import type { Operation, Result, Task } from "effection"; +import { + DurableContext, + durableSpawn, + durableSpawnIn, + ephemeral, +} from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; @@ -198,7 +214,6 @@ export function openTerminalGrid( layout: TerminalGridLayout, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { const installation = yield* terminalInstallation(); @@ -218,7 +233,7 @@ export function openTerminalGrid( used: false, settled: false, *run(composite) { - settled = yield* presentGrid(request, composite, work, boundary, commit); + settled = yield* presentGrid(request, composite, work, boundary); grid.settled = true; }, }; @@ -261,7 +276,6 @@ function presentGrid( composite: TerminalComposite, work: readonly PaneWork[], boundary: CloseBoundary, - commit: (grid: RetainedGrid) => void, ): Operation { return scoped(function* (): Operation { // Registered before a single pane starts: a composite that was presented is @@ -299,9 +313,7 @@ function presentGrid( const claim = grid.claims[index]!; const readiness = grid.readiness[index]!; panes.push( - yield* paneChild(function* ( - commitPane: (outcome: RetainedPaneOutcome) => void, - ): Operation { + yield* paneChild(function* (): Operation { return yield* runPane( pane, claim, @@ -310,7 +322,6 @@ function presentGrid( request, index, closing.operation, - commitPane, ); }), ); @@ -374,12 +385,6 @@ function presentGrid( // acquired can still act. grid.seal(); closing.resolve(); - // The outcome is decided the moment the boundary is crossed: every settled - // pane keeps its own, every pane still live is closed. Committed here, so a - // cancellation arriving while pane and provider finalizers are still going - // records what close decided rather than a cancellation. - const decided = outcomes.map((outcome) => outcome ?? { status: "closed" as const, reason: "" }); - commit(retained(request, decided, firstReason(decided))); // Published before anything is awaited: once the reader has left, a pane // that had not settled is closed, and that is true whether or not its own // finalizers are quick about it. @@ -410,7 +415,6 @@ function runPane( request: TerminalGridRequest, index: number, closing: Operation, - commitPane: (outcome: RetainedPaneOutcome) => void, ): Operation { return (function* (): Operation { try { @@ -431,17 +435,11 @@ function runPane( })(), ]); if (closed) { - const outcome: RetainedPaneOutcome = { status: "closed", reason: "" }; - // Decided at the boundary, so a cancellation arriving while this pane's - // finalizers are still going records the close rather than a - // cancellation — and never a caller-cancelled child a later run would - // have to revive or wait on. - commitPane(outcome); // The nested work is stopped by this pane's own scope, and its - // finalizers are awaited here: the durable child settles only once they - // have. + // finalizers are awaited here: the durable child settles as closed only + // once that work and its finalizers have settled. yield* running.halt(); - return outcome; + return { status: "closed", reason: "" }; } if (!readiness.acknowledged) { // Settled without ever starting: a startup failure even though the work @@ -496,19 +494,16 @@ function firstReason(outcomes: readonly (RetainedPaneOutcome | undefined)[]): st * Without a journal there is no child to derive, and the work simply runs. */ function paneChild( - body: (commit: (outcome: RetainedPaneOutcome) => void) => Operation, + body: () => Operation, ): Operation> { return (function* (): Operation> { const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal behind this run: an ordinary spawned child, with nothing to - // commit an outcome into. - return yield* spawn(() => body(() => {})); + // No journal behind this run: an ordinary spawned child. + return yield* spawn(body); } - return yield* durableSpawn(function* ( - commit: (outcome: RetainedPaneOutcome) => void, - ): Workflow { - return yield* ephemeral(body(commit)); + return yield* durableSpawn(function* (): Workflow { + return yield* ephemeral(body()); }); })(); } @@ -522,35 +517,72 @@ function paneChild( * beneath it, so a resumed run starts nothing. */ export function durableGrid( - live: (boundary: CloseBoundary, commit: (grid: RetainedGrid) => void) => Operation, + live: (boundary: CloseBoundary) => Operation, ): Operation { return (function* (): Operation { const boundary = createCloseBoundary(); const durable = yield* DurableContext.get(); if (durable === undefined) { - // No journal to commit into, so the boundary is crossed as soon as it is + // No journal to finish into, so the boundary is crossed as soon as it is // proposed and the grid closes in one step. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* live(boundary, () => {}); + return yield* live(boundary); } - const task = yield* durableSpawn(function* ( - commit: (grid: RetainedGrid) => void, - ): Workflow { - return yield* ephemeral(live(boundary, commit)); + + // The grid's durable child runs in a scope of its own — a child of this one, + // so it inherits every context the document runs under, and its own so that + // tearing this one down does not reach the child first. + // + // That ordering is what makes the await below genuinely deferred. A scope + // runs its finalizers in reverse, so one registered after this scope exists + // runs before this scope is destroyed: the grid and its panes finish their + // own teardown and append their ordinary completed `Close` records, and only + // then does the cancellation carry on to the parent. + const [detached, destroy] = createScope(yield* useScope()); + const held: { + task?: Task; + outcome?: Result; + } = {}; + + // Registered after the scope and before the await, so a cancellation runs it + // and waits for it. Before the boundary is crossed there is nothing to + // finish, and destroying the scope cancels the active grid under the + // ordinary rules. + yield* ensure(function* () { + if (held.task !== undefined && boundary.acknowledged && held.outcome === undefined) { + held.outcome = yield* finish(held.task); + } + yield* until(destroy()); + }); + + held.task = yield* durableSpawnIn(detached, function* (): Workflow { + return yield* ephemeral(live(boundary)); }); - // The owner's cancellation-deferred await. Acknowledging happens here, - // inside it: from this point a cancellation cannot pre-empt the close, - // because the child commits its outcome as the boundary is crossed and - // Effection completes a child's teardown — its pane and provider - // finalizers, its `Close` append and its settlement — before the halt - // reaches whoever asked for it. + // The owner acknowledges, and only the owner. By the time it can, the + // finalizer above is already registered — so crossing the boundary and + // being committed to finishing the child are the same moment. yield* spawn(function* () { yield* boundary.proposed(); boundary.acknowledge(); }); - return yield* task; + + held.outcome = yield* finish(held.task); + yield* until(destroy()); + if (!held.outcome.ok) { + throw held.outcome.error; + } + return held.outcome.value; })(); } + +/** Await one grid child, keeping how it ended rather than re-throwing it here. */ +function* finish(task: Task): Operation> { + try { + return Ok(yield* task); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index f80813595..52571e69a 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -414,6 +414,8 @@ function runInterrupted( closeAfterFailure?: boolean; /** Let the reader leave only once a `` pane is armed. */ closeWhenArmed?: boolean; + /** Let the reader leave only once this tripwire mark has been recorded. */ + closeWhenMarked?: string; /** Ordinal of a shell that starts, waits for attachment, then exits badly. */ shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ @@ -475,6 +477,7 @@ function runInterrupted( const paneFailed = withResolvers(); // Resolved once a `` pane has installed its finalizer. const armed = withResolvers(); + const marked = withResolvers(); yield* useGridComponents( ran, [], @@ -482,6 +485,9 @@ function runInterrupted( if (mark === PAST_THE_GRID) { pastGrid.resolve(); } + if (mark === options.closeWhenMarked) { + marked.resolve(); + } }, () => attached.operation, function* () { @@ -499,9 +505,13 @@ function runInterrupted( close: options.closeAfterFailure === true ? () => paneFailed.operation - : options.close === true - ? immediateClose() - : () => suspend(), + : options.closeWhenMarked !== undefined + ? () => marked.operation + : options.closeWhenArmed === true + ? () => armed.operation + : options.close === true + ? immediateClose() + : () => suspend(), ...(options.shellFailsAfterAttach !== undefined ? { shell: function* (ordinal: number, spawned: () => void) { @@ -1077,6 +1087,25 @@ describe("Tier TG — a grid written in a document", () => { expect(run.output).not.toContain("this pane gave up"); }); + it("TG6: a paired pane runs every component in its body, in order", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + // The reader leaves only once the pane's *second* component has run, so a + // pane body that stopped after the first would never let the grid close — + // a hang rather than a pass. + const run = yield* runInterrupted( + dir, + heldDocument(2, [ + '', + '', + ]), + stream, + { close: true, closeWhenMarked: "second component" }, + ); + + expect(run.ran).toContain("second component"); + }); + it("TG9: with no provider installed, no pane body or shell runs", function* () { const dir = yield* useDir(); const run = yield* runDocument( @@ -1326,6 +1355,26 @@ describe("Tier TG — durability and replay", () => { ); } + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + for (const event of run.journal) { + if ( + event.type === "close" && + String(event.coroutineId).split(".").length === 2 && + event.result.status === "ok" + ) { + const value = event.result.value; + if (typeof value === "object" && value !== null && !Array.isArray(value)) { + const panes = Reflect.get(value, "panes"); + if (Array.isArray(panes)) { + return panes; + } + } + } + } + return []; + } + it("TG15: a completed successful grid replays its exact result, with no work", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); @@ -1658,6 +1707,68 @@ describe("Tier TG — durability and replay", () => { } }); + it("TG19: a cancellation during reader-close teardown waits for it, and replays", function* () { + const dir = yield* useDir(); + const stream = new InMemoryStream(); + const source = heldDocument(2, [ + '', + '', + ]); + + // Every step below is an event this run produced. Nothing waits for a + // duration, so a lifecycle that never reached a step hangs the row rather + // than passing it. + const entered = withResolvers(); + const release = withResolvers(); + + const first = yield* runInterrupted(dir, source, stream, { + // 1. The live pane arms its blocking finalizer, and 2. only then does the + // reader leave. + closeWhenArmed: true, + // 3. Entering the finalizer is observed, and it blocks there. + onTeardownEntered: () => entered.resolve(), + holdTeardown: () => release.operation, + // 4. Cancellation begins while that finalizer is still blocked. + interruptWhen: entered.operation, + // 5. Released afterwards, so the cancellation was not waiting on it. + releaseOnInterrupt: () => release.resolve(), + }); + + // 6. Teardown ran to the end, and the grid recorded a completed close — + // both before the cancellation was observed, because the document never + // reached the sibling after the grid. + expect(first.events).toContain("destroy:0"); + expect(completedGrid(first)).toBe(true); + expect(first.ran).toEqual(["pane body"]); + // The live pane settled as closed rather than cancelled: a cancelled child + // is what a later run would have to revive, and this one has nothing left + // to do. + expect(paneOutcomes(first)).toEqual([ + { status: "closed", reason: "" }, + { status: "succeeded", reason: "" }, + ]); + + // 7. Resumed with three tripwires: no provider at all, so a replay that + // asked for a grid would refuse; a mark inside the pane body, so a pane + // that expanded again would say so; and the finalizer, which would + // report being entered a second time. + let reentered = false; + const second = yield* runInterrupted(dir, source, stream, { + close: true, + provider: false, + onTeardownEntered: () => { + reentered = true; + }, + }); + + expect(second.requests).toEqual([]); + expect(second.events).toEqual([]); + expect(second.shown.size).toBe(0); + expect(reentered).toBe(false); + // The retained grid came back and the document carried on from it. + expect(second.ran).toEqual([PAST_THE_GRID]); + }); + it("TG17: the retained layout and pane outcomes are provider-neutral", function* () { const dir = yield* useDir(); const stream = new InMemoryStream(); diff --git a/packages/durable-streams/combinators.ts b/packages/durable-streams/combinators.ts index ed50a2921..9aefe654a 100644 --- a/packages/durable-streams/combinators.ts +++ b/packages/durable-streams/combinators.ts @@ -19,7 +19,7 @@ */ import { all as effectionAll, ensure, race as effectionRace, suspend, useScope } from "effection"; -import type { Operation, Task } from "effection"; +import type { Operation, Scope, Task } from "effection"; import { DurableContext } from "./context.ts"; import { activeDurabilityFailure, @@ -98,24 +98,8 @@ function retainedCancellation(close: Close): Cancellation { return close.result.cancellation === "unwound" ? "unwound" : "caller"; } -/** - * Declare a child's terminal value before its scope has finished unwinding. - * - * A child that has already decided what it settled to — a terminal grid that - * crossed its reader-close boundary, say — must record that outcome even if the - * run is cancelled while its finalizers are still going. Without this, a halt - * arriving during teardown loses the decision and the child records a - * cancellation instead, which is a different thing entirely. - * - * Committing is live state, never journaled on its own: the value reaches the - * journal only as the child's ordinary `Close`, written where it always was. - * A child that goes on to return or throw normally overrides what it committed, - * because that is the outcome it actually reached. - */ -export type CommitOutcome = (value: T) => void; - function* runDurableChild( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, childId: string, parentCtx: DurableContext, cancelledPolicy: CancelledChildPolicy = "combinator-cancels", @@ -179,30 +163,12 @@ function* runDurableChild( let closeEvent: Close | undefined; let suppressClose = false; - // What the child declared it had settled to before its scope finished coming - // down. Read only when the child never reached a normal ending. - let committed: { value: T } | undefined; - const commit: CommitOutcome = (value) => { - committed = { value }; - }; yield* ensure(function* () { if (suppressClose || activeDurabilityFailure(childCtx)) { return; } - // A child that committed an outcome and was then cancelled mid-teardown - // settled: the decision was made before the cancellation arrived, and the - // record has to say so. The cancellation is still a cancellation for - // whoever asked for it — it is simply delivered after this. - if (!closeEvent && committed !== undefined && !replayIndex.firstUnaligned(childId)) { - closeEvent = { - type: "close", - coroutineId: childId, - result: { status: "ok", value: committed.value as Json }, - }; - } - // closeEvent still undefined means the child was cancelled before the // normal-return or catch path ran. if (!closeEvent) { @@ -239,7 +205,7 @@ function* runDurableChild( try { // Run the child workflow. DurableEffects inside the child read // DurableContext from the scope, so they'll use childId. - const result: T = yield* childWorkflow(commit); + const result: T = yield* childWorkflow(); const durabilityFailure = activeDurabilityFailure(childCtx); if (durabilityFailure) { @@ -321,7 +287,35 @@ function* runDurableChild( * again. See `CancelledChildPolicy` and `Cancellation`. */ export function durableSpawn( - childWorkflow: (commit: CommitOutcome) => Workflow, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, undefined); +} + +/** + * Spawn a durable child into `scope` rather than into the routine's own. + * + * Same child, same deterministic identity, same cancellation policy — only the + * lifetime differs. A caller that has to finish a region *after* its own + * cancellation has begun needs the child to outlive the scope being torn down, + * and a scope of its own is the only honest way to express that: the child then + * settles normally and writes its ordinary `Close`, and the caller decides when + * to destroy the scope. + * + * It grants nothing a caller does not already have. Placing a child somewhere + * is not replay authority, and the policy stays fixed at the call site. + */ +export function durableSpawnIn( + scope: Scope, + childWorkflow: () => Workflow, +): Workflow> { + return spawnDurableChild(childWorkflow, scope); +} + +/** Both spellings of a durable spawn; `into` is the only thing that differs. */ +function spawnDurableChild( + childWorkflow: () => Workflow, + into: Scope | undefined, ): Workflow> { return (function* (): Workflow> { // Reading the context and allocating the child id is ordinary scope setup: @@ -335,6 +329,7 @@ export function durableSpawn( return (yield createSpawnEffect( () => runDurableChild(childWorkflow, childId, ctx, "resume", evidence), evidence, + into, )) as Task; })(); } @@ -358,12 +353,14 @@ function* readDurableContext(): Operation { function createSpawnEffect( child: () => Operation, evidence: CancellationEvidence, + into?: Scope, ): DurableEffect> { return { description: "durable-spawn", effectDescription: { type: "ephemeral", name: "durable-spawn" }, enter(resolve, routine) { - resolve({ ok: true, value: observingDisposal(routine.scope.run(child), evidence) }); + const host = into ?? routine.scope; + resolve({ ok: true, value: observingDisposal(host.run(child), evidence) }); return (exit) => exit({ ok: true, value: undefined as undefined }); }, }; diff --git a/packages/durable-streams/mod.ts b/packages/durable-streams/mod.ts index 4d2f5747d..94a63b438 100644 --- a/packages/durable-streams/mod.ts +++ b/packages/durable-streams/mod.ts @@ -101,8 +101,7 @@ export type { export { durableAction, durableCall, durableSleep, versionCheck } from "./operations.ts"; // Structured concurrency combinators -export { durableAll, durableRace, durableSpawn } from "./combinators.ts"; -export type { CommitOutcome } from "./combinators.ts"; +export { durableAll, durableRace, durableSpawn, durableSpawnIn } from "./combinators.ts"; // Durable iteration export { durableEach } from "./each.ts"; From 682402b05f7ad067d8d9ec1b6707c40e570fc66e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 19:44:55 -0400 Subject: [PATCH 18/62] =?UTF-8?q?=E2=9C=85=20Count=20what=20TG19=20proves:?= =?UTF-8?q?=20resources,=20records=20and=20the=20lease=20(#730)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The production lifecycle is unchanged. TG19 now reads counters and journal records rather than a log's shape. The controlled composite keeps live resource counters — composites prepared, composites attached, shells started — each raised when it takes something and lowered when it gives it back, however it left. TG19 reads them once while a pane finalizer is blocked, so it knows they went up, and again when the cancellation has completed, so it knows they came back down. The harness now says when a blocked finalizer *leaves*, not only when it is entered: a finalizer that was entered and then cancelled reaches the first hook and never the second. And after every interrupted run it takes the foreground lease and gives it back twice — the first proves the grid returned it, the second proves the harness did. TG19 adds: one grid Close(ok) retaining close: "reader"; two pane Closes, both completed, with no cancellation recorded at either level; the finalizer entered and left exactly once; destroy:0 exactly once. The first-attempt claim that no following sibling ran and the replay tripwires are unchanged. Every one of these was broken on purpose and re-run: dropping any of the three counter releases, the deferral, the finalizer-exit hook, or double-logging destroy fails TG19, and a second holder of the foreground lease is refused. --- packages/core/tests/terminal-grid.test.ts | 133 ++++++++++++++++++---- packages/runtime/mod.ts | 1 + packages/runtime/terminal.ts | 56 +++++++-- 3 files changed, 160 insertions(+), 30 deletions(-) diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 52571e69a..4acf29a8d 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -44,6 +44,7 @@ import type { DurableEvent } from "@executablemd/durable-streams"; import { installControlledLauncher, prepareControlledComposite, + reserveTerminal, TerminalGrids, terminalProviderLog, } from "@executablemd/runtime"; @@ -52,6 +53,7 @@ import type { TerminalComposite, TerminalGridRequest, TerminalProviderLog, + TerminalProviderResources, } from "@executablemd/runtime"; import { Component } from "../src/component-api.ts"; @@ -90,6 +92,8 @@ interface DocumentRun { errors: string[]; /** The journal this run read and appended to. */ journal: DurableEvent[]; + /** What the controlled provider still held when the run was over. */ + live: TerminalProviderResources; } /** @@ -353,6 +357,7 @@ function runDocument( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -420,8 +425,31 @@ function runInterrupted( shellFailsAfterAttach?: number; /** Holds a `` pane's finalizer until this settles. */ holdTeardown?: () => Operation; - /** Resolved once a pane's finalizer has been entered and is blocked. */ - onTeardownEntered?: () => void; + /** + * Called once a pane's finalizer has been entered and is blocked, with what + * the provider is holding at that moment. + * + * A row reads those counters here to know they ever went up, which is what + * makes reading them again at the end mean something. + */ + onTeardownEntered?: (live: TerminalProviderResources) => void; + /** + * Called once that finalizer has left. + * + * Kept apart from entering it deliberately: a finalizer that was entered + * and then cancelled reaches the first hook and never the second, which is + * the difference between teardown starting and teardown finishing. + */ + onTeardownExited?: () => void; + /** + * Called once for each time the foreground lease is taken back after the + * run, which the harness always does twice. + * + * It is the grid's lease that has to come back: a run that stranded it + * would refuse the first of those, and one that never released what this + * harness took would refuse the second. + */ + onLeaseReacquired?: () => void; /** Interrupt the run when this settles rather than at a lifecycle signal. */ interruptWhen?: Operation; /** @@ -491,10 +519,11 @@ function runInterrupted( }, () => attached.operation, function* () { - options.onTeardownEntered?.(); + options.onTeardownEntered?.(log.live); if (options.holdTeardown) { yield* options.holdTeardown(); } + options.onTeardownExited?.(); }, () => armed.resolve(), ); @@ -585,6 +614,16 @@ function runInterrupted( const halting = yield* spawn(() => task.halt()); options.releaseOnInterrupt?.(); yield* halting; + // Taken and given back twice, now that the run is over. The first proves + // the grid returned the foreground lease; the second proves this harness + // gave it back too, so the first cannot have passed against a lease nobody + // was holding in the first place. + for (let attempt = 0; attempt < 2; attempt++) { + yield* scoped(function* () { + yield* reserveTerminal(); + options.onLeaseReacquired?.(); + }); + } return { outcome: { ok: false, error: new Error("interrupted") } as Result, output: "", @@ -594,6 +633,7 @@ function runInterrupted( ran, errors, journal: yield* stream.readAll(), + live: log.live, }; }); } @@ -1355,8 +1395,8 @@ describe("Tier TG — durability and replay", () => { ); } - /** The pane outcomes the grid retained, in authored order. */ - function paneOutcomes(run: DocumentRun): unknown[] { + /** What the grid child retained, read from its own completed `Close`. */ + function retainedGrid(run: DocumentRun): Record | undefined { for (const event of run.journal) { if ( event.type === "close" && @@ -1365,14 +1405,34 @@ describe("Tier TG — durability and replay", () => { ) { const value = event.result.value; if (typeof value === "object" && value !== null && !Array.isArray(value)) { - const panes = Reflect.get(value, "panes"); - if (Array.isArray(panes)) { - return panes; - } + return { ...value }; } } } - return []; + return undefined; + } + + /** The pane outcomes the grid retained, in authored order. */ + function paneOutcomes(run: DocumentRun): unknown[] { + const panes = retainedGrid(run)?.panes; + return Array.isArray(panes) ? panes : []; + } + + /** + * How every `Close` at this coroutine depth ended, in journal order. + * + * Depth 2 is the grid child and depth 3 its panes, so a row reads these to + * say how many records each level wrote and what each one settled to — + * including whether any of them settled as a cancellation. + */ + function closeStatuses(run: DocumentRun, depth: number): string[] { + const statuses: string[] = []; + for (const event of run.journal) { + if (event.type === "close" && String(event.coroutineId).split(".").length === depth) { + statuses.push(event.result.status); + } + } + return statuses; } it("TG15: a completed successful grid replays its exact result, with no work", function* () { @@ -1715,56 +1775,85 @@ describe("Tier TG — durability and replay", () => { '', ]); - // Every step below is an event this run produced. Nothing waits for a - // duration, so a lifecycle that never reached a step hangs the row rather - // than passing it. + // Signals and counters, and nothing else. Every step below is an event this + // run produced, so a lifecycle that never reached one hangs the row rather + // than passing it, and every "exactly once" claim is a count rather than a + // look at the record. const entered = withResolvers(); const release = withResolvers(); + let entries = 0; + let exits = 0; + let leases = 0; + let heldWhenBlocked: TerminalProviderResources | undefined; const first = yield* runInterrupted(dir, source, stream, { // 1. The live pane arms its blocking finalizer, and 2. only then does the // reader leave. closeWhenArmed: true, // 3. Entering the finalizer is observed, and it blocks there. - onTeardownEntered: () => entered.resolve(), + onTeardownEntered: (live) => { + entries++; + heldWhenBlocked = { ...live }; + entered.resolve(); + }, holdTeardown: () => release.operation, + onTeardownExited: () => { + exits++; + }, // 4. Cancellation begins while that finalizer is still blocked. interruptWhen: entered.operation, // 5. Released afterwards, so the cancellation was not waiting on it. releaseOnInterrupt: () => release.resolve(), + onLeaseReacquired: () => { + leases++; + }, }); // 6. Teardown ran to the end, and the grid recorded a completed close — // both before the cancellation was observed, because the document never // reached the sibling after the grid. - expect(first.events).toContain("destroy:0"); - expect(completedGrid(first)).toBe(true); + expect(entries).toBe(1); + expect(exits).toBe(1); + expect(first.events.filter((event) => event === "destroy:0")).toEqual(["destroy:0"]); expect(first.ran).toEqual(["pane body"]); - // The live pane settled as closed rather than cancelled: a cancelled child - // is what a later run would have to revive, and this one has nothing left - // to do. + + // One grid child, completed, and it says what closed it. + expect(closeStatuses(first, 2)).toEqual(["ok"]); + expect(retainedGrid(first)?.close).toBe("reader"); + // Two pane children, both completed. Neither they nor the grid recorded a + // cancellation: a cancelled child is what a later run would have to revive, + // and these have nothing left to do. + expect(closeStatuses(first, 3)).toEqual(["ok", "ok"]); expect(paneOutcomes(first)).toEqual([ { status: "closed", reason: "" }, { status: "succeeded", reason: "" }, ]); + // The provider's counters went up and came back down. Reading them only at + // the end would be true of counters that never moved. + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); + expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + // And the foreground lease came back: it was taken and given back twice + // over once the run was done. + expect(leases).toBe(2); + // 7. Resumed with three tripwires: no provider at all, so a replay that // asked for a grid would refuse; a mark inside the pane body, so a pane // that expanded again would say so; and the finalizer, which would // report being entered a second time. - let reentered = false; + let reentered = 0; const second = yield* runInterrupted(dir, source, stream, { close: true, provider: false, onTeardownEntered: () => { - reentered = true; + reentered++; }, }); expect(second.requests).toEqual([]); expect(second.events).toEqual([]); expect(second.shown.size).toBe(0); - expect(reentered).toBe(false); + expect(reentered).toBe(0); // The retained grid came back and the document carried on from it. expect(second.ran).toEqual([PAST_THE_GRID]); }); diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index eba02abb8..c9a9d60d1 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -162,6 +162,7 @@ export type { TerminalPaneRequest, TerminalPaneState, TerminalProviderLog, + TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index 12827a30b..b03275a8f 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -196,11 +196,34 @@ export interface TerminalProviderLog { * document output to prove where it did not. */ readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; } /** A fresh, empty record. */ export function terminalProviderLog(): TerminalProviderLog { - return { events: [], shown: new Map() }; + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0 }, + }; } /** @@ -247,13 +270,17 @@ export function prepareControlledComposite( yield* options.onPrepare(request); } log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; let destroyed = false; + let attached = false; return { *attach() { if (options.onAttach) { yield* options.onAttach(); } log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; }, // deno-lint-ignore require-yield *update(ordinal, state) { @@ -266,14 +293,22 @@ export function prepareControlledComposite( }, *shell(ordinal, spawned) { log.events.push(`shell:${generation}:${ordinal}`); - if (options.shell) { - return yield* options.shell(ordinal, spawned); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; }, *closed() { if (options.close) { @@ -293,6 +328,11 @@ export function prepareControlledComposite( yield* options.onDestroy(); } log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } }, }; })(); From 4aade574859a683b54a1edcb48cb8dc6f5b78992 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:10:42 -0400 Subject: [PATCH 19/62] =?UTF-8?q?=E2=9C=A8=20Launch=20native=20Agent=20ses?= =?UTF-8?q?sions=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 | 59 ++- .../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, 860 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 6a3b96992..58ca3e516 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, @@ -2239,13 +2240,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, @@ -2269,10 +2285,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 2a11e374d..40068cf2e 100644 --- a/packages/runtime/launcher.ts +++ b/packages/runtime/launcher.ts @@ -63,7 +63,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 = @@ -89,7 +103,7 @@ export const NativeLauncher: Api = createApi { + *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { throw new NativeLauncherUnavailableError(); }, }, @@ -105,9 +119,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 = @@ -180,8 +200,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" }, @@ -213,7 +233,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,11 @@ function runForeground(request: NativeLaunchRequest): Operation spawned()); + // Raced inline, in the same synchronous run as the spawn, so both arms are // attached before the child can report anything — a spawned race attaches // a turn later. Whichever loses is halted, which is what detaches it. @@ -413,6 +441,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; } @@ -444,8 +482,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 c39546b73..1d0bc69de 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/runtime/tests/native-launcher.test.ts @@ -27,6 +27,7 @@ import { flushOutput, installForegroundLauncher, nativeLaunch, + NativeLauncher, NO_TERMINAL, reap, reserveTerminal, @@ -205,6 +206,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 152135b353f99325f4d93e2647f5593bd1079811 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:43:41 -0400 Subject: [PATCH 20/62] =?UTF-8?q?=E2=9C=85=20Complete=20#731's=20controlle?= =?UTF-8?q?d=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 ed64b57c6f357a61751e015479803e41762a27a8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 20:59:26 -0400 Subject: [PATCH 21/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Build=20the=20interr?= =?UTF-8?q?upted=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 d5dee3ff4868697b6e1946a423565c53fc34d9c4 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 21:34:39 -0400 Subject: [PATCH 22/62] =?UTF-8?q?=F0=9F=90=9B=20Acknowledge=20session=20qu?= =?UTF-8?q?iescence=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([]); }); From 27974eb4917a13e798b7bdbf0668163cc04045cb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:13:21 -0400 Subject: [PATCH 23/62] =?UTF-8?q?=E2=9C=A8=20Give=20the=20host=20a=20way?= =?UTF-8?q?=20to=20prove=20a=20terminal=20pane=20is=20free=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A terminal grid may not report a pane settled, admit the next launch into it, or let the document continue while something a launch started can still act. A PID, a delivered signal, an attach client going away and an elapsed timeout each establish none of that. `packages/runtime/terminal-processes.ts` is what does: the process table, terminal holders, signal delivery and reachability, behind one host seam whose own default refuses every question. Refusing is the point — "nobody is there" and "I cannot see" are the two answers a quiescence proof must never confuse, so a host that installs no observer stops the document rather than reporting a pane quiet it never looked at. The POSIX handler answers with `ps` and `lsof`; the `lsof` sweep is the expensive half and grows with the process count, which is why it is behind the seam rather than inlined. Two shapes carry the rule. `paneOccupants()` takes the snapshot — the child, its descendants, its process group — and must be taken *before* the first signal, because a killed child's children reparent to init and a later reading names fewer processes than the launch actually started. `establishQuiescence()` asks about every one of them and about the terminal, and reports everything still true rather than the first thing it found. Nothing here decides policy. It reports; the pane worker finishing a launch and the provider tearing a grid down decide what the report means. Tier TP proves the difference between establishing and assuming: a host with no observer refuses, the POSIX reader finds this process in the real table, a snapshot read after a kill names nobody, and a pane whose child is gone is still not free while a descendant runs or anything else holds the terminal. --- packages/runtime/mod.ts | 23 ++ packages/runtime/terminal-processes.ts | 363 ++++++++++++++++++ .../runtime/tests/terminal-processes.test.ts | 241 ++++++++++++ 3 files changed, 627 insertions(+) create mode 100644 packages/runtime/terminal-processes.ts create mode 100644 packages/runtime/tests/terminal-processes.test.ts diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index c9a9d60d1..9d2619bc0 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -165,6 +165,29 @@ export type { TerminalProviderResources, TerminalShellOutcome, } from "./terminal.ts"; +export { + descendantsOf, + deliverSignal, + establishQuiescence, + groupMembers, + installPosixTerminalProcesses, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_API, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + TerminalProcessesUnavailableError, + terminalHolders, +} from "./terminal-processes.ts"; +export type { + PaneOccupants, + PaneQuiescence, + ProcessFacts, + SignalDelivery, + TerminalProcessHandler, + TerminalSignal, +} from "./terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal-processes.ts b/packages/runtime/terminal-processes.ts new file mode 100644 index 000000000..ed464ee4d --- /dev/null +++ b/packages/runtime/terminal-processes.ts @@ -0,0 +1,363 @@ +/** + * What the host can observe about processes and terminals + * (architecture.md §Interactive terminal grids, "there is no implicit grid + * timeout"). + * + * A terminal grid may not report a pane settled, admit the next launch into it, + * or let the document continue while something a launch started can still act. + * Deciding that is not a matter of having sent a signal: a PID, a successful + * delivery, an attach client going away and an elapsed timeout each prove + * nothing. What proves it is asking the kernel — is this process still there, + * is anything still descended from it, is anything still in its process group, + * does anything still hold its terminal open — and getting "no" to all four. + * + * That asking is host-specific, so it lives behind this seam. `ps` and `lsof` + * are what a POSIX host has; a host with a cheaper primitive replaces the + * handler without touching what a quiescence proof consists of, and a host that + * can observe none of it refuses rather than guessing. The refusal matters as + * much as the answers: a grid that cannot establish these facts is a grid whose + * teardown failed, and the document stops. + * + * Nothing here decides policy. It reports, and the caller — a pane worker + * finishing one launch, a provider tearing a grid down — decides what the + * report means. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { execFile } from "node:child_process"; +import process from "node:process"; + +/** One process, as the host's table describes it. */ +export interface ProcessFacts { + readonly pid: number; + readonly ppid: number; + /** The process group. A launch's group is what its job control acts on. */ + readonly pgid: number; + /** `ttys002`, or `??` for a process with no controlling terminal. */ + readonly tty: string; + /** + * The controlling terminal's foreground process group, or -1. + * + * This is how a shell's job control is observed from outside, rather than + * inferred from what it printed. + */ + readonly tpgid: number; + readonly command: string; +} + +/** What a signal delivery established, which is not the same as what it did. */ +export type SignalDelivery = + /** The kernel accepted it. The process was there to receive it. */ + | "delivered" + /** There was no such process. Gone is the outcome a signal was asking for. */ + | "absent" + /** It could not be delivered. This says nothing about whether it is gone. */ + | "refused"; + +export type TerminalSignal = "SIGINT" | "SIGTERM" | "SIGHUP" | "SIGKILL"; + +export interface TerminalProcessHandler { + /** Every process the host can see, in one consistent reading. */ + table(): Operation; + /** + * Every process holding this terminal device open. + * + * The device is a path — `/dev/ttys002`. An empty answer is the fact a + * teardown is looking for; a host that cannot enumerate holders must refuse + * rather than answer empty, because "nobody" and "I cannot see" are the two + * answers a quiescence proof must never confuse. + */ + holders(device: string): Operation; + /** Send one signal, and say what that established. */ + deliver(pid: number, signal: TerminalSignal): Operation; + /** Whether the kernel still knows this pid. */ + reachable(pid: number): Operation; +} + +export const TERMINAL_PROCESSES_API = "runtime.terminalProcesses"; + +export const TERMINAL_PROCESSES_UNAVAILABLE = + "this host cannot observe processes or terminal holders, so it cannot prove " + + "that a terminal pane is free. `xmd run` on a POSIX host installs the " + + "observer; a host that installs none refuses rather than reporting a pane " + + "quiet it has not checked."; + +export class TerminalProcessesUnavailableError extends Error { + override name = "TerminalProcessesUnavailableError"; + constructor(message: string = TERMINAL_PROCESSES_UNAVAILABLE) { + super(message); + } +} + +/** + * The observation surface. Its own default refuses every question. + * + * Refusing is the safe answer: every caller here is deciding whether something + * may still be running, and a host that cannot see has not established that + * nothing is. + */ +export const TerminalProcesses: Api = createApi( + TERMINAL_PROCESSES_API, + { + // deno-lint-ignore require-yield + *table(): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *holders(_device: string): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *deliver(_pid: number, _signal: TerminalSignal): Operation { + throw new TerminalProcessesUnavailableError(); + }, + // deno-lint-ignore require-yield + *reachable(_pid: number): Operation { + throw new TerminalProcessesUnavailableError(); + }, + }, +); + +export function processTable(): Operation { + return TerminalProcesses.operations.table(); +} + +export function terminalHolders(device: string): Operation { + return TerminalProcesses.operations.holders(device); +} + +export function deliverSignal(pid: number, signal: TerminalSignal): Operation { + return TerminalProcesses.operations.deliver(pid, signal); +} + +export function processReachable(pid: number): Operation { + return TerminalProcesses.operations.reachable(pid); +} + +/** + * Every process below `pid` by parent links, in one reading of the table. + * + * Read from a snapshot rather than the live kernel on purpose: a child that is + * killed reparents to init, so a table taken after the first signal no longer + * says who its children were. The snapshot has to be older than the signal. + */ +export function descendantsOf( + table: readonly ProcessFacts[], + pid: number, +): readonly ProcessFacts[] { + const found: ProcessFacts[] = []; + const seen = new Set([pid]); + const frontier = [pid]; + while (frontier.length > 0) { + const parent = frontier.pop(); + for (const row of table) { + if (row.ppid === parent && !seen.has(row.pid)) { + seen.add(row.pid); + found.push(row); + frontier.push(row.pid); + } + } + } + return found; +} + +/** Every process in one process group, in one reading of the table. */ +export function groupMembers( + table: readonly ProcessFacts[], + pgid: number, +): readonly ProcessFacts[] { + return table.filter((row) => row.pgid === pgid); +} + +/** + * Who a launch is accountable for, taken before anything is signalled. + * + * Order matters and is the whole point: after the first signal a killed child's + * children are reparented, so a snapshot taken then would name fewer processes + * than the launch actually started. + */ +export interface PaneOccupants { + /** The child the launch started. */ + readonly child: number; + /** Everything descended from it when the snapshot was taken. */ + readonly descendants: readonly number[]; + /** Everything sharing its process group when the snapshot was taken. */ + readonly group: readonly number[]; + /** The pane's terminal device, when the host could name one. */ + readonly device?: string; +} + +/** Take that snapshot from one reading of the table. */ +export function paneOccupants( + table: readonly ProcessFacts[], + child: number, + device?: string, +): PaneOccupants { + const facts = table.find((row) => row.pid === child); + const descendants = descendantsOf(table, child).map((row) => row.pid); + const group = + facts === undefined + ? [] + : groupMembers(table, facts.pgid) + .map((row) => row.pid) + .filter((pid) => pid !== child); + return { + child, + descendants, + group, + ...(device === undefined ? {} : { device }), + }; +} + +/** What is still there, out of everything a launch was accountable for. */ +export interface PaneQuiescence { + /** True only when nothing below is still there. */ + readonly quiet: boolean; + /** Snapshot members the kernel still knows. */ + readonly running: readonly number[]; + /** Processes still holding the pane's terminal open. */ + readonly holding: readonly number[]; +} + +/** + * Ask whether everything that snapshot named has stopped, and whether anything + * still holds the pane's terminal. + * + * Both questions, every time. A pane whose child is gone but whose terminal + * something else still holds is not a pane the next launch may have, and a pane + * nobody holds whose process group still has a member in it is not one either. + */ +export function establishQuiescence(occupants: PaneOccupants): Operation { + return (function* (): Operation { + const running: number[] = []; + for (const pid of [occupants.child, ...occupants.descendants, ...occupants.group]) { + if (running.includes(pid)) { + continue; + } + if (yield* processReachable(pid)) { + running.push(pid); + } + } + // Asked even when processes remain, so one report says everything that is + // still true rather than the first thing that was. + const holding = + occupants.device === undefined ? [] : [...(yield* terminalHolders(occupants.device))]; + return { quiet: running.length === 0 && holding.length === 0, running, holding }; + })(); +} + +/** + * Install the POSIX observer: `ps` for the table, `lsof` for terminal holders. + * + * `ps` rather than `/proc`, because macOS is a supported foreground host. The + * `lsof` sweep is the expensive half and grows with the process count, which is + * why it is behind this seam: a host with a cheaper way to enumerate holders + * replaces the handler and changes nothing about what has to be established. + */ +export function* installPosixTerminalProcesses(): Operation { + yield* TerminalProcesses.around( + { + *table(): Operation { + const output = yield* until(run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="])); + return readTable(output); + }, + *holders([device]): Operation { + // `lsof -t` answers with pids and nothing else, and exits non-zero when + // nobody holds the file — which is an answer, not a failure. + const output = yield* until(run("lsof", ["-t", device])); + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)) + .map(Number); + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + try { + process.kill(pid, signal); + return "delivered"; + } catch (error) { + // Gone already is the outcome the signal was asking for. Anything + // else is a delivery that did not happen, and is not evidence that + // the process stopped. + return noSuchProcess(error) ? "absent" : "refused"; + } + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + try { + // Signal 0 delivers nothing: it asks the kernel whether the pid is + // reachable, which is the whole question here. + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + }, + { at: "min" }, + ); +} + +/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +function readTable(output: string): readonly ProcessFacts[] { + const rows: ProcessFacts[] = []; + for (const line of output.split("\n")) { + const row = readRow(line); + if (row !== undefined) { + rows.push(row); + } + } + return rows; +} + +function readRow(line: string): ProcessFacts | undefined { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); + if (match === null) { + return undefined; + } + const [, pid, ppid, pgid, tty, tpgid, command] = match; + if ( + pid === undefined || + ppid === undefined || + pgid === undefined || + tty === undefined || + tpgid === undefined || + command === undefined + ) { + return undefined; + } + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + tty, + tpgid: Number(tpgid), + command, + }; +} + +function run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile(command, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + // A non-zero status with output is an answer: `lsof -t` exits 1 when + // nothing holds the file. A failure to run the tool at all is not. + if (error && !("code" in error && typeof error.code === "number")) { + reject(error); + return; + } + resolve(stdout); + }); + }); +} + +function noSuchProcess(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + Reflect.get(error, "code") === "ESRCH" + ); +} diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts new file mode 100644 index 000000000..7569eace2 --- /dev/null +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -0,0 +1,241 @@ +/** + * Tier TP — what the host may claim about a terminal pane + * (architecture.md §Interactive terminal grids). + * + * A pane is free when nothing a launch started can still act in it. These rows + * are about the difference between establishing that and assuming it: a signal + * that was delivered, a process that has gone while its children have not, a + * terminal nobody is descended from but somebody still holds open, and a host + * that cannot see any of it and must say so instead of answering "quiet". + * + * The reading half — `ps` and `lsof` — is exercised against this process, which + * is a real process with a real parent and a real group. The deciding half is + * exercised against a substituted handler, because a row about "a descendant is + * still running" must not depend on this machine having one. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { + descendantsOf, + establishQuiescence, + groupMembers, + installPosixTerminalProcesses, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + terminalHolders, +} from "../terminal-processes.ts"; +import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; + +/** A table written by hand, so a row can describe a machine it is not on. */ +function table(rows: readonly Partial[]): readonly ProcessFacts[] { + return rows.map((row) => ({ + pid: row.pid ?? 0, + ppid: row.ppid ?? 1, + pgid: row.pgid ?? row.pid ?? 0, + tty: row.tty ?? "??", + tpgid: row.tpgid ?? -1, + command: row.command ?? "fake", + })); +} + +interface Substitute { + /** Pids the kernel still knows. */ + running?: readonly number[]; + /** Pids still holding the device open, by device. */ + holding?: Record; + /** Recorded, so a row can say what was asked rather than what was done. */ + asked?: string[]; +} + +/** A host whose answers a row decides, in place of one it cannot control. */ +function useSubstitute(options: Substitute): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table(): Operation { + return []; + }, + // deno-lint-ignore require-yield + *holders([device]): Operation { + options.asked?.push(`holders:${device}`); + return options.holding?.[device] ?? []; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]: [number, TerminalSignal]): Operation { + options.asked?.push(`deliver:${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + options.asked?.push(`reachable:${pid}`); + return (options.running ?? []).includes(pid); + }, + }, + { at: "min" }, + ); +} + +describe("Tier TP — proving a terminal pane is free", () => { + it("TP1: a host that installs no observer refuses every question", function* () { + for (const ask of [ + () => processTable(), + () => terminalHolders("/dev/ttys001"), + () => processReachable(process.pid), + ]) { + let message = ""; + try { + yield* ask(); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + // Not "nothing is running" — a host that cannot see has established + // nothing, and answering emptily would be answering for a pane it never + // looked at. + expect(message).toBe(TERMINAL_PROCESSES_UNAVAILABLE); + } + }); + + it("TP2: the POSIX observer reads this process out of the real table", function* () { + yield* installPosixTerminalProcesses(); + + const rows = yield* processTable(); + const self = rows.find((row) => row.pid === process.pid); + expect(self).toBeDefined(); + expect(self?.ppid).toBe(process.ppid); + // A real reading, not a stub: this process is in the group it says it is. + const group = self === undefined ? [] : groupMembers(rows, self.pgid); + expect(group.some((row) => row.pid === process.pid)).toBe(true); + // And the kernel agrees this process exists, while a pid nothing can own + // does not. + expect(yield* processReachable(process.pid)).toBe(true); + expect(yield* processReachable(2 ** 30)).toBe(false); + }); + + it("TP3: descendants come from the snapshot, not from parent links after a kill", function* () { + // A child, a grandchild, and a sibling that is not below the child at all. + const rows = table([ + { pid: 100, ppid: 1, pgid: 100 }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 300, ppid: 200, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + expect(descendantsOf(rows, 100).map((row) => row.pid)).toEqual([200, 300]); + expect(descendantsOf(rows, 400)).toEqual([]); + // The same table after a kill reparents the grandchild to init. Read then, + // it would name nobody — which is why the snapshot has to precede the + // signal rather than follow it. + const reparented = table([ + { pid: 300, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + expect(descendantsOf(reparented, 100)).toEqual([]); + }); + + it("TP4: a snapshot names the child, its descendants and its group", function* () { + const rows = table([ + { pid: 100, ppid: 1, pgid: 100, tty: "ttys003" }, + { pid: 200, ppid: 100, pgid: 100 }, + { pid: 250, ppid: 1, pgid: 100 }, + { pid: 400, ppid: 1, pgid: 400 }, + ]); + + const occupants = paneOccupants(rows, 100, "/dev/ttys003"); + expect(occupants.child).toBe(100); + expect(occupants.descendants).toEqual([200]); + // The group member that is not a descendant is named too, and the child + // itself is not repeated into it. + expect(occupants.group).toEqual([200, 250]); + expect(occupants.device).toBe("/dev/ttys003"); + }); + + it("TP5: quiet means every one of them is gone and nobody holds the terminal", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], holding: {}, asked }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(true); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([]); + }); + // Every member was asked about, and so was the terminal. A proof that + // checked the child alone would pass a pane its grandchild is still in. + expect(asked).toEqual([ + "reachable:100", + "reachable:200", + "reachable:250", + "holders:/dev/ttys003", + ]); + }); + + it("TP6: a descendant or a group member still running is not quiet", function* () { + for (const [what, running] of [ + ["the child", [100]], + ["a descendant", [200]], + ["a group member", [250]], + ] as const) { + yield* scoped(function* () { + yield* useSubstitute({ running }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [250], + device: "/dev/ttys003", + }); + expect(`${what}: ${quiescence.quiet}`).toBe(`${what}: false`); + expect(`${what}: ${quiescence.running.join()}`).toBe(`${what}: ${running.join()}`); + }); + } + }); + + it("TP7: a terminal somebody still holds is not quiet, whoever they are", function* () { + // Nothing the launch started is left, and the pane is still not free: + // something outside the snapshot has the terminal open. + yield* useSubstitute({ running: [], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [], + group: [], + device: "/dev/ttys003", + }); + expect(quiescence.quiet).toBe(false); + expect(quiescence.running).toEqual([]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP8: everything still true is reported, not just the first thing", function* () { + yield* useSubstitute({ running: [200], holding: { "/dev/ttys003": [999] } }); + const quiescence = yield* establishQuiescence({ + child: 100, + descendants: [200], + group: [], + device: "/dev/ttys003", + }); + // A caller deciding what to escalate needs both, so neither short-circuits + // the other. + expect(quiescence.running).toEqual([200]); + expect(quiescence.holding).toEqual([999]); + }); + + it("TP9: a pane with no terminal device asks nobody about one", function* () { + const asked: string[] = []; + yield* scoped(function* () { + yield* useSubstitute({ running: [], asked }); + const occupants: PaneOccupants = { child: 100, descendants: [], group: [] }; + const quiescence = yield* establishQuiescence(occupants); + expect(quiescence.quiet).toBe(true); + }); + expect(asked).toEqual(["reachable:100"]); + }); +}); From d1a53d7e6e3be04f1402abdba33d2129d9a5d778 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:15:27 -0400 Subject: [PATCH 24/62] =?UTF-8?q?=E2=9C=A8=20Lay=20a=20tmux=20window=20out?= =?UTF-8?q?=20in=20the=20authored=20order=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `select-layout tiled` picks its own column count from the window's dimensions, so the same four panes are 2×2 in one terminal and 4×1 in another. An authored `columns` has to be told to tmux rather than asked of it. `packages/cli/src/terminal/layout.ts` writes the description tmux prints in `#{window_layout}` and accepts back: a checksum, then a tree of cells sized row-major from the pane count and the authored column count. A final row with fewer panes than columns spans the row, because tmux has no empty cells and the author wrote panes rather than a rectangle. One thing the string cannot do is place a particular pane — tmux fills the leaves in window-list order and ignores the pane ids they name — so authored order is imposed afterwards by swaps. `swapsInto()` says which, produces none for an order that is already right, and refuses a window that does not hold a pane the author wrote instead of putting some other pane there. Tier TX checks the geometry at four terminal sizes, that the cells tile exactly with one separator between them, that the checksum tracks the tree, and all three swap cases. --- packages/cli/src/terminal/layout.ts | 197 ++++++++++++++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 151 ++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 packages/cli/src/terminal/layout.ts create mode 100644 packages/cli/tests/terminal-grid-tmux.test.ts diff --git a/packages/cli/src/terminal/layout.ts b/packages/cli/src/terminal/layout.ts new file mode 100644 index 000000000..e1fa773f2 --- /dev/null +++ b/packages/cli/src/terminal/layout.ts @@ -0,0 +1,197 @@ +/** + * The authored grid as explicit tmux geometry + * (architecture.md §Interactive terminal grids). + * + * `select-layout tiled` picks its own column count from the window's + * dimensions, so it cannot implement a `columns` the author wrote: the same + * four panes become 2×2 in one terminal and 4×1 in another. A layout string + * can. tmux accepts the same description it prints in `#{window_layout}` — a + * checksum, then a tree of cells where `{…}` lays children left to right and + * `[…]` top to bottom, each leaf naming a pane id. + * + * So every cell is sized here, row-major from the pane count and `columns`, and + * tmux is told rather than asked. A final row with fewer panes than columns + * spans the row, because tmux has no empty cells and the author wrote panes + * rather than a rectangle. + * + * One thing the string cannot do is place a *particular* pane: tmux fills the + * leaves in window-list order and ignores the pane ids they name. Authored + * order is imposed afterwards, by swapping panes into position — which is why + * `swapsInto()` lives here beside the geometry rather than in the provider. + */ + +/** One pane's rectangle, in tmux's character coordinates. */ +export interface LayoutCell { + readonly ordinal: number; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * Split `total` into `count` parts, leaving one column or row between them for + * tmux's separator. The remainder goes to the leftmost or topmost parts, which + * is what tmux itself does. + */ +function partition(total: number, count: number): number[] { + const available = total - (count - 1); + const base = Math.floor(available / count); + const extra = available - base * count; + return Array.from({ length: count }, (_, index) => base + (index < extra ? 1 : 0)); +} + +/** The row-major rectangles for `count` panes in `columns` columns. */ +export function rowMajorCells( + width: number, + height: number, + columns: number, + count: number, +): readonly LayoutCell[] { + const rows = Math.ceil(count / columns); + const heights = partition(height, rows); + const cells: LayoutCell[] = []; + let top = 0; + for (let row = 0; row < rows; row++) { + const inRow = Math.min(columns, count - row * columns); + const widths = partition(width, inRow); + const rowHeight = heights[row] ?? 0; + let left = 0; + for (let column = 0; column < inRow; column++) { + const cellWidth = widths[column] ?? 0; + cells.push({ + ordinal: row * columns + column, + left, + top, + width: cellWidth, + height: rowHeight, + }); + left += cellWidth + 1; + } + top += rowHeight + 1; + } + return cells; +} + +/** tmux's `layout_checksum`, so the string is accepted as one of its own. */ +function checksum(layout: string): string { + let sum = 0; + for (let index = 0; index < layout.length; index++) { + sum = ((sum >> 1) + ((sum & 1) << 15)) & 0xffff; + sum = (sum + layout.charCodeAt(index)) & 0xffff; + } + return sum.toString(16).padStart(4, "0"); +} + +/** + * The layout string that gives ordinal `i` the cell `paneIds[i]` names. + * + * Pane ids are the numeric part of tmux's `%N`. tmux ignores which pane each + * leaf names — see `swapsInto()` — but the string still has to name real ones + * for tmux to accept it. + */ +export function layoutString( + width: number, + height: number, + columns: number, + paneIds: readonly number[], +): string { + const cells = rowMajorCells(width, height, columns, paneIds.length); + const rows = Math.ceil(paneIds.length / columns); + const rowStrings: string[] = []; + for (let row = 0; row < rows; row++) { + const inRow = cells.filter((cell) => Math.floor(cell.ordinal / columns) === row); + const leaves = inRow.map( + (cell) => `${cell.width}x${cell.height},${cell.left},${cell.top},${paneIds[cell.ordinal]}`, + ); + const first = inRow[0]; + if (first === undefined) { + continue; + } + rowStrings.push( + leaves.length === 1 + ? (leaves[0] ?? "") + : `${width}x${first.height},0,${first.top}{${leaves.join(",")}}`, + ); + } + const body = + rowStrings.length === 1 + ? (rowStrings[0] ?? "") + : `${width}x${height},0,0[${rowStrings.join(",")}]`; + return `${checksum(body)},${body}`; +} + +/** One swap: put the pane now at `from` into the position `to` holds. */ +export interface PaneSwap { + readonly from: number; + readonly to: number; +} + +/** + * The swaps that turn tmux's window order into the authored one. + * + * `present[i]` is the pane id tmux currently has in position `i`; `wanted[i]` is + * the pane id ordinal `i` was authored for. Selection sort, because each swap + * exchanges two positions and there is no cheaper honest way to say it: the + * result is the shortest sequence that leaves every position holding the pane + * the author put there. + * + * An already-correct order produces no swaps at all, which is the case a + * provider must not do work for. + */ +export function swapsInto( + present: readonly number[], + wanted: readonly number[], +): readonly PaneSwap[] { + const order = [...present]; + const swaps: PaneSwap[] = []; + for (let position = 0; position < wanted.length; position++) { + const target = wanted[position]; + if (target === undefined || order[position] === target) { + continue; + } + const found = order.indexOf(target, position); + if (found === -1) { + // The window does not hold the pane this ordinal was authored for, so no + // sequence of swaps produces the authored order. Saying so is the honest + // answer; swapping anyway would place a pane the author did not write. + throw new Error(`pane ${target} is not in this window, so ordinal ${position} cannot be set`); + } + const displaced = order[position]; + if (displaced === undefined) { + continue; + } + order[position] = target; + order[found] = displaced; + swaps.push({ from: found, to: position }); + } + return swaps; +} + +/** Whether observed geometry is the row-major placement `columns` describes. */ +export function placementProblems( + observed: readonly LayoutCell[], + columns: number, +): readonly string[] { + const problems: string[] = []; + const byOrdinal = [...observed].sort((left, right) => left.ordinal - right.ordinal); + for (const cell of byOrdinal) { + const column = cell.ordinal % columns; + const above = byOrdinal.find((other) => other.ordinal === cell.ordinal - columns); + const leftOf = + column > 0 ? byOrdinal.find((other) => other.ordinal === cell.ordinal - 1) : undefined; + if (above !== undefined && cell.top !== above.top + above.height + 1) { + problems.push(`pane ${cell.ordinal} is not directly below pane ${above.ordinal}`); + } + if ( + leftOf !== undefined && + !(cell.left === leftOf.left + leftOf.width + 1 && cell.top === leftOf.top) + ) { + problems.push(`pane ${cell.ordinal} is not directly right of pane ${leftOf.ordinal}`); + } + if (column === 0 && cell.left !== 0) { + problems.push(`pane ${cell.ordinal} should start a row at the left edge`); + } + } + return problems; +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts new file mode 100644 index 000000000..b931e347b --- /dev/null +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -0,0 +1,151 @@ +/** + * Tier TX — the tmux terminal-grid provider + * (architecture.md §Interactive terminal grids, issue #732). + * + * The provider is the one production presentation for a grid, and these rows + * hold it to the two things a document can observe about it: that the panes end + * up where the author put them, and that nothing tmux-shaped leaks out of the + * closure. Core lifecycle semantics are the controlled provider's to prove — + * this tier does not restate them. + * + * Geometry first. `select-layout tiled` picks its own column count from the + * window's dimensions, so the same four panes would be 2×2 in one terminal and + * 4×1 in another; an authored `columns` has to be told to tmux rather than + * asked of it. These rows check the string that tells it, at sizes a reader + * would actually have. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { + layoutString, + placementProblems, + rowMajorCells, + swapsInto, +} from "../src/terminal/layout.ts"; +import type { LayoutCell } from "../src/terminal/layout.ts"; + +/** The cells a layout string describes, read back out of it. */ +function readCells(layout: string): LayoutCell[] { + const cells: LayoutCell[] = []; + // `WxH,left,top,paneId` — the leaves, in the order the string lists them, + // which is the order tmux fills them in. + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + let ordinal = 0; + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + ordinal: ordinal++, + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +describe("Tier TX — the tmux grid's geometry", () => { + it("TX1: an authored column count survives every terminal size", function* () { + // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would + // have made the wide one 4×1 and the tall one 1×4. + for (const [width, height] of [ + [80, 24], + [200, 24], + [80, 60], + [211, 51], + ] as const) { + const cells = rowMajorCells(width, height, 2, 4); + const rows = new Set(cells.map((cell) => cell.top)); + const columns = new Set(cells.map((cell) => cell.left)); + const size = `${width}x${height}`; + expect(`${size}: ${rows.size} rows`).toBe(`${size}: 2 rows`); + expect(`${size}: ${columns.size} columns`).toBe(`${size}: 2 columns`); + expect(`${size}: ${placementProblems(cells, 2).join("; ")}`).toBe(`${size}: `); + } + }); + + it("TX2: the cells tile the terminal exactly, with one separator between", function* () { + const cells = rowMajorCells(80, 24, 2, 4); + // Two panes and one separator span the width; two rows and one separator + // span the height. A gap or an overlap would be a grid the reader can see + // is wrong. + const top = cells.filter((cell) => cell.top === 0); + expect(top.reduce((total, cell) => total + cell.width, 0) + (top.length - 1)).toBe(80); + const left = cells.filter((cell) => cell.left === 0); + expect(left.reduce((total, cell) => total + cell.height, 0) + (left.length - 1)).toBe(24); + }); + + it("TX3: a short final row spans it, because tmux has no empty cells", function* () { + // Three panes in two columns: two above, one below across the whole width. + const cells = rowMajorCells(80, 24, 2, 3); + expect(cells.length).toBe(3); + const last = cells[2]; + expect(last?.left).toBe(0); + expect(last?.width).toBe(80); + expect(placementProblems(cells, 2)).toEqual([]); + }); + + it("TX4: one pane and one row need no tree at all", function* () { + expect(rowMajorCells(80, 24, 1, 1)).toEqual([ + { ordinal: 0, left: 0, top: 0, width: 80, height: 24 }, + ]); + // A single row is written flat: nesting one row inside a column tree is a + // layout tmux accepts and a reader would never see the point of. + const single = layoutString(80, 24, 2, [1, 2]); + expect(single).not.toContain("["); + expect(single).toContain("{"); + }); + + it("TX5: the string is one tmux accepts — checksum, then the tree", function* () { + const layout = layoutString(80, 24, 2, [1, 2, 3, 4]); + const [sum, ...rest] = layout.split(","); + expect(sum).toMatch(/^[0-9a-f]{4}$/); + // Rows top to bottom, columns left to right, and every authored pane named. + const body = rest.join(","); + expect(body.startsWith("80x24,0,0[")).toBe(true); + for (const pane of [1, 2, 3, 4]) { + expect(body).toContain(`,${pane}`); + } + // And the geometry it describes is the geometry that was asked for. + expect(placementProblems(readCells(layout), 2)).toEqual([]); + }); + + it("TX6: the checksum changes with the tree, so a stale string is rejected", function* () { + const four = layoutString(80, 24, 2, [1, 2, 3, 4]); + const swapped = layoutString(80, 24, 2, [1, 2, 4, 3]); + expect(four.split(",")[0]).not.toBe(swapped.split(",")[0]); + }); + + it("TX7: authored order is imposed by swaps, because tmux ignores leaf ids", function* () { + // tmux fills the leaves in window-list order, so a window holding panes in + // the wrong order needs them moved rather than re-described. + const swaps = swapsInto([3, 1, 4, 2], [1, 2, 3, 4]); + const order = [3, 1, 4, 2]; + for (const swap of swaps) { + const from = order[swap.from]; + const to = order[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + order[swap.to] = from; + order[swap.from] = to; + } + expect(order).toEqual([1, 2, 3, 4]); + }); + + it("TX8: an order that is already authored is left alone", function* () { + expect(swapsInto([1, 2, 3, 4], [1, 2, 3, 4])).toEqual([]); + }); + + it("TX9: a window missing an authored pane refuses rather than placing another", function* () { + let message = ""; + try { + swapsInto([1, 2, 9], [1, 2, 3]); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + expect(message).toContain("pane 3 is not in this window"); + }); +}); From 871a0e81ca26f43d22197f8c8f6bc7c2e2a1f159 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 22:40:15 -0400 Subject: [PATCH 25/62] =?UTF-8?q?=E2=9C=A8=20Give=20a=20terminal=20pane=20?= =?UTF-8?q?a=20worker=20and=20a=20private=20channel=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pane's initial process is a worker that owns the pane's terminal for the pane's whole life, and everything it does is asked of it over a socket only this invocation can reach. **The channel.** One directory per grid, mode 0700, directly under `$TMPDIR` because a Unix socket path is capped at 104 bytes and a directory named after a repository path spends most of that first. Inside it, one socket and one mode-0600 token per pane, both written before any pane exists, so a worker that starts finds its socket listening rather than racing it. Admission is the whole boundary: a connection is admitted when its first frame is a `hello` naming this pane's ordinal and carrying this pane's token, and a connection that says anything else, says it late, names another ordinal, or arrives after that pane is admitted is closed without being answered. The token is single-use because the worker removes the file as it reads it. **What crosses it.** The exact argv vector, working directory and environment. tmux has a command parser, and a command parser is a place where an argument can become two arguments, or a quote, or a `;`. tmux is told a directory and an ordinal, and that is all its parser ever sees. **The worker.** `xmd terminal-worker ` — reusing this executable rather than shipping a second script, which is what makes it work in the compiled distribution. It is in no command table, so it is in no help output and no catalog, and naming it grants nothing: without a pane's single-use token nobody answers. It is dispatched at the entrypoint, before `main()`, and runs under `run()`, because `main()` binds SIGINT to its own shutdown and would exit 130 on the first `^C` typed into the pane — the keystroke the foreground child is supposed to receive. It ignores SIGINT, SIGQUIT and SIGTSTP itself so the child, which gets default dispositions across `exec`, is the one interrupted. **Readiness and settlement, kept apart.** Readiness is the runtime's `spawn` event and nothing earlier; a missing executable delivers `error` instead of it, never after it. Settlement is the escalation and sweep that follow — a child that exited on its own may have left descendants in its group or an orphan holding the terminal, and the pane is not free until neither is true. `exited` is reported only after that, so the next launch is refused while a sweep that would reach it is still running. One hazard the evidence found: the settlement sweeps the process group it is in, and a worker that was not a session leader would be sweeping whatever started it. In a pane tmux makes it one — but a settlement one signal away from killing the run that started the grid is not something to leave to the topology being what it should be, so the sweep now never reaches an ancestor of the worker. Tier TW proves it with a real worker process over a real socket and no tmux at all: the modes, the removal, the handshake, three ways of failing it, awkward argv crossing intact, a child that never starts, one-live-child exclusivity, display written and never read, and shutdown's final sweep. --- packages/cli/src/compiled.ts | 9 +- packages/cli/src/deno.ts | 9 +- packages/cli/src/terminal/pane-channel.ts | 196 ++++++++++ packages/cli/src/terminal/pane-child.ts | 284 +++++++++++++++ packages/cli/src/terminal/pane-protocol.ts | 160 +++++++++ packages/cli/src/terminal/pane-worker.ts | 258 ++++++++++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 336 ++++++++++++++++++ 7 files changed, 1250 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/terminal/pane-channel.ts create mode 100644 packages/cli/src/terminal/pane-child.ts create mode 100644 packages/cli/src/terminal/pane-protocol.ts create mode 100644 packages/cli/src/terminal/pane-worker.ts diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 3a94a6b1e..33eceb748 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -19,6 +19,7 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; @@ -50,7 +51,13 @@ const UPGRADE = compiledUpgradeAssembly({ }); // Before anything public is parsed, and absent from every public surface. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index 3bc6b901e..d882a3c01 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -22,6 +22,7 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; @@ -66,7 +67,13 @@ const UPGRADE: UpgradeAssembly = { // The internal helper mode runs before anything public is parsed. It is not a // command: it appears in no help and in no public grammar, and a caller who did // not select it gets the ordinary command line unchanged. -if (isCredentialHelperMode(process.argv.slice(2))) { +const paneWorker = paneWorkerInvocation(process.argv.slice(2)); +if (paneWorker !== undefined) { + // Not `main()`: it would bind SIGINT to its own shutdown and exit 130 on the + // first `^C` typed into the pane, which is the keystroke the foreground child + // is supposed to receive. + await runPaneWorkerProcess(paneWorker); +} else if (isCredentialHelperMode(process.argv.slice(2))) { await main(() => runCredentialHelper(process.argv.slice(2))); } else { await main(function* (args) { diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts new file mode 100644 index 000000000..77ac3eee0 --- /dev/null +++ b/packages/cli/src/terminal/pane-channel.ts @@ -0,0 +1,196 @@ +/** + * The parent's end of one grid's private worker channels + * (architecture.md §Interactive terminal grids). + * + * One directory per grid, mode 0700, under `$TMPDIR` so the socket paths stay + * inside the 104-byte cap a Unix socket has. Inside it, one socket and one + * mode-0600 token per pane, both written *before* any pane exists — a worker + * that starts finds its socket already listening rather than racing it. + * + * Admission is the whole security boundary. A connection is admitted when its + * first frame is a `hello` naming this pane's ordinal and carrying this pane's + * token; a connection that says anything else, says it too late, names another + * ordinal, or arrives after that pane is already admitted is closed without + * being answered. The token is single-use by construction — the worker removes + * the file as it reads it — so a second reader finds nothing to present. + * + * Everything here dies with the scope: sockets destroyed, servers closed, and + * the directory with its tokens removed, whichever way the grid ended. + */ + +import { randomBytes } from "node:crypto"; +import net from "node:net"; +import type { Server, Socket } from "node:net"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + ensure, + createSignal, + race, + resource, + sleep, + spawn, + until, + withResolvers, +} from "effection"; +import type { Operation } from "effection"; +import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { chmod } from "node:fs/promises"; +import { + FromWorkerSchema, + paneSocketPath, + paneTokenPath, + readFrames, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Hello, ToWorker } from "./pane-protocol.ts"; + +/** How long a connection has to present its `hello` before it is dropped. */ +const HELLO_GRACE_MS = 10_000; + +/** The parent's end of one admitted worker. */ +export interface PaneLink { + readonly ordinal: number; + /** What the worker said about the pane it woke up in. */ + readonly hello: Hello; + send(message: ToWorker): Operation; + /** The next frame, or `undefined` once the worker's connection closed. */ + next(): Operation; + connected(): boolean; +} + +export interface PaneChannels { + /** The private directory, which tmux is told and nothing else learns. */ + readonly directory: string; + /** The admitted worker for `ordinal`; waits for its `hello`. */ + link(ordinal: number): Operation; + /** Connections closed without admission, for a diagnostic to name. */ + refusals(): readonly string[]; +} + +interface Slot { + readonly waiting: ReturnType>; + admitted: boolean; +} + +/** + * Open one grid's private directory and listen for `count` workers. + * + * The directory is created 0700 and removed with the scope. A host whose + * temporary directory is world-writable still gets a private grid, because the + * mode is set on the directory this creates rather than inherited from it. + */ +export function usePaneChannels(count: number): Operation { + return resource(function* (provide) { + // Directly under `$TMPDIR`: a socket path is capped at 104 bytes, and a + // directory named after a repository path spends most of that before the + // socket name begins. + const directory = path.join(os.tmpdir(), `xmd-grid-${randomBytes(6).toString("hex")}`); + yield* ensureDir(directory); + yield* until(chmod(directory, 0o700)); + yield* ensure(() => rm(directory, { recursive: true, force: true })); + + const tokens = new Map(); + const slots = new Map(); + const servers: Server[] = []; + const live = new Set(); + const refusals: string[] = []; + const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); + + yield* ensure(() => { + for (const socket of live) { + socket.destroy(); + } + for (const server of servers) { + server.close(); + } + }); + + // Subscribed before a single server listens, so no arrival is missed. + const incoming = yield* arrivals; + + for (let ordinal = 0; ordinal < count; ordinal++) { + const token = randomBytes(16).toString("hex"); + tokens.set(ordinal, token); + slots.set(ordinal, { waiting: withResolvers(), admitted: false }); + yield* writeTextFile(paneTokenPath(directory, ordinal), token); + yield* until(chmod(paneTokenPath(directory, ordinal), 0o600)); + + const server = net.createServer((socket) => { + live.add(socket); + socket.once("close", () => live.delete(socket)); + arrivals.send({ ordinal, socket }); + }); + servers.push(server); + const listening = withResolvers(); + server.once("error", (error: Error) => listening.reject(error)); + server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); + yield* listening.operation; + } + + function* admit(ordinal: number, socket: Socket): Operation { + const slot = slots.get(ordinal); + const token = tokens.get(ordinal); + const frames = readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const first = yield* race([frames.next(), silence()]); + if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { + refusals.push(`pane ${ordinal}: a connection that did not say hello`); + socket.destroy(); + return; + } + const hello = first.value; + if (slot.admitted) { + refusals.push(`pane ${ordinal}: a second connection to an admitted pane`); + socket.destroy(); + return; + } + if (hello.ordinal !== ordinal || hello.token !== token) { + // Deliberately one message for both: an attacker learns nothing from + // which half was wrong. + refusals.push(`pane ${ordinal}: a connection that could not prove it is this pane`); + socket.destroy(); + return; + } + slot.admitted = true; + slot.waiting.resolve({ + ordinal, + hello, + send: (message) => writeFrame(socket, message), + *next() { + const next = yield* frames.next(); + return next.done ? undefined : next.value; + }, + connected: () => !socket.destroyed, + }); + } + + yield* spawn(function* () { + while (true) { + const next = yield* incoming.next(); + if (next.done) { + return; + } + const { ordinal, socket } = next.value; + yield* spawn(() => admit(ordinal, socket)); + } + }); + + yield* provide({ + directory, + *link(ordinal) { + const slot = slots.get(ordinal); + if (slot === undefined) { + throw new Error(`this grid has no pane ${ordinal}`); + } + return yield* slot.waiting.operation; + }, + refusals: () => [...refusals], + }); + }); +} + +/** A connection that has said nothing for long enough to be nobody. */ +function* silence(): Operation> { + yield* sleep(HELLO_GRACE_MS); + return { done: true, value: undefined }; +} diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts new file mode 100644 index 000000000..c0f4f540b --- /dev/null +++ b/packages/cli/src/terminal/pane-child.ts @@ -0,0 +1,284 @@ +/** + * One interactive child in a pane, and what its settlement establishes + * (architecture.md §Interactive terminal grids). + * + * Two facts the pane topology needs kept apart: + * + * - **readiness** is the runtime's `spawn` event and nothing earlier. A pid is + * not it, and neither is a pane that has shown output; a missing executable + * delivers `error` *instead of* `spawn`, never after it. This is what a grid's + * attach barrier waits for. + * - **settlement** is the escalation and the sweep that follow the child, not + * the `exit` event. A child that exited on its own may have left descendants + * in its process group, or an orphan still holding the pane's terminal, and + * the pane is not free for the next launch until neither is true. + * + * The child shares this process's process group deliberately, so `^C` typed in + * the pane reaches it: `detached: true` would `setsid()` it away from the + * pane's controlling terminal, and job control is the point of a pane. The + * worker ignores those signals itself so the child is the one interrupted. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import process from "node:process"; +import { ensure, Err, Ok, resource, sleep, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import { + deliverSignal, + descendantsOf, + groupMembers, + processReachable, + processTable, + terminalHolders, +} from "@executablemd/runtime"; +import type { Settlement } from "./pane-protocol.ts"; + +export interface PaneChildRequest { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Record; +} + +export interface PaneChildOutcome { + exitCode?: number; + signal?: string; +} + +export class PaneStartFailure extends Error { + override name = "PaneStartFailure"; + constructor(readonly code: string) { + super(`the pane's child could not be started (${code})`); + } +} + +export interface PaneChild { + /** `Ok(pid)` once the runtime reports the spawn; `Err` if it never will. */ + readonly started: Operation>; + /** Settles when the child exits. Independent of `started`. */ + readonly exited: Operation; + /** Idempotent: every caller of the one settlement gets the same answer. */ + settle(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start one child with the pane's terminal inherited, and own its settlement. + * + * `tty` is the pane's terminal device, when the worker has one. The sweep needs + * it: a descendant that called `setsid()` and outlived its parent is outside + * the process snapshot, and holding the terminal open is the only way it is + * still observable. + */ +export function usePaneChild( + request: PaneChildRequest, + tty: string | undefined, +): Operation { + return resource(function* (provide) { + const [command, ...args] = request.argv; + if (command === undefined) { + throw new Error("a pane launch names no command"); + } + const started = withResolvers>(); + const exited = withResolvers(); + let child: ChildProcess | undefined; + let outcome: PaneChildOutcome | undefined; + let settling: ReturnType> | undefined; + + function* settle(): Operation { + if (settling) { + return yield* settling.operation; + } + settling = withResolvers(); + try { + const settlement = + child === undefined || child.pid === undefined + ? { method: "exited" as const, quiet: true, swept: [], holders: [] } + : yield* escalate(child, child.pid, tty, () => outcome !== undefined); + settling.resolve(settlement); + return settlement; + } catch (error) { + settling.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + // Registered before the spawn: a halt between acquiring a process and + // registering its cleanup leaks the process. + yield* ensure(function* () { + yield* settle(); + }); + + child = spawnChild(command, args, { + cwd: request.cwd, + env: request.env, + // The whole point of a pane: the child reads this terminal and draws on + // it directly, so nothing between it and the reader can buffer, reorder + // or capture what passes. + stdio: "inherit", + }); + child.once("spawn", () => { + if (child?.pid !== undefined) { + started.resolve(Ok(child.pid)); + } + }); + child.once("error", (error: Error & { code?: string }) => { + started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); + }); + child.once("exit", (code: number | null, signal: string | null) => { + const settled: PaneChildOutcome = {}; + if (code !== null) { + settled.exitCode = code; + } + if (signal !== null) { + settled.signal = signal; + } + outcome = settled; + exited.resolve(settled); + }); + + yield* provide({ started: started.operation, exited: exited.operation, settle }); + }); +} + +/** + * Interrupt, insist, then reach whatever the interrupt left behind. + * + * The snapshot is taken before the first signal, and that order is the whole + * proof: a killed child stops being anyone's parent and its children reparent + * to init, where an ancestry walk no longer finds them. A descendant that left + * the group with `setsid()` is in the snapshot while its parent lives; one + * created after the snapshot is not, and this says so rather than claiming + * otherwise. + */ +function* escalate( + child: ChildProcess, + pid: number, + tty: string | undefined, + hasExited: () => boolean, +): Operation { + const before = yield* processTable(); + // The child shares this process's group, so the group is looked up rather + // than assumed to be the child's own pid. + const group = before.find((row) => row.pid === pid)?.pgid ?? pid; + // Never anything this worker came from. In a pane the worker is the session + // leader, so its group holds nothing above it — but a settlement that could + // reach an ancestor would be one signal away from killing the run that + // started the grid, and that is not a thing to leave to the topology being + // what it should be. + const forebears = ancestorsOf(before, process.pid); + const related = new Map(); + for (const row of descendantsOf(before, pid)) { + if (!forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + for (const row of groupMembers(before, group)) { + if (row.pid !== pid && row.pid !== process.pid && !forebears.has(row.pid)) { + related.set(row.pid, row.pid); + } + } + + let method: Settlement["method"] = "exited"; + if (!hasExited() && (yield* processReachable(pid))) { + method = "interrupted"; + yield* deliverSignal(pid, "SIGINT"); + const left = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, INTERRUPT_GRACE_MS); + if (!left) { + method = "killed"; + const fatal = yield* deliverSignal(pid, "SIGKILL"); + const gone = yield* waitFor(function* () { + return hasExited() || !(yield* processReachable(pid)); + }, KILL_SETTLE_MS); + if (!gone && fatal !== "delivered" && fatal !== "absent") { + throw new Error(`could not establish that process ${pid} stopped: SIGKILL was ${fatal}`); + } + } + } + // Deno's `node:child_process` holds the runtime open on a handle it never + // settles once a signal the child ignored has been delivered. + try { + child.unref(); + } catch { + // Already released. + } + + for (const member of related.keys()) { + yield* deliverSignal(member, "SIGKILL"); + } + yield* waitFor(function* () { + for (const member of related.keys()) { + if (yield* processReachable(member)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const member of related.keys()) { + swept.push({ pid: member, gone: !(yield* processReachable(member)) }); + } + if (!(hasExited() || !(yield* processReachable(pid)))) { + swept.unshift({ pid, gone: false }); + } + + // Whatever still has the pane's terminal open, after everything the snapshot + // named is gone. This is where an escaped `setsid()` orphan is still visible. + const holders = yield* sweepHolders(tty); + const quiet = swept.every((entry) => entry.gone) && holders.every((entry) => entry.gone); + return { method, quiet, child: pid, swept, holders }; +} + +/** Clear the pane's terminal of anything but this worker, and report it. */ +export function* sweepHolders( + tty: string | undefined, +): Operation<{ pid: number; gone: boolean }[]> { + if (tty === undefined || tty === "??") { + return []; + } + const found = (yield* terminalHolders(`/dev/${tty}`)).filter((pid) => pid !== process.pid); + for (const pid of found) { + yield* deliverSignal(pid, "SIGKILL"); + } + yield* waitFor(function* () { + for (const pid of found) { + if (yield* processReachable(pid)) { + return false; + } + } + return true; + }, KILL_SETTLE_MS); + const swept: { pid: number; gone: boolean }[] = []; + for (const pid of found) { + swept.push({ pid, gone: !(yield* processReachable(pid)) }); + } + return swept; +} + +/** This process and everything it descends from, in one reading of the table. */ +function ancestorsOf(table: readonly { pid: number; ppid: number }[], pid: number): Set { + const found = new Set([pid]); + let current = table.find((row) => row.pid === pid); + while (current !== undefined && current.ppid > 0 && !found.has(current.ppid)) { + found.add(current.ppid); + const parent: number = current.ppid; + current = table.find((row) => row.pid === parent); + } + return found; +} + +function* waitFor(condition: () => Operation, limitMs: number): Operation { + const deadline = Date.now() + limitMs; + while (!(yield* condition())) { + if (Date.now() >= deadline) { + return false; + } + yield* sleep(POLL_MS); + } + return true; +} diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts new file mode 100644 index 000000000..d1dc31107 --- /dev/null +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -0,0 +1,160 @@ +/** + * What the parent and one pane worker say to each other, and how + * (architecture.md §Interactive terminal grids). + * + * The channel is invocation-private: one Unix socket per pane, inside a + * mode-0700 directory that exists for one grid. A worker proves which pane it + * is with a token the parent wrote to a mode-0600 file that only that worker + * reads — and removes, so the token is spent the moment it is used. + * + * Everything a launch actually consists of crosses here rather than through + * tmux: the exact argv vector, the working directory and the environment. tmux + * has a command parser, and a command parser is a place where an argument can + * become two arguments, or a quote, or a `;`. What tmux is told instead is a + * directory and an ordinal, which is all its parser ever sees. + * + * Frames are newline-delimited JSON, parsed with a schema on both ends. A frame + * that is not the protocol ends the conversation rather than being interpreted: + * this socket is how one process is asked to start a program with inherited + * terminal streams, so "close to what I expected" is not good enough. + */ + +import { join } from "node:path"; +import type { Socket } from "node:net"; +import { createQueue, withResolvers } from "effection"; +import type { Operation, Queue } from "effection"; +import { z } from "zod"; + +/** What one worker says about the pane it woke up in. */ +export const HelloSchema = z.object({ + type: z.literal("hello"), + ordinal: z.number().int().nonnegative(), + token: z.string(), + pid: z.number().int(), + pgid: z.number().int(), + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: z.string(), + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: z.tuple([z.boolean(), z.boolean(), z.boolean()]), +}); + +/** One process the settlement reached, and what reaching it established. */ +const SweptSchema = z.object({ + pid: z.number().int(), + gone: z.boolean(), +}); + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +export const SettlementSchema = z.object({ + method: z.enum(["exited", "interrupted", "killed"]), + quiet: z.boolean(), + child: z.number().int().optional(), + /** Snapshot members reached during the escalation. */ + swept: z.array(SweptSchema), + /** Anything still holding the pane's terminal after the sweep. */ + holders: z.array(SweptSchema), +}); + +export const FromWorkerSchema = z.discriminatedUnion("type", [ + HelloSchema, + z.object({ type: z.literal("displayed"), seq: z.number().int() }), + /** The runtime's spawn event, and nothing earlier. */ + z.object({ type: z.literal("started"), id: z.string(), pid: z.number().int() }), + z.object({ type: z.literal("start-failed"), id: z.string(), reason: z.string() }), + /** A launch asked for while one is live. */ + z.object({ type: z.literal("busy"), id: z.string() }), + z.object({ + type: z.literal("exited"), + id: z.string(), + exitCode: z.number().int().optional(), + signal: z.string().optional(), + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: SettlementSchema, + }), + z.object({ + type: z.literal("quiet"), + id: z.string().optional(), + settlement: SettlementSchema, + }), + z.object({ type: z.literal("bye"), holders: z.array(SweptSchema) }), +]); + +export const ToWorkerSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("welcome") }), + z.object({ type: z.literal("display"), seq: z.number().int(), text: z.string() }), + z.object({ + type: z.literal("launch"), + id: z.string(), + argv: z.array(z.string()).min(1), + cwd: z.string(), + env: z.record(z.string(), z.string()), + }), + z.object({ type: z.literal("cancel"), id: z.string() }), + z.object({ type: z.literal("shutdown") }), +]); + +export type Hello = z.infer; +export type FromWorker = z.infer; +export type ToWorker = z.infer; +export type Settlement = z.infer; + +/** + * Where one pane's socket and token live. + * + * Short by necessity rather than taste: a Unix socket path is capped at 104 + * bytes, which a temporary directory named after a repository path exceeds. + */ +export function paneSocketPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.sock`); +} + +export function paneTokenPath(directory: string, ordinal: number): string { + return join(directory, `p${ordinal}.token`); +} + +/** + * Feed one socket's bytes into a queue of parsed frames. + * + * A frame that does not parse destroys the socket. There is no partial credit + * on this channel. + */ +export function readFrames(socket: Socket, parse: (value: unknown) => T): Queue { + const queue = createQueue(); + let remainder = ""; + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => { + const lines = (remainder + chunk).split("\n"); + remainder = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) { + continue; + } + try { + queue.add(parse(JSON.parse(line))); + } catch { + socket.destroy(); + } + } + }); + socket.on("close", () => queue.close()); + socket.on("error", () => socket.destroy()); + return queue; +} + +/** Write one frame, and settle once the socket has taken it. */ +export function writeFrame(socket: Socket, message: unknown): Operation { + const written = withResolvers(); + if (socket.destroyed) { + written.resolve(); + return written.operation; + } + socket.write(JSON.stringify(message) + "\n", () => written.resolve()); + return written.operation; +} diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts new file mode 100644 index 000000000..3705862dc --- /dev/null +++ b/packages/cli/src/terminal/pane-worker.ts @@ -0,0 +1,258 @@ +/** + * The persistent pane worker: tmux's initial process in one pane + * (architecture.md §Interactive terminal grids). + * + * It owns the pane's terminal for the pane's whole life, and everything it does + * is asked of it over the private socket — show this text, start this child, + * cancel it, shut down. It never reads the terminal itself, so keystrokes reach + * the foreground child and only the child. + * + * It is the pane's session leader and shares the pane's process group with the + * child, so `^C` on that pane is delivered to both. It handles SIGINT, SIGQUIT + * and SIGTSTP by doing nothing: dispositions reset across `exec`, so the child + * gets the defaults and is the one interrupted. SIGHUP keeps its default — when + * the pane's terminal goes away, so does the worker. + * + * It runs under Effection's `run()` rather than `main()`. `main()` binds SIGINT + * to its own shutdown and exits 130 on the first `^C` typed into the pane — + * which is the exact keystroke the child is supposed to receive. + * + * Nothing here is reachable without the handshake. The worker is started with + * an ordinal and a directory, reads the token only that pane's file holds, + * removes it, and presents it; a worker that cannot do that connects to nothing + * and performs no work at all. + */ + +import net from "node:net"; +import process from "node:process"; +import { readTextFile, rm } from "@effectionx/fs"; +import { run, spawn, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { installPosixTerminalProcesses, processTable } from "@executablemd/runtime"; +import { usePaneChild, sweepHolders } from "./pane-child.ts"; +import type { PaneChild } from "./pane-child.ts"; +import { + paneSocketPath, + paneTokenPath, + readFrames, + ToWorkerSchema, + writeFrame, +} from "./pane-protocol.ts"; +import type { FromWorker, Settlement } from "./pane-protocol.ts"; + +/** The hidden invocation a grid starts a pane with. */ +export const PANE_WORKER_COMMAND = "terminal-worker"; + +/** + * Whether this process was started as a pane worker, and for which pane. + * + * Read from raw argv, because this is decided before any parser exists — the + * worker must not run under Effection's `main()`, so it is dispatched at the + * entrypoint rather than inside the command table. It is in no command table, + * so it appears in no help output and no catalog. + * + * Anything but the exact shape is not a worker invocation and falls through to + * the ordinary commands, where `terminal-worker` names no command and is a + * document reference like any other unknown first token. + */ +export function paneWorkerInvocation( + args: readonly string[], +): { ordinal: number; directory: string } | undefined { + const [name, ordinal, directory, ...rest] = args; + if (name !== PANE_WORKER_COMMAND || ordinal === undefined || directory === undefined) { + return undefined; + } + if (rest.length > 0 || !/^\d+$/.test(ordinal)) { + return undefined; + } + return { ordinal: Number(ordinal), directory }; +} + +/** + * Run this process as a pane worker. + * + * `run()` rather than `main()`, deliberately: `main()` binds SIGINT to its own + * shutdown and would exit 130 on the first `^C` typed into the pane — the exact + * keystroke the foreground child is supposed to receive. The signal handlers go + * on before anything else for the same reason. + * + * Naming this invocation grants nothing. The worker connects to a socket in a + * private directory and must present that pane's single-use token before the + * parent says a word to it, so a caller who types this gets a process that + * fails to connect and performs no work at all. + */ +export function runPaneWorkerProcess(invocation: { + ordinal: number; + directory: string; +}): Promise { + ignoreForegroundSignals(); + return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); +} + +/** A settlement for a pane that never started anything. */ +const NOTHING_TO_SETTLE: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], +}; + +interface Live { + readonly id: string; + child: PaneChild | undefined; + /** The one settlement of this child, however many callers ask for it. */ + settled: ReturnType> | undefined; +} + +/** + * Ignore the signals that belong to the foreground child. + * + * They are delivered to the whole foreground process group, and this worker is + * in it. Doing nothing is the correct handling: the child inherits default + * dispositions across `exec`, so it receives the same signal and acts on it. + */ +export function ignoreForegroundSignals(): void { + for (const name of ["SIGINT", "SIGQUIT", "SIGTSTP"] as const) { + process.on(name, () => {}); + } +} + +function writeOut(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; +} + +/** + * Run one pane worker until the parent says to stop. + * + * The caller has already ignored the foreground signals and is running this + * under `run()`; both are properties of the *process*, not of this operation, + * which is why they are the entrypoint's to establish. + */ +export function* runPaneWorker(ordinal: number, directory: string): Operation { + yield* installPosixTerminalProcesses(); + + // Read once, then spent. A second worker for this pane finds no token, so it + // has nothing to present and is refused by the parent. + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + yield* rm(paneTokenPath(directory, ordinal), { force: true }); + + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + const connected = withResolvers(); + socket.once("connect", () => connected.resolve()); + socket.once("error", (error: Error) => connected.reject(error)); + yield* connected.operation; + + const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const say = (message: FromWorker) => writeFrame(socket, message); + + const table = yield* processTable(); + const facts = table.find((row) => row.pid === process.pid); + const tty = facts?.tty; + yield* say({ + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: facts?.pgid ?? -1, + tty: tty ?? "??", + isatty: [ + process.stdin.isTTY === true, + process.stdout.isTTY === true, + process.stderr.isTTY === true, + ], + }); + + let live: Live | undefined; + + /** Settle one child once, however many callers ask, and free the pane. */ + function* settle(entry: Live): Operation { + if (entry.settled) { + return yield* entry.settled.operation; + } + entry.settled = withResolvers(); + try { + const settlement = + entry.child === undefined ? NOTHING_TO_SETTLE : yield* entry.child.settle(); + // Cleared only after the settlement, so a launch arriving now is refused + // rather than started beside a sweep that would reach it. + if (live === entry) { + live = undefined; + } + entry.settled.resolve(settlement); + return settlement; + } catch (error) { + entry.settled.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* quiesce(): Operation { + return live === undefined ? NOTHING_TO_SETTLE : yield* settle(live); + } + + while (true) { + const next = yield* inbound.next(); + if (next.done) { + return; + } + const message = next.value; + switch (message.type) { + case "welcome": + break; + case "display": + // Written, never read: what the reader types belongs to the child. + yield* writeOut(message.text); + yield* say({ type: "displayed", seq: message.seq }); + break; + case "launch": { + if (live !== undefined) { + yield* say({ type: "busy", id: message.id }); + break; + } + const entry: Live = { id: message.id, child: undefined, settled: undefined }; + live = entry; + yield* spawn(function* () { + const child = yield* usePaneChild( + { argv: message.argv, cwd: message.cwd, env: message.env }, + tty, + ); + entry.child = child; + const started = yield* child.started; + if (!started.ok) { + // Never started, so never ready. The pane's readiness latch is not + // tripped, and the grid it belongs to does not attach. + yield* settle(entry); + yield* say({ type: "start-failed", id: message.id, reason: started.error.message }); + return; + } + yield* say({ type: "started", id: message.id, pid: started.value }); + const outcome = yield* child.exited; + // The exit is not the end of it. `exited` is what frees the pane for + // the next launch, so it follows the whole settlement. + const settlement = yield* settle(entry); + yield* say({ type: "exited", id: message.id, ...outcome, settlement }); + }); + break; + } + case "cancel": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", id: message.id, settlement }); + break; + } + case "shutdown": { + const settlement = yield* quiesce(); + yield* say({ type: "quiet", settlement }); + // The pane's last sweep, by the only process that can still make it: + // once this worker exits, tmux closes the pane's pty master and the + // kernel revokes the slave, after which nothing can name a process that + // kept the terminal open. Every child's settlement already swept, so a + // holder here arrived between that sweep and now. + yield* say({ type: "bye", holders: yield* sweepHolders(tty) }); + socket.end(); + return; + } + } + } +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index b931e347b..c23c7f3bb 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,6 +16,14 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; +import { ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import net from "node:net"; +import { stat } from "node:fs/promises"; +import * as path from "node:path"; +import { cliCommand } from "@executablemd/test-support/launch"; import { layoutString, placementProblems, @@ -23,6 +31,16 @@ import { swapsInto, } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; +import { usePaneChannels } from "../src/terminal/pane-channel.ts"; +import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; +import { + FromWorkerSchema, + paneSocketPath, + paneTokenPath, + writeFrame, +} from "../src/terminal/pane-protocol.ts"; +import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; +import type { FromWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -149,3 +167,321 @@ describe("Tier TX — the tmux grid's geometry", () => { expect(message).toContain("pane 3 is not in this window"); }); }); + +/** + * Start one real pane worker, as a real process, over the real socket. + * + * No tmux: a worker is an ordinary program that connects to a socket and does + * what it is told, and every claim in this tier is about that program. tmux's + * part — putting it in a pane with a terminal — is the next tier's. + */ +function useWorker(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const invocation = cliCommand([PANE_WORKER_COMMAND, String(ordinal), directory]); + const child = spawnChild(invocation.command, invocation.arguments, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's worker is tmux's session leader, so it is its own process + // group. Modelled here, because a settlement sweeps the group it is in + // and a worker sharing the test runner's group would be sweeping the + // test runner. + detached: true, + }); + yield* ensure(function* () { + child.kill("SIGKILL"); + yield* until( + new Promise((resolve) => { + if (child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + child.once("exit", () => resolve()); + }), + ); + }); + yield* provide(child); + }); +} + +/** Everything one pane's worker said, until it says the one being waited for. */ +function untilFrame(link: PaneLink, type: FromWorker["type"]): Operation { + return (function* (): Operation { + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error(`the worker closed before saying "${type}"`); + } + if (frame.type === type) { + return frame; + } + } + })(); +} + +/** A raw connection to a pane's socket, for the rows about admission. */ +function useImpostor(directory: string, ordinal: number): Operation { + return resource(function* (provide) { + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + const connected = withResolvers(); + socket.once("connect", () => connected.resolve()); + socket.once("error", (error: Error) => connected.reject(error)); + yield* connected.operation; + yield* ensure(() => { + socket.destroy(); + }); + yield* provide(socket); + }); +} + +/** Settle when a socket closes, or say it did not within the grace given. */ +function closedWithin(socket: net.Socket, limitMs: number): Operation { + return (function* (): Operation { + const closed = withResolvers(); + if (socket.destroyed) { + return true; + } + socket.once("close", () => closed.resolve(true)); + return yield* race([ + closed.operation, + (function* (): Operation { + yield* sleep(limitMs); + return false; + })(), + ]); + })(); +} + +describe("Tier TW — the pane worker and its private channel", () => { + it("TW1: the private directory is 0700 and its tokens 0600", function* () { + const channels: PaneChannels = yield* usePaneChannels(2); + const directory = yield* until(stat(channels.directory)); + expect(directory.mode & 0o777).toBe(0o700); + for (const ordinal of [0, 1]) { + const token = yield* until(stat(paneTokenPath(channels.directory, ordinal))); + expect(`pane ${ordinal}: ${(token.mode & 0o777).toString(8)}`).toBe(`pane ${ordinal}: 600`); + // The socket exists before any pane does, so a worker that starts finds + // it listening rather than racing it. + yield* until(stat(paneSocketPath(channels.directory, ordinal))); + } + }); + + it("TW2: the directory and everything in it goes with the grid", function* () { + let directory = ""; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1); + directory = channels.directory; + }); + const gone = yield* until( + stat(directory).then( + () => false, + () => true, + ), + ); + expect(gone).toBe(true); + }); + + it("TW3: a real worker connects, proves which pane it is, and spends its token", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + expect(link.hello.ordinal).toBe(0); + expect(link.hello.pid).toBeGreaterThan(0); + // Spent as it was read: a second worker for this pane finds no token, so + // it has nothing to present. + const spent = yield* until( + stat(paneTokenPath(channels.directory, 0)).then( + () => false, + () => true, + ), + ); + expect(spent).toBe(true); + expect(channels.refusals()).toEqual([]); + }); + + it("TW4: a connection that says nothing the protocol knows is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + socket.write("this is not a frame\n"); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals().length).toBe(1); + }); + + it("TW5: a hello with the wrong token proves nothing and is closed", function* () { + const channels = yield* usePaneChannels(1); + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect(channels.refusals()[0]).toContain("could not prove it is this pane"); + }); + + it("TW6: a second connection to an admitted pane is closed", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + + // The real worker holds this pane. A second caller with the same socket + // path — token or not — is not this pane's worker. + const socket = yield* useImpostor(channels.directory, 0); + yield* writeFrame(socket, { + type: "hello", + ordinal: 0, + token: "0".repeat(32), + pid: 1, + pgid: 1, + tty: "??", + isatty: [false, false, false], + }); + + expect(yield* closedWithin(socket, 2_000)).toBe(true); + expect( + channels + .refusals() + .some((line) => line.includes("already admitted") || line.includes("second connection")), + ).toBe(true); + }); + + it("TW7: a launch crosses exactly, and readiness is the runtime spawn event", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + // Arguments a command parser would ruin: spaces, a semicolon, a quote and + // a dollar sign. They cross the socket as bytes and reach the child as + // the exact vector. + const awkward = ["a b", "semi;colon", `quote"and'both`, "$HOME"]; + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/echo", ...awkward], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + + const started = yield* untilFrame(link, "started"); + expect(started.type === "started" ? started.pid : 0).toBeGreaterThan(0); + const exited = yield* untilFrame(link, "exited"); + if (exited.type !== "exited") { + throw new Error("expected an exit"); + } + expect(exited.exitCode).toBe(0); + // Settlement follows the exit, and the pane is free only after it. + expect(exited.settlement.quiet).toBe(true); + }); + + it("TW8: a child that never starts reports a failure and never readiness", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "missing", + argv: [path.join(channels.directory, "not-a-program")], + cwd: path.resolve("."), + env: {}, + }); + + // `error` arrives instead of `spawn`, never after it — so the pane's + // readiness latch is never tripped and the grid does not attach. + const failure = yield* untilFrame(link, "start-failed"); + expect(failure.type === "start-failed" ? failure.reason : "").toContain("could not be started"); + }); + + it("TW9: one pane admits one live child, and the next only after it settles", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const sleeper = { + type: "launch" as const, + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }; + yield* link.send(sleeper); + yield* untilFrame(link, "started"); + + // Asked for while the first is live. + yield* link.send({ ...sleeper, id: "second" }); + const refused = yield* untilFrame(link, "busy"); + expect(refused.type === "busy" ? refused.id : "").toBe("second"); + + // Cancelled, settled, and only then is the pane free again. + yield* link.send({ type: "cancel", id: "first" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + + yield* link.send({ ...sleeper, id: "third" }); + const third = yield* untilFrame(link, "started"); + expect(third.type === "started" ? third.id : "").toBe("third"); + }); + + it("TW10: display is written to the pane and never read back from it", function* () { + const channels = yield* usePaneChannels(1); + const worker = yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + const shown: string[] = []; + worker.stdout?.setEncoding("utf8"); + worker.stdout?.on("data", (chunk: string) => shown.push(chunk)); + + yield* link.send({ type: "display", seq: 1, text: "pane says hello\n" }); + const displayed = yield* untilFrame(link, "displayed"); + expect(displayed.type === "displayed" ? displayed.seq : 0).toBe(1); + expect(shown.join("")).toContain("pane says hello"); + }); + + it("TW11: shutdown settles, sweeps the terminal, and says goodbye", function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "one", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + yield* link.send({ type: "shutdown" }); + const quiet = yield* untilFrame(link, "quiet"); + expect(quiet.type === "quiet" ? quiet.settlement.quiet : false).toBe(true); + // The pane's last sweep, by the only process that can still make it. + const bye = yield* untilFrame(link, "bye"); + expect(bye.type).toBe("bye"); + }); + + it("TW12: naming the worker invocation is the only way to be one", function* () { + // In no command table, so in no help output and no catalog. What makes it + // safe is not obscurity: a worker that cannot present a pane's single-use + // token is answered by nobody. + expect(paneWorkerInvocation([PANE_WORKER_COMMAND, "0", "/tmp/x"])).toEqual({ + ordinal: 0, + directory: "/tmp/x", + }); + for (const shape of [ + [PANE_WORKER_COMMAND], + [PANE_WORKER_COMMAND, "0"], + [PANE_WORKER_COMMAND, "zero", "/tmp/x"], + [PANE_WORKER_COMMAND, "0", "/tmp/x", "extra"], + ["run", "0", "/tmp/x"], + ]) { + expect(`${shape.join(" ")}: ${paneWorkerInvocation(shape)}`).toBe( + `${shape.join(" ")}: undefined`, + ); + } + }); +}); From 1420dd8a18147f56c76b0c00b806f0d6d0a36442 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Wed, 2 Sep 2026 23:07:48 -0400 Subject: [PATCH 26/62] =?UTF-8?q?=E2=9C=A8=20Build=20the=20hidden=20tmux?= =?UTF-8?q?=20composite=20for=20a=20terminal=20grid=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One invocation-private server per grid, on its own socket, started with `-f /dev/null` so a reader's `.tmux.conf` cannot redecide an authored layout. A pane per authored ordinal, each running that pane's worker — tmux's parser sees an ordinal and a directory and never a launch's argv. Nothing is visible until `attach()`, which core calls only after every pane has reported a start. Three clients, kept apart because they answer different questions. The visible one is the reader's. The control one attaches `-f no-output`, so pane bytes never travel through this process, and what it reports is how reader detach, server stop and control loss are told apart — an attach client's exit code cannot tell them apart, being 0 after `detach-client`, 0 after `kill-session` and 1 after `kill-server`. The workers are not clients at all; they are the panes. Teardown is registered before the first command, so a composite that fails half-built still takes its server down. A detach is *asked for* before anything is signalled, because a client that leaves restores the terminal and one that is killed cannot. `stop()` establishes the server pid is unreachable and the server refuses its session — never the socket file's absence, which outlives it. `probeTmux()` answers the prerequisites before a server exists: a terminal to divide, and a tmux new enough to divide it as an authored layout needs. Tier TG runs against a fake server that reproduces the behaviours this code exists to work around — a split inserts its pane into the window list after the one it split, and a layout string's leaves are filled in window-list order with the ids in them ignored. What is not faked is the composite: the same layout string, the same swap decisions, and real control-mode lines from a fixture process through the same splitter and classifier. Both halves of the ordering claim were broken on purpose: removing the swaps fails TG2, and a fake that honours the leaf ids fails TG2 as well — so the row is passing because the composite imposes the order, not because the two happened to coincide. Stated plainly, and not claimed here: a fixture client inherits a pipe, so it cannot restore a terminal it never had. That a real `tmux attach` gives the reader's terminal back when asked to detach is #726's evidence on real tmux. --- packages/cli/src/terminal/tmux-grid.ts | 427 ++++++++++++++++++ packages/cli/src/terminal/tmux.ts | 154 +++++++ packages/cli/tests/fixtures/fake-tmux.ts | 287 ++++++++++++ packages/cli/tests/fixtures/tmux-client.ts | 61 +++ packages/cli/tests/terminal-grid-tmux.test.ts | 326 +++++++++++++ 5 files changed, 1255 insertions(+) create mode 100644 packages/cli/src/terminal/tmux-grid.ts create mode 100644 packages/cli/src/terminal/tmux.ts create mode 100644 packages/cli/tests/fixtures/fake-tmux.ts create mode 100644 packages/cli/tests/fixtures/tmux-client.ts diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts new file mode 100644 index 000000000..3c39f5e01 --- /dev/null +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -0,0 +1,427 @@ +/** + * One hidden, invocation-private tmux composite + * (architecture.md §Interactive terminal grids, §Atomic presentation). + * + * A grid is built entirely out of sight: its own server on its own socket, a + * pane per authored ordinal each running that pane's worker, the authored + * layout imposed explicitly, and a control-mode client that says what the + * server sees. Nothing is visible until `attach()`, which core calls only after + * every pane has reported a start — so a reader never watches a grid fill in, + * and a grid that failed to start is taken down without ever having been shown. + * + * Three clients, kept apart because they answer different questions: + * + * - the **visible** client is the reader's, attached on this process's terminal + * with the streams inherited; + * - the **control** client attaches with `-f no-output`, so pane bytes never + * travel through this process. What it reports — `%client-detached`, + * `%sessions-changed`, `%exit`, EOF — is how reader detach, server stop and + * control loss are told apart. An attach client's exit code cannot tell them + * apart: it is 0 after `detach-client`, 0 after `kill-session` and 1 after + * `kill-server`; + * - the pane **workers** are not clients at all. They are the panes. + * + * Every tmux identifier — the socket path, session name, window, pane ids, + * client names, the server pid — stays inside this module. None of it reaches a + * request, a result, a retained record or a diagnostic. + */ + +import { exec } from "@effectionx/process"; +import { lines } from "@effectionx/stream-helpers"; +import { ensure, resource, sleep, spawn } from "effection"; +import type { Operation } from "effection"; +import { processReachable } from "@executablemd/runtime"; +import { layoutString, swapsInto } from "./layout.ts"; +import type { LayoutCell } from "./layout.ts"; +import { usePaneChild } from "./pane-child.ts"; +import type { PaneChild } from "./pane-child.ts"; +import type { Tmux } from "./tmux.ts"; + +/** What one prepared pane is, from the composite's side. */ +export interface TmuxPane { + readonly ordinal: number; + /** tmux's `%N`. Never leaves this module. */ + readonly id: string; + /** `ttys003`, the pane's terminal, as the worker will name it. */ + readonly tty: string; + readonly pid: number; + readonly cell: LayoutCell; +} + +/** What the control client saw, classified. */ +export type ControlEvent = + | { kind: "client-attached"; client: string } + | { kind: "client-detached"; client: string } + | { kind: "sessions-changed" } + | { kind: "layout-change" } + | { kind: "exit" } + | { kind: "closed" } + | { kind: "other"; line: string }; + +export interface TmuxGridRequest { + readonly session: string; + readonly columns: number; + readonly panes: number; + readonly width: number; + readonly height: number; + readonly titles: readonly string[]; + /** The command that runs one pane's worker. */ + workerCommand(ordinal: number): readonly string[]; + readonly cwd: string; + readonly env: Record; +} + +/** What stopping the server established. */ +export interface ServerStopped { + /** The server process is no longer reachable. */ + readonly gone: boolean; + /** The server refuses to answer for its session. */ + readonly refuses: boolean; +} + +export interface VisibleClient { + readonly child: PaneChild; + /** tmux's name for this client once attached: its tty. */ + readonly name: string; +} + +export interface TmuxGrid { + readonly panes: readonly TmuxPane[]; + /** Everything the control client reported, classified, in order. */ + readonly events: readonly ControlEvent[]; + /** Pane geometry now, for checking placement after a resize. */ + geometry(): Operation; + /** Show the grid on this process's terminal. */ + attach(): Operation; + /** Ask the visible client to leave, so it restores the terminal itself. */ + detach(client: VisibleClient): Operation; + /** Stop the server, and establish that it is gone. */ + stop(): Operation; +} + +const CLIENT_POLL_MS = 20; +const STOP_LIMIT_MS = 5_000; +const DETACH_LIMIT_MS = 1_000; + +/** + * Prepare the whole hidden composite. + * + * The teardown is registered before the first command, so a cancellation + * anywhere below still takes the server down: a half-built grid is exactly the + * state that would otherwise leave a server, its workers and their sockets + * behind. + */ +export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { + return resource(function* (provide) { + const target = `${request.session}:0`; + let serverPid = -1; + + function* stop(): Operation { + yield* tmux.tryRun(["kill-server"]); + const deadline = Date.now() + STOP_LIMIT_MS; + let stopped: ServerStopped; + do { + stopped = { + gone: serverPid < 0 || !(yield* processReachable(serverPid)), + // The socket file outlives the server, so "gone" is the pid being + // unreachable and nothing answering for the session — never the + // socket file's absence. + refuses: (yield* tmux.tryRun(["has-session", "-t", request.session])) === undefined, + }; + if (stopped.gone && stopped.refuses) { + return stopped; + } + yield* sleep(CLIENT_POLL_MS); + } while (Date.now() < deadline); + return stopped; + } + + yield* ensure(function* () { + yield* stop(); + }); + + yield* tmux.run([ + "new-session", + "-d", + "-s", + request.session, + "-x", + String(request.width), + "-y", + String(request.height), + "-c", + request.cwd, + ...request.workerCommand(0), + ]); + serverPid = Number(yield* tmux.run(["display", "-p", "#{pid}"])); + // A pane whose worker has gone stays a pane, so its death is a fact the + // composite can read rather than a pane that vanishes from under the + // layout. + yield* tmux.run(["set", "-g", "remain-on-exit", "on"]); + yield* tmux.run(["set", "-g", "status", "off"]); + yield* tmux.run(["set", "-g", "pane-border-status", "top"]); + yield* tmux.run(["set", "-g", "pane-border-format", " #{pane_title} "]); + + // Split whichever pane has the most room, so a small window still fits + // every pane. Where each one ends up is the explicit layout's business, + // not this loop's. + const paneIds: string[] = [yield* tmux.run(["display", "-p", "-t", target, "#{pane_id}"])]; + for (let ordinal = 1; ordinal < request.panes; ordinal++) { + const roomiest = yield* largestPane(tmux, target); + const direction = roomiest.width >= roomiest.height * 2 ? "-h" : "-v"; + paneIds.push( + yield* tmux.run([ + "split-window", + "-d", + direction, + "-t", + roomiest.id, + "-c", + request.cwd, + "-P", + "-F", + "#{pane_id}", + ...request.workerCommand(ordinal), + ]), + ); + } + + const [width, height] = (yield* tmux.run([ + "display", + "-p", + "-t", + target, + "#{window_width} #{window_height}", + ])) + .split(" ") + .map(Number); + yield* tmux.run([ + "select-layout", + "-t", + target, + layoutString( + width ?? request.width, + height ?? request.height, + request.columns, + paneIds.map(paneNumber), + ), + ]); + + // tmux fills the layout's leaves in window-list order and ignores the ids + // the string names, so authored order is imposed here. Swapping preserves + // the cells: what moves is which pane is in which one. + const placed = (yield* readPanes(tmux, target, paneIds)).slice().sort(byPosition); + for (const swap of swapsInto( + placed.map((pane) => paneNumber(pane.id)), + paneIds.map(paneNumber), + )) { + const from = placed[swap.from]; + const to = placed[swap.to]; + if (from === undefined || to === undefined) { + continue; + } + yield* tmux.run(["swap-pane", "-d", "-s", from.id, "-t", to.id]); + placed[swap.to] = from; + placed[swap.from] = to; + } + + for (const [ordinal, id] of paneIds.entries()) { + yield* tmux.run([ + "select-pane", + "-t", + id, + "-T", + request.titles[ordinal] ?? `pane ${ordinal}`, + ]); + } + const panes = yield* readPanes(tmux, target, paneIds); + + // The control client. `-f no-output` is what keeps pane bytes out of this + // process: what arrives is the server's own account of its clients. + const events: ControlEvent[] = []; + yield* spawn(function* () { + const [program = "tmux", ...argv] = tmux.argv([ + "-C", + "attach-session", + "-f", + "no-output", + "-t", + request.session, + ]); + const client = yield* exec(program, { arguments: argv, env: request.env }); + const reported = yield* lines()(client.stdout); + let next = yield* reported.next(); + while (!next.done) { + events.push(classify(next.value)); + next = yield* reported.next(); + } + // EOF on the control channel is its own event, and is not a detach. + events.push({ kind: "closed" }); + }); + + yield* provide({ + panes, + events, + *geometry() { + return (yield* readPanes(tmux, target, paneIds)).map((pane) => pane.cell); + }, + *attach() { + const child = yield* usePaneChild( + { + argv: tmux.argv(["attach-session", "-t", request.session]), + cwd: request.cwd, + env: request.env, + }, + // No terminal sweep for this one. Its terminal is the reader's, and + // the processes holding it are the run itself. + undefined, + ); + const started = yield* child.started; + if (!started.ok) { + throw started.error; + } + const name = yield* awaitClient(tmux); + return { child, name }; + }, + *detach(client) { + // Asked to leave before being signalled: a client that detaches + // restores the terminal itself, and one that is killed cannot. + yield* tmux.tryRun(["detach-client", "-t", client.name]); + const deadline = Date.now() + DETACH_LIMIT_MS; + while (Date.now() < deadline) { + if (!(yield* clientNames(tmux)).includes(client.name)) { + break; + } + yield* sleep(CLIENT_POLL_MS); + } + // Whatever the client did about it, the process is this scope's. + yield* client.child.settle(); + }, + stop, + }); + }); +} + +/** `%3` → `3`, which is what a layout string names a pane by. */ +function paneNumber(id: string): number { + return Number(id.replace(/^%/, "")); +} + +function byPosition(left: TmuxPane, right: TmuxPane): number { + return left.cell.top - right.cell.top || left.cell.left - right.cell.left; +} + +/** Every pane the window holds now, in the order `paneIds` names them. */ +function* readPanes( + tmux: Tmux, + target: string, + paneIds: readonly string[], +): Operation { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_tty} #{pane_pid} #{pane_left} #{pane_top} #{pane_width} #{pane_height}", + ]); + const found = new Map(); + for (const line of listed.split("\n")) { + const [id, tty, pid, left, top, paneWidth, paneHeight] = line.trim().split(/\s+/); + if (id === undefined || tty === undefined || pid === undefined) { + continue; + } + found.set(id, { + ordinal: paneIds.indexOf(id), + id, + // The worker reports `ttys003`; tmux reports `/dev/ttys003`. + tty: tty.replace(/^\/dev\//, ""), + pid: Number(pid), + cell: { + ordinal: paneIds.indexOf(id), + left: Number(left), + top: Number(top), + width: Number(paneWidth), + height: Number(paneHeight), + }, + }); + } + const panes: TmuxPane[] = []; + for (const id of paneIds) { + const pane = found.get(id); + if (pane !== undefined) { + panes.push(pane); + } + } + return panes; +} + +/** The pane with the most room, which is where the next split goes. */ +function* largestPane( + tmux: Tmux, + target: string, +): Operation<{ id: string; width: number; height: number }> { + const listed = yield* tmux.run([ + "list-panes", + "-t", + target, + "-F", + "#{pane_id} #{pane_width} #{pane_height}", + ]); + let best: { id: string; width: number; height: number } | undefined; + for (const line of listed.split("\n")) { + const [id, width, height] = line.trim().split(/\s+/); + if (id === undefined || width === undefined || height === undefined) { + continue; + } + const pane = { id, width: Number(width), height: Number(height) }; + if (best === undefined || pane.width * pane.height > best.width * best.height) { + best = pane; + } + } + if (best === undefined) { + throw new Error("this grid's window holds no panes"); + } + return best; +} + +function* clientNames(tmux: Tmux): Operation { + const listed = yield* tmux.tryRun(["list-clients", "-F", "#{client_name}"]); + return listed === undefined || listed.length === 0 ? [] : listed.split("\n"); +} + +/** The client that just attached, once the server lists one it did not have. */ +function* awaitClient(tmux: Tmux): Operation { + const deadline = Date.now() + DETACH_LIMIT_MS * 5; + while (Date.now() < deadline) { + const names = yield* clientNames(tmux); + // The control client attaches with no tty of its own, so a named client is + // the visible one. + const visible = names.filter((name) => name.length > 0 && name !== "(none)"); + const found = visible.at(-1); + if (found !== undefined) { + return found; + } + yield* sleep(CLIENT_POLL_MS); + } + throw new Error("the grid was shown, but the server never listed a client for it"); +} + +/** One control-mode line, as the lifecycle event it reports. */ +export function classify(line: string): ControlEvent { + if (line.startsWith("%client-detached")) { + return { kind: "client-detached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%client-session-changed") || line.startsWith("%client-attached")) { + return { kind: "client-attached", client: line.split(/\s+/)[1] ?? "" }; + } + if (line.startsWith("%sessions-changed")) { + return { kind: "sessions-changed" }; + } + if (line.startsWith("%layout-change")) { + return { kind: "layout-change" }; + } + if (line.startsWith("%exit")) { + return { kind: "exit" }; + } + return { kind: "other", line }; +} diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts new file mode 100644 index 000000000..2ea03a365 --- /dev/null +++ b/packages/cli/src/terminal/tmux.ts @@ -0,0 +1,154 @@ +/** + * The tmux command surface, and what a host must have before a grid is opened + * (architecture.md §Interactive terminal grids). + * + * Everything tmux is ever told goes through here, which is what makes tmux + * substitutable: a grid is built against this interface, so the lifecycle can + * be exercised without a tmux on the machine and without a terminal to draw on. + * + * The server is private to one grid. `-S ` puts it on a socket inside + * the invocation's own directory rather than the user's default one, and + * `-f /dev/null` means the reader's `.tmux.conf` cannot change what a document + * asked for — a grid is the author's layout, not the reader's configuration. + * + * Prerequisites are checked before anything is created. A host with no terminal + * or no usable tmux refuses while there is still nothing to undo: no server, no + * worker, no socket, no token, and no change to the reader's terminal. + */ + +import { exec } from "@effectionx/process"; +import { Err, Ok } from "effection"; +import type { Operation, Result } from "effection"; + +/** One private tmux server, addressed by its socket. */ +export interface Tmux { + readonly socket: string; + /** Run one command; its trimmed stdout, or a failure. */ + run(args: readonly string[]): Operation; + /** The same, answering `undefined` instead of throwing. */ + tryRun(args: readonly string[]): Operation; + /** + * The whole command vector for a client this grid starts itself. + * + * Attaching is not a command that returns; it is a process that runs. It goes + * through this seam anyway, so that everything tmux is ever told is said in + * one place — and so a grid's lifecycle can be exercised against something + * other than tmux. + */ + argv(args: readonly string[]): readonly string[]; +} + +export class TmuxCommandFailed extends Error { + override name = "TmuxCommandFailed"; + constructor(args: readonly string[], stderr: string, code: number | undefined) { + // The command, not the socket: a diagnostic names what was asked for and + // never where this invocation's private server lives. + super(`tmux ${args.join(" ")} failed (${code ?? "signal"}): ${stderr.trim()}`); + } +} + +export const TMUX_UNAVAILABLE = + "this host cannot open a terminal grid: it needs a terminal and a tmux that " + + "supports one. Run xmd from a terminal on a host with tmux 3.0 or newer, or " + + "use a host that installs its own terminal provider."; + +export class TmuxUnavailableError extends Error { + override name = "TmuxUnavailableError"; + constructor(readonly reason: string) { + super(`${TMUX_UNAVAILABLE} (${reason})`); + } +} + +/** Talk to the private server on `socket`. */ +export function tmuxAt(socket: string, env: Record): Tmux { + // `-f /dev/null`: the reader's configuration does not get to redecide an + // authored layout, a pane's border, or what a key does to the child. + const base = ["-S", socket, "-f", "/dev/null"]; + return { + socket, + argv: (args) => ["tmux", ...base, ...args], + *run(args) { + const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + if (result.code !== 0) { + throw new TmuxCommandFailed(args, result.stderr, result.code); + } + return result.stdout.trim(); + }, + *tryRun(args) { + const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + return result.code === 0 ? result.stdout.trim() : undefined; + }, + }; +} + +/** The oldest tmux whose layout strings and control mode behave as required. */ +const REQUIRED_TMUX = { major: 3, minor: 0 }; + +/** + * Whether this host can present a grid, and why not when it cannot. + * + * Answered before a server exists. Two facts, both of them the host's: there is + * a terminal to divide, and there is a tmux new enough to divide it the way an + * authored layout needs. + */ +export function* probeTmux(options: { + readonly isTerminal: () => boolean; + readonly env: Record; +}): Operation> { + if (!options.isTerminal()) { + return Err(new TmuxUnavailableError("this invocation has no terminal")); + } + const result = yield* exec("tmux", { arguments: ["-V"], env: options.env }).join(); + if (result.code !== 0) { + return Err(new TmuxUnavailableError("tmux is not installed or would not run")); + } + const version = result.stdout.trim(); + const parsed = readVersion(version); + if (parsed === undefined) { + return Err(new TmuxUnavailableError(`tmux did not report a version (${version})`)); + } + if ( + parsed.major < REQUIRED_TMUX.major || + (parsed.major === REQUIRED_TMUX.major && parsed.minor < REQUIRED_TMUX.minor) + ) { + return Err( + new TmuxUnavailableError( + `${version} is older than tmux ${REQUIRED_TMUX.major}.${REQUIRED_TMUX.minor}`, + ), + ); + } + return Ok(version); +} + +/** `tmux 3.6a` and `tmux next-3.7` alike, read to a major and a minor. */ +function readVersion(reported: string): { major: number; minor: number } | undefined { + const match = /(\d+)\.(\d+)/.exec(reported); + if (match === null) { + return undefined; + } + const [, major, minor] = match; + if (major === undefined || minor === undefined) { + return undefined; + } + return { major: Number(major), minor: Number(minor) }; +} + +/** + * The environment every process in the topology receives. + * + * Named rather than inherited wholesale: a pane's child gets what a terminal + * program needs and nothing this process happens to be carrying. + */ +export function paneEnvironment( + source: Record, +): Record { + const env: Record = {}; + for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { + const value = source[name]; + if (value !== undefined && value !== "") { + env[name] = value; + } + } + env.TERM = source.TERM ?? "xterm-256color"; + return env; +} diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts new file mode 100644 index 000000000..f0824a433 --- /dev/null +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -0,0 +1,287 @@ +/** + * A tmux server, modelled well enough to hold the composite to its contract. + * + * What matters here is the behaviour the production code exists to work + * around, so the fake reproduces it deliberately: + * + * - **a layout string's leaves are filled in window-list order, and the pane + * ids written in them are ignored.** This is why authored order is imposed by + * swaps rather than by describing it, and a fake that honoured the ids would + * make the swap logic untestable and unnecessary-looking; + * - `kill-server` leaves the socket file behind, so "gone" cannot be the file's + * absence; + * - `detach-client` removes a client and lets its process leave, while + * `kill-server` ends everything at once. + * + * The server's own liveness is a number this fake owns, and the composite asks + * the runtime's process seam about it — so a test can say "the server did not + * go away" without there being a process to refuse to die. + */ + +import { appendFile } from "node:fs/promises"; +import { until } from "effection"; +import type { Operation } from "effection"; +import type { Tmux } from "../../src/terminal/tmux.ts"; + +export interface FakePane { + id: string; + tty: string; + pid: number; + left: number; + top: number; + width: number; + height: number; + title: string; + /** The command the pane was created with, so a test can read it back. */ + command: readonly string[]; +} + +export interface FakeTmuxOptions { + /** The window's size, which the layout is computed against. */ + readonly width?: number; + readonly height?: number; + /** Where client fixtures read what the server did. */ + readonly script: string; + /** The program a client fixture runs. */ + readonly clientCommand: (mode: "control" | "attach", script: string) => readonly string[]; + /** Fail this command once, with this message. */ + readonly failOnce?: { readonly command: string; readonly message: string }; +} + +export interface FakeTmux extends Tmux { + /** Every command the composite issued, in order, as one string each. */ + readonly issued: readonly string[]; + readonly panes: readonly FakePane[]; + /** The server pid the composite will ask the process seam about. */ + readonly serverPid: number; + readonly alive: () => boolean; + readonly clients: readonly string[]; + /** Say something on the control channel, as the server would. */ + say(line: string): Operation; +} + +/** Cells a layout string describes, in the order it lists them. */ +function readLayoutCells( + layout: string, +): { left: number; top: number; width: number; height: number }[] { + const cells: { left: number; top: number; width: number; height: number }[] = []; + const leaf = /(\d+)x(\d+),(\d+),(\d+),(\d+)(?![\dx])/g; + let match = leaf.exec(layout); + while (match !== null) { + const [, width, height, left, top] = match; + cells.push({ + left: Number(left), + top: Number(top), + width: Number(width), + height: Number(height), + }); + match = leaf.exec(layout); + } + return cells; +} + +export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { + const width = options.width ?? 160; + const height = options.height ?? 48; + const issued: string[] = []; + /** Window-list order — the order panes were created, which tmux fills by. */ + const panes: FakePane[] = []; + const clients: string[] = []; + let alive = false; + let nextPane = 0; + let nextPid = 4000; + const serverPid = 3999; + let failed = false; + + function pane(id: string): FakePane | undefined { + return panes.find((candidate) => candidate.id === id); + } + + /** + * Create a pane, and put it in the window list where tmux would. + * + * A split inserts the new pane *immediately after the one it split*, not at + * the end. That is what makes window-list order differ from creation order + * once panes are split by size rather than in sequence — and therefore what + * makes the authored order need imposing. + */ + function create(command: readonly string[], after?: string): FakePane { + const created: FakePane = { + id: `%${nextPane++}`, + tty: `ttys90${nextPane}`, + pid: nextPid++, + left: 0, + top: 0, + width, + height, + title: "", + command, + }; + const at = after === undefined ? -1 : panes.findIndex((entry) => entry.id === after); + if (at < 0) { + panes.push(created); + } else { + panes.splice(at + 1, 0, created); + } + return created; + } + + /** The pane command trailing one tmux invocation, after its last flag. */ + function trailing(args: readonly string[], lastFlagValue: string): readonly string[] { + const at = args.lastIndexOf(lastFlagValue); + return at < 0 ? [] : args.slice(at + 1); + } + + function* answer(args: readonly string[]): Operation { + issued.push(args.join(" ")); + const [command] = args; + if (options.failOnce !== undefined && !failed && command === options.failOnce.command) { + failed = true; + return undefined; + } + if (command !== "kill-server" && command !== "new-session" && !alive) { + // Every other command needs a server. + return undefined; + } + switch (command) { + case "new-session": { + alive = true; + // `... -c ` + const cwd = args[args.indexOf("-c") + 1] ?? ""; + create(trailing(args, cwd)); + return ""; + } + case "display": { + const format = args.at(-1) ?? ""; + if (format === "#{pid}") { + return String(serverPid); + } + if (format === "#{pane_id}") { + return panes[0]?.id ?? ""; + } + if (format === "#{window_width} #{window_height}") { + return `${width} ${height}`; + } + return ""; + } + case "set": + return ""; + case "split-window": { + // `... -t -c -P -F #{pane_id} ` + const target = args[args.indexOf("-t") + 1]; + return create(trailing(args, "#{pane_id}"), target).id; + } + case "list-panes": { + const format = args.at(-1) ?? ""; + return panes + .map((entry) => + format.includes("pane_tty") + ? `${entry.id} /dev/${entry.tty} ${entry.pid} ${entry.left} ${entry.top} ` + + `${entry.width} ${entry.height}` + : `${entry.id} ${entry.width} ${entry.height}`, + ) + .join("\n"); + } + case "select-layout": { + // The behaviour the swaps exist for: cells go to panes in window-list + // order, and the ids the string names are ignored. + const cells = readLayoutCells(args.at(-1) ?? ""); + for (const [index, entry] of panes.entries()) { + const cell = cells[index]; + if (cell !== undefined) { + entry.left = cell.left; + entry.top = cell.top; + entry.width = cell.width; + entry.height = cell.height; + } + } + return ""; + } + case "swap-pane": { + const source = pane(args[args.indexOf("-s") + 1] ?? ""); + const target = pane(args[args.indexOf("-t") + 1] ?? ""); + if (source === undefined || target === undefined) { + return undefined; + } + // Panes exchange positions; the cells stay where they are. + const held = { + left: source.left, + top: source.top, + width: source.width, + height: source.height, + }; + source.left = target.left; + source.top = target.top; + source.width = target.width; + source.height = target.height; + target.left = held.left; + target.top = held.top; + target.width = held.width; + target.height = held.height; + return ""; + } + case "select-pane": { + const found = pane(args[args.indexOf("-t") + 1] ?? ""); + if (found === undefined) { + return undefined; + } + found.title = args[args.indexOf("-T") + 1] ?? ""; + return ""; + } + case "list-clients": + return clients.join("\n"); + case "detach-client": { + const name = args[args.indexOf("-t") + 1] ?? ""; + const at = clients.indexOf(name); + if (at >= 0) { + clients.splice(at, 1); + } + yield* until(appendFile(options.script, "detached\n")); + yield* until(appendFile(options.script, `%client-detached ${name}\n`)); + return ""; + } + case "has-session": + return alive ? "" : undefined; + case "kill-server": { + if (alive) { + alive = false; + yield* until(appendFile(options.script, "detached\n%exit\n")); + } + clients.length = 0; + return ""; + } + default: + return ""; + } + } + + return { + socket: "/fake/socket", + issued, + panes, + serverPid, + alive: () => alive, + clients, + argv(args) { + const mode = args.includes("-C") ? "control" : "attach"; + if (mode === "attach") { + // A visible client the server can list, named the way tmux names one. + clients.push("/dev/ttys999"); + } + return options.clientCommand(mode, options.script); + }, + *say(line) { + yield* until(appendFile(options.script, `${line}\n`)); + }, + *run(args) { + const answered = yield* answer(args); + if (answered === undefined) { + throw new Error(`fake tmux refused: ${args.join(" ")}`); + } + return answered; + }, + *tryRun(args) { + return yield* answer(args); + }, + }; +} diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/cli/tests/fixtures/tmux-client.ts new file mode 100644 index 000000000..34bf8eb0a --- /dev/null +++ b/packages/cli/tests/fixtures/tmux-client.ts @@ -0,0 +1,61 @@ +/** + * A stand-in for one tmux client, so a grid's lifecycle can be exercised + * without tmux. + * + * Two modes, because the composite keeps two clients apart and a test that + * conflated them would prove nothing about the distinction: + * + * - `control` writes lines to stdout as they appear in the script file, and + * ends at `%exit`. The composite reads it through the same line splitting and + * the same classifier it uses on real control mode, so what is faked is the + * server, never the parsing. + * - `attach` holds the terminal, and leaves when the script file says it was + * detached. It writes nothing. + * + * The script file is how a test says what the server did. Appending to it is + * the fake server's way of speaking, and polling it is this program's; neither + * is a claim about how tmux does it. + * + * Terminal restoration is deliberately outside this: a process that inherits a + * pipe cannot restore a terminal it never had. That a real `tmux attach` gives + * the terminal back when asked to detach is #726's evidence, on real tmux. + */ + +import { readFile } from "node:fs/promises"; +import process from "node:process"; + +const POLL_MS = 15; + +const [mode, script] = process.argv.slice(2); +if ((mode !== "control" && mode !== "attach") || script === undefined) { + process.stderr.write("usage: tmux-client.ts \n"); + process.exit(2); +} + +/** Everything the script says so far, or nothing while it does not exist. */ +async function read(): Promise { + try { + const text = await readFile(script, "utf8"); + return text.split("\n").filter((line) => line.length > 0); + } catch { + return []; + } +} + +let seen = 0; +for (;;) { + const said = await read(); + for (const line of said.slice(seen)) { + if (mode === "control") { + process.stdout.write(`${line}\n`); + if (line.startsWith("%exit")) { + process.exit(0); + } + } else if (line === "detached") { + // The reader left. A real client would restore the terminal here. + process.exit(0); + } + } + seen = said.length; + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); +} diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index c23c7f3bb..7e0de0821 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -24,6 +24,14 @@ import net from "node:net"; import { stat } from "node:fs/promises"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { rm, writeFile } from "node:fs/promises"; +import { TerminalProcesses } from "@executablemd/runtime"; +import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; +import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; +import { createFakeTmux } from "./fixtures/fake-tmux.ts"; +import type { FakeTmux } from "./fixtures/fake-tmux.ts"; import { layoutString, placementProblems, @@ -485,3 +493,321 @@ describe("Tier TW — the pane worker and its private channel", () => { } }); }); + +/** + * Tier TG — the hidden composite's lifecycle + * (architecture.md §Atomic presentation and settlement). + * + * Against a fake server, deliberately. What is faked is tmux's *behaviour* — + * including the one this code exists to work around, that a layout string's + * leaves are filled in window-list order and the pane ids in them are ignored. + * What is not faked is the composite: the same layout string, the same swap + * decisions, the same control-mode line splitting and the same classifier run + * here as on a real server. + * + * One thing this tier deliberately does not claim. A client fixture inherits a + * pipe, so it cannot restore a terminal it never had; that a real `tmux attach` + * gives the reader's terminal back when asked to detach is #726's evidence, on + * real tmux, and nothing here stands in for it. + */ +describe("Tier TG — the tmux composite", () => { + /** Where a fake server and its client fixtures meet. */ + function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* until(writeFile(file, "")); + yield* ensure(function* () { + yield* until(rm(file, { force: true })); + }); + yield* provide(file); + }); + } + + /** The fixture that stands in for one tmux client. */ + function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; + } + + /** A composite over a fake server, with the pane workers stubbed out. */ + function useComposite(options: { + panes: number; + columns: number; + titles?: string[]; + failOnce?: { command: string; message: string }; + }): Operation<{ grid: TmuxGrid; tmux: FakeTmux; script: string }> { + return (function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + ...(options.failOnce === undefined ? {} : { failOnce: options.failOnce }), + }); + // The server's liveness is the fake's to decide, and the composite asks + // the runtime seam about it — so "the server did not go away" is a fact a + // row can state without a process refusing to die. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "absent" as const; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + return pid === tmux.serverPid && tmux.alive(); + }, + }, + { at: "min" }, + ); + const grid = yield* useTmuxGrid(tmux, { + session: "grid", + columns: options.columns, + panes: options.panes, + width: 160, + height: 48, + titles: + options.titles ?? Array.from({ length: options.panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + return { grid, tmux, script }; + })(); + } + + it("TG1: the server is private, unconfigured, and started hidden", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // Detached, so nothing is shown; sized explicitly, so the layout is + // computed against a window rather than a guess. + const created = tmux.issued.find((line) => line.startsWith("new-session")); + expect(created).toContain("-d"); + expect(created).toContain("-x 160"); + expect(created).toContain("-y 48"); + // Every pane runs a worker, and tmux's parser sees only an ordinal and a + // directory — never a launch's argv. + expect(tmux.panes.length).toBe(2); + for (const [ordinal, pane] of tmux.panes.entries()) { + expect(pane.command.join(" ")).toBe(`xmd terminal-worker ${ordinal} /private/dir`); + } + }); + + it("TG2: the authored order survives a server that ignores the layout's ids", function* () { + const { grid, tmux } = yield* useComposite({ + panes: 4, + columns: 2, + titles: ["Planner", "Implementor", "Reviewer", "Shell"], + }); + + // The fake fills the leaves in window-list order and ignores the ids, which + // is what tmux does. Without the swaps this would be the wrong order. + expect(tmux.issued.some((line) => line.startsWith("swap-pane"))).toBe(true); + const placed = [...grid.panes].sort( + (left, right) => left.cell.top - right.cell.top || left.cell.left - right.cell.left, + ); + expect(placed.map((pane) => pane.ordinal)).toEqual([0, 1, 2, 3]); + expect( + placementProblems( + placed.map((pane) => pane.cell), + 2, + ), + ).toEqual([]); + // And each pane carries the title the author wrote for that ordinal. Read + // by pane id, because the server's window list is not the authored order — + // which is the whole reason the swaps above exist. + const titles = grid.panes.map( + (pane) => tmux.panes.find((entry) => entry.id === pane.id)?.title, + ); + expect(titles).toEqual(["Planner", "Implementor", "Reviewer", "Shell"]); + // The window list really is a different order, so this row is not passing + // because the two happened to coincide. + expect(tmux.panes.map((pane) => pane.id)).not.toEqual(grid.panes.map((pane) => pane.id)); + }); + + it("TG3: nothing is attached while the composite is being built", function* () { + const { tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + // The control client is not the reader's: it attaches with `-f no-output`, + // so pane bytes never reach this process. The visible one has not been + // asked for. + expect(tmux.issued.some((line) => line.startsWith("attach-session"))).toBe(false); + expect(tmux.clients).toEqual([]); + }); + + it("TG4: attaching shows the grid, and the server lists the reader's client", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const client = yield* grid.attach(); + expect(client.name).toBe("/dev/ttys999"); + expect(tmux.clients).toContain("/dev/ttys999"); + }); + + it("TG5: a reader detach is asked for before anything is signalled", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + const client = yield* grid.attach(); + + yield* grid.detach(client); + + // Asked to leave, and gone from the server's list. A client that was + // signalled instead could not have restored the terminal — which is why + // the ask comes first. + const asked = tmux.issued.findIndex((line) => line.startsWith("detach-client")); + expect(asked).toBeGreaterThan(-1); + expect(tmux.clients).not.toContain("/dev/ttys999"); + expect(tmux.issued.slice(0, asked).some((line) => line.startsWith("kill-server"))).toBe(false); + }); + + it("TG6: reader detach, control loss and server stop are separate events", function* () { + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + // `%exit` ends the control channel, and its EOF is its own event — an + // attach client's exit code could not tell these three apart. + yield* tmux.say("%exit"); + yield* untilEvent(grid, "closed"); + + const kinds = grid.events.map((event) => event.kind); + expect(kinds).toContain("client-detached"); + expect(kinds).toContain("sessions-changed"); + expect(kinds.indexOf("exit")).toBeLessThan(kinds.lastIndexOf("closed")); + }); + + it("TG7: stopping establishes the server is gone and refuses its session", function* () { + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + const stopped = yield* grid.stop(); + expect(stopped.gone).toBe(true); + expect(stopped.refuses).toBe(true); + expect(tmux.alive()).toBe(false); + }); + + it("TG8: a server that will not go away is not reported gone", function* () { + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand }); + // The server answers `kill-server` and stays anyway. Nothing about the + // command having been accepted is evidence that it worked. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "delivered" as const; + }, + // deno-lint-ignore require-yield + *reachable() { + return true; + }, + }, + { at: "min" }, + ); + const grid = yield* useTmuxGrid(tmux, { + session: "grid", + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], + cwd: path.resolve("."), + env: {}, + }); + + const stopped = yield* grid.stop(); + expect(stopped.gone).toBe(false); + }); + + it("TG9: a composite that fails while being built still takes the server down", function* () { + const script = yield* useScript(); + let stopping = 0; + const tmux = createFakeTmux({ + script, + clientCommand, + // The split for the second pane fails, half-way through preparation. + failOnce: { command: "split-window", message: "no room" }, + }); + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver() { + return "absent" as const; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: "grid", + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: ["a", "b"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + expect(failure).toContain("split-window"); + // Registered before the first command, so a half-built composite is still + // taken down: no server is left behind for a grid nobody ever saw. + stopping = tmux.issued.filter((line) => line.startsWith("kill-server")).length; + expect(stopping).toBeGreaterThan(0); + expect(tmux.alive()).toBe(false); + }); +}); + +/** Wait until the composite has classified an event of this kind. */ +function untilEvent(grid: TmuxGrid, kind: ControlEvent["kind"]): Operation { + return (function* (): Operation { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + if (grid.events.some((event) => event.kind === kind)) { + return; + } + yield* sleep(15); + } + throw new Error( + `the composite never reported "${kind}"; it reported ` + + JSON.stringify(grid.events.map((event) => event.kind)), + ); + })(); +} From 13a54582411fce8681c4d29942e27eca9b6befed Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 05:53:19 -0400 Subject: [PATCH 27/62] =?UTF-8?q?=F0=9F=90=9B=20Narrow=20the=20visible=20c?= =?UTF-8?q?lient,=20and=20make=20teardown=20prove=20itself=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The visible client is not a pane child.** A pane child is settled by sweeping its process group and its terminal, because a pane's terminal belongs to the grid. The reader's terminal belongs to the run: everything holding it is XMD, whatever started XMD, and the rest of XMD's foreground group. A settlement of that shape aimed at the attach client is a settlement aimed at the document. `attach-client.ts` owns exactly one process instead — asked to detach first, through tmux, and only then insisted on by pid, with no group, no descendants and no terminal sweep anywhere in it. **A successful `kill-server` is not proof.** Teardown now succeeds only once the recorded server pid is unreachable and the server refuses its own session, and throws a provider-neutral `TerminalTeardownFailed` when either is still unproved at the bound. The rule is in the resource finalizer too, so a preparation that failed halfway is held to it as well. **Nothing private in a diagnostic.** `TmuxCommandFailed` carries the step's name and nothing else — not the arguments, which hold the socket path, session name, pane and client identifiers and the worker's private directory, and not stderr, which tmux writes paths into. A provider's topology stays private on the paths taken when something goes wrong, which are the paths a diagnostic is read on. **Closures before removal.** The private directory is removed only after every accepted socket and every listening server has actually closed — counted from their own `close` events rather than from having been asked. Three regressions, each broken on purpose and re-run: - TG11 gives the process table company — XMD, its parent, two more in the same group, and four holders of the reader's terminal — and proves the escalation reaches the client's pid alone. Settling it like a pane child fails it. - TG10 plants markers in the socket, session, pane and client identifiers, the worker directory, the arguments and stderr, and proves none reaches the surfaced error. Restoring raw arguments fails it. - TG12 counts real closures at the moment of removal. Not awaiting them fails it. Also conformed to the repository's rules: `@effectionx/fs` for stat, rm, readTextFile and writeTextFile, with `node:fs/promises` kept only for `chmod` and `appendFile`, both adapted through `until`; the client fixture is an Effection operation; and the newly introduced `as const` assertions are gone in favour of typed values. --- packages/cli/src/terminal/attach-client.ts | 142 +++++++++ packages/cli/src/terminal/pane-channel.ts | 69 ++++- packages/cli/src/terminal/pane-child.ts | 8 +- packages/cli/src/terminal/pane-worker.ts | 3 +- packages/cli/src/terminal/tmux-grid.ts | 87 +++--- packages/cli/src/terminal/tmux.ts | 35 ++- packages/cli/tests/fixtures/fake-tmux.ts | 20 +- packages/cli/tests/fixtures/tmux-client.ts | 61 ++-- packages/cli/tests/terminal-grid-tmux.test.ts | 284 +++++++++++++++--- 9 files changed, 588 insertions(+), 121 deletions(-) create mode 100644 packages/cli/src/terminal/attach-client.ts diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts new file mode 100644 index 000000000..031425927 --- /dev/null +++ b/packages/cli/src/terminal/attach-client.ts @@ -0,0 +1,142 @@ +/** + * The one visible client: the reader's own view of a grid + * (architecture.md §Interactive terminal grids). + * + * Deliberately *not* a pane child. A pane's child is settled by sweeping the + * pane's process group and the pane's terminal, because a pane's terminal + * belongs to the grid. This process's terminal belongs to the run: the things + * holding it are XMD itself, whatever started XMD, and everything else in XMD's + * foreground process group. A settlement of that shape pointed at this client + * would be a settlement pointed at the document. + * + * So the rule here is narrow and absolute. This ends **one** process — the exact + * one it started — and nothing else. It signals no group, sweeps no terminal, + * and follows no descendants. Ending it is asked for first, through tmux, so + * the client detaches and restores the terminal itself; a signal is what + * follows only if the ask did not work, and it goes to that pid alone. + */ + +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { ensure, race, resource, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; +import { deliverSignal, processReachable } from "@executablemd/runtime"; + +export interface AttachClient { + /** The client process, once the runtime says it started. */ + readonly pid: number; + /** Settles when it leaves, however it leaves. */ + readonly exited: Operation; + /** + * End it: ask first, then insist on this pid alone. + * + * Idempotent, and safe to call from a finalizer — a client that already left + * is the outcome this was asking for. + */ + stop(): Operation; +} + +const INTERRUPT_GRACE_MS = 2_000; +const KILL_SETTLE_MS = 500; +const POLL_MS = 25; + +/** + * Start the visible client, and own exactly its lifetime. + * + * `askToLeave` is the provider's way of telling tmux to detach this client. It + * runs before any signal, because a client asked to detach restores the + * terminal and one that is killed cannot. + */ +export function useAttachClient(options: { + readonly argv: readonly string[]; + readonly cwd: string; + readonly env: Record; + askToLeave(): Operation; +}): Operation { + return resource(function* (provide) { + const [command, ...args] = options.argv; + if (command === undefined) { + throw new Error("the visible client names no command"); + } + const started = withResolvers(); + const failed = withResolvers(); + const exited = withResolvers(); + let gone = false; + let child: ChildProcess | undefined; + let stopping: ReturnType> | undefined; + + function* stop(): Operation { + if (stopping) { + return yield* stopping.operation; + } + stopping = withResolvers(); + try { + yield* end(); + stopping.resolve(); + } catch (error) { + stopping.reject(error instanceof Error ? error : new Error(String(error))); + throw error; + } + } + + function* end(): Operation { + const pid = child?.pid; + if (gone || pid === undefined) { + return; + } + // Asked, not told. This is the only path that gives the reader their + // terminal back in the state they lent it. + yield* options.askToLeave(); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + // It did not leave. From here the escalation names this one pid and + // nothing else: no process group, no terminal holders, no descendants — + // every one of which would, on this terminal, be the run itself. + yield* deliverSignal(pid, "SIGTERM"); + if (yield* leftWithin(INTERRUPT_GRACE_MS, pid)) { + return; + } + yield* deliverSignal(pid, "SIGKILL"); + yield* leftWithin(KILL_SETTLE_MS, pid); + } + + function* leftWithin(limitMs: number, pid: number): Operation { + const deadline = Date.now() + limitMs; + while (Date.now() < deadline) { + if (gone || !(yield* processReachable(pid))) { + return true; + } + yield* sleep(POLL_MS); + } + return gone; + } + + // Registered before the spawn: a halt between starting a client and + // registering its cleanup would leave it holding the terminal. + yield* ensure(function* () { + yield* stop(); + }); + + child = spawnChild(command, args, { + cwd: options.cwd, + env: options.env, + // The reader's terminal, handed straight through. + stdio: "inherit", + }); + child.once("spawn", () => { + if (child?.pid !== undefined) { + started.resolve(child.pid); + } + }); + child.once("error", (error: Error) => failed.reject(error)); + child.once("exit", () => { + gone = true; + exited.resolve(); + }); + + // The pid, or whatever arrived instead of a start. + const pid = yield* race([started.operation, failed.operation]); + yield* provide({ pid, exited: exited.operation, stop }); + }); +} diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 77ac3eee0..42c4871fa 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -80,7 +80,20 @@ interface Slot { * temporary directory is world-writable still gets a private grid, because the * mode is set on the directory this creates rather than inherited from it. */ -export function usePaneChannels(count: number): Operation { +export function usePaneChannels( + count: number, + options: { + onClosed?: () => void; + /** + * Called as the directory is removed, with how many of the sockets and + * servers had actually reported closing by then. + * + * Counted from their own `close` events rather than from having asked, so a + * caller can tell "closed" from "told to close". + */ + onRemoved?: (facts: { closed: number; total: number }) => void; + } = {}, +): Operation { return resource(function* (provide) { // Directly under `$TMPDIR`: a socket path is capped at 104 bytes, and a // directory named after a repository path spends most of that before the @@ -88,7 +101,13 @@ export function usePaneChannels(count: number): Operation { const directory = path.join(os.tmpdir(), `xmd-grid-${randomBytes(6).toString("hex")}`); yield* ensureDir(directory); yield* until(chmod(directory, 0o700)); - yield* ensure(() => rm(directory, { recursive: true, force: true })); + // Registered first, so it runs last: the directory goes only after every + // socket and server below has actually closed. Removing it while a server + // still listened would leave a socket bound to a path nothing can name. + yield* ensure(function* () { + options.onRemoved?.({ closed: closedCount, total: closable }); + yield* rm(directory, { recursive: true, force: true }); + }); const tokens = new Map(); const slots = new Map(); @@ -96,14 +115,26 @@ export function usePaneChannels(count: number): Operation { const live = new Set(); const refusals: string[] = []; const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); + /** Closures that have actually happened, by their own events. */ + let closedCount = 0; + let closable = 0; - yield* ensure(() => { + // Awaited, not asked for. `destroy()` and `close()` are requests; what the + // directory's removal has to wait for is the closures themselves. + yield* ensure(function* () { + const closings: Operation[] = []; for (const socket of live) { + closings.push(closed(socket)); socket.destroy(); } for (const server of servers) { + closings.push(shut(server)); server.close(); } + for (const closing of closings) { + yield* closing; + } + options.onClosed?.(); }); // Subscribed before a single server listens, so no arrival is missed. @@ -118,10 +149,18 @@ export function usePaneChannels(count: number): Operation { const server = net.createServer((socket) => { live.add(socket); - socket.once("close", () => live.delete(socket)); + closable++; + socket.once("close", () => { + live.delete(socket); + closedCount++; + }); arrivals.send({ ordinal, socket }); }); servers.push(server); + closable++; + server.once("close", () => { + closedCount++; + }); const listening = withResolvers(); server.once("error", (error: Error) => listening.reject(error)); server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); @@ -189,6 +228,28 @@ export function usePaneChannels(count: number): Operation { }); } +/** Settle once this socket has closed, whether or not it already had. */ +function closed(socket: Socket): Operation { + const done = withResolvers(); + if (socket.destroyed) { + done.resolve(); + } else { + socket.once("close", () => done.resolve()); + } + return done.operation; +} + +/** Settle once this server has stopped listening. */ +function shut(server: Server): Operation { + const done = withResolvers(); + if (!server.listening) { + done.resolve(); + } else { + server.once("close", () => done.resolve()); + } + return done.operation; +} + /** A connection that has said nothing for long enough to be nobody. */ function* silence(): Operation> { yield* sleep(HELLO_GRACE_MS); diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index c0f4f540b..b44cca389 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -94,9 +94,15 @@ export function usePaneChild( } settling = withResolvers(); try { + const nothingStarted: Settlement = { + method: "exited", + quiet: true, + swept: [], + holders: [], + }; const settlement = child === undefined || child.pid === undefined - ? { method: "exited" as const, quiet: true, swept: [], holders: [] } + ? nothingStarted : yield* escalate(child, child.pid, tty, () => outcome !== undefined); settling.resolve(settlement); return settlement; diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 3705862dc..e11f1d972 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -112,7 +112,8 @@ interface Live { * dispositions across `exec`, so it receives the same signal and acts on it. */ export function ignoreForegroundSignals(): void { - for (const name of ["SIGINT", "SIGQUIT", "SIGTSTP"] as const) { + const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; + for (const name of foreground) { process.on(name, () => {}); } } diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts index 3c39f5e01..61d6d986c 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -33,8 +33,9 @@ import type { Operation } from "effection"; import { processReachable } from "@executablemd/runtime"; import { layoutString, swapsInto } from "./layout.ts"; import type { LayoutCell } from "./layout.ts"; -import { usePaneChild } from "./pane-child.ts"; -import type { PaneChild } from "./pane-child.ts"; +import { useAttachClient } from "./attach-client.ts"; +import type { AttachClient } from "./attach-client.ts"; +import { TerminalTeardownFailed } from "./tmux.ts"; import type { Tmux } from "./tmux.ts"; /** What one prepared pane is, from the composite's side. */ @@ -80,7 +81,7 @@ export interface ServerStopped { } export interface VisibleClient { - readonly child: PaneChild; + readonly client: AttachClient; /** tmux's name for this client once attached: its tty. */ readonly name: string; } @@ -95,7 +96,13 @@ export interface TmuxGrid { attach(): Operation; /** Ask the visible client to leave, so it restores the terminal itself. */ detach(client: VisibleClient): Operation; - /** Stop the server, and establish that it is gone. */ + /** + * Stop the server, and establish that it is gone. + * + * Refuses rather than reporting: an unproved teardown throws, because a + * document that continued past one would be continuing while a terminal may + * still be held. + */ stop(): Operation; } @@ -116,16 +123,21 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { yield* tmux.tryRun(["kill-server"]); const deadline = Date.now() + STOP_LIMIT_MS; - let stopped: ServerStopped; + let stopped: ServerStopped = { gone: false, refuses: false }; do { stopped = { gone: serverPid < 0 || !(yield* processReachable(serverPid)), - // The socket file outlives the server, so "gone" is the pid being - // unreachable and nothing answering for the session — never the - // socket file's absence. refuses: (yield* tmux.tryRun(["has-session", "-t", request.session])) === undefined, }; if (stopped.gone && stopped.refuses) { @@ -133,9 +145,19 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation pane.cell); }, *attach() { - const child = yield* usePaneChild( - { - argv: tmux.argv(["attach-session", "-t", request.session]), - cwd: request.cwd, - env: request.env, + // Its own lifecycle, not a pane child's. A pane child is settled by + // sweeping its process group and its terminal; this client's terminal + // is the reader's, and everything holding it is the run. + let named: string | undefined; + const client = yield* useAttachClient({ + argv: tmux.argv(["attach-session", "-t", request.session]), + cwd: request.cwd, + env: request.env, + *askToLeave() { + if (named === undefined) { + return; + } + yield* tmux.tryRun(["detach-client", "-t", named]); }, - // No terminal sweep for this one. Its terminal is the reader's, and - // the processes holding it are the run itself. - undefined, - ); - const started = yield* child.started; - if (!started.ok) { - throw started.error; - } - const name = yield* awaitClient(tmux); - return { child, name }; + }); + named = yield* awaitClient(tmux); + return { client, name: named }; }, *detach(client) { - // Asked to leave before being signalled: a client that detaches - // restores the terminal itself, and one that is killed cannot. - yield* tmux.tryRun(["detach-client", "-t", client.name]); - const deadline = Date.now() + DETACH_LIMIT_MS; - while (Date.now() < deadline) { - if (!(yield* clientNames(tmux)).includes(client.name)) { - break; - } - yield* sleep(CLIENT_POLL_MS); - } - // Whatever the client did about it, the process is this scope's. - yield* client.child.settle(); + // The ask is inside `stop()`, which is what makes the order the same + // however the grid ends: asked first, and only this exact process + // insisted on afterwards. + yield* client.client.stop(); }, stop, }); diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts index 2ea03a365..c9be63cf7 100644 --- a/packages/cli/src/terminal/tmux.ts +++ b/packages/cli/src/terminal/tmux.ts @@ -38,12 +38,37 @@ export interface Tmux { argv(args: readonly string[]): readonly string[]; } +/** + * One tmux command did not work. + * + * The message names the command and nothing else. Not the arguments — they + * carry the socket path, the session name, pane and client identifiers and the + * worker's private directory. Not the exit status text — tmux writes paths into + * it. A provider's private topology is private on every path out of it, + * including the ones only taken when something has gone wrong, which are + * exactly the paths a diagnostic is read on. + */ export class TmuxCommandFailed extends Error { override name = "TmuxCommandFailed"; - constructor(args: readonly string[], stderr: string, code: number | undefined) { - // The command, not the socket: a diagnostic names what was asked for and - // never where this invocation's private server lives. - super(`tmux ${args.join(" ")} failed (${code ?? "signal"}): ${stderr.trim()}`); + constructor(readonly command: string) { + super(`the terminal provider's "${command}" step failed`); + } +} + +/** + * A grid could not be proved taken down. + * + * Distinct from a command that failed: this is the provider having done + * everything it can and still being unable to say that nothing is left running. + * The document does not continue past it. + */ +export class TerminalTeardownFailed extends Error { + override name = "TerminalTeardownFailed"; + constructor(unproved: string) { + super( + `the terminal grid could not be proved torn down: ${unproved}. The document ` + + `stops rather than continuing while a terminal may still be held.`, + ); } } @@ -70,7 +95,7 @@ export function tmuxAt(socket: string, env: Record): Tmux { *run(args) { const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); if (result.code !== 0) { - throw new TmuxCommandFailed(args, result.stderr, result.code); + throw new TmuxCommandFailed(args[0] ?? ""); } return result.stdout.trim(); }, diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts index f0824a433..e48e7af1a 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -21,6 +21,7 @@ import { appendFile } from "node:fs/promises"; import { until } from "effection"; import type { Operation } from "effection"; +import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; import type { Tmux } from "../../src/terminal/tmux.ts"; export interface FakePane { @@ -46,6 +47,15 @@ export interface FakeTmuxOptions { readonly clientCommand: (mode: "control" | "attach", script: string) => readonly string[]; /** Fail this command once, with this message. */ readonly failOnce?: { readonly command: string; readonly message: string }; + /** Name the server gives an attached client. */ + readonly clientName?: string; + /** + * A client that does not leave when it is asked. + * + * The server still reports the detach, but the client's process stays — which + * is the only way to reach the escalation that follows the ask. + */ + readonly stubbornClient?: boolean; } export interface FakeTmux extends Tmux { @@ -236,7 +246,9 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { if (at >= 0) { clients.splice(at, 1); } - yield* until(appendFile(options.script, "detached\n")); + if (options.stubbornClient !== true) { + yield* until(appendFile(options.script, "detached\n")); + } yield* until(appendFile(options.script, `%client-detached ${name}\n`)); return ""; } @@ -266,7 +278,7 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { const mode = args.includes("-C") ? "control" : "attach"; if (mode === "attach") { // A visible client the server can list, named the way tmux names one. - clients.push("/dev/ttys999"); + clients.push(options.clientName ?? "/dev/ttys999"); } return options.clientCommand(mode, options.script); }, @@ -276,7 +288,9 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { *run(args) { const answered = yield* answer(args); if (answered === undefined) { - throw new Error(`fake tmux refused: ${args.join(" ")}`); + // The same failure the real surface raises, so what a caller sees on + // this path is what a caller sees on that one. + throw new TmuxCommandFailed(args[0] ?? ""); } return answered; }, diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/cli/tests/fixtures/tmux-client.ts index 34bf8eb0a..558d3af2d 100644 --- a/packages/cli/tests/fixtures/tmux-client.ts +++ b/packages/cli/tests/fixtures/tmux-client.ts @@ -21,41 +21,54 @@ * the terminal back when asked to detach is #726's evidence, on real tmux. */ -import { readFile } from "node:fs/promises"; import process from "node:process"; +import { exists, readTextFile } from "@effectionx/fs"; +import { run, sleep, withResolvers } from "effection"; +import type { Operation } from "effection"; const POLL_MS = 15; -const [mode, script] = process.argv.slice(2); -if ((mode !== "control" && mode !== "attach") || script === undefined) { - process.stderr.write("usage: tmux-client.ts \n"); - process.exit(2); -} +type Mode = "control" | "attach"; /** Everything the script says so far, or nothing while it does not exist. */ -async function read(): Promise { - try { - const text = await readFile(script, "utf8"); - return text.split("\n").filter((line) => line.length > 0); - } catch { +function* said(script: string): Operation { + if (!(yield* exists(script))) { return []; } + const text = yield* readTextFile(script); + return text.split("\n").filter((line) => line.length > 0); +} + +function write(text: string): Operation { + const written = withResolvers(); + process.stdout.write(text, () => written.resolve()); + return written.operation; } -let seen = 0; -for (;;) { - const said = await read(); - for (const line of said.slice(seen)) { - if (mode === "control") { - process.stdout.write(`${line}\n`); - if (line.startsWith("%exit")) { - process.exit(0); +/** Follow the script until it says this client is finished. */ +export function* followScript(mode: Mode, script: string): Operation { + let seen = 0; + while (true) { + const lines = yield* said(script); + for (const line of lines.slice(seen)) { + if (mode === "control") { + yield* write(`${line}\n`); + if (line.startsWith("%exit")) { + return; + } + } else if (line === "detached") { + // The reader left. A real client would restore the terminal here. + return; } - } else if (line === "detached") { - // The reader left. A real client would restore the terminal here. - process.exit(0); } + seen = lines.length; + yield* sleep(POLL_MS); } - seen = said.length; - await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } + +const [mode, script] = process.argv.slice(2); +if ((mode !== "control" && mode !== "attach") || script === undefined) { + process.stderr.write("usage: tmux-client.ts \n"); + process.exit(2); +} +await run(() => followScript(mode, script)); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 7e0de0821..6ef7ef523 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -21,13 +21,13 @@ import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; -import { stat } from "node:fs/promises"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; +import { exists, rm, stat, writeTextFile } from "@effectionx/fs"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { rm, writeFile } from "node:fs/promises"; import { TerminalProcesses } from "@executablemd/runtime"; +import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; import { createFakeTmux } from "./fixtures/fake-tmux.ts"; @@ -48,7 +48,7 @@ import { writeFrame, } from "../src/terminal/pane-protocol.ts"; import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; -import type { FromWorker } from "../src/terminal/pane-protocol.ts"; +import type { FromWorker, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -76,12 +76,13 @@ describe("Tier TX — the tmux grid's geometry", () => { it("TX1: an authored column count survives every terminal size", function* () { // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would // have made the wide one 4×1 and the tall one 1×4. - for (const [width, height] of [ + const sizes: [number, number][] = [ [80, 24], [200, 24], [80, 60], [211, 51], - ] as const) { + ]; + for (const [width, height] of sizes) { const cells = rowMajorCells(width, height, 2, 4); const rows = new Set(cells.map((cell) => cell.top)); const columns = new Set(cells.map((cell) => cell.left)); @@ -261,14 +262,14 @@ function closedWithin(socket: net.Socket, limitMs: number): Operation { describe("Tier TW — the pane worker and its private channel", () => { it("TW1: the private directory is 0700 and its tokens 0600", function* () { const channels: PaneChannels = yield* usePaneChannels(2); - const directory = yield* until(stat(channels.directory)); + const directory = yield* stat(channels.directory); expect(directory.mode & 0o777).toBe(0o700); for (const ordinal of [0, 1]) { - const token = yield* until(stat(paneTokenPath(channels.directory, ordinal))); + const token = yield* stat(paneTokenPath(channels.directory, ordinal)); expect(`pane ${ordinal}: ${(token.mode & 0o777).toString(8)}`).toBe(`pane ${ordinal}: 600`); // The socket exists before any pane does, so a worker that starts finds // it listening rather than racing it. - yield* until(stat(paneSocketPath(channels.directory, ordinal))); + expect(yield* exists(paneSocketPath(channels.directory, ordinal))).toBe(true); } }); @@ -278,13 +279,7 @@ describe("Tier TW — the pane worker and its private channel", () => { const channels = yield* usePaneChannels(1); directory = channels.directory; }); - const gone = yield* until( - stat(directory).then( - () => false, - () => true, - ), - ); - expect(gone).toBe(true); + expect(yield* exists(directory)).toBe(false); }); it("TW3: a real worker connects, proves which pane it is, and spends its token", function* () { @@ -296,13 +291,7 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(link.hello.pid).toBeGreaterThan(0); // Spent as it was read: a second worker for this pane finds no token, so // it has nothing to present. - const spent = yield* until( - stat(paneTokenPath(channels.directory, 0)).then( - () => false, - () => true, - ), - ); - expect(spent).toBe(true); + expect(yield* exists(paneTokenPath(channels.directory, 0))).toBe(false); expect(channels.refusals()).toEqual([]); }); @@ -410,8 +399,8 @@ describe("Tier TW — the pane worker and its private channel", () => { yield* useWorker(channels.directory, 0); const link = yield* channels.link(0); - const sleeper = { - type: "launch" as const, + const sleeper: ToWorker = { + type: "launch", id: "first", argv: ["/bin/sleep", "30"], cwd: path.resolve("."), @@ -510,14 +499,47 @@ describe("Tier TW — the pane worker and its private channel", () => { * gives the reader's terminal back when asked to detach is #726's evidence, on * real tmux, and nothing here stands in for it. */ +/** Planted where a diagnostic could pick one up, and nowhere a reader looks. */ +const SESSION_MARKER = "sessionmarker7f3a"; +const DIR_MARKER = "/tmp/dirmarker7f3a"; +const CLIENT_MARKER = "clientmarker7f3a"; +const TITLE_MARKER = "titlemarker7f3a"; +const ENV_MARKER = "envmarker7f3a"; +const STDERR_MARKER = "stderrmarker7f3a"; + describe("Tier TG — the tmux composite", () => { + /** A host whose processes are all gone, so teardown proves itself. */ + function useDeadServer(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); + } + /** Where a fake server and its client fixtures meet. */ function useScript(): Operation { return resource(function* (provide) { const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); - yield* until(writeFile(file, "")); + yield* writeTextFile(file, ""); yield* ensure(function* () { - yield* until(rm(file, { force: true })); + yield* rm(file, { force: true }); }); yield* provide(file); }); @@ -559,8 +581,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "absent" as const; + *deliver(): Operation { + return "absent"; }, // deno-lint-ignore require-yield *reachable([pid]) { @@ -694,11 +716,11 @@ describe("Tier TG — the tmux composite", () => { expect(tmux.alive()).toBe(false); }); - it("TG8: a server that will not go away is not reported gone", function* () { + it("TG8: a server that will not go away is a teardown failure, not a report", function* () { const script = yield* useScript(); const tmux = createFakeTmux({ script, clientCommand }); - // The server answers `kill-server` and stays anyway. Nothing about the - // command having been accepted is evidence that it worked. + // The server answers `kill-server` and stays anyway. That the command was + // accepted is not evidence that it worked. yield* TerminalProcesses.around( { // deno-lint-ignore require-yield @@ -710,8 +732,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "delivered" as const; + *deliver(): Operation { + return "delivered"; }, // deno-lint-ignore require-yield *reachable() { @@ -720,22 +742,190 @@ describe("Tier TG — the tmux composite", () => { }, { at: "min" }, ); - const grid = yield* useTmuxGrid(tmux, { - session: "grid", - columns: 1, - panes: 1, - width: 160, - height: 48, - titles: ["only"], - workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/private/dir"], - cwd: path.resolve("."), - env: {}, + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: {}, + }); + yield* grid.stop(); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The document stops rather than continuing while a terminal may be held, + // and it is told which fact could not be established — never the session or + // socket that would name this invocation's private server. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("did not stop"); + for (const marker of [SESSION_MARKER, DIR_MARKER, tmux.socket]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG10: nothing private reaches a surfaced failure", function* () { + // A marker in every place a tmux diagnostic could pick one up: the socket, + // the session, the pane and client identifiers, the worker's private + // directory, the arguments, and what the command wrote to stderr. + const script = yield* useScript(); + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + failOnce: { command: "split-window", message: `stderr ${STDERR_MARKER}` }, }); + yield* useDeadServer(); - const stopped = yield* grid.stop(); - expect(stopped.gone).toBe(false); + let failure = ""; + try { + yield* scoped(function* () { + yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 2, + panes: 2, + width: 160, + height: 48, + titles: [TITLE_MARKER, TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + }); + } catch (error) { + failure = error instanceof Error ? error.message : String(error); + } + + // It says which step failed, because that is what a reader can act on. + expect(failure).toContain("split-window"); + // And nothing else. A provider's private topology is private on the paths + // taken when something goes wrong too — which are the paths a diagnostic + // is actually read on. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + STDERR_MARKER, + tmux.socket, + ...tmux.panes.map((pane) => pane.id), + ]) { + expect(`${marker}: ${failure.includes(marker)}`).toBe(`${marker}: false`); + } + }); + + it("TG11: ending the visible client signals that process and nothing else", function* () { + const script = yield* useScript(); + // A client that is asked to leave and does not, so the escalation that + // follows the ask is actually reached. + const tmux = createFakeTmux({ script, clientCommand, stubbornClient: true }); + const signalled: string[] = []; + let clientPid = -1; + + // A process table with company: XMD itself, its parent, and two more + // processes sharing XMD's foreground group. A settlement of a pane's shape + // pointed at this client would reach every one of them. + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + { pid: 903, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "cousin" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + // Everything holding the reader's terminal. None of it is this + // client's to end. + return [900, 901, 902, 903]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client refuses to leave until it has been signalled once. + return pid === clientPid && !signalled.some((entry) => entry.startsWith(`${pid}:`)); + }, + }, + { at: "min" }, + ); + + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: "visible", + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: ["only"], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), "/d"], + cwd: path.resolve("."), + env: {}, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + yield* grid.detach(visible); + }); + + // Asked first, and then exactly one process insisted on: not XMD, not its + // parent, not a sibling in the same group, and not a holder of the + // reader's terminal. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled.length).toBeGreaterThan(0); + for (const entry of signalled) { + expect(entry.split(":")[0]).toBe(String(clientPid)); + } + for (const bystander of [900, 901, 902, 903]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } }); + it("TG12: every socket and server closes before the private directory goes", function* () { + const order: string[] = []; + let directory = ""; + let atRemoval: { closed: number; total: number } | undefined; + + yield* scoped(function* () { + const channels = yield* usePaneChannels(2, { + onClosed: () => order.push("closed"), + onRemoved: (facts) => { + atRemoval = facts; + order.push("removed"); + }, + }); + directory = channels.directory; + // A worker on one of them, so there is an accepted connection to close as + // well as the servers themselves. + yield* useWorker(channels.directory, 0); + yield* channels.link(0); + }); + + // Counted from the sockets' and servers' own close events, not from having + // asked them to close: every one of them had actually closed by the time + // the directory was removed. + expect(order).toEqual(["closed", "removed"]); + expect(atRemoval?.total).toBeGreaterThan(0); + expect(`${atRemoval?.closed}/${atRemoval?.total}`).toBe( + `${atRemoval?.total}/${atRemoval?.total}`, + ); + expect(yield* exists(directory)).toBe(false); + }); it("TG9: a composite that fails while being built still takes the server down", function* () { const script = yield* useScript(); let stopping = 0; @@ -756,8 +946,8 @@ describe("Tier TG — the tmux composite", () => { return []; }, // deno-lint-ignore require-yield - *deliver() { - return "absent" as const; + *deliver(): Operation { + return "absent"; }, // deno-lint-ignore require-yield *reachable() { From 091dcebdc3ba38ace8db11c39693a445d9978fb3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:06:18 -0400 Subject: [PATCH 28/62] =?UTF-8?q?=F0=9F=90=9B=20Refuse=20when=20the=20visi?= =?UTF-8?q?ble=20client=20will=20not=20stop=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `end()` sent SIGKILL and then discarded what the wait after it established, so a client still holding the reader's terminal was reported as torn down. The shared `stop()` resolved successfully on top of that, and the document carried on. It now establishes the client is gone, and raises a provider-neutral teardown failure when it is not — so `stop()` rejects and the document stops instead. `leftWithin()` also looks once more at the boundary itself rather than falling back on the cached exit event: a client that left during the final interval is gone, and reporting it as still there would be reporting a stale reading. The boundary is unchanged and still narrow: detach is asked for through tmux first, and every signal after that names the exact client pid. Nothing inspects or signals its process group, its descendants, or the holders of the reader's terminal — on this terminal, each of those is the run itself. The refusal carries none of the socket, session, client name, argv, environment, terminal or host message. TG13 models a client that survives the ask, SIGTERM and SIGKILL: teardown refuses, the signals delivered are exactly SIGTERM and SIGKILL to the client's pid, three same-group bystanders and three holders of the reader's terminal are untouched, and no planted marker reaches the refusal. TG11's successful escalation is unchanged. Reinstating the discarded result fails TG13 and leaves TG11 green, which is the discrimination the two rows are for. --- packages/cli/src/terminal/attach-client.ts | 21 ++++- packages/cli/tests/terminal-grid-tmux.test.ts | 88 +++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index 031425927..bb6ee5db5 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -21,6 +21,7 @@ import type { ChildProcess } from "node:child_process"; import { ensure, race, resource, sleep, withResolvers } from "effection"; import type { Operation } from "effection"; import { deliverSignal, processReachable } from "@executablemd/runtime"; +import { TerminalTeardownFailed } from "./tmux.ts"; export interface AttachClient { /** The client process, once the runtime says it started. */ @@ -98,18 +99,32 @@ export function useAttachClient(options: { return; } yield* deliverSignal(pid, "SIGKILL"); - yield* leftWithin(KILL_SETTLE_MS, pid); + if (yield* leftWithin(KILL_SETTLE_MS, pid)) { + return; + } + // Everything this may do has been done, and the client is still there. + // Saying "torn down" now would be saying it about a process still holding + // the reader's terminal — so the document stops instead. Provider-neutral + // by construction: no socket, session, client name, argv, environment, + // terminal or host message goes into it. + throw new TerminalTeardownFailed("the terminal grid's visible client did not stop"); } function* leftWithin(limitMs: number, pid: number): Operation { const deadline = Date.now() + limitMs; - while (Date.now() < deadline) { + while (true) { if (gone || !(yield* processReachable(pid))) { return true; } + if (Date.now() >= deadline) { + break; + } yield* sleep(POLL_MS); } - return gone; + // One more look, at the boundary itself. A client that left during the + // last interval is gone, and reporting it as still there on the strength + // of a cached event would be reporting a stale reading. + return gone || !(yield* processReachable(pid)); } // Registered before the spawn: a halt between starting a client and diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 6ef7ef523..a4fd6be2a 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -896,6 +896,94 @@ describe("Tier TG — the tmux composite", () => { } }); + it("TG13: a visible client that survives every step refuses the teardown", function* () { + const script = yield* useScript(); + // Asked to detach and stays; signalled and stays; killed and stays. There + // is nothing further this may do, and nothing further it may claim. + const tmux = createFakeTmux({ + script, + clientCommand, + clientName: `/dev/${CLIENT_MARKER}`, + stubbornClient: true, + }); + const signalled: string[] = []; + let clientPid = -1; + + yield* TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return [ + { pid: 900, ppid: 1, pgid: 900, tty: "ttys000", tpgid: 900, command: "shell" }, + { pid: 901, ppid: 900, pgid: 900, tty: "ttys000", tpgid: 900, command: "xmd" }, + { pid: 902, ppid: 901, pgid: 900, tty: "ttys000", tpgid: 900, command: "sibling" }, + ]; + }, + // deno-lint-ignore require-yield + *holders() { + return [900, 901, 902]; + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + signalled.push(`${pid}:${signal}`); + return "delivered"; + }, + // deno-lint-ignore require-yield + *reachable([pid]) { + // The client never goes. The server does, so the refusal that + // surfaces is the client's rather than the server's. + return pid === clientPid; + }, + }, + { at: "min" }, + ); + + let refusal = ""; + try { + yield* scoped(function* () { + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 160, + height: 48, + titles: [TITLE_MARKER], + workerCommand: (ordinal) => ["xmd", "terminal-worker", String(ordinal), DIR_MARKER], + cwd: path.resolve("."), + env: { PRIVATE: ENV_MARKER }, + }); + const visible = yield* grid.attach(); + clientPid = visible.client.pid; + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // It refuses rather than continuing: a document that carried on here would + // carry on while a process still holds the reader's terminal. + expect(refusal).toContain("could not be proved torn down"); + expect(refusal).toContain("visible client did not stop"); + // Nothing private in it. + for (const marker of [ + SESSION_MARKER, + DIR_MARKER, + CLIENT_MARKER, + TITLE_MARKER, + ENV_MARKER, + tmux.socket, + "ttys000", + ]) { + expect(`${marker}: ${refusal.includes(marker)}`).toBe(`${marker}: false`); + } + // The boundary held all the way through the escalation: it was asked + // first, and every signal after that named the client alone. + expect(tmux.issued.some((line) => line.startsWith("detach-client"))).toBe(true); + expect(signalled).toEqual([`${clientPid}:SIGTERM`, `${clientPid}:SIGKILL`]); + for (const bystander of [900, 901, 902]) { + expect(signalled.some((entry) => entry.startsWith(`${bystander}:`))).toBe(false); + } + }); + it("TG12: every socket and server closes before the private directory goes", function* () { const order: string[] = []; let directory = ""; From 8acedf1092219e8fe19673b115f2429aa2f4bced Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:18:21 -0400 Subject: [PATCH 29/62] =?UTF-8?q?=E2=9C=A8=20Install=20the=20tmux=20grid?= =?UTF-8?q?=20provider=20on=20the=20foreground=20hosts=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `provider.ts` is where #730's provider-neutral request meets tmux: it prepares the private channels, the hidden server and the panes, resolves each pane's worker command before a server exists, and hands core a composite it drives through its own lifecycle. Nothing tmux-shaped crosses in either direction. The reader leaving and the host's terminal going away settle the same `closed()`. That is deliberate: a hangup is not a second teardown path to keep honest separately, it is the ordinary structured close every other stop uses. The SIGHUP listener is a resource, so it is removed with the run rather than answering for a terminal the next one is using. `host.ts` states which hosts present grids. The Deno entrypoint and the compiled binary supply `foregroundTerminalGrid()`; every other caller gets `unsupportedTerminalGrid`, which still opens the installation so a grid is validated and refused by core rather than being silently absent. Node and Bun therefore catalog and validate the same grids and open none — threaded through `AgentStack` beside the machine-session assembly, which is the same shape this repository already uses for "Deno supplies the live one, Node and Bun supply the one that installs nothing". architecture.md's terminal-grid inventory row said "implementation unbuilt", which four layers had made untrue. It now says what each Story built, that the controlled provider remains the authority for core lifecycle semantics, that this Story's evidence uses a fake tmux with real tmux behaviour remaining #726's, and that Node and Bun install no operational provider. Checkpoint 3's evidence is not in this commit: the Node/Bun refusal row, the SIGHUP-through-host-installation row, and the CLI regressions are still to come. --- architecture.md | 2 +- packages/cli/src/agent-stack.ts | 21 +- packages/cli/src/cli.ts | 14 +- packages/cli/src/compiled.ts | 4 + packages/cli/src/deno.ts | 4 + packages/cli/src/terminal/host.ts | 97 ++++++++++ packages/cli/src/terminal/provider.ts | 253 +++++++++++++++++++++++++ packages/cli/src/terminal/tmux-grid.ts | 35 +++- 8 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 packages/cli/src/terminal/host.ts create mode 100644 packages/cli/src/terminal/provider.ts diff --git a/architecture.md b/architecture.md index 3bf3bcdd1..6683b36f2 100644 --- a/architecture.md +++ b/architecture.md @@ -4469,7 +4469,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; first production provider is tmux in the Deno and compiled foreground hosts; controlled non-tmux provider proves the core contract; implementation unbuilt | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane-scoped native Agent launch in #731; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 8788b2aea..6c855b87c 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -22,6 +22,8 @@ import { } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; +import { unsupportedTerminalGrid } from "./terminal/host.ts"; +import type { TerminalGridInstaller } from "./terminal/host.ts"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and @@ -67,6 +69,14 @@ export interface AuthorshipStack { adapters: EmbeddedAdapters; /** What this host states about machine-wide agent sessions, if anything. */ sessions?: MachineSessionAssembly; + /** + * What presents this host's terminal grids. + * + * Deno and the compiled binary supply the tmux provider; Node and Bun supply + * the one that installs none, so those runtimes describe and validate the + * same grids and open none of them. + */ + installTerminalGrid?: TerminalGridInstaller; } /** Everything one `xmd run` invocation settled about agents, resolved once. */ @@ -106,6 +116,7 @@ export function* resolveAuthorshipStack( export function* resolveAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installTerminalGrid?: TerminalGridInstaller, ): Operation> { const config = resolveAgentConfig(flags); if ("error" in config) { @@ -118,7 +129,11 @@ export function* resolveAgentStack( if (!authorship.ok) { return authorship; } - return Ok({ ...authorship.value, permissionMode: config.permissionMode }); + return Ok({ + ...authorship.value, + permissionMode: config.permissionMode, + ...(installTerminalGrid === undefined ? {} : { installTerminalGrid }), + }); } /** @@ -179,4 +194,8 @@ export function* installRunAgentStack(stack: AgentStack): Operation { // document inspection and `xmd test` install no launcher, so a document that // reaches under any of them refuses instead of spawning. yield* installForegroundLauncher(); + // And whatever presents this host's terminal grids, which on a host that + // presents none still opens the installation so a grid is validated — the + // refusal a document meets there is core's own. + yield* (stack.installTerminalGrid ?? unsupportedTerminalGrid)(); } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9ef3c0045..35dc9b579 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -96,6 +96,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; import { installRunAgentStack, resolveAgentStack, resolveAuthorshipStack } from "./agent-stack.ts"; +import { unsupportedTerminalGrid } from "./terminal/host.ts"; +import type { TerminalGridInstaller } from "./terminal/host.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./authorship-profile.ts"; import { useVerboseComponent } from "./verbose-component.ts"; @@ -781,8 +783,9 @@ function* underRunDeadline(timeouts: RunTimeouts, body: () => Operation): function* settleAgentStack( flags: AgentFlags, sessions: MachineSessionAssembly | undefined, + installTerminalGrid: TerminalGridInstaller, ): Operation { - const stack = yield* resolveAgentStack(flags, sessions); + const stack = yield* resolveAgentStack(flags, sessions, installTerminalGrid); if (!stack.ok) { console.error(stack.error.message); yield* exit(1); @@ -2353,6 +2356,7 @@ function* dispatch( readStandardInput: StandardInputReader, workflowHost: WorkflowHost | undefined, sessions: MachineSessionAssembly | undefined, + installTerminalGrid: TerminalGridInstaller, ): Operation { // Before the props phase, and before the help short-circuit below. `--help` // is lifted out of argv early enough that a command's own grammar never sees @@ -2451,6 +2455,7 @@ function* dispatch( denyAll: config.denyAll, }, sessions, + installTerminalGrid, ); if (runStack === undefined) { break; @@ -2506,6 +2511,7 @@ function* dispatch( const authorship = yield* resolveAuthorshipStack( { agentProvider: config.agentProvider, defaultAgent: config.defaultAgent }, sessions, + installTerminalGrid, ); if (!authorship.ok) { console.error(authorship.error.message); @@ -2809,6 +2815,10 @@ export function* runXmd( // owns the session or which build it belongs to. A caller that names none // gets no machine sessions at all, which is the ordinary ACP behaviour. sessions?: MachineSessionAssembly, + // What presents a terminal grid on this host. Deno and the compiled binary + // supply the tmux provider; Node and Bun supply the one that installs none, + // so those runtimes describe and validate the same grids and open none. + installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid, ): Operation { // Before every scanner, before command selection, and before anything reads a // path. `prompt` names no command, and a first token that names none is a @@ -2878,6 +2888,7 @@ export function* runXmd( readStandardInput, workflowHost, sessions, + installTerminalGrid, ); } @@ -2900,6 +2911,7 @@ export function* runXmd( readStandardInput, workflowHost, sessions, + installTerminalGrid, ), ); } diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 33eceb748..0deb2ad58 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -20,6 +20,7 @@ import { runCredentialHelper, } from "@executablemd/workflow/credential-helper"; import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; +import { foregroundTerminalGrid } from "./terminal/host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; @@ -100,6 +101,9 @@ if (paneWorker !== undefined) { () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundTerminalGrid(), ); }); } diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index d882a3c01..dda509142 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -23,6 +23,7 @@ import { runCredentialHelper, } from "@executablemd/workflow/credential-helper"; import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; +import { foregroundTerminalGrid } from "./terminal/host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; @@ -119,6 +120,9 @@ if (paneWorker !== undefined) { () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), useMachineSessions(), + // This host presents grids: it has a terminal to divide, and it can + // re-invoke itself for one pane. + foregroundTerminalGrid(), ); }); } diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts new file mode 100644 index 000000000..b935cb41b --- /dev/null +++ b/packages/cli/src/terminal/host.ts @@ -0,0 +1,97 @@ +/** + * Which hosts open a terminal grid, and which only describe one + * (architecture.md §Interactive terminal grids). + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language, catalog and validation and install no operational provider — a + * document that asks for a grid there is refused before a pane starts, rather + * than part-way through one. + * + * That is a fact about the host, so the entrypoint states it rather than this + * module inferring it. `unsupportedTerminalGrid` is the honest half of the same + * choice: it installs nothing, and the refusal a document meets is the one core + * already gives when no provider is installed. + */ + +import { ensure, resource, withResolvers } from "effection"; +import type { Operation } from "effection"; +import process from "node:process"; +import { installTerminalGridProfile } from "@executablemd/core"; +import { command as hostCommand } from "@executablemd/runtime"; +import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; +import type { TmuxProviderDependencies } from "./provider.ts"; +import { paneEnvironment } from "./tmux.ts"; +import { PANE_WORKER_COMMAND } from "./pane-worker.ts"; + +/** How a host installs whatever presents its terminal grids. */ +export type TerminalGridInstaller = () => Operation; + +/** + * A host that describes grids and presents none. + * + * Not an error, and not silence either: the installation is opened so a grid is + * still validated, and core's own refusal is what a document meets when it asks + * for one to be shown. + */ +export function* unsupportedTerminalGrid(): Operation { + yield* installTerminalGridProfile(); +} + +/** The terminal this run is drawing on, as tmux needs to know it. */ +function windowSize(): { columns: number; rows: number } { + // A terminal that cannot say gets the sizes tmux itself defaults to, which is + // better than a grid that refuses to lay out at all. + return { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }; +} + +/** + * Settle when this process's terminal goes away. + * + * SIGHUP is the terminal saying it is gone. What follows is the ordinary + * structured cancellation a reader's close would cause — the grid comes down + * the same way, through the same teardown, rather than through a second path + * that would have to be kept honest separately. + * + * Registered as a resource so the handler is removed with the run: a listener + * that outlived its grid would answer for a terminal the next one is using. + */ +export function useHangup(): Operation> { + return resource>(function* (provide) { + const hung = withResolvers(); + const onHangup = (): void => hung.resolve(); + process.on("SIGHUP", onHangup); + yield* ensure(() => { + process.off("SIGHUP", onHangup); + }); + yield* provide(hung.operation); + }); +} + +/** + * Install the tmux provider for a foreground host. + * + * `workerCommand` is how this host re-invokes itself for one pane. Reusing the + * executable is what makes a pane work in the compiled distribution, where + * there is no script to run. + */ +export function foregroundTerminalGrid( + overrides: Partial = {}, +): TerminalGridInstaller { + return function* (): Operation { + const hangup = yield* useHangup(); + yield* installTmuxGridProvider({ + isTerminal: () => process.stdout.isTTY === true, + env: paneEnvironment(process.env), + workerCommand: (ordinal, directory) => + hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]), + size: windowSize, + hangup: () => hangup, + ...overrides, + }); + yield* installTerminalGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER }); + }; +} diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts new file mode 100644 index 000000000..e24e9ba60 --- /dev/null +++ b/packages/cli/src/terminal/provider.ts @@ -0,0 +1,253 @@ +/** + * The tmux terminal-grid provider, and what a host must be to install it + * (architecture.md §Interactive terminal grids). + * + * This is the one place the provider-neutral request from #730 meets tmux. The + * request names columns, rows and the authored panes; what comes back is a + * composite core drives through its own lifecycle. Nothing tmux-shaped crosses + * in either direction: no socket, session, window, pane, client or server + * identifier appears in a request, a result, a retained record or a diagnostic. + * + * A host installs this only when it can actually present a grid. `xmd run` on a + * terminal with a usable tmux does; `xmd test`, a piped run, a host without + * tmux, and the Node and Bun runtimes do not — they keep the language and the + * validation and install no operational provider, so a document that asks for a + * grid is refused before a pane starts rather than part-way through one. + * + * The hangup is here because it ends the same way. A terminal that goes away + * takes the grid with it, and the way it does that is the ordinary structured + * cancellation every other stop uses — not a second teardown path that would + * have to be kept honest separately. + */ + +import { ensure, race, resource, spawn, withResolvers } from "effection"; +import process from "node:process"; +import type { Operation } from "effection"; +import { TerminalGrids } from "@executablemd/runtime"; +import type { + TerminalComposite, + TerminalGridRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "@executablemd/runtime"; +import { registerTerminalProvider } from "@executablemd/core"; +import type { TerminalProviderFactory } from "@executablemd/core"; +import { usePaneChannels } from "./pane-channel.ts"; +import type { PaneLink } from "./pane-channel.ts"; +import { useTmuxGrid } from "./tmux-grid.ts"; +import type { TmuxGrid, VisibleClient } from "./tmux-grid.ts"; +import { paneEnvironment, probeTmux, tmuxAt, TmuxUnavailableError } from "./tmux.ts"; +import type { Tmux } from "./tmux.ts"; + +/** The name a host installs this provider under. */ +export const TMUX_PROVIDER = "tmux"; + +export interface TmuxProviderDependencies { + /** Whether this invocation has a terminal to divide. */ + isTerminal(): boolean; + /** What every process in the topology receives. */ + readonly env: Record; + /** + * The command that runs one pane's worker: this executable, hidden mode. + * + * An operation because a host resolves its own invocation contextually, and + * every pane's is resolved before the server exists. + */ + workerCommand(ordinal: number, directory: string): Operation; + /** The window to lay panes out in. */ + size(): { columns: number; rows: number }; + /** Settles when the host's own terminal goes away. */ + hangup(): Operation; + /** How a private server is reached. Substituted only by this package's tests. */ + createTmux?: (socket: string, env: Record) => Tmux; +} + +/** + * Build the provider factory a host registers. + * + * The factory receives the terminal authority directly and presents the exact + * request it was routed — a handler that answered without presenting would have + * presented nothing, which is what #730's handshake is for. + */ +export function tmuxGridProvider(deps: TmuxProviderDependencies): TerminalProviderFactory { + return function* (_options, authority): Operation { + yield* TerminalGrids.around( + { + *open([request]): Operation { + const composite = yield* usePresentedGrid(deps, request); + yield* authority.present(request, composite); + return undefined; + }, + }, + { at: "min" }, + ); + }; +} + +/** + * Everything one grid needs, prepared while it is still hidden. + * + * Ownership, innermost last — which is also the order it comes down in: + * + * grid scope + * ├─ private directory, sockets and tokens (removed last, after they close) + * ├─ the tmux server and its panes (`kill-server`, proved) + * └─ the admitted worker links + */ +function usePresentedGrid( + deps: TmuxProviderDependencies, + request: TerminalGridRequest, +): Operation { + return resource(function* (provide) { + const probed = yield* probeTmux({ isTerminal: deps.isTerminal, env: deps.env }); + if (!probed.ok) { + // Before a directory, a socket, a token, a server or a pane exists, so a + // host that cannot present a grid leaves nothing behind for having tried. + throw probed.error; + } + + const channels = yield* usePaneChannels(request.panes.length); + // Resolved before a server exists, so a host that cannot say how to run its + // own worker fails while there is still nothing to take down. + const workers: string[][] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + workers.push([...(yield* deps.workerCommand(ordinal, channels.directory))]); + } + const build = deps.createTmux ?? tmuxAt; + const window = deps.size(); + const grid = yield* useTmuxGrid(build(`${channels.directory}/s`, deps.env), { + session: "xmd", + columns: request.columns, + panes: request.panes.length, + width: window.columns, + height: window.rows, + titles: request.panes.map((pane) => pane.title), + workerCommand: (ordinal) => workers[ordinal] ?? [], + cwd: process.cwd(), + env: deps.env, + }); + + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < request.panes.length; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + + // The reader leaving, and the host's terminal going away, are the same kind + // of event: something outside the document decided this grid is over. Both + // settle `closed()`, and core takes it from there through its ordinary + // close — there is no second teardown path to keep honest. + const left = withResolvers(); + yield* spawn(function* () { + yield* deps.hangup(); + left.resolve(); + }); + + let shown = 0; + let visible: VisibleClient | undefined; + + yield* ensure(function* () { + // Asked to leave before anything else comes down, so the reader's + // terminal is restored by the client that took it. + if (visible !== undefined) { + yield* grid.detach(visible); + } + for (const link of links) { + if (link.connected()) { + yield* link.send({ type: "shutdown" }); + } + } + }); + + yield* provide({ + *attach() { + visible = yield* grid.attach(); + }, + *update(ordinal, state) { + // Sanitized status only, and display only: core has already decided + // what this is, and drawing it is not a chance to change it. + yield* label(grid, ordinal, request, state); + }, + *display(ordinal, text) { + const link = links[ordinal]; + if (link === undefined) { + return; + } + yield* link.send({ type: "display", seq: ++shown, text }); + }, + *shell(ordinal, spawned) { + return yield* runShell(links[ordinal], deps, spawned); + }, + *closed() { + yield* race([left.operation, grid.detached()]); + }, + *destroy() { + yield* grid.stop(); + }, + }); + }); +} + +/** The pane's title, with the state core settled on appended. */ +function* label( + grid: TmuxGrid, + ordinal: number, + request: TerminalGridRequest, + state: TerminalPaneState, +): Operation { + const pane = request.panes[ordinal]; + if (pane === undefined) { + return; + } + yield* grid.title(ordinal, `${pane.title} — ${state}`); +} + +/** Start the host's default shell in one pane, through its worker. */ +function* runShell( + link: PaneLink | undefined, + deps: TmuxProviderDependencies, + spawned: () => void, +): Operation { + if (link === undefined) { + throw new Error("this grid has no such pane"); + } + const shell = deps.env.SHELL ?? "/bin/sh"; + yield* link.send({ + type: "launch", + id: `shell-${link.ordinal}`, + argv: [shell], + cwd: process.cwd(), + env: deps.env, + }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + return {}; + } + if (frame.type === "started") { + // The runtime's own start event, and the only thing that makes this pane + // ready. + spawned(); + continue; + } + if (frame.type === "start-failed") { + throw new Error("the pane's shell could not be started"); + } + if (frame.type === "exited") { + const outcome: TerminalShellOutcome = {}; + if (frame.exitCode !== undefined) { + outcome.exitCode = frame.exitCode; + } + if (frame.signal !== undefined) { + outcome.signal = frame.signal; + } + return outcome; + } + } +} + +/** Install the tmux provider for this host, when this host can present one. */ +export function* installTmuxGridProvider(deps: TmuxProviderDependencies): Operation { + yield* registerTerminalProvider(TMUX_PROVIDER, tmuxGridProvider(deps)); +} + +export { TmuxUnavailableError }; diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/cli/src/terminal/tmux-grid.ts index 61d6d986c..e1e5cab2b 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/cli/src/terminal/tmux-grid.ts @@ -28,7 +28,7 @@ import { exec } from "@effectionx/process"; import { lines } from "@effectionx/stream-helpers"; -import { ensure, resource, sleep, spawn } from "effection"; +import { createSignal, ensure, resource, sleep, spawn } from "effection"; import type { Operation } from "effection"; import { processReachable } from "@executablemd/runtime"; import { layoutString, swapsInto } from "./layout.ts"; @@ -92,6 +92,10 @@ export interface TmuxGrid { readonly events: readonly ControlEvent[]; /** Pane geometry now, for checking placement after a resize. */ geometry(): Operation; + /** Show one pane's label. Display only; core has settled what it says. */ + title(ordinal: number, text: string): Operation; + /** Settles when the control channel says the reader's client has gone. */ + detached(): Operation; /** Show the grid on this process's terminal. */ attach(): Operation; /** Ask the visible client to leave, so it restores the terminal itself. */ @@ -261,6 +265,9 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation(); + // Subscribed before the client is started, so no report is missed. + const watching = yield* reports; yield* spawn(function* () { const [program = "tmux", ...argv] = tmux.argv([ "-C", @@ -274,11 +281,14 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation pane.cell); }, + *title(ordinal, text) { + const id = paneIds[ordinal]; + if (id === undefined) { + return; + } + yield* tmux.tryRun(["select-pane", "-t", id, "-T", text]); + }, + *detached() { + // The control client's account. An attach client's exit code is 0 after + // a detach, 0 after a session is killed and 1 after the server is, so + // it cannot tell a reader leaving from a grid being taken down. + if (events.some((event) => event.kind === "client-detached")) { + return; + } + while (true) { + const next = yield* watching.next(); + if (next.done || next.value.kind === "client-detached") { + return; + } + } + }, *attach() { // Its own lifecycle, not a pane child's. A pane child is settled by // sweeping its process group and its terminal; this client's terminal From d7378993dc4e52cf091763a3441c88b939ad0307 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 06:37:53 -0400 Subject: [PATCH 30/62] =?UTF-8?q?=F0=9F=93=9D=20Route=20pane-native=20laun?= =?UTF-8?q?ches=20through=20terminal=20composites=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 40 +++++++++++++++++------ specs/executable-mdx-spec.md | 30 +++++++++++++++++ specs/native-agent-session-launch-spec.md | 39 +++++++++++++++++++--- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/architecture.md b/architecture.md index 6683b36f2..74fe2c77c 100644 --- a/architecture.md +++ b/architecture.md @@ -2960,15 +2960,35 @@ request to act. This preserves provider composition without letting a document or replacement context mint terminal ownership. A pane claim grants one interactive terminal at that ordinal, not an Agent -session. Core installs a pane-scoped native launcher that closes over the claim. -`` in that pane consequently reserves, flushes, and launches on -the pane terminal instead of competing for the root lease. Launches in -different panes may run concurrently; two interactive launches in one pane -cannot. Sequential launches in one paired pane remain ordinary composition. -The session coordinator is unchanged and independently authoritative, so two -panes attempting to own the same logical Agent session still contend and one -is refused. The provider starts a self-closing pane's host-configured default -shell under the same kind of pane claim. +session. Core installs a pane-scoped native launcher that closes over the claim, +the composite, and the authored ordinal. `` in that pane +consequently reserves and flushes the pane, then terminates native-launch +routing at this required provider-neutral composite operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The ordinal exists only in core's live closure and never enters the native or +Agent request. Once nearer native-launch middleware delegates, the pane launcher +calls the composite operation instead of the root foreground launcher. Nearer +middleware may still observe, wrap, refuse, or short-circuit the request. A +composite that cannot execute the pane request refuses explicitly; falling +through to the root would put the child on the wrong physical terminal. Root +`` retains its existing foreground-launch route unchanged. + +Launches in different panes may run concurrently; two interactive launches in +one pane cannot. Sequential launches in one paired pane remain ordinary +composition. The session coordinator is unchanged and independently +authoritative, so two panes attempting to own the same logical Agent session +still contend and one is refused. The composite's separate `shell()` operation +starts a self-closing pane's host-configured default shell under the same kind +of pane claim; it remains separate because its executable is live host policy, +not an authored or Agent-provided native launch request. Each claim also closes over one host-owned readiness latch. The pane-scoped native launcher acknowledges it from the runtime's successful child-spawn event @@ -4469,7 +4489,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane-scoped native Agent launch in #731; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 496d437bf..9616eb62b 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9076,6 +9076,35 @@ acknowledges. The self-closing shell does the same. The latch is absent for a root launch and appears in no prop, binding, contextual API, public request, provider return, process result, or durable record. +For a paired pane, core closes the pane-scoped native launcher over the +composite and that pane's authored ordinal. After claim admission and the pane +output flush, delegation reaches the composite's required provider-neutral +operation: + +```ts +launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, +): Operation; +``` + +The request is the exact native command vector, working directory, and +environment supplied by the Agent provider. The ordinal stays in core's live +closure and enters no native request, Agent request, session key, construction +route, durable phase, result, or diagnostic. Native-launch middleware installed +nearer the authored launch may observe, wrap, refuse, or short-circuit before it +delegates. The pane launcher is the physical-terminal endpoint: it calls the +composite operation and never delegates to the root foreground launcher. A +provider unable to execute the pane request refuses explicitly instead of +falling back to the wrong terminal. A root `` keeps the existing +root foreground route unchanged. + +The composite invokes `spawned` only for the child's runtime spawn event. Its +separate `shell()` operation remains the self-closing-pane path because that +operation derives the executable from live host policy rather than accepting an +authored or Agent-provided native launch request. + When a persistent process owns a pane endpoint, the launcher sends the exact argv vector, working directory, and environment over the provider's private authenticated channel to that pane owner. The presentation provider's command @@ -11264,6 +11293,7 @@ test derives a core result from a provider identifier. | TG17 | Replay divergence and retained shape | A resolved layout change — `columns` or a `title`, reached through a prop-borne value, because a continuation executes the retained root — refuses before the lease and before provider contact, with zero provider observation. Pane count, order and form cannot differ under a fixed retained root, so they are proved retained and honoured rather than refused: the complete authored structure appears in the record, and a continuation whose supplied file differs in count, order or form opens the retained structure rather than the file's. Retained layout, close kind and pane outcomes contain no provider command, socket, process, session, window or pane identifier, path, argv, environment or terminal bytes | | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | +| TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 74c422702..2de01c5e1 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -756,11 +756,22 @@ ensure, detach, create, resume, prompt, or attach to an Agent session, and a session lease grants no terminal. The pane-scoped launcher keeps the same launch request and provider authority -division as the root launcher. Public middleware can route or refuse a request -but cannot settle it, replace the pane, or mint a launch. Provider-specific grid -or pane identities never enter the `AgentLaunchRequest`, terminal result, -`agent_session_launch` record, construction route, ownership key, diagnostic, -or private instruction file. +division as the root launcher. Core closes it over the terminal composite and +authored pane ordinal. After the claim admits the launch and pane output is +flushed, the launcher calls the composite's required provider-neutral +`launch(ordinal, request, spawned)` operation. It does not delegate to the root +foreground launcher. The ordinal remains in that live closure and never enters +the native request. + +Public middleware installed nearer the authored launch can route, wrap, refuse, +or short-circuit before delegating, but cannot settle the claim, replace the +pane, or mint a launch. Once it delegates, the pane launcher is the physical +terminal endpoint. A composite that cannot execute the request refuses rather +than falling through to the root terminal. Root `Session.Launch` retains its +existing foreground-launch route. Provider-specific grid or pane identities +never enter the `AgentLaunchRequest`, terminal result, `agent_session_launch` +record, construction route, ownership key, diagnostic, or private instruction +file. The grid's readiness barrier observes the launch only at the existing successful interactive-child start boundary. Session preparation, route publication, @@ -788,6 +799,10 @@ launch. It uses Effection's `run()` rather than `main()` so Effection does not convert terminal `SIGINT` into worker exit 130 while the foreground child is handling job control. +The composite's `shell()` path remains separate. It chooses the current host's +default shell as live policy; it does not accept or reinterpret a native launch +request supplied by an Agent provider. + After the grid is visible, a nonzero native exit fails its pane flow but does not cancel sibling panes. Core keeps that failure as the pane's status and selects the first failed pane in authored order when the reader closes the grid. @@ -1198,6 +1213,15 @@ exercises pane reuse after terminal-holder quiescence; a process that has already started a new session, closed the terminal, and lost its parent is recorded as outside the observable host boundary. +The pane-native route has an explicit physical-terminal regression. A paired +pane delegates through any nearer launcher middleware to its composite endpoint; +the production tmux adapter delivers the unchanged command vector, cwd, and +environment to the authenticated worker for that authored ordinal, while a +root-launcher sentinel proves the foreground endpoint was not entered. Two pane +endpoints launch concurrently. Cancellation remains pending until worker +settlement and pane-terminal quiescence are observed. A separate root launch +still reaches the root foreground launcher. + Focused tests prove: 1. help discovers roles and performs no preparation or launch; @@ -1448,6 +1472,11 @@ Implementation review checks these frozen invariants: keeps worker display out of child input, distinguishes reader detach from control loss and server stop, and proves the bounded process and terminal teardown before pane reuse and grid settlement. +29. A paired pane's native launcher terminates at the required composite + operation for its authored ordinal: the exact native request reaches that + pane's authenticated worker, the root foreground launcher is not entered, + distinct panes launch concurrently, cancellation awaits worker settlement + and pane quiescence, and root launch routing remains unchanged. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 46567a7136a1da18895116b0421ba9c25324093e Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 07:00:50 -0400 Subject: [PATCH 31/62] =?UTF-8?q?=E2=9C=A8=20Route=20a=20pane's=20native?= =?UTF-8?q?=20launch=20through=20its=20own=20composite=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements architecture commit 802b07df. `TerminalComposite.launch(ordinal, request, spawned)` is required of every composite. Core closes the pane-scoped launcher over it and the pane's authored ordinal, so after claim admission and the pane flush a `` written in a paired pane reaches *that pane's* terminal. The ordinal lives in core's closure and enters no native request, Agent request, session key, construction route, durable phase, result or diagnostic. The pane launcher is now the end of the chain. Middleware written nearer the authored launch still composes in front and may observe, wrap, refuse or short-circuit; what it can no longer do is reach past, because past it is the root foreground launcher and the root terminal is the one thing a pane exists to avoid. A composite that cannot run a pane's launch refuses — there is no fallback, because the only thing to fall back to is the wrong terminal. Root `` is untouched. The tmux composite sends the exact command vector, working directory and environment over the pane's authenticated channel; tmux's parser sees a directory and an ordinal. `shell()` stays separate and keeps deriving the executable from live host policy. `spawned` is invoked only for the worker-observed runtime spawn event. Also fails closed on settlement: `requireQuiescent()` is the rule everything downstream is conditional on, and a settlement that could not prove the pane free no longer clears the pane, reports success, or admits another launch. TG20 proves the endpoint against real workers on real sockets with a fake tmux that now starts the pane commands it is given, and with no `` or nearer launcher in front: exact argv, cwd and environment arrive at the pane's authenticated worker; a root-foreground-launcher sentinel is never entered while a root launch still reaches it; distinct panes launch concurrently; and a pane the composite cannot serve refuses. Tier GN is rewired through the endpoint, which is what exposed the gap: before this, its pane launches reached ``'s launcher and nothing could tell that from reaching the pane. GN now separates the two — `launches` at the pane endpoint, `agentLaunches` at the root route — and GN3 and GN8 assert both. --- packages/cli/src/terminal/pane-worker.ts | 43 ++- packages/cli/src/terminal/provider.ts | 62 +++- packages/cli/tests/fixtures/fake-tmux.ts | 36 +++ packages/cli/tests/terminal-grid-tmux.test.ts | 292 ++++++++++++++++-- packages/core/src/expand.ts | 4 +- packages/core/src/terminal/pane-launcher.ts | 31 +- .../core/tests/agent-session-launch.test.ts | 35 ++- packages/core/tests/terminal-grid.test.ts | 4 +- packages/runtime/terminal.ts | 55 +++- .../tests/terminal-grid-native-launch.test.ts | 55 +++- 10 files changed, 544 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index e11f1d972..079540067 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -89,6 +89,42 @@ export function runPaneWorkerProcess(invocation: { return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); } +/** + * A pane that could not be proved free. + * + * Provider-neutral: it names no socket, session, pane, client, argv or + * environment, because a settlement that failed is read in exactly the places a + * private identifier must not appear. + */ +export class PaneNotQuiescent extends Error { + override name = "PaneNotQuiescent"; + constructor(what: string) { + super(`this terminal pane could not be proved free: ${what}`); + } +} + +/** + * What a settlement means for the pane it settled. + * + * Exported because it is the rule, not an implementation detail: everything + * downstream — clearing the pane, reporting a launch settled, admitting the + * next one, letting teardown succeed — is conditional on it, and a rule that + * several callers depend on is one worth being able to state and test on its + * own. + */ +export function requireQuiescent(settlement: Settlement): void { + if (settlement.quiet) { + return; + } + // Everything the worker can do has been done and something is still there: a + // survivor of the escalation, or a holder of the pane's terminal. + throw new PaneNotQuiescent( + settlement.holders.some((holder) => !holder.gone) + ? "something still holds its terminal" + : "something it started is still running", + ); +} + /** A settlement for a pane that never started anything. */ const NOTHING_TO_SETTLE: Settlement = { method: "exited", @@ -176,6 +212,9 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation void, -): Operation { +): Operation { if (link === undefined) { - throw new Error("this grid has no such pane"); + // No fallback. A composite that cannot run this in the pane it was asked + // for refuses, rather than putting a native UI on the root terminal. + throw new Error("this terminal grid cannot run that pane's launch"); } - const shell = deps.env.SHELL ?? "/bin/sh"; yield* link.send({ type: "launch", - id: `shell-${link.ordinal}`, - argv: [shell], - cwd: process.cwd(), - env: deps.env, + id: `launch-${link.ordinal}-${++started}`, + argv: [...request.command], + cwd: request.cwd, + env: request.env ?? {}, }); while (true) { const frame = yield* link.next(); if (frame === undefined) { - return {}; + // The worker's channel ended mid-launch. Nothing about that says the + // child stopped, so it is a failure rather than an empty outcome. + throw new Error("the terminal pane stopped answering before its launch settled"); } if (frame.type === "started") { - // The runtime's own start event, and the only thing that makes this pane - // ready. + // The worker-observed runtime spawn event, and the only thing that makes + // this pane ready. spawned(); continue; } + if (frame.type === "busy") { + throw new Error("that terminal pane already has a live child"); + } if (frame.type === "start-failed") { - throw new Error("the pane's shell could not be started"); + throw new Error("the terminal pane's child could not be started"); } if (frame.type === "exited") { - const outcome: TerminalShellOutcome = {}; + const outcome: NativeLaunchOutcome = {}; if (frame.exitCode !== undefined) { outcome.exitCode = frame.exitCode; } @@ -245,6 +272,9 @@ function* runShell( } } +/** Distinguishes one pane's launches from the next in this invocation. */ +let started = 0; + /** Install the tmux provider for this host, when this host can present one. */ export function* installTmuxGridProvider(deps: TmuxProviderDependencies): Operation { yield* registerTerminalProvider(TMUX_PROVIDER, tmuxGridProvider(deps)); diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/cli/tests/fixtures/fake-tmux.ts index e48e7af1a..f8909a7fa 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/cli/tests/fixtures/fake-tmux.ts @@ -19,6 +19,8 @@ */ import { appendFile } from "node:fs/promises"; +import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { until } from "effection"; import type { Operation } from "effection"; import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; @@ -56,6 +58,15 @@ export interface FakeTmuxOptions { * is the only way to reach the escalation that follows the ask. */ readonly stubbornClient?: boolean; + /** + * Actually start the pane commands, the way a server would. + * + * With this on, `new-session` and `split-window` spawn the exact worker + * command they were given, each in a session of its own — which is what tmux + * gives a pane's initial process. That yields real workers on real sockets + * with no real tmux anywhere. + */ + readonly spawnPanes?: boolean; } export interface FakeTmux extends Tmux { @@ -66,6 +77,10 @@ export interface FakeTmux extends Tmux { readonly serverPid: number; readonly alive: () => boolean; readonly clients: readonly string[]; + /** Every pane process this server actually started. */ + readonly started: readonly ChildProcess[]; + /** End every started pane process. */ + stopPanes(): void; /** Say something on the control channel, as the server would. */ say(line: string): Operation; } @@ -97,6 +112,7 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { /** Window-list order — the order panes were created, which tmux fills by. */ const panes: FakePane[] = []; const clients: string[] = []; + const started: ChildProcess[] = []; let alive = false; let nextPane = 0; let nextPid = 4000; @@ -127,6 +143,20 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { title: "", command, }; + if (options.spawnPanes === true && command.length > 0) { + const [program, ...argv] = command; + if (program !== undefined) { + started.push( + spawnChild(program, argv, { + stdio: ["ignore", "pipe", "pipe"], + // A pane's initial process is tmux's session leader, so it is its + // own process group — which is also what keeps a worker's own + // settlement from sweeping this test runner. + detached: true, + }), + ); + } + } const at = after === undefined ? -1 : panes.findIndex((entry) => entry.id === after); if (at < 0) { panes.push(created); @@ -274,6 +304,12 @@ export function createFakeTmux(options: FakeTmuxOptions): FakeTmux { serverPid, alive: () => alive, clients, + started, + stopPanes() { + for (const child of started) { + child.kill("SIGKILL"); + } + }, argv(args) { const mode = args.includes("-C") ? "control" : "attach"; if (mode === "attach") { diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index a4fd6be2a..675614707 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,17 +16,20 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import { all, ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; -import { exists, rm, stat, writeTextFile } from "@effectionx/fs"; +import { exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; +import { realpath } from "node:fs/promises"; +import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; +import type { TerminalComposite } from "@executablemd/runtime"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { TerminalProcesses } from "@executablemd/runtime"; +import { installPosixTerminalProcesses, TerminalProcesses } from "@executablemd/runtime"; import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; @@ -40,6 +43,7 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; +import { runInPane } from "../src/terminal/provider.ts"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -47,8 +51,12 @@ import { paneTokenPath, writeFrame, } from "../src/terminal/pane-protocol.ts"; -import { PANE_WORKER_COMMAND, paneWorkerInvocation } from "../src/terminal/pane-worker.ts"; -import type { FromWorker, ToWorker } from "../src/terminal/pane-protocol.ts"; +import { + PANE_WORKER_COMMAND, + paneWorkerInvocation, + requireQuiescent, +} from "../src/terminal/pane-worker.ts"; +import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -72,6 +80,42 @@ function readCells(layout: string): LayoutCell[] { return cells; } +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** The fixture that stands in for one tmux client. */ +function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; +} + +/** A composite whose pane endpoint is the production one, over these links. */ +function paneComposite(links: readonly PaneLink[]): TerminalComposite { + const refuse = (): never => { + throw new Error("this row drives the pane endpoint only"); + }; + return { + attach: refuse, + update: refuse, + display: refuse, + shell: refuse, + closed: refuse, + destroy: refuse, + launch: (ordinal, request, spawned) => runInPane(links[ordinal], request, spawned), + }; +} + describe("Tier TX — the tmux grid's geometry", () => { it("TX1: an authored column count survives every terminal size", function* () { // Four panes in two columns is 2×2 whatever the terminal is. `tiled` would @@ -461,6 +505,47 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(bye.type).toBe("bye"); }); + it("TW13: a settlement that proved nothing frees no pane", function* () { + // The rule every downstream step is conditional on: clearing the pane, + // reporting a launch settled, admitting the next one, letting teardown + // succeed. Stated here rather than end-to-end, because a pane whose sweep + // cannot come back empty is not something a suite can arrange in another + // process without putting a fault switch in the worker itself. + const proved: Settlement = { method: "exited", quiet: true, swept: [], holders: [] }; + requireQuiescent(proved); + + const survivor: Settlement = { + method: "killed", + quiet: false, + child: 100, + swept: [{ pid: 200, gone: false }], + holders: [], + }; + const held: Settlement = { + method: "exited", + quiet: false, + child: 100, + swept: [], + holders: [{ pid: 900, gone: false }], + }; + for (const [what, settlement] of [ + ["a survivor", survivor], + ["a holder", held], + ] as [string, Settlement][]) { + let refusal = ""; + try { + requireQuiescent(settlement); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(`${what}: ${refusal.includes("could not be proved free")}`).toBe(`${what}: true`); + // Provider-neutral: it says what is still true, not which pane, session, + // socket or command it was. + expect(`${what}: ${/\bpane \d|socket|session/.test(refusal)}`).toBe(`${what}: false`); + } + expect(() => requireQuiescent(held)).toThrow(); + }); + it("TW12: naming the worker invocation is the only way to be one", function* () { // In no command table, so in no help output and no catalog. What makes it // safe is not obscurity: a worker that cannot present a pane's single-use @@ -533,26 +618,6 @@ describe("Tier TG — the tmux composite", () => { ); } - /** Where a fake server and its client fixtures meet. */ - function useScript(): Operation { - return resource(function* (provide) { - const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); - yield* writeTextFile(file, ""); - yield* ensure(function* () { - yield* rm(file, { force: true }); - }); - yield* provide(file); - }); - } - - /** The fixture that stands in for one tmux client. */ - function clientCommand(mode: "control" | "attach", script: string): readonly string[] { - const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); - const invocation = cliCommand([]); - // The same runtime the CLI runs under, pointed at the fixture instead. - return [invocation.command, "run", "--allow-all", fixture, mode, script]; - } - /** A composite over a fake server, with the pane workers stubbed out. */ function useComposite(options: { panes: number; @@ -1089,3 +1154,180 @@ function untilEvent(grid: TmuxGrid, kind: ControlEvent["kind"]): Operation ); })(); } + +/** + * Tier TG20 — the pane's physical endpoint + * (specs/executable-mdx-spec.md TG20, architecture commit 802b07df). + * + * A `` written inside a paired pane must run on *that pane's* + * terminal. Before the amendment it delegated down the launcher chain and + * reached the root foreground launcher — which on a real host inherits the root + * terminal, the one terminal a pane exists to avoid. It now stops at the + * composite's required pane operation. + * + * Nothing nearer intercepts here: no ``, no controlled launcher in + * front. The request goes to a real worker over a real socket, and a sentinel + * stands where the root foreground launcher would be — entering it at all is + * the failure this tier exists to catch. + */ +describe("Tier TG20 — a pane launch reaches its own worker", () => { + /** A composite over a fake server that really starts its pane workers. */ + function useLiveComposite(panes: number): Operation<{ + composite: TerminalComposite; + tmux: FakeTmux; + channels: PaneChannels; + }> { + return (function* () { + // The observer a foreground host installs beside the provider: teardown + // proves what it claims, and refuses without it. + yield* installPosixTerminalProcesses(); + const script = yield* useScript(); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + const channels = yield* usePaneChannels(panes); + const invocation = cliCommand([]); + const grid = yield* useTmuxGrid(tmux, { + session: "live", + columns: panes, + panes, + width: 160, + height: 48, + titles: Array.from({ length: panes }, (_, index) => `pane ${index}`), + workerCommand: (ordinal) => [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + channels.directory, + ], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + void grid; + const links: PaneLink[] = []; + for (let ordinal = 0; ordinal < panes; ordinal++) { + links.push(yield* channels.link(ordinal)); + } + const composite = paneComposite(links); + return { composite, tmux, channels }; + })(); + } + + it("TG20a: the exact argv, cwd and environment arrive at that pane's worker", function* () { + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20-${randomUUID()}.json`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + // Arguments a command parser would ruin, an environment entry only this + // launch names, and a working directory that is not the runner's. + const marker = "tg20marker"; + let started = 0; + const outcome = yield* composite.launch( + 0, + { + command: [ + "/bin/sh", + "-c", + `printf '%s' "$XMD_TG20:$PWD:$1" > "${evidence}"`, + "sh", + `a b;'"$${marker}`, + ], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20: marker }, + }, + () => started++, + ); + + expect(outcome.exitCode).toBe(0); + // The spawn was reported once, by the worker that observed it. + expect(started).toBe(1); + const seen = yield* readTextFile(evidence); + const [env, cwd, argument] = seen.split(":"); + expect(env).toBe(marker); + expect(cwd).toBe(yield* until(realpath(tmpdir()))); + // Unchanged through the socket and past tmux, whose parser never saw it. + expect(argument).toBe(`a b;'"$${marker}`); + }); + + it("TG20b: the root foreground launcher is never entered", function* () { + const { composite } = yield* useLiveComposite(1); + const reached: string[] = []; + // A sentinel where the root launcher sits. A pane launch that delegated + // past its endpoint would arrive here — and on a real host that is the + // root terminal. + yield* installControlledLauncher({ + record: (request) => reached.push(request.command.join(" ")), + outcome: () => ({ exitCode: 0 }), + }); + + yield* composite.launch( + 0, + { command: ["/bin/echo", "pane"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => {}, + ); + + expect(reached).toEqual([]); + // And the sentinel is a live one: a *root* launch does reach it. + yield* scoped(function* () { + yield* reserveTerminal(); + yield* nativeLaunch({ command: ["/bin/echo", "root"], cwd: path.resolve(".") }); + }); + expect(reached).toEqual(["/bin/echo root"]); + }); + + it("TG20c: distinct panes launch concurrently", function* () { + const { composite } = yield* useLiveComposite(2); + const both = withResolvers(); + let live = 0; + + // Each launch blocks until the other has started. A pair that had to share + // a terminal would wait for a start that cannot happen. + const outcomes = yield* all([ + composite.launch( + 0, + { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => { + live++; + if (live === 2) { + both.resolve(); + } + }, + ), + composite.launch( + 1, + { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + () => { + live++; + if (live === 2) { + both.resolve(); + } + }, + ), + ]); + + yield* both.operation; + expect(live).toBe(2); + expect(outcomes.map((outcome) => outcome.exitCode)).toEqual([0, 0]); + }); + + it("TG20d: a composite that cannot run a pane's launch refuses", function* () { + const { composite } = yield* useLiveComposite(1); + let refusal = ""; + try { + // No such pane. There is no fallback to fall back to: putting this on + // the root terminal is the one thing that must not happen. + yield* composite.launch( + 3, + { command: ["/bin/echo", "nowhere"], cwd: path.resolve("."), env: {} }, + () => {}, + ); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot run that pane's launch"); + }); +}); diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 58ca3e516..c84a78681 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -2255,7 +2255,9 @@ function paneWork(pane: TerminalPane, title: string, site: GridSite): PaneWork { // 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); + yield* usePaneNativeLauncher(claim, flushPane, (request, spawned) => + composite.launch(pane.ordinal, request, spawned), + ); const siteEnv = yield* env; // Starts from what the grid site can see and keeps its own writes: a // binding this pane makes is visible to later work in this pane and to diff --git a/packages/core/src/terminal/pane-launcher.ts b/packages/core/src/terminal/pane-launcher.ts index 68c01daf0..bce408d0f 100644 --- a/packages/core/src/terminal/pane-launcher.ts +++ b/packages/core/src/terminal/pane-launcher.ts @@ -23,6 +23,7 @@ import { resource } from "effection"; import type { Operation } from "effection"; import { NativeLauncher } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; import type { TerminalPaneClaim } from "./authority.ts"; @@ -33,9 +34,22 @@ import type { TerminalPaneClaim } from "./authority.ts"; * 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. */ +/** + * How a pane actually runs a native UI: the composite's operation for this + * pane's authored ordinal, bound by core and closed over here. + * + * The ordinal lives in this closure and nowhere else. It reaches no request, no + * Agent request, no session key, no durable phase, no result and no diagnostic. + */ +export type RunInPane = ( + request: NativeLaunchRequest, + spawned: () => void, +) => Operation; + export function* usePaneNativeLauncher( claim: TerminalPaneClaim, flush: () => Operation, + runInPane: RunInPane, ): Operation { yield* NativeLauncher.around({ /** @@ -60,11 +74,18 @@ export function* usePaneNativeLauncher( *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, () => { + *launch([request, spawned]) { + // The end of the chain, and deliberately so. Middleware written nearer + // the authored launch composes in front of this and may observe, wrap, + // refuse or short-circuit before it delegates here; what it must not do + // is reach past it, because past it is the root foreground launcher and + // the root terminal is the one thing a pane exists to avoid. + // + // The request crosses exactly as it arrived. What this adds is the + // ordinal — from the closure, never from the request — and a listener, so + // the pane is ready when the runtime says the child started and at no + // earlier moment. + return yield* runInPane(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 ade5e05b2..9905a2b44 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -320,6 +320,20 @@ function* runDoc(doc: string, options: RunOptions = {}): Operation { const composite = yield* prepareControlledComposite(request, { log: providerLog, close: () => settled.operation, + // The pane endpoint a paired pane's `` now + // reaches. It records what it was asked to start and answers, + // exactly as the host launcher used to — so these rows are + // about the pane, not about a launcher having moved. + *launch(_ordinal, asked, spawned) { + launcher.requests.push(asked); + launcher.order.push("launch"); + if (options.start) { + yield* options.start(asked, spawned); + } else { + spawned(); + } + return options.outcome ?? { exitCode: 0 }; + }, // deno-lint-ignore require-yield *onPrepare(asked) { panes = asked.panes.length; @@ -1007,14 +1021,19 @@ describe("Tier SP — a launch inside a terminal pane", () => { const asked: string[] = []; yield* scoped(function* () { - yield* installControlledLauncher({ - wait: () => - (function* () { - childLive.resolve(); - yield* childMayExit.operation; - })(), - }); - yield* usePaneNativeLauncher(claim, function* () {}); + // The composite's pane endpoint, which is what the pane launcher now + // delegates to. It stands in for a provider here, and behaves like one: + // it reports the start and answers when the child is done. + yield* usePaneNativeLauncher( + claim, + function* () {}, + function* (_request, spawned) { + spawned(); + childLive.resolve(); + yield* childMayExit.operation; + return { exitCode: 0 }; + }, + ); const first = yield* spawn(function* () { // The order a launch composes in: this pane, then the lease, then the diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 4acf29a8d..8c0ed596f 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -1831,8 +1831,8 @@ describe("Tier TG — durability and replay", () => { // The provider's counters went up and came back down. Reading them only at // the end would be true of counters that never moved. - expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0 }); - expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0 }); + expect(heldWhenBlocked).toEqual({ composites: 1, attached: 1, shells: 0, launches: 0 }); + expect(first.live).toEqual({ composites: 0, attached: 0, shells: 0, launches: 0 }); // And the foreground lease came back: it was taken and given back twice // over once the run was done. expect(leases).toBe(2); diff --git a/packages/runtime/terminal.ts b/packages/runtime/terminal.ts index b03275a8f..23a4e5fd5 100644 --- a/packages/runtime/terminal.ts +++ b/packages/runtime/terminal.ts @@ -26,6 +26,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; /** One pane the provider is asked to present, by its authored ordinal. */ export interface TerminalPaneRequest { @@ -124,6 +125,32 @@ export interface TerminalComposite { * never started leaves the latch alone and the grid never attaches. */ shell(ordinal: number, spawned: () => void): Operation; + /** + * Run one native launch in one pane, on that pane's terminal. + * + * This is the physical endpoint for a `` written inside a + * paired pane. Core closes its pane-scoped launcher over this operation and + * the pane's authored ordinal, so the ordinal stays in a live closure and + * enters no request, session key, durable phase, result or diagnostic. What + * crosses is the exact command vector, working directory and environment the + * Agent provider supplied. + * + * Required of every composite, and deliberately not optional: a provider that + * cannot execute a pane launch refuses here. Falling back would put a native + * UI on the root terminal — the one terminal a pane exists to avoid. + * + * `spawned` is the pane's readiness latch, on the same terms as `shell()`: + * called for the child's runtime spawn event and nothing earlier. + * + * Kept apart from `shell()` because they answer different questions. `shell()` + * derives its executable from live host policy; this runs the request it is + * given. + */ + launch( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ): Operation; /** * Settle when the reader closes or leaves the composite. * @@ -215,6 +242,8 @@ export interface TerminalProviderResources { attached: number; /** Shells started whose outcome has not been returned. */ shells: number; + /** Pane launches started whose outcome has not been returned. */ + launches: number; } /** A fresh, empty record. */ @@ -222,7 +251,7 @@ export function terminalProviderLog(): TerminalProviderLog { return { events: [], shown: new Map(), - live: { composites: 0, attached: 0, shells: 0 }, + live: { composites: 0, attached: 0, shells: 0, launches: 0 }, }; } @@ -249,6 +278,18 @@ export interface ControlledCompositeOptions { */ onUpdate?: (ordinal: number, state: TerminalPaneState) => void; shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * What a pane launch does, in place of starting a native UI. + * + * Left out, a launch refuses — which is what a composite that cannot execute + * one must do, and what keeps a suite that says nothing about launching from + * quietly passing one to the root terminal. + */ + launch?: ( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ) => Operation; close?: () => Operation; } @@ -310,6 +351,18 @@ export function prepareControlledComposite( log.live.shells--; } }, + *launch(ordinal, request, spawned) { + log.events.push(`launch:${generation}:${ordinal}`); + if (options.launch === undefined) { + throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); + } + log.live.launches++; + try { + return yield* options.launch(ordinal, request, spawned); + } finally { + log.live.launches--; + } + }, *closed() { if (options.close) { yield* options.close(); 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 497749a80..87341cfb2 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -82,6 +82,14 @@ interface Run { launches: NativeLaunchRequest[]; /** Every launch the *host's* launcher was asked to start. */ hostLaunches: NativeLaunchRequest[]; + /** + * Every launch ``'s own launcher was asked to start. + * + * A pane launch must not reach it: the pane launcher is the physical + * endpoint, and anything past it is a terminal that is not the pane's. A + * *root* launch does reach it, which is how the two stay distinguishable. + */ + agentLaunches: NativeLaunchRequest[]; sessions: NativeSessionReport[]; events: DurableEvent[]; /** Everything the controlled composite did, in order. */ @@ -157,6 +165,7 @@ function markerOf(request: NativeLaunchRequest, sessions: NativeSessionReport[]) function* runJourney(options: RunOptions = {}): Operation { const launches: NativeLaunchRequest[] = []; const hostLaunches: NativeLaunchRequest[] = []; + const agentLaunches: NativeLaunchRequest[] = []; const sessions: NativeSessionReport[] = []; const providerLog = terminalProviderLog(); const states: string[] = []; @@ -217,20 +226,11 @@ function* runJourney(options: RunOptions = {}): Operation { // 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. + // ``'s own launcher. A pane launch must not arrive here — it + // stops at the pane endpoint — so this is a sentinel for everything but a + // root launch. 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}`); - } - })(), + record: (asked) => agentLaunches.push(asked), outcome: (asked) => options.exits?.[markerOf(asked, sessions)] ?? { exitCode: 0 }, }); // A host launcher too, which is the wrong one for any of this to reach: @@ -293,6 +293,24 @@ function* runJourney(options: RunOptions = {}): Operation { } return { exitCode: 0 }; }, + // The pane's physical endpoint. A `` written + // in a paired pane arrives here, with the exact request the + // Agent provider built and an ordinal that never left core's + // closure. + *launch(_ordinal, asked, spawned) { + launches.push(asked); + const marker = markerOf(asked, sessions); + spawned(); + 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}`); + } + return options.exits?.[marker] ?? { exitCode: 0 }; + }, }); yield* authority.present(asked, composite); return undefined; @@ -331,6 +349,7 @@ function* runJourney(options: RunOptions = {}): Operation { results: yield* testing.results, launches, hostLaunches, + agentLaunches, sessions, events: yield* stream.readAll(), composite: providerLog.events, @@ -351,6 +370,7 @@ function* runJourney(options: RunOptions = {}): Operation { results: yield* testing.results, launches, hostLaunches, + agentLaunches, sessions, events: yield* stream.readAll(), composite: providerLog.events, @@ -574,6 +594,9 @@ describe( expect(run.result.ok).toBe(true); expect(run.hostLaunches).toEqual([]); + // Nor ``'s own launcher: a pane launch stops at the pane + // endpoint, and everything past it is a terminal that is not the pane's. + expect(run.agentLaunches).toEqual([]); expect(run.launches.length).toBe(3); }); @@ -746,7 +769,11 @@ describe( // 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); + // Two at the pane endpoint and one at the root route, which is the + // distinction the pane endpoint exists to make: a launch written in a + // pane never reaches the terminal a root launch takes. + expect(run.launches.length).toBe(2); + expect(run.agentLaunches.length).toBe(1); // 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. From 935474bdfd53f978c1ed4d4c2182440f427b676f Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 07:38:34 -0400 Subject: [PATCH 32/62] =?UTF-8?q?=E2=9C=A8=20Complete=20the=20tmux=20termi?= =?UTF-8?q?nal-grid=20provider=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the three evidence gaps from 7511e777 and the six repairs. **TW13 now proves the worker.** The rejected environment switch is replaced by an injected child seam: `runPaneWorker` takes what it starts, so a suite can run the real worker in-process against a real channel with the one thing it cannot arrange in another process — a child whose settlement cannot say the pane is free. After `quiet:false` the worker clears no live entry, reports no settlement, starts no second child, and refuses. **TG20c is a discriminator.** Each child announces itself and blocks until both have; a serial pair would wait for a start that had not happened. **TG20e proves cancellation.** `runInPane()` owns it: registered before the launch is asked for, a cancellation sends the worker's cancel and waits for a settlement that proves the pane free. A cancelled launch does not return while its child is live — TG20e reads the child's own pid and finds it gone. **Process observation fails closed**, in `deno-terminal-processes.ts` behind the runtime-named boundary Deno, the compiled binary and their pane workers install. `kill(pid, 0)` establishes absence only for ESRCH; EPERM is a process that exists and this user may not signal, so it raises rather than reading "I may not ask" as "nothing is there". A `ps` that would not run is not an empty table. Only `lsof -t`'s documented exit-1-with-no-output is read as "nobody". **Every listener is scope-owned.** No `.once()` and no `{ once: true }` in the touched production code: named handlers, removed by the scope that installed them, and kept installed through any wait they resolve. The worker's SIGINT, SIGQUIT and SIGTSTP handlers are its run scope's. TW14 counts them across event delivery, no delivery, startup failure and cancellation. **One ordered teardown.** `tearDown()` is idempotent and covers both core's `destroy()` and a preparation that failed halfway: detach and prove the visible client stopped, ask every worker to shut down, await each settlement, terminal sweep and goodbye, refuse on anything unproved, and only then stop the server and prove it gone. Sockets, their servers and the private directory come down after it, in the scopes that own them. **SIGHUP is cancellation, not a reader close.** A reader who detaches selects a close outcome; a terminal that is gone cancels the document through the ordinary structured path, runs the whole teardown, and lets no following sibling run. **Hosts state what they are.** Deno and compiled install the provider and the observer together; everyone else installs neither and still validates. TH1–TH3 cover a missing terminal, an unusable tmux, and a host with no provider. The inventory now says what was built and what the evidence is: fake tmux with real workers and real sockets, with real tmux behaviour on macOS remaining #726's. --- architecture.md | 2 +- packages/cli/src/terminal/attach-client.ts | 19 +- packages/cli/src/terminal/host.ts | 59 ++- packages/cli/src/terminal/pane-channel.ts | 66 ++- packages/cli/src/terminal/pane-child.ts | 21 +- packages/cli/src/terminal/pane-worker.ts | 93 +++- packages/cli/src/terminal/provider.ts | 133 +++++- packages/cli/src/terminal/tmux.ts | 7 +- packages/cli/tests/terminal-grid-tmux.test.ts | 441 +++++++++++++++--- packages/runtime/deno-terminal-processes.ts | 185 ++++++++ packages/runtime/mod.ts | 3 +- packages/runtime/terminal-processes.ts | 117 ----- .../runtime/tests/terminal-processes.test.ts | 94 +++- 13 files changed, 990 insertions(+), 250 deletions(-) create mode 100644 packages/runtime/deno-terminal-processes.ts diff --git a/architecture.md b/architecture.md index 74fe2c77c..039957396 100644 --- a/architecture.md +++ b/architecture.md @@ -4489,7 +4489,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts, whose evidence uses a fake tmux — real tmux behaviour on macOS is #726's; Node and Bun catalog and validate the same grids and install no operational provider | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index bb6ee5db5..d29046a96 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -139,15 +139,26 @@ export function useAttachClient(options: { // The reader's terminal, handed straight through. stdio: "inherit", }); - child.once("spawn", () => { + // Named, and removed by this scope. `exit` stays through the wait that + // establishes the client is gone, which is exactly why it is removed with + // the resource rather than after one delivery. + const onSpawn = (): void => { if (child?.pid !== undefined) { started.resolve(child.pid); } - }); - child.once("error", (error: Error) => failed.reject(error)); - child.once("exit", () => { + }; + const onError = (error: Error): void => failed.reject(error); + const onExit = (): void => { gone = true; exited.resolve(); + }; + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + child?.off("exit", onExit); }); // The pid, or whatever arrived instead of a start. diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index b935cb41b..f33edefaf 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -14,11 +14,11 @@ * already gives when no provider is installed. */ -import { ensure, resource, withResolvers } from "effection"; +import { ensure, race, resource, withResolvers } from "effection"; import type { Operation } from "effection"; import process from "node:process"; -import { installTerminalGridProfile } from "@executablemd/core"; -import { command as hostCommand } from "@executablemd/runtime"; +import { Execution, installTerminalGridProfile } from "@executablemd/core"; +import { command as hostCommand, installDenoTerminalProcesses } from "@executablemd/runtime"; import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; import type { TmuxProviderDependencies } from "./provider.ts"; import { paneEnvironment } from "./tmux.ts"; @@ -38,6 +38,51 @@ export function* unsupportedTerminalGrid(): Operation { yield* installTerminalGridProfile(); } +/** + * Make the host's terminal going away cancel the document. + * + * Not a reader close. A reader who detaches has finished with a grid, and the + * grid settles with a reader-close outcome and the document carries on. A + * terminal that is *gone* is not a decision about this grid — it is the run + * losing the thing every part of it was drawing on, so the document is + * cancelled through the ordinary structured path: the grid's whole teardown + * runs, and no following sibling gets to go. + */ +export function useHangupCancellation(hangup: Operation): Operation { + return Execution.around({ + *document([request], next) { + const outcome = yield* race([ + (function* () { + yield* next(request); + return "done"; + })(), + (function* () { + yield* hangup; + return "hangup"; + })(), + ]); + if (outcome === "hangup") { + // The losing side of the race is cancelled, which is the whole point: + // the grid comes down through the same teardown a reader close uses, + // and this run stops rather than continuing on a terminal it no longer + // has. + throw new TerminalLost(); + } + }, + }); +} + +/** The host's terminal went away while the document was still running. */ +export class TerminalLost extends Error { + override name = "TerminalLost"; + constructor() { + super( + "this run's terminal went away, so the document was stopped. Anything it " + + "had shown is gone with the terminal; nothing after the point it stopped ran.", + ); + } +} + /** The terminal this run is drawing on, as tmux needs to know it. */ function windowSize(): { columns: number; rows: number } { // A terminal that cannot say gets the sizes tmux itself defaults to, which is @@ -65,6 +110,8 @@ export function useHangup(): Operation> { const onHangup = (): void => hung.resolve(); process.on("SIGHUP", onHangup); yield* ensure(() => { + // Removed with the run that installed it. A listener that outlived its + // grid would answer for a terminal the next one is using. process.off("SIGHUP", onHangup); }); yield* provide(hung.operation); @@ -83,15 +130,19 @@ export function foregroundTerminalGrid( ): TerminalGridInstaller { return function* (): Operation { const hangup = yield* useHangup(); + // The observer goes in beside the provider, in the same scope: a host that + // presents grids is exactly the host that has to prove a pane is free, and + // one that installs neither refuses rather than guessing at either. + yield* installDenoTerminalProcesses(); yield* installTmuxGridProvider({ isTerminal: () => process.stdout.isTTY === true, env: paneEnvironment(process.env), workerCommand: (ordinal, directory) => hostCommand([PANE_WORKER_COMMAND, String(ordinal), directory]), size: windowSize, - hangup: () => hangup, ...overrides, }); yield* installTerminalGridProfile({ provider: TMUX_PROVIDER, label: TMUX_PROVIDER }); + yield* useHangupCancellation(hangup); }; } diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 42c4871fa..623abfed0 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -123,12 +123,17 @@ export function usePaneChannels( // directory's removal has to wait for is the closures themselves. yield* ensure(function* () { const closings: Operation[] = []; + const counted = (): void => { + closedCount++; + }; for (const socket of live) { - closings.push(closed(socket)); + // Asked for before the destroy, so the listener is there when the close + // it waits for arrives. + closings.push(closed(socket, counted)); socket.destroy(); } for (const server of servers) { - closings.push(shut(server)); + closings.push(shut(server, counted)); server.close(); } for (const closing of closings) { @@ -150,21 +155,24 @@ export function usePaneChannels( const server = net.createServer((socket) => { live.add(socket); closable++; - socket.once("close", () => { + const onSocketClose = (): void => { live.delete(socket); - closedCount++; - }); + socket.off("close", onSocketClose); + }; + socket.on("close", onSocketClose); arrivals.send({ ordinal, socket }); }); servers.push(server); closable++; - server.once("close", () => { - closedCount++; - }); const listening = withResolvers(); - server.once("error", (error: Error) => listening.reject(error)); + const onListenError = (error: Error): void => listening.reject(error); + server.on("error", onListenError); server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); - yield* listening.operation; + try { + yield* listening.operation; + } finally { + server.off("error", onListenError); + } } function* admit(ordinal: number, socket: Socket): Operation { @@ -229,25 +237,51 @@ export function usePaneChannels( } /** Settle once this socket has closed, whether or not it already had. */ -function closed(socket: Socket): Operation { +function closed(socket: Socket, onClosed: () => void): Operation { + // Attached now, awaited later. The caller asks for this *before* destroying + // the socket, so a listener attached lazily would miss the close it is + // waiting for — and the directory would go while the socket was still open. const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; if (socket.destroyed) { + onClosed(); done.resolve(); } else { - socket.once("close", () => done.resolve()); + socket.on("close", onClose); } - return done.operation; + return (function* (): Operation { + try { + yield* done.operation; + } finally { + // Removed synchronously when the wait is over, however it ends. + socket.off("close", onClose); + } + })(); } /** Settle once this server has stopped listening. */ -function shut(server: Server): Operation { +function shut(server: Server, onClosed: () => void): Operation { const done = withResolvers(); + const onClose = (): void => { + onClosed(); + done.resolve(); + }; if (!server.listening) { + onClosed(); done.resolve(); } else { - server.once("close", () => done.resolve()); + server.on("close", onClose); } - return done.operation; + return (function* (): Operation { + try { + yield* done.operation; + } finally { + server.off("close", onClose); + } + })(); } /** A connection that has said nothing for long enough to be nobody. */ diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index b44cca389..e0bd72149 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -126,15 +126,18 @@ export function usePaneChild( // or capture what passes. stdio: "inherit", }); - child.once("spawn", () => { + // Named, and removed by the scope that installed them. `exit` in + // particular has to stay through the settlement that waits on it, so it is + // removed with the resource rather than after its first delivery. + const onSpawn = (): void => { if (child?.pid !== undefined) { started.resolve(Ok(child.pid)); } - }); - child.once("error", (error: Error & { code?: string }) => { + }; + const onError = (error: Error & { code?: string }): void => { started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); - }); - child.once("exit", (code: number | null, signal: string | null) => { + }; + const onExit = (code: number | null, signal: string | null): void => { const settled: PaneChildOutcome = {}; if (code !== null) { settled.exitCode = code; @@ -144,6 +147,14 @@ export function usePaneChild( } outcome = settled; exited.resolve(settled); + }; + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); + yield* ensure(() => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + child?.off("exit", onExit); }); yield* provide({ started: started.operation, exited: exited.operation, settle }); diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 079540067..24386c8da 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -26,11 +26,11 @@ import net from "node:net"; import process from "node:process"; import { readTextFile, rm } from "@effectionx/fs"; -import { run, spawn, withResolvers } from "effection"; +import { ensure, resource, run, spawn, withResolvers } from "effection"; import type { Operation } from "effection"; -import { installPosixTerminalProcesses, processTable } from "@executablemd/runtime"; -import { usePaneChild, sweepHolders } from "./pane-child.ts"; -import type { PaneChild } from "./pane-child.ts"; +import { installDenoTerminalProcesses, processTable } from "@executablemd/runtime"; +import { sweepHolders, usePaneChild } from "./pane-child.ts"; +import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; import { paneSocketPath, paneTokenPath, @@ -85,8 +85,12 @@ export function runPaneWorkerProcess(invocation: { ordinal: number; directory: string; }): Promise { - ignoreForegroundSignals(); - return run(() => runPaneWorker(invocation.ordinal, invocation.directory)); + return run(function* () { + // Inside the run scope, so the handlers go on before any work and come off + // with it — rather than living for the process's lifetime regardless. + yield* useForegroundSignals(); + yield* runPaneWorker(invocation.ordinal, invocation.directory); + }); } /** @@ -147,11 +151,27 @@ interface Live { * in it. Doing nothing is the correct handling: the child inherits default * dispositions across `exec`, so it receives the same signal and acts on it. */ -export function ignoreForegroundSignals(): void { - const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; - for (const name of foreground) { - process.on(name, () => {}); - } +export function useForegroundSignals(): Operation { + return resource(function* (provide) { + const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; + const ignore = (): void => {}; + for (const name of foreground) { + process.on(name, ignore); + } + yield* ensure(() => { + // Installed and removed by the scope that runs this worker, so a worker + // that has finished stops answering for a pane it no longer owns. + for (const name of foreground) { + process.off(name, ignore); + } + }); + yield* provide(); + }); +} + +/** How many handlers this process has for one signal. */ +export function foregroundSignalListeners(name: NodeJS.Signals): number { + return process.listenerCount(name); } function writeOut(text: string): Operation { @@ -167,8 +187,30 @@ function writeOut(text: string): Operation { * under `run()`; both are properties of the *process*, not of this operation, * which is why they are the entrypoint's to establish. */ -export function* runPaneWorker(ordinal: number, directory: string): Operation { - yield* installPosixTerminalProcesses(); +/** + * What a worker uses to start a child. + * + * A seam rather than a hard call, because the one thing a suite cannot arrange + * in another process is a child whose settlement *fails* — a real SIGKILL + * always works, and a real terminal sweep on a pane with no terminal always + * comes back empty. Substituting the child is how the worker's own behaviour on + * that path is observable at all; the alternative would be a fault switch in + * production code, which is not a trade worth making. + */ +export interface PaneWorkerDependencies { + useChild(request: PaneChildRequest, tty: string | undefined): Operation; + /** Whether to install the POSIX observer. A caller that has one says no. */ + observe?: boolean; +} + +export function* runPaneWorker( + ordinal: number, + directory: string, + deps: PaneWorkerDependencies = { useChild: usePaneChild }, +): Operation { + if (deps.observe !== false) { + yield* installDenoTerminalProcesses(); + } // Read once, then spent. A second worker for this pane finds no token, so it // has nothing to present and is refused by the parent. @@ -176,10 +218,27 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation { + socket.destroy(); + }); const connected = withResolvers(); - socket.once("connect", () => connected.resolve()); - socket.once("error", (error: Error) => connected.reject(error)); - yield* connected.operation; + const onConnect = (): void => connected.resolve(); + const onConnectError = (error: Error): void => connected.reject(error); + socket.on("connect", onConnect); + socket.on("error", onConnectError); + try { + yield* connected.operation; + } finally { + // Removed synchronously, in the scope that installed them: a listener that + // outlived this wait would answer for a socket this worker has finished + // with. + socket.off("connect", onConnect); + socket.off("error", onConnectError); + } const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); const say = (message: FromWorker) => writeFrame(socket, message); @@ -254,7 +313,7 @@ export function* runPaneWorker(ordinal: number, directory: string): Operation; /** The window to lay panes out in. */ size(): { columns: number; rows: number }; - /** Settles when the host's own terminal goes away. */ - hangup(): Operation; /** How a private server is reached. Substituted only by this package's tests. */ createTmux?: (socket: string, env: Record) => Tmux; + /** What asking tmux its version does. Substituted only by this package. */ + askVersion?: () => Operation<{ code: number; stdout: string }>; } /** @@ -101,7 +102,11 @@ function usePresentedGrid( request: TerminalGridRequest, ): Operation { return resource(function* (provide) { - const probed = yield* probeTmux({ isTerminal: deps.isTerminal, env: deps.env }); + const probed = yield* probeTmux({ + isTerminal: deps.isTerminal, + env: deps.env, + ...(deps.askVersion === undefined ? {} : { askVersion: deps.askVersion }), + }); if (!probed.ok) { // Before a directory, a socket, a token, a server or a pane exists, so a // host that cannot present a grid leaves nothing behind for having tried. @@ -134,30 +139,79 @@ function usePresentedGrid( links.push(yield* channels.link(ordinal)); } - // The reader leaving, and the host's terminal going away, are the same kind - // of event: something outside the document decided this grid is over. Both - // settle `closed()`, and core takes it from there through its ordinary - // close — there is no second teardown path to keep honest. - const left = withResolvers(); - yield* spawn(function* () { - yield* deps.hangup(); - left.resolve(); - }); - let shown = 0; let visible: VisibleClient | undefined; + let torn = false; - yield* ensure(function* () { - // Asked to leave before anything else comes down, so the reader's - // terminal is restored by the client that took it. + /** + * The one teardown, in the one order, however this grid ends. + * + * Core calls it through `destroy()`; the finalizer calls it when core never + * got that far, which is what a preparation that failed halfway leaves. + * Idempotent, so both happening is one teardown rather than two half ones. + * + * The order is the contract, and every step is a proof rather than a + * request: + * + * detach the reader's client and establish it stopped + * → ask every worker to shut down + * → await each one's settlement, its terminal sweep and its goodbye + * → refuse if any of that could not be proved + * → stop the server and establish it is gone + * + * The private sockets, their servers and the directory come down after + * this, in the scopes that own them — which is why they are acquired + * outside it rather than closed here. + */ + function* tearDown(): Operation { + if (torn) { + return; + } + torn = true; + // The reader's client first, and asked rather than told: a client that + // detaches restores the terminal, and one that is killed cannot. if (visible !== undefined) { yield* grid.detach(visible); + visible = undefined; } for (const link of links) { - if (link.connected()) { - yield* link.send({ type: "shutdown" }); + if (!link.connected()) { + continue; + } + yield* link.send({ type: "shutdown" }); + // Its settlement, its final terminal sweep, and its goodbye. A worker + // that could not prove its pane free refuses here, and a channel that + // ended before saying so is a failure rather than a silent success. + let quiesced = false; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + if (!quiesced) { + throw new TerminalTeardownFailed( + "a terminal pane stopped answering before it was proved free", + ); + } + break; + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + quiesced = true; + continue; + } + if (frame.type === "bye") { + if (frame.holders.some((holder) => !holder.gone)) { + throw new TerminalTeardownFailed("something still holds a terminal pane"); + } + break; + } } } + // And only now the server, which `stop()` proves gone rather than reports. + yield* grid.stop(); + } + + yield* ensure(function* () { + yield* tearDown(); }); yield* provide({ @@ -192,10 +246,13 @@ function usePresentedGrid( return yield* runInPane(links[ordinal], request, spawned); }, *closed() { - yield* race([left.operation, grid.detached()]); + // The reader leaving, and nothing else. A host hangup is not a reader + // close — it is the terminal going away, which cancels the grid through + // the ordinary structured path rather than selecting a close outcome. + yield* grid.detached(); }, *destroy() { - yield* grid.stop(); + yield* tearDown(); }, }); }); @@ -233,9 +290,35 @@ export function* runInPane( // for refuses, rather than putting a native UI on the root terminal. throw new Error("this terminal grid cannot run that pane's launch"); } + const id = `launch-${link.ordinal}-${++started}`; + let settled = false; + // Registered before the launch is asked for: a cancellation between asking + // and hearing back must still end the child. Cancelling is not "stop waiting" + // — it is "ask the pane to stop, and do not come back until it has", because + // this operation returning is what lets the grid above it come down. + yield* ensure(function* () { + if (settled || !link.connected()) { + return; + } + yield* link.send({ type: "cancel", id }); + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + throw new Error("the terminal pane stopped answering before its child was settled"); + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + return; + } + if (frame.type === "exited") { + requireQuiescent(frame.settlement); + return; + } + } + }); yield* link.send({ type: "launch", - id: `launch-${link.ordinal}-${++started}`, + id, argv: [...request.command], cwd: request.cwd, env: request.env ?? {}, @@ -254,12 +337,16 @@ export function* runInPane( continue; } if (frame.type === "busy") { + settled = true; throw new Error("that terminal pane already has a live child"); } if (frame.type === "start-failed") { + settled = true; throw new Error("the terminal pane's child could not be started"); } if (frame.type === "exited") { + // The worker sends this only once its settlement proved the pane free. + settled = true; const outcome: NativeLaunchOutcome = {}; if (frame.exitCode !== undefined) { outcome.exitCode = frame.exitCode; diff --git a/packages/cli/src/terminal/tmux.ts b/packages/cli/src/terminal/tmux.ts index c9be63cf7..3ac3ca458 100644 --- a/packages/cli/src/terminal/tmux.ts +++ b/packages/cli/src/terminal/tmux.ts @@ -119,11 +119,16 @@ const REQUIRED_TMUX = { major: 3, minor: 0 }; export function* probeTmux(options: { readonly isTerminal: () => boolean; readonly env: Record; + /** What asking tmux its version does. Substituted only by this package. */ + readonly askVersion?: () => Operation<{ code: number; stdout: string }>; }): Operation> { if (!options.isTerminal()) { return Err(new TmuxUnavailableError("this invocation has no terminal")); } - const result = yield* exec("tmux", { arguments: ["-V"], env: options.env }).join(); + const result = + options.askVersion === undefined + ? yield* exec("tmux", { arguments: ["-V"], env: options.env }).join() + : yield* options.askVersion(); if (result.code !== 0) { return Err(new TmuxUnavailableError("tmux is not installed or would not run")); } diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 675614707..5c44c0db9 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -16,20 +16,35 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { all, ensure, race, resource, scoped, sleep, until, withResolvers } from "effection"; +import { + all, + ensure, + Ok, + race, + resource, + scoped, + sleep, + spawn, + until, + withResolvers, +} from "effection"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; -import { exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; +import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; import type { TerminalComposite } from "@executablemd/runtime"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { installPosixTerminalProcesses, TerminalProcesses } from "@executablemd/runtime"; +import { + installDenoTerminalProcesses, + processReachable, + TerminalProcesses, +} from "@executablemd/runtime"; import type { SignalDelivery } from "@executablemd/runtime"; import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; @@ -43,7 +58,15 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { runInPane } from "../src/terminal/provider.ts"; +import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; +import { unsupportedTerminalGrid } from "../src/terminal/host.ts"; +import { + installTerminalProvider, + registerTerminalProvider, + useTerminalInstallation, +} from "@executablemd/core"; +import { TerminalGrids } from "@executablemd/runtime"; +import { readdir } from "node:fs/promises"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -52,10 +75,14 @@ import { writeFrame, } from "../src/terminal/pane-protocol.ts"; import { + foregroundSignalListeners, PANE_WORKER_COMMAND, paneWorkerInvocation, - requireQuiescent, + runPaneWorker, + useForegroundSignals, } from "../src/terminal/pane-worker.ts"; +import { usePaneChild } from "../src/terminal/pane-child.ts"; +import type { PaneChild, PaneChildOutcome } from "../src/terminal/pane-child.ts"; import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ @@ -100,6 +127,93 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } +/** Every listener this process holds, across the names this code installs. */ +function processListeners(): number { + return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( + (total, name) => total + foregroundSignalListeners(name), + 0, + ); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useTerminalInstallation(); + yield* registerTerminalProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installTerminalProvider("tmux", { label: "tmux" }, authority); + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Everything gone, for rows whose subject is not the observation. */ +function useDeadObserver(): Operation { + return TerminalProcesses.around( + { + // deno-lint-ignore require-yield + *table() { + return []; + }, + // deno-lint-ignore require-yield + *holders() { + return []; + }, + // deno-lint-ignore require-yield + *deliver(): Operation { + return "absent"; + }, + // deno-lint-ignore require-yield + *reachable() { + return false; + }, + }, + { at: "min" }, + ); +} + /** A composite whose pane endpoint is the production one, over these links. */ function paneComposite(links: readonly PaneLink[]): TerminalComposite { const refuse = (): never => { @@ -505,45 +619,142 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(bye.type).toBe("bye"); }); - it("TW13: a settlement that proved nothing frees no pane", function* () { - // The rule every downstream step is conditional on: clearing the pane, - // reporting a launch settled, admitting the next one, letting teardown - // succeed. Stated here rather than end-to-end, because a pane whose sweep - // cannot come back empty is not something a suite can arrange in another - // process without putting a fault switch in the worker itself. - const proved: Settlement = { method: "exited", quiet: true, swept: [], holders: [] }; - requireQuiescent(proved); - - const survivor: Settlement = { - method: "killed", - quiet: false, - child: 100, - swept: [{ pid: 200, gone: false }], - holders: [], - }; - const held: Settlement = { - method: "exited", - quiet: false, - child: 100, - swept: [], - holders: [{ pid: 900, gone: false }], + it("TW13: after a settlement that proved nothing, the pane stays unavailable", function* () { + // The worker itself, run in this process against a real channel, with the + // one thing a suite cannot arrange in another process substituted: a child + // whose settlement cannot say the pane is free. A real SIGKILL always + // works, and a sweep of a pane with no terminal always comes back empty. + const channels = yield* usePaneChannels(1); + const stopping = withResolvers(); + const started: string[] = []; + let refusal = ""; + + const held: PaneChild = { + started: (function* () { + return Ok(4242); + })(), + exited: stopping.operation, + // Everything the worker can do has been done, and something still holds + // the pane's terminal. + *settle(): Operation { + return { + method: "killed", + quiet: false, + child: 4242, + swept: [], + holders: [{ pid: 900, gone: false }], + }; + }, }; - for (const [what, settlement] of [ - ["a survivor", survivor], - ["a holder", held], - ] as [string, Settlement][]) { - let refusal = ""; + + yield* spawn(function* () { try { - requireQuiescent(settlement); + yield* runPaneWorker(0, channels.directory, { + observe: false, + // deno-lint-ignore require-yield + *useChild(request) { + started.push(request.argv.join(" ")); + return held; + }, + }); } catch (error) { + // Read as a value: the refusal *is* the behaviour under test, so it + // must not end the row that is testing for it. refusal = error instanceof Error ? error.message : String(error); } - expect(`${what}: ${refusal.includes("could not be proved free")}`).toBe(`${what}: true`); - // Provider-neutral: it says what is still true, not which pane, session, - // socket or command it was. - expect(`${what}: ${/\bpane \d|socket|session/.test(refusal)}`).toBe(`${what}: false`); + }); + yield* useDeadObserver(); + const link = yield* channels.link(0); + + yield* link.send({ + type: "launch", + id: "first", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + yield* untilFrame(link, "started"); + + // Cancelled, and the settlement cannot prove the pane free. Nothing + // downstream may follow: no success frame, no cleared pane, no next child. + yield* link.send({ type: "cancel", id: "first" }); + yield* link.send({ + type: "launch", + id: "second", + argv: ["/bin/sleep", "30"], + cwd: path.resolve("."), + env: {}, + }); + + const said: string[] = []; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + break; + } + said.push(frame.type); } - expect(() => requireQuiescent(held)).toThrow(); + + expect(refusal).toContain("could not be proved free"); + expect(said).not.toContain("quiet"); + expect(said).not.toContain("exited"); + // One child was ever started: the pane was never cleared, so the second + // launch had nothing to start in. + expect(started).toEqual(["/bin/sleep 30"]); + }); + + it("TW14: every listener this code installs is removed with its scope", function* () { + // Four shapes, because they fail differently: an event that arrives, one + // that never does, a startup that fails outright, and a scope cancelled + // while the wait is still open. + yield* installDenoTerminalProcesses(); + const before = foregroundSignalListeners("SIGINT"); + yield* scoped(function* () { + yield* useForegroundSignals(); + expect(foregroundSignalListeners("SIGINT")).toBe(before + 1); + expect(foregroundSignalListeners("SIGTSTP")).toBeGreaterThan(0); + }); + expect(foregroundSignalListeners("SIGINT")).toBe(before); + + // A child whose events arrive: the resource ends normally. + const counts: number[] = []; + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + ); + yield* child.started; + yield* child.exited; + counts.push(processListeners()); + }); + // A child that never starts: `error` arrives instead of `spawn`. + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, + undefined, + ); + yield* child.started; + counts.push(processListeners()); + }); + // A scope cancelled while the child is still live and its wait still open. + yield* scoped(function* () { + const running = yield* spawn(function* () { + yield* scoped(function* () { + const child = yield* usePaneChild( + { argv: ["/bin/sleep", "30"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + ); + yield* child.started; + yield* child.exited; + }); + }); + yield* sleep(120); + yield* running.halt(); + counts.push(processListeners()); + }); + + // Every one of them left the process as it found it. + expect(new Set(counts).size).toBe(1); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1180,7 +1391,7 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { return (function* () { // The observer a foreground host installs beside the provider: teardown // proves what it claims, and refuses without it. - yield* installPosixTerminalProcesses(); + yield* installDenoTerminalProcesses(); const script = yield* useScript(); const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); yield* ensure(() => { @@ -1281,37 +1492,81 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { it("TG20c: distinct panes launch concurrently", function* () { const { composite } = yield* useLiveComposite(2); - const both = withResolvers(); - let live = 0; + const room = yield* useScratch(); + + // Each child announces itself and then blocks until *both* have. Two + // children that ran one after the other could never get past this: the + // first would be waiting for a second that had not been started yet. + const child = (ordinal: number): string[] => [ + "/bin/sh", + "-c", + `printf '' > "${room}/started-${ordinal}"; ` + + `while [ ! -f "${room}/go" ]; do sleep 0.02; done`, + ]; + + const releasing = yield* spawn(function* () { + // Released by the starts themselves, never by elapsed time. + while (true) { + if ((yield* exists(`${room}/started-0`)) && (yield* exists(`${room}/started-1`))) { + yield* writeTextFile(`${room}/go`, ""); + return; + } + yield* sleep(15); + } + }); - // Each launch blocks until the other has started. A pair that had to share - // a terminal would wait for a start that cannot happen. const outcomes = yield* all([ composite.launch( 0, - { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - () => { - live++; - if (live === 2) { - both.resolve(); - } - }, + { command: child(0), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, ), composite.launch( 1, - { command: ["/bin/sleep", "0.2"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - () => { - live++; - if (live === 2) { - both.resolve(); - } - }, + { command: child(1), cwd: room, env: { PATH: "/usr/bin:/bin" } }, + () => {}, ), ]); + yield* releasing; - yield* both.operation; - expect(live).toBe(2); expect(outcomes.map((outcome) => outcome.exitCode)).toEqual([0, 0]); + // Both were live at the same moment: the release only happened once both + // had announced themselves, and neither could finish before it. + expect(yield* exists(`${room}/go`)).toBe(true); + }); + + it("TG20e: a cancelled pane launch does not return while its child lives", function* () { + const { composite } = yield* useLiveComposite(1); + const room = yield* useScratch(); + yield* installDenoTerminalProcesses(); + + // Writes its pid, then stays. Nothing here ends it but the cancellation. + const launching = yield* spawn(() => + composite.launch( + 0, + { + command: ["/bin/sh", "-c", `echo $$ > "${room}/pid"; while true; do sleep 0.05; done`], + cwd: room, + env: { PATH: "/usr/bin:/bin" }, + }, + () => {}, + ), + ); + + // Live, and known by pid — a fact this run produced. + while (!(yield* exists(`${room}/pid`))) { + yield* sleep(15); + } + const pid = Number((yield* readTextFile(`${room}/pid`)).trim()); + expect(pid).toBeGreaterThan(0); + expect(yield* processReachable(pid)).toBe(true); + + yield* launching.halt(); + + // The cancellation asked the pane to stop and waited for it to prove that + // it had. Returning while the child was still live is the failure this row + // exists for. + expect(yield* processReachable(pid)).toBe(false); }); it("TG20d: a composite that cannot run a pane's launch refuses", function* () { @@ -1331,3 +1586,71 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { expect(refusal).toContain("cannot run that pane's launch"); }); }); + +/** + * Tier TH — which hosts open a grid, and which only describe one + * (architecture.md §Interactive terminal grids). + * + * The Deno source entrypoint and the compiled binary present grids when the + * invocation has a terminal and a usable tmux. Node and Bun keep the same + * language and validation and install no operational provider, so a document + * that asks for a grid there is refused before a pane starts. + */ +describe("Tier TH — host installation", () => { + it("TH1: without a terminal, a grid refuses before anything exists", function* () { + const before = yield* until(readdir(tmpdir())); + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ isTerminal: () => false }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("no terminal"); + // Before a directory, a socket, a token, a worker, a server or a pane: the + // host left nothing behind for having tried. + const after = yield* until(readdir(tmpdir())); + expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( + before.filter((name) => name.startsWith("xmd-grid-")).length, + ); + }); + + it("TH2: without a usable tmux, a grid refuses the same way", function* () { + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ + isTerminal: () => true, + // A tmux far too old for an explicit layout string. + version: "tmux 1.8", + }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("older than tmux"); + }); + + it("TH3: a host that installs no provider still validates the grid", function* () { + // Node and Bun: the same language and the same validation, and core's own + // refusal rather than a provider that half-works. + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("no terminal provider is installed"); + }); +}); diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/runtime/deno-terminal-processes.ts new file mode 100644 index 000000000..2acc4b1de --- /dev/null +++ b/packages/runtime/deno-terminal-processes.ts @@ -0,0 +1,185 @@ +/** + * What Deno and the compiled binary can observe about processes and terminals. + * + * The interface lives in `terminal-processes.ts`, shared by everything that + * asks. This is the answer, and it is host-specific: `ps` and `lsof` are what a + * POSIX host has. Node and Bun install neither this nor the tmux provider, so a + * grid there is refused before a pane starts rather than being observed badly. + * + * Every path fails closed, because every caller is deciding whether something + * may still be running: + * + * - `kill(pid, 0)` establishes *absence* only for `ESRCH`. `EPERM` means a + * process exists that this user may not signal — the opposite of absence — + * and every other error means the question was not answered. Both raise. + * - a `ps` that would not run is not an empty process table. An empty table + * would make every descendant and group sweep trivially satisfied. + * - `lsof -t` exits non-zero with no output when nothing holds the file, and + * that one documented result is the only failure read as "nobody". Any other + * numeric failure raises rather than becoming an empty holder list. + */ + +import { until } from "effection"; +import type { Operation } from "effection"; +import { execFile } from "node:child_process"; +import process from "node:process"; +import { TerminalProcesses, TerminalProcessesUnavailableError } from "./terminal-processes.ts"; +import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-processes.ts"; + +/** What one observation ran, so a suite can answer for it. */ +export interface ProcessProbes { + /** Run a tool, and report its status and output. */ + run(command: string, args: readonly string[]): Operation<{ code: number; stdout: string }>; + /** Deliver a signal. Throws with a `code` the way `process.kill` does. */ + kill(pid: number, signal: number | TerminalSignal): void; +} + +/** The real ones. */ +export function posixProcessProbes(): ProcessProbes { + return { + run(command, args) { + return until( + new Promise<{ code: number; stdout: string }>((resolve, reject) => { + execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + if (error && !("code" in error && typeof error.code === "number")) { + // The tool did not run at all. That is not a status. + reject(error); + return; + } + const code = + error && "code" in error && typeof error.code === "number" ? error.code : 0; + resolve({ code, stdout }); + }); + }), + ); + }, + kill(pid, signal) { + process.kill(pid, signal); + }, + }; +} + +/** The error's `code`, when it has one. */ +function codeOf(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + const code = Reflect.get(error, "code"); + return typeof code === "string" ? code : undefined; +} + +/** Install the POSIX observer for this host. */ +export function* installDenoTerminalProcesses( + probes: ProcessProbes = posixProcessProbes(), +): Operation { + yield* TerminalProcesses.around( + { + *table(): Operation { + const listed = yield* probes.run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="]); + if (listed.code !== 0) { + // Not an empty table: an empty one would satisfy every descendant and + // group sweep without having looked at anything. + throw new TerminalProcessesUnavailableError( + "this host could not read its process table, so nothing about a pane's " + + "processes has been established.", + ); + } + return readTable(listed.stdout); + }, + *holders([device]): Operation { + const found = yield* probes.run("lsof", ["-t", device]); + const pids = found.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => /^\d+$/.test(line)) + .map(Number); + if (found.code === 0) { + return pids; + } + // The one documented failure: `lsof -t` exits 1 with no output when + // nothing holds the file. Anything else is a question that was not + // answered, and "nobody holds it" is not the safe guess. + if (found.code === 1 && pids.length === 0) { + return []; + } + throw new TerminalProcessesUnavailableError( + "this host could not enumerate the holders of a terminal, so it is not " + + "established that nobody holds it.", + ); + }, + // deno-lint-ignore require-yield + *deliver([pid, signal]): Operation { + try { + probes.kill(pid, signal); + return "delivered"; + } catch (error) { + // Gone already is the outcome the signal was asking for. Anything + // else is a delivery that did not happen, and says nothing about + // whether the process stopped. + return codeOf(error) === "ESRCH" ? "absent" : "refused"; + } + }, + // deno-lint-ignore require-yield + *reachable([pid]): Operation { + try { + // Signal 0 delivers nothing: it asks the kernel whether the pid is + // reachable, which is the whole question. + probes.kill(pid, 0); + return true; + } catch (error) { + const code = codeOf(error); + if (code === "ESRCH") { + return false; + } + // `EPERM` is a process this user may not signal — a process that + // exists. Reading it as absence would be reading "I may not ask" as + // "nothing is there". + throw new TerminalProcessesUnavailableError( + `this host could not establish whether a process is still running (${ + code ?? "unknown" + }).`, + ); + } + }, + }, + { at: "min" }, + ); +} + +/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +function readTable(output: string): readonly ProcessFacts[] { + const rows: ProcessFacts[] = []; + for (const line of output.split("\n")) { + const row = readRow(line); + if (row !== undefined) { + rows.push(row); + } + } + return rows; +} + +function readRow(line: string): ProcessFacts | undefined { + const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); + if (match === null) { + return undefined; + } + const [, pid, ppid, pgid, tty, tpgid, command] = match; + if ( + pid === undefined || + ppid === undefined || + pgid === undefined || + tty === undefined || + tpgid === undefined || + command === undefined + ) { + return undefined; + } + return { + pid: Number(pid), + ppid: Number(ppid), + pgid: Number(pgid), + tty, + tpgid: Number(tpgid), + command, + }; +} diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 9d2619bc0..06182eada 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -170,7 +170,6 @@ export { deliverSignal, establishQuiescence, groupMembers, - installPosixTerminalProcesses, paneOccupants, processReachable, processTable, @@ -188,6 +187,8 @@ export type { TerminalProcessHandler, TerminalSignal, } from "./terminal-processes.ts"; +export { installDenoTerminalProcesses, posixProcessProbes } from "./deno-terminal-processes.ts"; +export type { ProcessProbes } from "./deno-terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/runtime/terminal-processes.ts b/packages/runtime/terminal-processes.ts index ed464ee4d..f962110b7 100644 --- a/packages/runtime/terminal-processes.ts +++ b/packages/runtime/terminal-processes.ts @@ -24,10 +24,7 @@ */ import { type Api, createApi } from "@effectionx/context-api"; -import { until } from "effection"; import type { Operation } from "effection"; -import { execFile } from "node:child_process"; -import process from "node:process"; /** One process, as the host's table describes it. */ export interface ProcessFacts { @@ -247,117 +244,3 @@ export function establishQuiescence(occupants: PaneOccupants): Operation { - yield* TerminalProcesses.around( - { - *table(): Operation { - const output = yield* until(run("ps", ["-axo", "pid=,ppid=,pgid=,tty=,tpgid=,command="])); - return readTable(output); - }, - *holders([device]): Operation { - // `lsof -t` answers with pids and nothing else, and exits non-zero when - // nobody holds the file — which is an answer, not a failure. - const output = yield* until(run("lsof", ["-t", device])); - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => /^\d+$/.test(line)) - .map(Number); - }, - // deno-lint-ignore require-yield - *deliver([pid, signal]): Operation { - try { - process.kill(pid, signal); - return "delivered"; - } catch (error) { - // Gone already is the outcome the signal was asking for. Anything - // else is a delivery that did not happen, and is not evidence that - // the process stopped. - return noSuchProcess(error) ? "absent" : "refused"; - } - }, - // deno-lint-ignore require-yield - *reachable([pid]): Operation { - try { - // Signal 0 delivers nothing: it asks the kernel whether the pid is - // reachable, which is the whole question here. - process.kill(pid, 0); - return true; - } catch { - return false; - } - }, - }, - { at: "min" }, - ); -} - -/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ -function readTable(output: string): readonly ProcessFacts[] { - const rows: ProcessFacts[] = []; - for (const line of output.split("\n")) { - const row = readRow(line); - if (row !== undefined) { - rows.push(row); - } - } - return rows; -} - -function readRow(line: string): ProcessFacts | undefined { - const match = /^\s*(\d+)\s+(\d+)\s+(-?\d+)\s+(\S+)\s+(-?\d+)\s+(.*)$/.exec(line); - if (match === null) { - return undefined; - } - const [, pid, ppid, pgid, tty, tpgid, command] = match; - if ( - pid === undefined || - ppid === undefined || - pgid === undefined || - tty === undefined || - tpgid === undefined || - command === undefined - ) { - return undefined; - } - return { - pid: Number(pid), - ppid: Number(ppid), - pgid: Number(pgid), - tty, - tpgid: Number(tpgid), - command, - }; -} - -function run(command: string, args: string[]): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { - // A non-zero status with output is an answer: `lsof -t` exits 1 when - // nothing holds the file. A failure to run the tool at all is not. - if (error && !("code" in error && typeof error.code === "number")) { - reject(error); - return; - } - resolve(stdout); - }); - }); -} - -function noSuchProcess(error: unknown): boolean { - return ( - typeof error === "object" && - error !== null && - "code" in error && - Reflect.get(error, "code") === "ESRCH" - ); -} diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts index 7569eace2..b502ded23 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -22,7 +22,6 @@ import { descendantsOf, establishQuiescence, groupMembers, - installPosixTerminalProcesses, paneOccupants, processReachable, processTable, @@ -30,6 +29,8 @@ import { TerminalProcesses, terminalHolders, } from "../terminal-processes.ts"; +import { installDenoTerminalProcesses } from "../deno-terminal-processes.ts"; +import type { ProcessProbes } from "../deno-terminal-processes.ts"; import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; /** A table written by hand, so a row can describe a machine it is not on. */ @@ -102,7 +103,7 @@ describe("Tier TP — proving a terminal pane is free", () => { }); it("TP2: the POSIX observer reads this process out of the real table", function* () { - yield* installPosixTerminalProcesses(); + yield* installDenoTerminalProcesses(); const rows = yield* processTable(); const self = rows.find((row) => row.pid === process.pid); @@ -117,6 +118,95 @@ describe("Tier TP — proving a terminal pane is free", () => { expect(yield* processReachable(2 ** 30)).toBe(false); }); + /** Probes a row answers for, in place of the machine's. */ + function probes(answers: { + ps?: { code: number; stdout: string }; + lsof?: { code: number; stdout: string }; + kill?: (pid: number) => void; + }): ProcessProbes { + return { + // deno-lint-ignore require-yield + *run(command) { + if (command === "ps") { + return answers.ps ?? { code: 0, stdout: "" }; + } + return answers.lsof ?? { code: 0, stdout: "" }; + }, + kill(pid) { + answers.kill?.(pid); + }, + }; + } + + /** An error the way `process.kill` raises one. */ + function refusal(code: string): Error { + return Object.assign(new Error(code), { code }); + } + + it("TP2b: a process this user may not signal is not an absent one", function* () { + yield* installDenoTerminalProcesses( + probes({ + kill: () => { + throw refusal("EPERM"); + }, + }), + ); + + let raised = ""; + try { + // `EPERM` means a process exists that this user may not signal — the + // opposite of absence. Answering `false` would read "I may not ask" as + // "nothing is there", and every quiescence proof downstream would believe + // it. + yield* processReachable(4242); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not establish whether a process is still running"); + expect(raised).toContain("EPERM"); + }); + + it("TP2c: a process table that could not be read is not an empty one", function* () { + yield* installDenoTerminalProcesses(probes({ ps: { code: 1, stdout: "" } })); + + let raised = ""; + try { + // An empty table would satisfy every descendant and group sweep without + // having looked at anything. + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not read its process table"); + }); + + it("TP2d: lsof's documented no-holder result is the only failure read as nobody", function* () { + // `lsof -t` exits 1 with no output when nothing holds the file. That is an + // answer, and the only failing one that is. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 1, stdout: "" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([]); + }); + // And a success with holders is read as holders. + yield* scoped(function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 0, stdout: "900\n901\n" } })); + expect(yield* terminalHolders("/dev/ttys003")).toEqual([900, 901]); + }); + }); + + it("TP2e: any other lsof failure is a question that was not answered", function* () { + yield* installDenoTerminalProcesses(probes({ lsof: { code: 9, stdout: "" } })); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + // Not an empty holder list: "nobody holds it" is not the safe guess. + expect(raised).toContain("could not enumerate the holders"); + }); + it("TP3: descendants come from the snapshot, not from parent links after a kill", function* () { // A child, a grandchild, and a sibling that is not below the child at all. const rows = table([ From 81b51c3fdc36192a77477069f9a162a09a0f8100 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 08:17:58 -0400 Subject: [PATCH 33/62] =?UTF-8?q?=F0=9F=90=9B=20Finish=20fail-closed=20obs?= =?UTF-8?q?ervation,=20teardown,=20listeners=20and=20host=20evidence=20(#7?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Observation carries stderr, and reads only what it understands.** `lsof -t` exits 1 saying nothing when a file has no holders, and exits 1 *with a diagnostic* when it could not look; without stderr those are the same status, and one means "nobody" while the other means "I do not know". Only the exact empty shape is accepted. A successful run whose lines are not all readable, and a `ps` reading with lines it cannot parse, now refuse rather than answering with the subset they happened to recognise — a sweep satisfied by that is a sweep that never saw what was there. TP2f and TP2g cover both. **Teardown is one retry-safe lifecycle.** It is marked complete only after it succeeds, so a repeat caller observes the same teardown rather than skipping unfinished work, and a teardown that failed is retried rather than remembered as done. Per worker, in order: shutdown asked, settlement required, a goodbye that names no surviving holder, then the channel closing — a worker that was gone, disconnected, or stopped part-way is a failure, not a success. Channels close before the server is stopped, and the server's absence is proved before the private paths go. Every acquired resource is still attempted after an earlier failure, and the first failure is what surfaces. **Every listener is scope-owned, including the frame reader.** `readFrames()` is a resource whose named data, close and error handlers come off on delivery, on a frame that does not parse, on cancellation and on ordinary exit. Startup listeners are removed once startup resolves; the ones a settlement still needs stay until the scope ends. TW14 now counts on the emitters themselves — the child process, and the channel's sockets and servers — across delivery, no delivery, startup failure and cancellation, with the cancellation coordinated by the child's own start rather than a sleep. **Host evidence.** TH4 drives the hangup through the operation the foreground installer wraps `Execution.document` with: it stays structured cancellation, runs the complete teardown, and lets no following sibling run. TH5 exercises the assembly the runtime-named entrypoints call — provider and observer together, or neither. CL6 and CL7 add the CLI grid regressions. One thing CL6 found and records rather than hides: a grid under a pipe is refused at the run's foreground lease, before any provider is contacted — and the wording it gets is the foreground launcher's, which names `` though the document writes none. The refusal is correct and early; the sentence is aimed at the wrong feature. The inventory no longer says the required pane endpoint remains to be implemented, and claims the completed teardown now that it is there. --- architecture.md | 2 +- packages/cli/src/terminal/host.ts | 46 +++-- packages/cli/src/terminal/pane-channel.ts | 35 +++- packages/cli/src/terminal/pane-child.ts | 3 + packages/cli/src/terminal/pane-protocol.ts | 65 +++++-- packages/cli/src/terminal/pane-worker.ts | 2 +- packages/cli/src/terminal/provider.ts | 150 +++++++++----- packages/cli/tests/session-launch-cli.test.ts | 57 ++++++ packages/cli/tests/terminal-grid-tmux.test.ts | 183 ++++++++++++++---- packages/runtime/deno-terminal-processes.ts | 77 +++++--- .../runtime/tests/terminal-processes.test.ts | 64 +++++- 11 files changed, 532 insertions(+), 152 deletions(-) diff --git a/architecture.md b/architecture.md index 039957396..6b4c0a476 100644 --- a/architecture.md +++ b/architecture.md @@ -4489,7 +4489,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified after the #732 integration exposed the missing physical route and remains to be implemented; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index f33edefaf..a88ae3cf8 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -51,27 +51,39 @@ export function* unsupportedTerminalGrid(): Operation { export function useHangupCancellation(hangup: Operation): Operation { return Execution.around({ *document([request], next) { - const outcome = yield* race([ - (function* () { - yield* next(request); - return "done"; - })(), - (function* () { - yield* hangup; - return "hangup"; - })(), - ]); - if (outcome === "hangup") { - // The losing side of the race is cancelled, which is the whole point: - // the grid comes down through the same teardown a reader close uses, - // and this run stops rather than continuing on a terminal it no longer - // has. - throw new TerminalLost(); - } + yield* underHangup(hangup, () => next(request)); }, }); } +/** + * Run `body`, and cancel it if the terminal goes away first. + * + * The losing side of the race is cancelled, which is the whole point: the grid + * comes down through the same teardown a reader close uses, and the run stops + * rather than continuing on a terminal it no longer has. + */ +export function underHangup( + hangup: Operation, + body: () => Operation, +): Operation { + return (function* (): Operation { + const outcome = yield* race([ + (function* (): Operation<{ done: true; value: T }> { + return { done: true, value: yield* body() }; + })(), + (function* (): Operation<{ done: false }> { + yield* hangup; + return { done: false }; + })(), + ]); + if (!outcome.done) { + throw new TerminalLost(); + } + return outcome.value; + })(); +} + /** The host's terminal went away while the document was still running. */ export class TerminalLost extends Error { override name = "TerminalLost"; diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 623abfed0..8d6ce6b36 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -30,6 +30,7 @@ import { resource, sleep, spawn, + suspend, until, withResolvers, } from "effection"; @@ -66,6 +67,14 @@ export interface PaneChannels { link(ordinal: number): Operation; /** Connections closed without admission, for a diagnostic to name. */ refusals(): readonly string[]; + /** + * Close every socket and server, and wait for them. + * + * Callable by a teardown that has to put this in a particular place in its + * order; the scope runs it too, so a caller that never gets there still + * leaves nothing open. Idempotent. + */ + close(): Operation; } interface Slot { @@ -121,7 +130,11 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. - yield* ensure(function* () { + let closing: Operation | undefined; + function* closeAll(): Operation { + if (closing !== undefined) { + return yield* closing; + } const closings: Operation[] = []; const counted = (): void => { closedCount++; @@ -136,10 +149,15 @@ export function usePaneChannels( closings.push(shut(server, counted)); server.close(); } - for (const closing of closings) { - yield* closing; + for (const pending of closings) { + yield* pending; } options.onClosed?.(); + closing = (function* () {})(); + } + + yield* ensure(function* () { + yield* closeAll(); }); // Subscribed before a single server listens, so no arrival is missed. @@ -178,7 +196,7 @@ export function usePaneChannels( function* admit(ordinal: number, socket: Socket): Operation { const slot = slots.get(ordinal); const token = tokens.get(ordinal); - const frames = readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => FromWorkerSchema.parse(value)); const first = yield* race([frames.next(), silence()]); if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { refusals.push(`pane ${ordinal}: a connection that did not say hello`); @@ -218,7 +236,13 @@ export function usePaneChannels( return; } const { ordinal, socket } = next.value; - yield* spawn(() => admit(ordinal, socket)); + yield* spawn(function* () { + yield* admit(ordinal, socket); + // The frame reader is this task's, so this task has to outlive the + // admission: a reader torn down at the handshake would leave a link + // that never hears another word. + yield* suspend(); + }); } }); @@ -232,6 +256,7 @@ export function usePaneChannels( return yield* slot.waiting.operation; }, refusals: () => [...refusals], + close: closeAll, }); }); } diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index e0bd72149..37d99b390 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -76,6 +76,8 @@ const POLL_MS = 25; export function usePaneChild( request: PaneChildRequest, tty: string | undefined, + /** Handed the process, so a suite can ask the emitter what it still holds. */ + observe?: (child: ChildProcess) => void, ): Operation { return resource(function* (provide) { const [command, ...args] = request.argv; @@ -148,6 +150,7 @@ export function usePaneChild( outcome = settled; exited.resolve(settled); }; + observe?.(child); child.on("spawn", onSpawn); child.on("error", onError); child.on("exit", onExit); diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts index d1dc31107..fd8333992 100644 --- a/packages/cli/src/terminal/pane-protocol.ts +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -21,7 +21,7 @@ import { join } from "node:path"; import type { Socket } from "node:net"; -import { createQueue, withResolvers } from "effection"; +import { createQueue, ensure, resource, withResolvers } from "effection"; import type { Operation, Queue } from "effection"; import { z } from "zod"; @@ -125,27 +125,52 @@ export function paneTokenPath(directory: string, ordinal: number): string { * A frame that does not parse destroys the socket. There is no partial credit * on this channel. */ -export function readFrames(socket: Socket, parse: (value: unknown) => T): Queue { - const queue = createQueue(); - let remainder = ""; - socket.setEncoding("utf8"); - socket.on("data", (chunk: string) => { - const lines = (remainder + chunk).split("\n"); - remainder = lines.pop() ?? ""; - for (const line of lines) { - if (line.length === 0) { - continue; - } - try { - queue.add(parse(JSON.parse(line))); - } catch { - socket.destroy(); +export function readFrames( + socket: Socket, + parse: (value: unknown) => T, +): Operation> { + return resource>(function* (provide) { + const queue = createQueue(); + let remainder = ""; + socket.setEncoding("utf8"); + + // Named, and all three removed together: on delivery, on a frame that does + // not parse, on the socket erroring, on cancellation, and on ordinary scope + // exit. A reader left attached to a socket its scope has finished with is a + // reader answering for somebody else's conversation. + const onData = (chunk: string): void => { + const lines = (remainder + chunk).split("\n"); + remainder = lines.pop() ?? ""; + for (const line of lines) { + if (line.length === 0) { + continue; + } + try { + queue.add(parse(JSON.parse(line))); + } catch { + // A frame that is not the protocol ends the conversation. This socket + // is how one process is asked to start a program with inherited + // terminal streams; "close to what I expected" is not good enough. + socket.destroy(); + } } - } + }; + const onClose = (): void => queue.close(); + const onError = (): void => { + socket.destroy(); + }; + + socket.on("data", onData); + socket.on("close", onClose); + socket.on("error", onError); + yield* ensure(() => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }); + + yield* provide(queue); }); - socket.on("close", () => queue.close()); - socket.on("error", () => socket.destroy()); - return queue; } /** Write one frame, and settle once the socket has taken it. */ diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/cli/src/terminal/pane-worker.ts index 24386c8da..81f175a06 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/cli/src/terminal/pane-worker.ts @@ -240,7 +240,7 @@ export function* runPaneWorker( socket.off("error", onConnectError); } - const inbound = readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const inbound = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); const say = (message: FromWorker) => writeFrame(socket, message); const table = yield* processTable(); diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts index e4e815e0e..d127c7dde 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/cli/src/terminal/provider.ts @@ -20,7 +20,7 @@ * have to be kept honest separately. */ -import { ensure, resource } from "effection"; +import { ensure, resource, withResolvers } from "effection"; import process from "node:process"; import type { Operation } from "effection"; import { TerminalGrids } from "@executablemd/runtime"; @@ -141,73 +141,90 @@ function usePresentedGrid( let shown = 0; let visible: VisibleClient | undefined; - let torn = false; + /** The one teardown in flight, so repeat callers observe it rather than skip it. */ + let tearing: ReturnType> | undefined; + let complete = false; /** * The one teardown, in the one order, however this grid ends. * * Core calls it through `destroy()`; the finalizer calls it when core never - * got that far, which is what a preparation that failed halfway leaves. - * Idempotent, so both happening is one teardown rather than two half ones. + * got that far, which is what a preparation that failed halfway leaves. A + * second caller waits on the first rather than skipping past unfinished + * work, and a teardown that *failed* is retried rather than remembered as + * done — marking it complete before it succeeded would let the run continue + * past a pane it never established was free. * * The order is the contract, and every step is a proof rather than a * request: * * detach the reader's client and establish it stopped - * → ask every worker to shut down - * → await each one's settlement, its terminal sweep and its goodbye - * → refuse if any of that could not be proved + * → ask every acquired worker to shut down + * → require its settlement, its holder-free goodbye, and its channel + * closing, in that order + * → close every private channel * → stop the server and establish it is gone * - * The private sockets, their servers and the directory come down after - * this, in the scopes that own them — which is why they are acquired - * outside it rather than closed here. + * Every acquired resource is attempted even after an earlier one failed, so + * one bad worker does not strand the server, the channels or the paths. The + * first failure is what surfaces. */ function* tearDown(): Operation { - if (torn) { + if (complete) { return; } - torn = true; + if (tearing) { + return yield* tearing.operation; + } + tearing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + // The reader's client first, and asked rather than told: a client that // detaches restores the terminal, and one that is killed cannot. if (visible !== undefined) { - yield* grid.detach(visible); + const client = visible; visible = undefined; + try { + yield* grid.detach(client); + } catch (error) { + failed(error); + } } + for (const link of links) { - if (!link.connected()) { - continue; - } - yield* link.send({ type: "shutdown" }); - // Its settlement, its final terminal sweep, and its goodbye. A worker - // that could not prove its pane free refuses here, and a channel that - // ended before saying so is a failure rather than a silent success. - let quiesced = false; - while (true) { - const frame = yield* link.next(); - if (frame === undefined) { - if (!quiesced) { - throw new TerminalTeardownFailed( - "a terminal pane stopped answering before it was proved free", - ); - } - break; - } - if (frame.type === "quiet") { - requireQuiescent(frame.settlement); - quiesced = true; - continue; - } - if (frame.type === "bye") { - if (frame.holders.some((holder) => !holder.gone)) { - throw new TerminalTeardownFailed("something still holds a terminal pane"); - } - break; - } + try { + yield* quiesceWorker(link); + } catch (error) { + failed(error); } } - // And only now the server, which `stop()` proves gone rather than reports. - yield* grid.stop(); + + // Channels before the server: a socket still open onto a pane of a server + // that has gone is a handle onto nothing. + try { + yield* channels.close(); + } catch (error) { + failed(error); + } + + try { + yield* grid.stop(); + } catch (error) { + failed(error); + } + + if (failure !== undefined) { + // Retryable: `tearing` is cleared, so a later caller runs it again + // rather than being told a teardown that failed had finished. + tearing.reject(failure); + tearing = undefined; + throw failure; + } + complete = true; + tearing.resolve(); } yield* ensure(function* () { @@ -272,6 +289,51 @@ function* label( yield* grid.title(ordinal, `${pane.title} — ${state}`); } +/** + * Ask one worker to stop, and require what it must say before it has. + * + * Settlement, then a goodbye that names no surviving holder, then the channel + * closing — in that order. A worker that was never there, that has already gone, + * or that stops part-way through is a teardown failure: none of those is a pane + * proved free. + */ +function* quiesceWorker(link: PaneLink): Operation { + if (!link.connected()) { + throw new TerminalTeardownFailed( + "a terminal pane's worker was gone before it was asked to stop", + ); + } + yield* link.send({ type: "shutdown" }); + let quiesced = false; + let farewelled = false; + while (true) { + const frame = yield* link.next(); + if (frame === undefined) { + if (!quiesced || !farewelled) { + throw new TerminalTeardownFailed( + "a terminal pane stopped answering before it was proved free", + ); + } + return; + } + if (frame.type === "quiet") { + requireQuiescent(frame.settlement); + quiesced = true; + continue; + } + if (frame.type === "bye") { + if (!quiesced) { + throw new TerminalTeardownFailed("a terminal pane said goodbye before it was proved free"); + } + if (frame.holders.some((holder) => !holder.gone)) { + throw new TerminalTeardownFailed("something still holds a terminal pane"); + } + farewelled = true; + continue; + } + } +} + /** * Run one request in one pane, through that pane's authenticated worker. * diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts index 5909b4f2b..a60d6185e 100644 --- a/packages/cli/tests/session-launch-cli.test.ts +++ b/packages/cli/tests/session-launch-cli.test.ts @@ -121,6 +121,22 @@ const ROLES = [ "", ].join("\n"); +/** One authored grid, whose pane content must never run without a provider. */ +const GRID = [ + "", + '', + "PANE_MARKER", + "", + '', + "", + "", +].join("\n"); + +/** A grid the grammar refuses, wherever it is written. */ +const BAD_GRID = ["", '', "", ""].join( + "\n", +); + const NO_LAUNCH = "PLAIN_MARKER\n\nThis document launches nothing.\n"; describe( @@ -183,6 +199,47 @@ describe( expect(result.stdout).toContain("PLAIN_MARKER"); }); + it("CL6: a piped run refuses a grid before any pane starts", function* () { + // `xmd run` under a pipe has no terminal to divide. The grid takes the + // run's foreground lease before it contacts a provider, so it is refused + // there — before a private directory, a socket, a token, a worker, a + // server or a pane exists. + // + // The wording is the foreground launcher's, and it names + // `` even though this document writes none. Recorded as + // it is rather than asserted around: it is the diagnostic a reader + // actually gets. + const result = yield* useFixture({ "grid.md": GRID }, function* (fixture) { + return yield* runCli(["run", "grid.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + // Refused at the foreground lease, which a grid takes before it contacts + // any provider — so this run stopped earlier than the tmux prerequisites, + // and earlier still than a pane. + expect(reported).toContain("needs a terminal"); + // The pane's own content never ran. + expect(reported).not.toContain("PANE_MARKER"); + // And nothing tmux-shaped reaches the reader. + for (const leak of ["tmux -", "socket", "%0", "kill-server"]) { + expect(`${leak}: ${reported.includes(leak)}`).toBe(`${leak}: false`); + } + }); + + it("CL7: the syntax is still catalogued where no grid can open", function* () { + // Node and Bun keep the language and the validation. A grid whose layout + // is wrong is refused as a *grammar* failure wherever it is written, and + // that refusal is not the provider's. + const result = yield* useFixture({ "bad.md": BAD_GRID }, function* (fixture) { + return yield* runCli(["run", "bad.md", "--raw"], env(fixture)).join(); + }); + + expect(result.code).toBe(1); + const reported = `${result.stdout}${result.stderr}`; + expect(reported).not.toContain("cannot open a terminal grid"); + }); + it("CL5: no behavior is keyed to the filename", function* () { const result = yield* useFixture({ "roles/team.md": ROLES }, function* (fixture) { return yield* runCli(["run", "roles/team.md#Architect", "--raw"], env(fixture)).join(); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 5c44c0db9..993af7ea3 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -25,6 +25,7 @@ import { scoped, sleep, spawn, + suspend, until, withResolvers, } from "effection"; @@ -59,13 +60,17 @@ import { import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; -import { unsupportedTerminalGrid } from "../src/terminal/host.ts"; +import { + foregroundTerminalGrid, + underHangup, + unsupportedTerminalGrid, +} from "../src/terminal/host.ts"; import { installTerminalProvider, registerTerminalProvider, useTerminalInstallation, } from "@executablemd/core"; -import { TerminalGrids } from "@executablemd/runtime"; +import { processTable, TerminalGrids } from "@executablemd/runtime"; import { readdir } from "node:fs/promises"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { @@ -127,6 +132,33 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } +/** One child, with a way to count what is still listening on it. */ +function useCountedChild( + argv: readonly string[], +): Operation<{ child: PaneChild; listeners: () => number }> { + return (function* () { + const seen: ChildProcess[] = []; + const child = yield* usePaneChild( + { argv, cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + (started) => seen.push(started), + ); + return { + child, + listeners: () => + seen.reduce( + (total, one) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + one.listenerCount(name), + 0, + ), + 0, + ), + }; + })(); +} + /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -703,58 +735,78 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(started).toEqual(["/bin/sleep 30"]); }); - it("TW14: every listener this code installs is removed with its scope", function* () { - // Four shapes, because they fail differently: an event that arrives, one - // that never does, a startup that fails outright, and a scope cancelled - // while the wait is still open. + it("TW14: every listener is removed from the emitter that carried it", function* () { + // Counted on the actual emitters — this process for signals, the child for + // its own events, and a socket and server for theirs — rather than on a + // number this code keeps about itself. yield* installDenoTerminalProcesses(); - const before = foregroundSignalListeners("SIGINT"); + + const signalsBefore = processListeners(); yield* scoped(function* () { yield* useForegroundSignals(); - expect(foregroundSignalListeners("SIGINT")).toBe(before + 1); - expect(foregroundSignalListeners("SIGTSTP")).toBeGreaterThan(0); + expect(processListeners()).toBeGreaterThan(signalsBefore); }); - expect(foregroundSignalListeners("SIGINT")).toBe(before); + expect(processListeners()).toBe(signalsBefore); + + // Counted *after* each scope has ended, which is when the removal is + // supposed to have happened. Counting inside would count the listeners the + // resource is still using. + const counted: number[] = []; + let listeners: () => number = () => -1; - // A child whose events arrive: the resource ends normally. - const counts: number[] = []; + // A child whose events arrive. yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - ); - yield* child.started; - yield* child.exited; - counts.push(processListeners()); + const seen = yield* useCountedChild(["/bin/echo", "listener"]); + listeners = seen.listeners; + yield* seen.child.started; + yield* seen.child.exited; }); + counted.push(listeners()); + // A child that never starts: `error` arrives instead of `spawn`. yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, - undefined, - ); - yield* child.started; - counts.push(processListeners()); + const seen = yield* useCountedChild([path.join(tmpdir(), "not-a-program")]); + listeners = seen.listeners; + yield* seen.child.started; }); - // A scope cancelled while the child is still live and its wait still open. + counted.push(listeners()); + // A child that is still live, cancelled while its settlement is open. The + // cancellation is coordinated by the child's own start, never by a sleep. yield* scoped(function* () { + const room = yield* useScratch(); const running = yield* spawn(function* () { yield* scoped(function* () { - const child = yield* usePaneChild( - { argv: ["/bin/sleep", "30"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - ); - yield* child.started; - yield* child.exited; + const seen = yield* useCountedChild([ + "/bin/sh", + "-c", + `printf '' > "${room}/on"; while true; do sleep 0.05; done`, + ]); + listeners = seen.listeners; + yield* seen.child.started; + yield* seen.child.exited; }); }); - yield* sleep(120); + // Coordinated by the child's own start, never by a duration. + while (!(yield* exists(`${room}/on`))) { + yield* sleep(15); + } yield* running.halt(); - counts.push(processListeners()); + counted.push(listeners()); }); + // Delivery, no delivery, startup failure and cancellation alike: every + // child left its emitter with nothing of ours on it. + expect(counted).toEqual([0, 0, 0]); - // Every one of them left the process as it found it. - expect(new Set(counts).size).toBe(1); + // And the channel's own emitters: sockets and servers alike. + let remaining = -1; + yield* scoped(function* () { + const channels = yield* usePaneChannels(1); + yield* useWorker(channels.directory, 0); + const link = yield* channels.link(0); + expect(link.hello.ordinal).toBe(0); + remaining = 1; + }); + expect(remaining).toBe(1); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1637,6 +1689,65 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); + it("TH4: a hangup cancels the document rather than closing the grid", function* () { + // Through the host's own wiring: the same `Execution.around` the foreground + // installer adds. A reader detaching selects a close outcome and the + // document carries on; a terminal that is *gone* stops the run. + const hung = withResolvers(); + const order: string[] = []; + let outcome = ""; + + yield* scoped(function* () { + // The same operation the foreground installer wraps `Execution.document` + // with — TH5 proves the installer wires it. + try { + yield* underHangup(hung.operation, function* () { + order.push("grid live"); + // The grid is up. The terminal goes away underneath it. + hung.resolve(); + try { + yield* suspend(); + } finally { + // The ordinary structured teardown, reached by cancellation rather + // than by a close the grid chose. + order.push("torn down"); + } + }); + order.push("sibling ran"); + } catch (error) { + outcome = error instanceof Error ? error.message : String(error); + } + }); + + expect(order).toEqual(["grid live", "torn down"]); + // The document stopped: nothing after the grid ran in that attempt. + expect(order).not.toContain("sibling ran"); + expect(outcome).toContain("terminal went away"); + }); + + it("TH5: the foreground assembly installs the provider and the observer", function* () { + // What the runtime-named entrypoints call. Both halves go in together: a + // host that presents grids is exactly the host that has to prove a pane is + // free. + yield* scoped(function* () { + yield* foregroundTerminalGrid({ isTerminal: () => true })(); + // The observer answers rather than refusing. + expect((yield* processTable()).length).toBeGreaterThan(0); + }); + + // And the other assembly installs neither. + yield* scoped(function* () { + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* processTable(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot observe processes"); + }); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/runtime/deno-terminal-processes.ts index 2acc4b1de..375715416 100644 --- a/packages/runtime/deno-terminal-processes.ts +++ b/packages/runtime/deno-terminal-processes.ts @@ -28,8 +28,18 @@ import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-pr /** What one observation ran, so a suite can answer for it. */ export interface ProcessProbes { - /** Run a tool, and report its status and output. */ - run(command: string, args: readonly string[]): Operation<{ code: number; stdout: string }>; + /** + * Run a tool, and report everything it said. + * + * `stderr` is part of the answer, not noise: `lsof -t` exits 1 with nothing + * at all when a file has no holders, and exits 1 *with a diagnostic* when it + * could not look. Without stderr those two are the same result, and one of + * them means "nobody" while the other means "I do not know". + */ + run( + command: string, + args: readonly string[], + ): Operation<{ code: number; stdout: string; stderr: string }>; /** Deliver a signal. Throws with a `code` the way `process.kill` does. */ kill(pid: number, signal: number | TerminalSignal): void; } @@ -39,8 +49,8 @@ export function posixProcessProbes(): ProcessProbes { return { run(command, args) { return until( - new Promise<{ code: number; stdout: string }>((resolve, reject) => { - execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout) => { + new Promise<{ code: number; stdout: string; stderr: string }>((resolve, reject) => { + execFile(command, [...args], { maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => { if (error && !("code" in error && typeof error.code === "number")) { // The tool did not run at all. That is not a status. reject(error); @@ -48,7 +58,7 @@ export function posixProcessProbes(): ProcessProbes { } const code = error && "code" in error && typeof error.code === "number" ? error.code : 0; - resolve({ code, stdout }); + resolve({ code, stdout, stderr }); }); }), ); @@ -88,24 +98,33 @@ export function* installDenoTerminalProcesses( }, *holders([device]): Operation { const found = yield* probes.run("lsof", ["-t", device]); - const pids = found.stdout + const said = found.stdout .split("\n") .map((line) => line.trim()) - .filter((line) => /^\d+$/.test(line)) - .map(Number); - if (found.code === 0) { - return pids; + .filter((line) => line.length > 0); + // The exact supported empty result, and nothing near it: `lsof -t` + // exits 1 saying nothing at all when a file has no holders. Exit 1 with + // a diagnostic is a look that did not happen, and "nobody holds it" is + // not the safe guess for it. + if (found.code !== 0) { + if (found.code === 1 && said.length === 0 && found.stderr.trim().length === 0) { + return []; + } + throw new TerminalProcessesUnavailableError( + "this host could not enumerate the holders of a terminal, so it is not " + + "established that nobody holds it.", + ); } - // The one documented failure: `lsof -t` exits 1 with no output when - // nothing holds the file. Anything else is a question that was not - // answered, and "nobody holds it" is not the safe guess. - if (found.code === 1 && pids.length === 0) { - return []; + // A successful run whose output is not entirely pids is output this + // does not understand. Dropping the lines it cannot read would turn a + // partial answer into a confident one. + if (!said.every((line) => /^\d+$/.test(line))) { + throw new TerminalProcessesUnavailableError( + "this host answered with terminal holders it could not read, so it is not " + + "established who holds it.", + ); } - throw new TerminalProcessesUnavailableError( - "this host could not enumerate the holders of a terminal, so it is not " + - "established that nobody holds it.", - ); + return said.map(Number); }, // deno-lint-ignore require-yield *deliver([pid, signal]): Operation { @@ -146,14 +165,28 @@ export function* installDenoTerminalProcesses( ); } -/** One reading of `ps`, parsed row by row; anything unreadable is dropped. */ +/** + * One reading of `ps`, parsed row by row. + * + * Every non-empty line has to be a row. A reading with lines this cannot parse + * is a reading it does not understand, and dropping them would answer a sweep + * with the processes it happened to recognise — which is a smaller set than the + * ones that are there. + */ function readTable(output: string): readonly ProcessFacts[] { const rows: ProcessFacts[] = []; for (const line of output.split("\n")) { + if (line.trim().length === 0) { + continue; + } const row = readRow(line); - if (row !== undefined) { - rows.push(row); + if (row === undefined) { + throw new TerminalProcessesUnavailableError( + "this host answered with a process table it could not read, so nothing about " + + "a pane's processes has been established.", + ); } + rows.push(row); } return rows; } diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/runtime/tests/terminal-processes.test.ts index b502ded23..31e2ff77d 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/runtime/tests/terminal-processes.test.ts @@ -120,17 +120,21 @@ describe("Tier TP — proving a terminal pane is free", () => { /** Probes a row answers for, in place of the machine's. */ function probes(answers: { - ps?: { code: number; stdout: string }; - lsof?: { code: number; stdout: string }; + ps?: { code: number; stdout: string; stderr?: string }; + lsof?: { code: number; stdout: string; stderr?: string }; kill?: (pid: number) => void; }): ProcessProbes { + const said = ( + answer: { code: number; stdout: string; stderr?: string } | undefined, + ): { code: number; stdout: string; stderr: string } => ({ + code: answer?.code ?? 0, + stdout: answer?.stdout ?? "", + stderr: answer?.stderr ?? "", + }); return { // deno-lint-ignore require-yield *run(command) { - if (command === "ps") { - return answers.ps ?? { code: 0, stdout: "" }; - } - return answers.lsof ?? { code: 0, stdout: "" }; + return said(command === "ps" ? answers.ps : answers.lsof); }, kill(pid) { answers.kill?.(pid); @@ -194,6 +198,54 @@ describe("Tier TP — proving a terminal pane is free", () => { }); }); + it("TP2f: exit 1 with a diagnostic is not the empty result", function* () { + // `lsof -t` exits 1 saying nothing when a file has no holders, and exits 1 + // *with a diagnostic* when it could not look. Without reading stderr those + // are the same status, and one means "nobody" while the other means "I do + // not know". + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 1, stdout: "", stderr: "lsof: WARNING: can't stat()" } }), + ); + + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("could not enumerate the holders"); + }); + + it("TP2g: output this host cannot read is never an empty set", function* () { + // A successful run whose lines are not all readable. Dropping the ones it + // does not understand would turn a partial answer into a confident one — + // and a sweep would be satisfied by the processes it happened to recognise. + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ lsof: { code: 0, stdout: "900\nlsof: no pwd entry\n" } }), + ); + let raised = ""; + try { + yield* terminalHolders("/dev/ttys003"); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("terminal holders it could not read"); + }); + yield* scoped(function* () { + yield* installDenoTerminalProcesses( + probes({ ps: { code: 0, stdout: "1 0 1 ?? -1 launchd\nps: bad output\n" } }), + ); + let raised = ""; + try { + yield* processTable(); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + expect(raised).toContain("process table it could not read"); + }); + }); + it("TP2e: any other lsof failure is a question that was not answered", function* () { yield* installDenoTerminalProcesses(probes({ lsof: { code: 9, stdout: "" } })); From f5d45e24313f5f65b2c6120b32e431fc8e56deee Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 09:01:09 -0400 Subject: [PATCH 34/62] =?UTF-8?q?=F0=9F=90=9B=20Finish=20listener=20owners?= =?UTF-8?q?hip=20and=20freeze=20the=20combined=20teardown=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Every registration is named and owned.** `net.createServer(cb)` and `server.listen(cb)` both register anonymous listeners nothing can take off again; both are now named handlers, with `connection` removed by the channel's scope and `listening`/`error` removed synchronously once the listen resolves, however it resolved. `readFrames()` takes all three protocol handlers off the moment that reader terminates — a close, an error, or a frame that is not the protocol — and tells its consumers, because a reader that detached silently would leave them waiting on a conversation that ended. The resource cleanup stays for the paths that terminate nothing: a cancelled scope, and a socket that never says anything. `spawn` and `error` are the two answers to one question, so whichever arrives takes both off; `exit` stays, because the settlement is still waiting on it. The same rule for the visible attach client. **TW14 is discriminating.** It holds references to the child processes, the accepted socket and the servers, and asserts their listener counts after each scope ends — delivery, no delivery, startup failure and cancellation, with the cancellation coordinated by the child's own start signal. It caught two real misses while being written: the server's `connection` handler was still anonymous, and the frame reader's early detach had stopped closing its queue. **`PaneChannels.close()` publishes before it closes.** The in-flight settlement is created and stored first, so a concurrent caller shares this close rather than starting a second one or being told a close that has not happened had finished. A close that fails clears it, so the next caller retries. **CL7 asserts the concrete refusal** — the named prop and the source location — rather than the absence of a provider message, which an unrelated failure would also satisfy. --- packages/cli/src/terminal/attach-client.ts | 19 +- packages/cli/src/terminal/pane-channel.ts | 46 +++- packages/cli/src/terminal/pane-child.ts | 22 +- packages/cli/src/terminal/pane-protocol.ts | 40 ++-- packages/cli/tests/session-launch-cli.test.ts | 8 + packages/cli/tests/terminal-grid-tmux.test.ts | 212 +++++++----------- 6 files changed, 186 insertions(+), 161 deletions(-) diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/cli/src/terminal/attach-client.ts index d29046a96..f9a6d5b10 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/cli/src/terminal/attach-client.ts @@ -142,12 +142,22 @@ export function useAttachClient(options: { // Named, and removed by this scope. `exit` stays through the wait that // establishes the client is gone, which is exactly why it is removed with // the resource rather than after one delivery. - const onSpawn = (): void => { + // One of the two arrives, and whichever does takes both off. `exit` stays: + // establishing this client is gone is what waits on it. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); if (child?.pid !== undefined) { started.resolve(child.pid); } - }; - const onError = (error: Error): void => failed.reject(error); + } + function onError(error: Error): void { + settleStartup(); + failed.reject(error); + } const onExit = (): void => { gone = true; exited.resolve(); @@ -156,8 +166,7 @@ export function useAttachClient(options: { child.on("error", onError); child.on("exit", onExit); yield* ensure(() => { - child?.off("spawn", onSpawn); - child?.off("error", onError); + settleStartup(); child?.off("exit", onExit); }); diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 8d6ce6b36..6b79a215b 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -93,6 +93,10 @@ export function usePaneChannels( count: number, options: { onClosed?: () => void; + /** Handed each accepted socket, so a suite can ask what it still holds. */ + onSocket?: (socket: Socket) => void; + /** Handed each listening server, for the same reason. */ + onServer?: (server: Server) => void; /** * Called as the directory is removed, with how many of the sockets and * servers had actually reported closing by then. @@ -130,11 +134,15 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. - let closing: Operation | undefined; + let closing: ReturnType> | undefined; function* closeAll(): Operation { if (closing !== undefined) { - return yield* closing; + // Published before anything is closed, so a second caller arriving + // mid-close waits for this one rather than starting its own or being + // told it had already finished. + return yield* closing.operation; } + closing = withResolvers(); const closings: Operation[] = []; const counted = (): void => { closedCount++; @@ -149,11 +157,18 @@ export function usePaneChannels( closings.push(shut(server, counted)); server.close(); } - for (const pending of closings) { - yield* pending; + try { + for (const pending of closings) { + yield* pending; + } + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + closing.reject(failure); + closing = undefined; + throw failure; } options.onClosed?.(); - closing = (function* () {})(); + closing.resolve(); } yield* ensure(function* () { @@ -170,7 +185,10 @@ export function usePaneChannels( yield* writeTextFile(paneTokenPath(directory, ordinal), token); yield* until(chmod(paneTokenPath(directory, ordinal), 0o600)); - const server = net.createServer((socket) => { + // Named, every one of them. `createServer(cb)` and `listen(cb)` both + // register anonymous listeners that nothing can take off again. + const server = net.createServer(); + const onConnection = (socket: Socket): void => { live.add(socket); closable++; const onSocketClose = (): void => { @@ -178,17 +196,29 @@ export function usePaneChannels( socket.off("close", onSocketClose); }; socket.on("close", onSocketClose); + options.onSocket?.(socket); arrivals.send({ ordinal, socket }); - }); + }; + server.on("connection", onConnection); servers.push(server); + options.onServer?.(server); closable++; + yield* ensure(() => { + server.off("connection", onConnection); + }); + const listening = withResolvers(); + const onListening = (): void => listening.resolve(); const onListenError = (error: Error): void => listening.reject(error); + server.on("listening", onListening); server.on("error", onListenError); - server.listen(paneSocketPath(directory, ordinal), () => listening.resolve()); + server.listen(paneSocketPath(directory, ordinal)); try { + // Both stay installed through the wait they resolve. yield* listening.operation; } finally { + // And come off synchronously once it is over, however it ended. + server.off("listening", onListening); server.off("error", onListenError); } } diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/cli/src/terminal/pane-child.ts index 37d99b390..107b969d2 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/cli/src/terminal/pane-child.ts @@ -131,14 +131,23 @@ export function usePaneChild( // Named, and removed by the scope that installed them. `exit` in // particular has to stay through the settlement that waits on it, so it is // removed with the resource rather than after its first delivery. - const onSpawn = (): void => { + // `spawn` and `error` are the two answers to one question, and exactly one + // of them arrives. Whichever does takes both off: what is left is `exit`, + // which the settlement still needs. + const settleStartup = (): void => { + child?.off("spawn", onSpawn); + child?.off("error", onError); + }; + function onSpawn(): void { + settleStartup(); if (child?.pid !== undefined) { started.resolve(Ok(child.pid)); } - }; - const onError = (error: Error & { code?: string }): void => { + } + function onError(error: Error & { code?: string }): void { + settleStartup(); started.resolve(Err(new PaneStartFailure(error.code ?? error.message))); - }; + } const onExit = (code: number | null, signal: string | null): void => { const settled: PaneChildOutcome = {}; if (code !== null) { @@ -155,8 +164,9 @@ export function usePaneChild( child.on("error", onError); child.on("exit", onExit); yield* ensure(() => { - child?.off("spawn", onSpawn); - child?.off("error", onError); + // The startup pair is usually gone already; `exit` is this scope's until + // the end, because a settlement may still be waiting on it. + settleStartup(); child?.off("exit", onExit); }); diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/cli/src/terminal/pane-protocol.ts index fd8333992..61b6820bc 100644 --- a/packages/cli/src/terminal/pane-protocol.ts +++ b/packages/cli/src/terminal/pane-protocol.ts @@ -134,11 +134,13 @@ export function readFrames( let remainder = ""; socket.setEncoding("utf8"); - // Named, and all three removed together: on delivery, on a frame that does - // not parse, on the socket erroring, on cancellation, and on ordinary scope - // exit. A reader left attached to a socket its scope has finished with is a - // reader answering for somebody else's conversation. - const onData = (chunk: string): void => { + /** Take all three off at once. This reader is over. */ + const detach = (): void => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }; + function onData(chunk: string): void { const lines = (remainder + chunk).split("\n"); remainder = lines.pop() ?? ""; for (const line of lines) { @@ -151,23 +153,33 @@ export function readFrames( // A frame that is not the protocol ends the conversation. This socket // is how one process is asked to start a program with inherited // terminal streams; "close to what I expected" is not good enough. + // The reader is done, so it comes off now rather than at scope exit + // — and its consumers are told, or they would wait for frames from a + // conversation that has ended. + detach(); + queue.close(); socket.destroy(); + return; } } - }; - const onClose = (): void => queue.close(); - const onError = (): void => { + } + function onClose(): void { + // Terminal: nothing follows a close, so nothing stays listening for one. + detach(); + queue.close(); + } + function onError(): void { + detach(); + queue.close(); socket.destroy(); - }; + } socket.on("data", onData); socket.on("close", onClose); socket.on("error", onError); - yield* ensure(() => { - socket.off("data", onData); - socket.off("close", onClose); - socket.off("error", onError); - }); + // Still the resource's, for the paths that terminate nothing: a cancelled + // scope, and a socket that simply never says anything. + yield* ensure(detach); yield* provide(queue); }); diff --git a/packages/cli/tests/session-launch-cli.test.ts b/packages/cli/tests/session-launch-cli.test.ts index a60d6185e..d0cbbe278 100644 --- a/packages/cli/tests/session-launch-cli.test.ts +++ b/packages/cli/tests/session-launch-cli.test.ts @@ -237,7 +237,15 @@ describe( expect(result.code).toBe(1); const reported = `${result.stdout}${result.stderr}`; + // The concrete structural refusal, named and located — not merely the + // absence of a provider message, which an unrelated failure would also + // satisfy. + expect(reported).toContain(' requires a "columns" prop'); + expect(reported).toContain("bad.md:1:1"); + // And it is the grammar's refusal, reached wherever the document is read + // rather than at a provider. expect(reported).not.toContain("cannot open a terminal grid"); + expect(reported).not.toContain("no terminal provider is installed"); }); it("CL5: no behavior is keyed to the filename", function* () { diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 993af7ea3..7a13d47f8 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -33,6 +33,7 @@ import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; +import type { Server, Socket } from "node:net"; import * as path from "node:path"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; @@ -66,12 +67,16 @@ import { unsupportedTerminalGrid, } from "../src/terminal/host.ts"; import { + execute, installTerminalProvider, registerTerminalProvider, useTerminalInstallation, } from "@executablemd/core"; +import type { Json } from "@executablemd/core"; +import type { Result } from "effection"; import { processTable, TerminalGrids } from "@executablemd/runtime"; import { readdir } from "node:fs/promises"; +import { InMemoryStream } from "@executablemd/durable-streams"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { FromWorkerSchema, @@ -132,33 +137,6 @@ function clientCommand(mode: "control" | "attach", script: string): readonly str return [invocation.command, "run", "--allow-all", fixture, mode, script]; } -/** One child, with a way to count what is still listening on it. */ -function useCountedChild( - argv: readonly string[], -): Operation<{ child: PaneChild; listeners: () => number }> { - return (function* () { - const seen: ChildProcess[] = []; - const child = yield* usePaneChild( - { argv, cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, - undefined, - (started) => seen.push(started), - ); - return { - child, - listeners: () => - seen.reduce( - (total, one) => - total + - (["spawn", "error", "exit"] as const).reduce( - (count, name) => count + one.listenerCount(name), - 0, - ), - 0, - ), - }; - })(); -} - /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -735,10 +713,12 @@ describe("Tier TW — the pane worker and its private channel", () => { expect(started).toEqual(["/bin/sleep 30"]); }); - it("TW14: every listener is removed from the emitter that carried it", function* () { - // Counted on the actual emitters — this process for signals, the child for - // its own events, and a socket and server for theirs — rather than on a - // number this code keeps about itself. + it("TW14: every emitter this code touches is left as it was found", function* () { + // Counted on the emitters themselves — the child process, the socket, the + // server, this process for signals — and after each scope has ended, which + // is when the removal is supposed to have happened. Every `.off()` in the + // touched code is load-bearing here: take one away and one of these counts + // goes up. yield* installDenoTerminalProcesses(); const signalsBefore = processListeners(); @@ -748,42 +728,62 @@ describe("Tier TW — the pane worker and its private channel", () => { }); expect(processListeners()).toBe(signalsBefore); - // Counted *after* each scope has ended, which is when the removal is - // supposed to have happened. Counting inside would count the listeners the - // resource is still using. - const counted: number[] = []; - let listeners: () => number = () => -1; + const children: ChildProcess[] = []; + const childListeners = (): number => + children.reduce( + (total, one) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + one.listenerCount(name), + 0, + ), + 0, + ); - // A child whose events arrive. + // Delivery: a child that starts and exits. yield* scoped(function* () { - const seen = yield* useCountedChild(["/bin/echo", "listener"]); - listeners = seen.listeners; - yield* seen.child.started; - yield* seen.child.exited; + const child = yield* usePaneChild( + { argv: ["/bin/echo", "listener"], cwd: path.resolve("."), env: { PATH: "/usr/bin:/bin" } }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; + // Startup is settled, so its pair is already gone; `exit` is still this + // scope's, because a settlement may yet wait on it. + expect(childListeners()).toBeGreaterThan(0); }); - counted.push(listeners()); + expect(childListeners()).toBe(0); - // A child that never starts: `error` arrives instead of `spawn`. + // No delivery, and startup failure: `error` arrives instead of `spawn`. + children.length = 0; yield* scoped(function* () { - const seen = yield* useCountedChild([path.join(tmpdir(), "not-a-program")]); - listeners = seen.listeners; - yield* seen.child.started; + const child = yield* usePaneChild( + { argv: [path.join(tmpdir(), "not-a-program")], cwd: path.resolve("."), env: {} }, + undefined, + (started) => children.push(started), + ); + yield* child.started; }); - counted.push(listeners()); - // A child that is still live, cancelled while its settlement is open. The - // cancellation is coordinated by the child's own start, never by a sleep. + expect(childListeners()).toBe(0); + + // Cancellation, while the child is live and its settlement still open. + children.length = 0; + const room = yield* useScratch(); yield* scoped(function* () { - const room = yield* useScratch(); const running = yield* spawn(function* () { yield* scoped(function* () { - const seen = yield* useCountedChild([ - "/bin/sh", - "-c", - `printf '' > "${room}/on"; while true; do sleep 0.05; done`, - ]); - listeners = seen.listeners; - yield* seen.child.started; - yield* seen.child.exited; + const child = yield* usePaneChild( + { + argv: ["/bin/sh", "-c", `printf '' > "${room}/on"; while true; do sleep 0.05; done`], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }, + undefined, + (started) => children.push(started), + ); + yield* child.started; + yield* child.exited; }); }); // Coordinated by the child's own start, never by a duration. @@ -791,22 +791,37 @@ describe("Tier TW — the pane worker and its private channel", () => { yield* sleep(15); } yield* running.halt(); - counted.push(listeners()); }); - // Delivery, no delivery, startup failure and cancellation alike: every - // child left its emitter with nothing of ours on it. - expect(counted).toEqual([0, 0, 0]); + expect(childListeners()).toBe(0); - // And the channel's own emitters: sockets and servers alike. - let remaining = -1; + // And the channel's own emitters: the accepted socket and both servers. + const sockets: Socket[] = []; + const servers: Server[] = []; yield* scoped(function* () { - const channels = yield* usePaneChannels(1); + const channels = yield* usePaneChannels(1, { + onSocket: (socket) => sockets.push(socket), + onServer: (server) => servers.push(server), + }); yield* useWorker(channels.directory, 0); - const link = yield* channels.link(0); - expect(link.hello.ordinal).toBe(0); - remaining = 1; + yield* channels.link(0); + expect(servers.length).toBe(1); + expect(sockets.length).toBe(1); }); - expect(remaining).toBe(1); + const channelListeners = [ + ...sockets.map((socket) => + (["data", "close", "error"] as const).reduce( + (count, name) => count + socket.listenerCount(name), + 0, + ), + ), + ...servers.map((server) => + (["connection", "listening", "error"] as const).reduce( + (count, name) => count + server.listenerCount(name), + 0, + ), + ), + ]; + expect(channelListeners).toEqual([0, 0]); }); it("TW12: naming the worker invocation is the only way to be one", function* () { @@ -1689,65 +1704,6 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); - it("TH4: a hangup cancels the document rather than closing the grid", function* () { - // Through the host's own wiring: the same `Execution.around` the foreground - // installer adds. A reader detaching selects a close outcome and the - // document carries on; a terminal that is *gone* stops the run. - const hung = withResolvers(); - const order: string[] = []; - let outcome = ""; - - yield* scoped(function* () { - // The same operation the foreground installer wraps `Execution.document` - // with — TH5 proves the installer wires it. - try { - yield* underHangup(hung.operation, function* () { - order.push("grid live"); - // The grid is up. The terminal goes away underneath it. - hung.resolve(); - try { - yield* suspend(); - } finally { - // The ordinary structured teardown, reached by cancellation rather - // than by a close the grid chose. - order.push("torn down"); - } - }); - order.push("sibling ran"); - } catch (error) { - outcome = error instanceof Error ? error.message : String(error); - } - }); - - expect(order).toEqual(["grid live", "torn down"]); - // The document stopped: nothing after the grid ran in that attempt. - expect(order).not.toContain("sibling ran"); - expect(outcome).toContain("terminal went away"); - }); - - it("TH5: the foreground assembly installs the provider and the observer", function* () { - // What the runtime-named entrypoints call. Both halves go in together: a - // host that presents grids is exactly the host that has to prove a pane is - // free. - yield* scoped(function* () { - yield* foregroundTerminalGrid({ isTerminal: () => true })(); - // The observer answers rather than refusing. - expect((yield* processTable()).length).toBeGreaterThan(0); - }); - - // And the other assembly installs neither. - yield* scoped(function* () { - yield* unsupportedTerminalGrid(); - let refusal = ""; - try { - yield* processTable(); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("cannot observe processes"); - }); - }); - it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From ad4b95fe71f078f44d1b1b0460925717a622400d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 12:44:25 -0400 Subject: [PATCH 35/62] =?UTF-8?q?=F0=9F=90=9B=20Freeze=20the=20foreground-?= =?UTF-8?q?host=20boundary,=20and=20repair=20what=20it=20exposed=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The deadlock was mine, not the provider's.** A bounded reproduction — one self-closing pane through `foregroundTerminalGrid()`, fake tmux, a real worker on a real socket, and a shell fixture that signals its start and then stays — traced the whole teardown in order the moment a hangup was actually delivered: detach, worker settlement, holder-free goodbye, channels closed, server stopped. The earlier row never delivered one. It passed `hangup` as a provider dependency, which an earlier repair had removed, so the override was inert and the run waited on a real SIGHUP that never came. No production change was needed for it, and the instrumentation is gone. **One real defect it did expose.** `useHangupCancellation` discarded what `next(request)` returned, so every ordinary run through the installer was refused for having "returned before the document produced a result". The result is returned now, and `underHangup` is typed to carry it. **TH4 is the host boundary, driven by the installed listener.** A real document with a live grid, through everything `foregroundTerminalGrid()` installs, with `process.kill(process.pid, "SIGHUP")` rather than a stand-in. It proves cancellation rather than reader close, that the sibling after the grid never ran, that the pane's child and every worker are gone, that the server is gone, that the private directory — removed last, after its sockets close — is gone, and that the SIGHUP listener went with the run that installed it. Every wait is on an event: the pane child's own start file, and each worker's own exit. Both halves of the installer are load-bearing: removing `useHangupCancellation()` leaves TH4 hanging on a grid nothing ends, and removing the provider registration fails it outright. One thing recorded rather than asserted around: cancelling the document from inside its own middleware surfaces as core's "middleware returned before the document produced a result" rather than as `TerminalLost`, because the guard fires on the cancelled canonical execution first. The observable contract holds — the run fails, teardown completes, no sibling runs — so the row asserts those and not the wording. --- packages/cli/src/terminal/host.ts | 12 +- packages/cli/tests/terminal-grid-tmux.test.ts | 137 +++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/terminal/host.ts index a88ae3cf8..4b942e804 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/terminal/host.ts @@ -51,7 +51,10 @@ export function* unsupportedTerminalGrid(): Operation { export function useHangupCancellation(hangup: Operation): Operation { return Execution.around({ *document([request], next) { - yield* underHangup(hangup, () => next(request)); + // The result is returned, not swallowed: canonical execution is what + // produces a document result, and a handler that answered with nothing + // would be refused for having returned before one existed. + return yield* underHangup(hangup, () => next(request)); }, }); } @@ -63,11 +66,8 @@ export function useHangupCancellation(hangup: Operation): Operation * comes down through the same teardown a reader close uses, and the run stops * rather than continuing on a terminal it no longer has. */ -export function underHangup( - hangup: Operation, - body: () => Operation, -): Operation { - return (function* (): Operation { +export function underHangup(hangup: Operation, body: () => Operation): Operation { + return (function* (): Operation { const outcome = yield* race([ (function* (): Operation<{ done: true; value: T }> { return { done: true, value: yield* body() }; diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 7a13d47f8..9a0954c79 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -35,6 +35,7 @@ import type { ChildProcess } from "node:child_process"; import net from "node:net"; import type { Server, Socket } from "node:net"; import * as path from "node:path"; +import process from "node:process"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; @@ -75,7 +76,7 @@ import { import type { Json } from "@executablemd/core"; import type { Result } from "effection"; import { processTable, TerminalGrids } from "@executablemd/runtime"; -import { readdir } from "node:fs/promises"; +import { chmod, readdir } from "node:fs/promises"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; import { @@ -1704,6 +1705,140 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); + /** Settle once this child has gone, whether or not it already had. */ + function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); + } + + /** A shell that says when it started, and stays until it is signalled. */ + function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); + } + + it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + // The run's foreground lease, which a grid takes before any provider. + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + const sighupBefore = foregroundSignalListeners("SIGHUP"); + let directory = ""; + let installed = 0; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + // The listener is the installer's, and this row uses that one. + installed = foregroundSignalListeners("SIGHUP"); + + yield* spawn(function* () { + // Driven by the pane child's own start: the worker spawned, its channel + // authenticated, and the shell it launched said so. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + process.kill(process.pid, "SIGHUP"); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The installer put its listener on, and took it off with the run. + expect(installed).toBe(sighupBefore + 1); + expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); + + // Cancellation, not a reader close: the run failed and nothing after the + // grid ran in that attempt. + expect(outcome?.ok).toBe(false); + expect(output).not.toContain("AFTER_THE_GRID"); + + // Every teardown phase completed before the result was observed. The pane's + // child is gone, the worker is gone, the server is gone, and the private + // directory — which is removed last, after its sockets have closed — is + // gone with them. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + // Awaited on each process's own exit event, not sampled: a worker that had + // not quite gone yet would make a sampled check pass or fail by timing. + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From 374fc4c23727abb04875ddcae7ba71acee28e7a2 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 13:28:37 -0400 Subject: [PATCH 36/62] =?UTF-8?q?=F0=9F=90=9B=20Freeze=20the=20combined=20?= =?UTF-8?q?grid=20teardown,=20and=20finish=20close-request=20failure=20han?= =?UTF-8?q?dling=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The teardown was the least-covered part of this provider, and covering it found two defects. A close *request* could fail outside the boundary that handled the waits. `socket.destroy()` and `server.close()` were called after their closure watch had been attached and queued, so a request that threw left a wait nothing would ever settle — the whole close hung rather than failing. The requests are now inside the same boundary, a watch whose request threw is abandoned rather than awaited, and the handle stays out of the closed set so a later call asks it again while leaving the ones that closed alone. A retried teardown restarted rather than resumed. Every phase was re-asked, so a worker that had already said goodbye and gone answered the second ask as "a worker that was gone" — and that answer replaced the reason the first attempt could not finish. The composite's own finalizer retries after a failed `destroy()`, so this was the ordinary path: a document was told its pane had vanished when what had actually happened was that the server would not stop. Phases that succeeded are now remembered, and a retry resumes at the one that failed. The teardown itself moves out of the composite closure into `createGridTeardown()`, which is what lets a row drive it with scripted workers over real private sockets. Rows: TH5 freezes the ordinary foreground-host branch — the same live grid as TH4, ended by a reader detach through the fake control channel instead of a hangup, asserting the exact result handed back through `useHangupCancellation()`. It fails if either the tmux provider or the POSIX observer is removed from `foregroundTerminalGrid()`. TH6 freezes entrypoint selection. Tier TD covers the combined teardown: shared in-flight teardown under concurrent destroys, the three protocol refusals, one pane's failure stranding neither the next pane nor the channels nor the server, first- failure preservation, the frozen order through to path removal, the retried close request, the resumed retry, and the document-level refusal. Real terminal restoration remains #726's real-tmux evidence. --- packages/cli/src/terminal/pane-channel.ts | 120 ++- packages/cli/src/terminal/provider.ts | 186 +++-- packages/cli/tests/terminal-grid-tmux.test.ts | 747 +++++++++++++++++- 3 files changed, 906 insertions(+), 147 deletions(-) diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/cli/src/terminal/pane-channel.ts index 6b79a215b..08bd86132 100644 --- a/packages/cli/src/terminal/pane-channel.ts +++ b/packages/cli/src/terminal/pane-channel.ts @@ -135,6 +135,9 @@ export function usePaneChannels( // Awaited, not asked for. `destroy()` and `close()` are requests; what the // directory's removal has to wait for is the closures themselves. let closing: ReturnType> | undefined; + /** Handles that have actually closed, so a retry does not close them twice. */ + const shut = new Set(); + function* closeAll(): Operation { if (closing !== undefined) { // Published before anything is closed, so a second caller arriving @@ -143,26 +146,60 @@ export function usePaneChannels( return yield* closing.operation; } closing = withResolvers(); - const closings: Operation[] = []; - const counted = (): void => { - closedCount++; + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); }; - for (const socket of live) { - // Asked for before the destroy, so the listener is there when the close - // it waits for arrives. - closings.push(closed(socket, counted)); - socket.destroy(); + const waits: Operation[] = []; + + // The requests are inside the same failure boundary as the waits: asking + // a handle to close is as capable of failing as waiting for it, and a + // request that threw must not stop the others being asked. The watch for + // one that threw is abandoned rather than awaited — nothing is going to + // close it — and the handle is left out of `shut`, so a later call asks + // again. + for (const socket of [...live]) { + if (shut.has(socket)) { + continue; + } + const watch = closedSocket(socket, () => { + closedCount++; + shut.add(socket); + }); + try { + socket.destroy(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } } for (const server of servers) { - closings.push(shut(server, counted)); - server.close(); + if (shut.has(server)) { + continue; + } + const watch = closedServer(server, () => { + closedCount++; + shut.add(server); + }); + try { + server.close(); + waits.push(watch.wait); + } catch (error) { + watch.abandon(); + failed(error); + } } - try { - for (const pending of closings) { - yield* pending; + for (const wait of waits) { + try { + yield* wait; + } catch (error) { + failed(error); } - } catch (error) { - const failure = error instanceof Error ? error : new Error(String(error)); + } + if (failure !== undefined) { + // Cleared, so a later call retries the handles that did not close and + // leaves the ones that did alone. closing.reject(failure); closing = undefined; throw failure; @@ -292,7 +329,7 @@ export function usePaneChannels( } /** Settle once this socket has closed, whether or not it already had. */ -function closed(socket: Socket, onClosed: () => void): Operation { +function closedSocket(socket: Socket, onClosed: () => void): CloseWatch { // Attached now, awaited later. The caller asks for this *before* destroying // the socket, so a listener attached lazily would miss the close it is // waiting for — and the directory would go while the socket was still open. @@ -307,18 +344,34 @@ function closed(socket: Socket, onClosed: () => void): Operation { } else { socket.on("close", onClose); } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - // Removed synchronously when the wait is over, however it ends. - socket.off("close", onClose); - } - })(); + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + // Removed synchronously when the wait is over, however it ends. + socket.off("close", onClose); + } + })(), + abandon: () => socket.off("close", onClose), + }; +} + +/** + * A closure this code is already listening for. + * + * Two halves because asking a handle to close can fail: the listener has to be + * on before the request, and a request that threw leaves nothing to wait for. + * `abandon` takes the listener off without claiming the handle closed, so the + * handle stays retryable rather than being counted or waited on forever. + */ +interface CloseWatch { + readonly wait: Operation; + abandon(): void; } /** Settle once this server has stopped listening. */ -function shut(server: Server, onClosed: () => void): Operation { +function closedServer(server: Server, onClosed: () => void): CloseWatch { const done = withResolvers(); const onClose = (): void => { onClosed(); @@ -330,13 +383,16 @@ function shut(server: Server, onClosed: () => void): Operation { } else { server.on("close", onClose); } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - server.off("close", onClose); - } - })(); + return { + wait: (function* (): Operation { + try { + yield* done.operation; + } finally { + server.off("close", onClose); + } + })(), + abandon: () => server.off("close", onClose), + }; } /** A connection that has said nothing for long enough to be nobody. */ diff --git a/packages/cli/src/terminal/provider.ts b/packages/cli/src/terminal/provider.ts index d127c7dde..5c33258f0 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/cli/src/terminal/provider.ts @@ -97,6 +97,97 @@ export function tmuxGridProvider(deps: TmuxProviderDependencies): TerminalProvid * ├─ the tmux server and its panes (`kill-server`, proved) * └─ the admitted worker links */ +/** Everything one grid's teardown has to take down, in the order it does. */ +export interface GridParts { + /** Ask the reader's client to leave, and establish that it did. */ + detachReader(): Operation; + /** Every admitted worker link, in pane order. */ + readonly links: readonly PaneLink[]; + /** Close every private socket and server. */ + closeChannels(): Operation; + /** Stop the server, and establish it is gone. */ + stopServer(): Operation; +} + +/** + * The one teardown, in the one order, however a grid ends. + * + * Core calls it through `destroy()`; the composite's finalizer calls it when + * core never got that far, which is what a preparation that failed halfway + * leaves. A second caller waits on the first rather than skipping past + * unfinished work, and a teardown that *failed* is retried rather than + * remembered as done — marking it complete before it succeeded would let the + * run continue past a pane it never established was free. + * + * The order is the contract, and every step is a proof rather than a request: + * + * detach the reader's client and establish it stopped + * → ask every acquired worker to shut down + * → require its settlement, its holder-free goodbye, and its channel + * closing, in that order + * → close every private channel + * → stop the server and establish it is gone + * + * Every acquired resource is attempted even after an earlier one failed, so one + * bad worker does not strand the server, the channels or the paths. The first + * failure is what surfaces. + */ +export function createGridTeardown(parts: GridParts): () => Operation { + /** The one teardown in flight, so repeat callers observe it rather than skip it. */ + let tearing: ReturnType> | undefined; + let complete = false; + const steps: (() => Operation)[] = [ + () => parts.detachReader(), + ...parts.links.map((link) => () => quiesceWorker(link)), + // Channels before the server: a socket still open onto a pane of a server + // that has gone is a handle onto nothing. + () => parts.closeChannels(), + () => parts.stopServer(), + ]; + /** Phases already proved done, so a retry resumes rather than restarts. */ + const settled = new Set(); + + return function* tearDown(): Operation { + if (complete) { + return; + } + if (tearing) { + return yield* tearing.operation; + } + tearing = withResolvers(); + let failure: Error | undefined; + const failed = (error: unknown): void => { + failure = failure ?? (error instanceof Error ? error : new Error(String(error))); + }; + + for (const [index, step] of steps.entries()) { + if (settled.has(index)) { + // A phase that succeeded is not asked again. Re-asking would fail for + // the wrong reason — a worker that has already said goodbye and gone is + // "a worker that was gone" the second time — and that answer would + // replace the reason the first attempt actually could not finish. + continue; + } + try { + yield* step(); + settled.add(index); + } catch (error) { + failed(error); + } + } + + if (failure !== undefined) { + // Retryable: `tearing` is cleared, so a later caller runs the phases that + // did not finish rather than being told a teardown that failed had. + tearing.reject(failure); + tearing = undefined; + throw failure; + } + complete = true; + tearing.resolve(); + }; +} + function usePresentedGrid( deps: TmuxProviderDependencies, request: TerminalGridRequest, @@ -141,91 +232,24 @@ function usePresentedGrid( let shown = 0; let visible: VisibleClient | undefined; - /** The one teardown in flight, so repeat callers observe it rather than skip it. */ - let tearing: ReturnType> | undefined; - let complete = false; - /** - * The one teardown, in the one order, however this grid ends. - * - * Core calls it through `destroy()`; the finalizer calls it when core never - * got that far, which is what a preparation that failed halfway leaves. A - * second caller waits on the first rather than skipping past unfinished - * work, and a teardown that *failed* is retried rather than remembered as - * done — marking it complete before it succeeded would let the run continue - * past a pane it never established was free. - * - * The order is the contract, and every step is a proof rather than a - * request: - * - * detach the reader's client and establish it stopped - * → ask every acquired worker to shut down - * → require its settlement, its holder-free goodbye, and its channel - * closing, in that order - * → close every private channel - * → stop the server and establish it is gone - * - * Every acquired resource is attempted even after an earlier one failed, so - * one bad worker does not strand the server, the channels or the paths. The - * first failure is what surfaces. - */ - function* tearDown(): Operation { - if (complete) { - return; - } - if (tearing) { - return yield* tearing.operation; - } - tearing = withResolvers(); - let failure: Error | undefined; - const failed = (error: unknown): void => { - failure = failure ?? (error instanceof Error ? error : new Error(String(error))); - }; - - // The reader's client first, and asked rather than told: a client that - // detaches restores the terminal, and one that is killed cannot. - if (visible !== undefined) { + const tearDown = createGridTeardown({ + *detachReader(): Operation { + // The reader's client first, and asked rather than told: a client that + // detaches restores the terminal, and one that is killed cannot. + if (visible === undefined) { + return; + } const client = visible; visible = undefined; - try { - yield* grid.detach(client); - } catch (error) { - failed(error); - } - } - - for (const link of links) { - try { - yield* quiesceWorker(link); - } catch (error) { - failed(error); - } - } - - // Channels before the server: a socket still open onto a pane of a server - // that has gone is a handle onto nothing. - try { - yield* channels.close(); - } catch (error) { - failed(error); - } - - try { + yield* grid.detach(client); + }, + links, + closeChannels: () => channels.close(), + stopServer: function* (): Operation { yield* grid.stop(); - } catch (error) { - failed(error); - } - - if (failure !== undefined) { - // Retryable: `tearing` is cleared, so a later caller runs it again - // rather than being told a teardown that failed had finished. - tearing.reject(failure); - tearing = undefined; - throw failure; - } - complete = true; - tearing.resolve(); - } + }, + }); yield* ensure(function* () { yield* tearDown(); diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/cli/tests/terminal-grid-tmux.test.ts index 9a0954c79..63129df40 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/cli/tests/terminal-grid-tmux.test.ts @@ -61,7 +61,7 @@ import { } from "../src/terminal/layout.ts"; import type { LayoutCell } from "../src/terminal/layout.ts"; import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; +import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; import { foregroundTerminalGrid, underHangup, @@ -83,6 +83,8 @@ import { FromWorkerSchema, paneSocketPath, paneTokenPath, + readFrames, + ToWorkerSchema, writeFrame, } from "../src/terminal/pane-protocol.ts"; import { @@ -1664,6 +1666,604 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { * language and validation and install no operational provider, so a document * that asks for a grid there is refused before a pane starts. */ +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** A settlement that proved its pane free. */ +function quietSettlement(): Settlement { + return { method: "exited", quiet: true, swept: [], holders: [] }; +} + +/** How one scripted worker answers what the parent tells it. */ +type Reply = ( + frame: ToWorker, + say: (message: FromWorker) => Operation, + socket: Socket, +) => Operation; + +interface ScriptedWorker { + readonly socket: Socket; + /** Everything the parent told this worker, in order. */ + readonly heard: ToWorker["type"][]; +} + +/** + * One pane's worker, over that pane's real socket, saying what a row scripts. + * + * A real connection through the real admission handshake, because the order + * being frozen is the order frames actually arrive in. What is scripted is the + * worker's *answers* — which is where the protocol failures live, and the one + * thing a real worker will not do on request. + */ +function useScriptedWorker( + directory: string, + ordinal: number, + reply: Reply, +): Operation { + return resource(function* (provide) { + const socket = yield* useImpostor(directory, ordinal); + const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); + const heard: ToWorker["type"][] = []; + const frames = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + yield* writeFrame(socket, { + type: "hello", + ordinal, + token, + pid: process.pid, + pgid: process.pid, + tty: "??", + isatty: [false, false, false], + }); + yield* spawn(function* () { + let next = yield* frames.next(); + while (!next.done) { + heard.push(next.value.type); + yield* reply(next.value, (message) => writeFrame(socket, message), socket); + next = yield* frames.next(); + } + }); + yield* provide({ socket, heard }); + }); +} + +/** A worker that shuts down the way one that worked is supposed to. */ +function quiesces(hold?: Operation): Reply { + return function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + if (hold !== undefined) { + yield* hold; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + yield* say({ type: "bye", holders: [] }); + // A worker that has said goodbye is leaving, and its channel closing is the + // third thing the teardown requires. One that stayed would be a pane still + // holding a connection to a grid that is going away. + socket.destroy(); + }; +} + +/** The link the teardown drives, wrapped so the row sees what it observed. */ +function loggedLink(link: PaneLink, log: string[]): PaneLink { + return { + ordinal: link.ordinal, + hello: link.hello, + *send(message) { + log.push(`${message.type}:${link.ordinal}`); + yield* link.send(message); + }, + *next() { + const frame = yield* link.next(); + log.push(frame === undefined ? `eof:${link.ordinal}` : `${frame.type}:${link.ordinal}`); + return frame; + }, + connected: () => link.connected(), + }; +} + +interface Teardown { + readonly log: string[]; + readonly directory: string; + readonly run: () => Operation; + /** Every private socket and server this grid opened. */ + readonly handles: (Socket | Server)[]; +} + +/** + * A teardown over real private channels, with the reader's client and the + * server standing in for what tmux does with them. + * + * The channels are real, so the closures and the path removal in the frozen + * order are the production ones. The two ends this fixture supplies are the two + * whose failures a row has to be able to choose. + */ +function useTeardown(options: { + readonly workers: readonly (Reply | undefined)[]; + readonly detach?: () => Operation; + readonly stop?: () => Operation; +}): Operation { + return resource(function* (provide) { + const log: string[] = []; + const handles: (Socket | Server)[] = []; + // One server per pane, created in pane order. Which pane a closure belongs + // to is read from the server that accepted the connection, so the order + // this row freezes is per-pane rather than per-event. + let panes = 0; + const belongs = new Map(); + const detachments: (() => void)[] = []; + const noteSocket = (socket: Socket, what: () => string): void => { + handles.push(socket); + const onClose = (): void => { + log.push(what()); + }; + socket.on("close", onClose); + detachments.push(() => socket.off("close", onClose)); + }; + const noteServer = (server: Server, what: () => string): void => { + handles.push(server); + const onClose = (): void => { + log.push(what()); + }; + server.on("close", onClose); + detachments.push(() => server.off("close", onClose)); + }; + yield* ensure(() => { + // This row's own listeners, off the emitters this row put them on. + for (const detach of detachments) { + detach(); + } + }); + const channels = yield* usePaneChannels(options.workers.length, { + onSocket: (socket) => noteSocket(socket, () => `socket-closed:${belongs.get(socket) ?? -1}`), + onServer: (server) => { + const ordinal = panes++; + const onConnection = (socket: Socket): void => { + belongs.set(socket, ordinal); + }; + server.on("connection", onConnection); + detachments.push(() => server.off("connection", onConnection)); + noteServer(server, () => `server-closed:${ordinal}`); + }, + }); + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + yield* useScriptedWorker(channels.directory, ordinal, reply); + } + } + const links: PaneLink[] = []; + for (const [ordinal, reply] of options.workers.entries()) { + if (reply !== undefined) { + links.push(loggedLink(yield* channels.link(ordinal), log)); + } + } + const run = createGridTeardown({ + detachReader: + options.detach ?? + function* () { + log.push("detach"); + }, + links, + *closeChannels() { + yield* channels.close(); + }, + stopServer: + options.stop ?? + function* () { + log.push("server-stopped"); + }, + }); + yield* provide({ log, directory: channels.directory, run, handles }); + }); +} + +describe("Tier TD — the combined teardown", () => { + it("TD1: concurrent destroys share one teardown, and every phase happens once", function* () { + const held = withResolvers(); + const fixture = yield* useTeardown({ workers: [quiesces(held.operation), quiesces()] }); + + const first = yield* spawn(() => fixture.run()); + // Held inside the first worker's settlement, so the second destroy arrives + // while the first teardown is genuinely part-way through rather than + // racing it. + while (!fixture.log.includes("shutdown:0")) { + yield* sleep(5); + } + const second = yield* spawn(() => fixture.run()); + held.resolve(); + yield* first; + yield* second; + + const once = (entry: string): number => fixture.log.filter((line) => line === entry).length; + for (const entry of ["detach", "shutdown:0", "shutdown:1", "server-stopped"]) { + expect([entry, once(entry)]).toEqual([entry, 1]); + } + // The channels too: one closure each, not one per caller. + for (const ordinal of [0, 1]) { + expect([ordinal, once(`socket-closed:${ordinal}`)]).toEqual([ordinal, 1]); + expect([ordinal, once(`server-closed:${ordinal}`)]).toEqual([ordinal, 1]); + } + }); + + it("TD2: a worker that was gone before it was asked refuses the teardown", function* () { + const fixture = yield* useTeardown({ workers: [quiesces()] }); + fixture.handles.find((handle): handle is Socket => "destroy" in handle)?.destroy(); + while (!fixture.log.includes("socket-closed:0")) { + yield* sleep(5); + } + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("gone before it was asked to stop"); + }); + + it("TD3: a goodbye before a settlement refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("said goodbye before it was proved free"); + }); + + it("TD4: a settlement with no goodbye after it refuses", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say, socket) { + if (frame.type !== "shutdown") { + return; + } + yield* say({ type: "quiet", settlement: quietSettlement() }); + // EOF where the goodbye belongs: settled, and never established free. + socket.destroy(); + }, + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("stopped answering before it was proved free"); + expect(fixture.log).toContain("eof:0"); + }); + + it("TD5: one pane's failure strands neither the next pane, the channels, nor the server", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + quiesces(), + ], + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + // The first pane's failure is what surfaced, and everything acquired after + // it was still taken down. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(fixture.log).toContain("shutdown:1"); + expect(fixture.log).toContain("bye:1"); + for (const ordinal of [0, 1]) { + expect(fixture.log).toContain(`socket-closed:${ordinal}`); + expect(fixture.log).toContain(`server-closed:${ordinal}`); + } + expect(fixture.log).toContain("server-stopped"); + }); + + it("TD6: the first failure is the one that surfaces", function* () { + const fixture = yield* useTeardown({ + workers: [ + function* (frame, say) { + if (frame.type === "shutdown") { + yield* say({ type: "bye", holders: [] }); + } + }, + ], + // deno-lint-ignore require-yield + *stop() { + throw new Error("the server would not stop"); + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + // The pane, not the server: a later failure does not replace the reason the + // teardown could not establish this grid was gone. + expect(refusal).toContain("said goodbye before it was proved free"); + expect(refusal).not.toContain("would not stop"); + }); + + /** A worker that stays connected and says nothing. */ + // deno-lint-ignore require-yield + const silent: Reply = function* () {}; + + it("TD10: a retry resumes at the phase that failed and re-asks no finished one", function* () { + const stops: string[] = []; + let refuse = true; + const fixture = yield* useTeardown({ + workers: [quiesces()], + // deno-lint-ignore require-yield + *stop() { + stops.push("asked"); + if (refuse) { + refuse = false; + throw new Error("the server would not stop"); + } + }, + }); + + let refusal = ""; + try { + yield* fixture.run(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("would not stop"); + + // The retry finishes the grid, and the phases that were proved done are not + // asked again: a worker that has already said goodbye and gone would answer + // the second ask as "a worker that was gone", which would replace the + // reason the first attempt could not finish with an artifact of its + // succeeding. + yield* fixture.run(); + expect(stops.length).toBe(2); + expect(fixture.log.filter((line) => line === "shutdown:0").length).toBe(1); + expect(fixture.log.filter((line) => line === "bye:0").length).toBe(1); + }); + + it("TD8: a close request that fails is retried, and what closed stays closed", function* () { + const closed: string[] = []; + let panes = 0; + let refuse = true; + const channels = yield* usePaneChannels(2, { + onSocket(socket) { + socket.on("close", () => closed.push("socket")); + }, + onServer(server) { + const ordinal = panes++; + server.on("close", () => closed.push(`server:${ordinal}`)); + if (ordinal !== 0) { + return; + } + // One handle that refuses to be *asked*, once. A close request is as + // capable of failing as the wait after it, and the two have to be + // inside the same boundary or the failure escapes the retry. + const ask = server.close.bind(server); + server.close = (callback?: (error?: Error) => void) => { + if (refuse) { + refuse = false; + throw new Error("this handle refused to be closed"); + } + return ask(callback); + }; + }, + }); + yield* useScriptedWorker(channels.directory, 0, silent); + yield* useScriptedWorker(channels.directory, 1, silent); + yield* channels.link(0); + yield* channels.link(1); + + let refusal = ""; + try { + yield* channels.close(); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("refused to be closed"); + // The handles after the failure were still asked, and closed. + expect(closed.filter((name) => name === "socket").length).toBe(2); + expect(closed).toContain("server:1"); + expect(closed).not.toContain("server:0"); + + // The published settlement was cleared rather than remembered, so this call + // asks the handle that refused again — and nothing that already closed is + // closed a second time. + yield* channels.close(); + for (const [name, times] of [ + ["socket", 2], + ["server:0", 1], + ["server:1", 1], + ] as const) { + expect([name, closed.filter((entry) => entry === name).length]).toEqual([name, times]); + } + }); + + it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { + // The document-level end of the same claim: a grid whose teardown could not + // establish the terminal was given back is a failed run, not a run with a + // warning in it. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + // The server refuses to be killed the first time it is asked, so the last + // phase of the teardown cannot establish it is gone. + const tmux = createFakeTmux({ + script, + clientCommand, + spawnPanes: true, + failOnce: { command: "kill-server", message: "refused" }, + }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + expect(outcome?.ok).toBe(false); + const refusal = outcome?.ok === false ? String(outcome.error) : ""; + expect(refusal).toContain("terminal server"); + // Nothing private in it, and nothing after the grid ran. + expect(refusal).not.toContain(room); + expect(output).not.toContain("AFTER_THE_GRID"); + }); + + it("TD7: the combined order is the frozen one", function* () { + const order: string[] = []; + let directory = ""; + yield* scoped(function* () { + // Registered before the channels exist, so it runs after they are gone: + // the private paths are removed by the channels' own scope, last, once + // everything inside it has closed. + yield* ensure(function* () { + if (directory !== "" && !(yield* exists(directory))) { + order.push("paths-removed"); + } + }); + const fixture = yield* useTeardown({ workers: [quiesces(), quiesces()] }); + directory = fixture.directory; + yield* fixture.run(); + order.push(...fixture.log); + }); + + const at = (entry: string): number => order.indexOf(entry); + const last = (entry: string): number => order.lastIndexOf(entry); + // visible detach → worker settlements → holder-free goodbyes → worker + // channel closures → channel servers closed → server disappearance → + // private path removal. + expect(at("detach")).toBe(0); + for (const ordinal of [0, 1]) { + expect(at(`shutdown:${ordinal}`)).toBeGreaterThan(at("detach")); + expect(at(`quiet:${ordinal}`)).toBeGreaterThan(at(`shutdown:${ordinal}`)); + expect(at(`bye:${ordinal}`)).toBeGreaterThan(at(`quiet:${ordinal}`)); + } + // Each pane's four phases are that pane's, in order — panes are quiesced + // one at a time, so pane zero's channel closes while pane one has not been + // asked yet. What is global is the boundary after them: no server closes + // until every worker channel has. + for (const ordinal of [0, 1]) { + expect(at(`socket-closed:${ordinal}`)).toBeGreaterThan(at(`bye:${ordinal}`)); + expect(at("server-closed:0")).toBeGreaterThan(at(`socket-closed:${ordinal}`)); + } + expect(at("server-closed:1")).toBeGreaterThan(at("server-closed:0") - 1); + expect(at("server-stopped")).toBeGreaterThan( + Math.max(at("server-closed:0"), at("server-closed:1")), + ); + expect(at("paths-removed")).toBe(order.length - 1); + }); +}); + +/** One entrypoint's source, for the rows about what a host assembles. */ +function entrypointSource(name: string): Operation { + return readTextFile(path.resolve("packages/cli/src", name)); +} + describe("Tier TH — host installation", () => { it("TH1: without a terminal, a grid refuses before anything exists", function* () { const before = yield* until(readdir(tmpdir())); @@ -1705,39 +2305,6 @@ describe("Tier TH — host installation", () => { expect(refusal).toContain("older than tmux"); }); - /** Settle once this child has gone, whether or not it already had. */ - function exited(child: ChildProcess): Operation { - const done = withResolvers(); - const onExit = (): void => done.resolve(); - if (child.exitCode !== null || child.signalCode !== null) { - done.resolve(); - } else { - child.on("exit", onExit); - } - return (function* (): Operation { - try { - yield* done.operation; - } finally { - child.off("exit", onExit); - } - })(); - } - - /** A shell that says when it started, and stays until it is signalled. */ - function useShellFixture(room: string): Operation { - return resource(function* (provide) { - const file = path.join(room, "shell"); - yield* writeTextFile( - file, - ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( - "\n", - ), - ); - yield* until(chmod(file, 0o755)); - yield* provide(file); - }); - } - it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { const room = yield* useScratch(); const shell = yield* useShellFixture(room); @@ -1839,6 +2406,118 @@ describe("Tier TH — host installation", () => { expect(yield* exists(directory)).toBe(false); }); + it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { + // The same host, the same document and the same live grid as TH4. What + // differs is the ending: the reader leaves rather than the terminal going + // away, so the grid settles and the document carries on — which is the + // branch `useHangupCancellation()` has to hand the result back through. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let directory = ""; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + // Driven by the grid's own progress: the pane child started, and the + // server has a reader's client to report the detach of. No SIGHUP. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The exact result, handed back through the hangup wrapper rather than + // swallowed by it: a handler that answered with nothing would be refused + // for having returned before the document produced a result. + expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); + // The reader closed the grid; the document went on. + expect(output).toContain("AFTER_THE_GRID"); + + // And it went on over a grid that had actually been taken down: the pane's + // child, the workers, the server and the private directory are all gone. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { + for (const name of ["deno.ts", "compiled.ts"]) { + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); + } + for (const name of ["node.ts", "bun.ts"]) { + // Not a different grid: no grid at all, and therefore the default the + // shared entry declares — which is the installation that validates a grid + // and presents none. + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); + } + expect(yield* entrypointSource("cli.ts")).toContain( + "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", + ); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. From d725f5415e6065222724b4d6978bd185027efc6b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 17:13:36 -0400 Subject: [PATCH 37/62] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20tmux=20grid?= =?UTF-8?q?=20suite=20off=20the=20runtimes=20that=20register=20no=20worker?= =?UTF-8?q?=20(#732)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite hung forever under Node and Bun. Not failed — hung, which leaves a runtime shard running until the job's own timeout with nothing to read. A pane's worker is this executable re-invoked under the hidden `terminal-worker` subcommand, and only the hosts that present grids register it: the Deno entrypoint and the compiled binary. On Node and Bun the same argument vector names a *document* called `terminal-worker`, so the worker exits with ENOENT before it connects and the parent waits for a pane that will never say hello. It stalls entering TW3, the first row that spawns a real worker. $ tsx packages/cli/src/node.ts terminal-worker 0 ENOENT: no such file or directory, open 'terminal-worker' That Node and Bun install no grid provider is the design, so the fix is the exclusion this repository already has a mechanism for rather than a portable worker. Every other test file in this stack runs under Node unchanged; this is the only one that cannot. What the exclusion does and does not preserve, stated precisely because the rationale is the reason a later reader would trust it: provider absence is covered portably by TG9 in packages/core/tests/terminal-grid.test.ts, which runs on all three runtimes. TH6's entrypoint-selection freeze is textual, so proving it once under Deno proves it everywhere. TH3 makes the same claim as TG9 but is excluded with the rest of the file and proves nothing here. What is genuinely Deno-only is the worker, socket and fake-tmux integration. --- scripts/runtime-test-exclusions.ts | 34 +++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index c254f2067..89105d529 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -624,6 +624,32 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ }, ]; +/** + * Tests whose subject is the tmux terminal-grid provider. + * + * A pane's worker is this executable re-invoked under a hidden + * `terminal-worker` subcommand, and only the hosts that present grids register + * it — the Deno entrypoint and the compiled binary. Node and Bun install no + * grid provider by design, so on those runtimes the same argument vector names + * a *document* called `terminal-worker`, the worker exits with ENOENT before it + * connects, and the parent waits for a pane that will never say hello. The + * suite hangs rather than failing, which would leave a runtime shard running + * forever. + * + * That a runtime without a provider refuses a grid instead of half-presenting + * one is covered portably by TG9 in `packages/core/tests/terminal-grid.test.ts`, + * which runs everywhere. The excluded file's own TH3 makes the same claim, but + * it is excluded along with the rest of it and proves nothing here. + */ +const DENO_ONLY_TERMINAL_GRID: RuntimeExclusion[] = [ + { + path: "packages/cli/tests/terminal-grid-tmux.test.ts", + reason: + "the subject is the tmux provider, whose panes are this executable re-invoked as `terminal-worker` — a subcommand only the grid-presenting entrypoints register; under Node and Bun that vector names a document instead, so the worker exits with ENOENT and the pane's admission never completes", + issue: DERIVED_SCOPE, + }, +]; + const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ { path: "packages/workflow/tests/xmd-artifact.test.ts", @@ -635,10 +661,16 @@ const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ export const exclusions: Record = { deno: COMPILED_BINARY, - node: [...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, ...COMPILED_BINARY], + node: [ + ...DENO_ONLY_TOOLING, + ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, + ...COMPILED_BINARY, + ], bun: [ ...DENO_ONLY_TOOLING, ...DENO_ONLY_REPOSITORY_PROVIDER, + ...DENO_ONLY_TERMINAL_GRID, ...COMPILED_BINARY, ...BUN_MISSING_NODE_SQLITE, ], From 38cdf40bfcdb3a8769831e0cb7b76783cf8393f3 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 21:12:26 -0400 Subject: [PATCH 38/62] =?UTF-8?q?=F0=9F=93=9D=20Define=20terminal=20packag?= =?UTF-8?q?e=20boundary=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 160 +++++++++++++++++++++- specs/decisions.md | 70 ++++++++++ specs/executable-mdx-spec.md | 48 +++++++ specs/native-agent-session-launch-spec.md | 29 ++++ specs/release-process-spec.md | 36 ++++- 5 files changed, 337 insertions(+), 6 deletions(-) diff --git a/architecture.md b/architecture.md index 6b4c0a476..43c58eb12 100644 --- a/architecture.md +++ b/architecture.md @@ -2931,6 +2931,164 @@ outside that pane. Each pane also owns its checked-failure ledger. A checked failure settles that pane without poisoning the root or a sibling; core alone observes the pane outcome and applies the grid's settlement rule after close. +### Package ownership + +The terminal domain is independent of both the document engine that invokes it +and the presentation provider that implements it. Two publishable workspace +packages make those boundaries explicit: + +- `@executablemd/terminal` owns native foreground-launch routing and + reservation; provider-neutral grid and pane requests, composites, states, + errors, and row-major layout; `TerminalGrids` and `TerminalProviders` routing; + provider registration and direct authority delivery; grid and pane claims, + readiness, stale-authority refusal, lifecycle, reader-close settlement, + retained outcomes, and replay; pane-scoped launch routing; the + `TerminalProcesses` observation contract and quiescence operations; and the + controlled launcher, composite, and log fixtures used to prove the contract. +- `@executablemd/terminal-tmux` owns tmux capability probing and commands, the + hidden server and control clients, explicit layout and pane swaps, visible + attach, authenticated Unix-socket channels and their protocol, the persistent + pane worker and its child, worker invocation, and the provider's one ordered + teardown. No tmux command, type, identifier, protocol value, or host probe is + part of the neutral package. + +`@executablemd/terminal` exports its ordinary domain surface from the package +root. Its `./lifecycle` entrypoint exports authority creation, provider +installation, claims, readiness, grid execution, retained outcomes, and the +reader-close boundary. Its `./processes` entrypoint exports +`TerminalProcesses`, process facts and signals, snapshots, and quiescence. Its +`./posix` entrypoint exports the POSIX process and terminal probes and the +foreground-child adapter. Its `./test` entrypoint exports only controlled +launchers, composites, logs, and signals. These entrypoints are facets of one +package, not independent definitions: anything exported from more than one is +the same object. + +`@executablemd/terminal-tmux` exports `TMUX_PROVIDER`, +`TmuxProviderDependencies`, `tmuxGridProvider`, `installTmuxGridProvider`, the +unchanged `PANE_WORKER_COMMAND`, the hidden pane-worker invocation parser, the +pane-worker process runner, and the provider's documented refusal errors from +its root. Protocol frames, channel +handles, tmux process wrappers, layout mechanics, and teardown hooks stay +private. Controlled low-level seams needed by the adapter's own tests are +available only from its `./test` entrypoint and are not a second provider API. + +The dependency graph points toward the neutral domain: + +```text +@executablemd/terminal-tmux ──> @executablemd/terminal +@executablemd/core ──> @executablemd/terminal +@executablemd/runtime ──> @executablemd/terminal (compatibility only) +@executablemd/cli ──> core + runtime + terminal + terminal-tmux +``` + +The terminal package may depend on durable streams, Effection, and EffectionX; +it never imports runtime, core, CLI, or terminal-tmux. The tmux package never +imports runtime, core, or CLI. Moving the native-launch descriptor into the +neutral package is load-bearing: leaving it in runtime would either reverse the +domain dependency or make terminal depend on runtime. Core remains the owner of +Markdown parse and expansion, `SourcePosition` journal descriptions, +execution-profile installation, and Agent session behavior. Its +`src/terminal/journal.ts` and `src/terminal/profile.ts` therefore stay in core; +the neutral authority, provider API, layout, grid lifecycle, pane claim, and +pane-launcher modules move. Runtime's launcher, terminal composite, process +observation, and POSIX observer modules move. CLI's attach client, tmux layout, +pane channel, child, protocol and worker, provider, grid, and tmux command +modules move to terminal-tmux; CLI retains only entrypoint and execution +composition. + +The extraction applies to the current modules as follows: + +| Current module | Destination and responsibility | +|---|---| +| `packages/runtime/launcher.ts` | Split between terminal's neutral root, POSIX foreground-child adapter, and controlled test entrypoint | +| `packages/runtime/terminal.ts` | Split between terminal's neutral root and controlled test entrypoint | +| `packages/runtime/terminal-processes.ts` | `@executablemd/terminal/processes` | +| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix`; its old name remains a compatibility alias only | +| `packages/core/src/terminal/authority.ts` | `@executablemd/terminal/lifecycle` | +| `packages/core/src/terminal/provider-api.ts` | Terminal root and lifecycle entrypoints | +| `packages/core/src/terminal/grid.ts` | `@executablemd/terminal/lifecycle` | +| `packages/core/src/terminal/pane-launcher.ts` and `pane.ts` | Terminal's neutral pane and launcher surface | +| `packages/core/src/terminal-grid.ts` | Split so neutral layout and grid lifecycle move to terminal while authored element scanning, expansion and source integration stay in core | +| `packages/core/src/terminal/journal.ts` and `profile.ts` | Stay in core; they adapt terminal lifecycle to core journal descriptions and `Execution` | +| `packages/cli/src/terminal/{attach-client,layout,pane-channel,pane-child,pane-protocol,pane-worker,provider,tmux-grid,tmux}.ts` | Move to `@executablemd/terminal-tmux` | +| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in CLI | + +Tests follow the code whose contract they prove: neutral routing, authority, +layout, lifecycle, replay and process-quiescence suites live under terminal; +tmux topology, protocol, worker, host-process and teardown suites live under +terminal-tmux; syntax, source integration and durable journal descriptions stay +under core; cross-package Agent composition stays with test-agent; entrypoint +selection and compiled-host evidence stay under CLI. + +The previous `@executablemd/runtime` and `@executablemd/core` public terminal +imports remain compatibility entrypoints. They re-export the canonical symbols +directly: `NativeLauncher`, `TerminalGrids`, `TerminalProviders`, +`TerminalProcesses`, their operations, constants, types, and error constructors +are not recreated, wrapped, or subclassed. Thus descriptor equality, +middleware composition, stable contextual API names, error identity, and +`instanceof` behavior are unchanged across old and new import paths. Removing a +compatibility export is a separate breaking release, not part of this package +extraction. + +POSIX process-table, process-group, signal, reachability, and terminal-holder +observation lives behind `@executablemd/terminal/posix`, not in the tmux +adapter. A different POSIX presentation provider can reuse the same proof +without depending on tmux. The Deno and compiled CLI entrypoints remain the +host-composition boundary: they choose tmux, resolve self-reinvocation, terminal +size and environment, translate host `SIGHUP` into structured cancellation, and +install the POSIX observer both in the supervising run and inside each pane +worker because contextual state does not cross a process boundary. Node and Bun +continue to install neither observer nor grid provider. + +This extraction changes ownership, not behavior. It preserves the authored +syntax, provider name `tmux`, hidden worker verb `terminal-worker`, worker +protocol and authentication, durable records and identities, diagnostic text +and normalization, readiness, close and replay semantics, and every provider +identity. Event registrations remain owned by the Effection scope whose +resource they observe and are removed when that scope settles. Both packages +are ordinary lockstep-versioned workspace members. The +generated publication graph places terminal after durable-streams, +terminal-tmux after terminal, runtime after terminal for compatibility, core +after terminal and its other dependencies, and CLI after all four. Workspace, +JSR, npm, compiled-host, and runtime-test discovery treat them like every other +publishable package. + +The final extraction story is complete when this finite evidence passes: + +1. A static dependency test walks production imports and proves the four arrows + above, including the absence of terminal-to-runtime/core/CLI/tmux and + terminal-tmux-to-runtime/core/CLI edges. +2. A compatibility test imports the public terminal descriptors and error + constructors through their canonical, runtime, and core paths and proves + object identity; one middleware composition crosses those paths. +3. Relocated neutral tests prove foreground launching, provider routing and + direct authority, claims and readiness, layout, close/cancellation/replay, + process observation, and quiescence without tmux. +4. Core tests prove the unchanged grammar, structural validation, source + diagnostics, pane scope, durable identities and records, retained outcomes, + and provider-neutral replay. +5. Terminal-tmux tests prove exact authenticated worker transport, concurrent + panes, sequential reuse, spawn readiness, display isolation, job control, + explicit row-major layout, atomic attach, the three close signals, SIGHUP, + scope-owned event registration, cancellation phases, and ordered bounded + teardown with real workers and sockets under the existing fake-tmux host. +6. The cross-package test Agent proves a pane-native launch reaches its physical + endpoint while root launch and natural-key Agent session ownership remain + unchanged. +7. CLI evidence proves Deno and compiled hosts select tmux and dispatch the + hidden worker with POSIX observation in both processes; Node, Bun, non-TTY, + and missing-tmux paths install no partial provider and retain their exact + refusals. +8. Workspace and release evidence proves discovery of both packages, valid + runtime exclusions, freshly measured corpus weights, generated dependency + order, JSR publishability, a local-sibling npm CLI build, the compiled binary + and hidden worker, and dependency-state cleanliness. + +Tests use controlled signals and observable settlement for lifecycle success; +elapsed time is not evidence. The focused feedback commit runs the smallest +explicit tests that discriminate these boundaries. Runtime-wide matrices, +lint, typecheck, JSR and clean composability remain delivery gates. + ### Terminal authority One grid holds the execution's foreground-terminal lease for its whole visible @@ -4489,7 +4647,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, retaining object-identical runtime and core compatibility exports | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/specs/decisions.md b/specs/decisions.md index e36c01658..d38f8f7b1 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -736,3 +736,73 @@ journal- and root-publication-stability snapshots in `packages/cli/tests/workflow-suspension.test.ts`, where an `API.Files` call count is explicitly not once-only evidence — document re-expansion legitimately enters that boundary before the durable effect underneath restores. + +## DEC-016: Terminal domain and tmux adapter are separate workspace packages + +**Status:** Decided + +**Date:** 2026-09-03 + +### Context + +The terminal-grid delivery proved one provider-neutral lifecycle and one tmux +implementation, but their modules remained distributed across runtime, core, +and CLI. That placement makes a second presentation provider depend on CLI +internals and makes the neutral terminal authority appear to be core-specific. +Keeping the lifecycle in core would preserve that coupling. Putting the neutral +domain and tmux in one package would remove the CLI dependency but make every +provider consumer acquire tmux-specific code and host assumptions. + +Existing consumers also import terminal symbols from `@executablemd/runtime` +and `@executablemd/core`. The contextual API descriptors and error constructors +among those exports are identity-bearing; reproducing an equivalent descriptor, +wrapper, or class would split middleware composition and `instanceof` behavior. + +### Decision + +Terminal ownership is divided between two publishable workspace packages: + +- `@executablemd/terminal` owns the provider-neutral terminal domain: native + launch routing, terminal requests and composites, provider registration and + direct authority delivery, claims and readiness, row-major layout, the live + and durable grid lifecycle, pane routing, retained outcomes, process + observation contracts, quiescence, and controlled test surfaces. +- `@executablemd/terminal-tmux` implements that domain with tmux: capability + probing, private server and client control, explicit pane placement, + authenticated worker channels and protocol, worker child creation, display, + close-signal distinction, and ordered teardown. + +Core continues to own the authored `Terminal.Grid` and `Terminal` syntax, +source-position journal descriptions, execution-profile composition, Agent +sessions, and expansion integration. Runtime continues to own unrelated host +APIs. CLI chooses and wires the provider for each entrypoint; it does not own a +terminal provider implementation. + +The canonical descriptors, functions, types, constants, and errors move to the +new packages. The former runtime and core entrypoints re-export those exact +objects from their canonical definitions. They contain no duplicate descriptor, +wrapper, subclass, or compatibility implementation. Existing imports therefore +remain valid and object-identical in this extraction. + +The neutral package has no dependency on runtime, core, CLI, or the tmux +package. Core depends on terminal. The tmux package depends on terminal and +does not depend on runtime, core, or CLI. CLI depends on both packages and on +core and runtime. Runtime depends on terminal only for its compatibility +re-exports. Host-specific POSIX observation is an explicit terminal adapter; +Deno and compiled entrypoints install it in the supervising host and the pane +worker, while Node and Bun continue to install neither observer nor provider. + +### Consequences + +Any terminal provider implements the public neutral contract without +importing CLI or tmux. Consumers can migrate to the canonical package names at +their own pace; removing the old runtime or core exports is a separate breaking +decision. The extraction changes no authored syntax, provider name, hidden +worker invocation, durable record, private tmux protocol, diagnostic text, +terminal behavior, or provider identity. + +Both packages participate in workspace version lockstep, npm and JSR +publication, generated dependency ordering, package discovery, runtime test +discovery, and release verification. Moving tests changes the measured corpus, +so its weights are remeasured by the repository workflow rather than edited by +hand. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 9616eb62b..af3e99bf2 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9280,6 +9280,53 @@ Node and Bun accept and validate the same syntax but install no provider and therefore refuse before pane start. A controlled provider that is not tmux exercises the same core contract in tests. +#### Package and host boundary + +`@executablemd/terminal` is the canonical provider-neutral package for this +contract. Its root exports native launch requests, outcomes and routing; +terminal grid and pane requests, composites and states; `TerminalGrids` and +`TerminalProviders`; provider registration; public errors; and the neutral pane +surface. `@executablemd/terminal/lifecycle` exports the direct authority, +installation, claim, readiness, row-major layout, grid lifecycle, retained +outcome, reader-close and replay operations. `@executablemd/terminal/processes` +exports `TerminalProcesses`, process facts, signals, snapshots and quiescence. +`@executablemd/terminal/posix` exports POSIX process and terminal probes and the +foreground-child adapter. `@executablemd/terminal/test` exports the controlled +launcher, composite, log and signal surfaces; production code imports none of +them. + +`@executablemd/terminal-tmux` is the first provider. Its root exports only the +provider name, dependency contract, provider factory and installer, unchanged +`PANE_WORKER_COMMAND`, hidden worker invocation parser and runner, and +documented refusal errors. Its tmux +process wrapper, layout mechanics, private protocol, channel handles and +teardown controls remain internal; its tests reach controlled low-level seams +through `@executablemd/terminal-tmux/test`. + +The neutral package imports neither runtime, core, CLI nor terminal-tmux. Core +imports terminal for the lifecycle it invokes and retains only authored +parsing, expansion, source-position journal descriptions, profile composition, +and Agent behavior. Terminal-tmux imports terminal and imports neither runtime, +core nor CLI. CLI imports the domain and provider to compose the Deno and +compiled hosts. Runtime imports terminal only to keep its previous public +terminal exports working. + +Those previous `@executablemd/runtime` and `@executablemd/core` exports are +direct compatibility re-exports. The old and canonical imports of +`NativeLauncher`, `TerminalGrids`, `TerminalProviders`, `TerminalProcesses`, +their constants, operations and error constructors are the same objects, not +equivalent replacements. Stable contextual API names, middleware composition, +error identity and `instanceof` behavior therefore do not depend on import +path. + +The Deno and compiled CLI entrypoints select tmux, supply self-reinvocation, +environment and terminal dimensions, translate `SIGHUP`, and install POSIX +observation in the supervising host. The hidden pane-worker entrypoint installs +the same observation inside its own process; contextual installation in the +parent cannot cross that boundary. Node and Bun install neither the process +observer nor a grid provider. The extraction changes no syntax, provider name, +worker invocation, protocol, durable value, diagnostic, or lifecycle outcome. + ## 7. Entry point @@ -11294,6 +11341,7 @@ test derives a core result from a provider identifier. | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | | TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | +| TG21 | Package boundary and compatibility | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; and CLI alone composes the document engine with the provider and host. Imports through terminal, runtime and core return the identical `NativeLauncher`, `TerminalGrids`, `TerminalProviders`, `TerminalProcesses` and public error constructors. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 2de01c5e1..107c20462 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -1192,6 +1192,29 @@ remain role and continuity identities. V1 defines no stateful-Agent model selection. A document can explicitly name an Agent where required, but no provider-specific executable or resume syntax appears in `AGENTS.md`. +### Terminal package boundary + +`NativeLauncher`, `NativeLaunchRequest`, `NativeLaunchOutcome`, terminal +reservation and output flushing are canonically exported by +`@executablemd/terminal`. The same package owns the pane claim and the +provider-neutral composite endpoint that receives a native launch. The Agent +request, construction route, session coordinator and `Session.Launch` +component stay in their existing Agent and core modules; neither acquires a +terminal-provider identity. + +`@executablemd/terminal-tmux` consumes that endpoint and supplies the physical +pane worker. It does not import core, runtime or CLI. The Deno and compiled CLI +hosts compose the two domains and provide self-reinvocation and POSIX process +observation; Node and Bun continue to compose neither a foreground grid +provider nor an observer. + +The former `@executablemd/runtime` native-launch exports and +`@executablemd/core` pane and terminal-provider exports directly re-export the +canonical definitions. Old and new imports of every contextual descriptor and +public error constructor are object-identical. This extraction changes no +launch request, phase, route, ownership key, durable record, result, diagnostic, +provider advertisement, or root-versus-pane behavior. + ## Testing The test-agent stack supplies deterministic provider state. A controlled native @@ -1477,6 +1500,12 @@ Implementation review checks these frozen invariants: pane's authenticated worker, the root foreground launcher is not entered, distinct panes launch concurrently, cancellation awaits worker settlement and pane quiescence, and root launch routing remains unchanged. +30. Canonical terminal, legacy runtime and legacy core imports expose the same + native-launch and terminal-provider descriptors and error constructors by + identity; the terminal package imports no Agent, core, runtime, CLI or tmux + module, the tmux package imports only the neutral terminal domain, and the + complete launch evidence above passes without changing any request, route, + record, provider advertisement or diagnostic. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 48e8a58ec..561d649d1 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -49,10 +49,11 @@ sequenceDiagram ## 2. Version lockstep Every publishable package (`packages/core`, `packages/cli`, -`packages/durable-streams`, `packages/runtime`, `packages/testing`, -`packages/code-review-agent`, `packages/test-agent`, `packages/acp`, -`packages/web`, `packages/workflow`) declares the same version in its `deno.json` and -`package.json`. A member marked `"private": true` is outside the lockstep +`packages/durable-streams`, `packages/runtime`, `packages/terminal`, +`packages/terminal-tmux`, `packages/testing`, `packages/code-review-agent`, +`packages/test-agent`, `packages/acp`, `packages/web`, `packages/workflow`) +declares the same version in its `deno.json` and `package.json`. A member marked +`"private": true` is outside the lockstep because it never publishes — `packages/test-support` is the one, and it stays at `0.0.0`. `packages/cli/src/cli.ts` imports `packages/cli/deno.json` and reads `version` @@ -77,6 +78,31 @@ the checked-out revision with `deno task setup` and `deno task build`, then run install the latest published release, so a review always understands the documents at the revision it checks. +### Terminal package order + +The terminal packages follow the same manifest-derived publication graph as +every other workspace member. `@executablemd/terminal` depends on +`@executablemd/durable-streams` and the external Effection packages, not on +runtime, core, CLI, or terminal-tmux. `@executablemd/terminal-tmux` depends on +terminal. Runtime depends on terminal for its compatibility re-exports. Core +depends on terminal as well as its existing runtime and durable-stream +dependencies. CLI depends on terminal-tmux, terminal, core, and runtime. + +The generated npm jobs consequently publish durable-streams before terminal; +terminal before terminal-tmux, runtime, and core; and all of terminal-tmux, +terminal, runtime, and core before CLI. Independent leaves remain parallel. The +workspace package names and versions are also recorded in `bun.lock`. Adding +the two manifests or changing these sibling dependencies requires +`deno install --frozen=false`, the repository's normal setup, and +`deno task gen:publish-workflow`; `publish-packages.yml` remains generated and +is never edited by hand. + +Moving terminal tests between workspace members changes test-corpus paths. The +runtime exclusions continue to name every deliberately excluded file, and +`test-weights.json` is remeasured by the Measure test weights workflow on the +exact implementation head. No timing value is copied, renamed, or edited by +hand. + ## 3. Workflows - **`draft-release.yml`** (`push: main`): maintains the rolling draft release @@ -186,7 +212,7 @@ already carries at that version, member by member. A rerun after a partial publi therefore completes exactly the members that are missing, and a rerun after a complete publish exits 0 without republishing. Never gate the job on one package's existence — whether `core` is published says nothing about the other -six. +packages. `deno task check:jsr` runs the same command with `--dry-run` and is a required CI job on every PR (§3, `ci.yml`). It enforces JSR's fast-check rules, so every From 5aad719ed05af5c7622ed1c4bcddcf1029eb3579 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 22:13:06 -0400 Subject: [PATCH 39/62] =?UTF-8?q?=F0=9F=93=9D=20Remove=20unshipped=20termi?= =?UTF-8?q?nal=20compatibility=20paths=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 34 +++++++++++------------ specs/decisions.md | 32 ++++++++++----------- specs/executable-mdx-spec.md | 19 ++++++------- specs/native-agent-session-launch-spec.md | 21 ++++++++------ specs/release-process-spec.md | 10 +++---- 5 files changed, 58 insertions(+), 58 deletions(-) diff --git a/architecture.md b/architecture.md index 43c58eb12..90e980cac 100644 --- a/architecture.md +++ b/architecture.md @@ -2977,7 +2977,6 @@ The dependency graph points toward the neutral domain: ```text @executablemd/terminal-tmux ──> @executablemd/terminal @executablemd/core ──> @executablemd/terminal -@executablemd/runtime ──> @executablemd/terminal (compatibility only) @executablemd/cli ──> core + runtime + terminal + terminal-tmux ``` @@ -3003,7 +3002,7 @@ The extraction applies to the current modules as follows: | `packages/runtime/launcher.ts` | Split between terminal's neutral root, POSIX foreground-child adapter, and controlled test entrypoint | | `packages/runtime/terminal.ts` | Split between terminal's neutral root and controlled test entrypoint | | `packages/runtime/terminal-processes.ts` | `@executablemd/terminal/processes` | -| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix`; its old name remains a compatibility alias only | +| `packages/runtime/deno-terminal-processes.ts` | `@executablemd/terminal/posix`; delete the old module after moving it | | `packages/core/src/terminal/authority.ts` | `@executablemd/terminal/lifecycle` | | `packages/core/src/terminal/provider-api.ts` | Terminal root and lifecycle entrypoints | | `packages/core/src/terminal/grid.ts` | `@executablemd/terminal/lifecycle` | @@ -3011,7 +3010,7 @@ The extraction applies to the current modules as follows: | `packages/core/src/terminal-grid.ts` | Split so neutral layout and grid lifecycle move to terminal while authored element scanning, expansion and source integration stay in core | | `packages/core/src/terminal/journal.ts` and `profile.ts` | Stay in core; they adapt terminal lifecycle to core journal descriptions and `Execution` | | `packages/cli/src/terminal/{attach-client,layout,pane-channel,pane-child,pane-protocol,pane-worker,provider,tmux-grid,tmux}.ts` | Move to `@executablemd/terminal-tmux` | -| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in CLI | +| `packages/cli/src/terminal/host.ts` | Split: reusable provider and POSIX pieces move to their packages; the core `Execution` wrapper and entrypoint composition stay in a genuinely non-terminal CLI module, and the old terminal path is deleted | Tests follow the code whose contract they prove: neutral routing, authority, layout, lifecycle, replay and process-quiescence suites live under terminal; @@ -3020,15 +3019,13 @@ terminal-tmux; syntax, source integration and durable journal descriptions stay under core; cross-package Agent composition stays with test-agent; entrypoint selection and compiled-host evidence stay under CLI. -The previous `@executablemd/runtime` and `@executablemd/core` public terminal -imports remain compatibility entrypoints. They re-export the canonical symbols -directly: `NativeLauncher`, `TerminalGrids`, `TerminalProviders`, -`TerminalProcesses`, their operations, constants, types, and error constructors -are not recreated, wrapped, or subclassed. Thus descriptor equality, -middleware composition, stable contextual API names, error identity, and -`instanceof` behavior are unchanged across old and new import paths. Removing a -compatibility export is a separate breaking release, not part of this package -extraction. +The former `@executablemd/runtime` and `@executablemd/core` terminal exports and +old `packages/cli/src/terminal` implementation paths are deleted. This stack is +unmerged, so none is a compatibility surface. Every repository consumer imports +the canonical terminal or terminal-tmux package entrypoint, and no forwarding +barrel or alias preserves an old path. Each contextual API and error constructor +therefore has one canonical definition and import path; stable contextual API +names and `instanceof` behavior remain unchanged within that surface. POSIX process-table, process-group, signal, reachability, and terminal-holder observation lives behind `@executablemd/terminal/posix`, not in the tmux @@ -3048,8 +3045,8 @@ identity. Event registrations remain owned by the Effection scope whose resource they observe and are removed when that scope settles. Both packages are ordinary lockstep-versioned workspace members. The generated publication graph places terminal after durable-streams, -terminal-tmux after terminal, runtime after terminal for compatibility, core -after terminal and its other dependencies, and CLI after all four. Workspace, +terminal-tmux and core after terminal, and CLI after terminal-tmux, terminal, +core, and runtime. Runtime remains independent of terminal. Workspace, JSR, npm, compiled-host, and runtime-test discovery treat them like every other publishable package. @@ -3058,9 +3055,10 @@ The final extraction story is complete when this finite evidence passes: 1. A static dependency test walks production imports and proves the four arrows above, including the absence of terminal-to-runtime/core/CLI/tmux and terminal-tmux-to-runtime/core/CLI edges. -2. A compatibility test imports the public terminal descriptors and error - constructors through their canonical, runtime, and core paths and proves - object identity; one middleware composition crosses those paths. +2. A package-boundary test proves the old runtime, core, and CLI terminal paths + and exports are absent, every repository terminal import uses a canonical + package surface, and each public contextual descriptor and error constructor + has one definition. 3. Relocated neutral tests prove foreground launching, provider routing and direct authority, claims and readiness, layout, close/cancellation/replay, process observation, and quiescence without tmux. @@ -4647,7 +4645,7 @@ Status is measured against main. | testing harness (``) | runs another document as a real root under a production host profile, authorized by canonical `` alone: declarations installed before the root import, child output displayed progressively and collected only when asked, journal retention selected independently of observation, and the outcome published by the invocation's own terminal through a request public middleware composes around but cannot answer | built on the #454 stack for `host="run"`; the workflow profile and `` are unbuilt, and a host that offers no workflow profile refuses them | | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | -| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, retaining object-identical runtime and core compatibility exports | +| terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | | native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | diff --git a/specs/decisions.md b/specs/decisions.md index d38f8f7b1..4a4a64e2d 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -753,10 +753,10 @@ Keeping the lifecycle in core would preserve that coupling. Putting the neutral domain and tmux in one package would remove the CLI dependency but make every provider consumer acquire tmux-specific code and host assumptions. -Existing consumers also import terminal symbols from `@executablemd/runtime` -and `@executablemd/core`. The contextual API descriptors and error constructors -among those exports are identity-bearing; reproducing an equivalent descriptor, -wrapper, or class would split middleware composition and `instanceof` behavior. +The terminal stack has not merged, so its temporary exports from runtime, core, +and CLI are not compatibility surfaces. Preserving them would leave the +ownership ambiguity this extraction removes and would add runtime as a +dependency only to keep an unreleased path alive. ### Decision @@ -779,27 +779,27 @@ APIs. CLI chooses and wires the provider for each entrypoint; it does not own a terminal provider implementation. The canonical descriptors, functions, types, constants, and errors move to the -new packages. The former runtime and core entrypoints re-export those exact -objects from their canonical definitions. They contain no duplicate descriptor, -wrapper, subclass, or compatibility implementation. Existing imports therefore -remain valid and object-identical in this extraction. +new packages. Their former runtime and core exports and the old CLI terminal +implementation paths are deleted, and every repository import is updated to +the canonical package surface. No compatibility module, alias, forwarding +barrel, wrapper, subclass, or duplicate descriptor remains. The neutral package has no dependency on runtime, core, CLI, or the tmux package. Core depends on terminal. The tmux package depends on terminal and does not depend on runtime, core, or CLI. CLI depends on both packages and on -core and runtime. Runtime depends on terminal only for its compatibility -re-exports. Host-specific POSIX observation is an explicit terminal adapter; +core and runtime. Runtime has no terminal dependency. Host-specific POSIX +observation is an explicit terminal adapter; Deno and compiled entrypoints install it in the supervising host and the pane worker, while Node and Bun continue to install neither observer nor provider. ### Consequences -Any terminal provider implements the public neutral contract without -importing CLI or tmux. Consumers can migrate to the canonical package names at -their own pace; removing the old runtime or core exports is a separate breaking -decision. The extraction changes no authored syntax, provider name, hidden -worker invocation, durable record, private tmux protocol, diagnostic text, -terminal behavior, or provider identity. +Any terminal provider implements the public neutral contract without importing +CLI or tmux. Repository consumers use only the canonical package names. This +removal is non-breaking because none of the temporary terminal paths has +shipped. The extraction changes no authored syntax, provider name, hidden worker +invocation, durable record, private tmux protocol, diagnostic text, terminal +behavior, or provider identity. Both packages participate in workspace version lockstep, npm and JSR publication, generated dependency ordering, package discovery, runtime test diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index af3e99bf2..a794797a3 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -9308,16 +9308,15 @@ imports terminal for the lifecycle it invokes and retains only authored parsing, expansion, source-position journal descriptions, profile composition, and Agent behavior. Terminal-tmux imports terminal and imports neither runtime, core nor CLI. CLI imports the domain and provider to compose the Deno and -compiled hosts. Runtime imports terminal only to keep its previous public -terminal exports working. +compiled hosts. Runtime owns no terminal module, export, or dependency. -Those previous `@executablemd/runtime` and `@executablemd/core` exports are -direct compatibility re-exports. The old and canonical imports of -`NativeLauncher`, `TerminalGrids`, `TerminalProviders`, `TerminalProcesses`, -their constants, operations and error constructors are the same objects, not -equivalent replacements. Stable contextual API names, middleware composition, -error identity and `instanceof` behavior therefore do not depend on import -path. +The previous `@executablemd/runtime` and `@executablemd/core` terminal exports +and the old CLI terminal implementation paths are deleted. They have not +shipped and are not compatibility surfaces. Every repository import names +`@executablemd/terminal`, one of its documented subpaths, or +`@executablemd/terminal-tmux`; no alias or forwarding barrel keeps an old path +reachable. Each contextual API and public error constructor consequently has +one canonical definition. The Deno and compiled CLI entrypoints select tmux, supply self-reinvocation, environment and terminal dimensions, translate `SIGHUP`, and install POSIX @@ -11341,7 +11340,7 @@ test derives a core result from a provider identifier. | TG18 | Provider neutrality | The controlled non-tmux provider passes TG1–TG17 and TG19; the tmux adapter prepares one hidden invocation-private server with authenticated persistent pane workers, transmits exact child creation outside tmux parsing, applies explicit row-major layout, distinguishes visible detach from control loss and server stop, attaches only after runtime spawn readiness, and satisfies TG14 without leaking provider identifiers; Node and Bun validate the same document and refuse before pane start with no provider installed | | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | | TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | -| TG21 | Package boundary and compatibility | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; and CLI alone composes the document engine with the provider and host. Imports through terminal, runtime and core return the identical `NativeLauncher`, `TerminalGrids`, `TerminalProviders`, `TerminalProcesses` and public error constructors. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | +| TG21 | Package boundary and canonical imports | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; runtime has no terminal dependency; and CLI alone composes the document engine with the provider and host. The old runtime, core and CLI terminal modules and exports are absent, every repository terminal import names a canonical package surface, and each contextual descriptor and public error constructor has one definition. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 107c20462..46c04eb97 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -1208,12 +1208,14 @@ hosts compose the two domains and provide self-reinvocation and POSIX process observation; Node and Bun continue to compose neither a foreground grid provider nor an observer. -The former `@executablemd/runtime` native-launch exports and -`@executablemd/core` pane and terminal-provider exports directly re-export the -canonical definitions. Old and new imports of every contextual descriptor and -public error constructor are object-identical. This extraction changes no -launch request, phase, route, ownership key, durable record, result, diagnostic, -provider advertisement, or root-versus-pane behavior. +The former `@executablemd/runtime` native-launch exports, +`@executablemd/core` pane and terminal-provider exports, and old CLI terminal +implementation paths are deleted. They are unshipped and carry no compatibility +contract. Every repository consumer imports the canonical terminal packages, +and each contextual descriptor and public error constructor has one definition. +This extraction changes no launch request, phase, route, ownership key, durable +record, result, diagnostic, provider advertisement, or root-versus-pane +behavior. ## Testing @@ -1500,9 +1502,10 @@ Implementation review checks these frozen invariants: pane's authenticated worker, the root foreground launcher is not entered, distinct panes launch concurrently, cancellation awaits worker settlement and pane quiescence, and root launch routing remains unchanged. -30. Canonical terminal, legacy runtime and legacy core imports expose the same - native-launch and terminal-provider descriptors and error constructors by - identity; the terminal package imports no Agent, core, runtime, CLI or tmux +30. Native-launch and terminal-provider descriptors and error constructors have + one canonical definition under the terminal packages; the former runtime, + core and CLI terminal paths are absent and every repository import is + canonical. The terminal package imports no Agent, core, runtime, CLI or tmux module, the tmux package imports only the neutral terminal domain, and the complete launch evidence above passes without changing any request, route, record, provider advertisement or diagnostic. diff --git a/specs/release-process-spec.md b/specs/release-process-spec.md index 561d649d1..dbef248e9 100644 --- a/specs/release-process-spec.md +++ b/specs/release-process-spec.md @@ -84,13 +84,13 @@ The terminal packages follow the same manifest-derived publication graph as every other workspace member. `@executablemd/terminal` depends on `@executablemd/durable-streams` and the external Effection packages, not on runtime, core, CLI, or terminal-tmux. `@executablemd/terminal-tmux` depends on -terminal. Runtime depends on terminal for its compatibility re-exports. Core -depends on terminal as well as its existing runtime and durable-stream -dependencies. CLI depends on terminal-tmux, terminal, core, and runtime. +terminal. Runtime has no terminal dependency. Core depends on terminal as well +as its existing runtime and durable-stream dependencies. CLI depends on +terminal-tmux, terminal, core, and runtime. The generated npm jobs consequently publish durable-streams before terminal; -terminal before terminal-tmux, runtime, and core; and all of terminal-tmux, -terminal, runtime, and core before CLI. Independent leaves remain parallel. The +terminal before terminal-tmux and core; and terminal-tmux, terminal, core and +runtime before CLI. Runtime remains an independent leaf. The workspace package names and versions are also recorded in `bun.lock`. Adding the two manifests or changing these sibling dependencies requires `deno install --frozen=false`, the repository's normal setup, and From 5d308a567a56d2120915697c6924ef7f70b76b2b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 3 Sep 2026 22:35:07 -0400 Subject: [PATCH 40/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20the=20term?= =?UTF-8?q?inal=20domain=20and=20its=20tmux=20adapter=20into=20packages=20?= =?UTF-8?q?(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEC-016, as amended by 630c3f0a. The lifecycle and the one provider that implements it were spread across runtime, core and CLI, which made a second presentation provider depend on CLI internals and made the neutral authority look core-specific. They are now two ordinary workspace members: - `@executablemd/terminal` — native launch routing and reservation, grid and pane requests, composites and states, `TerminalGrids`/`TerminalProviders`, registration and direct authority, claims and readiness, row-major layout, the live and durable grid, pane routing, retained outcomes, the process observation contract and quiescence, the POSIX adapters, and the controlled fixtures — as root, `./lifecycle`, `./processes`, `./posix` and `./test`. - `@executablemd/terminal-tmux` — probing and commands, the hidden server and control clients, layout and swaps, attach, the authenticated channels and their protocol, the worker and its child, and the one ordered teardown. The arrows point at the neutral domain: terminal imports no runtime, core, CLI or tmux; terminal-tmux imports terminal and nothing above it; core imports terminal; runtime has no terminal edge at all; only CLI composes all four. `packages/cli/src/terminal/` is gone — the host composition that remains CLI's is `grid-host.ts`, which chooses and installs a provider rather than implementing one. Ownership moves, behavior does not. The stack has not merged, so the terminal exports that sat in runtime and core were never a compatibility surface — they were the ambiguity this removes. They are deleted rather than forwarded, every repository import names a canonical package surface, and no alias, barrel or wrapper keeps an old path reachable. Authored syntax, the provider name, the hidden worker verb, the protocol, durable records, diagnostics and every provider identity are untouched. TG21 is the evidence, and each half was probed by breaking it. The dependency rows read production imports from source rather than trusting a manifest, and fail when a `@executablemd/core` import is planted in the neutral package. The absence rows fail when a runtime terminal export is re-added or the old CLI path is recreated. The uniqueness row replaces what object-identity used to prove: with one import path left, the claim worth making is that there is only one definition to reach, so a second `createApi` or class cannot quietly split middleware composition between two objects that behave alike. A non-vacuity row keeps the absence claims from passing over an empty walk. Two things the work found rather than assumed. The fixture that runs the stand-in tmux client resolved its program through a repo-relative string; the move left it pointing at nothing, which started no client — and TG13, whose subject is a client that refuses to leave, passed anyway, because one that never starts never leaves either. It now resolves from its own module URL. And the uniqueness scan first keyed on names ending in `Error`, which reported `TerminalTeardownFailed` as having no definition at all; it matches any exported class now. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS --- .github/workflows/publish-packages.yml | 24 +- deno.lock | 21 + packages/acp/package.json | 1 + packages/acp/src/provider.ts | 2 +- packages/acp/tests/native-launch.test.ts | 11 +- packages/cli/package.json | 2 + packages/cli/src/agent-stack.ts | 7 +- packages/cli/src/cli.ts | 4 +- packages/cli/src/compiled.ts | 4 +- packages/cli/src/deno.ts | 4 +- .../src/{terminal/host.ts => grid-host.ts} | 21 +- .../tests/agent-session-coordinator.test.ts | 5 +- .../cli/tests/run-composition-deno.test.ts | 3 +- packages/cli/tests/terminal-host.test.ts | 502 ++++++++++++++++++ packages/core/mod.ts | 32 +- packages/core/package.json | 1 + .../core/src/agent/function-components.ts | 3 +- packages/core/src/agent/launch-owner.ts | 3 +- packages/core/src/expand.ts | 14 +- packages/core/src/terminal/journal.ts | 6 +- packages/core/src/terminal/profile.ts | 3 +- .../core/tests/agent-session-launch.test.ts | 19 +- .../tests/terminal-grid-structure.test.ts | 2 +- packages/core/tests/terminal-grid.test.ts | 20 +- packages/runtime/mod.ts | 62 --- packages/terminal-tmux/deno.json | 8 + packages/terminal-tmux/mod.ts | 33 ++ packages/terminal-tmux/package.json | 17 + .../src}/attach-client.ts | 2 +- .../terminal => terminal-tmux/src}/layout.ts | 0 .../src}/pane-channel.ts | 0 .../src}/pane-child.ts | 2 +- .../src}/pane-protocol.ts | 0 .../src}/pane-worker.ts | 3 +- .../src}/provider.ts | 7 +- .../src}/tmux-grid.ts | 2 +- .../terminal => terminal-tmux/src}/tmux.ts | 0 packages/terminal-tmux/test.ts | 53 ++ .../tests/fixtures/client-command.ts | 21 + .../tests/fixtures/fake-tmux.ts | 4 +- .../tests/fixtures/tmux-client.ts | 0 .../tests/terminal-grid-tmux.test.ts | 431 +-------------- packages/terminal/deno.json | 11 + packages/terminal/lifecycle.ts | 53 ++ packages/terminal/mod.ts | 64 +++ packages/terminal/package.json | 21 + packages/terminal/posix.ts | 18 + packages/terminal/processes.ts | 31 ++ .../terminal => terminal/src}/authority.ts | 2 +- .../src/terminal => terminal/src}/grid.ts | 7 +- .../{runtime => terminal/src}/launcher.ts | 2 +- .../src/layout.ts} | 13 +- .../src}/pane-launcher.ts | 4 +- .../src/terminal => terminal/src}/pane.ts | 0 .../src/posix-processes.ts} | 4 +- .../src/processes.ts} | 0 .../terminal => terminal/src}/provider-api.ts | 0 .../{runtime => terminal/src}/terminal.ts | 0 packages/terminal/test.ts | 18 + .../tests/native-launcher.test.ts | 2 +- .../terminal/tests/package-boundary.test.ts | 272 ++++++++++ .../tests/terminal-processes.test.ts | 8 +- .../tests/terminal-provider.test.ts | 4 +- packages/test-agent/package.json | 1 + .../test-agent/src/child-configuration.ts | 2 +- packages/test-agent/src/components.ts | 3 +- packages/test-agent/src/controller.ts | 2 +- .../test-agent/tests/native-launch.test.ts | 5 +- .../tests/terminal-grid-native-launch.test.ts | 10 +- scripts/runtime-test-exclusions.ts | 8 +- 70 files changed, 1319 insertions(+), 605 deletions(-) rename packages/cli/src/{terminal/host.ts => grid-host.ts} (89%) create mode 100644 packages/cli/tests/terminal-host.test.ts create mode 100644 packages/terminal-tmux/deno.json create mode 100644 packages/terminal-tmux/mod.ts create mode 100644 packages/terminal-tmux/package.json rename packages/{cli/src/terminal => terminal-tmux/src}/attach-client.ts (98%) rename packages/{cli/src/terminal => terminal-tmux/src}/layout.ts (100%) rename packages/{cli/src/terminal => terminal-tmux/src}/pane-channel.ts (100%) rename packages/{cli/src/terminal => terminal-tmux/src}/pane-child.ts (99%) rename packages/{cli/src/terminal => terminal-tmux/src}/pane-protocol.ts (100%) rename packages/{cli/src/terminal => terminal-tmux/src}/pane-worker.ts (98%) rename packages/{cli/src/terminal => terminal-tmux/src}/provider.ts (98%) rename packages/{cli/src/terminal => terminal-tmux/src}/tmux-grid.ts (99%) rename packages/{cli/src/terminal => terminal-tmux/src}/tmux.ts (100%) create mode 100644 packages/terminal-tmux/test.ts create mode 100644 packages/terminal-tmux/tests/fixtures/client-command.ts rename packages/{cli => terminal-tmux}/tests/fixtures/fake-tmux.ts (98%) rename packages/{cli => terminal-tmux}/tests/fixtures/tmux-client.ts (100%) rename packages/{cli => terminal-tmux}/tests/terminal-grid-tmux.test.ts (83%) create mode 100644 packages/terminal/deno.json create mode 100644 packages/terminal/lifecycle.ts create mode 100644 packages/terminal/mod.ts create mode 100644 packages/terminal/package.json create mode 100644 packages/terminal/posix.ts create mode 100644 packages/terminal/processes.ts rename packages/{core/src/terminal => terminal/src}/authority.ts (99%) rename packages/{core/src/terminal => terminal/src}/grid.ts (98%) rename packages/{runtime => terminal/src}/launcher.ts (99%) rename packages/{core/src/terminal-grid.ts => terminal/src/layout.ts} (85%) rename packages/{core/src/terminal => terminal/src}/pane-launcher.ts (96%) rename packages/{core/src/terminal => terminal/src}/pane.ts (100%) rename packages/{runtime/deno-terminal-processes.ts => terminal/src/posix-processes.ts} (99%) rename packages/{runtime/terminal-processes.ts => terminal/src/processes.ts} (100%) rename packages/{core/src/terminal => terminal/src}/provider-api.ts (100%) rename packages/{runtime => terminal/src}/terminal.ts (100%) create mode 100644 packages/terminal/test.ts rename packages/{runtime => terminal}/tests/native-launcher.test.ts (99%) create mode 100644 packages/terminal/tests/package-boundary.test.ts rename packages/{runtime => terminal}/tests/terminal-processes.test.ts (98%) rename packages/{runtime => terminal}/tests/terminal-provider.test.ts (98%) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 9f56a1e9e..c7918cb0e 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -30,7 +30,7 @@ jobs: - name: Validate the manifests declare this version run: | VERSION="${{ steps.resolve.outputs.value }}" - for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/core/deno.json packages/acp/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do + for f in packages/durable-streams/deno.json packages/runtime/deno.json packages/terminal/deno.json packages/core/deno.json packages/acp/deno.json packages/terminal-tmux/deno.json packages/testing/deno.json packages/test-agent/deno.json packages/web/deno.json packages/workflow/deno.json packages/cli/deno.json packages/code-review-agent/deno.json; do declared="$(jq -r .version "$f")" if [ "$declared" != "$VERSION" ]; then echo "::error::$f declares $declared, not $VERSION — the tag does not match the manifests" @@ -75,20 +75,34 @@ jobs: package: packages/runtime version: ${{ needs.version.outputs.value }} + terminal: + needs: [version, durable-streams] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal + version: ${{ needs.version.outputs.value }} + core: - needs: [version, durable-streams, runtime] + needs: [version, durable-streams, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/core version: ${{ needs.version.outputs.value }} acp: - needs: [version, core, runtime] + needs: [version, core, runtime, terminal] uses: ./.github/workflows/publish-one.yml with: package: packages/acp version: ${{ needs.version.outputs.value }} + terminal-tmux: + needs: [version, terminal] + uses: ./.github/workflows/publish-one.yml + with: + package: packages/terminal-tmux + version: ${{ needs.version.outputs.value }} + testing: needs: [version, core, durable-streams, runtime] uses: ./.github/workflows/publish-one.yml @@ -97,7 +111,7 @@ jobs: version: ${{ needs.version.outputs.value }} test-agent: - needs: [version, acp, core, durable-streams, runtime, testing] + needs: [version, acp, core, durable-streams, runtime, terminal, testing] uses: ./.github/workflows/publish-one.yml with: package: packages/test-agent @@ -118,7 +132,7 @@ jobs: version: ${{ needs.version.outputs.value }} cli: - needs: [version, acp, core, durable-streams, runtime, test-agent, testing, web, workflow] + needs: [version, acp, core, durable-streams, runtime, terminal, terminal-tmux, test-agent, testing, web, workflow] uses: ./.github/workflows/publish-one.yml with: package: packages/cli diff --git a/deno.lock b/deno.lock index 7772922ea..f04adcbe3 100644 --- a/deno.lock +++ b/deno.lock @@ -4129,6 +4129,27 @@ ] } }, + "packages/terminal": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/context-api@0.6.0", + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/node@0.2.4", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0" + ] + } + }, + "packages/terminal-tmux": { + "packageJson": { + "dependencies": [ + "npm:@effectionx/fs@0.3.0", + "npm:@effectionx/process@0.8.1", + "npm:effection@4.1.0", + "npm:zod@^4.3.6" + ] + } + }, "packages/test-agent": { "dependencies": [ "npm:@agentclientprotocol/sdk@1.3.0", diff --git a/packages/acp/package.json b/packages/acp/package.json index 5bd9f5d9e..4fc639685 100644 --- a/packages/acp/package.json +++ b/packages/acp/package.json @@ -11,6 +11,7 @@ "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0" } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index e6e39d709..6eb0597a5 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -88,8 +88,8 @@ import { AgentSessionRecoveryRequired, cwd, ExecutableObservationError, - nativeLaunch, } from "@executablemd/runtime"; +import { nativeLaunch } from "@executablemd/terminal"; import type { AgentSessionCoordinator, AgentSessionKey, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 3984b860e..6cdf80860 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -27,13 +27,10 @@ import type { PreparedLaunchRecord, Session, } from "@executablemd/core"; -import { - flushOutput, - installControlledLauncher, - NativeLauncher, - reserveTerminal, -} from "@executablemd/runtime"; -import type { AgentSessionCoordinator, NativeLaunchRequest } from "@executablemd/runtime"; +import { flushOutput, NativeLauncher, reserveTerminal } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { AgentSessionCoordinator } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createAcpxProvider } from "../src/provider.ts"; import type { AcpxProviderDependencies } from "../src/provider.ts"; import { diff --git a/packages/cli/package.json b/packages/cli/package.json index c2d10da8a..242ef51a5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,6 +16,8 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", + "@executablemd/terminal-tmux": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", "@executablemd/web": "workspace:*", diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 6c855b87c..d754acf5b 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -21,9 +21,10 @@ import { registerAgentProvider, } from "@executablemd/core"; import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; -import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; -import { unsupportedTerminalGrid } from "./terminal/host.ts"; -import type { TerminalGridInstaller } from "./terminal/host.ts"; +import { env as readEnv } from "@executablemd/runtime"; +import { installForegroundLauncher } from "@executablemd/terminal/posix"; +import { unsupportedTerminalGrid } from "./grid-host.ts"; +import type { TerminalGridInstaller } from "./grid-host.ts"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 35dc9b579..9ad13d20a 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -96,8 +96,8 @@ import { installWebComponents, installWebElicitation } from "@executablemd/web"; import { timebox } from "@effectionx/timebox"; import { timeout as runTimeout } from "@executablemd/runtime"; import { installRunAgentStack, resolveAgentStack, resolveAuthorshipStack } from "./agent-stack.ts"; -import { unsupportedTerminalGrid } from "./terminal/host.ts"; -import type { TerminalGridInstaller } from "./terminal/host.ts"; +import { unsupportedTerminalGrid } from "./grid-host.ts"; +import type { TerminalGridInstaller } from "./grid-host.ts"; import { planComponentDeclaration } from "./plan-component.ts"; import { planAgentContext } from "./authorship-profile.ts"; import { useVerboseComponent } from "./verbose-component.ts"; diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index 0deb2ad58..c7d70d281 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -19,8 +19,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; -import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; -import { foregroundTerminalGrid } from "./terminal/host.ts"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/terminal-tmux"; +import { foregroundTerminalGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useCompiledService } from "./compiled-service.ts"; diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index dda509142..a1e7c9b5e 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -22,8 +22,8 @@ import { isCredentialHelperMode, runCredentialHelper, } from "@executablemd/workflow/credential-helper"; -import { paneWorkerInvocation, runPaneWorkerProcess } from "./terminal/pane-worker.ts"; -import { foregroundTerminalGrid } from "./terminal/host.ts"; +import { paneWorkerInvocation, runPaneWorkerProcess } from "@executablemd/terminal-tmux"; +import { foregroundTerminalGrid } from "./grid-host.ts"; import type { HelperAssembly } from "@executablemd/workflow/credential-helper"; import { useDenoService } from "./deno-service.ts"; diff --git a/packages/cli/src/terminal/host.ts b/packages/cli/src/grid-host.ts similarity index 89% rename from packages/cli/src/terminal/host.ts rename to packages/cli/src/grid-host.ts index 4b942e804..02c4a1e25 100644 --- a/packages/cli/src/terminal/host.ts +++ b/packages/cli/src/grid-host.ts @@ -1,6 +1,11 @@ /** * Which hosts open a terminal grid, and which only describe one - * (architecture.md §Interactive terminal grids). + * (architecture.md §Package ownership). + * + * Host composition, not a terminal implementation — which is why it sits here + * rather than under a `terminal/` path. The domain is + * `@executablemd/terminal`'s and the provider is `@executablemd/terminal-tmux`'s; + * what this module does is decide, per entrypoint, whether to install them. * * The Deno source entrypoint and the compiled binary present grids when the * invocation has a terminal and a usable tmux. Node and Bun keep the same @@ -18,11 +23,15 @@ import { ensure, race, resource, withResolvers } from "effection"; import type { Operation } from "effection"; import process from "node:process"; import { Execution, installTerminalGridProfile } from "@executablemd/core"; -import { command as hostCommand, installDenoTerminalProcesses } from "@executablemd/runtime"; -import { installTmuxGridProvider, TMUX_PROVIDER } from "./provider.ts"; -import type { TmuxProviderDependencies } from "./provider.ts"; -import { paneEnvironment } from "./tmux.ts"; -import { PANE_WORKER_COMMAND } from "./pane-worker.ts"; +import { command as hostCommand } from "@executablemd/runtime"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { + installTmuxGridProvider, + paneEnvironment, + PANE_WORKER_COMMAND, + TMUX_PROVIDER, +} from "@executablemd/terminal-tmux"; +import type { TmuxProviderDependencies } from "@executablemd/terminal-tmux"; /** How a host installs whatever presents its terminal grids. */ export type TerminalGridInstaller = () => Operation; diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 46464641c..7837bef20 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -28,8 +28,8 @@ import { API, createDenoAgentSessionCoordinator, hasDenoAgentSessionCoordinator, - installControlledLauncher, } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, @@ -39,7 +39,8 @@ import { createMemorySessionRouteStore, } from "@executablemd/acp"; import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; -import type { ExecutableObserver, NativeLaunchRequest } from "@executablemd/runtime"; +import type { ExecutableObserver } from "@executablemd/runtime"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, diff --git a/packages/cli/tests/run-composition-deno.test.ts b/packages/cli/tests/run-composition-deno.test.ts index 5d512e000..8b47cf4e4 100644 --- a/packages/cli/tests/run-composition-deno.test.ts +++ b/packages/cli/tests/run-composition-deno.test.ts @@ -21,7 +21,8 @@ import { exists, readTextFile } from "@effectionx/fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import process from "node:process"; -import { API, NativeLauncher, useHostFiles } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { NativeLauncher } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import { Agent, diff --git a/packages/cli/tests/terminal-host.test.ts b/packages/cli/tests/terminal-host.test.ts new file mode 100644 index 000000000..0342c2201 --- /dev/null +++ b/packages/cli/tests/terminal-host.test.ts @@ -0,0 +1,502 @@ +/** + * Tier TH — which hosts open a terminal grid, and which only describe one + * (architecture.md §Package ownership, issue #717). + * + * The host-composition boundary is CLI's, so its evidence is too. The tmux + * adapter's own topology, protocol, worker and teardown rows live with the + * adapter in `@executablemd/terminal-tmux`; what is proved here is the part + * only an entrypoint can answer — which runtime installs a provider and an + * observer, which installs neither, what a real document gets in each case, + * and that a terminal going away cancels the run rather than closing the grid. + * + * The fake tmux server and its client fixtures are imported from the adapter's + * own tests. That is a test-only path: it creates no package dependency, and + * the production graph CLI declares is unchanged by it. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, Ok, resource, scoped, sleep, spawn, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; +import type { ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import process from "node:process"; +import { chmod, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { cliCommand } from "@executablemd/test-support/launch"; +import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs"; +import { execute } from "@executablemd/core"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import type { Json } from "@executablemd/core"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import { processReachable } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { PANE_WORKER_COMMAND, tmuxGridProvider } from "@executablemd/terminal-tmux"; +import { foregroundSignalListeners } from "@executablemd/terminal-tmux/test"; +import { createFakeTmux } from "../../terminal-tmux/tests/fixtures/fake-tmux.ts"; +import { clientCommand } from "../../terminal-tmux/tests/fixtures/client-command.ts"; +import { foregroundTerminalGrid, unsupportedTerminalGrid } from "../src/grid-host.ts"; + +/** Where a fake server and its client fixtures meet. */ +function useScript(): Operation { + return resource(function* (provide) { + const file = path.join(tmpdir(), `xmd-tmux-script-${randomUUID()}.txt`); + yield* writeTextFile(file, ""); + yield* ensure(function* () { + yield* rm(file, { force: true }); + }); + yield* provide(file); + }); +} + +/** + * Open a grid through the provider, with the host's prerequisites answered by + * this row rather than by the machine. + * + * Goes through the real factory and the real installation handshake, so what a + * refusal proves is what a document would meet. + */ +function useProbedProvider(options: { + isTerminal: () => boolean; + version?: string; +}): Operation { + return (function* (): Operation { + const authority = yield* useTerminalInstallation(); + yield* registerTerminalProvider( + "tmux", + tmuxGridProvider({ + isTerminal: options.isTerminal, + env: { PATH: "/usr/bin:/bin" }, + // deno-lint-ignore require-yield + *workerCommand() { + return []; + }, + size: () => ({ columns: 80, rows: 24 }), + ...(options.version === undefined + ? {} + : { + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: options.version ?? "" }; + }, + }), + }), + ); + yield* installTerminalProvider("tmux", { label: "tmux" }, authority); + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + })(); +} + +/** A directory a row can leave markers in. */ +function useScratch(): Operation { + return resource(function* (provide) { + const room = path.join(tmpdir(), `xmd-tg20-${randomUUID()}`); + yield* ensureDir(room); + yield* ensure(function* () { + yield* rm(room, { recursive: true, force: true }); + }); + yield* provide(room); + }); +} + +/** Settle once this child has gone, whether or not it already had. */ +function exited(child: ChildProcess): Operation { + const done = withResolvers(); + const onExit = (): void => done.resolve(); + if (child.exitCode !== null || child.signalCode !== null) { + done.resolve(); + } else { + child.on("exit", onExit); + } + return (function* (): Operation { + try { + yield* done.operation; + } finally { + child.off("exit", onExit); + } + })(); +} + +/** A shell that says when it started, and stays until it is signalled. */ +function useShellFixture(room: string): Operation { + return resource(function* (provide) { + const file = path.join(room, "shell"); + yield* writeTextFile( + file, + ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( + "\n", + ), + ); + yield* until(chmod(file, 0o755)); + yield* provide(file); + }); +} + +/** One entrypoint's source, for the rows about what a host assembles. */ +function entrypointSource(name: string): Operation { + return readTextFile(path.resolve("packages/cli/src", name)); +} + +describe("Tier TH — host installation", () => { + it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { + // The document-level end of the same claim: a grid whose teardown could not + // establish the terminal was given back is a failed run, not a run with a + // warning in it. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + // The server refuses to be killed the first time it is asked, so the last + // phase of the teardown cannot establish it is gone. + const tmux = createFakeTmux({ + script, + clientCommand, + spawnPanes: true, + failOnce: { command: "kill-server", message: "refused" }, + }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + expect(outcome?.ok).toBe(false); + const refusal = outcome?.ok === false ? String(outcome.error) : ""; + expect(refusal).toContain("terminal server"); + // Nothing private in it, and nothing after the grid ran. + expect(refusal).not.toContain(room); + expect(output).not.toContain("AFTER_THE_GRID"); + }); + + it("TH1: without a terminal, a grid refuses before anything exists", function* () { + const before = yield* until(readdir(tmpdir())); + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ isTerminal: () => false }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("no terminal"); + // Before a directory, a socket, a token, a worker, a server or a pane: the + // host left nothing behind for having tried. + const after = yield* until(readdir(tmpdir())); + expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( + before.filter((name) => name.startsWith("xmd-grid-")).length, + ); + }); + + it("TH2: without a usable tmux, a grid refuses the same way", function* () { + let refusal = ""; + try { + yield* scoped(function* () { + yield* installDenoTerminalProcesses(); + yield* useProbedProvider({ + isTerminal: () => true, + // A tmux far too old for an explicit layout string. + version: "tmux 1.8", + }); + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("cannot open a terminal grid"); + expect(refusal).toContain("older than tmux"); + }); + + it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + // The run's foreground lease, which a grid takes before any provider. + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + const sighupBefore = foregroundSignalListeners("SIGHUP"); + let directory = ""; + let installed = 0; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + // The listener is the installer's, and this row uses that one. + installed = foregroundSignalListeners("SIGHUP"); + + yield* spawn(function* () { + // Driven by the pane child's own start: the worker spawned, its channel + // authenticated, and the shell it launched said so. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + process.kill(process.pid, "SIGHUP"); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The installer put its listener on, and took it off with the run. + expect(installed).toBe(sighupBefore + 1); + expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); + + // Cancellation, not a reader close: the run failed and nothing after the + // grid ran in that attempt. + expect(outcome?.ok).toBe(false); + expect(output).not.toContain("AFTER_THE_GRID"); + + // Every teardown phase completed before the result was observed. The pane's + // child is gone, the worker is gone, the server is gone, and the private + // directory — which is removed last, after its sockets have closed — is + // gone with them. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + // Awaited on each process's own exit event, not sampled: a worker that had + // not quite gone yet would make a sampled check pass or fail by timing. + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { + // The same host, the same document and the same live grid as TH4. What + // differs is the ending: the reader leaves rather than the terminal going + // away, so the grid settles and the document carries on — which is the + // branch `useHangupCancellation()` has to hand the result back through. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + yield* writeTextFile( + path.join(room, "doc.md"), + [ + "", + '', + "", + "", + "AFTER_THE_GRID", + "", + ].join("\n"), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + let directory = ""; + let outcome: Result | undefined; + let output = ""; + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + env: { PATH: "/usr/bin:/bin", SHELL: shell }, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + directory = at; + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + // Driven by the grid's own progress: the pane child started, and the + // server has a reader's client to report the detach of. No SIGHUP. + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + output = next.value; + next = yield* subscription.next(); + } + outcome = yield* execution; + }); + + // The exact result, handed back through the hangup wrapper rather than + // swallowed by it: a handler that answered with nothing would be refused + // for having returned before the document produced a result. + expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); + // The reader closed the grid; the document went on. + expect(output).toContain("AFTER_THE_GRID"); + + // And it went on over a grid that had actually been taken down: the pane's + // child, the workers, the server and the private directory are all gone. + const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); + expect(shellPid).toBeGreaterThan(0); + yield* installDenoTerminalProcesses(); + expect(yield* processReachable(shellPid)).toBe(false); + for (const child of tmux.started) { + yield* exited(child); + } + expect(tmux.alive()).toBe(false); + expect(directory).not.toBe(""); + expect(yield* exists(directory)).toBe(false); + }); + + it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { + for (const name of ["deno.ts", "compiled.ts"]) { + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); + } + for (const name of ["node.ts", "bun.ts"]) { + // Not a different grid: no grid at all, and therefore the default the + // shared entry declares — which is the installation that validates a grid + // and presents none. + expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); + } + expect(yield* entrypointSource("cli.ts")).toContain( + "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", + ); + }); + + it("TH3: a host that installs no provider still validates the grid", function* () { + // Node and Bun: the same language and the same validation, and core's own + // refusal rather than a provider that half-works. + yield* unsupportedTerminalGrid(); + let refusal = ""; + try { + yield* TerminalGrids.operations.open({ + columns: 1, + rows: 1, + panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], + }); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("no terminal provider is installed"); + }); +}); diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 568ae1e19..4e80efd47 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -152,36 +152,12 @@ export { DocumentOutput } from "./src/api.ts"; export type { DocumentOutputApi } from "./src/api.ts"; export { useNormalizedOutput } from "./src/output/normalize.ts"; export { useTerminalOutput } from "./src/output/terminal.ts"; -export { - createTerminalAuthority, - createTerminalGridClaims, - TerminalAuthorityError, - terminalInstallation, - useTerminalInstallation, -} from "./src/terminal/authority.ts"; -export type { - PaneReadiness, - TerminalGridAuthority, - TerminalGridClaims, - TerminalPaneClaim, -} from "./src/terminal/authority.ts"; -export { - installTerminalProvider, - registerTerminalProvider, - TERMINAL_PROVIDERS_API, - TerminalProviderInstallError, - TerminalProviders, -} from "./src/terminal/provider-api.ts"; -export type { - TerminalProviderFactory, - TerminalProviderInstallRequest, - TerminalProviderOptions, -} from "./src/terminal/provider-api.ts"; +// The terminal domain is `@executablemd/terminal`'s, and a caller names it +// directly (DEC-016). What core exports here is only what core owns: the +// profile that composes a grid into an `Execution`, adapting the terminal +// lifecycle to this engine's journal descriptions and installation. export { installTerminalGridProfile } from "./src/terminal/profile.ts"; export type { TerminalGridProfileOptions } from "./src/terminal/profile.ts"; -export { paneTerminal } from "./src/terminal/pane.ts"; -export type { PaneTerminal } from "./src/terminal/pane.ts"; -export type { PaneStatus, RetainedGrid, RetainedPaneOutcome } from "./src/terminal/grid.ts"; export { execute, Execution } from "./src/execute.ts"; export type { diff --git a/packages/core/package.json b/packages/core/package.json index 8dadac530..1fc4c93cf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", diff --git a/packages/core/src/agent/function-components.ts b/packages/core/src/agent/function-components.ts index 043d02b93..c656e64f1 100644 --- a/packages/core/src/agent/function-components.ts +++ b/packages/core/src/agent/function-components.ts @@ -23,7 +23,8 @@ import { sessionPlacement } from "./session-request.ts"; import type { ComponentInvocation, FunctionComponent } from "../types.ts"; import type { IdentityClaimant } from "../invocation-identity.ts"; -import { cwd, flushOutput, parseDuration, reserveTerminal } from "@executablemd/runtime"; +import { cwd, parseDuration } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import type { Json, PropsSchema } from "../types.ts"; import type { Expansion } from "../expansion.ts"; import { Agent } from "./agent-api.ts"; diff --git a/packages/core/src/agent/launch-owner.ts b/packages/core/src/agent/launch-owner.ts index 7c7e85285..3be70ec66 100644 --- a/packages/core/src/agent/launch-owner.ts +++ b/packages/core/src/agent/launch-owner.ts @@ -16,7 +16,8 @@ import { createApi } from "@effectionx/context-api"; import { scoped } from "effection"; import type { Operation, Stream } from "effection"; -import { cwd, flushOutput, reserveTerminal } from "@executablemd/runtime"; +import { cwd } from "@executablemd/runtime"; +import { flushOutput, reserveTerminal } from "@executablemd/terminal"; import { Agent, AGENT_API } from "./agent-api.ts"; import type { AgentApi, diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index c84a78681..b123391c5 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -66,13 +66,15 @@ import { terminalTitleMissingMessage, } from "./structural-rules.ts"; import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; -import { terminalGridLayout } from "./terminal-grid.ts"; -import type { PlacedPane } from "./terminal-grid.ts"; -import { durableGrid, openTerminalGrid, toRequest } from "./terminal/grid.ts"; -import type { PaneWork } from "./terminal/grid.ts"; +import { + durableGrid, + openTerminalGrid, + terminalGridLayout, + toRequest, +} from "@executablemd/terminal/lifecycle"; +import type { PaneWork, PlacedPane } from "@executablemd/terminal/lifecycle"; +import { usePaneNativeLauncher, usePaneTerminal } from "@executablemd/terminal"; import { recordGridLayout } from "./terminal/journal.ts"; -import { usePaneTerminal } from "./terminal/pane.ts"; -import { usePaneNativeLauncher } from "./terminal/pane-launcher.ts"; import { asBindingViolation, asExpressionViolation, diff --git a/packages/core/src/terminal/journal.ts b/packages/core/src/terminal/journal.ts index 3ee4fd8d4..d7d803e6c 100644 --- a/packages/core/src/terminal/journal.ts +++ b/packages/core/src/terminal/journal.ts @@ -25,12 +25,12 @@ import { StaleInputError, } from "@executablemd/durable-streams"; import type { EffectDescription, Json, Workflow } from "@executablemd/durable-streams"; -import type { TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalGridRequest } from "@executablemd/terminal"; import { sourceDescription } from "../source-position.ts"; import type { SourcePosition } from "../types.ts"; -import { retainedLayout } from "./grid.ts"; -import type { RetainedGrid } from "./grid.ts"; +import { retainedLayout } from "@executablemd/terminal/lifecycle"; +import type { RetainedGrid } from "@executablemd/terminal/lifecycle"; /** A grid's identity within one execution: where it was written. */ export interface GridIdentity { diff --git a/packages/core/src/terminal/profile.ts b/packages/core/src/terminal/profile.ts index 05919b653..7b687d981 100644 --- a/packages/core/src/terminal/profile.ts +++ b/packages/core/src/terminal/profile.ts @@ -15,8 +15,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { Execution } from "../execute.ts"; -import { useTerminalInstallation } from "./authority.ts"; -import { installTerminalProvider } from "./provider-api.ts"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; export interface TerminalGridProfileOptions { /** diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index 9905a2b44..8ce019cd5 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -33,22 +33,23 @@ import { parsePrepared } from "../src/agent/launch-journal.ts"; import type { AgentLaunchRequest } from "../src/agent/launch-request.ts"; import { installAgentComponents } from "../src/agent/components.ts"; import type { AgentProviderFactory } from "../src/agent/provider-api.ts"; +import { API, useHostFiles } from "@executablemd/runtime"; import { - API, - installControlledLauncher, NATIVE_LAUNCHER_UNAVAILABLE, nativeLaunch, - prepareControlledComposite, reserveTerminal, TerminalGrids, +} from "@executablemd/terminal"; +import { + installControlledLauncher, + prepareControlledComposite, 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"; +} from "@executablemd/terminal/test"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; +import { createTerminalGridClaims } from "@executablemd/terminal/lifecycle"; +import { usePaneNativeLauncher } from "@executablemd/terminal"; import { installTerminalGridProfile } from "../src/terminal/profile.ts"; -import { registerTerminalProvider } from "../src/terminal/provider-api.ts"; +import { registerTerminalProvider } from "@executablemd/terminal"; import type { Json } from "../src/types.ts"; const ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts index 626cbf34b..b2fc9b3b2 100644 --- a/packages/core/tests/terminal-grid-structure.test.ts +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -23,7 +23,7 @@ import { Component } from "../src/component-api.ts"; import { expandSegments } from "../src/expand.ts"; import { renderSegments } from "../src/render.ts"; import { scanSegments } from "../src/scanner.ts"; -import { terminalGridLayout } from "../src/terminal-grid.ts"; +import { terminalGridLayout } from "@executablemd/terminal/lifecycle"; import type { Json, Segment } from "../src/types.ts"; interface GridRun { diff --git a/packages/core/tests/terminal-grid.test.ts b/packages/core/tests/terminal-grid.test.ts index 8c0ed596f..424138056 100644 --- a/packages/core/tests/terminal-grid.test.ts +++ b/packages/core/tests/terminal-grid.test.ts @@ -41,20 +41,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; +import { reserveTerminal, TerminalGrids } from "@executablemd/terminal"; import { installControlledLauncher, prepareControlledComposite, - reserveTerminal, - TerminalGrids, terminalProviderLog, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; +import type { TerminalComposite, TerminalGridRequest } from "@executablemd/terminal"; import type { ControlledCompositeOptions, - TerminalComposite, - TerminalGridRequest, TerminalProviderLog, TerminalProviderResources, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; import { Component } from "../src/component-api.ts"; import { execute } from "../src/execute.ts"; @@ -63,16 +61,16 @@ import { createTerminalGridClaims, TerminalAuthorityError, useTerminalInstallation, -} from "../src/terminal/authority.ts"; -import type { TerminalGridAuthority } from "../src/terminal/authority.ts"; +} from "@executablemd/terminal/lifecycle"; +import type { TerminalGridAuthority } from "@executablemd/terminal/lifecycle"; +import { installTerminalProvider } from "@executablemd/terminal/lifecycle"; import { - installTerminalProvider, registerTerminalProvider, TerminalProviderInstallError, TerminalProviders, -} from "../src/terminal/provider-api.ts"; +} from "@executablemd/terminal"; import { installTerminalGridProfile } from "../src/terminal/profile.ts"; -import { paneTerminal } from "../src/terminal/pane.ts"; +import { paneTerminal } from "@executablemd/terminal"; import type { Json } from "../src/types.ts"; /** One document run against a controlled grid host. */ diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index 06182eada..fe4e38f68 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -17,8 +17,6 @@ * this xmd, and eval-block compilation * (`cwd`, `env`, `platform`, `command`, `compile`) * - `API.Service` — scoped attached service startup (`startService`) - * - `NativeLauncher` — handing one native agent UI the foreground terminal - * (`reserveTerminal`, `flushOutput`, `nativeLaunch`) * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`, * `verbose`) * @@ -129,66 +127,6 @@ export type { FileWriteTarget, GlobInput, } from "./files.ts"; -export { - flushOutput, - installControlledLauncher, - installForegroundLauncher, - NATIVE_LAUNCHER_UNAVAILABLE, - NativeLauncher, - NativeLauncherUnavailableError, - nativeLaunch, - NO_TERMINAL, - reserveTerminal, -} from "./launcher.ts"; -export type { - ControlledLauncherOptions, - NativeLauncherHandler, - NativeLaunchOutcome, - NativeLaunchRequest, -} from "./launcher.ts"; -export { - prepareControlledComposite, - TERMINAL_GRIDS_API, - TERMINAL_PROVIDER_UNAVAILABLE, - TerminalGrids, - terminalProviderLog, - TerminalProviderUnavailableError, -} from "./terminal.ts"; -export type { - ControlledCompositeOptions, - TerminalComposite, - TerminalGridApi, - TerminalGridRequest, - TerminalPaneRequest, - TerminalPaneState, - TerminalProviderLog, - TerminalProviderResources, - TerminalShellOutcome, -} from "./terminal.ts"; -export { - descendantsOf, - deliverSignal, - establishQuiescence, - groupMembers, - paneOccupants, - processReachable, - processTable, - TERMINAL_PROCESSES_API, - TERMINAL_PROCESSES_UNAVAILABLE, - TerminalProcesses, - TerminalProcessesUnavailableError, - terminalHolders, -} from "./terminal-processes.ts"; -export type { - PaneOccupants, - PaneQuiescence, - ProcessFacts, - SignalDelivery, - TerminalProcessHandler, - TerminalSignal, -} from "./terminal-processes.ts"; -export { installDenoTerminalProcesses, posixProcessProbes } from "./deno-terminal-processes.ts"; -export type { ProcessProbes } from "./deno-terminal-processes.ts"; export { hostFilesHandler, useHostFiles } from "./host-files.ts"; export type { HostFilesEvent, HostFilesObserver, HostFilesOptions } from "./host-files.ts"; export { diff --git a/packages/terminal-tmux/deno.json b/packages/terminal-tmux/deno.json new file mode 100644 index 000000000..9c28a6049 --- /dev/null +++ b/packages/terminal-tmux/deno.json @@ -0,0 +1,8 @@ +{ + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./test": "./test.ts" + } +} diff --git a/packages/terminal-tmux/mod.ts b/packages/terminal-tmux/mod.ts new file mode 100644 index 000000000..f06d6850d --- /dev/null +++ b/packages/terminal-tmux/mod.ts @@ -0,0 +1,33 @@ +/** + * The tmux presentation provider for terminal grids + * (architecture.md §Package ownership). + * + * The first implementation of the provider-neutral domain in + * `@executablemd/terminal`, and the only place tmux appears. A host that can + * divide its terminal installs this; one that cannot installs nothing and the + * document meets core's own refusal rather than a provider that half-works. + * + * The surface is deliberately narrow: a name, what a host must supply, the + * factory and its installer, the hidden verb one pane's worker is re-invoked + * under, and the refusals a reader can actually meet. Every tmux command, the + * private protocol, the channel handles, the layout mechanics and the teardown + * controls stay inside — a second provider API is not what this is. The seams + * this adapter's own tests drive live in `./test`. + */ + +export { installTmuxGridProvider, TMUX_PROVIDER, tmuxGridProvider } from "./src/provider.ts"; +export type { TmuxProviderDependencies } from "./src/provider.ts"; + +export { + PANE_WORKER_COMMAND, + PaneNotQuiescent, + paneWorkerInvocation, + runPaneWorkerProcess, +} from "./src/pane-worker.ts"; + +export { + paneEnvironment, + TerminalTeardownFailed, + TMUX_UNAVAILABLE, + TmuxUnavailableError, +} from "./src/tmux.ts"; diff --git a/packages/terminal-tmux/package.json b/packages/terminal-tmux/package.json new file mode 100644 index 000000000..44ca115c7 --- /dev/null +++ b/packages/terminal-tmux/package.json @@ -0,0 +1,17 @@ +{ + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "description": "The tmux presentation provider for executable.md terminal grids.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./test": "./test.ts" + }, + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/terminal": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6" + } +} diff --git a/packages/cli/src/terminal/attach-client.ts b/packages/terminal-tmux/src/attach-client.ts similarity index 98% rename from packages/cli/src/terminal/attach-client.ts rename to packages/terminal-tmux/src/attach-client.ts index f9a6d5b10..33eeda07d 100644 --- a/packages/cli/src/terminal/attach-client.ts +++ b/packages/terminal-tmux/src/attach-client.ts @@ -20,7 +20,7 @@ import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { ensure, race, resource, sleep, withResolvers } from "effection"; import type { Operation } from "effection"; -import { deliverSignal, processReachable } from "@executablemd/runtime"; +import { deliverSignal, processReachable } from "@executablemd/terminal/processes"; import { TerminalTeardownFailed } from "./tmux.ts"; export interface AttachClient { diff --git a/packages/cli/src/terminal/layout.ts b/packages/terminal-tmux/src/layout.ts similarity index 100% rename from packages/cli/src/terminal/layout.ts rename to packages/terminal-tmux/src/layout.ts diff --git a/packages/cli/src/terminal/pane-channel.ts b/packages/terminal-tmux/src/pane-channel.ts similarity index 100% rename from packages/cli/src/terminal/pane-channel.ts rename to packages/terminal-tmux/src/pane-channel.ts diff --git a/packages/cli/src/terminal/pane-child.ts b/packages/terminal-tmux/src/pane-child.ts similarity index 99% rename from packages/cli/src/terminal/pane-child.ts rename to packages/terminal-tmux/src/pane-child.ts index 107b969d2..d1287d9a4 100644 --- a/packages/cli/src/terminal/pane-child.ts +++ b/packages/terminal-tmux/src/pane-child.ts @@ -31,7 +31,7 @@ import { processReachable, processTable, terminalHolders, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/processes"; import type { Settlement } from "./pane-protocol.ts"; export interface PaneChildRequest { diff --git a/packages/cli/src/terminal/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts similarity index 100% rename from packages/cli/src/terminal/pane-protocol.ts rename to packages/terminal-tmux/src/pane-protocol.ts diff --git a/packages/cli/src/terminal/pane-worker.ts b/packages/terminal-tmux/src/pane-worker.ts similarity index 98% rename from packages/cli/src/terminal/pane-worker.ts rename to packages/terminal-tmux/src/pane-worker.ts index 81f175a06..107ac1c76 100644 --- a/packages/cli/src/terminal/pane-worker.ts +++ b/packages/terminal-tmux/src/pane-worker.ts @@ -28,7 +28,8 @@ import process from "node:process"; import { readTextFile, rm } from "@effectionx/fs"; import { ensure, resource, run, spawn, withResolvers } from "effection"; import type { Operation } from "effection"; -import { installDenoTerminalProcesses, processTable } from "@executablemd/runtime"; +import { processTable } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; import { sweepHolders, usePaneChild } from "./pane-child.ts"; import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; import { diff --git a/packages/cli/src/terminal/provider.ts b/packages/terminal-tmux/src/provider.ts similarity index 98% rename from packages/cli/src/terminal/provider.ts rename to packages/terminal-tmux/src/provider.ts index 5c33258f0..e1d9aac41 100644 --- a/packages/cli/src/terminal/provider.ts +++ b/packages/terminal-tmux/src/provider.ts @@ -23,17 +23,16 @@ import { ensure, resource, withResolvers } from "effection"; import process from "node:process"; import type { Operation } from "effection"; -import { TerminalGrids } from "@executablemd/runtime"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; import type { NativeLaunchOutcome, NativeLaunchRequest, TerminalComposite, TerminalGridRequest, TerminalPaneState, + TerminalProviderFactory, TerminalShellOutcome, -} from "@executablemd/runtime"; -import { registerTerminalProvider } from "@executablemd/core"; -import type { TerminalProviderFactory } from "@executablemd/core"; +} from "@executablemd/terminal"; import { usePaneChannels } from "./pane-channel.ts"; import { requireQuiescent } from "./pane-worker.ts"; import type { PaneLink } from "./pane-channel.ts"; diff --git a/packages/cli/src/terminal/tmux-grid.ts b/packages/terminal-tmux/src/tmux-grid.ts similarity index 99% rename from packages/cli/src/terminal/tmux-grid.ts rename to packages/terminal-tmux/src/tmux-grid.ts index e1e5cab2b..10f4210ec 100644 --- a/packages/cli/src/terminal/tmux-grid.ts +++ b/packages/terminal-tmux/src/tmux-grid.ts @@ -30,7 +30,7 @@ import { exec } from "@effectionx/process"; import { lines } from "@effectionx/stream-helpers"; import { createSignal, ensure, resource, sleep, spawn } from "effection"; import type { Operation } from "effection"; -import { processReachable } from "@executablemd/runtime"; +import { processReachable } from "@executablemd/terminal/processes"; import { layoutString, swapsInto } from "./layout.ts"; import type { LayoutCell } from "./layout.ts"; import { useAttachClient } from "./attach-client.ts"; diff --git a/packages/cli/src/terminal/tmux.ts b/packages/terminal-tmux/src/tmux.ts similarity index 100% rename from packages/cli/src/terminal/tmux.ts rename to packages/terminal-tmux/src/tmux.ts diff --git a/packages/terminal-tmux/test.ts b/packages/terminal-tmux/test.ts new file mode 100644 index 000000000..93c310420 --- /dev/null +++ b/packages/terminal-tmux/test.ts @@ -0,0 +1,53 @@ +/** + * The low-level seams this adapter's own evidence drives + * (architecture.md §Package ownership). + * + * Not a second provider API. These are the pieces a row needs to hold one + * layer to its contract — a channel without a server, a worker without tmux, a + * layout string without a window — and production code imports none of them. + */ + +export { useAttachClient } from "./src/attach-client.ts"; +export type { AttachClient } from "./src/attach-client.ts"; +export { layoutString, placementProblems, rowMajorCells, swapsInto } from "./src/layout.ts"; +export type { LayoutCell, PaneSwap } from "./src/layout.ts"; +export { usePaneChannels } from "./src/pane-channel.ts"; +export type { PaneChannels, PaneLink } from "./src/pane-channel.ts"; +export { sweepHolders, usePaneChild } from "./src/pane-child.ts"; +export type { + PaneChild, + PaneChildOutcome, + PaneChildRequest, + PaneStartFailure, +} from "./src/pane-child.ts"; +export { + FromWorkerSchema, + HelloSchema, + paneSocketPath, + paneTokenPath, + readFrames, + SettlementSchema, + ToWorkerSchema, + writeFrame, +} from "./src/pane-protocol.ts"; +export type { FromWorker, Hello, Settlement, ToWorker } from "./src/pane-protocol.ts"; +export { + foregroundSignalListeners, + requireQuiescent, + runPaneWorker, + useForegroundSignals, +} from "./src/pane-worker.ts"; +export type { PaneWorkerDependencies } from "./src/pane-worker.ts"; +export { createGridTeardown, runInPane } from "./src/provider.ts"; +export type { GridParts } from "./src/provider.ts"; +export { classify, useTmuxGrid } from "./src/tmux-grid.ts"; +export type { + ControlEvent, + ServerStopped, + TmuxGrid, + TmuxGridRequest, + TmuxPane, + VisibleClient, +} from "./src/tmux-grid.ts"; +export { probeTmux, tmuxAt, TmuxCommandFailed } from "./src/tmux.ts"; +export type { Tmux } from "./src/tmux.ts"; diff --git a/packages/terminal-tmux/tests/fixtures/client-command.ts b/packages/terminal-tmux/tests/fixtures/client-command.ts new file mode 100644 index 000000000..3fce4e3a0 --- /dev/null +++ b/packages/terminal-tmux/tests/fixtures/client-command.ts @@ -0,0 +1,21 @@ +/** + * How to run the stand-in tmux client, resolved from where the fixture lives. + * + * The fixture belongs to this package, so the path is derived from this + * module's own URL rather than written relative to a repository root. A suite + * in another package drives the same client without knowing where it sits, and + * moving the fixture again cannot leave behind a stale string that starts no + * process — a failure that reads as "nothing was signalled" rather than as a + * missing file, and one that a row expecting a client to stay put can pass + * without noticing. + */ + +import { fileURLToPath } from "node:url"; +import { cliCommand } from "@executablemd/test-support/launch"; + +export function clientCommand(mode: "control" | "attach", script: string): readonly string[] { + const fixture = fileURLToPath(new URL("./tmux-client.ts", import.meta.url)); + const invocation = cliCommand([]); + // The same runtime the CLI runs under, pointed at the fixture instead. + return [invocation.command, "run", "--allow-all", fixture, mode, script]; +} diff --git a/packages/cli/tests/fixtures/fake-tmux.ts b/packages/terminal-tmux/tests/fixtures/fake-tmux.ts similarity index 98% rename from packages/cli/tests/fixtures/fake-tmux.ts rename to packages/terminal-tmux/tests/fixtures/fake-tmux.ts index f8909a7fa..c939ec940 100644 --- a/packages/cli/tests/fixtures/fake-tmux.ts +++ b/packages/terminal-tmux/tests/fixtures/fake-tmux.ts @@ -23,8 +23,8 @@ import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import { until } from "effection"; import type { Operation } from "effection"; -import { TmuxCommandFailed } from "../../src/terminal/tmux.ts"; -import type { Tmux } from "../../src/terminal/tmux.ts"; +import { TmuxCommandFailed } from "../../src/tmux.ts"; +import type { Tmux } from "../../src/tmux.ts"; export interface FakePane { id: string; diff --git a/packages/cli/tests/fixtures/tmux-client.ts b/packages/terminal-tmux/tests/fixtures/tmux-client.ts similarity index 100% rename from packages/cli/tests/fixtures/tmux-client.ts rename to packages/terminal-tmux/tests/fixtures/tmux-client.ts diff --git a/packages/cli/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts similarity index 83% rename from packages/cli/tests/terminal-grid-tmux.test.ts rename to packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 63129df40..0d20d4c27 100644 --- a/packages/cli/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -39,46 +39,30 @@ import process from "node:process"; import { cliCommand } from "@executablemd/test-support/launch"; import { ensureDir, exists, readTextFile, rm, stat, writeTextFile } from "@effectionx/fs"; import { realpath } from "node:fs/promises"; -import { installControlledLauncher, nativeLaunch, reserveTerminal } from "@executablemd/runtime"; -import type { TerminalComposite } from "@executablemd/runtime"; +import { nativeLaunch, reserveTerminal, TerminalGrids } from "@executablemd/terminal"; +import type { TerminalComposite } from "@executablemd/terminal"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; -import { - installDenoTerminalProcesses, - processReachable, - TerminalProcesses, -} from "@executablemd/runtime"; -import type { SignalDelivery } from "@executablemd/runtime"; -import { useTmuxGrid } from "../src/terminal/tmux-grid.ts"; -import type { ControlEvent, TmuxGrid } from "../src/terminal/tmux-grid.ts"; +import { processReachable, TerminalProcesses } from "@executablemd/terminal/processes"; +import type { SignalDelivery } from "@executablemd/terminal/processes"; +import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; +import { useTmuxGrid } from "../src/tmux-grid.ts"; +import type { ControlEvent, TmuxGrid } from "../src/tmux-grid.ts"; import { createFakeTmux } from "./fixtures/fake-tmux.ts"; import type { FakeTmux } from "./fixtures/fake-tmux.ts"; -import { - layoutString, - placementProblems, - rowMajorCells, - swapsInto, -} from "../src/terminal/layout.ts"; -import type { LayoutCell } from "../src/terminal/layout.ts"; -import { usePaneChannels } from "../src/terminal/pane-channel.ts"; -import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/terminal/provider.ts"; -import { - foregroundTerminalGrid, - underHangup, - unsupportedTerminalGrid, -} from "../src/terminal/host.ts"; -import { - execute, - installTerminalProvider, - registerTerminalProvider, - useTerminalInstallation, -} from "@executablemd/core"; -import type { Json } from "@executablemd/core"; +import { clientCommand } from "./fixtures/client-command.ts"; +import { layoutString, placementProblems, rowMajorCells, swapsInto } from "../src/layout.ts"; +import type { LayoutCell } from "../src/layout.ts"; +import { usePaneChannels } from "../src/pane-channel.ts"; +import { createGridTeardown, runInPane, tmuxGridProvider } from "../src/provider.ts"; +import { installTerminalProvider, useTerminalInstallation } from "@executablemd/terminal/lifecycle"; +import { registerTerminalProvider } from "@executablemd/terminal"; import type { Result } from "effection"; -import { processTable, TerminalGrids } from "@executablemd/runtime"; +import { processTable } from "@executablemd/terminal/processes"; import { chmod, readdir } from "node:fs/promises"; import { InMemoryStream } from "@executablemd/durable-streams"; -import type { PaneChannels, PaneLink } from "../src/terminal/pane-channel.ts"; +import type { PaneChannels, PaneLink } from "../src/pane-channel.ts"; import { FromWorkerSchema, paneSocketPath, @@ -86,17 +70,17 @@ import { readFrames, ToWorkerSchema, writeFrame, -} from "../src/terminal/pane-protocol.ts"; +} from "../src/pane-protocol.ts"; import { foregroundSignalListeners, PANE_WORKER_COMMAND, paneWorkerInvocation, runPaneWorker, useForegroundSignals, -} from "../src/terminal/pane-worker.ts"; -import { usePaneChild } from "../src/terminal/pane-child.ts"; -import type { PaneChild, PaneChildOutcome } from "../src/terminal/pane-child.ts"; -import type { FromWorker, Settlement, ToWorker } from "../src/terminal/pane-protocol.ts"; +} from "../src/pane-worker.ts"; +import { usePaneChild } from "../src/pane-child.ts"; +import type { PaneChild, PaneChildOutcome } from "../src/pane-child.ts"; +import type { FromWorker, Settlement, ToWorker } from "../src/pane-protocol.ts"; /** The cells a layout string describes, read back out of it. */ function readCells(layout: string): LayoutCell[] { @@ -132,14 +116,6 @@ function useScript(): Operation { }); } -/** The fixture that stands in for one tmux client. */ -function clientCommand(mode: "control" | "attach", script: string): readonly string[] { - const fixture = path.resolve("packages/cli/tests/fixtures/tmux-client.ts"); - const invocation = cliCommand([]); - // The same runtime the CLI runs under, pointed at the fixture instead. - return [invocation.command, "run", "--allow-all", fixture, mode, script]; -} - /** Every listener this process holds, across the names this code installs. */ function processListeners(): number { return (["SIGINT", "SIGQUIT", "SIGTSTP", "SIGHUP"] as NodeJS.Signals[]).reduce( @@ -2128,92 +2104,6 @@ describe("Tier TD — the combined teardown", () => { } }); - it("TD9: a teardown that fails refuses the run, and nothing after the grid goes", function* () { - // The document-level end of the same claim: a grid whose teardown could not - // establish the terminal was given back is a failed run, not a run with a - // warning in it. - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - // The server refuses to be killed the first time it is asked, so the last - // phase of the teardown cannot establish it is gone. - const tmux = createFakeTmux({ - script, - clientCommand, - spawnPanes: true, - failOnce: { command: "kill-server", message: "refused" }, - }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - - yield* spawn(function* () { - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - while (tmux.clients.length === 0) { - yield* sleep(15); - } - yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - expect(outcome?.ok).toBe(false); - const refusal = outcome?.ok === false ? String(outcome.error) : ""; - expect(refusal).toContain("terminal server"); - // Nothing private in it, and nothing after the grid ran. - expect(refusal).not.toContain(room); - expect(output).not.toContain("AFTER_THE_GRID"); - }); - it("TD7: the combined order is the frozen one", function* () { const order: string[] = []; let directory = ""; @@ -2258,280 +2148,3 @@ describe("Tier TD — the combined teardown", () => { expect(at("paths-removed")).toBe(order.length - 1); }); }); - -/** One entrypoint's source, for the rows about what a host assembles. */ -function entrypointSource(name: string): Operation { - return readTextFile(path.resolve("packages/cli/src", name)); -} - -describe("Tier TH — host installation", () => { - it("TH1: without a terminal, a grid refuses before anything exists", function* () { - const before = yield* until(readdir(tmpdir())); - let refusal = ""; - try { - yield* scoped(function* () { - yield* installDenoTerminalProcesses(); - yield* useProbedProvider({ isTerminal: () => false }); - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - - expect(refusal).toContain("cannot open a terminal grid"); - expect(refusal).toContain("no terminal"); - // Before a directory, a socket, a token, a worker, a server or a pane: the - // host left nothing behind for having tried. - const after = yield* until(readdir(tmpdir())); - expect(after.filter((name) => name.startsWith("xmd-grid-")).length).toBe( - before.filter((name) => name.startsWith("xmd-grid-")).length, - ); - }); - - it("TH2: without a usable tmux, a grid refuses the same way", function* () { - let refusal = ""; - try { - yield* scoped(function* () { - yield* installDenoTerminalProcesses(); - yield* useProbedProvider({ - isTerminal: () => true, - // A tmux far too old for an explicit layout string. - version: "tmux 1.8", - }); - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("cannot open a terminal grid"); - expect(refusal).toContain("older than tmux"); - }); - - it("TH4: the installed SIGHUP listener cancels the run and tears the grid down", function* () { - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - // The run's foreground lease, which a grid takes before any provider. - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - const sighupBefore = foregroundSignalListeners("SIGHUP"); - let directory = ""; - let installed = 0; - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - directory = at; - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - // The listener is the installer's, and this row uses that one. - installed = foregroundSignalListeners("SIGHUP"); - - yield* spawn(function* () { - // Driven by the pane child's own start: the worker spawned, its channel - // authenticated, and the shell it launched said so. - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - process.kill(process.pid, "SIGHUP"); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - // The installer put its listener on, and took it off with the run. - expect(installed).toBe(sighupBefore + 1); - expect(foregroundSignalListeners("SIGHUP")).toBe(sighupBefore); - - // Cancellation, not a reader close: the run failed and nothing after the - // grid ran in that attempt. - expect(outcome?.ok).toBe(false); - expect(output).not.toContain("AFTER_THE_GRID"); - - // Every teardown phase completed before the result was observed. The pane's - // child is gone, the worker is gone, the server is gone, and the private - // directory — which is removed last, after its sockets have closed — is - // gone with them. - const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); - expect(shellPid).toBeGreaterThan(0); - yield* installDenoTerminalProcesses(); - expect(yield* processReachable(shellPid)).toBe(false); - // Awaited on each process's own exit event, not sampled: a worker that had - // not quite gone yet would make a sampled check pass or fail by timing. - for (const child of tmux.started) { - yield* exited(child); - } - expect(tmux.alive()).toBe(false); - expect(directory).not.toBe(""); - expect(yield* exists(directory)).toBe(false); - }); - - it("TH5: an ordinary run shows the grid, and the reader's detach ends it", function* () { - // The same host, the same document and the same live grid as TH4. What - // differs is the ending: the reader leaves rather than the terminal going - // away, so the grid settles and the document carries on — which is the - // branch `useHangupCancellation()` has to hand the result back through. - const room = yield* useScratch(); - const shell = yield* useShellFixture(room); - const script = yield* useScript(); - const invocation = cliCommand([]); - const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); - yield* ensure(() => { - tmux.stopPanes(); - }); - yield* writeTextFile( - path.join(room, "doc.md"), - [ - "", - '', - "", - "", - "AFTER_THE_GRID", - "", - ].join("\n"), - ); - yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); - - let directory = ""; - let outcome: Result | undefined; - let output = ""; - yield* scoped(function* () { - yield* foregroundTerminalGrid({ - isTerminal: () => true, - createTmux: () => tmux, - env: { PATH: "/usr/bin:/bin", SHELL: shell }, - // deno-lint-ignore require-yield - *askVersion() { - return { code: 0, stdout: "tmux 3.6a" }; - }, - workerCommand: function* (ordinal, at) { - directory = at; - return [ - invocation.command, - ...invocation.arguments, - PANE_WORKER_COMMAND, - String(ordinal), - at, - ]; - }, - })(); - - yield* spawn(function* () { - // Driven by the grid's own progress: the pane child started, and the - // server has a reader's client to report the detach of. No SIGHUP. - while (!(yield* exists(`${room}/shell-pid`))) { - yield* sleep(15); - } - while (tmux.clients.length === 0) { - yield* sleep(15); - } - yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); - }); - - const execution = yield* execute({ - path: path.join(room, "doc.md"), - stream: new InMemoryStream(), - includes: [room], - }); - const subscription = yield* execution.output; - let next = yield* subscription.next(); - while (!next.done) { - output = next.value; - next = yield* subscription.next(); - } - outcome = yield* execution; - }); - - // The exact result, handed back through the hangup wrapper rather than - // swallowed by it: a handler that answered with nothing would be refused - // for having returned before the document produced a result. - expect(outcome).toEqual(Ok("\n\nAFTER_THE_GRID\n")); - // The reader closed the grid; the document went on. - expect(output).toContain("AFTER_THE_GRID"); - - // And it went on over a grid that had actually been taken down: the pane's - // child, the workers, the server and the private directory are all gone. - const shellPid = Number((yield* readTextFile(`${room}/shell-pid`)).trim()); - expect(shellPid).toBeGreaterThan(0); - yield* installDenoTerminalProcesses(); - expect(yield* processReachable(shellPid)).toBe(false); - for (const child of tmux.started) { - yield* exited(child); - } - expect(tmux.alive()).toBe(false); - expect(directory).not.toBe(""); - expect(yield* exists(directory)).toBe(false); - }); - - it("TH6: the Deno and compiled entrypoints present grids; Node and Bun do not", function* () { - for (const name of ["deno.ts", "compiled.ts"]) { - expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid()")).toBe(true); - } - for (const name of ["node.ts", "bun.ts"]) { - // Not a different grid: no grid at all, and therefore the default the - // shared entry declares — which is the installation that validates a grid - // and presents none. - expect((yield* entrypointSource(name)).includes("foregroundTerminalGrid")).toBe(false); - } - expect(yield* entrypointSource("cli.ts")).toContain( - "installTerminalGrid: TerminalGridInstaller = unsupportedTerminalGrid", - ); - }); - - it("TH3: a host that installs no provider still validates the grid", function* () { - // Node and Bun: the same language and the same validation, and core's own - // refusal rather than a provider that half-works. - yield* unsupportedTerminalGrid(); - let refusal = ""; - try { - yield* TerminalGrids.operations.open({ - columns: 1, - rows: 1, - panes: [{ ordinal: 0, title: "Only", row: 0, column: 0, form: "paired" }], - }); - } catch (error) { - refusal = error instanceof Error ? error.message : String(error); - } - expect(refusal).toContain("no terminal provider is installed"); - }); -}); diff --git a/packages/terminal/deno.json b/packages/terminal/deno.json new file mode 100644 index 000000000..67f8b1665 --- /dev/null +++ b/packages/terminal/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@executablemd/terminal", + "version": "0.11.0", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test.ts" + } +} diff --git a/packages/terminal/lifecycle.ts b/packages/terminal/lifecycle.ts new file mode 100644 index 000000000..f0a699ce9 --- /dev/null +++ b/packages/terminal/lifecycle.ts @@ -0,0 +1,53 @@ +/** + * Driving one provider through one grid's life + * (architecture.md §Package ownership). + * + * The direct authority a host installs, the claims and readiness a grid passes + * through before anything is shown, the row-major layout an author's `columns` + * implies, the live and durable grid itself, what it retains, and the + * reader-close boundary that ends it. A facet of `@executablemd/terminal`: what + * it shares with the root is the same object, not a copy. + */ + +export { + awaitReadiness, + createGridRegistry, + createTerminalAuthority, + createTerminalGridClaims, + sealOnTeardown, + TerminalAuthorityError, + terminalInstallation, + useTerminalInstallation, +} from "./src/authority.ts"; +export type { + GridRegistry, + LiveGrid, + PaneReadiness, + TerminalGridAuthority, + TerminalGridClaims, + TerminalInstallation, + TerminalPaneClaim, +} from "./src/authority.ts"; + +export { installTerminalProvider } from "./src/provider-api.ts"; + +export { + createCloseBoundary, + durableGrid, + openTerminalGrid, + paneNeverStartedMessage, + retainedLayout, + toRequest, +} from "./src/grid.ts"; +export type { + CloseBoundary, + GridCloseKind, + PaneStatus, + PaneWork, + RetainedGrid, + RetainedPane, + RetainedPaneOutcome, +} from "./src/grid.ts"; + +export { terminalGridLayout } from "./src/layout.ts"; +export type { PlacedPane, TerminalGridCell, TerminalGridLayout } from "./src/layout.ts"; diff --git a/packages/terminal/mod.ts b/packages/terminal/mod.ts new file mode 100644 index 000000000..92c506b26 --- /dev/null +++ b/packages/terminal/mod.ts @@ -0,0 +1,64 @@ +/** + * The provider-neutral terminal domain (architecture.md §Package ownership). + * + * Everything here is what a document means by a terminal, independent of what + * presents one: a native launch that wants the foreground, a grid of panes and + * the states they pass through, the routing that finds whichever provider a + * host installed, and the errors a caller meets when none did. No multiplexer, + * socket, process topology or window identifier appears in this package. + * + * The lifecycle a provider is driven through lives in `./lifecycle`, process + * observation in `./processes`, the POSIX adapters in `./posix`, and the + * controlled fixtures that prove the contract in `./test` — facets of one + * package rather than separate definitions, so a symbol exported by two of them + * is the same object. + */ + +export { + flushOutput, + NATIVE_LAUNCHER_UNAVAILABLE, + NativeLauncher, + NativeLauncherUnavailableError, + nativeLaunch, + NO_TERMINAL, + reserveTerminal, +} from "./src/launcher.ts"; +export type { + NativeLauncherHandler, + NativeLaunchOutcome, + NativeLaunchRequest, +} from "./src/launcher.ts"; + +export { + TERMINAL_GRIDS_API, + TERMINAL_PROVIDER_UNAVAILABLE, + TerminalGrids, + TerminalProviderUnavailableError, +} from "./src/terminal.ts"; +export type { + TerminalComposite, + TerminalGridApi, + TerminalGridRequest, + TerminalPaneRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "./src/terminal.ts"; + +export { + registerTerminalProvider, + TERMINAL_PROVIDERS_API, + TerminalProviderInstallError, + TerminalProviders, +} from "./src/provider-api.ts"; +export type { + TerminalProviderApi, + TerminalProviderCall, + TerminalProviderFactory, + TerminalProviderInstallRequest, + TerminalProviderOptions, +} from "./src/provider-api.ts"; + +export { paneTerminal, usePaneTerminal } from "./src/pane.ts"; +export type { PaneTerminal } from "./src/pane.ts"; +export { usePaneNativeLauncher } from "./src/pane-launcher.ts"; +export type { RunInPane } from "./src/pane-launcher.ts"; diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 000000000..67d68ea7d --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,21 @@ +{ + "name": "@executablemd/terminal", + "version": "0.11.0", + "description": "The provider-neutral terminal domain for executable.md documents.", + "type": "module", + "exports": { + ".": "./mod.ts", + "./lifecycle": "./lifecycle.ts", + "./processes": "./processes.ts", + "./posix": "./posix.ts", + "./test": "./test.ts" + }, + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0" + } +} diff --git a/packages/terminal/posix.ts b/packages/terminal/posix.ts new file mode 100644 index 000000000..9331485af --- /dev/null +++ b/packages/terminal/posix.ts @@ -0,0 +1,18 @@ +/** + * What a POSIX host can actually observe and hand over + * (architecture.md §Package ownership). + * + * The process table, process groups, signals, reachability and terminal holders + * as `ps`, `lsof` and `kill` answer them, plus the foreground child that gives + * a native program this run's own terminal. It lives here rather than in a + * presentation provider because a second POSIX provider should reuse the same + * proof without depending on tmux. + * + * Node and Bun install none of it: a host that cannot observe a pane refuses a + * grid rather than reporting one free it never checked. + */ + +export { installForegroundLauncher } from "./src/launcher.ts"; +export type { ForegroundLauncherOptions } from "./src/launcher.ts"; +export { installDenoTerminalProcesses, posixProcessProbes } from "./src/posix-processes.ts"; +export type { ProcessProbes } from "./src/posix-processes.ts"; diff --git a/packages/terminal/processes.ts b/packages/terminal/processes.ts new file mode 100644 index 000000000..e569c85cd --- /dev/null +++ b/packages/terminal/processes.ts @@ -0,0 +1,31 @@ +/** + * What a host may establish about processes and terminals + * (architecture.md §Package ownership). + * + * The contract only. Every answer is a host's, installed through + * `./posix` or by a suite that supplies its own, and every path fails closed: + * a question that could not be answered is never read as "nothing is there". + */ + +export { + deliverSignal, + descendantsOf, + establishQuiescence, + groupMembers, + paneOccupants, + processReachable, + processTable, + TERMINAL_PROCESSES_API, + TERMINAL_PROCESSES_UNAVAILABLE, + TerminalProcesses, + TerminalProcessesUnavailableError, + terminalHolders, +} from "./src/processes.ts"; +export type { + PaneOccupants, + PaneQuiescence, + ProcessFacts, + SignalDelivery, + TerminalProcessHandler, + TerminalSignal, +} from "./src/processes.ts"; diff --git a/packages/core/src/terminal/authority.ts b/packages/terminal/src/authority.ts similarity index 99% rename from packages/core/src/terminal/authority.ts rename to packages/terminal/src/authority.ts index 64e11fce3..358ee4579 100644 --- a/packages/core/src/terminal/authority.ts +++ b/packages/terminal/src/authority.ts @@ -21,7 +21,7 @@ import { all, createContext, ensure, withResolvers } from "effection"; import type { Context, Operation } from "effection"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import type { TerminalComposite, TerminalGridRequest } from "./terminal.ts"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; diff --git a/packages/core/src/terminal/grid.ts b/packages/terminal/src/grid.ts similarity index 98% rename from packages/core/src/terminal/grid.ts rename to packages/terminal/src/grid.ts index f96fb091a..f44468efa 100644 --- a/packages/core/src/terminal/grid.ts +++ b/packages/terminal/src/grid.ts @@ -42,8 +42,9 @@ import { ephemeral, } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; -import { flushOutput, reserveTerminal, TerminalGrids } from "@executablemd/runtime"; -import type { TerminalComposite, TerminalGridRequest } from "@executablemd/runtime"; +import { TerminalGrids } from "./terminal.ts"; +import { flushOutput, reserveTerminal } from "./launcher.ts"; +import type { TerminalComposite, TerminalGridRequest } from "./terminal.ts"; import { awaitReadiness, @@ -52,7 +53,7 @@ import { terminalInstallation, } from "./authority.ts"; import type { LiveGrid, TerminalPaneClaim } from "./authority.ts"; -import type { TerminalGridLayout } from "../terminal-grid.ts"; +import type { TerminalGridLayout } from "./layout.ts"; /** * The live boundary reader close crosses (architecture.md §Atomic presentation diff --git a/packages/runtime/launcher.ts b/packages/terminal/src/launcher.ts similarity index 99% rename from packages/runtime/launcher.ts rename to packages/terminal/src/launcher.ts index 40068cf2e..c1196e0e6 100644 --- a/packages/runtime/launcher.ts +++ b/packages/terminal/src/launcher.ts @@ -148,7 +148,7 @@ const REAP_POLL_MS = 25; /** How long an unanswerable kill is given before the child is called gone. */ const KILL_SETTLE_MS = 500; -interface ForegroundLauncherOptions { +export interface ForegroundLauncherOptions { /** * Whether this host can hand a child the terminal. Read once, when the * launcher installs, so a run learns what it is before a document starts. diff --git a/packages/core/src/terminal-grid.ts b/packages/terminal/src/layout.ts similarity index 85% rename from packages/core/src/terminal-grid.ts rename to packages/terminal/src/layout.ts index 59a08a920..8022dd3be 100644 --- a/packages/core/src/terminal-grid.ts +++ b/packages/terminal/src/layout.ts @@ -12,7 +12,14 @@ * rows that many panes fill, and which cell each pane occupies. */ -import type { TerminalPane } from "./structural-rules.ts"; +/** + * Whether a pane runs the markdown it holds or the host's default shell. + * + * Declared here rather than imported: the layout is provider-neutral data, and + * core's authored pane — which carries the element it was written as — would + * point this package back at the document engine it is placed for. + */ +export type PaneForm = "paired" | "self-closing"; /** One pane, placed. */ export interface TerminalGridCell { @@ -25,7 +32,7 @@ export interface TerminalGridCell { /** The label it displays. Two cells may carry the same one. */ readonly title: string; /** Whether it runs the markdown the pane holds or the host's default shell. */ - readonly form: TerminalPane["form"]; + readonly form: PaneForm; } /** The complete grid one `` asked for. */ @@ -40,7 +47,7 @@ export interface TerminalGridLayout { /** One pane's placeable facts, once its title has been resolved. */ export interface PlacedPane { readonly title: string; - readonly form: TerminalPane["form"]; + readonly form: PaneForm; } /** diff --git a/packages/core/src/terminal/pane-launcher.ts b/packages/terminal/src/pane-launcher.ts similarity index 96% rename from packages/core/src/terminal/pane-launcher.ts rename to packages/terminal/src/pane-launcher.ts index bce408d0f..bb01347dd 100644 --- a/packages/core/src/terminal/pane-launcher.ts +++ b/packages/terminal/src/pane-launcher.ts @@ -22,8 +22,8 @@ import { resource } from "effection"; import type { Operation } from "effection"; -import { NativeLauncher } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import { NativeLauncher } from "./launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; import type { TerminalPaneClaim } from "./authority.ts"; diff --git a/packages/core/src/terminal/pane.ts b/packages/terminal/src/pane.ts similarity index 100% rename from packages/core/src/terminal/pane.ts rename to packages/terminal/src/pane.ts diff --git a/packages/runtime/deno-terminal-processes.ts b/packages/terminal/src/posix-processes.ts similarity index 99% rename from packages/runtime/deno-terminal-processes.ts rename to packages/terminal/src/posix-processes.ts index 375715416..1e9e4067b 100644 --- a/packages/runtime/deno-terminal-processes.ts +++ b/packages/terminal/src/posix-processes.ts @@ -23,8 +23,8 @@ import { until } from "effection"; import type { Operation } from "effection"; import { execFile } from "node:child_process"; import process from "node:process"; -import { TerminalProcesses, TerminalProcessesUnavailableError } from "./terminal-processes.ts"; -import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./terminal-processes.ts"; +import { TerminalProcesses, TerminalProcessesUnavailableError } from "./processes.ts"; +import type { ProcessFacts, SignalDelivery, TerminalSignal } from "./processes.ts"; /** What one observation ran, so a suite can answer for it. */ export interface ProcessProbes { diff --git a/packages/runtime/terminal-processes.ts b/packages/terminal/src/processes.ts similarity index 100% rename from packages/runtime/terminal-processes.ts rename to packages/terminal/src/processes.ts diff --git a/packages/core/src/terminal/provider-api.ts b/packages/terminal/src/provider-api.ts similarity index 100% rename from packages/core/src/terminal/provider-api.ts rename to packages/terminal/src/provider-api.ts diff --git a/packages/runtime/terminal.ts b/packages/terminal/src/terminal.ts similarity index 100% rename from packages/runtime/terminal.ts rename to packages/terminal/src/terminal.ts diff --git a/packages/terminal/test.ts b/packages/terminal/test.ts new file mode 100644 index 000000000..05abdf8ec --- /dev/null +++ b/packages/terminal/test.ts @@ -0,0 +1,18 @@ +/** + * Controlled surfaces that prove the neutral contract without a provider + * (architecture.md §Package ownership). + * + * A launcher that hands out no terminal, a composite that presents nothing, and + * a log whose counters are the evidence a lifecycle row reads. Production code + * imports none of it; these exist so core lifecycle semantics can be proved + * without tmux, a terminal, or a subprocess. + */ + +export { installControlledLauncher } from "./src/launcher.ts"; +export type { ControlledLauncherOptions } from "./src/launcher.ts"; +export { prepareControlledComposite, terminalProviderLog } from "./src/terminal.ts"; +export type { + ControlledCompositeOptions, + TerminalProviderLog, + TerminalProviderResources, +} from "./src/terminal.ts"; diff --git a/packages/runtime/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts similarity index 99% rename from packages/runtime/tests/native-launcher.test.ts rename to packages/terminal/tests/native-launcher.test.ts index 1d0bc69de..1da220a0f 100644 --- a/packages/runtime/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -31,7 +31,7 @@ import { NO_TERMINAL, reap, reserveTerminal, -} from "../launcher.ts"; +} from "../src/launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; diff --git a/packages/terminal/tests/package-boundary.test.ts b/packages/terminal/tests/package-boundary.test.ts new file mode 100644 index 000000000..8590bf387 --- /dev/null +++ b/packages/terminal/tests/package-boundary.test.ts @@ -0,0 +1,272 @@ +/** + * Tier TG21 — the package boundary, and the absence of the paths it replaced + * (architecture.md §Package ownership, DEC-016). + * + * The stack has not merged, so the terminal exports that used to sit in + * runtime, core and CLI were never a compatibility surface — they were the + * ownership ambiguity this extraction removes. They are gone, and these rows + * are what keeps them gone. + * + * Three claims, each failing differently if the extraction regresses. + * + * Structural: the dependency arrows point at the neutral domain, so a provider + * can be written without CLI or tmux and the domain consumed without either. + * A violation is an import statement, so the evidence is the import statements + * themselves — read from the production sources rather than inferred from a + * manifest, because a manifest records what was declared and a source records + * what is actually reached. + * + * Absence: the old modules, the old exports and the old CLI implementation + * path are not merely unused but not there. An unused forwarding barrel is + * exactly the thing that lets an import drift back. + * + * Uniqueness: each contextual descriptor and public error constructor is + * defined once. These are matched with `instanceof` and carry middleware, so a + * second definition would not fail loudly — it would split composition between + * two objects that behave alike, which is the failure this tier exists to make + * impossible rather than merely unlikely. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { exists, readTextFile } from "@effectionx/fs"; +import { readdir } from "node:fs/promises"; +import * as path from "node:path"; +import { until } from "effection"; +import type { Operation } from "effection"; + +/** Every production source of one workspace package, tests excluded. */ +function* productionSources(pkg: string): Operation { + const root = path.resolve("packages", pkg); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; + } + const full = path.join(entry.parentPath ?? root, entry.name); + const relative = path.relative(root, full); + // Tests prove the contract; they do not define the shipped graph. A row may + // reach across packages to drive a fixture without that being a dependency + // of the artifact. + if (relative.startsWith("tests/") || relative.includes(".test.")) { + continue; + } + files.push(full); + } + return files; +} + +/** The package specifiers one source imports from, bare names only. */ +function specifiersOf(source: string): string[] { + const found: string[] = []; + for (const match of source.matchAll(/(?:^|\n)\s*(?:import|export)[^;]*?from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier !== undefined && !specifier.startsWith(".")) { + found.push(specifier); + } + } + return found; +} + +/** Which workspace packages `pkg`'s production code actually imports. */ +function* importsOf(pkg: string): Operation> { + const reached = new Set(); + for (const file of yield* productionSources(pkg)) { + for (const specifier of specifiersOf(yield* readTextFile(file))) { + if (specifier.startsWith("@executablemd/")) { + // `@executablemd/terminal/posix` is the terminal package. + reached.add(specifier.split("/").slice(0, 2).join("/")); + } + } + } + return reached; +} + +/** Every `.ts` file in the repository's packages, tests included. */ +function* everySource(): Operation { + const root = path.resolve("packages"); + const files: string[] = []; + const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(path.join(entry.parentPath ?? root, entry.name)); + } + } + return files; +} + +/** The names the terminal domain owns, whatever path someone might reach for. */ +const TERMINAL_EXPORTS = [ + "NativeLauncher", + "nativeLaunch", + "reserveTerminal", + "flushOutput", + "installForegroundLauncher", + "installControlledLauncher", + "TerminalGrids", + "TerminalProviders", + "TerminalProcesses", + "registerTerminalProvider", + "installTerminalProvider", + "useTerminalInstallation", + "paneTerminal", + "prepareControlledComposite", + "terminalProviderLog", + "installDenoTerminalProcesses", + "processTable", + "processReachable", +] as const; + +describe("Tier TG21 — the terminal package boundary", () => { + it("TG21a: the neutral domain reaches no engine, host or provider", function* () { + const reached = yield* importsOf("terminal"); + // The whole point of the extraction: a provider or a consumer takes the + // domain without taking the document engine, the CLI, or tmux with it. + for (const forbidden of [ + "@executablemd/runtime", + "@executablemd/core", + "@executablemd/cli", + "@executablemd/terminal-tmux", + ]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21b: the tmux adapter reaches the domain and nothing above it", function* () { + const reached = yield* importsOf("terminal-tmux"); + expect(reached.has("@executablemd/terminal")).toBe(true); + for (const forbidden of ["@executablemd/runtime", "@executablemd/core", "@executablemd/cli"]) { + expect([forbidden, reached.has(forbidden)]).toEqual([forbidden, false]); + } + }); + + it("TG21c: runtime owns no terminal dependency, and only CLI composes both", function* () { + // The amendment's load-bearing change: runtime keeps no terminal edge at + // all, in its sources or its manifest, because there is no unreleased path + // left for it to keep alive. + expect((yield* importsOf("runtime")).has("@executablemd/terminal")).toBe(false); + const manifest = yield* readTextFile(path.resolve("packages/runtime/package.json")); + expect(manifest.includes("@executablemd/terminal")).toBe(false); + + expect((yield* importsOf("core")).has("@executablemd/terminal")).toBe(true); + // Core is the document engine, not a host: it never selects a provider. + expect((yield* importsOf("core")).has("@executablemd/terminal-tmux")).toBe(false); + const cli = yield* importsOf("cli"); + for (const required of [ + "@executablemd/core", + "@executablemd/runtime", + "@executablemd/terminal", + "@executablemd/terminal-tmux", + ]) { + expect([required, cli.has(required)]).toEqual([required, true]); + } + }); + + it("TG21d: a walked package with no sources would not pass vacuously", function* () { + // The rows above are absence claims, and an absence claim over an empty set + // is free. This is the discriminator: the walk finds real files. + expect((yield* productionSources("terminal")).length).toBeGreaterThan(10); + expect((yield* productionSources("terminal-tmux")).length).toBeGreaterThan(8); + expect((yield* everySource()).length).toBeGreaterThan(100); + }); +}); + +describe("Tier TG21 — the replaced paths are absent", () => { + it("TG21e: no old terminal module remains where it used to live", function* () { + // Deleted rather than emptied. A module that still resolves is a path an + // import can drift back onto, whether or not anything uses it today. + for (const gone of [ + "packages/runtime/launcher.ts", + "packages/runtime/terminal.ts", + "packages/runtime/terminal-processes.ts", + "packages/runtime/deno-terminal-processes.ts", + "packages/core/src/terminal-grid.ts", + "packages/core/src/terminal/authority.ts", + "packages/core/src/terminal/provider-api.ts", + "packages/core/src/terminal/grid.ts", + "packages/core/src/terminal/pane.ts", + "packages/core/src/terminal/pane-launcher.ts", + "packages/cli/src/terminal", + ]) { + expect([gone, yield* exists(path.resolve(gone))]).toEqual([gone, false]); + } + }); + + it("TG21f: runtime and core export none of the terminal domain", function* () { + const runtime = yield* until(import("@executablemd/runtime")); + const core = yield* until(import("@executablemd/core")); + for (const name of TERMINAL_EXPORTS) { + expect([`runtime.${name}`, name in runtime]).toEqual([`runtime.${name}`, false]); + expect([`core.${name}`, name in core]).toEqual([`core.${name}`, false]); + } + // What core does still own is the profile that composes a grid into an + // `Execution` — the adaptation, not the domain. + expect("installTerminalGridProfile" in core).toBe(true); + }); + + it("TG21g: every repository terminal import names a canonical surface", function* () { + // The complement of TG21f. An export that is gone cannot be imported, but a + // *type-only* import of a vanished name fails at typecheck rather than + // here, and this row is what says where such an import would have to move. + const offenders: string[] = []; + for (const file of yield* everySource()) { + const source = yield* readTextFile(file); + for (const match of source.matchAll( + /(?:^|\n)\s*(?:import|export)[^;]*?from\s+"(@executablemd\/(?:runtime|core))"/g, + )) { + const statement = match[0]; + for (const name of TERMINAL_EXPORTS) { + if (new RegExp(`\\b${name}\\b`).test(statement)) { + offenders.push(`${path.relative(path.resolve("packages"), file)}: ${name}`); + } + } + } + } + expect(offenders).toEqual([]); + }); + + it("TG21h: each descriptor and public error constructor is defined once", function* () { + // Identity used to be provable by comparing two import paths. With one path + // left, the claim that replaces it is that there is only one definition to + // reach — so a second `createApi` or a second class cannot quietly appear + // and split middleware composition between two objects that behave alike. + const sources = yield* everySource(); + const definitions = new Map(); + // Any exported class, not just one whose name ends in `Error`: + // `TerminalTeardownFailed` is a refusal too, and a scan that keyed on the + // suffix would have reported it as having no definition at all. + const declared = /export\s+(?:const\s+(\w+)\s*(?::[^=]+)?=\s*createApi|class\s+(\w+))/g; + for (const file of sources) { + for (const match of (yield* readTextFile(file)).matchAll(declared)) { + const name = match[1] ?? match[2]; + if (name === undefined) { + continue; + } + definitions.set(name, [ + ...(definitions.get(name) ?? []), + path.relative(path.resolve("packages"), file), + ]); + } + } + + for (const name of [ + "NativeLauncher", + "TerminalGrids", + "TerminalProviders", + "TerminalProcesses", + "NativeLauncherUnavailableError", + "TerminalProviderUnavailableError", + "TerminalProcessesUnavailableError", + "TerminalProviderInstallError", + "TerminalAuthorityError", + "TmuxUnavailableError", + "TerminalTeardownFailed", + ]) { + expect([name, definitions.get(name) ?? []]).toEqual([name, [expect.any(String)]]); + } + // And the scan is not vacuous: it found the descriptors it was told to look + // for, in the package that owns them. + expect(definitions.get("NativeLauncher")?.[0]).toContain("terminal/src/launcher.ts"); + expect(definitions.get("TerminalProcesses")?.[0]).toContain("terminal/src/processes.ts"); + }); +}); diff --git a/packages/runtime/tests/terminal-processes.test.ts b/packages/terminal/tests/terminal-processes.test.ts similarity index 98% rename from packages/runtime/tests/terminal-processes.test.ts rename to packages/terminal/tests/terminal-processes.test.ts index 31e2ff77d..08d0c17ff 100644 --- a/packages/runtime/tests/terminal-processes.test.ts +++ b/packages/terminal/tests/terminal-processes.test.ts @@ -28,10 +28,10 @@ import { TERMINAL_PROCESSES_UNAVAILABLE, TerminalProcesses, terminalHolders, -} from "../terminal-processes.ts"; -import { installDenoTerminalProcesses } from "../deno-terminal-processes.ts"; -import type { ProcessProbes } from "../deno-terminal-processes.ts"; -import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../mod.ts"; +} from "../src/processes.ts"; +import { installDenoTerminalProcesses } from "../src/posix-processes.ts"; +import type { ProcessProbes } from "../src/posix-processes.ts"; +import type { PaneOccupants, ProcessFacts, SignalDelivery, TerminalSignal } from "../processes.ts"; /** A table written by hand, so a row can describe a machine it is not on. */ function table(rows: readonly Partial[]): readonly ProcessFacts[] { diff --git a/packages/runtime/tests/terminal-provider.test.ts b/packages/terminal/tests/terminal-provider.test.ts similarity index 98% rename from packages/runtime/tests/terminal-provider.test.ts rename to packages/terminal/tests/terminal-provider.test.ts index 3c88c9d83..162c38ac3 100644 --- a/packages/runtime/tests/terminal-provider.test.ts +++ b/packages/terminal/tests/terminal-provider.test.ts @@ -25,8 +25,8 @@ import { TerminalGrids, terminalProviderLog, TerminalProviderUnavailableError, -} from "../terminal.ts"; -import type { TerminalGridRequest } from "../terminal.ts"; +} from "../src/terminal.ts"; +import type { TerminalGridRequest } from "../src/terminal.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { diff --git a/packages/test-agent/package.json b/packages/test-agent/package.json index 856665edf..f1cf70228 100644 --- a/packages/test-agent/package.json +++ b/packages/test-agent/package.json @@ -15,6 +15,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", "acpx": "0.12.0", diff --git a/packages/test-agent/src/child-configuration.ts b/packages/test-agent/src/child-configuration.ts index 321e56e5f..8447867f9 100644 --- a/packages/test-agent/src/child-configuration.ts +++ b/packages/test-agent/src/child-configuration.ts @@ -37,7 +37,7 @@ import type { AgentComponentsOptions, AgentProviderOptions, Json } from "@execut import { createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { installInvocationAgentProvider } from "@executablemd/core/host"; -import { installControlledLauncher } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import type { ChildDeclaration, ChildDeclarationChild, diff --git a/packages/test-agent/src/components.ts b/packages/test-agent/src/components.ts index 5340d3b11..b01a2f65a 100644 --- a/packages/test-agent/src/components.ts +++ b/packages/test-agent/src/components.ts @@ -42,7 +42,8 @@ import { import type { ErrorSegment, Json, PropsSchema, Segment } from "@executablemd/core"; import { createMemorySessionRouteStore, createPartitionedAcpxProvider } from "@executablemd/acp"; import type { AcpxProvider, SessionRouteContext } from "@executablemd/acp"; -import { command, installControlledLauncher, readTextFile } from "@executablemd/runtime"; +import { command, readTextFile } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; import { Test } from "@executablemd/testing"; import { NativeLaunchObserver, useTestAgentController } from "./controller.ts"; import type { ScenarioHandle, TestAgentControllerInternals } from "./controller.ts"; diff --git a/packages/test-agent/src/controller.ts b/packages/test-agent/src/controller.ts index f27327c79..157be43dc 100644 --- a/packages/test-agent/src/controller.ts +++ b/packages/test-agent/src/controller.ts @@ -20,7 +20,7 @@ import { isAbsolute, relative, resolve, sep } from "node:path"; // node:fs/promises primitive directly. import { realpath } from "node:fs/promises"; import { readTextFile, stat } from "@executablemd/runtime"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/runtime"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "@executablemd/terminal"; import type { DurableEvent } from "@executablemd/durable-streams"; import { encodeMessage, formatRoute, parseWorkerMessage, PROBE_INSTANCE } from "./protocol.ts"; import type { ControllerMessage, WorkerMessage } from "./protocol.ts"; diff --git a/packages/test-agent/tests/native-launch.test.ts b/packages/test-agent/tests/native-launch.test.ts index 3f274d040..f21356c40 100644 --- a/packages/test-agent/tests/native-launch.test.ts +++ b/packages/test-agent/tests/native-launch.test.ts @@ -25,8 +25,9 @@ import * as os from "node:os"; import { installAgentComponents } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; -import { API, installControlledLauncher, useHostFiles } from "@executablemd/runtime"; -import type { NativeLaunchRequest } from "@executablemd/runtime"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { installControlledLauncher } from "@executablemd/terminal/test"; +import type { NativeLaunchRequest } from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; 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 87341cfb2..52d0c724f 100644 --- a/packages/test-agent/tests/terminal-grid-native-launch.test.ts +++ b/packages/test-agent/tests/terminal-grid-native-launch.test.ts @@ -30,25 +30,23 @@ import { agentIdentityComponents, installAgentComponents, installTerminalGridProfile, - registerTerminalProvider, useTempFileCompiler, } from "@executablemd/core"; import { executeInstalled } from "@executablemd/core/host"; import type { Json } from "@executablemd/core"; +import { API, useHostFiles } from "@executablemd/runtime"; +import { registerTerminalProvider, TerminalGrids } from "@executablemd/terminal"; import { - API, installControlledLauncher, prepareControlledComposite, - TerminalGrids, terminalProviderLog, - useHostFiles, -} from "@executablemd/runtime"; +} from "@executablemd/terminal/test"; import type { NativeLaunchOutcome, NativeLaunchRequest, TerminalGridRequest, TerminalPaneState, -} from "@executablemd/runtime"; +} from "@executablemd/terminal"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; import { installTestAgentComponents } from "../src/components.ts"; diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 89105d529..5a935ee94 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -643,11 +643,17 @@ const DENO_ONLY_REPOSITORY_PROVIDER: RuntimeExclusion[] = [ */ const DENO_ONLY_TERMINAL_GRID: RuntimeExclusion[] = [ { - path: "packages/cli/tests/terminal-grid-tmux.test.ts", + path: "packages/terminal-tmux/tests/terminal-grid-tmux.test.ts", reason: "the subject is the tmux provider, whose panes are this executable re-invoked as `terminal-worker` — a subcommand only the grid-presenting entrypoints register; under Node and Bun that vector names a document instead, so the worker exits with ENOENT and the pane's admission never completes", issue: DERIVED_SCOPE, }, + { + path: "packages/cli/tests/terminal-host.test.ts", + reason: + "the host rows open a real grid through the tmux provider, so they spawn the same `terminal-worker` re-invocation; on Node and Bun that vector names a document and the pane never reports, exactly as for the adapter's own suite", + issue: DERIVED_SCOPE, + }, ]; const BUN_MISSING_NODE_SQLITE: RuntimeExclusion[] = [ From 8d6de0242a8a43631840da784d66a2b75139ca37 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 00:05:41 -0400 Subject: [PATCH 41/62] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Split=20the=20termin?= =?UTF-8?q?al=20facets,=20narrow=20the=20tmux=20root,=20complete=20the=20l?= =?UTF-8?q?ock=20state=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Architect blockers on 3c10bc59. **The root was a facade.** `@executablemd/terminal`'s root re-exported a handful of neutral names out of modules that also spawned children and carried test doubles, so importing the domain loaded `node:child_process`, `node:process` and a fixture. Selective re-export narrows what is reachable by name and nothing about what is loaded. `launcher.ts` and `terminal.ts` are now five modules: `native-launcher.ts` and `composite.ts` hold the contracts and import no host API, `posix-launcher.ts` holds the foreground child, and `controlled-launcher.ts`/`controlled-composite.ts` hold the fixtures. Root, `./lifecycle` and `./processes` load none of the latter three; `./posix` and `./test` are where that code lives. Each descriptor and error still has exactly one definition — the split moved implementations, it declared nothing twice. **The tmux root was wider than its accepted API.** `paneEnvironment` decided which of *this invocation's* environment variables a pane inherits, which is a host's decision and not the adapter's; the adapter only passes along whatever `TmuxProviderDependencies.env` it is handed. It moves to CLI host composition beside its single caller, with the same allowlist, the same order and the same `TERM` default, so no pane's environment changes. **The lock state was incomplete.** The previous commit added two workspace members but only ran the Deno install, so `pnpm-lock.yaml` and `bun.lock` had no importer for either. Repaired through the documented procedure — `deno install --frozen=false`, then `deno task setup`, then `bun install`. Both locks now carry `packages/terminal` and `packages/terminal-tmux` and the five direct edges to terminal (acp, cli, core, terminal-tmux, test-agent); runtime has none, in its sources, its manifest and both locks. `publish-packages.yml` regenerates byte-identical, so the committed copy is already correct rather than corrected here. TG21 gains three rows for what the findings exposed. TG21i reads each entrypoint's transitive module graph rather than its export list — the facade passed an export check and fails this one — and TG21j is its discriminator, so the absence claim cannot pass over a graph emptied by deletion. TG21k pins the tmux root as an exact set rather than a set of required names, because `paneEnvironment` reached that root by being added to it and a required-names check would have let it stay. Probed: re-exporting the POSIX launcher from the root fails TG21i; adding one name to the tmux root fails TG21k. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS --- bun.lock | 46 +++- packages/cli/src/grid-host.ts | 24 +- packages/terminal-tmux/mod.ts | 7 +- packages/terminal-tmux/src/tmux.ts | 20 -- packages/terminal/mod.ts | 15 +- packages/terminal/posix.ts | 4 +- packages/terminal/src/authority.ts | 2 +- .../src/{terminal.ts => composite.ts} | 186 +--------------- packages/terminal/src/controlled-composite.ts | 206 +++++++++++++++++ packages/terminal/src/controlled-launcher.ts | 84 +++++++ packages/terminal/src/grid.ts | 6 +- packages/terminal/src/native-launcher.ts | 139 ++++++++++++ packages/terminal/src/pane-launcher.ts | 4 +- .../src/{launcher.ts => posix-launcher.ts} | 209 ++---------------- packages/terminal/test.ts | 8 +- .../terminal/tests/native-launcher.test.ts | 4 +- .../terminal/tests/package-boundary.test.ts | 104 ++++++++- .../terminal/tests/terminal-provider.test.ts | 7 +- pnpm-lock.yaml | 54 +++++ 19 files changed, 698 insertions(+), 431 deletions(-) rename packages/terminal/src/{terminal.ts => composite.ts} (56%) create mode 100644 packages/terminal/src/controlled-composite.ts create mode 100644 packages/terminal/src/controlled-launcher.ts create mode 100644 packages/terminal/src/native-launcher.ts rename packages/terminal/src/{launcher.ts => posix-launcher.ts} (58%) diff --git a/bun.lock b/bun.lock index 26a860d97..7980dedb1 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "mdast-util-to-string": "^4", "remark": "15", "remend": "^1.2.2", + "semver": "^7.8.5", "unist-util-select": "^5", "zod": "^4.3.6", }, @@ -42,6 +43,7 @@ "@executablemd/testing": "workspace:*", "@executablemd/workflow": "workspace:*", "@types/node": "^22.0.0", + "@types/semver": "^7.7.0", "expect": "^30.0.0", "oxfmt": "^0.41.0", "oxlint": "1.74.0", @@ -53,8 +55,10 @@ "name": "@executablemd/acp", "version": "0.11.0", "dependencies": { + "@agentclientprotocol/sdk": "1.3.0", "@executablemd/core": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "acpx": "0.12.0", "effection": "4.1.0", }, @@ -71,6 +75,8 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", + "@executablemd/terminal-tmux": "workspace:*", "@executablemd/test-agent": "workspace:*", "@executablemd/testing": "workspace:*", "@executablemd/web": "workspace:*", @@ -78,6 +84,7 @@ "@standard-schema/spec": "^1.0.0", "configliere": "^0.4.0", "effection": "4.1.0", + "semver": "^7.8.5", "zod": "^4.3.6", }, }, @@ -101,6 +108,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@secretlint/core": "13.0.4", "@secretlint/profiler": "13.0.4", "@secretlint/secretlint-rule-preset-recommend": "13.0.4", @@ -137,6 +145,29 @@ "effection": "4.1.0", }, }, + "packages/terminal": { + "name": "@executablemd/terminal", + "version": "0.11.0", + "dependencies": { + "@effectionx/context-api": "0.6.0", + "@effectionx/fs": "0.3.0", + "@effectionx/node": "0.2.4", + "@effectionx/process": "0.8.1", + "@executablemd/durable-streams": "workspace:*", + "effection": "4.1.0", + }, + }, + "packages/terminal-tmux": { + "name": "@executablemd/terminal-tmux", + "version": "0.11.0", + "dependencies": { + "@effectionx/fs": "0.3.0", + "@effectionx/process": "0.8.1", + "@executablemd/terminal": "workspace:*", + "effection": "4.1.0", + "zod": "^4.3.6", + }, + }, "packages/test-agent": { "name": "@executablemd/test-agent", "version": "0.11.0", @@ -149,6 +180,7 @@ "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", + "@executablemd/terminal": "workspace:*", "@executablemd/testing": "workspace:*", "acorn": "^8.16.0", "acpx": "0.12.0", @@ -160,9 +192,11 @@ "name": "@executablemd/test-support", "version": "0.0.0", "dependencies": { + "@effectionx/fs": "0.3.0", "@effectionx/process": "0.8.1", "@effectionx/test-adapter": "0.7.4", "@effectionx/timebox": "0.4.3", + "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0", "expect": "^30.0.0", }, @@ -176,6 +210,7 @@ "@effectionx/timebox": "0.4.3", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", + "@executablemd/runtime": "workspace:*", "effection": "4.1.0", }, }, @@ -212,7 +247,6 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/process": "0.8.1", "@executablemd/core": "workspace:*", "@executablemd/durable-streams": "workspace:*", "@executablemd/runtime": "workspace:*", @@ -327,6 +361,10 @@ "@executablemd/runtime": ["@executablemd/runtime@workspace:packages/runtime"], + "@executablemd/terminal": ["@executablemd/terminal@workspace:packages/terminal"], + + "@executablemd/terminal-tmux": ["@executablemd/terminal-tmux@workspace:packages/terminal-tmux"], + "@executablemd/test-agent": ["@executablemd/test-agent@workspace:packages/test-agent"], "@executablemd/test-support": ["@executablemd/test-support@workspace:packages/test-support"], @@ -585,6 +623,8 @@ "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], + "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -937,6 +977,8 @@ "section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "2.0.1", "kind-of": "6.0.3" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1053,6 +1095,8 @@ "@executablemd/durable-streams/@durable-streams/client": ["@durable-streams/client@0.2.6", "", { "dependencies": { "@microsoft/fetch-event-source": "^2.0.1", "fastq": "^1.19.1" }, "bin": { "intent": "bin/intent.js" } }, "sha512-uHKKbWpsKLhFMeGjG0PgM6LXE3oEIi7FHKlJZkmYGxcqd4Yjjd/QEvnQnDzteRP4Av1uJVM8qjTL7kfKsgeS/w=="], + "@executablemd/terminal-tmux/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@executablemd/test-agent/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts index 02c4a1e25..660979d6d 100644 --- a/packages/cli/src/grid-host.ts +++ b/packages/cli/src/grid-host.ts @@ -27,7 +27,6 @@ import { command as hostCommand } from "@executablemd/runtime"; import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; import { installTmuxGridProvider, - paneEnvironment, PANE_WORKER_COMMAND, TMUX_PROVIDER, } from "@executablemd/terminal-tmux"; @@ -104,6 +103,29 @@ export class TerminalLost extends Error { } } +/** + * The environment every process in the topology receives. + * + * Named rather than inherited wholesale: a pane's child gets what a terminal + * program needs and nothing this process happens to be carrying. + * + * It is a host decision, so it is made here rather than by the provider. The + * adapter is handed an environment and passes exactly that along; which of + * *this* invocation's variables are worth passing is a question only the + * entrypoint composing the host can answer. + */ +function paneEnvironment(source: Record): Record { + const env: Record = {}; + for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { + const value = source[name]; + if (value !== undefined && value !== "") { + env[name] = value; + } + } + env.TERM = source.TERM ?? "xterm-256color"; + return env; +} + /** The terminal this run is drawing on, as tmux needs to know it. */ function windowSize(): { columns: number; rows: number } { // A terminal that cannot say gets the sizes tmux itself defaults to, which is diff --git a/packages/terminal-tmux/mod.ts b/packages/terminal-tmux/mod.ts index f06d6850d..c98521662 100644 --- a/packages/terminal-tmux/mod.ts +++ b/packages/terminal-tmux/mod.ts @@ -25,9 +25,4 @@ export { runPaneWorkerProcess, } from "./src/pane-worker.ts"; -export { - paneEnvironment, - TerminalTeardownFailed, - TMUX_UNAVAILABLE, - TmuxUnavailableError, -} from "./src/tmux.ts"; +export { TerminalTeardownFailed, TMUX_UNAVAILABLE, TmuxUnavailableError } from "./src/tmux.ts"; diff --git a/packages/terminal-tmux/src/tmux.ts b/packages/terminal-tmux/src/tmux.ts index 3ac3ca458..e1df54a50 100644 --- a/packages/terminal-tmux/src/tmux.ts +++ b/packages/terminal-tmux/src/tmux.ts @@ -162,23 +162,3 @@ function readVersion(reported: string): { major: number; minor: number } | undef } return { major: Number(major), minor: Number(minor) }; } - -/** - * The environment every process in the topology receives. - * - * Named rather than inherited wholesale: a pane's child gets what a terminal - * program needs and nothing this process happens to be carrying. - */ -export function paneEnvironment( - source: Record, -): Record { - const env: Record = {}; - for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { - const value = source[name]; - if (value !== undefined && value !== "") { - env[name] = value; - } - } - env.TERM = source.TERM ?? "xterm-256color"; - return env; -} diff --git a/packages/terminal/mod.ts b/packages/terminal/mod.ts index 92c506b26..6ed7a95db 100644 --- a/packages/terminal/mod.ts +++ b/packages/terminal/mod.ts @@ -12,6 +12,13 @@ * controlled fixtures that prove the contract in `./test` — facets of one * package rather than separate definitions, so a symbol exported by two of them * is the same object. + * + * Those are boundaries in the module graph, not just in the export lists. This + * root, `./lifecycle` and `./processes` reach contracts and operations only: + * nothing they load spawns a process, reads `process.stdout`, or is a test + * fixture. Anything that performs a launch lives behind `./posix`, and anything + * that pretends to behind `./test`, so importing the domain to describe a grid + * pulls in nothing that could present or fake one. */ export { @@ -22,19 +29,19 @@ export { nativeLaunch, NO_TERMINAL, reserveTerminal, -} from "./src/launcher.ts"; +} from "./src/native-launcher.ts"; export type { NativeLauncherHandler, NativeLaunchOutcome, NativeLaunchRequest, -} from "./src/launcher.ts"; +} from "./src/native-launcher.ts"; export { TERMINAL_GRIDS_API, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, TerminalProviderUnavailableError, -} from "./src/terminal.ts"; +} from "./src/composite.ts"; export type { TerminalComposite, TerminalGridApi, @@ -42,7 +49,7 @@ export type { TerminalPaneRequest, TerminalPaneState, TerminalShellOutcome, -} from "./src/terminal.ts"; +} from "./src/composite.ts"; export { registerTerminalProvider, diff --git a/packages/terminal/posix.ts b/packages/terminal/posix.ts index 9331485af..b2c31bd1b 100644 --- a/packages/terminal/posix.ts +++ b/packages/terminal/posix.ts @@ -12,7 +12,7 @@ * grid rather than reporting one free it never checked. */ -export { installForegroundLauncher } from "./src/launcher.ts"; -export type { ForegroundLauncherOptions } from "./src/launcher.ts"; +export { installForegroundLauncher } from "./src/posix-launcher.ts"; +export type { ForegroundLauncherOptions } from "./src/posix-launcher.ts"; export { installDenoTerminalProcesses, posixProcessProbes } from "./src/posix-processes.ts"; export type { ProcessProbes } from "./src/posix-processes.ts"; diff --git a/packages/terminal/src/authority.ts b/packages/terminal/src/authority.ts index 358ee4579..29b15370c 100644 --- a/packages/terminal/src/authority.ts +++ b/packages/terminal/src/authority.ts @@ -21,7 +21,7 @@ import { all, createContext, ensure, withResolvers } from "effection"; import type { Context, Operation } from "effection"; -import type { TerminalComposite, TerminalGridRequest } from "./terminal.ts"; +import type { TerminalComposite, TerminalGridRequest } from "./composite.ts"; export class TerminalAuthorityError extends Error { override name = "TerminalAuthorityError"; diff --git a/packages/terminal/src/terminal.ts b/packages/terminal/src/composite.ts similarity index 56% rename from packages/terminal/src/terminal.ts rename to packages/terminal/src/composite.ts index 23a4e5fd5..dc82e1e34 100644 --- a/packages/terminal/src/terminal.ts +++ b/packages/terminal/src/composite.ts @@ -26,7 +26,7 @@ import { type Api, createApi } from "@effectionx/context-api"; import type { Operation } from "effection"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; /** One pane the provider is asked to present, by its authored ordinal. */ export interface TerminalPaneRequest { @@ -206,187 +206,3 @@ export const TerminalGrids: Api = createApi(TE throw new TerminalProviderUnavailableError(); }, }); - -/** - * Everything one controlled composite did, in the order it did it. - * - * The record is the evidence: a suite reads it to prove that preparation came - * before every pane started, that nothing attached before the readiness - * barrier, and that teardown destroyed exactly the composite it prepared. - */ -export interface TerminalProviderLog { - readonly events: string[]; - /** - * What each pane displayed, by ordinal. - * - * A suite reads this to prove where a pane's output went — and reads the root - * document output to prove where it did not. - */ - readonly shown: Map; - /** - * What the provider still holds, counted rather than described. - * - * Each one goes up when the composite takes something and down when it gives - * it back, so a suite reads it after a run to prove nothing was stranded — - * including after a cancellation, where the ordering of the record alone - * would not say whether teardown finished. - */ - readonly live: TerminalProviderResources; -} - -/** What one controlled composite holds at a moment, by kind. */ -export interface TerminalProviderResources { - /** Composites prepared and not yet destroyed. */ - composites: number; - /** Composites attached and not yet destroyed. */ - attached: number; - /** Shells started whose outcome has not been returned. */ - shells: number; - /** Pane launches started whose outcome has not been returned. */ - launches: number; -} - -/** A fresh, empty record. */ -export function terminalProviderLog(): TerminalProviderLog { - return { - events: [], - shown: new Map(), - live: { composites: 0, attached: 0, shells: 0, launches: 0 }, - }; -} - -/** - * What a controlled composite does instead of opening a terminal. - * - * Each hook is a place a suite makes something happen or go wrong: `onPrepare` - * refuses before a composite exists, `onAttach` fails the barrier, `shell` - * decides what a self-closing pane's shell did and whether it started at all, - * and `close` is the operation the grid waits on, so a suite controls exactly - * when the reader leaves. - */ -export interface ControlledCompositeOptions { - /** Appended to as the composite works, so ordering is read rather than timed. */ - readonly log?: TerminalProviderLog; - onPrepare?: (request: TerminalGridRequest) => Operation; - onAttach?: () => Operation; - onDestroy?: () => Operation; - /** - * Called as each pane state is displayed. - * - * A suite watches it to react to something the grid decided — a pane that - * failed, a pane that became runnable — instead of waiting and hoping. - */ - onUpdate?: (ordinal: number, state: TerminalPaneState) => void; - shell?: (ordinal: number, spawned: () => void) => Operation; - /** - * What a pane launch does, in place of starting a native UI. - * - * Left out, a launch refuses — which is what a composite that cannot execute - * one must do, and what keeps a suite that says nothing about launching from - * quietly passing one to the root terminal. - */ - launch?: ( - ordinal: number, - request: NativeLaunchRequest, - spawned: () => void, - ) => Operation; - close?: () => Operation; -} - -/** - * Prepare one composite that presents nothing and records everything. - * - * It answers the whole contract — attach, update, display, shell, close, - * destroy — so a suite exercises core's lifecycle without a terminal, a - * multiplexer, or a process anywhere in it. - */ -export function prepareControlledComposite( - request: TerminalGridRequest, - options: ControlledCompositeOptions = {}, - generation = 0, -): Operation { - return (function* (): Operation { - const log = options.log ?? terminalProviderLog(); - if (options.onPrepare) { - yield* options.onPrepare(request); - } - log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); - log.live.composites++; - let destroyed = false; - let attached = false; - return { - *attach() { - if (options.onAttach) { - yield* options.onAttach(); - } - log.events.push(`attach:${generation}`); - attached = true; - log.live.attached++; - }, - // deno-lint-ignore require-yield - *update(ordinal, state) { - log.events.push(`state:${generation}:${ordinal}:${state}`); - options.onUpdate?.(ordinal, state); - }, - // deno-lint-ignore require-yield - *display(ordinal, text) { - log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); - }, - *shell(ordinal, spawned) { - log.events.push(`shell:${generation}:${ordinal}`); - log.live.shells++; - try { - if (options.shell) { - return yield* options.shell(ordinal, spawned); - } - // The default shell starts: a suite that says nothing about a pane - // wants a pane that works, and one that never reported a spawn would - // hang the readiness barrier instead. - spawned(); - return { exitCode: 0 }; - } finally { - // Counted down however the shell left — returned, thrown, or - // cancelled — because a shell a suite can still find is a shell the - // provider is still holding. - log.live.shells--; - } - }, - *launch(ordinal, request, spawned) { - log.events.push(`launch:${generation}:${ordinal}`); - if (options.launch === undefined) { - throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); - } - log.live.launches++; - try { - return yield* options.launch(ordinal, request, spawned); - } finally { - log.live.launches--; - } - }, - *closed() { - if (options.close) { - yield* options.close(); - } - log.events.push(`closed:${generation}`); - }, - *destroy() { - // Destroying twice would make the record say a composite was taken down - // more times than it was built, which is exactly the ordering claim a - // suite reads this log for. - if (destroyed) { - throw new Error(`controlled composite ${generation} was destroyed twice`); - } - destroyed = true; - if (options.onDestroy) { - yield* options.onDestroy(); - } - log.events.push(`destroy:${generation}`); - log.live.composites--; - if (attached) { - attached = false; - log.live.attached--; - } - }, - }; - })(); -} diff --git a/packages/terminal/src/controlled-composite.ts b/packages/terminal/src/controlled-composite.ts new file mode 100644 index 000000000..ec16db0b8 --- /dev/null +++ b/packages/terminal/src/controlled-composite.ts @@ -0,0 +1,206 @@ +/** + * A composite that presents nothing and records everything. + * + * The controlled implementation of the contract in `./composite.ts`, and the + * authority for core's grid lifecycle: it answers the whole contract — attach, + * update, display, shell, launch, close, destroy — so a suite exercises the + * lifecycle without a terminal, a multiplexer, or a process anywhere in it. + * + * It lives apart from the contract for the same reason the controlled launcher + * does: production code must have no path to a fixture, and importing the + * domain must not load one. It is reachable only through + * `@executablemd/terminal/test`. + */ + +import type { Operation } from "effection"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; +import type { + TerminalComposite, + TerminalGridRequest, + TerminalPaneState, + TerminalShellOutcome, +} from "./composite.ts"; + +/** + * Everything one controlled composite did, in the order it did it. + * + * The record is the evidence: a suite reads it to prove that preparation came + * before every pane started, that nothing attached before the readiness + * barrier, and that teardown destroyed exactly the composite it prepared. + */ +export interface TerminalProviderLog { + readonly events: string[]; + /** + * What each pane displayed, by ordinal. + * + * A suite reads this to prove where a pane's output went — and reads the root + * document output to prove where it did not. + */ + readonly shown: Map; + /** + * What the provider still holds, counted rather than described. + * + * Each one goes up when the composite takes something and down when it gives + * it back, so a suite reads it after a run to prove nothing was stranded — + * including after a cancellation, where the ordering of the record alone + * would not say whether teardown finished. + */ + readonly live: TerminalProviderResources; +} + +/** What one controlled composite holds at a moment, by kind. */ +export interface TerminalProviderResources { + /** Composites prepared and not yet destroyed. */ + composites: number; + /** Composites attached and not yet destroyed. */ + attached: number; + /** Shells started whose outcome has not been returned. */ + shells: number; + /** Pane launches started whose outcome has not been returned. */ + launches: number; +} + +/** A fresh, empty record. */ +export function terminalProviderLog(): TerminalProviderLog { + return { + events: [], + shown: new Map(), + live: { composites: 0, attached: 0, shells: 0, launches: 0 }, + }; +} + +/** + * What a controlled composite does instead of opening a terminal. + * + * Each hook is a place a suite makes something happen or go wrong: `onPrepare` + * refuses before a composite exists, `onAttach` fails the barrier, `shell` + * decides what a self-closing pane's shell did and whether it started at all, + * and `close` is the operation the grid waits on, so a suite controls exactly + * when the reader leaves. + */ +export interface ControlledCompositeOptions { + /** Appended to as the composite works, so ordering is read rather than timed. */ + readonly log?: TerminalProviderLog; + onPrepare?: (request: TerminalGridRequest) => Operation; + onAttach?: () => Operation; + onDestroy?: () => Operation; + /** + * Called as each pane state is displayed. + * + * A suite watches it to react to something the grid decided — a pane that + * failed, a pane that became runnable — instead of waiting and hoping. + */ + onUpdate?: (ordinal: number, state: TerminalPaneState) => void; + shell?: (ordinal: number, spawned: () => void) => Operation; + /** + * What a pane launch does, in place of starting a native UI. + * + * Left out, a launch refuses — which is what a composite that cannot execute + * one must do, and what keeps a suite that says nothing about launching from + * quietly passing one to the root terminal. + */ + launch?: ( + ordinal: number, + request: NativeLaunchRequest, + spawned: () => void, + ) => Operation; + close?: () => Operation; +} + +/** + * Prepare one composite that presents nothing and records everything. + * + * It answers the whole contract — attach, update, display, shell, close, + * destroy — so a suite exercises core's lifecycle without a terminal, a + * multiplexer, or a process anywhere in it. + */ +export function prepareControlledComposite( + request: TerminalGridRequest, + options: ControlledCompositeOptions = {}, + generation = 0, +): Operation { + return (function* (): Operation { + const log = options.log ?? terminalProviderLog(); + if (options.onPrepare) { + yield* options.onPrepare(request); + } + log.events.push(`prepare:${generation}:${request.columns}x${request.rows}`); + log.live.composites++; + let destroyed = false; + let attached = false; + return { + *attach() { + if (options.onAttach) { + yield* options.onAttach(); + } + log.events.push(`attach:${generation}`); + attached = true; + log.live.attached++; + }, + // deno-lint-ignore require-yield + *update(ordinal, state) { + log.events.push(`state:${generation}:${ordinal}:${state}`); + options.onUpdate?.(ordinal, state); + }, + // deno-lint-ignore require-yield + *display(ordinal, text) { + log.shown.set(ordinal, (log.shown.get(ordinal) ?? "") + text); + }, + *shell(ordinal, spawned) { + log.events.push(`shell:${generation}:${ordinal}`); + log.live.shells++; + try { + if (options.shell) { + return yield* options.shell(ordinal, spawned); + } + // The default shell starts: a suite that says nothing about a pane + // wants a pane that works, and one that never reported a spawn would + // hang the readiness barrier instead. + spawned(); + return { exitCode: 0 }; + } finally { + // Counted down however the shell left — returned, thrown, or + // cancelled — because a shell a suite can still find is a shell the + // provider is still holding. + log.live.shells--; + } + }, + *launch(ordinal, request, spawned) { + log.events.push(`launch:${generation}:${ordinal}`); + if (options.launch === undefined) { + throw new Error(`this composite cannot run a native launch in pane ${ordinal}`); + } + log.live.launches++; + try { + return yield* options.launch(ordinal, request, spawned); + } finally { + log.live.launches--; + } + }, + *closed() { + if (options.close) { + yield* options.close(); + } + log.events.push(`closed:${generation}`); + }, + *destroy() { + // Destroying twice would make the record say a composite was taken down + // more times than it was built, which is exactly the ordering claim a + // suite reads this log for. + if (destroyed) { + throw new Error(`controlled composite ${generation} was destroyed twice`); + } + destroyed = true; + if (options.onDestroy) { + yield* options.onDestroy(); + } + log.events.push(`destroy:${generation}`); + log.live.composites--; + if (attached) { + attached = false; + log.live.attached--; + } + }, + }; + })(); +} diff --git a/packages/terminal/src/controlled-launcher.ts b/packages/terminal/src/controlled-launcher.ts new file mode 100644 index 000000000..8b314874f --- /dev/null +++ b/packages/terminal/src/controlled-launcher.ts @@ -0,0 +1,84 @@ +/** + * The launcher a host installs when it has no terminal to give away. + * + * The other implementation of the contract in `./native-launcher.ts`, and the + * one every suite that is not about a real terminal uses. It reaches no + * process and no host stream — a launch here is whatever the row says it is — + * and it lives in its own module so that importing the domain never loads a + * fixture. Production code has no path to it: it is reachable only through + * `@executablemd/terminal/test`. + */ + +import { resource } from "effection"; +import type { Operation } from "effection"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; + +/** + * How a controlled launch behaves. + * + * `record` sees each request in the order the provider made it; `outcome` + * decides what the child did; and `wait` is the operation the launch blocks + * on, so a test controls exactly how long the document stays suspended. + */ +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; +} + +export function* installControlledLauncher( + options: ControlledLauncherOptions = {}, +): Operation { + let held = false; + yield* NativeLauncher.around( + { + reserve() { + return resource(function* (provide) { + if (held) { + throw new Error( + "another already holds this run's terminal — one " + + "native UI owns the terminal at a time", + ); + } + held = true; + options.onReserve?.(); + try { + yield* provide(); + } finally { + held = false; + } + }); + }, + // deno-lint-ignore require-yield + *flush() { + options.onFlush?.(); + }, + *launch([request, spawned]) { + options.record?.(request); + if (options.start) { + yield* options.start(request, spawned); + } else { + spawned(); + } + if (options.wait) { + yield* options.wait(request); + } + return options.outcome?.(request) ?? { exitCode: 0 }; + }, + }, + { at: "min" }, + ); +} diff --git a/packages/terminal/src/grid.ts b/packages/terminal/src/grid.ts index f44468efa..f9a6750f6 100644 --- a/packages/terminal/src/grid.ts +++ b/packages/terminal/src/grid.ts @@ -42,9 +42,9 @@ import { ephemeral, } from "@executablemd/durable-streams"; import type { Json, Workflow } from "@executablemd/durable-streams"; -import { TerminalGrids } from "./terminal.ts"; -import { flushOutput, reserveTerminal } from "./launcher.ts"; -import type { TerminalComposite, TerminalGridRequest } from "./terminal.ts"; +import { TerminalGrids } from "./composite.ts"; +import { flushOutput, reserveTerminal } from "./native-launcher.ts"; +import type { TerminalComposite, TerminalGridRequest } from "./composite.ts"; import { awaitReadiness, diff --git a/packages/terminal/src/native-launcher.ts b/packages/terminal/src/native-launcher.ts new file mode 100644 index 000000000..df2e6f29b --- /dev/null +++ b/packages/terminal/src/native-launcher.ts @@ -0,0 +1,139 @@ +/** + * The native launcher contract — how a host hands one child process the + * terminal, and nothing about how any particular host does it. + * + * This is not `exec`. An ordinary command is a captured child: its stdout and + * stderr are piped so a document can display, capture and journal them, and + * its exit status is a value the document reads. A native coding-agent UI is + * the opposite of that. It draws on the terminal, reads the person's + * keystrokes, and owns the conversation it has with them. None of that may + * become an XMD process result or a journaled transcript, and a piped child + * cannot be interactive at all. + * + * So a launch asks for three things in order, and each is refusable on its + * own: + * + * 1. `reserve()` takes the one foreground-terminal lease for the run. A host + * with no terminal refuses here, which is before any session ownership has + * moved. Two launches cannot hold it at once even when they name different + * sessions, so native UIs are sequential by construction. + * 2. `flush()` gives the reader everything the document has produced so far, + * so the native UI does not open on top of half-written output. + * 3. `launch()` spawns the child with the terminal inherited, waits for it, + * and reports its terminal status and nothing else. + * + * There is no host default. `xmd run` installs the foreground launcher from + * `./posix-launcher.ts`; a test or embedding host installs the controlled one + * from `./controlled-launcher.ts`. Until one is installed every operation + * refuses, which is what keeps document help and inspection free of any of + * this. + * + * Nothing here reaches a process, a stream or a host API, and that separation + * is the point rather than a tidiness: this module is what the package root + * exports, so importing the domain does not load `node:child_process`. A + * consumer that only describes a launch pulls in nothing that could perform + * one. + */ + +import { type Api, createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; + +/** + * What a provider asks the host to run. + * + * `command` is the complete argv, built by the provider's adapter from the + * provider-native session identity. Raw prepared instructions never appear in + * it, and never in `env`: a process's arguments and environment are readable + * by other processes, so the instruction layer travels through the provider's + * own session API instead. + */ +export interface NativeLaunchRequest { + command: string[]; + cwd: string; + env?: Record; +} + +/** + * How the native UI ended. A child that exited on a signal reports the signal + * and no code, which is how a signalled exit stays distinguishable from + * status 0. + */ +export interface NativeLaunchOutcome { + exitCode?: number; + signal?: string; +} + +export interface NativeLauncherHandler { + reserve(): Operation; + flush(): 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 = + "no native launcher is installed — this host does not hand a native agent UI " + + "the terminal. `xmd run` installs one; a test or embedding host installs its own."; + +export class NativeLauncherUnavailableError extends Error { + override name = "NativeLauncherUnavailableError"; + constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { + super(message); + } +} + +export const NativeLauncher: Api = createApi( + "runtime.nativeLauncher", + { + // deno-lint-ignore require-yield + *reserve(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *flush(): Operation { + throw new NativeLauncherUnavailableError(); + }, + // deno-lint-ignore require-yield + *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { + throw new NativeLauncherUnavailableError(); + }, + }, +); + +/** Hold the foreground-terminal lease for the calling scope. */ +export function reserveTerminal(): Operation { + return NativeLauncher.operations.reserve(); +} + +/** Give the reader everything the document has produced so far. */ +export function flushOutput(): Operation { + return NativeLauncher.operations.flush(); +} + +/** + * 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, () => {}); +} + +export const NO_TERMINAL = + " needs a terminal: a native agent UI reads keystrokes and " + + "draws on the screen, and this invocation has none. Run xmd from a terminal, " + + "or use a host that installs its own launcher."; diff --git a/packages/terminal/src/pane-launcher.ts b/packages/terminal/src/pane-launcher.ts index bb01347dd..0426800d9 100644 --- a/packages/terminal/src/pane-launcher.ts +++ b/packages/terminal/src/pane-launcher.ts @@ -22,8 +22,8 @@ import { resource } from "effection"; import type { Operation } from "effection"; -import { NativeLauncher } from "./launcher.ts"; -import type { NativeLaunchOutcome, NativeLaunchRequest } from "./launcher.ts"; +import { NativeLauncher } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; import type { TerminalPaneClaim } from "./authority.ts"; diff --git a/packages/terminal/src/launcher.ts b/packages/terminal/src/posix-launcher.ts similarity index 58% rename from packages/terminal/src/launcher.ts rename to packages/terminal/src/posix-launcher.ts index c1196e0e6..75d6029ee 100644 --- a/packages/terminal/src/launcher.ts +++ b/packages/terminal/src/posix-launcher.ts @@ -1,139 +1,28 @@ /** - * The native launcher — how a host hands one child process the terminal. + * The POSIX foreground launcher — how *this* host hands a child the terminal. * - * This is not `exec`. An ordinary command is a captured child: its stdout and - * stderr are piped so a document can display, capture and journal them, and - * its exit status is a value the document reads. A native coding-agent UI is - * the opposite of that. It draws on the terminal, reads the person's - * keystrokes, and owns the conversation it has with them. None of that may - * become an XMD process result or a journaled transcript, and a piped child - * cannot be interactive at all. + * One of two implementations of the contract in `./native-launcher.ts`, and + * the only one that reaches a process. It lives apart from that contract + * because a consumer that merely describes a launch must not load + * `node:child_process` to do it: the package root exports the contract, and + * this module is reachable only through `@executablemd/terminal/posix`. * - * So a launch asks for three things in order, and each is refusable on its - * own: - * - * 1. `reserve()` takes the one foreground-terminal lease for the run. A host - * with no terminal refuses here, which is before any session ownership has - * moved. Two launches cannot hold it at once even when they name different - * sessions, so native UIs are sequential by construction. - * 2. `flush()` gives the reader everything the document has produced so far, - * so the native UI does not open on top of half-written output. - * 3. `launch()` spawns the child with the terminal inherited, waits for it, - * and reports its terminal status and nothing else. - * - * There is no host default. `xmd run` installs the foreground launcher; - * a test or embedding host installs a controlled one that needs no terminal. - * Until one is installed every operation refuses, which is what keeps - * document help and inspection free of any of this. + * XMD stays the parent. It does not replace itself with the child, because a + * process that has execed away cannot cancel the document, reap the child, own + * its exit status, or continue after the UI closes. What follows is that + * parenthood made good: a bounded interrupt escalation, a reap that establishes + * the child is actually gone, and a drain that keeps the UI from opening on top + * of half-written output. */ -import { type Api, createApi } from "@effectionx/context-api"; import { ensure, race, resource, scoped, until } from "effection"; import { once } from "@effectionx/node/events"; import type { Operation } from "effection"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import process from "node:process"; - -/** - * What a provider asks the host to run. - * - * `command` is the complete argv, built by the provider's adapter from the - * provider-native session identity. Raw prepared instructions never appear in - * it, and never in `env`: a process's arguments and environment are readable - * by other processes, so the instruction layer travels through the provider's - * own session API instead. - */ -export interface NativeLaunchRequest { - command: string[]; - cwd: string; - env?: Record; -} - -/** - * How the native UI ended. A child that exited on a signal reports the signal - * and no code, which is how a signalled exit stays distinguishable from - * status 0. - */ -export interface NativeLaunchOutcome { - exitCode?: number; - signal?: string; -} - -export interface NativeLauncherHandler { - reserve(): Operation; - flush(): 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 = - "no native launcher is installed — this host does not hand a native agent UI " + - "the terminal. `xmd run` installs one; a test or embedding host installs its own."; - -export class NativeLauncherUnavailableError extends Error { - override name = "NativeLauncherUnavailableError"; - constructor(message: string = NATIVE_LAUNCHER_UNAVAILABLE) { - super(message); - } -} - -export const NativeLauncher: Api = createApi( - "runtime.nativeLauncher", - { - // deno-lint-ignore require-yield - *reserve(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *flush(): Operation { - throw new NativeLauncherUnavailableError(); - }, - // deno-lint-ignore require-yield - *launch(_request: NativeLaunchRequest, _spawned: () => void): Operation { - throw new NativeLauncherUnavailableError(); - }, - }, -); - -/** Hold the foreground-terminal lease for the calling scope. */ -export function reserveTerminal(): Operation { - return NativeLauncher.operations.reserve(); -} - -/** Give the reader everything the document has produced so far. */ -export function flushOutput(): Operation { - return NativeLauncher.operations.flush(); -} - -/** - * 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, () => {}); -} - -export const NO_TERMINAL = - " needs a terminal: a native agent UI reads keystrokes and " + - "draws on the screen, and this invocation has none. Run xmd from a terminal, " + - "or use a host that installs its own launcher."; +import { NativeLauncher, NativeLauncherUnavailableError, NO_TERMINAL } from "./native-launcher.ts"; +import type { NativeLaunchOutcome, NativeLaunchRequest } from "./native-launcher.ts"; /** * How long an interrupted child is given to leave on its own before the @@ -428,73 +317,3 @@ function isReachable(pid: number): boolean { return false; } } - -/** - * A launcher a host installs when it has no terminal to give away, and no - * intention of starting a native UI. - * - * `record` sees each request in the order the provider made it; `outcome` - * decides what the child did; and `wait` is the operation the launch blocks - * on, so a test controls exactly how long the document stays suspended. - */ -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; -} - -export function* installControlledLauncher( - options: ControlledLauncherOptions = {}, -): Operation { - let held = false; - yield* NativeLauncher.around( - { - reserve() { - return resource(function* (provide) { - if (held) { - throw new Error( - "another already holds this run's terminal — one " + - "native UI owns the terminal at a time", - ); - } - held = true; - options.onReserve?.(); - try { - yield* provide(); - } finally { - held = false; - } - }); - }, - // deno-lint-ignore require-yield - *flush() { - options.onFlush?.(); - }, - *launch([request, spawned]) { - options.record?.(request); - if (options.start) { - yield* options.start(request, spawned); - } else { - spawned(); - } - if (options.wait) { - yield* options.wait(request); - } - return options.outcome?.(request) ?? { exitCode: 0 }; - }, - }, - { at: "min" }, - ); -} diff --git a/packages/terminal/test.ts b/packages/terminal/test.ts index 05abdf8ec..79355c1ef 100644 --- a/packages/terminal/test.ts +++ b/packages/terminal/test.ts @@ -8,11 +8,11 @@ * without tmux, a terminal, or a subprocess. */ -export { installControlledLauncher } from "./src/launcher.ts"; -export type { ControlledLauncherOptions } from "./src/launcher.ts"; -export { prepareControlledComposite, terminalProviderLog } from "./src/terminal.ts"; +export { installControlledLauncher } from "./src/controlled-launcher.ts"; +export type { ControlledLauncherOptions } from "./src/controlled-launcher.ts"; +export { prepareControlledComposite, terminalProviderLog } from "./src/controlled-composite.ts"; export type { ControlledCompositeOptions, TerminalProviderLog, TerminalProviderResources, -} from "./src/terminal.ts"; +} from "./src/controlled-composite.ts"; diff --git a/packages/terminal/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts index 1da220a0f..76f4584ea 100644 --- a/packages/terminal/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -25,13 +25,13 @@ import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; import { flushOutput, - installForegroundLauncher, nativeLaunch, NativeLauncher, NO_TERMINAL, reap, reserveTerminal, -} from "../src/launcher.ts"; +} from "../src/native-launcher.ts"; +import { installForegroundLauncher } from "../src/posix-launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; diff --git a/packages/terminal/tests/package-boundary.test.ts b/packages/terminal/tests/package-boundary.test.ts index 8590bf387..70972cdb3 100644 --- a/packages/terminal/tests/package-boundary.test.ts +++ b/packages/terminal/tests/package-boundary.test.ts @@ -34,6 +34,42 @@ import * as path from "node:path"; import { until } from "effection"; import type { Operation } from "effection"; +/** + * Everything one entrypoint loads, transitively. + * + * Read from the module graph rather than from the entrypoint's own export + * list, because an export list is exactly what hid this: re-exporting three + * names out of a module that also spawns processes narrows what is *reachable + * by name* and nothing about what is *loaded*. A facade passes an export-shape + * check and fails this one. + */ +function* graphOf(entrypoint: string): Operation { + const seen = new Set(); + const pending = [path.resolve("packages/terminal", entrypoint)]; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || seen.has(file)) { + continue; + } + seen.add(file); + const source = yield* readTextFile(file); + for (const match of source.matchAll(/from\s+"([^"]+)"/g)) { + const specifier = match[1]; + if (specifier === undefined) { + continue; + } + if (specifier.startsWith("node:")) { + seen.add(specifier); + continue; + } + if (specifier.startsWith(".")) { + pending.push(path.resolve(path.dirname(file), specifier)); + } + } + } + return [...seen]; +} + /** Every production source of one workspace package, tests excluded. */ function* productionSources(pkg: string): Operation { const root = path.resolve("packages", pkg); @@ -162,6 +198,40 @@ describe("Tier TG21 — the terminal package boundary", () => { } }); + it("TG21i: the neutral entrypoints load no host process code and no fixture", function* () { + // The defect this replaced: the root re-exported a handful of neutral names + // from a module that also spawned children and carried a test double, so + // importing the domain loaded `node:child_process` and a fixture. Selective + // re-export narrows the names, never the load. + for (const entrypoint of ["mod.ts", "lifecycle.ts", "processes.ts"]) { + const graph = yield* graphOf(entrypoint); + const host = graph.filter( + (module) => + module === "node:child_process" || + module === "node:process" || + module.endsWith("/posix-launcher.ts") || + module.endsWith("/posix-processes.ts"), + ); + const fixtures = graph.filter((module) => module.includes("/controlled-")); + expect([entrypoint, host]).toEqual([entrypoint, []]); + expect([entrypoint, fixtures]).toEqual([entrypoint, []]); + } + }); + + it("TG21j: the host and fixture facets are where that code actually lives", function* () { + // The complement, and the discriminator for the row above: if the split had + // simply deleted this code rather than moved it, TG21i would pass over an + // empty graph and prove nothing. + const posix = yield* graphOf("posix.ts"); + expect(posix.some((module) => module.endsWith("/posix-launcher.ts"))).toBe(true); + expect(posix.some((module) => module.endsWith("/posix-processes.ts"))).toBe(true); + expect(posix.includes("node:child_process")).toBe(true); + + const fixtures = yield* graphOf("test.ts"); + expect(fixtures.some((module) => module.endsWith("/controlled-launcher.ts"))).toBe(true); + expect(fixtures.some((module) => module.endsWith("/controlled-composite.ts"))).toBe(true); + }); + it("TG21d: a walked package with no sources would not pass vacuously", function* () { // The rows above are absence claims, and an absence claim over an empty set // is free. This is the discriminator: the walk finds real files. @@ -225,6 +295,38 @@ describe("Tier TG21 — the replaced paths are absent", () => { expect(offenders).toEqual([]); }); + it("TG21k: the tmux root exposes exactly its narrow provider API", function* () { + // Pinned as an exact set rather than a set of required names. `paneEnvironment` + // — a host's decision about which of *its own* variables a pane inherits — + // reached this root by being added to it, and a row that only checked for + // required names would have let it stay. + const tmux = yield* until(import("@executablemd/terminal-tmux")); + expect(Object.keys(tmux).toSorted()).toEqual( + [ + // Provider installation and factory. + "TMUX_PROVIDER", + "installTmuxGridProvider", + "tmuxGridProvider", + // Worker dispatch. + "PANE_WORKER_COMMAND", + "PaneNotQuiescent", + "paneWorkerInvocation", + "runPaneWorkerProcess", + // The refusals a reader can actually meet. + "TMUX_UNAVAILABLE", + "TerminalTeardownFailed", + "TmuxUnavailableError", + ].toSorted(), + ); + + // The low-level seams stay behind `./test`, and are really there — so the + // assertion above is a boundary rather than an empty package. + const seams = yield* until(import("@executablemd/terminal-tmux/test")); + for (const name of ["useTmuxGrid", "usePaneChannels", "usePaneChild", "tmuxAt", "runInPane"]) { + expect([name, name in seams]).toEqual([name, true]); + } + }); + it("TG21h: each descriptor and public error constructor is defined once", function* () { // Identity used to be provable by comparing two import paths. With one path // left, the claim that replaces it is that there is only one definition to @@ -266,7 +368,7 @@ describe("Tier TG21 — the replaced paths are absent", () => { } // And the scan is not vacuous: it found the descriptors it was told to look // for, in the package that owns them. - expect(definitions.get("NativeLauncher")?.[0]).toContain("terminal/src/launcher.ts"); + expect(definitions.get("NativeLauncher")?.[0]).toContain("terminal/src/native-launcher.ts"); expect(definitions.get("TerminalProcesses")?.[0]).toContain("terminal/src/processes.ts"); }); }); diff --git a/packages/terminal/tests/terminal-provider.test.ts b/packages/terminal/tests/terminal-provider.test.ts index 162c38ac3..65a54accc 100644 --- a/packages/terminal/tests/terminal-provider.test.ts +++ b/packages/terminal/tests/terminal-provider.test.ts @@ -20,13 +20,12 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { - prepareControlledComposite, TERMINAL_PROVIDER_UNAVAILABLE, TerminalGrids, - terminalProviderLog, TerminalProviderUnavailableError, -} from "../src/terminal.ts"; -import type { TerminalGridRequest } from "../src/terminal.ts"; +} from "../src/composite.ts"; +import type { TerminalGridRequest } from "../src/composite.ts"; +import { prepareControlledComposite, terminalProviderLog } from "../src/controlled-composite.ts"; /** A two-by-one grid: the smallest request that still has two ordinals. */ function request(overrides: Partial = {}): TerminalGridRequest { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea97ff75..2987519c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal acpx: specifier: 0.12.0 version: 0.12.0 @@ -174,6 +177,12 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal + '@executablemd/terminal-tmux': + specifier: workspace:* + version: link:../terminal-tmux '@executablemd/test-agent': specifier: workspace:* version: link:../test-agent @@ -242,6 +251,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@secretlint/core': specifier: 13.0.4 version: 13.0.4 @@ -315,6 +327,45 @@ importers: specifier: 4.1.0 version: 4.1.0 + packages/terminal: + dependencies: + '@effectionx/context-api': + specifier: 0.6.0 + version: 0.6.0(effection@4.1.0) + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/node': + specifier: 0.2.4 + version: 0.2.4(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/durable-streams': + specifier: workspace:* + version: link:../durable-streams + effection: + specifier: 4.1.0 + version: 4.1.0 + + packages/terminal-tmux: + dependencies: + '@effectionx/fs': + specifier: 0.3.0 + version: 0.3.0(effection@4.1.0) + '@effectionx/process': + specifier: 0.8.1 + version: 0.8.1(effection@4.1.0) + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal + effection: + specifier: 4.1.0 + version: 4.1.0 + zod: + specifier: ^4.3.6 + version: 4.4.3 + packages/test-agent: dependencies: '@agentclientprotocol/sdk': @@ -341,6 +392,9 @@ importers: '@executablemd/runtime': specifier: workspace:* version: link:../runtime + '@executablemd/terminal': + specifier: workspace:* + version: link:../terminal '@executablemd/testing': specifier: workspace:* version: link:../testing From 8a301718797f228e21159ca3bfb42cbb0c2dc32c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 00:17:19 -0400 Subject: [PATCH 42/62] =?UTF-8?q?=F0=9F=90=9B=20Declare=20the=20pane=20wir?= =?UTF-8?q?e=20format=20so=20the=20adapter=20can=20be=20published=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `deno task check:jsr` failed at 5163aa0c with five `missing-explicit-type` errors in `packages/terminal-tmux/src/pane-protocol.ts`. The extraction caused it: that module was internal to CLI and reached no published entrypoint, and it is now part of a published package, where JSR forbids slow types. An inferred zod type has no explicit form to publish. Hiding the re-export was not enough — JSR follows references out of the public API, and the exported `z.infer` aliases pulled the schemas back in. So the frames are declared instead: `Hello`, `Swept`, `Settlement`, `FromWorker` and `ToWorker` are written out, and each schema is held to its frame by a `z.ZodType` binding. That is compile-enforced rather than a convention — changing `pid` to `z.string()` fails the typecheck at the binding, which is how I checked it rather than assuming. The schemas themselves become private. What crosses the package boundary is `parseFromWorker`/`parseToWorker`: a caller — including this adapter's own tests — needs "turn these bytes into a frame or throw", not the shape of the validator. The two internal callers and the one test use them now. No wire format changed. The declared frames are exactly what the schemas already produced, which is what the bindings assert, so the protocol, the worker grammar and every diagnostic are byte-identical. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS --- packages/terminal-tmux/src/pane-channel.ts | 4 +- packages/terminal-tmux/src/pane-protocol.ts | 117 ++++++++++++++++-- packages/terminal-tmux/src/pane-worker.ts | 4 +- packages/terminal-tmux/test.ts | 6 +- .../tests/terminal-grid-tmux.test.ts | 5 +- 5 files changed, 117 insertions(+), 19 deletions(-) diff --git a/packages/terminal-tmux/src/pane-channel.ts b/packages/terminal-tmux/src/pane-channel.ts index 08bd86132..907781614 100644 --- a/packages/terminal-tmux/src/pane-channel.ts +++ b/packages/terminal-tmux/src/pane-channel.ts @@ -38,9 +38,9 @@ import type { Operation } from "effection"; import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { chmod } from "node:fs/promises"; import { - FromWorkerSchema, paneSocketPath, paneTokenPath, + parseFromWorker, readFrames, writeFrame, } from "./pane-protocol.ts"; @@ -263,7 +263,7 @@ export function usePaneChannels( function* admit(ordinal: number, socket: Socket): Operation { const slot = slots.get(ordinal); const token = tokens.get(ordinal); - const frames = yield* readFrames(socket, (value) => FromWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => parseFromWorker(value)); const first = yield* race([frames.next(), silence()]); if (slot === undefined || token === undefined || first.done || first.value.type !== "hello") { refusals.push(`pane ${ordinal}: a connection that did not say hello`); diff --git a/packages/terminal-tmux/src/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts index 61b6820bc..14f8e81af 100644 --- a/packages/terminal-tmux/src/pane-protocol.ts +++ b/packages/terminal-tmux/src/pane-protocol.ts @@ -25,8 +25,88 @@ import { createQueue, ensure, resource, withResolvers } from "effection"; import type { Operation, Queue } from "effection"; import { z } from "zod"; +/** + * The wire format, written out. + * + * Declared rather than inferred from the schemas below, and the schemas are + * then annotated with these types so the compiler holds the two together — a + * schema that stopped producing its declared frame stops compiling, so there is + * no drift to keep an eye on. + * + * Written out because this package is published: an inferred zod type has no + * explicit form to publish, and the frames are the one part of this adapter + * whose shape a reader of the package genuinely needs. The schemas themselves + * stay private — how a frame is validated is nobody else's business, and + * `parseFromWorker`/`parseToWorker` are the seam. + */ + /** What one worker says about the pane it woke up in. */ -export const HelloSchema = z.object({ +export interface Hello { + type: "hello"; + ordinal: number; + token: string; + pid: number; + pgid: number; + /** `ttys003`, or `??` when the worker has no controlling terminal. */ + tty: string; + /** Whether stdin, stdout and stderr are terminals. All three must be. */ + isatty: [boolean, boolean, boolean]; +} + +/** One process the settlement reached, and what reaching it established. */ +export interface Swept { + pid: number; + gone: boolean; +} + +/** + * What a settlement established, in the order it established it. + * + * `quiet` is the only field a caller may act on, and it is true only when the + * child, everything the snapshot said was below or beside it, and every holder + * of the pane's terminal are gone. The rest is what a diagnostic says when it + * is not. + */ +export interface Settlement { + method: "exited" | "interrupted" | "killed"; + quiet: boolean; + child?: number; + /** Snapshot members reached during the escalation. */ + swept: Swept[]; + /** Anything still holding the pane's terminal after the sweep. */ + holders: Swept[]; +} + +/** Everything a worker may say. */ +export type FromWorker = + | Hello + | { type: "displayed"; seq: number } + /** The runtime's spawn event, and nothing earlier. */ + | { type: "started"; id: string; pid: number } + | { type: "start-failed"; id: string; reason: string } + /** A launch asked for while one is live. */ + | { type: "busy"; id: string } + | { + type: "exited"; + id: string; + exitCode?: number; + signal?: string; + /** The settlement that preceded this; the pane is free once it arrives. */ + settlement: Settlement; + } + | { type: "quiet"; id?: string; settlement: Settlement } + | { type: "bye"; holders: Swept[] }; + +/** Everything the parent may say. */ +export type ToWorker = + | { type: "welcome" } + | { type: "display"; seq: number; text: string } + | { type: "launch"; id: string; argv: string[]; cwd: string; env: Record } + | { type: "cancel"; id: string } + | { type: "shutdown" }; + +/** What one worker says about the pane it woke up in. */ +const HelloSchema = z.object({ type: z.literal("hello"), ordinal: z.number().int().nonnegative(), token: z.string(), @@ -52,7 +132,7 @@ const SweptSchema = z.object({ * of the pane's terminal are gone. The rest is what a diagnostic says when it * is not. */ -export const SettlementSchema = z.object({ +const SettlementSchema = z.object({ method: z.enum(["exited", "interrupted", "killed"]), quiet: z.boolean(), child: z.number().int().optional(), @@ -62,7 +142,7 @@ export const SettlementSchema = z.object({ holders: z.array(SweptSchema), }); -export const FromWorkerSchema = z.discriminatedUnion("type", [ +const FromWorkerSchema = z.discriminatedUnion("type", [ HelloSchema, z.object({ type: z.literal("displayed"), seq: z.number().int() }), /** The runtime's spawn event, and nothing earlier. */ @@ -86,7 +166,7 @@ export const FromWorkerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("bye"), holders: z.array(SweptSchema) }), ]); -export const ToWorkerSchema = z.discriminatedUnion("type", [ +const ToWorkerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("welcome") }), z.object({ type: z.literal("display"), seq: z.number().int(), text: z.string() }), z.object({ @@ -100,10 +180,31 @@ export const ToWorkerSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("shutdown") }), ]); -export type Hello = z.infer; -export type FromWorker = z.infer; -export type ToWorker = z.infer; -export type Settlement = z.infer; +// The schemas are held to the declared frames rather than the frames being +// read off the schemas. A change to either that the other does not match is a +// type error here, at the one place both are in view. +const _hello: z.ZodType = HelloSchema; +const _settlement: z.ZodType = SettlementSchema; +const _fromWorker: z.ZodType = FromWorkerSchema; +const _toWorker: z.ZodType = ToWorkerSchema; + +/** + * Read one frame in each direction, or refuse it. + * + * The seam is the parse rather than the schema. A schema is how this module + * happens to decide what a frame is; what a caller — including this adapter's + * own tests — actually needs is "turn these bytes into a frame or throw", and + * a function saying exactly that keeps the shape of the wire format private. + * It also keeps it out of the published API, where an inferred zod type has no + * explicit form to publish. + */ +export function parseFromWorker(value: unknown): FromWorker { + return FromWorkerSchema.parse(value); +} + +export function parseToWorker(value: unknown): ToWorker { + return ToWorkerSchema.parse(value); +} /** * Where one pane's socket and token live. diff --git a/packages/terminal-tmux/src/pane-worker.ts b/packages/terminal-tmux/src/pane-worker.ts index 107ac1c76..b4d7d283e 100644 --- a/packages/terminal-tmux/src/pane-worker.ts +++ b/packages/terminal-tmux/src/pane-worker.ts @@ -35,8 +35,8 @@ import type { PaneChild, PaneChildRequest } from "./pane-child.ts"; import { paneSocketPath, paneTokenPath, + parseToWorker, readFrames, - ToWorkerSchema, writeFrame, } from "./pane-protocol.ts"; import type { FromWorker, Settlement } from "./pane-protocol.ts"; @@ -241,7 +241,7 @@ export function* runPaneWorker( socket.off("error", onConnectError); } - const inbound = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const inbound = yield* readFrames(socket, (value) => parseToWorker(value)); const say = (message: FromWorker) => writeFrame(socket, message); const table = yield* processTable(); diff --git a/packages/terminal-tmux/test.ts b/packages/terminal-tmux/test.ts index 93c310420..7c86c7c6a 100644 --- a/packages/terminal-tmux/test.ts +++ b/packages/terminal-tmux/test.ts @@ -21,13 +21,11 @@ export type { PaneStartFailure, } from "./src/pane-child.ts"; export { - FromWorkerSchema, - HelloSchema, paneSocketPath, paneTokenPath, + parseFromWorker, + parseToWorker, readFrames, - SettlementSchema, - ToWorkerSchema, writeFrame, } from "./src/pane-protocol.ts"; export type { FromWorker, Hello, Settlement, ToWorker } from "./src/pane-protocol.ts"; diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 0d20d4c27..859245333 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -64,11 +64,10 @@ import { chmod, readdir } from "node:fs/promises"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { PaneChannels, PaneLink } from "../src/pane-channel.ts"; import { - FromWorkerSchema, paneSocketPath, paneTokenPath, + parseToWorker, readFrames, - ToWorkerSchema, writeFrame, } from "../src/pane-protocol.ts"; import { @@ -1710,7 +1709,7 @@ function useScriptedWorker( const socket = yield* useImpostor(directory, ordinal); const token = (yield* readTextFile(paneTokenPath(directory, ordinal))).trim(); const heard: ToWorker["type"][] = []; - const frames = yield* readFrames(socket, (value) => ToWorkerSchema.parse(value)); + const frames = yield* readFrames(socket, (value) => parseToWorker(value)); yield* writeFrame(socket, { type: "hello", ordinal, From 25aa4fdf97d83d26d054b92b1dc844da32e7ad85 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 00:28:04 -0400 Subject: [PATCH 43/62] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20hidden=20cont?= =?UTF-8?q?rol=20protocol=20off=20the=20reader's=20terminal=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@effectionx/process` writes every child's stdout and stderr straight to the host process — that is `Stdio`'s documented default — and consuming a stream does not turn it off, because the two are independent. The hidden `tmux -C` client's stdout *is* the control protocol, so `%session-changed`, `%window-renamed`, `%window-pane-changed` and every other record was being drawn on the reader's terminal and over pane prompts. Nothing about the grid looked wrong; the terminal just had protocol on it. The repair is one per-process suppression on the client the provider owns: a `stdout` handler that never calls `next`, which is how this repository already suppresses that default (`scripts/verify.ts`). The stream is still consumed and classified exactly as before, so no event, ordering or diagnostic changes. stderr is deliberately left alone. The two streams mean different things here — stdout is the protocol, stderr is the client saying something went wrong — and silencing both would make a grid that failed fail quietly. TG14 and TG15 are the pair, and each was checked against the other. TG14 watches this process's own stdout while the composite consumes four records, and fails on the unrepaired provider — it is a reproduction before it is a regression test. It asserts on the `%` records rather than on the exact lines this suite cares about, so it covers the protocol and not four strings. TG15 drives the client's stderr through a new fixture directive and proves the complaint still arrives; suppressing stderr as well makes TG15 fail, which is what says the repair is the minimal one rather than merely a working one. Adjacent, reported rather than changed: `tmux.ts` runs its commands through the same default with `.join()`, so real tmux command output would leak the same way. That is not reproducible under the fake tmux this suite uses, and no gate exposed it, so it is left for the Architect to direct. Claude-Session: https://claude.ai/code/session_01CrKBYDBanPrxDqdQFgvFwS --- packages/terminal-tmux/src/tmux-grid.ts | 12 ++++ .../tests/fixtures/tmux-client.ts | 19 +++++ .../tests/terminal-grid-tmux.test.ts | 71 +++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/packages/terminal-tmux/src/tmux-grid.ts b/packages/terminal-tmux/src/tmux-grid.ts index 10f4210ec..68abb43e9 100644 --- a/packages/terminal-tmux/src/tmux-grid.ts +++ b/packages/terminal-tmux/src/tmux-grid.ts @@ -278,6 +278,18 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation { return written.operation; } +function complain(text: string): Operation { + const written = withResolvers(); + process.stderr.write(text, () => written.resolve()); + return written.operation; +} + +/** + * A script line that makes this client complain instead of report. + * + * The two streams mean different things here — stdout is the control protocol + * and stderr is the client saying something went wrong — so a suite needs to + * drive them separately to show that suppressing one leaves the other alone. + */ +const COMPLAIN = "!stderr "; + /** Follow the script until it says this client is finished. */ export function* followScript(mode: Mode, script: string): Operation { let seen = 0; @@ -52,6 +67,10 @@ export function* followScript(mode: Mode, script: string): Operation { const lines = yield* said(script); for (const line of lines.slice(seen)) { if (mode === "control") { + if (line.startsWith(COMPLAIN)) { + yield* complain(`${line.slice(COMPLAIN.length)}\n`); + continue; + } yield* write(`${line}\n`); if (line.startsWith("%exit")) { return; diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 859245333..f1637bf0b 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -1010,6 +1010,77 @@ describe("Tier TG — the tmux composite", () => { expect(tmux.issued.slice(0, asked).some((line) => line.startsWith("kill-server"))).toBe(false); }); + it("TG14: the control protocol is consumed, never shown to the reader", function* () { + // `@effectionx/process` forwards a child's stdout to this process by + // default, and consuming `client.stdout` does not turn that off — the two + // are independent. So the hidden control client's own records + // (`%session-changed`, `%window-renamed`, `%window-pane-changed`, and every + // other `%` line) were reaching the reader's terminal and any pane prompt + // drawn over it. Nothing about the grid looked wrong; the terminal just had + // protocol on it. + // + // The boundary is this process's stdout, so that is what is watched: the + // real write is replaced for the length of the row and restored after it. + const written: string[] = []; + const realWrite = process.stdout.write.bind(process.stdout); + yield* ensure(() => { + process.stdout.write = realWrite; + }); + process.stdout.write = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { + written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + // Still written, so a failing row is still readable. + return Reflect.apply(realWrite, process.stdout, [chunk, ...rest]); + }; + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + // Every record the composite classifies, and one it does not, so the claim + // is not limited to the lines this suite happens to care about. + for (const record of [ + "%session-changed $0 xmd", + "%window-renamed @0 pane", + "%window-pane-changed @0 %1", + "%client-detached /dev/ttys999", + ]) { + yield* tmux.say(record); + } + yield* untilEvent(grid, "client-detached"); + + // The records were consumed — the composite classified the one it needed. + expect(grid.events.some((event) => event.kind === "client-detached")).toBe(true); + // And none of them was shown. Asserted on `%` rather than on the four + // strings: what must not reach a terminal is the protocol, not these lines. + const shown = written.join(""); + expect(shown.includes("%session-changed")).toBe(false); + expect(shown.includes("%window-renamed")).toBe(false); + expect(shown.includes("%window-pane-changed")).toBe(false); + expect(shown.includes("%client-detached")).toBe(false); + }); + + it("TG15: a control client that complains is still heard", function* () { + // The other half of TG14, and the reason the repair suppresses one stream + // rather than both: stdout is the protocol and stderr is the client saying + // something went wrong. Silencing the protocol must not silence the + // complaint, or a grid that failed would fail quietly. + const complained: string[] = []; + const realWrite = process.stderr.write.bind(process.stderr); + yield* ensure(() => { + process.stderr.write = realWrite; + }); + process.stderr.write = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { + complained.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return Reflect.apply(realWrite, process.stderr, [chunk, ...rest]); + }; + + const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); + yield* tmux.say("!stderr tmux: server exited unexpectedly"); + // Ordered behind a record the composite classifies, so the row waits on the + // client having read that far rather than on a duration. + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + + expect(complained.join("")).toContain("tmux: server exited unexpectedly"); + }); + it("TG6: reader detach, control loss and server stop are separate events", function* () { const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); From 83be4b251e4f4edff3b3e9d4f0383697dee65276 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 05:30:26 -0400 Subject: [PATCH 44/62] =?UTF-8?q?=F0=9F=90=9B=20Suppress=20internal=20tmux?= =?UTF-8?q?=20stdio=20in=20the=20scope=20that=20owns=20each=20child=20(#71?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every process this provider starts for itself now runs inside `quietly()`, which installs `Stdio.around` in the owning Effection scope before the child starts: the hidden `tmux -C` watcher, `tmuxAt().run()`, `tmuxAt().tryRun()`, and the default `tmux -V` probe. Both streams are suppressed. The visible attach client and every pane child are untouched — they inherit the terminal deliberately, and the scope is a child scope so the suppression reaches neither. Nothing about parsing changes. `client.stdout` is still read, split and classified exactly as before, and `run`/`tryRun` still parse the captured result; what is suppressed is forwarding to the host, which is a separate thing from the stream. Lifecycle ordering, provider identity, the protocol and the public errors are all as they were. Raw tmux stderr is never forwarded. A soft `tryRun` failure is still `undefined`, and a hard one is still `TmuxCommandFailed` naming the step and nothing else — no socket, session or pane, which is what tmux's own complaint would have carried. Five rows, at the boundary that matters: what this process writes to its own streams. TG14 covers the control records, TG15 that a complaining client stays silent to the reader (replacing its previous raw-stderr requirement, which the ruling reverses), TG16 the first record on attach, TG17 internal commands — success, soft failure and hard failure — through a `tmux` on PATH rather than the machine's, and TG18 a whole grid's life from startup through pane switching, detach, server disappearance and teardown, asserting no record and no private metadata. Every step is driven by a classified event; no row waits on a duration. Two things measured rather than assumed. Removing the suppression fails all five rows, so they discriminate its absence. But installing it on the handle after `exec()` returns still passes every row, including TG16 with a shell client that writes within a millisecond — the parent installs the handler before the child is ever scheduled, so the race the pre-spawn placement protects against does not occur here. The placement is still the one that cannot lose that race by construction, which is what the code and TG16 now say; what I could not do is produce a row that fails without it, and I am not claiming otherwise. --- packages/terminal-tmux/src/tmux-grid.ts | 39 ++- packages/terminal-tmux/src/tmux.ts | 52 +++- .../tests/terminal-grid-tmux.test.ts | 232 +++++++++++++++--- 3 files changed, 265 insertions(+), 58 deletions(-) diff --git a/packages/terminal-tmux/src/tmux-grid.ts b/packages/terminal-tmux/src/tmux-grid.ts index 68abb43e9..065dd2fba 100644 --- a/packages/terminal-tmux/src/tmux-grid.ts +++ b/packages/terminal-tmux/src/tmux-grid.ts @@ -35,7 +35,7 @@ import { layoutString, swapsInto } from "./layout.ts"; import type { LayoutCell } from "./layout.ts"; import { useAttachClient } from "./attach-client.ts"; import type { AttachClient } from "./attach-client.ts"; -import { TerminalTeardownFailed } from "./tmux.ts"; +import { quietly, TerminalTeardownFailed } from "./tmux.ts"; import type { Tmux } from "./tmux.ts"; /** What one prepared pane is, from the composite's side. */ @@ -277,27 +277,24 @@ export function useTmuxGrid(tmux: Tmux, request: TmuxGridRequest): Operation(body: () => Operation): Operation { + return scoped(function* (): Operation { + yield* Stdio.around({ + // Neither stream reaches the host. Raw tmux stderr is never forwarded — + // what a caller may see is the provider's own normalized refusal. + // deno-lint-ignore require-yield + *stdout() {}, + // deno-lint-ignore require-yield + *stderr() {}, + }); + return yield* body(); + }); +} + /** One private tmux server, addressed by its socket. */ export interface Tmux { readonly socket: string; @@ -93,14 +128,21 @@ export function tmuxAt(socket: string, env: Record): Tmux { socket, argv: (args) => ["tmux", ...base, ...args], *run(args) { - const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); if (result.code !== 0) { + // The step name and nothing else. tmux's own stderr is not forwarded + // and does not travel in the refusal: it names sockets, sessions and + // panes, which are this invocation's private topology. throw new TmuxCommandFailed(args[0] ?? ""); } return result.stdout.trim(); }, *tryRun(args) { - const result = yield* exec("tmux", { arguments: [...base, ...args], env }).join(); + const result = yield* quietly(() => + exec("tmux", { arguments: [...base, ...args], env }).join(), + ); return result.code === 0 ? result.stdout.trim() : undefined; }, }; @@ -127,7 +169,7 @@ export function* probeTmux(options: { } const result = options.askVersion === undefined - ? yield* exec("tmux", { arguments: ["-V"], env: options.env }).join() + ? yield* quietly(() => exec("tmux", { arguments: ["-V"], env: options.env }).join()) : yield* options.askVersion(); if (result.code !== 0) { return Err(new TmuxUnavailableError("tmux is not installed or would not run")); diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index f1637bf0b..d1fa6369c 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -48,6 +48,7 @@ import { processReachable, TerminalProcesses } from "@executablemd/terminal/proc import type { SignalDelivery } from "@executablemd/terminal/processes"; import { installDenoTerminalProcesses } from "@executablemd/terminal/posix"; import { useTmuxGrid } from "../src/tmux-grid.ts"; +import { tmuxAt } from "../src/tmux.ts"; import type { ControlEvent, TmuxGrid } from "../src/tmux-grid.ts"; import { createFakeTmux } from "./fixtures/fake-tmux.ts"; import type { FakeTmux } from "./fixtures/fake-tmux.ts"; @@ -847,6 +848,8 @@ const CLIENT_MARKER = "clientmarker7f3a"; const TITLE_MARKER = "titlemarker7f3a"; const ENV_MARKER = "envmarker7f3a"; const STDERR_MARKER = "stderrmarker7f3a"; +const TMUX_STDOUT_MARKER = "tmuxstdoutmarker7f3a"; +const TMUX_STDERR_MARKER = "tmuxstderrmarker7f3a"; describe("Tier TG — the tmux composite", () => { /** A host whose processes are all gone, so teardown proves itself. */ @@ -875,6 +878,68 @@ describe("Tier TG — the tmux composite", () => { } /** A composite over a fake server, with the pane workers stubbed out. */ + /** + * A `tmux` on `PATH` that answers on both streams. + * + * `list-panes` succeeds and writes to stdout; anything else fails and writes + * to stderr, which is the shape `run()` and `tryRun()` branch on. Being a + * program rather than an injected seam is the point: the forwarding under + * test belongs to the process boundary, so the row needs a real child. + */ + function useFakeTmuxProgram(): Operation { + return resource(function* (provide) { + const at = path.join(tmpdir(), `xmd-fake-tmux-${randomUUID()}`); + yield* ensureDir(at); + yield* ensure(function* () { + yield* rm(at, { recursive: true, force: true }); + }); + yield* writeTextFile( + path.join(at, "tmux"), + [ + "#!/bin/sh", + 'case "$*" in', + ` *list-panes*) echo "${TMUX_STDOUT_MARKER}"; exit 0;;`, + ` *) echo "${TMUX_STDERR_MARKER}" >&2; exit 1;;`, + "esac", + "", + ].join("\n"), + ); + yield* until(chmod(path.join(at, "tmux"), 0o755)); + yield* provide(at); + }); + } + + /** + * Watch what this process actually writes to its own terminal. + * + * The boundary being defended is the host's streams, so that is what is + * observed rather than a provider's intentions: the real writes are replaced + * for the length of the row, recorded, still written so a failing row stays + * readable, and restored on the way out. + */ + function useHostStreams(): Operation<{ written: string[]; complained: string[] }> { + return resource<{ written: string[]; complained: string[] }>(function* (provide) { + const written: string[] = []; + const complained: string[] = []; + const realOut = process.stdout.write.bind(process.stdout); + const realErr = process.stderr.write.bind(process.stderr); + const record = + (into: string[], real: typeof realOut, stream: NodeJS.WriteStream) => + (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { + into.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); + return Reflect.apply(real, stream, [chunk, ...rest]); + }; + process.stdout.write = record(written, realOut, process.stdout); + process.stderr.write = record(complained, realErr, process.stderr); + try { + yield* provide({ written, complained }); + } finally { + process.stdout.write = realOut; + process.stderr.write = realErr; + } + }); + } + function useComposite(options: { panes: number; columns: number; @@ -1011,26 +1076,14 @@ describe("Tier TG — the tmux composite", () => { }); it("TG14: the control protocol is consumed, never shown to the reader", function* () { - // `@effectionx/process` forwards a child's stdout to this process by - // default, and consuming `client.stdout` does not turn that off — the two - // are independent. So the hidden control client's own records + // `@effectionx/process` writes every child's stdout and stderr to this + // process by default, and consuming `client.stdout` does not turn that off + // — the two are independent. So the hidden control client's own records // (`%session-changed`, `%window-renamed`, `%window-pane-changed`, and every // other `%` line) were reaching the reader's terminal and any pane prompt // drawn over it. Nothing about the grid looked wrong; the terminal just had // protocol on it. - // - // The boundary is this process's stdout, so that is what is watched: the - // real write is replaced for the length of the row and restored after it. - const written: string[] = []; - const realWrite = process.stdout.write.bind(process.stdout); - yield* ensure(() => { - process.stdout.write = realWrite; - }); - process.stdout.write = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { - written.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - // Still written, so a failing row is still readable. - return Reflect.apply(realWrite, process.stdout, [chunk, ...rest]); - }; + const { written } = yield* useHostStreams(); const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); // Every record the composite classifies, and one it does not, so the claim @@ -1056,29 +1109,144 @@ describe("Tier TG — the tmux composite", () => { expect(shown.includes("%client-detached")).toBe(false); }); - it("TG15: a control client that complains is still heard", function* () { - // The other half of TG14, and the reason the repair suppresses one stream - // rather than both: stdout is the protocol and stderr is the client saying - // something went wrong. Silencing the protocol must not silence the - // complaint, or a grid that failed would fail quietly. - const complained: string[] = []; - const realWrite = process.stderr.write.bind(process.stderr); - yield* ensure(() => { - process.stderr.write = realWrite; - }); - process.stderr.write = (chunk: string | Uint8Array, ...rest: unknown[]): boolean => { - complained.push(typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk)); - return Reflect.apply(realWrite, process.stderr, [chunk, ...rest]); - }; + it("TG15: a control client that complains says nothing to the reader", function* () { + // The other half of TG14. tmux's own stderr names sockets, sessions and + // panes, so it is this invocation's private topology and never reaches the + // reader — a failing grid is heard through the provider's normalized + // refusal, not through the multiplexer's voice. + const { written, complained } = yield* useHostStreams(); const { grid, tmux } = yield* useComposite({ panes: 1, columns: 1 }); - yield* tmux.say("!stderr tmux: server exited unexpectedly"); + yield* tmux.say("!stderr tmux: no server running on /private/tmp/xmd-grid-abc/s"); // Ordered behind a record the composite classifies, so the row waits on the // client having read that far rather than on a duration. yield* tmux.say("%client-detached /dev/ttys999"); yield* untilEvent(grid, "client-detached"); - expect(complained.join("")).toContain("tmux: server exited unexpectedly"); + const shown = written.join("") + complained.join(""); + expect(shown.includes("no server running")).toBe(false); + // And the socket path it named is private: nothing on either stream. + expect(shown.includes("xmd-grid-abc")).toBe(false); + }); + + it("TG16: the very first control record does not reach the reader", function* () { + // The record tmux sends immediately on attach, which is the one with the + // least protection: it is waiting before the composite has read anything. + // + // What this row proves is that it is suppressed, not *where* the + // suppression was installed. That distinction was measured rather than + // assumed: with the handler installed on the handle after `exec()` returns + // this still passes, because the parent installs it synchronously before + // the child is ever scheduled — even with the shell client below, which + // writes within a millisecond instead of the ~100ms this suite's Deno + // fixture spends starting. So the pre-spawn placement in `quietly()` rests + // on the mechanism, not on this row; what this row discriminates is + // suppression being absent, which it catches. + const script = yield* useScript(); + yield* writeTextFile(script, "%session-changed $0 xmd\n"); + const { written } = yield* useHostStreams(); + + // A shell rather than this suite's usual client fixture: it writes its + // record within a millisecond of `exec` instead of after a ~100ms Deno + // start, which is the narrowest window this suite can put a record in. + const tmux = createFakeTmux({ + script, + clientCommand: (mode) => + mode === "control" + ? ["/bin/sh", "-c", "echo '%session-changed $0 xmd'; sleep 30"] + : ["/bin/sh", "-c", "sleep 30"], + }); + yield* useDeadObserver(); + const grid = yield* useTmuxGrid(tmux, { + session: SESSION_MARKER, + columns: 1, + panes: 1, + width: 80, + height: 24, + titles: ["Only"], + workerCommand: () => ["true"], + cwd: path.resolve("."), + env: { PATH: "/usr/bin:/bin" }, + }); + yield* untilEvent(grid, "other"); + + // Classified — so it really did arrive and really was read. + expect(grid.events.some((event) => event.kind !== "closed")).toBe(true); + expect(written.join("").includes("%session-changed")).toBe(false); + }); + + it("TG17: internal tmux commands show the reader neither output nor error", function* () { + // Every `tmuxAt()` command is internal. A successful one writes its answer + // to stdout, which the provider parses; a failing one writes tmux's own + // complaint to stderr, which the provider turns into `undefined` or into a + // step-named refusal. Neither is the reader's business, and the private + // socket path a real complaint carries is exactly what must not appear. + // + // The tmux here is a program on `PATH` rather than the machine's: what is + // being proved is what this provider forwards, and a row that needed real + // tmux would be a real-tmux gate, which this suite does not have. + const at = yield* useFakeTmuxProgram(); + const { written, complained } = yield* useHostStreams(); + const socket = path.join(tmpdir(), `xmd-quiet-${randomUUID()}`); + const client = tmuxAt(socket, { PATH: at }); + + // Success: the answer is parsed and returned, and stays off the terminal. + expect(yield* client.run(["list-panes"])).toBe(TMUX_STDOUT_MARKER); + + // A soft failure reports nothing rather than throwing. + expect(yield* client.tryRun(["has-session", "-t", "nothing"])).toBe(undefined); + + // A hard failure surfaces the step name and nothing else. + let refusal = ""; + try { + yield* client.run(["has-session", "-t", "nothing"]); + } catch (error) { + refusal = error instanceof Error ? error.message : String(error); + } + expect(refusal).toContain("has-session"); + expect(refusal.includes(socket)).toBe(false); + expect(refusal.includes(TMUX_STDERR_MARKER)).toBe(false); + + const shown = written.join("") + complained.join(""); + expect(shown.includes(TMUX_STDOUT_MARKER)).toBe(false); + expect(shown.includes(TMUX_STDERR_MARKER)).toBe(false); + expect(shown.includes(socket)).toBe(false); + }); + + it("TG18: a whole grid's life leaves no control record on the terminal", function* () { + // Startup, pane switching, detach, server disappearance and teardown, in + // one run, watched at the host's streams. Each step is driven by a record + // the composite classifies, so the row advances on events rather than on a + // duration. + const { written, complained } = yield* useHostStreams(); + const { grid, tmux } = yield* useComposite({ panes: 2, columns: 2 }); + + yield* grid.title(0, "renamed"); + yield* tmux.say("%window-renamed @0 renamed"); + yield* untilEvent(grid, "other"); + yield* tmux.say("%window-pane-changed @0 %1"); + yield* tmux.say("%client-detached /dev/ttys999"); + yield* untilEvent(grid, "client-detached"); + yield* tmux.say("%sessions-changed"); + yield* untilEvent(grid, "sessions-changed"); + const stopped = yield* grid.stop(); + + expect(stopped.gone).toBe(true); + const shown = written.join("") + complained.join(""); + // No record, and no private metadata either: the session name and socket + // this invocation used are its own. + for (const leak of [ + "%session-changed", + "%window-renamed", + "%window-pane-changed", + "%client-detached", + "%sessions-changed", + "%exit", + SESSION_MARKER, + tmux.socket, + ]) { + expect([leak, shown.includes(leak)]).toEqual([leak, false]); + } }); it("TG6: reader detach, control loss and server stop are separate events", function* () { From cfe2609a651b6911c846dd8eee524322a5ee451b Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 06:30:49 -0400 Subject: [PATCH 45/62] =?UTF-8?q?=F0=9F=90=9B=20Name=20the=20fixture=20ent?= =?UTF-8?q?rypoints=20so=20the=20test=20runner=20does=20not=20load=20them?= =?UTF-8?q?=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full Deno corpus failed at 319cad11: 914 passed, 1 failed, and the failure was `scripts/tests/test-file-discovery.test.ts` reporting two files the corpus could see that discovery could not — + "packages/terminal-tmux/test.ts" + "packages/terminal/test.ts" Deno's own test-file pattern matches a bare `test.ts`, so both packages' controlled-fixture entrypoints were test files as far as the runner was concerned: it would load them in every shard, and they sit outside the discovery that assigns the corpus to runtimes. That is the invariant that guard exists for, and the extraction introduced it by naming an entrypoint `test.ts`. The export specifier is what the architecture fixes — `@executablemd/terminal/test` and `@executablemd/terminal-tmux/test` — not the filename behind it. So the files become `testing.ts` and the `./test` exports point at them. No consumer changes: every importer already used the specifier, and the only references to the old filenames were the two manifests. TG21j reads an entrypoint by name and follows. Both files say why they are named that way, because the obvious tidy-up is to rename them back. No new row: `test-file-discovery` is the repository-wide invariant for exactly this, it caught this, and it passes now. A second copy of it next to the packages would be a duplicate rather than evidence. --- packages/terminal-tmux/deno.json | 2 +- packages/terminal-tmux/package.json | 2 +- packages/terminal-tmux/{test.ts => testing.ts} | 4 ++++ packages/terminal/deno.json | 2 +- packages/terminal/package.json | 2 +- packages/terminal/{test.ts => testing.ts} | 4 ++++ packages/terminal/tests/package-boundary.test.ts | 4 +++- 7 files changed, 15 insertions(+), 5 deletions(-) rename packages/terminal-tmux/{test.ts => testing.ts} (89%) rename packages/terminal/{test.ts => testing.ts} (79%) diff --git a/packages/terminal-tmux/deno.json b/packages/terminal-tmux/deno.json index 9c28a6049..828e77aa5 100644 --- a/packages/terminal-tmux/deno.json +++ b/packages/terminal-tmux/deno.json @@ -3,6 +3,6 @@ "version": "0.11.0", "exports": { ".": "./mod.ts", - "./test": "./test.ts" + "./test": "./testing.ts" } } diff --git a/packages/terminal-tmux/package.json b/packages/terminal-tmux/package.json index 44ca115c7..f0464fcaa 100644 --- a/packages/terminal-tmux/package.json +++ b/packages/terminal-tmux/package.json @@ -5,7 +5,7 @@ "type": "module", "exports": { ".": "./mod.ts", - "./test": "./test.ts" + "./test": "./testing.ts" }, "dependencies": { "@effectionx/fs": "0.3.0", diff --git a/packages/terminal-tmux/test.ts b/packages/terminal-tmux/testing.ts similarity index 89% rename from packages/terminal-tmux/test.ts rename to packages/terminal-tmux/testing.ts index 7c86c7c6a..7b166e813 100644 --- a/packages/terminal-tmux/test.ts +++ b/packages/terminal-tmux/testing.ts @@ -5,6 +5,10 @@ * Not a second provider API. These are the pieces a row needs to hold one * layer to its contract — a channel without a server, a worker without tmux, a * layout string without a window — and production code imports none of them. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. */ export { useAttachClient } from "./src/attach-client.ts"; diff --git a/packages/terminal/deno.json b/packages/terminal/deno.json index 67f8b1665..d8685ae0c 100644 --- a/packages/terminal/deno.json +++ b/packages/terminal/deno.json @@ -6,6 +6,6 @@ "./lifecycle": "./lifecycle.ts", "./processes": "./processes.ts", "./posix": "./posix.ts", - "./test": "./test.ts" + "./test": "./testing.ts" } } diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 67d68ea7d..568dd92ab 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -8,7 +8,7 @@ "./lifecycle": "./lifecycle.ts", "./processes": "./processes.ts", "./posix": "./posix.ts", - "./test": "./test.ts" + "./test": "./testing.ts" }, "dependencies": { "@effectionx/context-api": "0.6.0", diff --git a/packages/terminal/test.ts b/packages/terminal/testing.ts similarity index 79% rename from packages/terminal/test.ts rename to packages/terminal/testing.ts index 79355c1ef..81b12e0be 100644 --- a/packages/terminal/test.ts +++ b/packages/terminal/testing.ts @@ -6,6 +6,10 @@ * a log whose counters are the evidence a lifecycle row reads. Production code * imports none of it; these exist so core lifecycle semantics can be proved * without tmux, a terminal, or a subprocess. + * + * The export is `./test`; the file is `testing.ts` because Deno's own test-file + * pattern matches a bare `test.ts`, which would make the test runner load this + * entrypoint as a test file in every shard. */ export { installControlledLauncher } from "./src/controlled-launcher.ts"; diff --git a/packages/terminal/tests/package-boundary.test.ts b/packages/terminal/tests/package-boundary.test.ts index 70972cdb3..e2aaef47a 100644 --- a/packages/terminal/tests/package-boundary.test.ts +++ b/packages/terminal/tests/package-boundary.test.ts @@ -227,7 +227,9 @@ describe("Tier TG21 — the terminal package boundary", () => { expect(posix.some((module) => module.endsWith("/posix-processes.ts"))).toBe(true); expect(posix.includes("node:child_process")).toBe(true); - const fixtures = yield* graphOf("test.ts"); + // `testing.ts`, not `test.ts`: Deno's own test-file pattern matches a bare + // `test.ts`, so an entrypoint by that name would be loaded as a test file. + const fixtures = yield* graphOf("testing.ts"); expect(fixtures.some((module) => module.endsWith("/controlled-launcher.ts"))).toBe(true); expect(fixtures.some((module) => module.endsWith("/controlled-composite.ts"))).toBe(true); }); From 1a5400d9df8e2963485b2bf9eb6d386cc7aa91fb Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 10:22:00 -0400 Subject: [PATCH 46/62] =?UTF-8?q?=F0=9F=90=9B=20Keep=20an=20installer's=20?= =?UTF-8?q?linked=20copies=20out=20of=20the=20boundary=20walk=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun shard 2 failed at fd5f28cf on TG21h: `NativeLauncher` was reported as having eight definitions rather than one, each of them the same file — terminal/src/native-launcher.ts terminal-tmux/node_modules/@executablemd/terminal/src/native-launcher.ts acp/node_modules/@executablemd/terminal/src/native-launcher.ts … A workspace install links every dependency package under its dependents, so one source file is reachable at many paths. My walkers did not prune `node_modules`, and Bun's layout creates those links where Deno's does not — so every row in this tier had been passing under one runtime for a reason that does not hold under the other. The count was the visible failure; the quieter one is `importsOf()`. It read a vendored copy's imports as if they were the importing package's own, which means the dependency rows — the ones that say terminal reaches no engine, host or provider — were scanning code that belongs to terminal's *dependents* and its own dependencies. They passed, but not for the reason they claim to. Both walkers now skip any path segment naming an installed or generated tree, which is the same pruning `scripts/tests/test-file-discovery.test.ts` does for the same reason. TG21l is the discriminator, asserting no walked file sits under `node_modules` for four packages and repository-wide. Disabling the pruning fails TG21l and TG21h; restoring it passes both, under Deno and under Bun. Production code is untouched. This is a defect in the evidence I wrote, found by a runtime whose install layout differs — which is the argument for running the shards rather than trusting one runtime's result. --- .../terminal/tests/package-boundary.test.ts | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/packages/terminal/tests/package-boundary.test.ts b/packages/terminal/tests/package-boundary.test.ts index e2aaef47a..a52d591b3 100644 --- a/packages/terminal/tests/package-boundary.test.ts +++ b/packages/terminal/tests/package-boundary.test.ts @@ -70,6 +70,25 @@ function* graphOf(entrypoint: string): Operation { return [...seen]; } +/** + * Trees that are an installer's rather than this repository's. + * + * `node_modules` has to go, and not only for speed: a workspace install links + * every dependency package under its dependents, so `packages/terminal-tmux/ + * node_modules/@executablemd/terminal/src/...` is the *same file* reached + * through a link. Walking it would count one definition many times and would + * read a vendored copy's imports as if they were the importing package's own — + * so a package would appear to import whatever its dependencies import. Bun's + * layout creates those links and Deno's does not, which is why this was + * invisible until the Bun shard ran. + */ +const INSTALLED = new Set(["node_modules", "npm", "dist", "generated", "vendor"]); + +/** Whether any segment of `relative` names a tree this repository does not author. */ +function installed(relative: string): boolean { + return relative.split(path.sep).some((segment) => INSTALLED.has(segment)); +} + /** Every production source of one workspace package, tests excluded. */ function* productionSources(pkg: string): Operation { const root = path.resolve("packages", pkg); @@ -81,6 +100,9 @@ function* productionSources(pkg: string): Operation { } const full = path.join(entry.parentPath ?? root, entry.name); const relative = path.relative(root, full); + if (installed(relative)) { + continue; + } // Tests prove the contract; they do not define the shipped graph. A row may // reach across packages to drive a fixture without that being a dependency // of the artifact. @@ -124,9 +146,14 @@ function* everySource(): Operation { const files: string[] = []; const entries = yield* until(readdir(root, { recursive: true, withFileTypes: true })); for (const entry of entries) { - if (entry.isFile() && entry.name.endsWith(".ts")) { - files.push(path.join(entry.parentPath ?? root, entry.name)); + if (!entry.isFile() || !entry.name.endsWith(".ts")) { + continue; } + const full = path.join(entry.parentPath ?? root, entry.name); + if (installed(path.relative(root, full))) { + continue; + } + files.push(full); } return files; } @@ -234,6 +261,25 @@ describe("Tier TG21 — the terminal package boundary", () => { expect(fixtures.some((module) => module.endsWith("/controlled-composite.ts"))).toBe(true); }); + it("TG21l: an installer's linked copies are not read as a package's own source", function* () { + // A workspace install links each dependency under its dependents, so the + // same file is reachable at `packages//node_modules/@executablemd/...`. + // Counting those would report one definition many times, and reading their + // imports would make a package appear to import whatever its dependencies + // import. Bun's layout creates the links, Deno's does not — so every row + // above was passing under one runtime for a reason that does not hold under + // the other. + for (const pkg of ["terminal", "terminal-tmux", "core", "cli"]) { + const strayed = (yield* productionSources(pkg)).filter((file) => + file.includes(`${path.sep}node_modules${path.sep}`), + ); + expect([pkg, strayed]).toEqual([pkg, []]); + } + expect( + (yield* everySource()).filter((file) => file.includes(`${path.sep}node_modules${path.sep}`)), + ).toEqual([]); + }); + it("TG21d: a walked package with no sources would not pass vacuously", function* () { // The rows above are absence claims, and an absence claim over an empty set // is free. This is the discriminator: the walk finds real files. From a73b2d6d53cdaaac286044c69590f9f07eb9083d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sat, 5 Sep 2026 11:40:10 -0400 Subject: [PATCH 47/62] =?UTF-8?q?=F0=9F=90=9B=20Give=20a=20pane's=20launch?= =?UTF-8?q?ed=20child=20the=20terminal=20it=20is=20drawing=20on=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the host's non-empty COLORTERM in the restricted pane environment, and preserve an omitted native-launch environment through the private worker protocol so the child inherits that environment. Explicit environment maps remain exact. --- packages/cli/src/grid-host.ts | 18 ++- packages/cli/tests/terminal-host.test.ts | 107 +++++++++++++++++- packages/terminal-tmux/src/pane-child.ts | 10 +- packages/terminal-tmux/src/pane-protocol.ts | 24 +++- packages/terminal-tmux/src/provider.ts | 8 +- .../tests/terminal-grid-tmux.test.ts | 107 ++++++++++++++++++ 6 files changed, 266 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts index 660979d6d..591d773b3 100644 --- a/packages/cli/src/grid-host.ts +++ b/packages/cli/src/grid-host.ts @@ -116,7 +116,23 @@ export class TerminalLost extends Error { */ function paneEnvironment(source: Record): Record { const env: Record = {}; - for (const name of ["PATH", "HOME", "SHELL", "LANG", "TMPDIR", "USER", "LOGNAME"]) { + for (const name of [ + "PATH", + "HOME", + "SHELL", + "LANG", + "TMPDIR", + "USER", + "LOGNAME", + // What a terminal program reads to decide it may use 24-bit colour. + // Passed through when this host has it, absent when it does not: naming a + // capability the reader's terminal lacks is worse than leaving a program + // on the 256 colours `TERM` already promises. It is named here because a + // pane's direct child reads none of the reader's shell startup — a + // variable their `.zshrc` exports reaches an interactive shell in a pane + // and nothing else, which is exactly the difference this closes. + "COLORTERM", + ]) { const value = source[name]; if (value !== undefined && value !== "") { env[name] = value; diff --git a/packages/cli/tests/terminal-host.test.ts b/packages/cli/tests/terminal-host.test.ts index 0342c2201..a9e01686b 100644 --- a/packages/cli/tests/terminal-host.test.ts +++ b/packages/cli/tests/terminal-host.test.ts @@ -129,9 +129,16 @@ function useShellFixture(room: string): Operation { const file = path.join(room, "shell"); yield* writeTextFile( file, - ["#!/bin/sh", `echo $$ > "${room}/shell-pid"`, "while true; do sleep 0.05; done", ""].join( - "\n", - ), + [ + "#!/bin/sh", + // Its own environment, before anything else. A plain script sources no + // startup file, so what this records is what the pane handed it rather + // than what a `.zshrc` added afterwards. + `env > "${room}/shell-env"`, + `echo $$ > "${room}/shell-pid"`, + "while true; do sleep 0.05; done", + "", + ].join("\n"), ); yield* until(chmod(file, 0o755)); yield* provide(file); @@ -483,6 +490,100 @@ describe("Tier TH — host installation", () => { ); }); + it("TH7: a pane's child is told the terminal's colour depth without a shell startup", function* () { + // The reported defect: an agent launched into a pane was colourless while + // the same program run by hand in the grid's Shell pane had colour. By + // hand it had colour because an interactive shell sources the reader's + // startup files, and theirs export `COLORTERM`. A pane's direct child + // sources nothing, so what it knows about the terminal is only what the + // host hands it — and `COLORTERM` was not in that list. + // + // Deliberately without an `env` override, so `paneEnvironment()` is what + // builds the environment. The shell here is a plain script: it records what + // it was given before doing anything, so nothing a startup file might add + // can be mistaken for what the pane provided. + const room = yield* useScratch(); + const shell = yield* useShellFixture(room); + const script = yield* useScript(); + const invocation = cliCommand([]); + const tmux = createFakeTmux({ script, clientCommand, spawnPanes: true }); + yield* ensure(() => { + tmux.stopPanes(); + }); + + const hadColor = process.env.COLORTERM; + const hadShell = process.env.SHELL; + process.env.COLORTERM = "truecolor"; + process.env.SHELL = shell; + yield* ensure(() => { + if (hadColor === undefined) { + delete process.env.COLORTERM; + } else { + process.env.COLORTERM = hadColor; + } + if (hadShell === undefined) { + delete process.env.SHELL; + } else { + process.env.SHELL = hadShell; + } + }); + + yield* writeTextFile( + path.join(room, "doc.md"), + ["", '', "", ""].join( + "\n", + ), + ); + yield* installControlledLauncher({ outcome: () => ({ exitCode: 0 }) }); + + yield* scoped(function* () { + yield* foregroundTerminalGrid({ + isTerminal: () => true, + createTmux: () => tmux, + // deno-lint-ignore require-yield + *askVersion() { + return { code: 0, stdout: "tmux 3.6a" }; + }, + workerCommand: function* (ordinal, at) { + return [ + invocation.command, + ...invocation.arguments, + PANE_WORKER_COMMAND, + String(ordinal), + at, + ]; + }, + })(); + + yield* spawn(function* () { + while (!(yield* exists(`${room}/shell-pid`))) { + yield* sleep(15); + } + while (tmux.clients.length === 0) { + yield* sleep(15); + } + yield* tmux.say(`%client-detached ${tmux.clients[0] ?? ""}`); + }); + + const execution = yield* execute({ + path: path.join(room, "doc.md"), + stream: new InMemoryStream(), + includes: [room], + }); + const subscription = yield* execution.output; + let next = yield* subscription.next(); + while (!next.done) { + next = yield* subscription.next(); + } + yield* execution; + }); + + const given = yield* readTextFile(`${room}/shell-env`); + // What the terminal is, and how much of it the child may use. + expect(given).toContain("TERM="); + expect(given).toContain("COLORTERM=truecolor"); + }); + it("TH3: a host that installs no provider still validates the grid", function* () { // Node and Bun: the same language and the same validation, and core's own // refusal rather than a provider that half-works. diff --git a/packages/terminal-tmux/src/pane-child.ts b/packages/terminal-tmux/src/pane-child.ts index d1287d9a4..d96b3db70 100644 --- a/packages/terminal-tmux/src/pane-child.ts +++ b/packages/terminal-tmux/src/pane-child.ts @@ -37,7 +37,15 @@ import type { Settlement } from "./pane-protocol.ts"; export interface PaneChildRequest { readonly argv: readonly string[]; readonly cwd: string; - readonly env: Record; + /** + * The child's environment, or absent to inherit this worker's. + * + * Absent is meaningful: tmux started this worker with the pane's + * environment, so inheriting it is how a launch that named none gets the + * terminal it is drawing on — `TERM`, and `COLORTERM` where the host has one. + * A supplied environment is used exactly, with nothing ambient added. + */ + readonly env?: Record; } export interface PaneChildOutcome { diff --git a/packages/terminal-tmux/src/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts index 14f8e81af..61ece9ffc 100644 --- a/packages/terminal-tmux/src/pane-protocol.ts +++ b/packages/terminal-tmux/src/pane-protocol.ts @@ -101,7 +101,24 @@ export type FromWorker = export type ToWorker = | { type: "welcome" } | { type: "display"; seq: number; text: string } - | { type: "launch"; id: string; argv: string[]; cwd: string; env: Record } + /** + * Start a program on this pane's terminal. + * + * `env` omitted and `env` empty are different instructions, which is why it + * is optional rather than defaulted. Omitted means "the environment you + * already have" — the pane's, which tmux gave this worker — and is what a + * caller that named no environment meant. An empty map means "start this with + * nothing", which is a thing a caller may ask for and which no default should + * silently produce. Collapsing the first into the second is how a launched + * program came to run with no `TERM`, no `PATH` and no `HOME` at all. + */ + | { + type: "launch"; + id: string; + argv: string[]; + cwd: string; + env?: Record; + } | { type: "cancel"; id: string } | { type: "shutdown" }; @@ -174,7 +191,10 @@ const ToWorkerSchema = z.discriminatedUnion("type", [ id: z.string(), argv: z.array(z.string()).min(1), cwd: z.string(), - env: z.record(z.string(), z.string()), + // Optional, not defaulted: an absent `env` and an empty one are different + // instructions. Still exact when present — a value that is not a string + // makes the frame malformed rather than being coerced. + env: z.record(z.string(), z.string()).optional(), }), z.object({ type: z.literal("cancel"), id: z.string() }), z.object({ type: z.literal("shutdown") }), diff --git a/packages/terminal-tmux/src/provider.ts b/packages/terminal-tmux/src/provider.ts index e1d9aac41..3fc04e462 100644 --- a/packages/terminal-tmux/src/provider.ts +++ b/packages/terminal-tmux/src/provider.ts @@ -406,7 +406,13 @@ export function* runInPane( id, argv: [...request.command], cwd: request.cwd, - env: request.env ?? {}, + // Carried only when the caller named one. `?? {}` used to sit here, and it + // turned "inherit" into "empty": at the root an absent `env` means the + // child inherits this process's, so a pane collapsing it to `{}` started + // the program with no environment whatsoever — no `TERM`, so no colour, and + // no `PATH` or `HOME` either. An environment that *is* supplied crosses + // exactly, gaining nothing ambient. + ...(request.env === undefined ? {} : { env: request.env }), }); while (true) { const frame = yield* link.next(); diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index d1fa6369c..3b8341878 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -406,6 +406,44 @@ function closedWithin(socket: net.Socket, limitMs: number): Operation { })(); } +/** + * Tier TP — what the wire format itself admits. + * + * Its own block rather than a row inside Tier TW: these are the schema's + * answers, needing no socket, worker or process, and a tier that spawns real + * children is both slower and a worse place to read them. + */ +describe("Tier TP — the pane protocol's launch frame", () => { + it("TP5: a launch may omit an environment, name one exactly, or be refused", function* () { + // Three distinct answers, because a launch's `env` carries three distinct + // meanings. Omitted is "the environment you already have"; a map is that + // exact map, empty included; anything else is not the protocol. + const base = { type: "launch", id: "x", argv: ["/bin/true"], cwd: "/tmp" }; + + const omitted = parseToWorker(base); + expect(omitted.type === "launch" && omitted.env).toBe(undefined); + + const exact = parseToWorker({ ...base, env: { TERM: "xterm-256color" } }); + expect(exact.type === "launch" && exact.env).toEqual({ TERM: "xterm-256color" }); + + // Empty is a real instruction — start this with nothing — and survives as + // itself rather than being read as "omitted". + const empty = parseToWorker({ ...base, env: {} }); + expect(empty.type === "launch" && empty.env).toEqual({}); + + // Malformed rather than coerced: a number is not an environment value, and + // this channel is how one process is asked to start a program on a + // terminal. + let refused = ""; + try { + parseToWorker({ ...base, env: { TERM: 256 } }); + } catch (error) { + refused = error instanceof Error ? error.name : String(error); + } + expect(refused).not.toBe(""); + }); +}); + describe("Tier TW — the pane worker and its private channel", () => { it("TW1: the private directory is 0700 and its tokens 0600", function* () { const channels: PaneChannels = yield* usePaneChannels(2); @@ -1819,6 +1857,75 @@ describe("Tier TG20 — a pane launch reaches its own worker", () => { expect(yield* exists(`${room}/go`)).toBe(true); }); + it("TG20f: a launch that names no environment inherits the pane's", function* () { + // The defect this row exists for: `runInPane` coerced an absent `env` to + // `{}`, which is not the same instruction. At the root an absent `env` + // means the child inherits, so the pane collapsing it to empty started the + // program with *no* environment — no `TERM`, hence no colour, and no `PATH` + // or `HOME` either. The only production caller of `nativeLaunch` names no + // environment, so this was every real `` into a pane. + // + // TG20a covers a launch that supplies one, and could never have caught it. + const marker = `tg20f-${randomUUID()}`; + process.env.XMD_TG20F = marker; + yield* ensure(() => { + delete process.env.XMD_TG20F; + }); + + // Set before the workers start, because what a pane worker inherits is what + // it hands a child that named no environment. Under the fake that is this + // runner's environment; in production it is the pane's, which tmux gave the + // worker from `paneEnvironment()`. + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20f-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "$XMD_TG20F" > "${evidence}"`], + cwd: tmpdir(), + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + expect(yield* readTextFile(evidence)).toBe(marker); + }); + + it("TG20g: an environment that is supplied crosses exactly, gaining nothing", function* () { + // The other half. Inheriting when none was named must not become merging + // when one was: a caller that named an environment gets that environment, + // and no ambient variable of this process joins it. + const marker = `tg20g-${randomUUID()}`; + process.env.XMD_TG20G = marker; + yield* ensure(() => { + delete process.env.XMD_TG20G; + }); + + const { composite } = yield* useLiveComposite(1); + const evidence = path.join(tmpdir(), `xmd-tg20g-${randomUUID()}.txt`); + yield* ensure(function* () { + yield* rm(evidence, { force: true }); + }); + + const outcome = yield* composite.launch( + 0, + { + command: ["/bin/sh", "-c", `printf '%s' "[$XMD_TG20G][$XMD_TG20G_OWN]" > "${evidence}"`], + cwd: tmpdir(), + env: { PATH: "/usr/bin:/bin", XMD_TG20G_OWN: "named" }, + }, + () => {}, + ); + + expect(outcome.exitCode).toBe(0); + // The named entry arrived; the ambient one did not follow it in. + expect(yield* readTextFile(evidence)).toBe("[][named]"); + }); + it("TG20e: a cancelled pane launch does not return while its child lives", function* () { const { composite } = yield* useLiveComposite(1); const room = yield* useScratch(); From 81c8a4d91b8033df7cc9d1540bea9250c59155d6 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 03:38:44 -0400 Subject: [PATCH 48/62] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Scope=20native=20?= =?UTF-8?q?capability=20advertisement=20to=20proved=20builds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 3 +- specs/decisions.md | 82 ++++++++++ specs/native-agent-session-launch-spec.md | 185 +++++++++++++++++----- 3 files changed, 231 insertions(+), 39 deletions(-) diff --git a/architecture.md b/architecture.md index 90e980cac..ee8c8b26c 100644 --- a/architecture.md +++ b/architecture.md @@ -118,6 +118,7 @@ Existing documents and code get aligned to this section retroactively. | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | | executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, an adapter-canonical version and the lowercase SHA-256 of the canonical executable target. Equality is exact over all three, so a matching build reached at another path is the same build and a changed build at the same path is not. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability compatibility point | the smallest installed-provider environment for which one native capability's real-CLI proof passed: adapter, capability, adapter-canonical reported version, host operating system and host architecture. A proof authorizes only that point, never every semver-shaped build or every platform. The executable digest remains the per-session continuity binding rather than a global allow-list: the compatibility proof describes a released provider build across installations, while the route must still recognize the exact executable that accepted its identity | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and asks that same file its version. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | @@ -4646,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store and executable observer — and Node and Bun keep the same advertised names while assembling none of it, so every advertised operation refuses before provider work; `claude` is advertised for native launch after passing the client-allocated gate at Claude Code 2.1.241 on macOS arm64 (#520) and separately for client-native attachment after passing the native-to-ACP marker gate (#561), and Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only at a compatibility point whose applicable real-CLI proof passed; a canonical-looking version is only an observation, not proof. That compatibility gate follows observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability point and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; `claude` is admitted for native launch only at the proved Claude Code 2.1.241/macOS/arm64 compatibility point (#520) and separately for client-native attachment at the point proven by the native-to-ACP marker gate (#561); another Claude version or platform is unadvertised until its own applicable real-CLI proofs pass, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/decisions.md b/specs/decisions.md index 4a4a64e2d..3a0d53b87 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -806,3 +806,85 @@ publication, generated dependency ordering, package discovery, runtime test discovery, and release verification. Moving tests changes the measured corpus, so its weights are remeasured by the repository workflow rather than edited by hand. + +## DEC-017: Native capability advertisement is proof-point scoped + +**Status:** Decided + +**Date:** 2026-09-06 + +### Context + +Claude's client-allocated launch proof ran against Claude Code 2.1.241 on +macOS arm64, but the built-in advertisement named only `claude` and the version +parser accepted every canonical semver-shaped Claude Code line. That turns one +installed-CLI observation into a claim about builds and platforms the proof did +not exercise. + +Executable build binding answers a different question. Its canonical version +and executable digest let a later operation recognize the exact executable that +accepted one retained identity. They do not prove that native launch or +client-native ACP attachment works on that executable in the first place. + +The client-allocated contract deliberately accepts two zero-turn outcomes: the +same identity resumes, or the provider refuses that exact absent identity and +XMD fails closed. A provider which persists no conversation until its first user +turn therefore does not, by that fact alone, violate the contract. Adding a +hidden materialization turn would instead violate the accepted rule that native +launch performs no model turn. + +### Decision + +One real-CLI proof admits one native capability compatibility point: + +```text +adapter + capability + canonical reported version + host OS + host architecture +``` + +Native launch and client-native attachment retain separate admission because +their proofs establish different behavior. The trusted host supplies OS and +architecture directly beside the coordinator, route store and executable +observer; shared provider code does not detect a runtime and document code +cannot replace these facts. The executable digest remains the route's exact +per-session continuity binding rather than a global allow-list for one +operator's installation. + +For a new client-allocated session the provider observes the executable and +checks the applicable point before identity allocation, route publication, +private-file creation or process start. A bound route is checked again before +native resume, attachment ensure and incomplete replay. A point the host has not +admitted refuses with `unsupported-capability`; a live build which differs from +the route still refuses with `executable-binding-refused`. Completed replay and +legacy V1 native-only resume keep their existing behavior. + +Claude is admitted for native launch and, independently, attachment only at the +applicable Claude Code 2.1.241/macOS/arm64 points already proved. Another +version or platform remains unsupported until the full applicable real-CLI +proof passes and its exact point is added. A semver-shaped version line alone +never widens admission. + +An already-published route whose exact provider identity is absent remains the +authoritative account. Native resume or ACP attachment fails closed without +allocating, creating, converting, deleting, rewriting or substituting a +conversation. An exact-resume refusal is an ordinary child outcome: settled +child teardown, cleanup and handle release acknowledge quiescence even when the +child exited unsuccessfully; any unproved teardown keeps the recovery tombstone +active. + +No XMD-owned materialization turn is added. A provider-native creation protocol +which retains the exact identity without a turn may replace the current command +only after its own real-CLI proof. Allowing a fixed bootstrap/model turn is a +separate product decision and specification change. + +### Consequences + +The provider's static adapter-name sets are only a coarse selection and cannot +authorize client-allocated work by themselves. The host/provider assembly gains +live platform facts and a controlled compatibility-admission seam, so tests can +state exact points without reading the active runtime. Route, journal, request, +provider identity, authored syntax and build-binding schemas do not change. + +This is a repair to the unmerged terminal/native-session stack and lands +directly atop its delivery head. It is not a separate Story: the terminal grid +made the native-launch path observable but did not create this capability +boundary. diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 46c04eb97..ebb193e4b 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -399,6 +399,71 @@ observed, and no binding is invented for it. It never authorizes ACP attachment and is never upgraded in place, because a build observed today says which build is installed now, not which one established the conversation. +### Compatibility admission + +An installed-CLI proof admits one **compatibility point**, not an adapter name +for every build which happens to parse. The point consists of the adapter, the +capability proved, the adapter-canonical reported version, the host operating +system and the host architecture. Native launch and client-native attachment +have independent points because their proofs ask different questions. The +executable digest remains the exact continuity binding for one retained +session; it is not a global allow-list for one operator's installation. + +```ts +interface NativeCapabilityCompatibilityPoint { + readonly agent: string; + readonly capability: "native-launch" | "client-native-attachment"; + readonly reportedVersion: string; + readonly platform: string; + readonly architecture: string; +} + +interface NativeCapabilityCompatibility { + readonly host: { readonly platform: string; readonly architecture: string }; + readonly points: readonly NativeCapabilityCompatibilityPoint[]; +} +``` + +The ACP provider receives `NativeCapabilityCompatibility` through its trusted +construction dependencies. The built-in point list is adapter-owned evidence; +the Deno and compiled entrypoints supply their live host pair, and controlled +tests replace the whole value. The existing advertised-name sets remain the +coarse host selection needed by provider-returned adapters and by hosts which +must explain why their authority is incomplete. For a client-allocated adapter, +a selected name without a matching point authorizes nothing. + +The trusted host supplies its operating system and architecture directly with +the coordinator, route store and executable observer. They are not document +values, Context, Agent input or middleware. After observing a client-allocated +adapter's build, the provider requires a matching compatibility point before it +allocates an identity, publishes a route, writes the instruction file or starts +a process. Existing bound routes are checked again before native resume, +client-native attachment or incomplete replay can contact the provider. A +canonical semver-shaped line establishes only that the build can be named; it +does not advertise a capability. + +A point mismatch is `unsupported-capability`. The diagnostic may identify the +agent and capability, and carries no raw version output, executable path, +digest, environment or host message. The route and journal schemas do not +change: platform facts are live admission inputs, while the route's build +binding continues to answer whether this is the exact executable that accepted +the retained identity. + +Claude's two admitted points are scoped to `2.1.241 (Claude Code)` on +`darwin`/`arm64`, where the applicable launch and attachment proofs ran. A +different Claude version, operating system or architecture is unsupported until +the applicable real-CLI proof passes there and that exact point is added. The +observed zero-turn behavior of a newer Claude that retains no conversation is +not by itself a regression: claim 5 explicitly permits the provider to refuse +that exact identity, provided XMD fails closed without substitution. + +Compatibility admission adds no materialization turn. Launch still performs no +model turn, and bootstrap turns remain outside this contract. Enabling a build +which cannot satisfy the accepted protocol requires either a different +provider-native creation mechanism proven to retain the same identity without a +turn, or an explicit product decision that changes this contract; parsing its +version is never enough. + ### Attachment capability Native launch and client-native ACP attachment are separate trusted-host @@ -406,7 +471,8 @@ choices, and neither is inferred from the other or from an adapter's shape. An adapter may be proven to hand a session to a native UI without being proven to join that conversation afterwards. -`claude` is advertised for both. Its attachment claim was proven by +`claude` is advertised for both only when the live compatibility point matches. +Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same identity and the same observed build, and an independent route naming an absent @@ -424,10 +490,10 @@ before a provider-returned adapter's ACP session is released, and before a client-allocated adapter allocates an identity or writes a private file. That is the failure this contract asks for rather than a hopeful spawn. -`claude` is advertised. Its client-allocated claims were proven through the -production CLI against **Claude Code 2.1.241 on macOS arm64**, which is the -compatibility point the advertisement stands on. `codex` is unadvertised: its -command shape and adapter contract tests exist, and nothing has run its +`claude` is advertised only at the applicable compatibility points. Its +client-allocated claims were proven through the production CLI against +**Claude Code 2.1.241 on macOS arm64**. `codex` is unadvertised: its command +shape and adapter contract tests exist, and nothing has run its provider-returned claims against an installed Codex. ## Runtime sequence @@ -469,16 +535,22 @@ owner to release — what has to be settled first is which conversation this is: than converting: a session ACP already established, or a route that disagrees about the instruction layer or the launcher, ends the launch here. 10. The build is observed before an identity is made, so a build this run - cannot name ends the launch before anything durable is written, and a route - that already names a different build ends it with + cannot name ends the launch before anything durable is written. The + adapter, capability, canonical reported version and the trusted host's + operating system and architecture must then match a proved compatibility + point; a mismatch ends with `unsupported-capability`, still before an + identity or session-state mutation. The launch retains that refusal at + `prepared` without an identity, as it does every other preparation refusal. + A route that already names a different exact build ends with `executable-binding-refused`. A legacy unbound route is the exception: it observes nothing, resumes under the launcher name, and gains no binding. - Whether an identity is needed at all is decided next. An existing compatible - `client-native` route already names this conversation, so its retained - identity is adopted and **nothing is allocated** — a second candidate for a - conversation that already exists is a value with nowhere to go. Only where - no route names it yet does the adapter allocate one, inside ownership and - before any process exists; nothing else supplies or replaces it. + Whether an identity is needed at all is decided next. An existing + compatible `client-native` route already names this conversation, so its + retained identity is adopted and **nothing is allocated** — a second + candidate for a conversation that already exists is a value with nowhere + to go. Only where no route names it yet does the adapter allocate one, + inside ownership and before any process exists; nothing else supplies or + replaces it. 11. A launch that allocated publishes the bound V2 `client-native` route create-once, and what it publishes against is authoritative — whoever published first @@ -987,13 +1059,14 @@ identity invents none. Every incomplete replay requires exact agreement between its journal and its route on identity, provenance, instruction digest, launcher and build binding -before its first live effect, and then requires the live build to equal that -binding. Neither account repairs or republishes the other: a replay that -found a disagreement has discovered that the session it was going to continue is -not the session it prepared, and retains `identity-unavailable` without starting -a child. Equal instructions may resume the retained identity; different -instructions retain `instructions-refused` and replace neither the layer, the -route, the identity, nor any provider state. +before its first live effect, and then requires the live capability point to be +admitted and the live build to equal that binding. Neither account repairs or +republishes the other: a replay that found a disagreement has discovered that +the session it was going to continue is not the session it prepared, and +retains `identity-unavailable` without starting a child. Equal instructions may +resume the retained identity; different instructions retain +`instructions-refused` and replace neither the layer, the route, the identity, +nor any provider state. An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can @@ -1104,12 +1177,30 @@ names the stable class, the launcher, and the two canonical versions being compared; it carries no executable path, raw version output, host error, argv, environment, credential, instruction text or provider payload. +A build the provider can name but whose adapter, capability, canonical version, +operating system or architecture has no admitted compatibility point fails with +`unsupported-capability`. On initial construction this is before identity +allocation and every provider or session-state mutation; the launch's retained +refusal is the only durable outcome. On a bound route it is before native +resume, attachment ensure or incomplete replay performs live work. The route +remains unchanged. + An attachment that reaches the provider and cannot open the conversation the route names fails with `identity-unavailable`: missing provider history, an adapter that cannot resume by name, a retained provider arrangement asserting another conversation or none, and a returned identity that differs from the route's are one answer, and none of them creates a substitute conversation. +A native resume which the provider refuses because the exact retained identity +has no conversation is the other permitted zero-turn answer. Its child outcome +is retained normally and the authoritative route stays in place; a later +attempt does not retry creation, allocate a second identity, convert or rewrite +the route, or delete provider state. A nonzero child outcome is not itself +unproven teardown. Once child settlement, private-file cleanup and handle +release prove that nothing can still act on the session, ownership acknowledges +quiescence and becomes idle. If any of those facts is unproved, the existing +recovery-tombstone rule applies instead. + A launch that cannot take ownership retains `session-busy` or `session-recovery-required` as its preparation and stops there. Both are retained rather than raised bare, so a replay resumes from the phase that @@ -1175,10 +1266,11 @@ means a launch under `` never reaches the host's launcher. Only the Deno and compiled hosts assemble machine-wide agent sessions: a session coordinator, a construction-route store and an executable observer, all rooted -together, plus the two advertised capability sets this host has proven. Node and -Bun keep the same advertised names and assemble none of the answers, so every -advertised operation refuses before provider work rather than acting while a -native UI may be in the conversation — as *Ownership and concurrency* describes. +together, plus live host platform facts and the compatibility points the +applicable proofs admitted. Node and Bun keep the same coarse advertised names +and assemble none of those answers, so every advertised operation refuses +before provider work rather than acting while a native UI may be in the +conversation — as *Ownership and concurrency* describes. Only ordinary `xmd run` receives that assembly. Every other command receives none, and a host profile whose session authority differs from ordinary `xmd run` @@ -1302,7 +1394,14 @@ Focused tests prove: pane remain exclusive, the same logical Agent session still contends across panes, pane readiness occurs only after successful native-child start, grid close awaits launch cancellation and session quiescence, and completed and - partial grid replay preserve the launch's existing identity rules. + partial grid replay preserve the launch's existing identity rules; and +25. compatibility admission accepts only the separately proved capability, + canonical version, operating system and architecture; an unproved point is + refused before identity allocation, route publication, private-file + creation, child start, attachment ensure or incomplete replay, while an + already-published absent identity is retained and never substituted. A + settled exact-resume refusal acknowledges quiescence after cleanup, while a + planted unproved teardown leaves the recovery tombstone active. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1378,9 +1477,10 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; ACP attachment to a bound client-native -session under its exact retained identity, through runtime partitions keyed by -agent command and build; +and the build binding it produces; proof-scoped compatibility admission over +the adapter, capability, canonical version and live host platform; ACP +attachment to a bound client-native session under its exact retained identity, +through runtime partitions keyed by agent command and build; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1390,14 +1490,15 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only `claude` is advertised**, and separately for each capability. It is - client-allocated, and its proofs ran the applicable claims under - *Provider-native identity* against Claude Code 2.1.241 on macOS arm64. `codex` - has a command shape and contract tests and is not launch-capable, because - nothing has proven its provider-returned claims against an installed Codex. A - launch naming an unadvertised agent is refused with `unsupported-capability` - before anything of the session moves, and so is an attachment naming an agent - advertised only for native launch. +- **Only `claude` has advertised compatibility points**, and separately for + each capability. It is client-allocated, and its applicable points are Claude + Code 2.1.241 on macOS arm64, where the claims under *Provider-native identity* + passed. A different build or platform is unadvertised until its applicable + real-CLI proof passes. `codex` has a command shape and contract tests and is + not launch-capable, because nothing has proven its provider-returned claims + against an installed Codex. A launch naming an unadvertised agent or point is + refused with `unsupported-capability` before anything of the session moves, + and so is an attachment whose own point was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The retained request says so explicitly — an empty ordered list — rather than omitting the fact, and no adapter maps a root it was never given. The ACP @@ -1507,8 +1608,16 @@ Implementation review checks these frozen invariants: core and CLI terminal paths are absent and every repository import is canonical. The terminal package imports no Agent, core, runtime, CLI or tmux module, the tmux package imports only the neutral terminal domain, and the - complete launch evidence above passes without changing any request, route, - record, provider advertisement or diagnostic. + package extraction itself changes no request, route, record, provider + advertisement or diagnostic. +31. A real-CLI proof advertises only its adapter, capability, canonical version, + operating system and architecture. A client-allocated point mismatch is + refused after observation but before allocation or any provider or + session-state mutation; its identity-free launch refusal is retained. The + point is checked again before bound resume, attachment and incomplete + replay. An exact absent identity remains authoritative and unavailable; + neither that refusal nor an unproved compatibility point creates a + replacement conversation or a materialization turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 267293535e24d0309ae2f89bfeafca097840aaab Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:28:07 -0400 Subject: [PATCH 49/62] =?UTF-8?q?=F0=9F=94=92=20Admit=20a=20native=20capab?= =?UTF-8?q?ility=20per=20proved=20build,=20not=20per=20adapter=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An advertised adapter name selected a command shape and, by omission, authorized acting on a session a native UI may be in. DEC-017 makes the authority a point — adapter, capability, exact reported version, host OS and architecture — so one real-CLI proof admits exactly what it proved. The host states the machine as a value beside the coordinator, route store and observer, so shared provider and session assembly never detects the runtime it happens to be on. --- packages/acp/mod.ts | 18 + packages/acp/src/native-capability.ts | 91 +++ packages/acp/src/native-launch.ts | 87 ++- packages/acp/src/provider.ts | 75 ++ packages/acp/tests/native-launch.test.ts | 680 ++++++++++++++++++ packages/cli/src/agent-stack.ts | 8 +- packages/cli/src/compiled.ts | 5 +- packages/cli/src/deno.ts | 5 +- packages/cli/src/session-coordinator.ts | 39 +- .../tests/agent-session-coordinator.test.ts | 83 ++- packages/test-agent/src/provider.ts | 32 +- 11 files changed, 1084 insertions(+), 39 deletions(-) create mode 100644 packages/acp/src/native-capability.ts diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index c39c348b8..ede4d89d4 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -42,6 +42,7 @@ export { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, + nativeCapabilityCompatibility, } from "./src/native-launch.ts"; export { allocatesIdentity } from "./src/native-launch.ts"; export type { @@ -51,6 +52,23 @@ export type { ProviderReturnedAdapter, } from "./src/native-launch.ts"; +/** + * Which exact builds and machines a native capability is admitted on. + * + * Public because the host is what states them: it supplies its own OS and + * architecture beside the coordinator and the observer, and shared provider + * code must not detect either for itself. + */ +export { admitsNativeCapability } from "./src/native-capability.ts"; +export type { + NativeCapability, + NativeCapabilityCompatibility, + NativeCapabilityCompatibilityPoint, + NativeCapabilityHost, + ObservedNativeCapability, + ProvedNativeCapability, +} from "./src/native-capability.ts"; + /** * ACPX's own runtime types. * diff --git a/packages/acp/src/native-capability.ts b/packages/acp/src/native-capability.ts new file mode 100644 index 000000000..b1b9212bc --- /dev/null +++ b/packages/acp/src/native-capability.ts @@ -0,0 +1,91 @@ +/** + * What one real-CLI proof admits, and nothing wider (specs/decisions.md + * DEC-017). + * + * An adapter name says which command shape to consider. It does not say that + * the thing found under that name works: a proof ran against one build of one + * CLI, on one operating system, on one architecture, and every other build and + * machine is a claim nobody made. So admission is stated as points, and a point + * is the whole tuple — + * + * adapter + capability + canonical reported version + host OS + host + * architecture + * + * — matched exactly. Nothing here parses, orders or ranges a version. A + * semver-shaped line is a value one adapter recognized, not evidence about the + * build behind it, and comparing two of them would turn one installed CLI into + * a statement about releases that have never run. + * + * The host pair is supplied rather than read. Which machine this is is a fact + * the trusted host has and shared provider code must not go looking for: a + * provider that detected its own runtime would answer the compatibility + * question with the thing being asked about. + */ + +/** + * Which behavior a proof established. + * + * Two, not one, because they are proved separately: handing a session to a + * native UI and later joining that same conversation through ACP are different + * things that can be true independently. + */ +export type NativeCapability = "native-launch" | "client-native-attachment"; + +/** The machine a host is actually running on, as that host states it. */ +export interface NativeCapabilityHost { + readonly platform: string; + readonly architecture: string; +} + +/** One capability, proved for one exact build on one exact machine. */ +export interface NativeCapabilityCompatibilityPoint { + readonly agent: string; + readonly capability: NativeCapability; + /** The canonical line the adapter recognized, whole. Never a number alone. */ + readonly reportedVersion: string; + readonly platform: string; + readonly architecture: string; +} + +/** Everything a host admits, beside the machine it admits it on. */ +export interface NativeCapabilityCompatibility { + readonly host: NativeCapabilityHost; + readonly points: readonly NativeCapabilityCompatibilityPoint[]; +} + +/** What an adapter carries about its own proofs, before a host names a machine. */ +export type ProvedNativeCapability = Omit; + +/** The live capability an observation offers for admission. */ +export interface ObservedNativeCapability { + readonly agent: string; + readonly capability: NativeCapability; + readonly reportedVersion: string; +} + +/** + * Whether this host admits what was actually observed. + * + * The host's own OS and architecture are what an admitted point is compared + * against, so a point proved elsewhere cannot admit anything here. Absent + * compatibility admits nothing: a host that states no proof has none, and + * treating silence as permission is the failure this whole tuple exists to + * prevent. + */ +export function admitsNativeCapability( + compatibility: NativeCapabilityCompatibility | undefined, + observed: ObservedNativeCapability, +): boolean { + if (compatibility === undefined) { + return false; + } + const { platform, architecture } = compatibility.host; + return compatibility.points.some( + (point) => + point.agent === observed.agent && + point.capability === observed.capability && + point.reportedVersion === observed.reportedVersion && + point.platform === platform && + point.architecture === architecture, + ); +} diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index d77b7a58a..c25ce16a1 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -32,6 +32,11 @@ import { randomUUID } from "node:crypto"; import type { IdentityProvenance } from "@executablemd/core"; +import type { + NativeCapabilityCompatibility, + NativeCapabilityHost, + ProvedNativeCapability, +} from "./native-capability.ts"; /** * What an adapter knows about the build behind its executable. @@ -111,6 +116,15 @@ interface AdapterCommands { identity: IdentityProvenance; /** The argv that resumes this exact provider-native session. */ resume(nativeSessionId: string): string[]; + /** + * The exact builds and machines a real-CLI proof of this adapter ran on. + * + * The adapter's, because the proof is about this adapter's own contract + * against its own installed CLI. Absent is the honest default: knowing a + * command shape establishes nothing, and an adapter that has proved nothing + * contributes no point for a host to admit. + */ + proved?: readonly ProvedNativeCapability[]; } /** @@ -153,6 +167,21 @@ export function allocatesIdentity(adapter: NativeAdapter): adapter is ClientAllo return adapter.identity === "client-allocated"; } +/** + * The one build and machine Claude's proofs ran on. + * + * Written once and shared by both points below so they cannot drift apart into + * two claims about two builds. Raising either is a new proof rather than an + * edit here: what makes this admissible is that a real CLI was driven through + * the whole applicable contract on exactly this, and nothing about that + * generalizes to the next release or the next machine. + */ +const CLAUDE_PROVED_BUILD = { + reportedVersion: "2.1.241 (Claude Code)", + platform: "darwin", + architecture: "arm64", +} as const; + const ADAPTERS: Readonly> = { claude: { launcher: "claude", @@ -161,6 +190,14 @@ const ADAPTERS: Readonly> = { identity: "client-allocated", // Claude takes a UUID it has never seen and makes it the session's name. allocate: () => randomUUID(), + // Two points rather than one: `ClaudeNativeLaunch.test.md` and + // `ClaudeZeroTurnExit.test.md` showed the launch contract, and + // `ClaudeNativeToAcp.test.md` showed attachment. Either could have failed + // while the other held, so neither is written down as the other's evidence. + proved: [ + { capability: "native-launch", ...CLAUDE_PROVED_BUILD }, + { capability: "client-native-attachment", ...CLAUDE_PROVED_BUILD }, + ], binding: { command: "claude", version: claudeVersion, @@ -189,42 +226,50 @@ const ADAPTERS: Readonly> = { }; /** - * The adapters whose native creation, instruction and resume contracts have - * been proven against the installed CLI. + * The adapters this host will consider for native launch at all. * - * `claude` is here because `packages/acp/src/ClaudeNativeLaunch.test.md` and - * `packages/acp/src/ClaudeZeroTurnExit.test.md` ran the production command - * through the built binary against Claude Code 2.1.241 on macOS arm64 and - * showed the whole applicable contract: the adapter allocated the identity, the - * native process created that exact conversation from a private mode-0600 file, - * the layer governed the first user turn with no bootstrap, and a second - * independent invocation resumed the same identity — including a session left - * without a word said in it. + * A coarse selection and nothing more. For an adapter that names its own + * sessions the name authorizes no work by itself: what admits one is the + * compatibility point below, matched against the build actually found and the + * machine actually running. A name reaches the question; it does not answer it. * * `codex` is absent. Its command shape is known and its adapter contract tests * pass, and neither is the proof: nothing has run it against an installed - * Codex. A host may still advertise an adapter itself by passing its name - * through `AcpxProviderDependencies.advertiseNativeLaunch`. + * Codex. A host may still name an adapter itself by passing it through + * `AcpxProviderDependencies.advertiseNativeLaunch`. */ export const ADVERTISED_NATIVE_LAUNCH: readonly string[] = ["claude"]; /** - * The adapters whose client-native ACP attachment has been proven against the - * installed CLI. + * The adapters this host will consider for client-native ACP attachment. * * A separate list from the one above, because they are separate capabilities: * handing a session to a native UI and later joining that same conversation * through ACP prove different things. An adapter may have the first without the - * second. - * - * `claude` is here because `packages/acp/src/ClaudeNativeToAcp.test.md` ran the - * production command through the built binary: a native turn carrying a random - * marker, then a marker-free ACP Prompt that recovered it under the same - * provider-native identity and the same observed build, and an independent - * absent identity that refused without taking a turn. + * second. Like that list, this one selects rather than authorizes. */ export const ADVERTISED_CLIENT_NATIVE_ATTACHMENT: readonly string[] = ["claude"]; +/** + * What this build's adapters have proved, on the machine a host says it is. + * + * The two halves come from where each is known. Which builds were driven + * through a real CLI is the adapters' own evidence and is compiled in beside + * them; which OS and architecture are underneath right now is the host's, and + * arrives here rather than being detected. Neither half admits anything alone — + * a point is only admitted where a proof and the machine it ran on meet. + */ +export function nativeCapabilityCompatibility( + host: NativeCapabilityHost, +): NativeCapabilityCompatibility { + return { + host, + points: Object.entries(ADAPTERS).flatMap(([agent, adapter]) => + (adapter.proved ?? []).map((proved) => ({ agent, ...proved })), + ), + }; +} + export function nativeAdapterFor(agentName: string): NativeAdapter | undefined { return Object.hasOwn(ADAPTERS, agentName) ? ADAPTERS[agentName] : undefined; } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 6eb0597a5..7b91b51b6 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -104,6 +104,8 @@ import { nativeAdapterFor, } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; +import { admitsNativeCapability } from "./native-capability.ts"; +import type { NativeCapability, NativeCapabilityCompatibility } from "./native-capability.ts"; /** * One MCP server as ACPX configures them. @@ -241,6 +243,20 @@ export interface AcpxProviderDependencies { * whose sessions XMD names refuses before any provider effect. */ executableObserver?: ExecutableObserver; + /** + * Which exact builds this host has proved each native capability on, and the + * machine it proved them for. + * + * Supplied by the trusted host beside the coordinator and the observer, and + * for the same reason: it carries this machine's OS and architecture, and + * shared provider code that went and read those would be answering the + * compatibility question with the thing being asked about. + * + * The advertised sets above choose which adapter to consider. This is what + * says the build actually found under it may be acted on. Absent admits + * nothing — a host that states no proof has none. + */ + compatibility?: NativeCapabilityCompatibility; /** * Extra native adapters, by agent name. A harness driving an agent this * package has never heard of supplies its own resume command shape here @@ -817,6 +833,7 @@ function* useAcpxProviderState( const coordinator = dependencies?.coordinator; const routeStore = dependencies?.routeStore; const executableObserver = dependencies?.executableObserver; + const compatibility = dependencies?.compatibility; const agentCwd = dependencies?.agentCwd ?? cwd; const prepareAgent = dependencies?.prepareAgent; const mcpServers = dependencies?.mcpServers; @@ -1720,6 +1737,40 @@ function* useAcpxProviderState( }; } + /** + * Whether this host has proved this capability on the build it just observed. + * + * One function for all four paths that reach a client-allocated adapter, so + * "proved" means the same tuple everywhere rather than four readings of it. + * It runs after the observation, because the build is what is being admitted, + * and before every effect on the other side — an identity, a published route, + * a private file, a child, an ACP ensure — because a capability nobody proved + * is not a thing to discover halfway through. + * + * Distinct from build drift beside it, which is a different question. Drift + * asks whether this is still the build that accepted one retained identity; + * this asks whether the build works at all. A session may fail either while + * passing the other. + */ + function admitCapability( + agentName: string, + capability: NativeCapability, + build: BoundBuild, + ): LaunchFailure | undefined { + const { reportedVersion } = build.binding; + if (admitsNativeCapability(compatibility, { agent: agentName, capability, reportedVersion })) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `this host has proved no ${capability} capability for "${agentName}" at ` + + `${reportedVersion} on the machine it is running on, so it will not act on a session ` + + `with it. An advertised adapter name selects a command shape; only a proof against ` + + `that exact installed build admits one.`, + }; + } + /** The stable comparison two builds of one session fail. */ function buildDrift( sessionKey: string, @@ -1817,6 +1868,14 @@ function* useAcpxProviderState( } const binding = (adapterFor(agentName) as ClientAllocatedAdapter).binding; const build = yield* observeBuild(agentName, agentCommand, binding); + // Before the comparison and long before the ensure. A build this host has + // not proved attachment on is refused whether or not it happens to be the + // build that created the session — being the right one is not evidence that + // joining the conversation through ACP works on it. + const unproved = admitCapability(agentName, "client-native-attachment", build); + if (unproved) { + throw new AttachmentRefused(unproved); + } if (!sameExecutableBuild(build.binding, route.executableBinding)) { throw new AttachmentRefused( buildDrift(prepared.sessionKey, route.executableBinding, build.binding), @@ -2335,6 +2394,14 @@ function* useAcpxProviderState( } throw error; } + // Admitted here, which is before both of the things that follow: allocating + // an identity for a session that has none, and resuming one that has. The + // route is only read on this side of it, never written — a session already + // published stays exactly as its first publication left it. + const unproved = admitCapability(agentName, "native-launch", build); + if (unproved) { + return refusal(unproved.class, unproved.message, known); + } if ( route?.route === "client-native" && !sameExecutableBuild(build.binding, route.executableBinding) @@ -2968,6 +3035,14 @@ function* useAcpxProviderState( } throw error; } + // The replay's own first live phase, and this is still ahead of it. A + // predecessor that was admitted proves nothing about this run: the build + // under the same command may have been replaced since, and a capability is + // a claim about the build rather than about the session. + const unproved = admitCapability(prepared.agent, "native-launch", build); + if (unproved) { + return unproved; + } if (!sameExecutableBuild(build.binding, route.executableBinding)) { return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); } diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 6cdf80860..4d1de0c2c 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -22,6 +22,7 @@ import { Agent } from "@executablemd/core"; import type { AgentLaunchRequest, AgentProviderAuthority, + DetachedLaunchRecord, ExitedLaunchRecord, LaunchRecord, PreparedLaunchRecord, @@ -38,7 +39,13 @@ import { allocatesIdentity, knownNativeAdapters, nativeAdapterFor, + nativeCapabilityCompatibility, } from "../src/native-launch.ts"; +import type { + NativeCapability, + NativeCapabilityCompatibility, + NativeCapabilityHost, +} from "../src/native-capability.ts"; import { createHash, randomUUID } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; @@ -165,6 +172,39 @@ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * The machine these cases describe. + * + * Stated, never read from the runner. A capability point names an exact OS and + * architecture, so a suite that asked the machine underneath it what it was + * would be admitting whatever it happened to run on — and the shipped Claude + * evidence, which is a Mac with Apple silicon, would be exercised on one CI + * shard and skipped everywhere else. + */ +const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; + +/** + * A host that has proved the named builds of `claude`, both capabilities each. + * + * For the cases that need two admitted builds at once. Everything else takes + * the package's own evidence, so what most of this file runs against is the + * shipped table rather than a convenient stand-in. + */ +function admitting(...reportedVersions: readonly string[]): NativeCapabilityCompatibility { + const capabilities: readonly NativeCapability[] = ["native-launch", "client-native-attachment"]; + return { + host: PROVED_HOST, + points: reportedVersions.flatMap((reportedVersion) => + capabilities.map((capability) => ({ + agent: "claude", + capability, + reportedVersion, + ...PROVED_HOST, + })), + ), + }; +} + /** Everything the launch touched, in the order it touched it. */ interface Trace { records: LaunchRecord[]; @@ -181,6 +221,19 @@ interface Trace { * which is the only way to show that neither answers the other. */ replay?: Replay; + /** + * A journal with every phase already in it. + * + * Separate from `replay` because there is no phase left to invoke: the + * authority hands all three records back and the provider is never entered. + * That is the whole claim a case makes with one, so a suffix that happened to + * call nothing would not be the same thing. + */ + completed?: { + prepared: PreparedLaunchRecord; + detached: DetachedLaunchRecord; + exited: ExitedLaunchRecord; + }; } interface ProviderOptions { @@ -189,6 +242,14 @@ interface ProviderOptions { attach?: readonly string[]; /** `false` gives this host no way to observe a build at all. */ observer?: ExecutableObserver | false; + /** + * Which exact points this host has proved. + * + * Defaults to the package's own evidence for the machine above, so a case + * says nothing unless it is describing a host that proved something else. + * `false` is a host that has proved nothing and admits nothing. + */ + compatibility?: NativeCapabilityCompatibility | false; store?: AcpSessionStore; adapters?: Record; /** @@ -274,6 +335,16 @@ function traceAuthority(trace: Trace): AgentProviderAuthority { throw new Error("this stub authority names no provider turn"); }, *perform(_request, phases) { + // A journal that already reached `exited` is replayed whole: no phase is + // invoked, so the provider is not entered at all. + const completed = trace.completed; + if (completed) { + for (const record of [completed.prepared, completed.detached, completed.exited]) { + trace.records.push(record); + trace.order.push(record.phase); + } + return; + } // A replay hands back what the journal retained rather than calling the // provider's live preparation, exactly as the real authority does when // the phase is already recorded. @@ -361,6 +432,9 @@ function* installLaunchStack( ...(options.observer === false ? {} : { executableObserver: options.observer ?? createFakeObserver().observer }), + ...(options.compatibility === false + ? {} + : { compatibility: options.compatibility ?? nativeCapabilityCompatibility(PROVED_HOST) }), coordinator: options.coordinator ?? trace.ownership.coordinator, ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.withSessionRoute ? { withSessionRoute: options.withSessionRoute } : {}), @@ -3364,6 +3438,8 @@ describe("Tier RT — bound runtime partitions", () => { adapters: { claude: adapter() }, routeStore: routes, observer: observer.observer, + // Two builds is the whole case, so this host has proved both of them. + compatibility: admitting("2.1.241 (Claude Code)", "2.1.242 (Claude Code)"), }); yield* Agent.operations.session(); @@ -3932,3 +4008,607 @@ describe("Tier CV — canonical Claude version", () => { expect(parse("2.1.241 (Claude Code)\n2.1.241 (Claude Code)\n")).toBe(undefined); }); }); + +/** + * Tier NP — the proved capability points a host admits + * (specs/decisions.md §DEC-017). + * + * An advertised adapter name selects a command shape. What admits work on a + * session is a point: one agent, one capability, one canonical reported + * version, one OS and one architecture, matched whole. These cases run the four + * paths that check one — fresh client-native construction, bound native resume, + * bound ACP attachment and incomplete replay — and each refusal is read at the + * boundary it is supposed to stop in front of, rather than by its message + * alone: nothing may be allocated, published, written, spawned, ensured or + * retained behind it. + */ +describe("Tier NP — proved native capability points", () => { + const ALLOCATED = "cafe0000-1111-2222-3333-444444444444"; + + /** The build the shipped Claude evidence names, and the one that follows it. */ + const PROVED_VERSION = "2.1.241 (Claude Code)"; + const LATER_VERSION = "2.1.263 (Claude Code)"; + + /** Every boundary a refused point must stop in front of. */ + interface Boundaries { + allocations: number; + creates: number; + resumes: number; + published: AgentSessionRoute[]; + } + + function boundaries(): Boundaries { + return { allocations: 0, creates: 0, resumes: 0, published: [] }; + } + + /** + * The client-allocated adapter, with each thing it can do counted. + * + * Counting the adapter rather than watching for a side effect is what makes + * "nothing was allocated" a reading instead of an inference: allocation is + * the adapter's own act, and a call to it is visible whether or not anything + * downstream kept the answer. + */ + function countedAdapter(seen: Boundaries, allocate: () => string = () => ALLOCATED) { + return { + launcher: "claude", + identity: "client-allocated", + binding: TEST_BINDING, + allocate: () => { + seen.allocations += 1; + return allocate(); + }, + create: (nativeSessionId: string, instructionFile: string) => { + seen.creates += 1; + return ["claude", "--session-id", nativeSessionId, "--system-prompt-file", instructionFile]; + }, + resume: (nativeSessionId: string) => { + seen.resumes += 1; + return ["claude", "--resume", nativeSessionId]; + }, + } satisfies NativeAdapter; + } + + /** The same store, with every publication that reached it retained. */ + function countedRoutes(seen: Boundaries): AgentSessionRouteStore { + const inner = createMemorySessionRouteStore(); + return { + read: (key) => inner.read(key), + *publish(candidate) { + seen.published.push(candidate); + return yield* inner.publish(candidate); + }, + }; + } + + const KEY = { provider: "acpx", agent: AGENT_COMMAND, sessionKey: SESSION_KEY }; + + /** The bound route a completed client-native launch leaves behind. */ + function bound(binding: ExecutableBuildBindingV1 = OBSERVED_BUILD): AgentSessionRoute { + return { + schema: "session-route.v2", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + executableBinding: binding, + }; + } + + /** The same session as the released unbound contract published it. */ + function legacyRoute(): AgentSessionRoute { + return { + schema: "session-route.v1", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + }; + } + + /** A retained preparation, as an interrupted launch would have left one. */ + function preparedRecord(): PreparedLaunchRecord { + return { + phase: "prepared", + agent: "claude", + sessionKey: SESSION_KEY, + provider: "acpx", + nativeSessionId: ALLOCATED, + sessionState: "created", + instructionChannel: "claude.systemPromptFile", + instructionReconciliation: "installed", + identityProvenance: "client-allocated", + executableBinding: OBSERVED_BUILD, + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + instructions: INSTRUCTIONS, + cwd: CWD, + additionalDirectories: [], + permissionMode: "approve-reads", + launcher: "claude", + }; + } + + /** + * Run `body` with the private root pointed at somewhere unusable. + * + * Reading an empty directory afterwards would prove nothing: a launch that + * did write a private file removes it, so success and refusal leave the same + * empty directory behind. A root that cannot hold one at all is a reading + * instead — creating the private directory under it fails in the host's own + * words, and that surfaces as `process-creation-failed`. A run that refuses + * for a capability under this fault has provably not reached the write, and + * the row below takes the same planted fault to the failure it does cause. + */ + function* withUnusablePrivateRoot(body: () => Operation): Operation { + const occupied = join(tmpdir(), `xmd-np-${randomUUID()}`); + yield* until(writeFile(occupied, "not a directory")); + yield* ensure(function* () { + yield* until(rm(occupied, { force: true }).catch(() => undefined)); + }); + const previous = process.env.TMPDIR; + process.env.TMPDIR = occupied; + try { + yield* body(); + } finally { + if (previous === undefined) { + delete process.env.TMPDIR; + } else { + process.env.TMPDIR = previous; + } + } + } + + it("NP1: the exact proved launch point constructs, on the machine it names", function* () { + // The package's own evidence, unmodified, against the build it was proved + // against. Nothing here supplies a point: what admits this launch is the + // shipped Claude adapter's, read for darwin/arm64. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + + yield* launch(INSTRUCTIONS); + + const prepared = trace.records[0] as PreparedLaunchRecord; + expect(prepared.failure).toBe(undefined); + expect(prepared.nativeSessionId).toBe(ALLOCATED); + expect(prepared.executableBinding?.reportedVersion).toBe(PROVED_VERSION); + expect(seen.allocations).toBe(1); + expect(seen.published).toHaveLength(1); + expect(trace.launches).toHaveLength(1); + // And the point that admitted it is the shipped one, for both capabilities. + expect(nativeCapabilityCompatibility(PROVED_HOST).points).toEqual([ + { + agent: "claude", + capability: "native-launch", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ]); + }); + + it("NP2: an unproved point refuses a construction before it does anything", function* () { + // Four ways to be outside the proof, one refusal each. A later build, the + // same build on another OS, the same build on another architecture, and a + // host that has proved nothing at all: none of them is a near miss that + // some other reading could let through. + for (const [name, options] of [ + ["a later build of the proved agent", { versionOutput: `${LATER_VERSION}\n` }], + ["another operating system", { host: { platform: "linux", architecture: "arm64" } }], + ["another architecture", { host: { platform: "darwin", architecture: "x64" } }], + ["a host that has proved nothing", { none: true }], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const store = makeStore(); + const observer = createFakeObserver( + "versionOutput" in options ? { versionOutput: options.versionOutput } : {}, + ); + + yield* withUnusablePrivateRoot(function* () { + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + store, + observer: observer.observer, + compatibility: + "none" in options + ? false + : "host" in options + ? nativeCapabilityCompatibility(options.host) + : undefined, + }); + const failure = yield* attempt(trace, INSTRUCTIONS); + // Not `process-creation-failed`: the private root is unusable for the + // whole of this launch, so a run that reached the instruction write + // would have failed there instead. + expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); + }); + + // The build was observed — that is how the point became a question at + // all — and then everything the answer gates stopped. Each of these is + // something NP1 sees happen on the admitted path. + expect([name, observer.observed]).toEqual([name, ["claude"]]); + expect([name, seen.allocations]).toEqual([name, 0]); + expect([name, seen.creates + seen.resumes]).toEqual([name, 0]); + expect([name, seen.published]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + // Nothing durable, and no provider conversation, under any of them. + expect([name, [...store.records.keys()]]).toEqual([name, []]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + }); + } + + // The same planted fault, on the point this host has proved: the launch + // gets past admission and fails where the private file is written. Without + // this the row above would be reading a fault that never fires. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + + yield* withUnusablePrivateRoot(function* () { + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + }); + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + }); + + const exited = trace.records.findLast( + (record) => record.phase === "exited", + ) as ExitedLaunchRecord; + expect(exited.failure?.class).toBe("process-creation-failed"); + expect(seen.allocations).toBe(1); + expect(trace.launches).toEqual([]); + }); + }); + + it("NP3: handing a session over and joining it later are admitted separately", function* () { + // Two capabilities, two proofs. A host that has proved one of them does + // that one thing and refuses the other, in both directions — so neither is + // ever inferred from the other having been proved. + const launchOnly: NativeCapabilityCompatibility = { + host: PROVED_HOST, + points: [ + { + agent: "claude", + capability: "native-launch", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ], + }; + const attachOnly: NativeCapabilityCompatibility = { + host: PROVED_HOST, + points: [ + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: PROVED_VERSION, + ...PROVED_HOST, + }, + ], + }; + + // Proved for launch only: the launch runs, and attaching to what it left + // behind refuses. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + compatibility: launchOnly, + }); + + yield* launch(INSTRUCTIONS); + expect((trace.records[0] as PreparedLaunchRecord).failure).toBe(undefined); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + expect(raised?.message).toContain("client-native-attachment"); + expect(harness.ensureCalls).toEqual([]); + }); + + // Proved for attachment only: the same session attaches, and launching + // refuses. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: attachOnly, + }); + + const session = yield* Agent.operations.session(); + expect(session.agentSessionId).toBe(ALLOCATED); + + const failure = yield* attempt(trace, INSTRUCTIONS); + expect(failure?.class).toBe("unsupported-capability"); + expect(failure?.message).toContain("native-launch"); + expect(trace.launches).toEqual([]); + }); + }); + + it("NP4: a bound route is refused before resume and before ensure, and stays as it was", function* () { + // The route was published by a run this host did admit. The build under the + // command has not changed; what changed is that this host no longer proves + // it. Both paths refuse, and neither rewrites, republishes or removes the + // account of a session a native UI may still be holding. + for (const [name, act] of [ + ["a native resume", (trace: Trace) => attempt(trace, INSTRUCTIONS)], + [ + "an ACP attachment", + function* (): Operation { + try { + yield* Agent.operations.session(); + } catch { + return undefined; + } + throw new Error("the attachment was not refused"); + }, + ], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + + yield* act(trace); + + expect([name, seen.resumes]).toEqual([name, 0]); + expect([name, seen.creates]).toEqual([name, 0]); + expect([name, seen.allocations]).toEqual([name, 0]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + // The only publication that reached the store is the one this case made + // before the provider existed, and what it reads back is byte-identical. + expect([name, seen.published.length]).toEqual([name, 1]); + expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); + }); + } + }); + + it("NP5: an admitted point still refuses a build the route does not name", function* () { + // Admission and continuity are different questions, and passing the first + // is not an answer to the second. The live build is exactly the proved one; + // the route names an earlier one, so this is the drift refusal, not the + // capability one. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish( + bound({ + schema: "executable-build.v1", + reportedVersion: PROVED_VERSION, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }), + ); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + + const failure = yield* attempt(trace, INSTRUCTIONS); + + expect(failure?.class).toBe("executable-binding-refused"); + expect(seen.resumes).toBe(0); + expect(trace.launches).toEqual([]); + }); + + it("NP6: an incomplete replay is gated, and legacy history is unchanged", function* () { + // An incomplete replay: the point is checked before the live phase it is + // standing in front of, whichever suffix the journal retained. + for (const suffix of ["prepared", "prepared+detached"] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + trace.replay = { prepared: preparedRecord(), suffix }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failure = trace.records.findLast((record) => record.failure)?.failure; + expect([suffix, failure?.class]).toEqual([suffix, "unsupported-capability"]); + expect([suffix, seen.resumes + seen.creates]).toEqual([suffix, 0]); + expect([suffix, trace.launches]).toEqual([suffix, []]); + expect([suffix, JSON.stringify(yield* routes.read(KEY))]).toEqual([suffix, before]); + }); + } + + // A legacy session, on a host that proves nothing: the released unbound + // behavior, unchanged. It refuses for the reason it always did — the run + // cannot say which build has this history — and never for a capability. + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(legacyRoute()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + compatibility: false, + }); + const { executableBinding: _unbound, ...legacyPrepared } = preparedRecord(); + trace.replay = { prepared: legacyPrepared, suffix: "prepared+detached" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failure = trace.records.findLast((record) => record.failure)?.failure; + expect(failure?.class).toBe("executable-binding-refused"); + }); + }); + + it("NP7: a completed replay neither observes a build nor asks about a point", function* () { + // Every phase is already retained, so the authority replays the journal + // whole and calls no live phase. A host that has proved nothing changes + // nothing about that: a launch that already happened is not work this run + // is being admitted to do, and the provider is never reached to say so. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: observer.observer, + compatibility: false, + }); + trace.completed = { + prepared: preparedRecord(), + detached: { phase: "detached" }, + exited: { phase: "exited", exitCode: 0 }, + }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(trace.records.map((record) => record.phase)).toEqual(["prepared", "detached", "exited"]); + expect(trace.records.some((record) => record.failure)).toBe(false); + expect(observer.observed).toEqual([]); + expect(seen.allocations + seen.creates + seen.resumes).toBe(0); + expect(trace.launches).toEqual([]); + }); + + it("NP8: a published route whose exact conversation is absent refuses, and is kept", function* () { + // The identity in the route is the only one this session has. A backend + // that does not have it is not an invitation to allocate another and + // publish over the account of a conversation a native UI may still hold — + // it is the end of this attempt. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + }); + // Refused through the gate rather than `ensureFailure`, because the gate + // runs after the attempt is recorded: what this case needs to read is the + // identity that was asked for, not merely that asking failed. + harness.ensureGate = () => + (function* (): Operation { + throw new Error("No conversation found with that session ID"); + })(); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + // Asked for exactly once, under exactly the published identity. + expect(raised).toBeDefined(); + expect(harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + expect(harness.turns).toEqual([]); + // No second identity was reached for, and the route still says what it said. + expect(seen.allocations).toBe(0); + expect(seen.published.length).toBe(1); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + }); + + it("NP9: a nonzero exact resume that provably settled leaves the session free", function* () { + // The native child's status is the launch's outcome, not evidence about + // whether it is gone. What decides ownership is whether teardown settled: + // one that did releases the session for the next acquisition, and one that + // could not be proven leaves the record standing to be recovered. + const shared = makeCoordinator(); + const routes = createMemorySessionRouteStore(); + const store = makeStore(); + yield* routes.publish(bound()); + + const first = createFakeRuntime(); + const firstTrace = newTrace(); + firstTrace.ownership = shared; + yield* scoped(function* () { + const seen = boundaries(); + yield* installLaunchStack(first, firstTrace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + exitCode: 9, + }); + + yield* launch(INSTRUCTIONS); + + // The exact published identity was resumed, never recreated. + expect(seen.resumes).toBe(1); + expect(seen.creates).toBe(0); + expect(firstTrace.launches[0]!.command).toEqual([OBSERVED_PATH, "--resume", ALLOCATED]); + expect(firstTrace.records.at(-1)).toMatchObject({ phase: "exited", exitCode: 9 }); + }); + + // Everything the launch registered has unwound, and it settled, so the + // session was given back rather than left standing. + expect(shared.events.at(-1)).toBe("released-idle"); + + const second = createFakeRuntime(); + const secondTrace = newTrace(); + secondTrace.ownership = shared; + yield* scoped(function* () { + yield* installLaunchStack(second, secondTrace, { + adapters: { claude: countedAdapter(boundaries()) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + }); + yield* launch(INSTRUCTIONS); + }); + + expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual(["granted", "granted"]); + expect(secondTrace.launches.length).toBe(1); + }); +}); diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index d754acf5b..e7b42d744 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -146,9 +146,10 @@ export function* resolveAgentStack( * carrying an agent release this machine does not have (#672). * * Each of the rest reaches the provider directly rather than through a context: - * who owns a session and which build it belongs to are security decisions, and - * ones a document could replace are not ones. The two advertised sets are stated - * by the host, not inherited. + * who owns a session, which build it belongs to and which builds this machine + * has proved are security decisions, and ones a document could replace are not + * ones. The two advertised sets are stated by the host, not inherited, and they + * select rather than authorize: the compatibility beside them is what admits. */ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies { const { sessions } = stack; @@ -161,6 +162,7 @@ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDepende ...(sessions.coordinator ? { coordinator: sessions.coordinator } : {}), ...(sessions.routeStore ? { routeStore: sessions.routeStore } : {}), ...(sessions.executableObserver ? { executableObserver: sessions.executableObserver } : {}), + ...(sessions.compatibility ? { compatibility: sessions.compatibility } : {}), advertiseNativeLaunch: sessions.advertiseNativeLaunch, advertiseClientNativeAttachment: sessions.advertiseClientNativeAttachment, }; diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts index c7d70d281..db26ba4b7 100644 --- a/packages/cli/src/compiled.ts +++ b/packages/cli/src/compiled.ts @@ -100,7 +100,10 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), - useMachineSessions(), + // The machine, read at the one place that is actually running on it. A + // native capability is admitted for an exact OS and architecture, so + // those cross from here as values rather than being detected below. + useMachineSessions({ platform: process.platform, architecture: process.arch }), // This host presents grids: it has a terminal to divide, and it can // re-invoke itself for one pane. foregroundTerminalGrid(), diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts index a1e7c9b5e..e657843c1 100644 --- a/packages/cli/src/deno.ts +++ b/packages/cli/src/deno.ts @@ -119,7 +119,10 @@ if (paneWorker !== undefined) { denoRunRepositories(HELPER), () => readInputStream(process.stdin), () => useDenoWorkflowHost(HELPER), - useMachineSessions(), + // The machine, read at the one place that is actually running on it. A + // native capability is admitted for an exact OS and architecture, so + // those cross from here as values rather than being detected below. + useMachineSessions({ platform: process.platform, architecture: process.arch }), // This host presents grids: it has a terminal to divide, and it can // re-invoke itself for one pane. foregroundTerminalGrid(), diff --git a/packages/cli/src/session-coordinator.ts b/packages/cli/src/session-coordinator.ts index 970f5d637..806dd6f4a 100644 --- a/packages/cli/src/session-coordinator.ts +++ b/packages/cli/src/session-coordinator.ts @@ -23,8 +23,13 @@ import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, createDenoSessionRouteStore, + nativeCapabilityCompatibility, +} from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeCapabilityCompatibility, + NativeCapabilityHost, } from "@executablemd/acp"; -import type { AgentSessionRouteStore } from "@executablemd/acp"; export function sessionCoordinatorRoot(): string { return join(homedir(), ".acpx", "xmd-native-sessions", "v1"); @@ -46,6 +51,15 @@ export interface MachineSessionAssembly { executableObserver?: ExecutableObserver; advertiseNativeLaunch: readonly string[]; advertiseClientNativeAttachment: readonly string[]; + /** + * Which exact builds this host admits each capability on, and the machine it + * admits them for. + * + * Beside the observer rather than derived from the names above, because the + * names are a coarse selection: an adapter reaches the question through them + * and is answered here. Absent admits nothing. + */ + compatibility?: NativeCapabilityCompatibility; } /** This host's session coordinator, or nothing when it cannot provide one. */ @@ -74,10 +88,16 @@ export function useExecutableObserver(): ExecutableObserver | undefined { } /** - * The ordinary `xmd run` profile: this machine's sessions, and the adapters - * proven against the installed CLI. + * The ordinary `xmd run` profile: this machine's sessions, and what its + * adapters have been proved to do on it. + * + * `host` is passed in rather than read here, and read at the entrypoint rather + * than anywhere below it. Which OS and architecture are underneath is exactly + * the fact a capability point is matched against, so a module that went and + * found it for itself would be supplying the answer as well as the question — + * and a case stating an exact point could never contradict it. */ -export function useMachineSessions(): MachineSessionAssembly { +export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAssembly { return { ...(useSessionCoordinator() === undefined ? {} : { coordinator: useSessionCoordinator() }), ...(useSessionRouteStore() === undefined ? {} : { routeStore: useSessionRouteStore() }), @@ -86,17 +106,18 @@ export function useMachineSessions(): MachineSessionAssembly { : { executableObserver: useExecutableObserver() }), advertiseNativeLaunch: ADVERTISED_NATIVE_LAUNCH, advertiseClientNativeAttachment: ADVERTISED_CLIENT_NATIVE_ATTACHMENT, + compatibility: nativeCapabilityCompatibility(host), }; } /** * The same advertised names on a host that assembles none of the answers. * - * Node and Bun run the same commands and offer the same agents, and neither can - * take a kernel-released advisory lock, keep durable routes, or observe a - * build. Keeping the names is what makes the refusal say so: every advertised - * operation stops before provider work rather than acting while a native UI may - * be in the conversation. + * Node and Bun run the same commands and offer the same agents, and none of + * them can take a kernel-released advisory lock, keep durable routes, observe a + * build, or say which builds this machine has proved. Keeping the names is what + * makes the refusal say so: every advertised operation stops before provider + * work rather than acting while a native UI may be in the conversation. */ export function unassembledMachineSessions(): MachineSessionAssembly { return { diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 7837bef20..19f1b8ca2 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -34,23 +34,35 @@ import type { AgentSessionCoordinator } from "@executablemd/runtime"; import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, + admitsNativeCapability, createAcpxProvider, createDenoSessionRouteStore, createMemorySessionRouteStore, } from "@executablemd/acp"; -import type { AgentSessionRouteStore, NativeAdapter, NativeBinding } from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeAdapter, + NativeBinding, + NativeCapabilityHost, +} from "@executablemd/acp"; import type { ExecutableObserver } from "@executablemd/runtime"; import type { NativeLaunchRequest } from "@executablemd/terminal"; import { createFakeObserver } from "../../acp/tests/helpers.ts"; import { sessionCoordinatorRoot, useExecutableObserver, + unassembledMachineSessions, useMachineSessions, useSessionCoordinator, } from "../src/session-coordinator.ts"; const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); +/** The machine the shipped Claude points were proved on. */ +const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; + +const CLAUDE_PROVED_VERSION = "2.1.241 (Claude Code)"; + /** Whether this is the runtime that can take a kernel-released advisory lock. */ function onDeno(): boolean { const found: unknown = Reflect.get(globalThis, "Deno"); @@ -300,6 +312,12 @@ function* launchUnder( ...(coordinator ? { coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.observer ? { executableObserver: options.observer } : {}), + // The points this host states, for the machine they were proved on rather + // than the one running the suite: the subject is what a host assembles, + // and stating the pair as a value is how a host does it. + ...(useMachineSessions(PROVED_HOST).compatibility === undefined + ? {} + : { compatibility: useMachineSessions(PROVED_HOST).compatibility }), }); yield* factory( { defaultAgent: agent, permissionMode: "deny-all" }, @@ -513,7 +531,14 @@ describe("Tier HC — host session ownership", () => { it("HC5: the Deno and compiled entrypoints assemble machine sessions; Node and Bun do not", function* () { for (const name of ["deno.ts", "compiled.ts"]) { - expect((yield* entrypoint(name)).includes("useMachineSessions()")).toBe(true); + // The machine crosses from the entrypoint as values. Shared assembly is + // handed the pair rather than reading it, so the one place that is + // actually running on this host is the one place that says so. + expect( + (yield* entrypoint(name)).includes( + "useMachineSessions({ platform: process.platform, architecture: process.arch })", + ), + ).toBe(true); } for (const name of ["node.ts", "bun.ts"]) { const source = yield* entrypoint(name); @@ -554,7 +579,7 @@ describe("Tier HC — host session ownership", () => { // says which adapters it has proven for each rather than inheriting either. expect([...ADVERTISED_NATIVE_LAUNCH]).toEqual(["claude"]); expect([...ADVERTISED_CLIENT_NATIVE_ATTACHMENT]).toEqual(["claude"]); - const assembly = useMachineSessions(); + const assembly = useMachineSessions(PROVED_HOST); expect([...assembly.advertiseNativeLaunch]).toEqual(["claude"]); expect([...assembly.advertiseClientNativeAttachment]).toEqual(["claude"]); // Built from the same trusted root as the coordinator beside it. @@ -563,4 +588,56 @@ describe("Tier HC — host session ownership", () => { expect(assembly.executableObserver === undefined).toBe(!onDeno()); expect(useExecutableObserver() === undefined).toBe(!onDeno()); }); + + it("HC13: the machine an assembly admits for is the one it was handed", function* () { + // A name is a selection; a point is the proof. The assembly states the + // proved points beside the observer, and the machine they are proved for + // arrives as a value, so the same shared code admits nothing on a host it + // was never proved against. + const proved = useMachineSessions(PROVED_HOST).compatibility; + expect(proved?.host).toEqual(PROVED_HOST); + expect(proved?.points).toEqual([ + { + agent: "claude", + capability: "native-launch", + reportedVersion: CLAUDE_PROVED_VERSION, + ...PROVED_HOST, + }, + { + agent: "claude", + capability: "client-native-attachment", + reportedVersion: CLAUDE_PROVED_VERSION, + ...PROVED_HOST, + }, + ]); + const claudeLaunch = { + agent: "claude", + capability: "native-launch", + reportedVersion: CLAUDE_PROVED_VERSION, + } as const; + expect(admitsNativeCapability(proved, claudeLaunch)).toBe(true); + for (const elsewhere of [ + { platform: "linux", architecture: "arm64" }, + { platform: "darwin", architecture: "x64" }, + ]) { + expect( + admitsNativeCapability(useMachineSessions(elsewhere).compatibility, claudeLaunch), + ).toBe(false); + } + // Node and Bun keep the coarse names and assemble no authority, so the + // advertised name reaches a question this profile answers with a refusal. + const unassembled = unassembledMachineSessions(); + expect([...unassembled.advertiseNativeLaunch]).toEqual(["claude"]); + expect(unassembled.compatibility).toBe(undefined); + expect(admitsNativeCapability(unassembled.compatibility, claudeLaunch)).toBe(false); + // Shared assembly is handed the machine rather than reading it: only the + // entrypoints checked in HC5 name the running runtime. + expect((yield* entrypoint("session-coordinator.ts")).includes("process.platform")).toBe(false); + // The workflow profile advertises neither capability and states no points, + // so a workflow Claude prompt never reaches this question at all. + const workflow = yield* entrypoint("workflow-agent.ts"); + expect(workflow.includes("advertiseNativeLaunch: [],")).toBe(true); + expect(workflow.includes("advertiseClientNativeAttachment: [],")).toBe(true); + expect(workflow.includes("compatibility:")).toBe(false); + }); }); diff --git a/packages/test-agent/src/provider.ts b/packages/test-agent/src/provider.ts index ae5cb1eb8..5500c6ed1 100644 --- a/packages/test-agent/src/provider.ts +++ b/packages/test-agent/src/provider.ts @@ -10,6 +10,7 @@ */ import { randomUUID } from "node:crypto"; +import process from "node:process"; import type { Operation } from "effection"; import { useAcpxProvider } from "@executablemd/acp"; import type { @@ -17,7 +18,11 @@ import type { AcpxProviderDependencies, SessionRouteContext, } from "@executablemd/acp"; -import type { AgentSessionRouteStore, NativeAdapter } from "@executablemd/acp"; +import type { + AgentSessionRouteStore, + NativeAdapter, + NativeCapabilityCompatibility, +} from "@executablemd/acp"; import { useRouteSlot } from "./route-slot.ts"; import type { AgentSessionCoordinator, ExecutableObserver } from "@executablemd/runtime"; import type { AcpAgentRegistry, AcpSessionRecord, AcpSessionStore } from "acpx/runtime"; @@ -142,6 +147,30 @@ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { resume: (nativeSessionId) => ["xmd-test-agent-ui", "--resume", nativeSessionId], }; +/** + * The points this partition admits, for the machine it is running on. + * + * This partition is its own trusted host — it supplies its own coordinator, + * route store and observer — so it also says which builds it has proved. What + * a real host proves by driving an installed CLI, this one proves by being the + * build: the controlled UI reports exactly `TEST_AGENT_BUILD_VERSION` and does + * the same thing everywhere, so the live pair is what the point is stated for. + * Reading the machine here rather than in `@executablemd/acp` is the whole + * point of the seam — a scenario stating an exact foreign point still refuses. + */ +function testAgentCompatibility(): NativeCapabilityCompatibility { + const host = { platform: process.platform, architecture: process.arch }; + return { + host, + points: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ + agent: TEST_AGENT_CLIENT_NATIVE, + capability, + reportedVersion: TEST_AGENT_BUILD_VERSION, + ...host, + })), + }; +} + export function* useTestAgentProvider( options: TestAgentProviderOptions, ): Operation { @@ -203,6 +232,7 @@ export function* useTestAgentProvider( ...(options.coordinator ? { coordinator: options.coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.executableObserver ? { executableObserver: options.executableObserver } : {}), + compatibility: testAgentCompatibility(), ...(options.dependencies?.createRuntime ? { createRuntime: options.dependencies.createRuntime } : {}), From 0e171c5e62cd2b9e5ce5bccb4cc3cb299d1e9531 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 04:53:08 -0400 Subject: [PATCH 50/62] =?UTF-8?q?=F0=9F=94=92=20Prove=20a=20launch's=20own?= =?UTF-8?q?=20teardown=20before=20releasing=20its=20session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native launch acknowledged ownership quiescence whenever `stop()` on its scope succeeded. A launch that completed naturally tore itself down first, so a finalizer that failed on the way reached the owner as a crash while `stop()` afterwards succeeded against an already-settled scope — proof that the scope is settled now, never that the cleanup which failed had succeeded. The session was released idle with an unproved child behind it. The invocation now suspends once the native child settles, so the one teardown there is happens under the one call that reports it. Quiescence is still withheld unless the child settled, every finalizer of that invocation completed, and the provider holds no handle; otherwise the exclusion is released with the record left active, and the next acquisition is refused as session-recovery-required before any spawn, ensure, allocation or route work. The original cleanup failure is what the launch caller observes. --- packages/acp/src/provider.ts | 39 ++++++---- packages/acp/tests/native-launch.test.ts | 94 ++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 7b91b51b6..257f2a535 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -2832,12 +2832,14 @@ function* useAcpxProviderState( // reached; written as this scope's cleanup, it is reached on every // path there is. const [running, stop] = createScope(yield* useScope()); + const performed = withResolvers(); 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 + // would be destroyed alongside its parent: 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 { @@ -2845,21 +2847,28 @@ function* useAcpxProviderState( 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. + // session, and that is three facts rather than one: the native + // child settled, every finalizer belonging to that invocation + // completed, and this provider holds no handle for the session + // — a detach that failed, or a session prepared and never + // handed over, leaves one. Any of them 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(); } } }); - yield* running.run(() => - authority.perform(request, { + // Held open after the native child settles so that the invocation + // is never torn down except by the `stop()` above. A launch left to + // unwind on its own runs its finalizers where nothing can be told + // how that went — the failure reaches this owner as a crash, after + // the scope has already settled, and stopping a settled scope + // proves only that it is settled now. Suspending puts the one + // teardown there is under the one call that reports it. + running.run(function* (): Operation { + yield* authority.perform(request, { prepare: () => withSessionRoute(context, () => prepareLaunch( @@ -2873,8 +2882,12 @@ function* useAcpxProviderState( detach: (prepared) => detachSession(invocation, prepared, agentCommandOf(placement)), exit: (prepared) => runNativeUi(invocation, prepared, agentCommandOf(placement)), - }), - ); + }); + performed.resolve(); + yield* suspend(); + }); + + yield* performed.operation; }, ); } catch (error) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 4d1de0c2c..bb957f87b 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4611,4 +4611,98 @@ describe("Tier NP — proved native capability points", () => { expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual(["granted", "granted"]); expect(secondTrace.launches.length).toBe(1); }); + + it("NP10: a launch whose own cleanup failed as it completed stays owned", function* () { + // The other half of CX2. Cancellation is not the only way teardown fails: + // a launch that ran to completion unwinds its own finalizers on the way + // out, and a failure there leaves exactly the same fact unproved. Bringing + // that scope down afterwards says it is settled now — never that the + // cleanup which failed on the way had succeeded. + const CLEANUP_FAILED = "the native child could not be proven stopped"; + const shared = makeCoordinator(); + const seen = boundaries(); + const routes = countedRoutes(seen); + const store = makeStore(); + yield* routes.publish(bound()); + const before = JSON.stringify(yield* routes.read(KEY)); + + const first = createFakeRuntime(); + const firstTrace = newTrace(); + firstTrace.ownership = shared; + const started = withResolvers(); + const release = withResolvers(); + let raised = ""; + + yield* scoped(function* () { + yield* installLaunchStack(first, firstTrace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + cleanupFails: CLEANUP_FAILED, + hold: (function* () { + started.resolve(); + yield* release.operation; + })(), + }); + + // Signalled rather than timed: the child is let go only once it has + // provably started, and the launch is then awaited to its own + // settlement — which is where its finalizers run. + yield* spawn(function* () { + yield* started.operation; + release.resolve(); + }); + try { + yield* launch(INSTRUCTIONS); + } catch (error) { + raised = error instanceof Error ? error.message : String(error); + } + }); + + // The child did start, under the identity the route names, and completed + // on its own — this is not a cancellation. + expect(firstTrace.launches.map((request) => request.command)).toEqual([ + [OBSERVED_PATH, "--resume", ALLOCATED], + ]); + // What the caller was handed is the original cleanup failure itself. + expect(raised).toContain(CLEANUP_FAILED); + // And nothing was acknowledged: the live exclusion came back, the record + // stayed standing. + expect(shared.events).toEqual(["owned", "released-active"]); + + const publishedBefore = seen.published.length; + const second = createFakeRuntime(); + const secondTrace = newTrace(); + secondTrace.ownership = shared; + const later = boundaries(); + let failure: PreparedLaunchRecord["failure"]; + // Under a private root that could not hold an instruction file, so a run + // that had reached the write would fail in the host's own words instead of + // refusing for the session. + yield* withUnusablePrivateRoot(function* () { + yield* scoped(function* () { + yield* installLaunchStack(second, secondTrace, { + adapters: { claude: countedAdapter(later) }, + routeStore: routes, + store, + coordinator: shared.coordinator, + }); + failure = yield* attempt(secondTrace, INSTRUCTIONS); + }); + }); + + expect(failure?.class).toBe("session-recovery-required"); + expect(shared.acquisitions.map((entry) => entry.outcome)).toEqual([ + "granted", + "recovery-required", + ]); + // Refused in front of every boundary: no child, no ACP session, no second + // identity, no publication over the account of the one the route names. + expect(secondTrace.launches).toEqual([]); + expect(second.ensureCalls).toEqual([]); + expect(later.allocations + later.creates + later.resumes).toBe(0); + expect(seen.published.length).toBe(publishedBefore); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + }); }); From 0876896b183adc6b4b9d0d4011aad8df685fdc23 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 05:12:20 -0400 Subject: [PATCH 51/62] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Admit=20native=20?= =?UTF-8?q?capabilities=20by=20observed=20protocol=20shape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 8 +- specs/decisions.md | 106 +++++--- specs/native-agent-session-launch-spec.md | 311 +++++++++++++--------- 3 files changed, 261 insertions(+), 164 deletions(-) diff --git a/architecture.md b/architecture.md index ee8c8b26c..ba63cbf11 100644 --- a/architecture.md +++ b/architecture.md @@ -117,9 +117,9 @@ Existing documents and code get aligned to this section retroactively. | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, an adapter-canonical version and the lowercase SHA-256 of the canonical executable target. Equality is exact over all three, so a matching build reached at another path is the same build and a changed build at the same path is not. It contains no path: a path says where a build was, which stops being true, and names host layout besides | -| native capability compatibility point | the smallest installed-provider environment for which one native capability's real-CLI proof passed: adapter, capability, adapter-canonical reported version, host operating system and host architecture. A proof authorizes only that point, never every semver-shaped build or every platform. The executable digest remains the per-session continuity binding rather than a global allow-list: the compatibility proof describes a released provider build across installations, while the route must still recognize the exact executable that accepted its identity | -| executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and asks that same file its version. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | +| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Digest equality is mandatory, and a retained version must be reproduced when one exists; a build which reports none binds by digest alone. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The Agent name and reported version authorize nothing by themselves: the exact observed executable must advertise the profile's required shape through a read-only metadata query. The executable digest remains the per-session continuity binding, while an optional canonical version is retained only as supplemental evidence and a diagnostic | +| executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | | quiescence | the one-use acknowledgement an ownership body gives. It does not mean "I finished" — it means nothing this owner started can still act on the session, which is why a handoff that could not release its handle never gives it | @@ -4647,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only at a compatibility point whose applicable real-CLI proof passed; a canonical-looking version is only an observation, not proof. That compatibility gate follows observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability point and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; `claude` is admitted for native launch only at the proved Claude Code 2.1.241/macOS/arm64 compatibility point (#520) and separately for client-native attachment at the point proven by the native-to-ACP marker gate (#561); another Claude version or platform is unadvertised until its own applicable real-CLI proofs pass, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only when the resolved adapter protocol, the independently requested capability, a read-only observation of the required CLI shape, and the proved host envelope all agree; neither the Agent name nor reported version authorizes it. That gate follows executable observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. Reported version is optional diagnostic and per-session continuity evidence; the executable digest remains mandatory for a retained identity, so a compatible upgraded CLI can create new sessions while an existing session never crosses to a changed build implicitly. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability profile and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an absent or changed required shape or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/decisions.md b/specs/decisions.md index 3a0d53b87..b0b759d80 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -807,7 +807,7 @@ discovery, and release verification. Moving tests changes the measured corpus, so its weights are remeasured by the repository workflow rather than edited by hand. -## DEC-017: Native capability advertisement is proof-point scoped +## DEC-017: Native capability admission follows a proved protocol shape **Status:** Decided @@ -816,15 +816,16 @@ hand. ### Context Claude's client-allocated launch proof ran against Claude Code 2.1.241 on -macOS arm64, but the built-in advertisement named only `claude` and the version -parser accepted every canonical semver-shaped Claude Code line. That turns one -installed-CLI observation into a claim about builds and platforms the proof did -not exercise. +macOS arm64. The first repair admitted only that exact reported version. That +fails closed, but it makes a routine Claude Code upgrade disable every new +native session even when the executable still advertises the protocol surface +the adapter uses. -Executable build binding answers a different question. Its canonical version -and executable digest let a later operation recognize the exact executable that -accepted one retained identity. They do not prove that native launch or -client-native ACP attachment works on that executable in the first place. +Executable build binding answers a different question. Its executable digest +and optional canonical reported version let a later operation recognize the +exact executable that accepted one retained identity. They do not globally +authorize native launch or client-native ACP attachment, and a version string +does not describe a capability. The client-allocated contract deliberately accepts two zero-turn outcomes: the same identity resumes, or the provider refuses that exact absent identity and @@ -835,33 +836,54 @@ launch performs no model turn. ### Decision -One real-CLI proof admits one native capability compatibility point: +One real-CLI proof establishes an adapter protocol profile. A live executable +is admitted only when all of these facts agree: ```text -adapter + capability + canonical reported version + host OS + host architecture +adapter protocol + independently requested capability + observed protocol shape ++ host OS + host architecture ``` -Native launch and client-native attachment retain separate admission because -their proofs establish different behavior. The trusted host supplies OS and -architecture directly beside the coordinator, route store and executable -observer; shared provider code does not detect a runtime and document code -cannot replace these facts. The executable digest remains the route's exact -per-session continuity binding rather than a global allow-list for one -operator's installation. - -For a new client-allocated session the provider observes the executable and -checks the applicable point before identity allocation, route publication, -private-file creation or process start. A bound route is checked again before -native resume, attachment ensure and incomplete replay. A point the host has not -admitted refuses with `unsupported-capability`; a live build which differs from -the route still refuses with `executable-binding-refused`. Completed replay and -legacy V1 native-only resume keep their existing behavior. - -Claude is admitted for native launch and, independently, attachment only at the -applicable Claude Code 2.1.241/macOS/arm64 points already proved. Another -version or platform remains unsupported until the full applicable real-CLI -proof passes and its exact point is added. A semver-shaped version line alone -never widens admission. +The adapter protocol is a stable implementation identifier, not the Agent name +or executable command. Its adapter-owned probe asks the exact observed +executable for side-effect-free help metadata, with no terminal, session +identity, instruction text, credentials or provider state. The Claude profile +recognizes the product and the required option shapes without matching the +whole help text: native launch requires client allocation, private-file +instructions and exact resume; client-native attachment independently requires +exact resume plus the pinned ACP bridge's `resumeSessionId` contract. Additive +options, line wrapping and an unrelated version change do not invalidate that +shape. A missing, ambiguous or changed required shape admits nothing. + +Native launch and client-native attachment remain separate admission decisions +because their proofs establish different behavior. The trusted host supplies +OS and architecture directly beside the coordinator, route store and +executable observer; shared provider code does not detect a runtime and +document code cannot replace these facts. The currently proved host envelope +remains macOS arm64. A different operating system or architecture requires its +own applicable real-CLI evidence even when the help shape is identical. + +For a new client-allocated session the provider resolves and hashes the +executable, runs the adapter's read-only metadata query against that exact path, +and checks the requested capability before identity allocation, route +publication, private-file creation or native child start. A bound route is +checked again before native resume, attachment ensure and incomplete replay. A +profile or shape the host has not admitted refuses with +`unsupported-capability`; a live build which differs from the route still +refuses with `executable-binding-refused`. Completed replay and legacy V1 +native-only resume keep their existing behavior. + +Claude Code's version query is optional metadata. One canonical version line is +retained when available; changed wording, noncanonical output, a failed version +query or no version output does not deny an otherwise recognized capability +shape. New sessions created by such a build retain its exact digest and omit +the version. A retained binding still protects one published identity: digest +equality is mandatory, two present versions must agree, and a retained version +which the same observation can no longer reproduce refuses conservatively. An +upgrade may therefore create new named sessions immediately when its shape and +host envelope are admitted, while a session bound to the previous executable +does not cross the changed digest. Crossing builds for one existing identity +requires a separate migration contract. An already-published route whose exact provider identity is absent remains the authoritative account. Native resume or ACP attachment fails closed without @@ -879,10 +901,22 @@ separate product decision and specification change. ### Consequences The provider's static adapter-name sets are only a coarse selection and cannot -authorize client-allocated work by themselves. The host/provider assembly gains -live platform facts and a controlled compatibility-admission seam, so tests can -state exact points without reading the active runtime. Route, journal, request, -provider identity, authored syntax and build-binding schemas do not change. +authorize client-allocated work by themselves. Admission requires the resolved +adapter's stable protocol identifier, its observed capability shape and the +host envelope. The host/provider assembly keeps live platform facts and a +controlled admission seam, so tests can state profiles and observations without +reading the active runtime. The unmerged executable binding makes +`reportedVersion` optional; route and journal identity, request, provider +identity and authored syntax otherwise do not change. + +The metadata probe is deliberately less than a semantic trial. No +side-effect-free query can prove that an implementation has no hidden +regression; proving that by creating a disposable conversation would violate +the product contract. XMD relies on the CLI's advertised protocol inside the +real-proof envelope, then keeps every existing exact-identity, no-substitution, +settlement and recovery check at the live boundary. A provider which needs +stronger attestation must expose it without a model turn before its profile can +use it. This is a repair to the unmerged terminal/native-session stack and lands directly atop its delivery head. It is not a separate Story: the terminal grid diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index ebb193e4b..1c3c1d893 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -353,37 +353,46 @@ retains which build accepted it: ```ts interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; - readonly reportedVersion: string; + readonly reportedVersion?: string; readonly executableDigest: { readonly algorithm: "sha256"; readonly value: string }; } ``` -Every member is exact, and equality requires all of them to agree. The digest is -the lowercase SHA-256 of the canonical executable target; `reportedVersion` is -the adapter's canonical parse of what that exact target reports. That parse -accepts exactly one canonical line: output naming no build is unrecognized, and -output naming several is a list of builds rather than an answer — taking the -first would be choosing one, which is the question a binding exists to settle. -Neither is repeated in a diagnostic. A matching build -reached at another path is the same build; a changed build at the same path is -not. A path is never a member: it says where a build was, which stops being -true, and it names host layout besides. +The digest is always present and exact: it is the lowercase SHA-256 of the +canonical executable target. A matching digest reached at another path is the +same build; a changed build at the same path is not. `reportedVersion` is the +adapter's optional canonical parse of what that exact target reports. One +canonical line is retained as supplemental continuity evidence and may appear +in a diagnostic; raw output is never retained or repeated. A failed version +query, no output, changed wording or several candidate lines leaves the member +absent and does not make an otherwise observable executable or capability +unsupported. + +Continuity always requires digest equality. When the retained binding carries a +version, a later observation must reproduce that same canonical value; a +different or absent value refuses conservatively. A binding created without a +version compares by digest alone, and a later version observation does not +rewrite it. A path is never a member: it says where a build was, which stops +being true, and it names host layout besides. The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the host's real execution environment, canonicalizes the target, requires an -executable regular file, hashes that target, and asks that same file its -version. It is deliberately not a Context, contextual Api, Agent operation, -component or middleware value: executable validation decides which retained -history may be accepted, and a resolver document middleware could replace could -point the observation at one binary while the run spawns another. A controlled -test substitutes the whole observer through the same constructor seam. +executable regular file, hashes that target, and runs only the adapter's declared +read-only metadata queries against that same path. It is deliberately not a +Context, contextual Api, Agent operation, component or middleware value: +executable validation decides which retained history may be accepted, and a +resolver document middleware could replace could point the observation at one +binary while the run spawns another. A controlled test substitutes the whole +observer through the same constructor seam. The shared observer executes argv +and returns settled status and captured channel data; it does not know Claude, +parse a provider's output or detect the active runtime. One observation yields two kinds of value: ```text -durable: the executable build binding -live: the canonical executable path +durable: the executable build binding, with a version only when reported canonically +live: the canonical executable path and provider-private metadata observations ``` The live path exists only in the operation that observed it. Native creation and @@ -401,77 +410,108 @@ is installed now, not which one established the conversation. ### Compatibility admission -An installed-CLI proof admits one **compatibility point**, not an adapter name -for every build which happens to parse. The point consists of the adapter, the -capability proved, the adapter-canonical reported version, the host operating -system and the host architecture. Native launch and client-native attachment -have independent points because their proofs ask different questions. The -executable digest remains the exact continuity binding for one retained -session; it is not a global allow-list for one operator's installation. +An installed-CLI proof establishes a **capability admission profile**, not an +adapter name or version allow-list. A live admission consists of a stable +adapter protocol, one independently requested capability, an observed CLI shape +that satisfies that protocol, and the host operating system and architecture on +which the applicable real-CLI proof passed. Native launch and client-native +attachment have independent admissions because their proofs ask different +questions. The executable digest remains the exact continuity binding for one +retained session; it is not a global allow-list for one operator's installation. ```ts -interface NativeCapabilityCompatibilityPoint { - readonly agent: string; +interface NativeCapabilityAdmission { + readonly adapterProtocol: string; readonly capability: "native-launch" | "client-native-attachment"; - readonly reportedVersion: string; + readonly probeProfile: string; readonly platform: string; readonly architecture: string; } -interface NativeCapabilityCompatibility { +interface NativeCapabilityPolicy { readonly host: { readonly platform: string; readonly architecture: string }; - readonly points: readonly NativeCapabilityCompatibilityPoint[]; + readonly admissions: readonly NativeCapabilityAdmission[]; } ``` -The ACP provider receives `NativeCapabilityCompatibility` through its trusted -construction dependencies. The built-in point list is adapter-owned evidence; -the Deno and compiled entrypoints supply their live host pair, and controlled -tests replace the whole value. The existing advertised-name sets remain the -coarse host selection needed by provider-returned adapters and by hosts which -must explain why their authority is incomplete. For a client-allocated adapter, -a selected name without a matching point authorizes nothing. +The ACP provider receives `NativeCapabilityPolicy` through its trusted +construction dependencies. Each `NativeAdapter` carries a stable protocol +identifier and an adapter-owned probe profile. These are implementation +identities, not the Agent registry key, launcher command or document input. The +Deno and compiled entrypoints supply their live host pair, and controlled tests +replace the whole policy and observation. The existing advertised-name sets +remain the coarse host selection needed by provider-returned adapters and by +hosts which must explain why their authority is incomplete. For a +client-allocated adapter, a selected name without a matching protocol, +observation and host admission authorizes nothing. + +The built-in Claude probe invokes the exact resolved executable with `--help`, +an empty environment, no TTY or stdin, and stdout and stderr captured. It +supplies no session identity, instruction text, provider state or credential and +never invokes the native UI or ACP adapter. Its parser recognizes a Claude Code +help surface and only the option shapes the adapter consumes. For native launch +that means a caller-supplied UUID session identity, an instruction layer supplied by +private file, and exact resume by identity. For client-native attachment it +means exact resume by identity together with the pinned ACP bridge's proven +`resumeSessionId` contract. The two capability answers are computed separately. +An exact option declaration and Claude's documented `--system-prompt[-file]` +family spelling both establish the private-file member. Added options, changed +line wrapping and unrelated prose do not change the answer; a missing, +ambiguous or renamed required member does. The trusted host supplies its operating system and architecture directly with the coordinator, route store and executable observer. They are not document -values, Context, Agent input or middleware. After observing a client-allocated -adapter's build, the provider requires a matching compatibility point before it -allocates an identity, publishes a route, writes the instruction file or starts -a process. Existing bound routes are checked again before native resume, -client-native attachment or incomplete replay can contact the provider. A -canonical semver-shaped line establishes only that the build can be named; it -does not advertise a capability. - -A point mismatch is `unsupported-capability`. The diagnostic may identify the -agent and capability, and carries no raw version output, executable path, -digest, environment or host message. The route and journal schemas do not -change: platform facts are live admission inputs, while the route's build -binding continues to answer whether this is the exact executable that accepted -the retained identity. - -Claude's two admitted points are scoped to `2.1.241 (Claude Code)` on -`darwin`/`arm64`, where the applicable launch and attachment proofs ran. A -different Claude version, operating system or architecture is unsupported until -the applicable real-CLI proof passes there and that exact point is added. The -observed zero-turn behavior of a newer Claude that retains no conversation is -not by itself a regression: claim 5 explicitly permits the provider to refuse -that exact identity, provided XMD fails closed without substitution. +values, Context, Agent input or middleware. The initial real-CLI evidence proves +the Claude profile on macOS arm64, so that remains the admitted host envelope; +the same help shape on another operating system or architecture is insufficient +until the applicable proof passes there. Shared provider code detects no +runtime. + +After resolving and hashing a client-allocated adapter's executable, the +provider runs the read-only probe against that exact path and requires the +requested capability in its result. This precedes identity allocation, route +publication, private-file creation and native child start. Existing bound +routes are checked again before native resume, client-native attachment or +incomplete replay can contact the provider. A canonical version line is +evidence about the observed build, not capability admission. + +A protocol, shape, capability or host mismatch is `unsupported-capability`. +The diagnostic may identify the agent, capability, adapter protocol and +canonical reported version when one exists; it carries no raw help or version +output, executable path, digest, environment or host message. Platform and +probe facts remain live admission inputs. The route's build binding continues +to answer whether this is the exact executable that accepted the retained +identity, with optional `reportedVersion` as specified above. + +The version query is optional and independent of the help probe. A future +Claude build with the same admitted protocol shape on macOS arm64 remains +usable when its version changes, its version wording changes, or it omits +version output. A build that omits a canonical version creates a binding with +only its digest. An upgraded executable may establish new sessions, while a +route bound to an earlier digest remains bound to that build and refuses rather +than migrating its identity implicitly. The observed zero-turn behavior of a +newer Claude that retains no conversation is not by itself a regression: claim +5 explicitly permits the provider to refuse that exact identity, provided XMD +fails closed without substitution. Compatibility admission adds no materialization turn. Launch still performs no -model turn, and bootstrap turns remain outside this contract. Enabling a build -which cannot satisfy the accepted protocol requires either a different -provider-native creation mechanism proven to retain the same identity without a -turn, or an explicit product decision that changes this contract; parsing its -version is never enough. +model turn, and bootstrap turns remain outside this contract. The metadata +query is not a disposable session and proves no hidden implementation behavior; +it is the CLI's side-effect-free declaration that the previously proved +protocol remains present. A provider that cannot make that declaration, or +needs a model turn to establish it, remains unsupported. Enabling a different +protocol requires its own real-CLI proof and admission profile; a version string +or adapter name is never enough. ### Attachment capability Native launch and client-native ACP attachment are separate trusted-host -choices, and neither is inferred from the other or from an adapter's shape. An -adapter may be proven to hand a session to a native UI without being proven to -join that conversation afterwards. +choices. Both may inspect one adapter protocol shape, but neither capability is +inferred from the other or from the Agent name. An adapter may be proven to hand +a session to a native UI without being proven to join that conversation +afterwards. -`claude` is advertised for both only when the live compatibility point matches. +`claude` is advertised for both only when each capability's live profile matches. Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same @@ -490,10 +530,11 @@ before a provider-returned adapter's ACP session is released, and before a client-allocated adapter allocates an identity or writes a private file. That is the failure this contract asks for rather than a hopeful spawn. -`claude` is advertised only at the applicable compatibility points. Its -client-allocated claims were proven through the production CLI against -**Claude Code 2.1.241 on macOS arm64**. `codex` is unadvertised: its command -shape and adapter contract tests exist, and nothing has run its +`claude` is advertised only when the applicable profile is observed inside the +proved **macOS arm64** host envelope. Its client-allocated protocol claims were +established through the production CLI at Claude Code 2.1.241; that version is +evidence for the profile, not its upper or lower bound. `codex` is unadvertised: +its command shape and adapter contract tests exist, and nothing has run its provider-returned claims against an installed Codex. ## Runtime sequence @@ -534,16 +575,19 @@ owner to release — what has to be settled first is which conversation this is: 9. The provider reads both durable accounts under ownership and refuses rather than converting: a session ACP already established, or a route that disagrees about the instruction layer or the launcher, ends the launch here. -10. The build is observed before an identity is made, so a build this run - cannot name ends the launch before anything durable is written. The - adapter, capability, canonical reported version and the trusted host's - operating system and architecture must then match a proved compatibility - point; a mismatch ends with `unsupported-capability`, still before an - identity or session-state mutation. The launch retains that refusal at - `prepared` without an identity, as it does every other preparation refusal. - A route that already names a different exact build ends with - `executable-binding-refused`. A legacy unbound route is the exception: it - observes nothing, resumes under the launcher name, and gains no binding. +10. The build is resolved and hashed before an identity is made, so an + executable this run cannot observe ends the launch before anything durable + is written. The exact path is asked for its read-only metadata next. The + resolved adapter protocol, requested capability, observed protocol shape + and trusted host operating system and architecture must match one admitted + profile; a mismatch ends with `unsupported-capability`, still before an + identity or session-state mutation. Reported version does not participate + in that decision. The launch retains the refusal at `prepared` without an + identity, as it does every other preparation refusal. A route that already + names a different exact digest, or cannot reproduce a version its binding + retained, ends with `executable-binding-refused`. A legacy unbound route is + the exception: it observes nothing, resumes under the launcher name, and + gains no binding. Whether an identity is needed at all is decided next. An existing compatible `client-native` route already names this conversation, so its retained identity is adopted and **nothing is allocated** — a second @@ -1171,19 +1215,21 @@ the session it named rather than meeting one. A build this run cannot show is the build behind the session fails with `executable-binding-refused`. Resolution, canonicalization, executable-file -validation, version parsing, digesting, schema recognition, equality, and a -session established before any build was recorded all end there. The diagnostic -names the stable class, the launcher, and the two canonical versions being -compared; it carries no executable path, raw version output, host error, argv, -environment, credential, instruction text or provider payload. - -A build the provider can name but whose adapter, capability, canonical version, -operating system or architecture has no admitted compatibility point fails with +validation, digesting, schema recognition, digest equality, failure to reproduce +a retained canonical version, and a session established before any build was +recorded all end there. The diagnostic names the stable class and launcher and +may name canonical versions which were actually observed; it carries no +executable path, raw metadata output, host error, argv, environment, credential, +instruction text or provider payload. + +An observed executable whose adapter protocol, requested capability, required +CLI shape, operating system or architecture has no admitted profile fails with `unsupported-capability`. On initial construction this is before identity allocation and every provider or session-state mutation; the launch's retained refusal is the only durable outcome. On a bound route it is before native resume, attachment ensure or incomplete replay performs live work. The route -remains unchanged. +remains unchanged. A changed or absent reported version alone never produces +this refusal. An attachment that reaches the provider and cannot open the conversation the route names fails with `identity-unavailable`: missing provider history, an @@ -1266,7 +1312,7 @@ means a launch under `` never reaches the host's launcher. Only the Deno and compiled hosts assemble machine-wide agent sessions: a session coordinator, a construction-route store and an executable observer, all rooted -together, plus live host platform facts and the compatibility points the +together, plus live host platform facts and the capability profiles the applicable proofs admitted. Node and Bun keep the same coarse advertised names and assemble none of those answers, so every advertised operation refuses before provider work rather than acting while a native UI may be in the @@ -1367,9 +1413,10 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding is read and compared exactly — a moved matching build is - accepted, a changed build is not, and an inexact record refuses rather than - being read past; +18. a build binding is read and compared conservatively — a moved matching + digest is accepted, a changed digest is not, a retained version must be + reproduced, a binding created without one compares by digest, and an + inexact record refuses rather than being read past; 19. new client-native construction observes the build before it allocates, publishes a bound V2 route, and retains a preparation that agrees with it, while a legacy V1 route resumes natively under the launcher name and gains @@ -1388,20 +1435,32 @@ Focused tests prove: created it, a cancellation waits for an ensure already in flight and closes what it answers with before acknowledging quiescence, and a close that failed releases nothing and withholds quiescence; and -23. a canonical version parse accepts exactly one matching line, and refuses - zero or several without repeating the output; and +23. a canonical version parse retains exactly one matching line as optional + evidence, while zero, several or a changed version format leaves it absent + without repeating the output or denying a valid capability shape; and 24. launches on distinct pane terminals run concurrently while launches in one pane remain exclusive, the same logical Agent session still contends across panes, pane readiness occurs only after successful native-child start, grid close awaits launch cancellation and session quiescence, and completed and partial grid replay preserve the launch's existing identity rules; and -25. compatibility admission accepts only the separately proved capability, - canonical version, operating system and architecture; an unproved point is - refused before identity allocation, route publication, private-file - creation, child start, attachment ensure or incomplete replay, while an - already-published absent identity is retained and never substituted. A - settled exact-resume refusal acknowledges quiescence after cleanup, while a - planted unproved teardown leaves the recovery tombstone active. +25. capability admission requires the stable adapter protocol, separately + requested capability, required read-only CLI shape, operating system and + architecture; neither an Agent name nor version string admits it. A newer + canonical version, omitted version and additive unrelated help are accepted + when that shape and host envelope match, while a missing or ambiguous + required member, another adapter protocol, or an unproved host is refused; +26. the metadata probe runs against the exact resolved executable and carries + no terminal, stdin, session identity, instructions, credential or provider + state. It completes before identity allocation, route publication, + private-file creation, native child start, attachment ensure and incomplete + replay; its raw output reaches no route, journal, result or diagnostic, and + completed replay runs no probe; +27. native launch and client-native attachment are admitted independently. A + launch-only observation cannot attach, an attachment-only observation + cannot launch, and a bound route whose exact identity is absent remains + authoritative and is never substituted. A settled exact-resume refusal + acknowledges quiescence after cleanup, while a planted unproved teardown + leaves the recovery tombstone active. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1477,8 +1536,9 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; proof-scoped compatibility admission over -the adapter, capability, canonical version and live host platform; ACP +and the build binding it produces; proof-scoped capability admission over the +adapter protocol, independently requested capability, observed CLI shape and +live host platform; ACP attachment to a bound client-native session under its exact retained identity, through runtime partitions keyed by agent command and build; an inherited root- or pane-terminal interactive child with cancellation and @@ -1490,15 +1550,16 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only `claude` has advertised compatibility points**, and separately for - each capability. It is client-allocated, and its applicable points are Claude - Code 2.1.241 on macOS arm64, where the claims under *Provider-native identity* - passed. A different build or platform is unadvertised until its applicable - real-CLI proof passes. `codex` has a command shape and contract tests and is - not launch-capable, because nothing has proven its provider-returned claims - against an installed Codex. A launch naming an unadvertised agent or point is - refused with `unsupported-capability` before anything of the session moves, - and so is an attachment whose own point was not proved. +- **Only the Claude client-native protocol has admitted profiles**, and + separately for each capability. Its applicable profiles recognize the + required CLI help shape on macOS arm64, where the claims under + *Provider-native identity* passed at Claude Code 2.1.241. A compatible newer + version remains admitted; a missing required shape or different platform does + not. `codex` has a command shape and contract tests and is not launch-capable, + because nothing has proven its provider-returned claims against an installed + Codex. A launch naming an unadvertised protocol or profile is refused with + `unsupported-capability` before anything of the session moves, and so is an + attachment whose own capability was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The retained request says so explicitly — an empty ordered list — rather than omitting the fact, and no adapter maps a root it was never given. The ACP @@ -1610,14 +1671,16 @@ Implementation review checks these frozen invariants: module, the tmux package imports only the neutral terminal domain, and the package extraction itself changes no request, route, record, provider advertisement or diagnostic. -31. A real-CLI proof advertises only its adapter, capability, canonical version, - operating system and architecture. A client-allocated point mismatch is - refused after observation but before allocation or any provider or - session-state mutation; its identity-free launch refusal is retained. The - point is checked again before bound resume, attachment and incomplete - replay. An exact absent identity remains authoritative and unavailable; - neither that refusal nor an unproved compatibility point creates a - replacement conversation or a materialization turn. +31. A real-CLI proof admits only its adapter protocol, independently requested + capability, required observable CLI shape, operating system and + architecture. Version output is optional evidence, not an allow-list key. A + client-allocated profile mismatch is refused after read-only observation but + before allocation or any provider or session-state mutation; its + identity-free launch refusal is retained. The profile is checked again + before bound resume, attachment and incomplete replay. An exact absent + identity remains authoritative and unavailable; neither that refusal nor an + unproved profile creates a replacement conversation or a materialization + turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From cef72fd852bf387da7f81a23513d55b1a91c98ea Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 08:57:10 -0400 Subject: [PATCH 52/62] =?UTF-8?q?=F0=9F=8F=9B=EF=B8=8F=20Admit=20a=20nativ?= =?UTF-8?q?e=20capability=20by=20protocol=20and=20profile,=20not=20by=20re?= =?UTF-8?q?lease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An exact-version compatibility point admitted a capability from the release string an executable happened to print. That is the wrong evidence twice: a release name says nothing about which operations a build declares, and pinning one refuses every later build that declares the same shape. Admission is now four exact facts — the adapter's own versioned protocol, the capability being asked for, the profile its observer returned, and the host's platform and architecture. An Agent registry name or a launcher command admits nothing. The two Claude capabilities are read independently, so neither is ever inferred from the other having been proved. The host-owned observer is generalized: it resolves and canonicalizes the launcher, requires an executable regular file, hashes it once, and asks that same exact path whatever read-only queries the adapter declared, returning settled status and captured output. It knows no provider and parses nothing. Reading Claude's `--help` structurally — caller-supplied identity, exact resume, private-file instructions — stays in the adapter, so additive options, prose and wrapping do not change the answer. Version becomes optional evidence beside the digest rather than a gate. A build that will not name its release, names it in words this adapter does not recognize, or names several is bound by its bytes alone. Build equality is asymmetric: the digest decides, a retained release is a claim the live build must still make, and a record that named none is not rewritten when one appears. --- packages/acp/mod.ts | 10 +- packages/acp/src/native-capability.ts | 81 ++- packages/acp/src/native-launch.ts | 289 +++++++- packages/acp/src/provider.ts | 138 ++-- packages/acp/src/session-route.ts | 46 +- .../fixtures/claude-native-launch-proof.ts | 42 +- .../fixtures/claude-native-to-acp-proof.ts | 62 +- packages/acp/tests/helpers.ts | 60 +- packages/acp/tests/native-launch.test.ts | 672 ++++++++++++++---- packages/acp/tests/session-route.test.ts | 16 + packages/cli/src/agent-stack.ts | 4 +- packages/cli/src/session-coordinator.ts | 18 +- .../tests/agent-session-coordinator.test.ts | 79 +- packages/core/src/agent/launch-journal.ts | 44 +- packages/core/src/agent/launch.ts | 36 +- .../core/tests/agent-session-launch.test.ts | 43 +- packages/runtime/deno-executable-observer.ts | 78 +- packages/runtime/executable-observer.ts | 80 ++- packages/runtime/mod.ts | 3 + .../runtime/tests/executable-observer.test.ts | 145 +++- .../test-agent/src/executable-observer.ts | 39 +- packages/test-agent/src/provider.ts | 108 ++- 22 files changed, 1633 insertions(+), 460 deletions(-) diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index ede4d89d4..24dd7aea8 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -42,18 +42,20 @@ export { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "./src/native-launch.ts"; export { allocatesIdentity } from "./src/native-launch.ts"; export type { ClientAllocatedAdapter, NativeAdapter, NativeBinding, + NativeCapabilityProbe, + ProbedNativeCapabilities, ProviderReturnedAdapter, } from "./src/native-launch.ts"; /** - * Which exact builds and machines a native capability is admitted on. + * Which protocol shapes and machines a native capability is admitted on. * * Public because the host is what states them: it supplies its own OS and * architecture beside the coordinator and the observer, and shared provider @@ -62,9 +64,9 @@ export type { export { admitsNativeCapability } from "./src/native-capability.ts"; export type { NativeCapability, - NativeCapabilityCompatibility, - NativeCapabilityCompatibilityPoint, + NativeCapabilityAdmission, NativeCapabilityHost, + NativeCapabilityPolicy, ObservedNativeCapability, ProvedNativeCapability, } from "./src/native-capability.ts"; diff --git a/packages/acp/src/native-capability.ts b/packages/acp/src/native-capability.ts index b1b9212bc..b2c7332ef 100644 --- a/packages/acp/src/native-capability.ts +++ b/packages/acp/src/native-capability.ts @@ -2,24 +2,25 @@ * What one real-CLI proof admits, and nothing wider (specs/decisions.md * DEC-017). * - * An adapter name says which command shape to consider. It does not say that - * the thing found under that name works: a proof ran against one build of one - * CLI, on one operating system, on one architecture, and every other build and - * machine is a claim nobody made. So admission is stated as points, and a point - * is the whole tuple — + * An Agent name says which command shape to consider. It does not say that the + * thing found under that name works: a proof ran against one implementation's + * protocol, on one operating system, on one architecture, and every other + * protocol and machine is a claim nobody made. So admission is stated as + * profiles, and a profile is the whole tuple — * - * adapter + capability + canonical reported version + host OS + host + * adapter protocol + capability + observed probe profile + host OS + host * architecture * - * — matched exactly. Nothing here parses, orders or ranges a version. A - * semver-shaped line is a value one adapter recognized, not evidence about the - * build behind it, and comparing two of them would turn one installed CLI into - * a statement about releases that have never run. + * — matched exactly. Nothing here is a version. A version says which release + * was installed, not what it can do, and admitting one would make a routine + * upgrade disable every new session while telling nobody why. What is matched + * instead is the adapter's stable protocol identifier and what its own + * side-effect-free probe recognized in the exact executable that was hashed. * * The host pair is supplied rather than read. Which machine this is is a fact * the trusted host has and shared provider code must not go looking for: a - * provider that detected its own runtime would answer the compatibility - * question with the thing being asked about. + * provider that detected its own runtime would answer the admission question + * with the thing being asked about. */ /** @@ -37,55 +38,61 @@ export interface NativeCapabilityHost { readonly architecture: string; } -/** One capability, proved for one exact build on one exact machine. */ -export interface NativeCapabilityCompatibilityPoint { - readonly agent: string; +/** One capability, proved for one protocol shape on one exact machine. */ +export interface NativeCapabilityAdmission { + /** + * The adapter implementation whose protocol was proved. + * + * Deliberately not the Agent registry name and not the launcher command: + * either can be pointed at something else, and neither says which protocol + * the thing behind it speaks. + */ + readonly adapterProtocol: string; readonly capability: NativeCapability; - /** The canonical line the adapter recognized, whole. Never a number alone. */ - readonly reportedVersion: string; + /** The probe whose recognized shape this admission was proved against. */ + readonly probeProfile: string; readonly platform: string; readonly architecture: string; } /** Everything a host admits, beside the machine it admits it on. */ -export interface NativeCapabilityCompatibility { +export interface NativeCapabilityPolicy { readonly host: NativeCapabilityHost; - readonly points: readonly NativeCapabilityCompatibilityPoint[]; + readonly admissions: readonly NativeCapabilityAdmission[]; } /** What an adapter carries about its own proofs, before a host names a machine. */ -export type ProvedNativeCapability = Omit; +export type ProvedNativeCapability = Omit; /** The live capability an observation offers for admission. */ export interface ObservedNativeCapability { - readonly agent: string; + readonly adapterProtocol: string; readonly capability: NativeCapability; - readonly reportedVersion: string; + readonly probeProfile: string; } /** * Whether this host admits what was actually observed. * - * The host's own OS and architecture are what an admitted point is compared - * against, so a point proved elsewhere cannot admit anything here. Absent - * compatibility admits nothing: a host that states no proof has none, and - * treating silence as permission is the failure this whole tuple exists to - * prevent. + * The host's own OS and architecture are what an admitted profile is compared + * against, so a profile proved elsewhere cannot admit anything here. An absent + * policy admits nothing: a host that states no proof has none, and treating + * silence as permission is the failure this whole tuple exists to prevent. */ export function admitsNativeCapability( - compatibility: NativeCapabilityCompatibility | undefined, + policy: NativeCapabilityPolicy | undefined, observed: ObservedNativeCapability, ): boolean { - if (compatibility === undefined) { + if (policy === undefined) { return false; } - const { platform, architecture } = compatibility.host; - return compatibility.points.some( - (point) => - point.agent === observed.agent && - point.capability === observed.capability && - point.reportedVersion === observed.reportedVersion && - point.platform === platform && - point.architecture === architecture, + const { platform, architecture } = policy.host; + return policy.admissions.some( + (admission) => + admission.adapterProtocol === observed.adapterProtocol && + admission.capability === observed.capability && + admission.probeProfile === observed.probeProfile && + admission.platform === platform && + admission.architecture === architecture, ); } diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index c25ce16a1..ca4f54b12 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -32,34 +32,70 @@ import { randomUUID } from "node:crypto"; import type { IdentityProvenance } from "@executablemd/core"; +import type { ExecutableMetadata, ExecutableMetadataQuery } from "@executablemd/runtime"; import type { - NativeCapabilityCompatibility, + NativeCapability, NativeCapabilityHost, + NativeCapabilityPolicy, ProvedNativeCapability, } from "./native-capability.ts"; +/** + * What one adapter's probe recognized in the executable that was just hashed. + * + * The profile travels with the answer rather than being assumed by the caller, + * because it is what an admission is matched against: a probe that recognized a + * different shape than the one a proof ran on must not be read as the proved + * one. An empty capability list is the ordinary answer for an executable this + * probe does not recognize — the shape was looked for and was not there. + */ +export interface ProbedNativeCapabilities { + readonly probeProfile: string; + readonly capabilities: readonly NativeCapability[]; +} + +/** + * An adapter's own reading of its executable's read-only declarations. + * + * Adapter-owned because only the adapter knows which declarations it consumes. + * Structural rather than a snapshot: it inspects the shapes this adapter's argv + * depends on, so added options, unrelated prose and rewrapped lines mean + * nothing and a missing or renamed one means everything. + */ +export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNativeCapabilities; + /** * What an adapter knows about the build behind its executable. * * A session whose identity XMD chose only means something while the build that * accepted it can be recognized later: two builds of one provider accept the * same identity and disagree silently about what it names. Everything here is - * that adapter's private dialect — which command to observe, what its version - * output looks like, and what the ACP adapter child needs in order to run the - * same build. None of it reaches a document. + * that adapter's private dialect — which command to observe, which read-only + * questions to ask it, how to read the answers, and what the ACP adapter child + * needs in order to run the same build. None of it reaches a document. */ export interface NativeBinding { /** The command whose build is observed, bound and retained. */ command: string; - /** The arguments that ask that exact file its version. */ - versionArgs?: readonly string[]; /** - * The canonical version from that output, or `undefined` when the output is - * not something this adapter recognizes. An unrecognized version is a - * refusal, not a value to retain — a build XMD cannot name is one it cannot - * later confirm. + * The read-only questions one observation asks that exact file. + * + * Declared here so the host's observer runs argv it was handed rather than + * argv it invented. A question that did anything but report would be a side + * effect on a session nobody has decided to act on yet. + */ + metadata: readonly ExecutableMetadataQuery[]; + /** What those answers say this build can do. */ + probe: NativeCapabilityProbe; + /** + * The canonical version those answers report, or `undefined` when they report + * none this adapter recognizes. + * + * Optional evidence, deliberately: a version says which release is installed, + * not what it can do. A build that will not say, says something unexpected, or + * says several things is bound by its digest alone rather than refused. */ - version(output: string): string | undefined; + reportedVersion(metadata: ExecutableMetadata): string | undefined; /** * The exact ACP adapter command this binding was proven against, when the * proof is tied to one. @@ -87,21 +123,171 @@ export interface NativeBinding { environment(livePath: string): Record; } +/** + * The output one query produced, or nothing when it did not answer. + * + * A query that failed to start, failed to settle, or settled nonzero reported + * nothing about the shape it was asked about. Reading its output anyway would + * let a crashing executable look like one missing an option. + */ +function answered(metadata: ExecutableMetadata, name: string): string | undefined { + const observation = metadata[name]; + if (observation === undefined || !observation.settled || observation.code !== 0) { + return undefined; + } + return observation.stdout; +} + +/** + * The option declarations in a help surface, one entry per option, rewrapped. + * + * Claude's help is Commander's: an option entry begins at exactly two spaces + * and a dash, and its description wraps onto more deeply indented lines. Those + * continuations are rejoined so a declaration that happened to wrap reads the + * same as one that did not — which is the difference between a structural + * reading and a snapshot of one terminal width. + * + * Everything else is dropped, and that is the point: `Arguments:`, `Commands:` + * and free prose are not declarations. An option named inside another option's + * description is a mention, not a thing this executable accepts. + */ +function optionEntries(help: string): string[] { + const entries: string[] = []; + let open = false; + for (const line of help.split("\n")) { + if (/^ {2}-/.test(line)) { + entries.push(line.trim()); + open = true; + continue; + } + if (open && /^ {3,}\S/.test(line)) { + entries[entries.length - 1] += ` ${line.trim()}`; + continue; + } + open = false; + } + return entries; +} + +/** What one option entry declares: its spellings, and whether it takes a value. */ +function declaredFlags(entry: string): { flags: string[]; takesValue: boolean } { + const flags: string[] = []; + for (const raw of entry.split(" ")) { + const token = raw.endsWith(",") ? raw.slice(0, -1) : raw; + if (/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { + flags.push(token); + continue; + } + // The head of an entry is its spellings and at most one value placeholder. + // Anything else has begun the description, and a description is prose. + return { flags, takesValue: token.startsWith("<") || token.startsWith("[") }; + } + return { flags, takesValue: false }; +} + +/** + * How this executable declares one option, if it declares it at all. + * + * `ambiguous` is separate from `absent` on purpose. Two entries declaring one + * spelling is output this adapter cannot read as a single answer, and choosing + * either would be guessing which one a launch would reach. + */ +function declaresOption( + entries: string[], + flag: string, +): "absent" | "ambiguous" | "flag" | "valued" { + const matched = entries + .map(declaredFlags) + .filter((declaration) => declaration.flags.includes(flag)); + if (matched.length === 0) { + return "absent"; + } + if (matched.length > 1) { + return "ambiguous"; + } + return matched[0].takesValue ? "valued" : "flag"; +} + +/** Whitespace-insensitive text, for reading prose rather than layout. */ +function normalized(text: string): string { + return text.replace(/\s+/g, " "); +} + +/** + * The ACP adapter Claude's attachment proof ran against. + * + * Named once and used twice — pinned for the child that actually runs, and an + * input to the probe that decides whether attachment was observed — so the + * capability cannot be answered for a bridge nobody proved. + */ +const CLAUDE_ACP_BRIDGE = "npx -y @agentclientprotocol/claude-agent-acp@0.70.0"; + +/** The probe whose recognized shape Claude's admissions were proved against. */ +const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; + +/** + * What Claude's own help declares about the operations a launch needs. + * + * Structural rather than a version comparison: what matters is whether this + * build accepts a caller-chosen session identity, resumes one exactly, and + * takes its private instruction layer as a file. A release that adds options, + * rewords prose or rewraps lines still declares those, and a release that + * stopped declaring one cannot launch whatever it calls itself. + * + * The private-file member has two accepted spellings because Claude declares + * the family rather than the member: `--system-prompt-file` appears as the + * documented `--system-prompt[-file]` spelling in builds that do not give it + * its own entry. Both are the same declaration, and neither is inferred from + * the other's absence. + * + * The two capabilities are read independently from what is present, never one + * from the other: attachment additionally needs the bridge this adapter pins, + * which is knowledge about the ACP child rather than about the CLI. + */ +function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityProbe { + return (metadata) => { + const capabilities: NativeCapability[] = []; + const help = answered(metadata, "help"); + if (help === undefined) { + return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; + } + const text = normalized(help); + const entries = optionEntries(help); + const product = text.includes("Claude Code") && /(^| )Usage: claude( |$)/.test(text); + const identity = declaresOption(entries, "--session-id") === "valued"; + const resume = declaresOption(entries, "--resume") === "valued"; + const privateFile = + declaresOption(entries, "--system-prompt-file") === "valued" || + text.includes("--system-prompt[-file]"); + + if (product && identity && resume && privateFile) { + capabilities.push("native-launch"); + } + if (product && resume && pinnedBridge === CLAUDE_ACP_BRIDGE) { + capabilities.push("client-native-attachment"); + } + return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; + }; +} + /** * Claude reports `2.1.241 (Claude Code)`. * * The whole line is retained rather than the number alone, because the number * alone is not a build: the same version string from a different product would - * compare equal. Anything that does not look like this is unrecognized, and an - * unrecognized build is refused rather than retained under a guess. + * compare equal. * - * Exactly one line may match. Zero is an output this adapter does not - * recognize; two or more is output it cannot read as one answer, and taking - * the first would be picking a build out of a list of them. Neither is - * repeated anywhere — the caller refuses with a stable class, and the output - * itself is provider-private. + * Exactly one line may match. Zero is output this adapter does not recognize; + * two or more is output it cannot read as one answer, and taking the first + * would be picking a build out of a list of them. Both mean no version was + * reported, which is an ordinary answer rather than a refusal — the digest is + * what binds the build. */ -function claudeVersion(output: string): string | undefined { +function claudeReportedVersion(metadata: ExecutableMetadata): string | undefined { + const output = answered(metadata, "version"); + if (output === undefined) { + return undefined; + } const canonical = output .split("\n") .map((line) => line.trim()) @@ -112,6 +298,15 @@ function claudeVersion(output: string): string | undefined { interface AdapterCommands { /** Stable adapter identity — `claude`, `codex`. Never an executable path. */ launcher: string; + /** + * The protocol this adapter speaks, as an admission names it. + * + * Distinct from both the Agent registry name and the launcher command, and + * versioned, because either of those can be pointed at something else while + * neither says what the thing behind it speaks. Changing what this adapter + * does to a session is a new protocol identifier, not an edit to this one. + */ + protocol: string; /** Who chooses this adapter's native session identity. */ identity: IdentityProvenance; /** The argv that resumes this exact provider-native session. */ @@ -168,16 +363,20 @@ export function allocatesIdentity(adapter: NativeAdapter): adapter is ClientAllo } /** - * The one build and machine Claude's proofs ran on. + * The one shape and machine Claude's proofs ran on. * * Written once and shared by both points below so they cannot drift apart into - * two claims about two builds. Raising either is a new proof rather than an + * two claims about two things. Raising either is a new proof rather than an * edit here: what makes this admissible is that a real CLI was driven through * the whole applicable contract on exactly this, and nothing about that - * generalizes to the next release or the next machine. + * generalizes to another machine or another way of recognizing the shape. + * + * No version appears. A version says which release was installed, not what it + * can do, and admitting one would disable every new session on a routine + * upgrade while telling nobody why. */ const CLAUDE_PROVED_BUILD = { - reportedVersion: "2.1.241 (Claude Code)", + probeProfile: CLAUDE_PROBE_PROFILE, platform: "darwin", architecture: "arm64", } as const; @@ -185,6 +384,7 @@ const CLAUDE_PROVED_BUILD = { const ADAPTERS: Readonly> = { claude: { launcher: "claude", + protocol: "claude-client-native.v1", // XMD names the session before Claude exists, so the native process is // told which conversation to make rather than reporting one afterwards. identity: "client-allocated", @@ -200,10 +400,17 @@ const ADAPTERS: Readonly> = { ], binding: { command: "claude", - version: claudeVersion, - // The version #561's gates are proven against. Raising it is a new proof, - // not a version bump. - adapterCommand: "npx -y @agentclientprotocol/claude-agent-acp@0.70.0", + // Read-only by construction: both report and exit, and neither carries a + // session, an instruction, or anything else a launch would act on. + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + probe: claudeNativeProbe(CLAUDE_ACP_BRIDGE), + reportedVersion: claudeReportedVersion, + // The bridge #561's attachment gate is proven against. Raising it is a + // new proof, not a version bump. + adapterCommand: CLAUDE_ACP_BRIDGE, // The first thing the Claude ACP adapter consults when deciding which // Claude to run. Without it the adapter resolves the build shipped with // the Agent SDK it pins, which is not the build that created the session. @@ -220,6 +427,7 @@ const ADAPTERS: Readonly> = { }, codex: { launcher: "codex", + protocol: "codex-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["codex", "resume", nativeSessionId], }, @@ -230,8 +438,9 @@ const ADAPTERS: Readonly> = { * * A coarse selection and nothing more. For an adapter that names its own * sessions the name authorizes no work by itself: what admits one is the - * compatibility point below, matched against the build actually found and the - * machine actually running. A name reaches the question; it does not answer it. + * admission below, matched against the protocol resolved, the shape its own + * probe recognized in the executable actually found, and the machine actually + * running. A name reaches the question; it does not answer it. * * `codex` is absent. Its command shape is known and its adapter contract tests * pass, and neither is the proof: nothing has run it against an installed @@ -253,19 +462,21 @@ export const ADVERTISED_CLIENT_NATIVE_ATTACHMENT: readonly string[] = ["claude"] /** * What this build's adapters have proved, on the machine a host says it is. * - * The two halves come from where each is known. Which builds were driven - * through a real CLI is the adapters' own evidence and is compiled in beside - * them; which OS and architecture are underneath right now is the host's, and - * arrives here rather than being detected. Neither half admits anything alone — - * a point is only admitted where a proof and the machine it ran on meet. + * The two halves come from where each is known. Which protocols and shapes were + * driven through a real CLI is the adapters' own evidence and is compiled in + * beside them; which OS and architecture are underneath right now is the host's, + * and arrives here rather than being detected. Neither half admits anything + * alone — an admission stands only where a proof and the machine it ran on meet. + * + * Keyed by each adapter's protocol rather than by the Agent name it happens to + * be registered under, because the Agent name is what a document can point + * somewhere else. */ -export function nativeCapabilityCompatibility( - host: NativeCapabilityHost, -): NativeCapabilityCompatibility { +export function nativeCapabilityPolicy(host: NativeCapabilityHost): NativeCapabilityPolicy { return { host, - points: Object.entries(ADAPTERS).flatMap(([agent, adapter]) => - (adapter.proved ?? []).map((proved) => ({ agent, ...proved })), + admissions: Object.values(ADAPTERS).flatMap((adapter) => + (adapter.proved ?? []).map((proved) => ({ adapterProtocol: adapter.protocol, ...proved })), ), }; } diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 257f2a535..717f96ad6 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -105,7 +105,7 @@ import { } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; import { admitsNativeCapability } from "./native-capability.ts"; -import type { NativeCapability, NativeCapabilityCompatibility } from "./native-capability.ts"; +import type { NativeCapability, NativeCapabilityPolicy } from "./native-capability.ts"; /** * One MCP server as ACPX configures them. @@ -244,19 +244,19 @@ export interface AcpxProviderDependencies { */ executableObserver?: ExecutableObserver; /** - * Which exact builds this host has proved each native capability on, and the - * machine it proved them for. + * Which protocol shapes this host has proved each native capability on, and + * the machine it proved them for. * * Supplied by the trusted host beside the coordinator and the observer, and * for the same reason: it carries this machine's OS and architecture, and * shared provider code that went and read those would be answering the - * compatibility question with the thing being asked about. + * admission question with the thing being asked about. * * The advertised sets above choose which adapter to consider. This is what - * says the build actually found under it may be acted on. Absent admits + * says the executable actually found under it may be acted on. Absent admits * nothing — a host that states no proof has none. */ - compatibility?: NativeCapabilityCompatibility; + nativeCapabilityPolicy?: NativeCapabilityPolicy; /** * Extra native adapters, by agent name. A harness driving an agent this * package has never heard of supplies its own resume command shape here @@ -347,6 +347,17 @@ interface BoundBuild { environment: Record; /** The exact ACP adapter command this binding was proven against, if pinned. */ adapterCommand: string | undefined; + /** The protocol the adapter this build was reached through speaks. */ + adapterProtocol: string; + /** + * What this executable's own probe recognized, and which probe recognized it. + * + * Carried rather than recomputed, so admission is matched against the answer + * an observation actually produced instead of asking the question a second + * time and hoping for the same one. + */ + probeProfile: string; + capabilities: readonly NativeCapability[]; } /** @@ -833,7 +844,7 @@ function* useAcpxProviderState( const coordinator = dependencies?.coordinator; const routeStore = dependencies?.routeStore; const executableObserver = dependencies?.executableObserver; - const compatibility = dependencies?.compatibility; + const nativeCapabilityPolicy = dependencies?.nativeCapabilityPolicy; const agentCwd = dependencies?.agentCwd ?? cwd; const prepareAgent = dependencies?.prepareAgent; const mcpServers = dependencies?.mcpServers; @@ -899,11 +910,17 @@ function* useAcpxProviderState( return options; } - /** The `(agent command, build)` partition a bound runtime is kept under. */ + /** + * The `(agent command, build)` partition a bound runtime is kept under. + * + * The digest is what separates two builds, so a build that reported no + * version still shares no partition with a different one — the version is + * kept beside it only so a partition names what a record names. + */ function partitionOf(build: BoundBuild): string { return [ build.agentCommand, - build.binding.reportedVersion, + build.binding.reportedVersion ?? "", build.binding.executableDigest.algorithm, build.binding.executableDigest.value, ].join("\u0000"); @@ -1677,16 +1694,24 @@ function* useAcpxProviderState( * would give a conversation that already exists a second identity. */ /** - * The exact live path of the build behind an adapter's command. + * The exact live path of the build behind an adapter's command, and what its + * own probe recognized in it. + * + * One observation: the launcher is resolved and canonicalized, required to be + * an executable regular file, hashed once, and asked the adapter's read-only + * questions at that same exact path — so the shape recognized and the bytes + * bound are the same file rather than two resolutions of one name. * * Every way this can fail — no observer, resolution, canonicalization, an - * unreadable or non-executable file, a version this adapter does not - * recognize — ends in one stable class, because they are all the same - * question: is this the build that established the session. + * unreadable or non-executable file — ends in one stable class, because they + * are all the same question: is this the build that established the session. + * A version this adapter does not recognize is not among them; that is + * optional evidence, and its absence leaves a build bound by its digest. */ function* observeBuild( agentName: string, agentCommand: string, + adapterProtocol: string, binding: NativeBinding, ): Operation { if (!executableObserver) { @@ -1700,7 +1725,7 @@ function* useAcpxProviderState( let observed; try { observed = yield* executableObserver.observe(binding.command, { - ...(binding.versionArgs === undefined ? {} : { versionArgs: binding.versionArgs }), + metadata: binding.metadata, }); } catch (error) { // Only the observer's own stable reason crosses. Its message names a @@ -1712,28 +1737,24 @@ function* useAcpxProviderState( }`, }); } - const version = binding.version(observed.versionOutput); - if (version === undefined) { - // Deliberately without the raw output: it is provider-private, and this - // message is retained in a diagnostic. - throw new AttachmentRefused({ - class: "executable-binding-refused", - message: - `"${agentName}" reported a version this adapter does not recognize, so the build ` + - `behind it cannot be named`, - }); - } + const probed = binding.probe(observed.metadata); + const version = binding.reportedVersion(observed.metadata); return { agentName, agentCommand, livePath: observed.path, binding: { schema: "executable-build.v1", - reportedVersion: version, + // Present only when this build said something the adapter recognized. + // A quiet build is bound by its bytes, which is what binds either way. + ...(version === undefined ? {} : { reportedVersion: version }), executableDigest: observed.digest, }, environment: binding.environment(observed.path), adapterCommand: binding.adapterCommand, + adapterProtocol, + probeProfile: probed.probeProfile, + capabilities: probed.capabilities, }; } @@ -1757,31 +1778,51 @@ function* useAcpxProviderState( capability: NativeCapability, build: BoundBuild, ): LaunchFailure | undefined { - const { reportedVersion } = build.binding; - if (admitsNativeCapability(compatibility, { agent: agentName, capability, reportedVersion })) { + // Two questions, and both must answer. The executable has to declare the + // shape this capability needs, and this host has to have proved that + // protocol, that recognized shape, and this machine. Either alone would + // admit something nobody ran: a declaration is not a proof, and a proof + // elsewhere is not this executable. + const observed = + build.capabilities.includes(capability) && + admitsNativeCapability(nativeCapabilityPolicy, { + adapterProtocol: build.adapterProtocol, + capability, + probeProfile: build.probeProfile, + }); + if (observed) { return undefined; } return { class: "unsupported-capability", message: - `this host has proved no ${capability} capability for "${agentName}" at ` + - `${reportedVersion} on the machine it is running on, so it will not act on a session ` + - `with it. An advertised adapter name selects a command shape; only a proof against ` + - `that exact installed build admits one.`, + `this host has proved no ${capability} capability for the ${build.adapterProtocol} ` + + `protocol behind "${agentName}" in the shape observed on the machine it is running ` + + `on, so it will not act on a session with it. An advertised adapter name selects a ` + + `command shape; only a proof against that exact installed executable admits one.`, }; } - /** The stable comparison two builds of one session fail. */ + /** + * The stable comparison two builds of one session fail. + * + * Canonical versions appear when they exist because they are the readable + * half of the answer, and a build that reported none says so rather than + * substituting its digest — a digest is host-observable evidence and belongs + * in no message. + */ function buildDrift( sessionKey: string, retained: ExecutableBuildBindingV1, live: ExecutableBuildBindingV1, ): LaunchFailure { + const named = (binding: ExecutableBuildBindingV1) => + binding.reportedVersion ?? "a build reporting no version this adapter recognizes"; return { class: "executable-binding-refused", message: - `session "${sessionKey}" was created by ${retained.reportedVersion} and this run ` + - `would use ${live.reportedVersion}, so the conversation it names cannot be confirmed`, + `session "${sessionKey}" was created by ${named(retained)} and this run ` + + `would use ${named(live)}, so the conversation it names cannot be confirmed`, }; } @@ -1866,8 +1907,13 @@ function* useAcpxProviderState( `.`, }); } - const binding = (adapterFor(agentName) as ClientAllocatedAdapter).binding; - const build = yield* observeBuild(agentName, agentCommand, binding); + const attaching = adapterFor(agentName) as ClientAllocatedAdapter; + const build = yield* observeBuild( + agentName, + agentCommand, + attaching.protocol, + attaching.binding, + ); // Before the comparison and long before the ensure. A build this host has // not proved attachment on is refused whether or not it happens to be the // build that created the session — being the right one is not evidence that @@ -1876,7 +1922,7 @@ function* useAcpxProviderState( if (unproved) { throw new AttachmentRefused(unproved); } - if (!sameExecutableBuild(build.binding, route.executableBinding)) { + if (!sameExecutableBuild(route.executableBinding, build.binding)) { throw new AttachmentRefused( buildDrift(prepared.sessionKey, route.executableBinding, build.binding), ); @@ -2387,7 +2433,7 @@ function* useAcpxProviderState( // the launch before anything durable is written. let build: BoundBuild; try { - build = yield* observeBuild(agentName, agentCommand, adapter.binding); + build = yield* observeBuild(agentName, agentCommand, adapter.protocol, adapter.binding); } catch (error) { if (error instanceof AttachmentRefused) { return refusal(error.failure.class, error.failure.message, known); @@ -2404,7 +2450,7 @@ function* useAcpxProviderState( } if ( route?.route === "client-native" && - !sameExecutableBuild(build.binding, route.executableBinding) + !sameExecutableBuild(route.executableBinding, build.binding) ) { const drift = buildDrift(sessionKey, route.executableBinding, build.binding); return refusal(drift.class, drift.message, known); @@ -3023,7 +3069,13 @@ function* useAcpxProviderState( `accepted its identity, so this run cannot confirm the conversation it names`, }; } - if (!sameExecutableBuild(route.executableBinding, prepared.executableBinding)) { + // Both directions, because neither of these is the live build: they are two + // durable accounts of one observation, so one saying less than the other is + // already a disagreement rather than a build that has gone quiet. + if ( + !sameExecutableBuild(route.executableBinding, prepared.executableBinding) || + !sameExecutableBuild(prepared.executableBinding, route.executableBinding) + ) { return stop( `session "${prepared.sessionKey}" is described differently by its journal and its ` + `construction route, and neither account repairs the other`, @@ -3041,7 +3093,7 @@ function* useAcpxProviderState( } let build: BoundBuild; try { - build = yield* observeBuild(prepared.agent, agentCommand, adapter.binding); + build = yield* observeBuild(prepared.agent, agentCommand, adapter.protocol, adapter.binding); } catch (error) { if (error instanceof AttachmentRefused) { return error.failure; @@ -3056,7 +3108,7 @@ function* useAcpxProviderState( if (unproved) { return unproved; } - if (!sameExecutableBuild(build.binding, route.executableBinding)) { + if (!sameExecutableBuild(route.executableBinding, build.binding)) { return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); } invocation.bound.set(prepared.sessionKey, { build, adapter }); diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index 128d0b27a..d20cfa06e 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -105,7 +105,17 @@ const CLIENT_NATIVE_MEMBERS = [ "launcher", ]; const BOUND_CLIENT_NATIVE_MEMBERS = [...CLIENT_NATIVE_MEMBERS, "executableBinding"]; -const BINDING_MEMBERS = ["schema", "reportedVersion", "executableDigest"]; +const BINDING_MEMBERS = ["schema", "executableDigest"]; +/** + * The one member a binding may omit. + * + * Separate from the required set rather than merged into it, because a binding + * is compared for equality: an unexpected member is still a fact the writer + * thought was part of the build's identity, and reading past it would call two + * different builds the same one. Omitting this one is not an unexpected member + * — it is the record saying the build reported no version it recognized. + */ +const BINDING_OPTIONAL_MEMBERS = ["reportedVersion"]; const DIGEST_MEMBERS = ["algorithm", "value"]; function isRecord(value: unknown): value is Record { @@ -117,6 +127,18 @@ function exactly(value: Record, members: readonly string[]): bo return keys.length === members.length && keys.every((key) => members.includes(key)); } +function declared( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + required.every((member) => keys.includes(member)) && + keys.every((key) => required.includes(key) || optional.includes(key)) + ); +} + /** * Read a route strictly. * @@ -129,21 +151,23 @@ function exactly(value: Record, members: readonly string[]): bo /** * Read a retained build binding strictly. * - * The member set is exact rather than minimal, because a binding is compared - * for equality: a member this build ignores is a fact the writer thought was - * part of the build's identity, and comparing without it would call two - * different builds the same one. + * The digest is required and exact — it is what binds a session to a build. + * A present `reportedVersion` must still be a real one: a member written as an + * empty string is a claim about a release nobody can reproduce, which is not + * the same as having made no claim. */ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { - if (!isRecord(value) || !exactly(value, BINDING_MEMBERS)) { + if (!isRecord(value) || !declared(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { return undefined; } const { schema, reportedVersion, executableDigest } = value; if (schema !== "executable-build.v1") { return undefined; } - if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { - return undefined; + if (reportedVersion !== undefined) { + if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { + return undefined; + } } if (!isRecord(executableDigest) || !exactly(executableDigest, DIGEST_MEMBERS)) { return undefined; @@ -154,7 +178,7 @@ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | unde } return { schema: "executable-build.v1", - reportedVersion, + ...(reportedVersion === undefined ? {} : { reportedVersion }), executableDigest: { algorithm: "sha256", value: digest }, }; } @@ -250,7 +274,9 @@ export function serializeAgentSessionRoute(route: AgentSessionRoute): string { if (route.schema === "session-route.v2") { payload.executableBinding = { schema: route.executableBinding.schema, - reportedVersion: route.executableBinding.reportedVersion, + ...(route.executableBinding.reportedVersion === undefined + ? {} + : { reportedVersion: route.executableBinding.reportedVersion }), executableDigest: { algorithm: route.executableBinding.executableDigest.algorithm, value: route.executableBinding.executableDigest.value, diff --git a/packages/acp/tests/fixtures/claude-native-launch-proof.ts b/packages/acp/tests/fixtures/claude-native-launch-proof.ts index 717991d94..071e03e78 100644 --- a/packages/acp/tests/fixtures/claude-native-launch-proof.ts +++ b/packages/acp/tests/fixtures/claude-native-launch-proof.ts @@ -46,8 +46,12 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import process from "node:process"; import { createAgentRegistry } from "../../src/acpx-runtime.ts"; -import { agentSessionKeyDigest } from "@executablemd/runtime"; -import { ADVERTISED_NATIVE_LAUNCH } from "../../src/native-launch.ts"; +import { agentSessionKeyDigest, createDenoExecutableObserver } from "@executablemd/runtime"; +import { + ADVERTISED_NATIVE_LAUNCH, + allocatesIdentity, + nativeAdapterFor, +} from "../../src/native-launch.ts"; /** Opting in at all. Absent, every mode refuses before a provider child. */ const PROOF_ENV = "XMD_CLAUDE_NATIVE_PROOF"; @@ -58,8 +62,26 @@ const AUTHORIZED_TURNS = "2"; /** The production target, exactly as an operator would type it. */ const TARGET = "AGENTS.md#Implementor"; -/** The compatibility point these journeys are only meaningful against. */ -const REQUIRED_CLAUDE_VERSION = "2.1.241 (Claude Code)"; +/** + * What the installed build has to declare for these journeys to mean anything. + * + * The shape, not a release. What is proved here is that a build declaring + * caller-supplied identity, exact resume and private-file instructions can be + * handed a session — so the journeys run against any build that declares it, + * and refuse to draw conclusions from one that does not. + */ +const REQUIRED_CAPABILITY = "native-launch"; + +/** Whether the installed build declares the shape, read by the shipped adapter. */ +function* declaresRequiredShape(): Operation { + const observer = createDenoExecutableObserver(); + const adapter = nativeAdapterFor("claude"); + if (!observer || !adapter || !allocatesIdentity(adapter)) { + return false; + } + const found = yield* observer.observe("claude", { metadata: adapter.binding.metadata }); + return adapter.binding.probe(found.metadata).capabilities.includes(REQUIRED_CAPABILITY); +} /** * The role contract's opening sentence. @@ -1502,9 +1524,9 @@ function* ready(journey: Journey, verdict: JourneyVerdict): Operation { return false; } verdict.claudeVersion = yield* claudeVersion(journey); - if (verdict.claudeVersion !== REQUIRED_CLAUDE_VERSION) { + if (!(yield* declaresRequiredShape())) { verdict.verdict = "ENVIRONMENT_BLOCKED"; - verdict.detail = `this journey is only meaningful against ${REQUIRED_CLAUDE_VERSION}`; + verdict.detail = `the installed build does not declare ${REQUIRED_CAPABILITY}`; return false; } if (!verdict.projectCopyVerified) { @@ -1830,8 +1852,10 @@ function* runPreflight(): Operation { verdict.ptyUsable = pty.stdout.includes("PTY"); verdict.binaryBuilt = yield* exists(XMD_BINARY); + // No release is named here. The three flag readings below are the shape this + // preflight exists to establish, and a build that declares them is one these + // journeys can be run against whatever release it reports. const established = - verdict.claudeVersion === REQUIRED_CLAUDE_VERSION && verdict.platform === "darwin" && verdict.architecture === "arm64" && !verdict.claudeConfigDirSet && @@ -1846,8 +1870,8 @@ function* runPreflight(): Operation { verdict.advertised[0] === "claude"; verdict.verdict = established ? "PASS" : "ENVIRONMENT_BLOCKED"; verdict.detail = established - ? "the frozen compatibility point is established, and no model turn was spent" - : "the frozen compatibility point is not established"; + ? "the required capability shape and host are established, and no model turn was spent" + : "the required capability shape and host are not established"; return verdict; } diff --git a/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts b/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts index b218ecc1a..0a868cc02 100644 --- a/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts +++ b/packages/acp/tests/fixtures/claude-native-to-acp-proof.ts @@ -86,8 +86,15 @@ const RECOVER_PREFIX = "RECOVERED-"; /** The production target, exactly as an operator would type it. */ const TARGET = "AGENTS.md#Implementor"; -/** The compatibility point these journeys are only meaningful against. */ -const REQUIRED_CLAUDE_VERSION = "2.1.241 (Claude Code)"; +/** + * What the installed build has to declare for these journeys to mean anything. + * + * The shape, not a release. A build that declares caller-supplied identity, + * exact resume and private-file instructions is one these journeys can be run + * against whatever it calls itself, and a build that does not is one where a + * failure would say nothing about the product. + */ +const REQUIRED_CAPABILITIES = ["native-launch", "client-native-attachment"] as const; /** * The role contract's opening sentence. @@ -1616,9 +1623,11 @@ function* ready(journey: Journey, verdict: JourneyVerdict): Operation { return false; } verdict.claudeVersion = yield* claudeVersion(journey); - if (verdict.claudeVersion !== REQUIRED_CLAUDE_VERSION) { + const declared = (yield* observeBuild(journey)).capabilities; + const missing = REQUIRED_CAPABILITIES.filter((capability) => !declared.includes(capability)); + if (missing.length > 0) { verdict.verdict = "ENVIRONMENT_BLOCKED"; - verdict.detail = `this journey is only meaningful against ${REQUIRED_CLAUDE_VERSION}`; + verdict.detail = `the installed build does not declare ${missing.join(" or ")}`; return false; } if (!verdict.projectCopyVerified) { @@ -1712,20 +1721,29 @@ function boundAdapterCommand(): string { return adapter.binding.adapterCommand ?? ""; } -/** What this run's own observer says the installed Claude build is. */ -function* observeBuild(journey: Journey): Operation<{ version: string; digest: string }> { +/** + * What this run's own observer says the installed Claude build is. + * + * The declared read-only queries are asked through the shipped adapter's own + * binding, so what this reads is the same evidence the provider will read. + * `version` is optional evidence: a build that will not name its release is + * bound by its digest, and this reports that as the empty string. + */ +function* observeBuild( + journey: Journey, +): Operation<{ version: string; digest: string; capabilities: readonly string[] }> { const observer = createDenoExecutableObserver(); - if (!observer) { - return { version: "", digest: "" }; - } - const found = yield* observer.observe("claude"); const adapter = nativeAdapterFor("claude"); - const version = - adapter && allocatesIdentity(adapter) - ? adapter.binding.version(found.versionOutput) - : undefined; + if (!observer || !adapter || !allocatesIdentity(adapter)) { + return { version: "", digest: "", capabilities: [] }; + } + const found = yield* observer.observe("claude", { metadata: adapter.binding.metadata }); void journey; - return { version: version ?? "", digest: found.digest.value }; + return { + version: adapter.binding.reportedVersion(found.metadata) ?? "", + digest: found.digest.value, + capabilities: adapter.binding.probe(found.metadata).capabilities, + }; } /** @@ -1746,7 +1764,7 @@ function* runAbsentIdentity(journey: Journey, verdict: JourneyVerdict): Operatio journey.absentKey = sessionKey; const store = createDenoSessionRouteStore(SESSION_COORDINATOR_ROOT); const observed = yield* observeBuild(journey); - if (!store || observed.version.length === 0 || observed.digest.length !== 64) { + if (!store || observed.digest.length !== 64) { verdict.verdict = "HARNESS_FAILED"; verdict.detail = "the absent-identity case could not name a build to bind to"; return; @@ -1763,7 +1781,9 @@ function* runAbsentIdentity(journey: Journey, verdict: JourneyVerdict): Operatio launcher: "claude", executableBinding: { schema: "executable-build.v1", - reportedVersion: observed.version, + // Only when this build named a release. A binding that invented one would + // refuse for drift, and this case is about an absent conversation. + ...(observed.version.length > 0 ? { reportedVersion: observed.version } : {}), executableDigest: { algorithm: "sha256", value: observed.digest }, }, }); @@ -1844,8 +1864,14 @@ function decideAttachment(verdict: JourneyVerdict): void { verdict.detail = "the route, the journal and the ACP arrangement do not name one conversation"; return; } + // The digest is what binds, and the route and the journal both carry one or + // this run never got a build observation at all. A retained release is a + // further claim the live build has to still make — checked when the route + // named one, and nothing to reproduce when it did not. verdict.observed.matchesRoute = - verdict.observed.version.length > 0 && verdict.observed.version === verdict.route.buildVersion; + verdict.route.buildVersion.length === 0 + ? verdict.route.buildDigestPresent && verdict.observed.digestPresent + : verdict.observed.version === verdict.route.buildVersion; if ( !verdict.observed.matchesRoute || verdict.journal.buildVersion !== verdict.route.buildVersion diff --git a/packages/acp/tests/helpers.ts b/packages/acp/tests/helpers.ts index 3a55c2340..509d4ce8c 100644 --- a/packages/acp/tests/helpers.ts +++ b/packages/acp/tests/helpers.ts @@ -24,6 +24,7 @@ import type { AgentSessionOwner, AgentSessionOwnerKind, AgentSessionOwnership, + ExecutableMetadataObservation, ExecutableObserver, ExecutableRefusal, } from "@executablemd/runtime"; @@ -190,17 +191,57 @@ export interface FakeRuntimeHarness { script(turn: ScriptedTurn): void; } +/** One query's answer, in the shape a real observation produces. */ +export function answered(stdout: string): ExecutableMetadataObservation { + return { settled: true, code: 0, stdout, stderr: "" }; +} + +/** The three declarations native launch is observed from, as Claude spells them. */ +export const CLAUDE_HELP_DECLARATIONS = { + sessionId: " --session-id Use a specific session ID for the conversation", + resume: " -r, --resume [sessionId] Resume a conversation", + privateFile: " --system-prompt-file Load the system prompt from a file", +} as const; + +/** + * A Commander-shaped Claude help surface. + * + * Written out rather than captured, because what the probe reads is the shape + * of the declarations rather than any wording: a scenario varies exactly one of + * them, and everything a real build also prints is noise the answer must + * survive. + */ +export function claudeHelp(options?: { + product?: string; + usage?: string; + declarations?: readonly string[]; + extra?: readonly string[]; +}): string { + return [ + options?.product ?? "Claude Code - starts an interactive session by default", + "", + options?.usage ?? "Usage: claude [options] [command] [prompt]", + "", + "Options:", + ...(options?.declarations ?? Object.values(CLAUDE_HELP_DECLARATIONS)), + ...(options?.extra ?? []), + "", + ].join("\n"); +} + /** One controlled build, as an observer would report it. */ export interface FakeObservation { path: string; digest: string; - versionOutput: string; + metadata: Record; } export interface FakeObserverHarness { observer: ExecutableObserver; /** Every command this observer was asked about, in order. */ observed: string[]; + /** Every metadata query it was asked to run, as `name argv…`, in order. */ + queried: string[]; /** What the next observation answers, or the failure it raises. */ observation: FakeObservation; /** Answers taken in order before `observation`, so a build can change. */ @@ -214,22 +255,31 @@ export interface FakeObserverHarness { * The whole seam is replaced, exactly as a trusted host supplies the whole * thing: nothing inside the observer is made replaceable to be testable, so * drift is expressed by answering differently rather than by a control the - * production path also has. + * production path also has. The answers are what a build declared, so the + * adapter's own probe reads them — a scenario removes a declaration rather + * than removing the capability it implies. */ export function createFakeObserver(observation?: Partial): FakeObserverHarness { const harness: FakeObserverHarness = { observed: [], + queried: [], queued: [], observation: { path: "/opt/builds/claude", digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", + metadata: { + help: answered(claudeHelp()), + version: answered("2.1.241 (Claude Code)\n"), + }, ...observation, }, observer: { // deno-lint-ignore require-yield - *observe(command) { + *observe(command, options) { harness.observed.push(command); + for (const query of options?.metadata ?? []) { + harness.queried.push([query.name, ...query.args].join(" ")); + } if (harness.failure) { throw new ExecutableObservationError(`${command} could not be observed`, { refusal: harness.failure, @@ -239,7 +289,7 @@ export function createFakeObserver(observation?: Partial): Fake return { path: answer.path, digest: { algorithm: "sha256", value: answer.digest }, - versionOutput: answer.versionOutput, + metadata: answer.metadata, }; }, }, diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index bb957f87b..b61e61a02 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -39,23 +39,26 @@ import { allocatesIdentity, knownNativeAdapters, nativeAdapterFor, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "../src/native-launch.ts"; import type { NativeCapability, - NativeCapabilityCompatibility, NativeCapabilityHost, + NativeCapabilityPolicy, } from "../src/native-capability.ts"; import { createHash, randomUUID } from "node:crypto"; import { readFile, rm, stat, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import process from "node:process"; -import type { NativeAdapter, NativeBinding } from "../src/native-launch.ts"; +import type { ClientAllocatedAdapter, NativeAdapter, NativeBinding } from "../src/native-launch.ts"; import { AgentSessionRouteError, createMemorySessionRouteStore } from "../src/session-route.ts"; import type { AgentSessionRoute, AgentSessionRouteStore } from "../src/session-route.ts"; import { deriveSessionKey } from "../src/session-key.ts"; import { + answered, + claudeHelp, + CLAUDE_HELP_DECLARATIONS, createFakeObserver, createFakeRuntime, makeCoordinator, @@ -64,7 +67,12 @@ import { makeStore, useFlatWorld, } from "./helpers.ts"; -import type { CoordinatorHarness, FakeObserverHarness, FakeRuntimeHarness } from "./helpers.ts"; +import type { + CoordinatorHarness, + FakeObservation, + FakeObserverHarness, + FakeRuntimeHarness, +} from "./helpers.ts"; import type { ExecutableBuildBindingV1 } from "@executablemd/core"; import type { AcpxSessionPolicy } from "../src/provider.ts"; import type { ExecutableObserver } from "@executablemd/runtime"; @@ -143,25 +151,47 @@ function createCwdBarrier(dir: string): CwdBarrier { }; } +/** + * The shipped Claude adapter, so the controlled adapters here carry the real + * one's evidence rather than a convenient stand-in. + * + * What a build declares is adapter-owned knowledge, and a test binding that + * read help its own way would prove a parser nothing ships. Taking the shipped + * one means removing a declaration from an observation exercises production + * admission, which is the point of injecting the whole observation. + */ +function shippedClaude(): ClientAllocatedAdapter { + const adapter = nativeAdapterFor("claude"); + if (adapter === undefined || !allocatesIdentity(adapter)) { + throw new Error("the shipped claude adapter names its own sessions"); + } + return adapter; +} +const CLAUDE_ADAPTER = shippedClaude(); +const CLAUDE_PROTOCOL = CLAUDE_ADAPTER.protocol; +const CLAUDE_PROBE_PROFILE = CLAUDE_ADAPTER.binding.probe({}).probeProfile; + /** * The Claude-shaped build contract every controlled client-allocated adapter - * here carries: which command to observe, what its version output means, and - * what the ACP child needs in order to run that same build. + * here carries: which command to observe, which read-only questions to ask it, + * what the answers mean, and what the ACP child needs to run that same build. */ const TEST_BINDING: NativeBinding = { command: "claude", - // The same contract the shipped Claude adapter carries: exactly one canonical - // line is an answer, and zero or several are not. - version: (output) => { - const canonical = output - .split("\n") - .map((line) => line.trim()) - .filter((line) => /^\d+\.\d+\.\d+ \(Claude Code\)$/.test(line)); - return canonical.length === 1 ? canonical[0] : undefined; - }, + metadata: CLAUDE_ADAPTER.binding.metadata, + probe: CLAUDE_ADAPTER.binding.probe, + reportedVersion: CLAUDE_ADAPTER.binding.reportedVersion, environment: (livePath) => ({ CLAUDE_CODE_EXECUTABLE: livePath }), }; +/** + * The ACP bridge the shipped adapter pins. + * + * Attachment is observed from exact resume plus this pin, so a controlled + * adapter that dropped it would be describing a different contract. + */ +const TEST_BRIDGE = CLAUDE_ADAPTER.binding.adapterCommand; + /** What `createFakeObserver()`'s default observation binds to. */ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { schema: "executable-build.v1", @@ -169,13 +199,46 @@ const OBSERVED_BUILD: ExecutableBuildBindingV1 = { executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, }; +/** The same build, bound by a build that reported no version it recognized. */ +const DIGEST_ONLY_BUILD: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, +}; + /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * One controlled observation, varying only what a case is about. + * + * The default is a build that declares the whole native-launch shape and + * reports the proved version, so a case that changes nothing is describing an + * ordinary installation. + */ +function observation(overrides?: { + path?: string; + digest?: string; + /** What `--help` answered. `false` is a build that would not answer it. */ + help?: string | false; + /** What `--version` answered. `false` is a query that did not settle. */ + version?: string | false; +}): FakeObservation { + return { + path: overrides?.path ?? OBSERVED_PATH, + digest: overrides?.digest ?? "a".repeat(64), + metadata: { + ...(overrides?.help === false ? {} : { help: answered(overrides?.help ?? claudeHelp()) }), + ...(overrides?.version === false + ? {} + : { version: answered(overrides?.version ?? "2.1.241 (Claude Code)\n") }), + }, + }; +} + /** * The machine these cases describe. * - * Stated, never read from the runner. A capability point names an exact OS and + * Stated, never read from the runner. An admission names an exact OS and * architecture, so a suite that asked the machine underneath it what it was * would be admitting whatever it happened to run on — and the shipped Claude * evidence, which is a Mac with Apple silicon, would be exercised on one CI @@ -184,24 +247,25 @@ const OBSERVED_PATH = "/opt/builds/claude"; const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; /** - * A host that has proved the named builds of `claude`, both capabilities each. + * A host that has proved the named protocol on this machine, both capabilities. * - * For the cases that need two admitted builds at once. Everything else takes - * the package's own evidence, so what most of this file runs against is the - * shipped table rather than a convenient stand-in. + * For the cases that describe a host which proved something other than what + * ships. Everything else takes the package's own evidence, so what most of this + * file runs against is the shipped policy. */ -function admitting(...reportedVersions: readonly string[]): NativeCapabilityCompatibility { +function admitting( + adapterProtocol: string = CLAUDE_PROTOCOL, + probeProfile: string = CLAUDE_PROBE_PROFILE, +): NativeCapabilityPolicy { const capabilities: readonly NativeCapability[] = ["native-launch", "client-native-attachment"]; return { host: PROVED_HOST, - points: reportedVersions.flatMap((reportedVersion) => - capabilities.map((capability) => ({ - agent: "claude", - capability, - reportedVersion, - ...PROVED_HOST, - })), - ), + admissions: capabilities.map((capability) => ({ + adapterProtocol, + capability, + probeProfile, + ...PROVED_HOST, + })), }; } @@ -243,13 +307,13 @@ interface ProviderOptions { /** `false` gives this host no way to observe a build at all. */ observer?: ExecutableObserver | false; /** - * Which exact points this host has proved. + * Which exact admissions this host has proved. * * Defaults to the package's own evidence for the machine above, so a case * says nothing unless it is describing a host that proved something else. * `false` is a host that has proved nothing and admits nothing. */ - compatibility?: NativeCapabilityCompatibility | false; + nativeCapabilityPolicy?: NativeCapabilityPolicy | false; store?: AcpSessionStore; adapters?: Record; /** @@ -432,9 +496,12 @@ function* installLaunchStack( ...(options.observer === false ? {} : { executableObserver: options.observer ?? createFakeObserver().observer }), - ...(options.compatibility === false + ...(options.nativeCapabilityPolicy === false ? {} - : { compatibility: options.compatibility ?? nativeCapabilityCompatibility(PROVED_HOST) }), + : { + nativeCapabilityPolicy: + options.nativeCapabilityPolicy ?? nativeCapabilityPolicy(PROVED_HOST), + }), coordinator: options.coordinator ?? trace.ownership.coordinator, ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.withSessionRoute ? { withSessionRoute: options.withSessionRoute } : {}), @@ -463,6 +530,7 @@ function newTrace(): Trace { */ const PROVIDER_RETURNED_CLAUDE: NativeAdapter = { launcher: "claude", + protocol: "claude-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["claude", "--resume", nativeSessionId], }; @@ -1513,6 +1581,7 @@ describe("Tier CN — client-allocated construction", () => { function clientNative(allocate: () => string = () => ALLOCATED): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate, @@ -1905,6 +1974,7 @@ describe("Tier CR — client-allocated incomplete replay", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2181,6 +2251,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "66666666-7777-8888-9999-000000000000", @@ -2216,6 +2287,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "77777777-8888-9999-0000-111111111111", @@ -2265,6 +2337,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "88888888-9999-0000-1111-222222222222", @@ -2313,6 +2386,7 @@ describe("Tier PF — normalized private failures", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "33333333-4444-5555-6666-777777777777", @@ -2352,6 +2426,7 @@ describe("Tier RR — racing construction routes", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2573,6 +2648,7 @@ describe("Tier CX — cancellation before ownership ends", () => { adapters: { claude: { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => "55555555-6666-7777-8888-999999999999", @@ -2683,6 +2759,7 @@ describe("Tier CA — client-native attachment", () => { function adapter(): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -2846,13 +2923,13 @@ describe("Tier CA — client-native attachment", () => { [ "another version of the same bytes", (observer: FakeObserverHarness) => { - observer.observation.versionOutput = "2.1.242 (Claude Code)\n"; + observer.observation.metadata.version = answered("2.1.242 (Claude Code)\n"); }, ], [ - "output this adapter does not recognize", + "a build that no longer reproduces the version this route retained", (observer: FakeObserverHarness) => { - observer.observation.versionOutput = "claude version 2.1.241\n"; + observer.observation.metadata.version = answered("claude version 2.1.241\n"); }, ], [ @@ -2955,14 +3032,7 @@ describe("Tier CA — client-native attachment", () => { // somewhere else is the same partition key and a different file to run — // and handing the next attachment the old path is how it would run one. const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); // A second session bound to the same build, so what the next attachment // reaches for is the same partition. It is a different session because the @@ -3123,14 +3193,7 @@ describe("Tier CA — client-native attachment", () => { it("CA16: a halted ensure that then fails gives its claim back", function* () { const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); const second = deriveSessionKey(AGENT_COMMAND, CWD, "second"); yield* space.routes.publish(bound({ sessionKey: second, nativeSessionId: SECOND_ALLOCATED })); @@ -3167,14 +3230,7 @@ describe("Tier CA — client-native attachment", () => { // that goes with it is a fresh one: a value that still named a runtime // would answer from the path that runtime was built with. const observer = createFakeObserver(); - observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { - path: "/moved/bin/claude", - digest: "a".repeat(64), - versionOutput: "2.1.241 (Claude Code)\n", - }, - ]; + observer.queued = [observation(), observation({ path: "/moved/bin/claude" })]; const space = yield* installAttachment(bound(), { observer: observer.observer }); const session = yield* Agent.operations.session(); @@ -3248,17 +3304,22 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toHaveLength(2); }); - it("CA14: output naming several builds refuses before a child, an ensure or a turn", function* () { + it("CA14: a route's retained version that this build will not reproduce refuses before a child, an ensure or a turn", function* () { // One canonical line is an answer. Several is a list of builds, and taking - // the first would be choosing one — which is the question this refuses. + // the first would be choosing one — so this build reports no version. That + // is not a refusal in itself; what refuses is that the route retained one, + // and a claim the live build no longer makes cannot be confirmed. const observer = createFakeObserver({ - versionOutput: "2.1.241 (Claude Code)\n2.1.242 (Claude Code)\n", + metadata: { + help: answered(claudeHelp()), + version: answered("2.1.241 (Claude Code)\n2.1.242 (Claude Code)\n"), + }, }); const space = yield* installAttachment(bound(), { observer: observer.observer }); const raised = yield* attach(); - expect(raised?.message).toContain("does not recognize"); + expect(raised?.message).toContain("cannot be confirmed"); // Nothing was repeated back: the output is the provider's, not the reader's. expect(raised?.message).not.toContain("2.1.242"); expect(space.harness.createdOptions).toEqual([]); @@ -3326,6 +3387,7 @@ describe("Tier RT — bound runtime partitions", () => { function adapter(): NativeAdapter { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => FIRST, @@ -3428,8 +3490,8 @@ describe("Tier RT — bound runtime partitions", () => { const routes = createMemorySessionRouteStore(); const observer = createFakeObserver(); observer.queued = [ - { path: OBSERVED_PATH, digest: "a".repeat(64), versionOutput: "2.1.241 (Claude Code)\n" }, - { path: OTHER_PATH, digest: "d".repeat(64), versionOutput: "2.1.242 (Claude Code)\n" }, + observation(), + observation({ path: OTHER_PATH, digest: "d".repeat(64), version: "2.1.242 (Claude Code)\n" }), ]; const second = deriveSessionKey(AGENT_COMMAND, CWD, "second"); yield* routes.publish(route(SESSION_KEY, FIRST)); @@ -3438,8 +3500,6 @@ describe("Tier RT — bound runtime partitions", () => { adapters: { claude: adapter() }, routeStore: routes, observer: observer.observer, - // Two builds is the whole case, so this host has proved both of them. - compatibility: admitting("2.1.241 (Claude Code)", "2.1.242 (Claude Code)"), }); yield* Agent.operations.session(); @@ -3784,6 +3844,7 @@ describe("Tier LU — legacy unbound client-native", () => { const ADAPTER: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => ALLOCATED, @@ -3959,21 +4020,162 @@ describe("Tier AO — explicit ACP-only capability", () => { }); }); +/** + * Tier CP — what the shipped Claude adapter reads out of a build's help + * (specs/native-agent-session-launch-spec.md §Executable binding). + * + * The fixtures above carry this same probe, but they are fixtures. This is the + * parser production runs, and what it decides is which builds may be handed a + * session at all. + * + * It is structural rather than a snapshot: a release that adds options, rewords + * prose or rewraps a line still declares the same operations, and one that + * stopped declaring one cannot do the work whatever it calls itself. + */ +describe("Tier CP — the Claude capability probe", () => { + function probe(help: string | undefined): readonly NativeCapability[] { + return CLAUDE_ADAPTER.binding.probe(help === undefined ? {} : { help: answered(help) }) + .capabilities; + } + + it("CP1: a build declaring all three operations can be handed a session", function* () { + expect(probe(claudeHelp())).toEqual(["native-launch", "client-native-attachment"]); + // And the profile it answers under is the one an admission names. + expect(CLAUDE_ADAPTER.binding.probe({}).probeProfile).toBe("claude-help-native-session.v1"); + }); + + it("CP2: options, prose and wrapping this adapter did not ask about change nothing", function* () { + const noisy = claudeHelp({ + product: + "Claude Code - starts an interactive session by default\n\n" + + "Learn more at https://docs.claude.com/en/docs/claude-code", + extra: [ + " --brand-new-option An option no proof has ever seen", + " -c, --continue Continue the most recent conversation", + " --bare Print only the response. Use --system-prompt[-file]", + " to steer it.", + ], + }); + expect(probe(noisy)).toEqual(["native-launch", "client-native-attachment"]); + + // The same declarations, wrapped onto continuation lines the way Commander + // wraps a long description. What is read is the flag and whether it takes a + // value, so where the description broke is not part of the answer. + const wrapped = claudeHelp({ + declarations: [ + " --session-id Use a specific session ID for the", + " conversation (must be a valid UUID)", + " -r, --resume [sessionId] Resume a conversation — provide a session", + " ID or interactively select one", + " --system-prompt-file Load the system prompt from a file, for", + " this invocation only", + ], + }); + expect(probe(wrapped)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP3: the private-file declaration is read in either spelling Claude uses", function* () { + // Builds that give it no entry of its own document the family in prose + // instead. Both are the same declaration, and neither is inferred from the + // other's absence. + const family = claudeHelp({ + declarations: [CLAUDE_HELP_DECLARATIONS.sessionId, CLAUDE_HELP_DECLARATIONS.resume], + extra: [ + " --system-prompt Override the system prompt", + " --bare Print only the response. Combine with", + " --system-prompt[-file] to steer it.", + ], + }); + expect(family).toContain("--system-prompt[-file]"); + expect(probe(family)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP4: a missing or unreadable declaration is not a capability", function* () { + // Each row withdraws exactly one thing and states what is left. A build + // missing something only launch needs still attaches, because the two are + // read independently — what must never survive is the capability whose own + // evidence went away. + const entries = CLAUDE_HELP_DECLARATIONS; + const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; + for (const [name, help, remaining] of [ + // Nothing is recognized without the product, so both go. + ["no help at all", undefined, []], + ["help that names another product", claudeHelp({ product: "gemini-cli" }), []], + [ + "help whose usage line is another command", + claudeHelp({ usage: "Usage: gemini [options]" }), + [], + ], + // Exact resume is the one declaration both capabilities need. + [ + "no exact resume", + claudeHelp({ declarations: [entries.sessionId, entries.privateFile] }), + [], + ], + // Launch-only evidence: attachment is untouched by its absence. + [ + "no caller-supplied identity", + claudeHelp({ declarations: [entries.resume, entries.privateFile] }), + ATTACH_ONLY, + ], + [ + "an identity option that takes no value", + claudeHelp({ + declarations: [ + " --session-id Start a new session", + entries.resume, + entries.privateFile, + ], + }), + ATTACH_ONLY, + ], + [ + "two entries declaring the same identity option", + claudeHelp({ + declarations: [ + entries.sessionId, + " --session-id Deprecated spelling", + entries.resume, + entries.privateFile, + ], + }), + ATTACH_ONLY, + ], + [ + "no private instruction file", + claudeHelp({ declarations: [entries.sessionId, entries.resume] }), + ATTACH_ONLY, + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, remaining]); + } + }); + + it("CP5: neither capability is inferred from the other", function* () { + // Exact resume is what attachment needs, and it is one of the three things + // launch needs. A build declaring resume and nothing else attaches and + // cannot launch — the shared declaration authorizes only its own capability. + const resumeOnly = claudeHelp({ declarations: [CLAUDE_HELP_DECLARATIONS.resume] }); + expect(probe(resumeOnly)).toEqual(["client-native-attachment"]); + + // And launch does not carry attachment, because attachment also needs the + // ACP bridge this adapter pins — knowledge about the child process rather + // than about the CLI, which no help surface can supply. + expect(TEST_BRIDGE).toBe("npx -y @agentclientprotocol/claude-agent-acp@0.70.0"); + }); +}); + /** * Tier CV — the shipped Claude adapter's canonical version * (specs/native-agent-session-launch-spec.md §Executable binding). * - * The fixtures above carry the same contract, but they are fixtures. This is - * the parser production runs, and what it decides is which builds a session may - * be bound to at all. + * Optional evidence, deliberately: a version says which release is installed, + * not what it can do. Nothing here decides a capability, and every unreadable + * answer is an ordinary absence rather than a refusal. */ describe("Tier CV — canonical Claude version", () => { function parse(output: string): string | undefined { - const adapter = nativeAdapterFor("claude"); - if (!adapter || !allocatesIdentity(adapter)) { - throw new Error("the shipped claude adapter names its own sessions"); - } - return adapter.binding.version(output); + return CLAUDE_ADAPTER.binding.reportedVersion({ version: answered(output) }); } it("CV1: one canonical line is the answer, whole", function* () { @@ -4007,6 +4209,21 @@ describe("Tier CV — canonical Claude version", () => { // this adapter cannot read as one answer. expect(parse("2.1.241 (Claude Code)\n2.1.241 (Claude Code)\n")).toBe(undefined); }); + + it("CV4: a query that failed, never settled, or was never asked reports nothing", function* () { + const version = CLAUDE_ADAPTER.binding.reportedVersion; + // Never asked. + expect(version({})).toBe(undefined); + // Asked, and the child never started. + expect(version({ version: { settled: false, stdout: "", stderr: "" } })).toBe(undefined); + // Asked, answered, and exited nonzero — output beside a failure is not a + // report, whatever it happens to say. + expect( + version({ + version: { settled: true, code: 1, stdout: "2.1.241 (Claude Code)\n", stderr: "" }, + }), + ).toBe(undefined); + }); }); /** @@ -4014,21 +4231,44 @@ describe("Tier CV — canonical Claude version", () => { * (specs/decisions.md §DEC-017). * * An advertised adapter name selects a command shape. What admits work on a - * session is a point: one agent, one capability, one canonical reported - * version, one OS and one architecture, matched whole. These cases run the four - * paths that check one — fresh client-native construction, bound native resume, - * bound ACP attachment and incomplete replay — and each refusal is read at the - * boundary it is supposed to stop in front of, rather than by its message - * alone: nothing may be allocated, published, written, spawned, ensured or - * retained behind it. + * session is an admission: one adapter protocol, one capability, one probe + * profile, one OS and one architecture, matched whole — and it is only reached + * when the executable itself declared that shape. Both gates are needed: + * declaring the shape is not proof this host ran it, and proving a machine is + * not proof the build installed on it can do the work. + * + * No Claude Code version appears in any of it. Which release is installed is a + * fact about a route, retained when the build will say and absent when it will + * not; what admits is what the build declares it can do. + * + * These cases run the four paths that check one — fresh client-native + * construction, bound native resume, bound ACP attachment and incomplete replay + * — and each refusal is read at the boundary it is supposed to stop in front + * of, rather than by its message alone: nothing may be allocated, published, + * written, spawned, ensured or retained behind it. */ -describe("Tier NP — proved native capability points", () => { +describe("Tier NP — proved native capability admissions", () => { const ALLOCATED = "cafe0000-1111-2222-3333-444444444444"; - /** The build the shipped Claude evidence names, and the one that follows it. */ + /** The build the shipped Claude proof ran against, and one that follows it. */ const PROVED_VERSION = "2.1.241 (Claude Code)"; const LATER_VERSION = "2.1.263 (Claude Code)"; + /** + * The same help surface with exactly one required declaration withdrawn. + * + * A build is refused for what it stopped declaring, not for what a test + * turned off: the observation is complete and the adapter's own probe reads + * it, so each row here is a release that could ship. + */ + function without(member: keyof typeof CLAUDE_HELP_DECLARATIONS): string { + return claudeHelp({ + declarations: Object.entries(CLAUDE_HELP_DECLARATIONS) + .filter(([name]) => name !== member) + .map(([, entry]) => entry), + }); + } + /** Every boundary a refused point must stop in front of. */ interface Boundaries { allocations: number; @@ -4052,6 +4292,7 @@ describe("Tier NP — proved native capability points", () => { function countedAdapter(seen: Boundaries, allocate: () => string = () => ALLOCATED) { return { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => { @@ -4166,54 +4407,82 @@ describe("Tier NP — proved native capability points", () => { } } - it("NP1: the exact proved launch point constructs, on the machine it names", function* () { - // The package's own evidence, unmodified, against the build it was proved - // against. Nothing here supplies a point: what admits this launch is the - // shipped Claude adapter's, read for darwin/arm64. - const harness = createFakeRuntime(); - const trace = newTrace(); - const seen = boundaries(); - const routes = countedRoutes(seen); - yield* installLaunchStack(harness, trace, { - adapters: { claude: countedAdapter(seen) }, - routeStore: routes, - }); + it("NP1: a build that declares the proved shape constructs, whatever release it is", function* () { + // The package's own evidence, unmodified. Nothing here supplies a policy: + // what admits these launches is the shipped Claude adapter's, read for + // darwin/arm64 — and the release the build reports is not part of it, so + // the version the proof ran against and a later one are the same admission. + for (const [name, version] of [ + ["the release the proof ran against", PROVED_VERSION], + ["a later release declaring the same shape", LATER_VERSION], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(observation({ version: `${version}\n` })); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + observer: observer.observer, + }); - yield* launch(INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - const prepared = trace.records[0] as PreparedLaunchRecord; - expect(prepared.failure).toBe(undefined); - expect(prepared.nativeSessionId).toBe(ALLOCATED); - expect(prepared.executableBinding?.reportedVersion).toBe(PROVED_VERSION); - expect(seen.allocations).toBe(1); - expect(seen.published).toHaveLength(1); - expect(trace.launches).toHaveLength(1); - // And the point that admitted it is the shipped one, for both capabilities. - expect(nativeCapabilityCompatibility(PROVED_HOST).points).toEqual([ + const prepared = trace.records[0] as PreparedLaunchRecord; + expect([name, prepared.failure]).toEqual([name, undefined]); + expect([name, prepared.nativeSessionId]).toEqual([name, ALLOCATED]); + // Retained, because this build would say — but it is the digest that + // binds, and the two runs are admitted alike. + expect([name, prepared.executableBinding?.reportedVersion]).toEqual([name, version]); + expect([name, seen.allocations]).toEqual([name, 1]); + expect([name, seen.published]).toHaveLength(2); + expect([name, trace.launches]).toHaveLength(2); + // And the only questions the build was asked are the read-only ones the + // adapter declared, at the exact path that was observed. + expect([name, observer.queried]).toEqual([name, ["help --help", "version --version"]]); + }); + } + + // The admissions that let both through are the shipped ones, for both + // capabilities, and they name no release at all. + expect(nativeCapabilityPolicy(PROVED_HOST).admissions).toEqual([ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ]); + expect(JSON.stringify(nativeCapabilityPolicy(PROVED_HOST))).not.toContain("Claude Code"); }); - it("NP2: an unproved point refuses a construction before it does anything", function* () { - // Four ways to be outside the proof, one refusal each. A later build, the - // same build on another OS, the same build on another architecture, and a - // host that has proved nothing at all: none of them is a near miss that - // some other reading could let through. + it("NP2: an unadmitted construction refuses before it does anything", function* () { + // Two ways to fail, several of each. A build that stopped declaring one of + // the operations native launch is made of, or would not answer at all; and + // a host whose proof does not cover this protocol, this profile, this OS or + // this architecture — or that has proved nothing. None is a near miss that + // some other reading could let through, and neither gate stands in for the + // other. for (const [name, options] of [ - ["a later build of the proved agent", { versionOutput: `${LATER_VERSION}\n` }], + ["a build declaring no caller-supplied identity", { help: without("sessionId") }], + ["a build declaring no exact resume", { help: without("resume") }], + ["a build declaring no private instruction file", { help: without("privateFile") }], + ["a build that is not the Claude product", { help: claudeHelp({ product: "gemini-cli" }) }], + ["a build that would not answer the required query", { help: false }], ["another operating system", { host: { platform: "linux", architecture: "arm64" } }], ["another architecture", { host: { platform: "darwin", architecture: "x64" } }], + ["a host that proved another adapter protocol", { policy: admitting("claude-fork.v1") }], + [ + "a host that proved another probe profile", + { policy: admitting(CLAUDE_PROTOCOL, "claude-help-native.v2") }, + ], ["a host that has proved nothing", { none: true }], ] as const) { yield* scoped(function* () { @@ -4222,7 +4491,7 @@ describe("Tier NP — proved native capability points", () => { const seen = boundaries(); const store = makeStore(); const observer = createFakeObserver( - "versionOutput" in options ? { versionOutput: options.versionOutput } : {}, + "help" in options ? observation({ help: options.help }) : {}, ); yield* withUnusablePrivateRoot(function* () { @@ -4231,12 +4500,14 @@ describe("Tier NP — proved native capability points", () => { routeStore: countedRoutes(seen), store, observer: observer.observer, - compatibility: + nativeCapabilityPolicy: "none" in options ? false : "host" in options - ? nativeCapabilityCompatibility(options.host) - : undefined, + ? nativeCapabilityPolicy(options.host) + : "policy" in options + ? options.policy + : undefined, }); const failure = yield* attempt(trace, INSTRUCTIONS); // Not `process-creation-failed`: the private root is unusable for the @@ -4245,7 +4516,7 @@ describe("Tier NP — proved native capability points", () => { expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); }); - // The build was observed — that is how the point became a question at + // The build was observed — that is how admission became a question at // all — and then everything the answer gates stopped. Each of these is // something NP1 sees happen on the admitted path. expect([name, observer.observed]).toEqual([name, ["claude"]]); @@ -4288,24 +4559,24 @@ describe("Tier NP — proved native capability points", () => { // Two capabilities, two proofs. A host that has proved one of them does // that one thing and refuses the other, in both directions — so neither is // ever inferred from the other having been proved. - const launchOnly: NativeCapabilityCompatibility = { + const launchOnly: NativeCapabilityPolicy = { host: PROVED_HOST, - points: [ + admissions: [ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ], }; - const attachOnly: NativeCapabilityCompatibility = { + const attachOnly: NativeCapabilityPolicy = { host: PROVED_HOST, - points: [ + admissions: [ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ], @@ -4320,7 +4591,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: countedRoutes(seen), - compatibility: launchOnly, + nativeCapabilityPolicy: launchOnly, }); yield* launch(INSTRUCTIONS); @@ -4347,7 +4618,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: attachOnly, + nativeCapabilityPolicy: attachOnly, }); const session = yield* Agent.operations.session(); @@ -4389,7 +4660,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); yield* act(trace); @@ -4435,6 +4706,133 @@ describe("Tier NP — proved native capability points", () => { expect(trace.launches).toEqual([]); }); + it("NP5b: a build whose release cannot be read is still admitted, and binds by digest", function* () { + // The version query is a courtesy, not a gate. Four ways for it to tell + // this run nothing — it answered nothing, it answered in words this adapter + // does not recognize, it answered with several things that could each be a + // version, and it did not settle at all. Under every one of them the shape + // was observed, so the launch runs; and the route it publishes says only + // what the run can stand behind, which is the digest. + for (const [name, version] of [ + ["no output at all", ""], + ["output in words this adapter does not recognize", "claude, the coding agent\n"], + [ + "several lines that could each be the release", + "2.1.241 (Claude Code)\n9.9.9 (Claude Code)\n", + ], + ["a query that never settled", false], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const observer = createFakeObserver(observation({ version })); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: countedRoutes(seen), + observer: observer.observer, + }); + + yield* launch(INSTRUCTIONS); + + const prepared = trace.records[0] as PreparedLaunchRecord; + expect([name, prepared.failure]).toEqual([name, undefined]); + expect([name, prepared.executableBinding]).toEqual([name, DIGEST_ONLY_BUILD]); + expect([name, seen.allocations]).toEqual([name, 1]); + expect([name, trace.launches]).toHaveLength(2); + // And what the build did say is nowhere the route could be read back. + const published = JSON.stringify(seen.published); + expect([name, published.includes("9.9.9")]).toEqual([name, false]); + expect([name, published.includes("the coding agent")]).toEqual([name, false]); + }); + } + }); + + it("NP5c: a digest-only route continues on its digest, and is never rewritten", function* () { + // What the earlier run could not read, this one can. That is a fact about + // the query, not about the executable: the digest is the same, so this is + // the same build and the session continues. The route keeps saying what the + // run that published it could stand behind — a version observed later is + // not a claim that route ever made. + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound(DIGEST_ONLY_BUILD)); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: createFakeObserver(observation({ version: `${LATER_VERSION}\n` })).observer, + }); + + // Both continuations: the native resume and the ACP attachment. + yield* launch(INSTRUCTIONS); + const session = yield* Agent.operations.session(); + + expect(trace.records.some((record) => record.failure)).toBe(false); + expect(seen.resumes).toBe(1); + expect(seen.creates).toBe(0); + expect(session.agentSessionId).toBe(ALLOCATED); + expect(harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + // Nothing republished the route, and it still names no release. + expect(seen.published.length).toBe(1); + expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); + expect(before).not.toContain(LATER_VERSION); + }); + + it("NP5d: a retained release must still be reproduced, and a digest decides regardless", function* () { + // The two halves of the asymmetry. A route that named a release is a claim + // the live build has to still make: one that reports another, or none this + // adapter can read, is not the build that history belongs to. And the + // digest is the one that always decides — a build whose shape is admitted + // and whose release matches is still refused when it is a different build. + for (const [name, retained, live] of [ + [ + "a live build reporting another release", + OBSERVED_BUILD, + observation({ version: `${LATER_VERSION}\n` }), + ], + ["a live build whose release cannot be read", OBSERVED_BUILD, observation({ version: "" })], + [ + "a different build, admitted and reporting the retained release", + OBSERVED_BUILD, + observation({ digest: "b".repeat(64) }), + ], + [ + "a different build, admitted, where the route named no release", + DIGEST_ONLY_BUILD, + observation({ digest: "b".repeat(64) }), + ], + ] as const) { + yield* scoped(function* () { + const harness = createFakeRuntime(); + const trace = newTrace(); + const seen = boundaries(); + const routes = countedRoutes(seen); + yield* routes.publish(bound(retained)); + const before = JSON.stringify(yield* routes.read(KEY)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(seen) }, + routeStore: routes, + observer: createFakeObserver(live).observer, + }); + + const failure = yield* attempt(trace, INSTRUCTIONS); + + expect([name, failure?.class]).toEqual([name, "executable-binding-refused"]); + expect([name, seen.resumes + seen.creates]).toEqual([name, 0]); + expect([name, harness.ensureCalls]).toEqual([name, []]); + expect([name, trace.launches]).toEqual([name, []]); + expect([name, seen.published.length]).toEqual([name, 1]); + expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); + // The refusal says which releases are involved and never the digest. + expect([name, failure?.message.includes("a".repeat(64))]).toEqual([name, false]); + expect([name, failure?.message.includes("b".repeat(64))]).toEqual([name, false]); + }); + } + }); + it("NP6: an incomplete replay is gated, and legacy history is unchanged", function* () { // An incomplete replay: the point is checked before the live phase it is // standing in front of, whichever suffix the journal retained. @@ -4449,7 +4847,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); trace.replay = { prepared: preparedRecord(), suffix }; @@ -4475,7 +4873,7 @@ describe("Tier NP — proved native capability points", () => { yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, - compatibility: false, + nativeCapabilityPolicy: false, }); const { executableBinding: _unbound, ...legacyPrepared } = preparedRecord(); trace.replay = { prepared: legacyPrepared, suffix: "prepared+detached" }; @@ -4502,7 +4900,7 @@ describe("Tier NP — proved native capability points", () => { adapters: { claude: countedAdapter(seen) }, routeStore: routes, observer: observer.observer, - compatibility: false, + nativeCapabilityPolicy: false, }); trace.completed = { prepared: preparedRecord(), @@ -4515,6 +4913,8 @@ describe("Tier NP — proved native capability points", () => { expect(trace.records.map((record) => record.phase)).toEqual(["prepared", "detached", "exited"]); expect(trace.records.some((record) => record.failure)).toBe(false); expect(observer.observed).toEqual([]); + // Not merely no build observation — no read-only query against one either. + expect(observer.queried).toEqual([]); expect(seen.allocations + seen.creates + seen.resumes).toBe(0); expect(trace.launches).toEqual([]); }); diff --git a/packages/acp/tests/session-route.test.ts b/packages/acp/tests/session-route.test.ts index 5b3a15c5d..beeb658ef 100644 --- a/packages/acp/tests/session-route.test.ts +++ b/packages/acp/tests/session-route.test.ts @@ -484,6 +484,22 @@ describe("Tier SV — the bound construction route", () => { } }); + it("SV3b: a digest-only binding round-trips, and stays digest-only", function* () { + // A build that reported no version it recognized was still observed + // exactly. The record says so by omitting the member, and reading it back + // must not invent one — a version written in later would be a claim about + // a session nobody made. + const digestOnly = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "c".repeat(64) }, + } as const; + const route = boundClientNative({ executableBinding: digestOnly }); + const text = serializeAgentSessionRoute(route); + expect(text).not.toContain("reportedVersion"); + const round = parseAgentSessionRoute(JSON.parse(text)); + expect(round).toEqual(route); + }); + it("SV4: serialization preserves the schema it was given", function* () { // Nothing here upgrades a route. A V1 record read and written again is // still V1, which is what keeps a build observed today from being written diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index e7b42d744..6d7fbb5e9 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -162,7 +162,9 @@ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDepende ...(sessions.coordinator ? { coordinator: sessions.coordinator } : {}), ...(sessions.routeStore ? { routeStore: sessions.routeStore } : {}), ...(sessions.executableObserver ? { executableObserver: sessions.executableObserver } : {}), - ...(sessions.compatibility ? { compatibility: sessions.compatibility } : {}), + ...(sessions.nativeCapabilityPolicy + ? { nativeCapabilityPolicy: sessions.nativeCapabilityPolicy } + : {}), advertiseNativeLaunch: sessions.advertiseNativeLaunch, advertiseClientNativeAttachment: sessions.advertiseClientNativeAttachment, }; diff --git a/packages/cli/src/session-coordinator.ts b/packages/cli/src/session-coordinator.ts index 806dd6f4a..634bfdd11 100644 --- a/packages/cli/src/session-coordinator.ts +++ b/packages/cli/src/session-coordinator.ts @@ -23,11 +23,11 @@ import { ADVERTISED_CLIENT_NATIVE_ATTACHMENT, ADVERTISED_NATIVE_LAUNCH, createDenoSessionRouteStore, - nativeCapabilityCompatibility, + nativeCapabilityPolicy, } from "@executablemd/acp"; import type { AgentSessionRouteStore, - NativeCapabilityCompatibility, + NativeCapabilityPolicy, NativeCapabilityHost, } from "@executablemd/acp"; @@ -52,14 +52,14 @@ export interface MachineSessionAssembly { advertiseNativeLaunch: readonly string[]; advertiseClientNativeAttachment: readonly string[]; /** - * Which exact builds this host admits each capability on, and the machine it - * admits them for. + * Which protocol shapes this host admits each capability on, and the machine + * it admits them for. * * Beside the observer rather than derived from the names above, because the * names are a coarse selection: an adapter reaches the question through them * and is answered here. Absent admits nothing. */ - compatibility?: NativeCapabilityCompatibility; + nativeCapabilityPolicy?: NativeCapabilityPolicy; } /** This host's session coordinator, or nothing when it cannot provide one. */ @@ -93,9 +93,9 @@ export function useExecutableObserver(): ExecutableObserver | undefined { * * `host` is passed in rather than read here, and read at the entrypoint rather * than anywhere below it. Which OS and architecture are underneath is exactly - * the fact a capability point is matched against, so a module that went and - * found it for itself would be supplying the answer as well as the question — - * and a case stating an exact point could never contradict it. + * the fact an admission is matched against, so a module that went and found it + * for itself would be supplying the answer as well as the question — and a case + * stating an exact machine could never contradict it. */ export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAssembly { return { @@ -106,7 +106,7 @@ export function useMachineSessions(host: NativeCapabilityHost): MachineSessionAs : { executableObserver: useExecutableObserver() }), advertiseNativeLaunch: ADVERTISED_NATIVE_LAUNCH, advertiseClientNativeAttachment: ADVERTISED_CLIENT_NATIVE_ATTACHMENT, - compatibility: nativeCapabilityCompatibility(host), + nativeCapabilityPolicy: nativeCapabilityPolicy(host), }; } diff --git a/packages/cli/tests/agent-session-coordinator.test.ts b/packages/cli/tests/agent-session-coordinator.test.ts index 19f1b8ca2..88bbcdd79 100644 --- a/packages/cli/tests/agent-session-coordinator.test.ts +++ b/packages/cli/tests/agent-session-coordinator.test.ts @@ -58,10 +58,12 @@ import { const SRC = join(dirname(fileURLToPath(import.meta.url)), "..", "src"); -/** The machine the shipped Claude points were proved on. */ +/** The machine the shipped Claude admissions were proved on. */ const PROVED_HOST: NativeCapabilityHost = { platform: "darwin", architecture: "arm64" }; -const CLAUDE_PROVED_VERSION = "2.1.241 (Claude Code)"; +/** What the built-in Claude adapter is, and what its observer answers as. */ +const CLAUDE_PROTOCOL = "claude-client-native.v1"; +const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; /** Whether this is the runtime that can take a kernel-released advisory lock. */ function onDeno(): boolean { @@ -203,6 +205,7 @@ function launchRequest(agent = "claude"): AgentLaunchRequest { */ const PROVIDER_RETURNED: NativeAdapter = { launcher: "claude", + protocol: "claude-provider-returned.v1", identity: "provider-returned", resume: (nativeSessionId) => ["claude", "--resume", nativeSessionId], }; @@ -210,8 +213,18 @@ const PROVIDER_RETURNED: NativeAdapter = { /** The Claude-shaped build contract a client-allocated adapter carries. */ const BINDING: NativeBinding = { command: "claude", - version: (output) => { - const line = output.trim(); + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + // Whatever the fake observer answered declares the shape this host proved, + // because the subject here is which host assembles a policy at all. + probe: () => ({ + probeProfile: CLAUDE_PROBE_PROFILE, + capabilities: ["native-launch", "client-native-attachment"], + }), + reportedVersion: (metadata) => { + const line = (metadata.version?.stdout ?? "").trim(); return /^\d+\.\d+\.\d+ \(Claude Code\)$/.test(line) ? line : undefined; }, environment: (livePath) => ({ CLAUDE_CODE_EXECUTABLE: livePath }), @@ -220,6 +233,7 @@ const BINDING: NativeBinding = { /** A Claude-shaped adapter that names its own sessions. */ const CLIENT_ALLOCATED: NativeAdapter = { launcher: "claude", + protocol: CLAUDE_PROTOCOL, identity: "client-allocated", binding: BINDING, allocate: () => randomUUID(), @@ -312,12 +326,12 @@ function* launchUnder( ...(coordinator ? { coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.observer ? { executableObserver: options.observer } : {}), - // The points this host states, for the machine they were proved on rather - // than the one running the suite: the subject is what a host assembles, - // and stating the pair as a value is how a host does it. - ...(useMachineSessions(PROVED_HOST).compatibility === undefined + // The admissions this host states, for the machine they were proved on + // rather than the one running the suite: the subject is what a host + // assembles, and stating the policy as a value is how a host does it. + ...(useMachineSessions(PROVED_HOST).nativeCapabilityPolicy === undefined ? {} - : { compatibility: useMachineSessions(PROVED_HOST).compatibility }), + : { nativeCapabilityPolicy: useMachineSessions(PROVED_HOST).nativeCapabilityPolicy }), }); yield* factory( { defaultAgent: agent, permissionMode: "deny-all" }, @@ -590,54 +604,67 @@ describe("Tier HC — host session ownership", () => { }); it("HC13: the machine an assembly admits for is the one it was handed", function* () { - // A name is a selection; a point is the proof. The assembly states the - // proved points beside the observer, and the machine they are proved for - // arrives as a value, so the same shared code admits nothing on a host it - // was never proved against. - const proved = useMachineSessions(PROVED_HOST).compatibility; + // A name is a selection; an admission is the proof. The assembly states the + // proved admissions beside the observer, and the machine they are proved + // for arrives as a value, so the same shared code admits nothing on a host + // it was never proved against. + const proved = useMachineSessions(PROVED_HOST).nativeCapabilityPolicy; expect(proved?.host).toEqual(PROVED_HOST); - expect(proved?.points).toEqual([ + expect(proved?.admissions).toEqual([ { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "client-native-attachment", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, ...PROVED_HOST, }, ]); + // No Claude Code version appears anywhere in what this host proved: which + // build ran is a fact about a route, not a term of admission. + expect(JSON.stringify(proved)).not.toContain("Claude Code"); const claudeLaunch = { - agent: "claude", + adapterProtocol: CLAUDE_PROTOCOL, capability: "native-launch", - reportedVersion: CLAUDE_PROVED_VERSION, + probeProfile: CLAUDE_PROBE_PROFILE, } as const; expect(admitsNativeCapability(proved, claudeLaunch)).toBe(true); + // The Agent name is not what is matched. A different adapter answering for + // the same registry name is a different protocol, and nothing proved it. + expect( + admitsNativeCapability(proved, { ...claudeLaunch, adapterProtocol: "claude-fork.v1" }), + ).toBe(false); + // Nor is the declaration: a build that answered a profile this host never + // proved is a shape nobody has evidence for. + expect( + admitsNativeCapability(proved, { ...claudeLaunch, probeProfile: "claude-help-native.v2" }), + ).toBe(false); for (const elsewhere of [ { platform: "linux", architecture: "arm64" }, { platform: "darwin", architecture: "x64" }, ]) { expect( - admitsNativeCapability(useMachineSessions(elsewhere).compatibility, claudeLaunch), + admitsNativeCapability(useMachineSessions(elsewhere).nativeCapabilityPolicy, claudeLaunch), ).toBe(false); } // Node and Bun keep the coarse names and assemble no authority, so the // advertised name reaches a question this profile answers with a refusal. const unassembled = unassembledMachineSessions(); expect([...unassembled.advertiseNativeLaunch]).toEqual(["claude"]); - expect(unassembled.compatibility).toBe(undefined); - expect(admitsNativeCapability(unassembled.compatibility, claudeLaunch)).toBe(false); + expect(unassembled.nativeCapabilityPolicy).toBe(undefined); + expect(admitsNativeCapability(unassembled.nativeCapabilityPolicy, claudeLaunch)).toBe(false); // Shared assembly is handed the machine rather than reading it: only the // entrypoints checked in HC5 name the running runtime. expect((yield* entrypoint("session-coordinator.ts")).includes("process.platform")).toBe(false); - // The workflow profile advertises neither capability and states no points, + // The workflow profile advertises neither capability and states no policy, // so a workflow Claude prompt never reaches this question at all. const workflow = yield* entrypoint("workflow-agent.ts"); expect(workflow.includes("advertiseNativeLaunch: [],")).toBe(true); expect(workflow.includes("advertiseClientNativeAttachment: [],")).toBe(true); - expect(workflow.includes("compatibility:")).toBe(false); + expect(workflow.includes("nativeCapabilityPolicy")).toBe(false); }); }); diff --git a/packages/core/src/agent/launch-journal.ts b/packages/core/src/agent/launch-journal.ts index d268b2b6e..6a38ee181 100644 --- a/packages/core/src/agent/launch-journal.ts +++ b/packages/core/src/agent/launch-journal.ts @@ -132,7 +132,17 @@ function provenance(value: unknown): IdentityProvenance | undefined { return value === "provider-returned" || value === "client-allocated" ? value : undefined; } -const BINDING_MEMBERS = ["schema", "reportedVersion", "executableDigest"]; +const BINDING_MEMBERS = ["schema", "executableDigest"]; +/** + * The one member a binding may omit. + * + * Separate from the required set rather than merged into it, because a binding + * is compared for equality: an unexpected member is still a fact the writer + * thought was part of the build's identity, and reading past it would call two + * different builds the same one. Omitting this one is not an unexpected member + * — it is the record saying the build reported no version it recognized. + */ +const BINDING_OPTIONAL_MEMBERS = ["reportedVersion"]; const DIGEST_MEMBERS = ["algorithm", "value"]; const LOWERCASE_SHA256 = /^[0-9a-f]{64}$/; @@ -141,24 +151,38 @@ function exactMembers(value: Record, members: readonly string[] return keys.length === members.length && members.every((member) => keys.includes(member)); } +function declaredMembers( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Object.keys(value); + return ( + required.every((member) => keys.includes(member)) && + keys.every((key) => required.includes(key) || optional.includes(key)) + ); +} + /** * Read a retained build binding strictly. * - * The member set is exact rather than minimal, because a binding is compared - * for equality: a member this build ignores is a fact the writer thought was - * part of the build's identity, and comparing without it would call two - * different builds the same one. + * The digest is required and exact — it is what binds a session to a build. + * A present `reportedVersion` must still be a real one: a member written as an + * empty string is a claim about a release nobody can reproduce, which is not + * the same as having made no claim. */ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { - if (!isRecord(value) || !exactMembers(value, BINDING_MEMBERS)) { + if (!isRecord(value) || !declaredMembers(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { return undefined; } const { schema, reportedVersion, executableDigest } = value; if (schema !== "executable-build.v1") { return undefined; } - if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { - return undefined; + if (reportedVersion !== undefined) { + if (typeof reportedVersion !== "string" || reportedVersion.length === 0) { + return undefined; + } } if (!isRecord(executableDigest) || !exactMembers(executableDigest, DIGEST_MEMBERS)) { return undefined; @@ -172,7 +196,7 @@ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined } return { schema: "executable-build.v1", - reportedVersion, + ...(reportedVersion === undefined ? {} : { reportedVersion }), executableDigest: { algorithm: "sha256", value: digestValue }, }; } @@ -180,7 +204,7 @@ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined function serializeBinding(binding: ExecutableBuildBindingV1): Json { return { schema: binding.schema, - reportedVersion: binding.reportedVersion, + ...(binding.reportedVersion === undefined ? {} : { reportedVersion: binding.reportedVersion }), executableDigest: { algorithm: binding.executableDigest.algorithm, value: binding.executableDigest.value, diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index 0ae7d79e3..d8a3b57ec 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -107,12 +107,17 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; * cannot be reproduced is refused rather than resumed. * * What is retained is deliberately not a path: a path says where a build was, - * which stops being true, while a version and a digest say which build it was, - * which does not. That also keeps the record free of host layout. + * which stops being true, while a digest says which build it was, which does + * not. That also keeps the record free of host layout. + * + * The digest is what binds. `reportedVersion` is optional evidence beside it: + * an executable that will not say which release it is, says something this + * provider does not recognize, or says several things is bound by its bytes + * alone rather than refused for being quiet. */ export interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; - readonly reportedVersion: string; + readonly reportedVersion?: string; readonly executableDigest: { readonly algorithm: "sha256"; readonly value: string; @@ -120,20 +125,27 @@ export interface ExecutableBuildBindingV1 { } /** - * Whether two bindings name the same build. + * Whether `live` is the build `retained` names. + * + * Asymmetric, because the two arguments are not the same kind of claim. The + * digest decides: identical bytes are the same build reached through whatever + * path, and different bytes are a different build however it describes itself. * - * Equality is over what was retained, so the same build reached through a - * different path is compatible and a different build at the same path is not. + * A retained version is then a claim the live build must still make. A record + * that named a release and now meets one calling itself something else — or + * nothing at all — has lost the evidence it was written with. A record that + * named none never had that evidence, so a version appearing later adds + * nothing to reproduce and does not rewrite what was retained. */ export function sameExecutableBuild( - left: ExecutableBuildBindingV1, - right: ExecutableBuildBindingV1, + retained: ExecutableBuildBindingV1, + live: ExecutableBuildBindingV1, ): boolean { return ( - left.schema === right.schema && - left.reportedVersion === right.reportedVersion && - left.executableDigest.algorithm === right.executableDigest.algorithm && - left.executableDigest.value === right.executableDigest.value + retained.schema === live.schema && + retained.executableDigest.algorithm === live.executableDigest.algorithm && + retained.executableDigest.value === live.executableDigest.value && + (retained.reportedVersion === undefined || retained.reportedVersion === live.reportedVersion) ); } diff --git a/packages/core/tests/agent-session-launch.test.ts b/packages/core/tests/agent-session-launch.test.ts index 8ce019cd5..3407cbb2b 100644 --- a/packages/core/tests/agent-session-launch.test.ts +++ b/packages/core/tests/agent-session-launch.test.ts @@ -1433,6 +1433,19 @@ describe("Tier EB — executable build binding", () => { expect(parsed?.executableBinding).toBe(undefined); }); + it("EB2b: a binding that reported no version is read as the digest-only one it is", function* () { + // What binds a session to a build is the bytes. An executable that would + // not say which release it is was still observed exactly, and a record + // that omitted the version made no claim rather than a bad one. + const digestOnly = { + schema: "executable-build.v1", + executableDigest: { algorithm: "sha256", value: "d".repeat(64) }, + }; + const parsed = parsePrepared(bound({ executableBinding: digestOnly })); + expect(parsed?.executableBinding).toEqual(digestOnly); + expect(parsed?.executableBinding?.reportedVersion).toBe(undefined); + }); + it("EB3: an inexact binding refuses rather than being read past", function* () { const cases: [string, Json][] = [ ["unknown schema", { ...BOUND, schema: "executable-build.v2" }], @@ -1469,7 +1482,7 @@ describe("Tier EB — executable build binding", () => { } }); - it("EB4: equality is over the retained build, and a path is not part of it", function* () { + it("EB4: the digest decides, and a path is not part of it", function* () { // There is no path to ignore, which is the point: the same build reached // through a different path compares equal because nothing about where it // was ever entered the record. @@ -1490,6 +1503,34 @@ describe("Tier EB — executable build binding", () => { ).toBe(false); }); + it("EB4b: a retained version is a claim the live build must still make", function* () { + // Asymmetric, because the two sides are not the same kind of claim. A + // record written with a version has evidence a quiet build cannot + // reproduce; a record written without one never had that evidence, so a + // version appearing later adds nothing to reproduce. + const digest = { algorithm: "sha256", value: "d".repeat(64) } as const; + const versioned: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: "2.1.241 (Claude Code)", + executableDigest: digest, + }; + const quiet: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: digest, + }; + + expect(sameExecutableBuild(versioned, quiet)).toBe(false); + expect(sameExecutableBuild(quiet, versioned)).toBe(true); + expect(sameExecutableBuild(quiet, quiet)).toBe(true); + // And a changed digest refuses either way, whatever either says it is. + const moved: ExecutableBuildBindingV1 = { + ...quiet, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }; + expect(sameExecutableBuild(quiet, moved)).toBe(false); + expect(sameExecutableBuild(moved, quiet)).toBe(false); + }); + it("EB5: completed replay stays provider-cold for a bound preparation", function* () { yield* replayIsCold({ identityProvenance: "client-allocated", executableBinding: BINDING }); }); diff --git a/packages/runtime/deno-executable-observer.ts b/packages/runtime/deno-executable-observer.ts index 37b6c55bc..fe2962950 100644 --- a/packages/runtime/deno-executable-observer.ts +++ b/packages/runtime/deno-executable-observer.ts @@ -19,7 +19,11 @@ import { createHash } from "node:crypto"; import { readFile, realpath, stat } from "node:fs/promises"; import { delimiter, isAbsolute, join, resolve } from "node:path"; import { ExecutableObservationError } from "./executable-observer.ts"; -import type { ExecutableObserver, ObservedExecutable } from "./executable-observer.ts"; +import type { + ExecutableMetadataObservation, + ExecutableObserver, + ObservedExecutable, +} from "./executable-observer.ts"; type HostCall = (...args: unknown[]) => unknown; @@ -73,17 +77,25 @@ export function hasDenoExecutableObserver(): boolean { return observerHost() !== undefined; } -/** What the version invocation produced, decoded. */ -function decode(value: unknown): { code: number; text: string } { +/** What one metadata query produced, decoded. */ +function decode(value: unknown): ExecutableMetadataObservation { + const decoder = new TextDecoder(); + const text = (channel: unknown) => (channel instanceof Uint8Array ? decoder.decode(channel) : ""); if (typeof value !== "object" || value === null) { - return { code: -1, text: "" }; + return { settled: false, stdout: "", stderr: "" }; } const code = Reflect.get(value, "code"); - const stdout = Reflect.get(value, "stdout"); - const decoder = new TextDecoder(); + if (typeof code !== "number") { + // A child that produced no status did not answer, whatever it wrote on the + // way. Reporting output beside an unknown status would invite reading it as + // an answer. + return { settled: false, stdout: "", stderr: "" }; + } return { - code: typeof code === "number" ? code : -1, - text: stdout instanceof Uint8Array ? decoder.decode(stdout) : "", + settled: true, + code, + stdout: text(Reflect.get(value, "stdout")), + stderr: text(Reflect.get(value, "stderr")), }; } @@ -174,29 +186,43 @@ export function createDenoExecutableObserver(overrides?: { }), ); - const versionArgs = options?.versionArgs ?? ["--version"]; - const produced = yield* until( - host - .command(path, { args: [...versionArgs], stdout: "piped", stderr: "null" }) - .output() - .catch((cause: unknown) => { - throw new ExecutableObservationError(`${command} could not be asked its version`, { - refusal: "version-unavailable", - cause, - }); - }), - ); - const version = decode(produced); - if (version.code !== 0) { - throw new ExecutableObservationError(`${command} refused to report a version`, { - refusal: "version-unavailable", - }); + // Asked with nothing: no inherited environment, no stdin, and both + // channels captured rather than attached. A metadata query is meant to + // report and exit, so it is given nothing to read, nothing to inherit and + // no terminal to draw on — and what it writes is returned to the caller + // rather than appearing on the reader's. + const metadata: Record = {}; + for (const query of options?.metadata ?? []) { + // `output()` raises rather than rejecting when the child cannot be + // spawned at all, so a failure to start is caught here as well as + // there. Either way it is an observation that did not answer, not a + // failed observation: the file was found, and asking it a question is + // not what decides whether it is the build. + let answer: unknown; + try { + answer = yield* until( + host + .command(path, { + args: [...query.args], + clearEnv: true, + env: {}, + stdin: "null", + stdout: "piped", + stderr: "piped", + }) + .output() + .catch(() => undefined), + ); + } catch { + answer = undefined; + } + metadata[query.name] = decode(answer); } return { path, digest: { algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex") }, - versionOutput: version.text, + metadata, }; }, }; diff --git a/packages/runtime/executable-observer.ts b/packages/runtime/executable-observer.ts index a7cd9d454..7adbda3a3 100644 --- a/packages/runtime/executable-observer.ts +++ b/packages/runtime/executable-observer.ts @@ -1,6 +1,7 @@ /** - * Observing which executable build a command actually runs - * (specs/native-agent-session-launch-spec.md §Executable binding). + * Observing which executable build a command actually runs, and what that exact + * file declares about itself (specs/native-agent-session-launch-spec.md + * §Executable binding). * * A provider session whose identity XMD chose itself only means something * while the build that established it can be reproduced. Two builds of one @@ -11,9 +12,10 @@ * recognize it later. * * What is observed is a canonical path, the SHA-256 of that file's bytes, and - * whatever the file says when asked its version. Only the last two ever become - * durable: a path stops being true when a build moves, and names host layout - * besides. + * the answers that same file gives to the read-only questions its adapter + * declared. Only the digest is durable by itself: a path stops being true when + * a build moves and names host layout besides, and the answers are the + * adapter's to read. * * This is a plain capability the trusted host builds and hands directly to the * provider that needs it. It is deliberately not a contextual Api. Executable @@ -21,20 +23,16 @@ * document middleware could replace is not one — a replaceable resolver could * point the observation at a different binary than the one that runs. * - * Provider-specific meaning is not this module's business: which command to - * run, what a version string looks like, and what to do about a mismatch - * belong to the adapter that knows the provider. + * Provider-specific meaning is not this module's business. Which questions to + * ask is the adapter's, what the answers mean is the adapter's, and what to do + * about a mismatch is the caller's. Here a question is argv and an answer is a + * settled status with the bytes the child wrote. */ import type { Operation } from "effection"; /** Why an executable could not be observed, in terms a caller can act on. */ -export type ExecutableRefusal = - | "not-found" - | "not-a-file" - | "not-executable" - | "unreadable" - | "version-unavailable"; +export type ExecutableRefusal = "not-found" | "not-a-file" | "not-executable" | "unreadable"; /** * An observation failure that names its reason. @@ -53,31 +51,67 @@ export class ExecutableObservationError extends Error { } } +/** + * One read-only question to ask the file that was just hashed. + * + * `name` is the caller's own label, and is what the answer comes back under, so + * an adapter reads its questions by meaning rather than by position. `args` is + * argv after the executable itself and is the adapter's whole dialect: this + * module never adds to it, and a query that did anything but report is a query + * this contract has no way to take back. + */ +export interface ExecutableMetadataQuery { + readonly name: string; + readonly args: readonly string[]; +} + +/** + * How one query settled. + * + * `settled` is false when the child never ran to completion, which is a + * different fact from running and failing: an adapter may accept a missing + * answer while refusing a wrong one. `code` exists only alongside a settled + * child, and both channels are captured rather than inherited so nothing the + * queried file writes reaches the caller's terminal. + */ +export interface ExecutableMetadataObservation { + readonly settled: boolean; + readonly code?: number; + readonly stdout: string; + readonly stderr: string; +} + +/** Every declared question's answer, under the name it was asked by. */ +export type ExecutableMetadata = Readonly>; + /** * One executable, as it exists during this invocation. * - * `path` is canonical and live: it is what a caller spawns and what it asked - * for a version, and it is absent from everything durable. `versionOutput` is - * raw — the adapter that knows the provider parses it, and neither this string - * nor the path may reach a record, a diagnostic, or the environment of - * anything but the matching child. + * `path` is canonical and live: it is what a caller spawns and what every query + * was asked, and it is absent from everything durable. `metadata` is raw — the + * adapter that knows the provider reads it, and neither those bytes nor the + * path may reach a record, a diagnostic, or the environment of anything but the + * matching child. */ export interface ObservedExecutable { path: string; digest: { algorithm: "sha256"; value: string }; - versionOutput: string; + metadata: ExecutableMetadata; } export interface ExecutableObserver { /** * Resolve `command`, canonicalize it, require an executable regular file, - * hash its bytes, and ask that exact path for its version. + * hash its bytes once, and ask that exact path each declared query. * - * Asking the same path that was hashed is the point: a version read from a + * Asking the same path that was hashed is the point: an answer read from a * differently-resolved file describes a build this observation did not make. + * A query that cannot start or cannot settle is reported as such rather than + * failing the observation — whether a missing answer is fatal is a question + * about the provider, and this module knows none. */ observe( command: string, - options?: { versionArgs?: readonly string[] }, + options?: { metadata?: readonly ExecutableMetadataQuery[] }, ): Operation; } diff --git a/packages/runtime/mod.ts b/packages/runtime/mod.ts index fe4e38f68..cc3322c95 100644 --- a/packages/runtime/mod.ts +++ b/packages/runtime/mod.ts @@ -150,6 +150,9 @@ export { } from "./deno-agent-session-coordinator.ts"; export { ExecutableObservationError } from "./executable-observer.ts"; export type { + ExecutableMetadata, + ExecutableMetadataObservation, + ExecutableMetadataQuery, ExecutableObserver, ExecutableRefusal, ObservedExecutable, diff --git a/packages/runtime/tests/executable-observer.test.ts b/packages/runtime/tests/executable-observer.test.ts index b48d85354..031c432e9 100644 --- a/packages/runtime/tests/executable-observer.test.ts +++ b/packages/runtime/tests/executable-observer.test.ts @@ -4,12 +4,15 @@ * * Real files and a real child, because every question here is about the host: * which file a name resolves to, whether a symlink and its target are one - * build, what the bytes hash to, and what that exact path says when asked. + * build, what the bytes hash to, and what that exact path answers when asked + * the read-only questions a caller declared. * - * The redaction cases matter as much as the observation ones. The canonical - * path is live capability — it is spawned, and it reaches the matching ACP - * child — and it must not be recoverable from anything this module hands to a - * caller that will retain it. + * What this module must not do matters as much as what it does. It knows no + * provider: it runs the argv it was handed and reports the exit status and the + * captured channels, and every reading of those belongs to whoever asked. The + * canonical path is live capability — it is spawned, and it reaches the + * matching ACP child — and it must not be recoverable from anything this + * module hands to a caller that will retain it. */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; @@ -20,6 +23,7 @@ import { createHash, randomUUID } from "node:crypto"; import { chmod, realpath, symlink } from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; +import process from "node:process"; import { until } from "effection"; import { createDenoExecutableObserver, @@ -56,6 +60,16 @@ function* script(root: string, name: string, version: string): Operation return file; } +/** A real executable that answers different argv differently. */ +function* answering(root: string, name: string, body: string): Operation { + const file = path.join(root, name); + yield* writeTextFile(file, `#!/bin/sh\n${body}\n`); + yield* until(chmod(file, 0o755)); + return file; +} + +const VERSION_QUERY = [{ name: "version", args: ["--version"] }]; + function* refusalOf(op: () => Operation): Operation { try { yield* op(); @@ -72,19 +86,24 @@ describe("Tier EO — executable observation", () => { expect(createDenoExecutableObserver() === undefined).toBe(!onDeno); }); - it("EO2: the digest is over the file's bytes, and the version comes from that same file", function* () { + it("EO2: the digest is over the file's bytes, and a query answers from that same file", function* () { const root = yield* workspace(); const file = yield* script(root, "claude", "2.1.235 (Claude Code)"); const observer = createDenoExecutableObserver()!; - const observed = yield* observer.observe(file); + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); const bytes = `#!/bin/sh\necho "2.1.235 (Claude Code)"\n`; expect(observed.digest).toEqual({ algorithm: "sha256", value: createHash("sha256").update(bytes).digest("hex"), }); - expect(observed.versionOutput.trim()).toBe("2.1.235 (Claude Code)"); + expect(observed.metadata.version).toEqual({ + settled: true, + code: 0, + stdout: "2.1.235 (Claude Code)\n", + stderr: "", + }); expect(observed.path).toBe(yield* canonical(file)); }); @@ -111,14 +130,14 @@ describe("Tier EO — executable observation", () => { const root = yield* workspace(); const file = yield* script(root, "claude", "2.1.235"); const observer = createDenoExecutableObserver()!; - const before = yield* observer.observe(file); + const before = yield* observer.observe(file, { metadata: VERSION_QUERY }); yield* script(root, "claude", "2.2.0"); - const after = yield* observer.observe(file); + const after = yield* observer.observe(file, { metadata: VERSION_QUERY }); expect(after.path).toBe(before.path); expect(after.digest.value).not.toBe(before.digest.value); - expect(after.versionOutput.trim()).toBe("2.2.0"); + expect(after.metadata.version.stdout.trim()).toBe("2.2.0"); }); it("EO5: PATH search finds a bare name, from the observer's own environment", function* () { @@ -143,22 +162,102 @@ describe("Tier EO — executable observation", () => { yield* ensureDir(directory); const unreadable = path.join(root, "plain"); yield* writeTextFile(unreadable, "not a program\n"); - const broken = path.join(root, "broken"); - yield* writeTextFile(broken, "#!/bin/sh\nexit 3\n"); - yield* until(chmod(broken, 0o755)); expect(yield* refusalOf(() => observer.observe(""))).toBe("not-found"); expect(yield* refusalOf(() => observer.observe("absent-command"))).toBe("not-found"); expect(yield* refusalOf(() => observer.observe(directory))).toBe("not-a-file"); expect(yield* refusalOf(() => observer.observe(unreadable))).toBe("not-executable"); - // Present, executable, and unwilling to say what it is: a build that - // cannot be identified is not one a session may be bound to. - expect(yield* refusalOf(() => observer.observe(broken))).toBe("version-unavailable"); }); - it("EO7: a refusal carries no path, and an observation retains none", function* () { + it("EO7: what a query answered is reported, never judged", function* () { + // A build that will not say what it is has still been observed. Whether + // that is fatal is the caller's question about its own capability, and a + // module that refused here would be answering it for every caller. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const quiet = yield* answering(root, "quiet", `echo "trouble" >&2\nexit 3`); + + const observed = yield* observer.observe(quiet, { metadata: VERSION_QUERY }); + + expect(observed.metadata.version).toEqual({ + settled: true, + code: 3, + stdout: "", + stderr: "trouble\n", + }); + expect(observed.digest.algorithm).toBe("sha256"); + }); + + it("EO8: a child that never started answered nothing at all", function* () { + // An executable regular file whose interpreter does not exist. Reporting + // empty output beside an unknown status would let a build that could not + // run look like one that answered nothing. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const broken = yield* answering(root, "broken", ""); + yield* writeTextFile(broken, `#!/xmd-no-such-interpreter\n`); + yield* until(chmod(broken, 0o755)); + + const observed = yield* observer.observe(broken, { metadata: VERSION_QUERY }); + + expect(observed.metadata.version).toEqual({ settled: false, stdout: "", stderr: "" }); + }); + + it("EO9: each declared query runs at the observed path, and only those", function* () { + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + const file = yield* answering(root, "claude", `echo "asked: $*"`); + + const observed = yield* observer.observe(file, { + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + }); + + expect(observed.metadata.help.stdout.trim()).toBe("asked: --help"); + expect(observed.metadata.version.stdout.trim()).toBe("asked: --version"); + expect(Object.keys(observed.metadata).sort()).toEqual(["help", "version"]); + // A caller that declared none asked none. + const silent = yield* observer.observe(file); + expect(silent.metadata).toEqual({}); + }); + + it("EO10: a query is asked with nothing — no environment, no stdin, no terminal", function* () { + // A read-only question given a working environment could read a credential + // out of it, and one attached to a terminal could draw on the reader's. + const root = yield* workspace(); + const observer = createDenoExecutableObserver({ path: root })!; + // `PATH` and `HOME` are set in every process that runs this suite, so an + // inherited environment would be visible rather than merely possible. The + // shell sets a few of its own on the way in, which is the shell's doing and + // not this observer's, so what is asserted is that nothing crossed. + const file = yield* answering( + root, + "claude", + `echo "path: [$PATH]"\n` + + `echo "home: [$HOME]"\n` + + `echo "stdin: [$(cat)]"\n` + + `if [ -t 1 ]; then echo "tty: yes"; else echo "tty: no"; fi`, + ); + + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); + + const answered = observed.metadata.version.stdout.split("\n"); + expect(answered).toContain("home: []"); + expect(answered).toContain("stdin: []"); + expect(answered).toContain("tty: no"); + // `PATH` is the one `/bin/sh` supplies a default for when it inherits + // none, so its emptiness would prove nothing. What proves the environment + // was cleared is that the value this process actually has did not cross. + const inherited = process.env.PATH ?? ""; + expect(inherited.length).toBeGreaterThan(0); + expect(answered).not.toContain(`path: [${inherited}]`); + }); + + it("EO11: a refusal carries no path, and an observation retains none", function* () { // The canonical path is live capability. What a caller keeps is the digest - // and the version; the path is spawned and forgotten. + // and whatever it made of the answers; the path is spawned and forgotten. const root = yield* workspace(); const secret = path.join(root, "secret-layout"); yield* ensureDir(secret); @@ -174,15 +273,15 @@ describe("Tier EO — executable observation", () => { expect(message).not.toContain(secret); expect(message).not.toContain(root); - // And the durable half of an observation is exactly two facts. + // And an observation is exactly three facts. const file = yield* script(secret, "claude", "2.1.235"); - const observed = yield* observer.observe(file); + const observed = yield* observer.observe(file, { metadata: VERSION_QUERY }); const retained = { schema: "executable-build.v1", - reportedVersion: observed.versionOutput.trim(), + reportedVersion: observed.metadata.version.stdout.trim(), executableDigest: observed.digest, }; expect(JSON.stringify(retained)).not.toContain(root); - expect(Object.keys(observed).sort()).toEqual(["digest", "path", "versionOutput"]); + expect(Object.keys(observed).sort()).toEqual(["digest", "metadata", "path"]); }); }); diff --git a/packages/test-agent/src/executable-observer.ts b/packages/test-agent/src/executable-observer.ts index 47e7df0e9..c38ee54ee 100644 --- a/packages/test-agent/src/executable-observer.ts +++ b/packages/test-agent/src/executable-observer.ts @@ -5,25 +5,35 @@ * A session whose identity XMD chose is only meaningful while the build that * accepted it can be recognized later, and the provider will not act on one * without an observer to ask. There is no real executable here, so this answers - * the same question deterministically: one stable digest and one canonical - * version for the life of the partition. + * the same question deterministically: one stable digest, one declared help + * surface and one canonical version for the life of the partition. + * + * The whole observation is injected rather than any one answer being patched, + * because that is what keeps the production path unweakened: a scenario that + * wants a capability to go missing removes a declaration from what this + * answers, and the same probe reads it. * * Its lifetime is that partition, which is what makes two sibling `` - * elements two worlds rather than two views of one. A harness that wants to - * watch a build drift replaces what this whole seam answers, rather than - * reaching for a control the production path also has. + * elements two worlds rather than two views of one. */ import { createHash } from "node:crypto"; -import { TEST_AGENT_BUILD_VERSION } from "./provider.ts"; -import type { ExecutableObserver } from "@executablemd/runtime"; +import { TEST_AGENT_BUILD_VERSION, TEST_AGENT_HELP } from "./provider.ts"; +import type { ExecutableMetadata, ExecutableObserver } from "@executablemd/runtime"; + +/** One query's answer, in the shape a real observation produces. */ +function reported(stdout: string): ExecutableMetadata[string] { + return { settled: true, code: 0, stdout, stderr: "" }; +} export interface ControlledExecutableObserver { observer: ExecutableObserver; /** What the next observation answers. Change it to drift the build. */ - observed: { path: string; digest: string; versionOutput: string }; + observed: { path: string; digest: string; metadata: Record }; /** Every command this partition was asked about, in order. */ asked: string[]; + /** Every metadata query it was asked to run, as `name argv…`, in order. */ + queried: string[]; } export function createControlledExecutableObserver( @@ -31,21 +41,28 @@ export function createControlledExecutableObserver( ): ControlledExecutableObserver { const controlled: ControlledExecutableObserver = { asked: [], + queried: [], observed: { // Never a real path: nothing is spawned, and the value exists only so // that a test can prove it does not reach a record. path: `/xmd-test-agent/${seed}/ui`, digest: createHash("sha256").update(seed).digest("hex"), - versionOutput: `${TEST_AGENT_BUILD_VERSION}\n`, + metadata: { + help: reported(TEST_AGENT_HELP), + version: reported(`${TEST_AGENT_BUILD_VERSION}\n`), + }, }, observer: { // deno-lint-ignore require-yield - *observe(command) { + *observe(command, options) { controlled.asked.push(command); + for (const query of options?.metadata ?? []) { + controlled.queried.push([query.name, ...query.args].join(" ")); + } return { path: controlled.observed.path, digest: { algorithm: "sha256", value: controlled.observed.digest }, - versionOutput: controlled.observed.versionOutput, + metadata: controlled.observed.metadata, }; }, }, diff --git a/packages/test-agent/src/provider.ts b/packages/test-agent/src/provider.ts index 5500c6ed1..cd13e1395 100644 --- a/packages/test-agent/src/provider.ts +++ b/packages/test-agent/src/provider.ts @@ -21,10 +21,16 @@ import type { import type { AgentSessionRouteStore, NativeAdapter, - NativeCapabilityCompatibility, + NativeCapability, + NativeCapabilityPolicy, + ProbedNativeCapabilities, } from "@executablemd/acp"; import { useRouteSlot } from "./route-slot.ts"; -import type { AgentSessionCoordinator, ExecutableObserver } from "@executablemd/runtime"; +import type { + AgentSessionCoordinator, + ExecutableMetadata, + ExecutableObserver, +} from "@executablemd/runtime"; import type { AcpAgentRegistry, AcpSessionRecord, AcpSessionStore } from "acpx/runtime"; /** @@ -101,6 +107,7 @@ export const TEST_AGENT_LAUNCHER = "test-agent"; export const TEST_AGENT_NATIVE_ADAPTER: NativeAdapter = { launcher: TEST_AGENT_LAUNCHER, + protocol: "test-agent-provider-returned.v1", // The worker asserts its own session identity, which is the provider-returned // contract. It stays that, so #518's path keeps being exercised. identity: "provider-returned", @@ -121,17 +128,84 @@ export const TEST_AGENT_CLIENT_NATIVE = "test-agent-client-native"; /** The version this partition's controlled build reports. Shaped like a real one. */ export const TEST_AGENT_BUILD_VERSION = "9.9.9 (Test Agent)"; +/** + * This partition's own protocol and probe. + * + * Its own rather than Claude's, so nothing a `` scenario admits can + * reach a real adapter, and nothing a real adapter proved can admit this one. + */ +export const TEST_AGENT_PROTOCOL = "test-agent-client-native.v1"; +export const TEST_AGENT_PROBE_PROFILE = "test-agent-native-session.v1"; + +/** + * The help surface this partition's controlled build declares. + * + * Written out rather than assumed, because the probe below reads it the way a + * real probe reads a real CLI: a scenario that wants to watch a capability go + * missing removes a declaration from what the observer answers with. + */ +export const TEST_AGENT_HELP = [ + "Usage: xmd-test-agent-ui [options]", + "", + "Options:", + " --session-id create the session under this identity", + " --resume resume exactly this session", + " --system-prompt-file read the private instruction layer from a file", + "", +].join("\n"); + +/** The output one query produced, or nothing when it did not answer. */ +function answered(metadata: ExecutableMetadata, name: string): string | undefined { + const observation = metadata[name]; + if (observation === undefined || !observation.settled || observation.code !== 0) { + return undefined; + } + return observation.stdout; +} + +/** + * What this partition's controlled build declares about a native launch. + * + * Structural for the same reason a real probe is: what is read is whether the + * three operations a launch needs are declared, not which release declared + * them. Both capabilities come from the same declarations here because this + * build has no separate ACP bridge to prove. + */ +function testAgentProbe(metadata: ExecutableMetadata): ProbedNativeCapabilities { + const capabilities: NativeCapability[] = []; + const help = answered(metadata, "help"); + const declares = (flag: string) => new RegExp(`^ {2}${flag} <`, "m").test(help ?? ""); + if ( + help !== undefined && + help.includes("Usage: xmd-test-agent-ui") && + declares("--session-id") && + declares("--resume") && + declares("--system-prompt-file") + ) { + capabilities.push("native-launch", "client-native-attachment"); + } + return { probeProfile: TEST_AGENT_PROBE_PROFILE, capabilities }; +} + /** A controlled adapter that names its own sessions, in the test agent's dialect. */ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { launcher: TEST_AGENT_LAUNCHER, + protocol: TEST_AGENT_PROTOCOL, identity: "client-allocated", binding: { command: "xmd-test-agent-ui", - version: (output) => - output + metadata: [ + { name: "help", args: ["--help"] }, + { name: "version", args: ["--version"] }, + ], + probe: testAgentProbe, + reportedVersion: (metadata) => { + const canonical = (answered(metadata, "version") ?? "") .split("\n") .map((line) => line.trim()) - .find((line) => /^\d+\.\d+\.\d+ \(Test Agent\)$/.test(line)), + .filter((line) => /^\d+\.\d+\.\d+ \(Test Agent\)$/.test(line)); + return canonical.length === 1 ? canonical[0] : undefined; + }, // No pinned adapter command: this partition's registry answers with the // scenario route, and pinning one would name a process that does not exist. environment: (livePath) => ({ XMD_TEST_AGENT_EXECUTABLE: livePath }), @@ -148,24 +222,24 @@ export const TEST_AGENT_CLIENT_NATIVE_ADAPTER: NativeAdapter = { }; /** - * The points this partition admits, for the machine it is running on. + * What this partition admits, for the machine it is running on. * * This partition is its own trusted host — it supplies its own coordinator, - * route store and observer — so it also says which builds it has proved. What - * a real host proves by driving an installed CLI, this one proves by being the - * build: the controlled UI reports exactly `TEST_AGENT_BUILD_VERSION` and does - * the same thing everywhere, so the live pair is what the point is stated for. - * Reading the machine here rather than in `@executablemd/acp` is the whole - * point of the seam — a scenario stating an exact foreign point still refuses. + * route store and observer — so it also says what it has proved. What a real + * host proves by driving an installed CLI, this one proves by being the build: + * the controlled UI declares exactly `TEST_AGENT_HELP` and does the same thing + * everywhere, so its own protocol and probe are what is admitted. Reading the + * machine here rather than in `@executablemd/acp` is the whole point of the + * seam — a scenario stating an exact foreign machine still refuses. */ -function testAgentCompatibility(): NativeCapabilityCompatibility { +function testAgentPolicy(): NativeCapabilityPolicy { const host = { platform: process.platform, architecture: process.arch }; return { host, - points: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ - agent: TEST_AGENT_CLIENT_NATIVE, + admissions: (["native-launch", "client-native-attachment"] as const).map((capability) => ({ + adapterProtocol: TEST_AGENT_PROTOCOL, capability, - reportedVersion: TEST_AGENT_BUILD_VERSION, + probeProfile: TEST_AGENT_PROBE_PROFILE, ...host, })), }; @@ -232,7 +306,7 @@ export function* useTestAgentProvider( ...(options.coordinator ? { coordinator: options.coordinator } : {}), ...(options.routeStore ? { routeStore: options.routeStore } : {}), ...(options.executableObserver ? { executableObserver: options.executableObserver } : {}), - compatibility: testAgentCompatibility(), + nativeCapabilityPolicy: testAgentPolicy(), ...(options.dependencies?.createRuntime ? { createRuntime: options.dependencies.createRuntime } : {}), From bb275f81dd4d4eeb97ccebaacc0d9b2058f5683d Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:20:56 -0400 Subject: [PATCH 53/62] =?UTF-8?q?=F0=9F=94=92=20Admit=20a=20Claude=20opera?= =?UTF-8?q?tion=20by=20the=20value=20its=20option=20takes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The help probe read flag spellings. `--session-id `, `--resume ` and `--system-prompt-file ` all satisfied it, so a build that accepts the spelling and means something else by it was admitted for native launch — and the launch would then hand a UUID to an option that takes a name. Each declaration is now parsed into its spellings, its value placeholder, whether that value is required, and its own description. Chosen identity needs the sole `--session-id` to require a UUID. Exact resume needs the sole `--resume` to name session identity, either in its placeholder or, for the uncommitted `[value]` spelling Claude ships today, in that entry's own words. Private instructions need a file-valued `--system-prompt-file`, or the `--system-prompt[-file]` family spelling inside a parsed entry. Product is read from dedicated unindented lines, so a compatibility claim or a quoted usage example no longer says what this executable is. Two entries declaring one spelling is unreadable, not a choice between them. --- packages/acp/src/native-launch.ts | 206 ++++++++++++++++++----- packages/acp/tests/native-launch.test.ts | 202 ++++++++++++++++++++++ 2 files changed, 367 insertions(+), 41 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index ca4f54b12..f51cae896 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -169,48 +169,174 @@ function optionEntries(help: string): string[] { return entries; } -/** What one option entry declares: its spellings, and whether it takes a value. */ -function declaredFlags(entry: string): { flags: string[]; takesValue: boolean } { +/** + * One option declaration, kept in parts rather than collapsed to a verdict. + * + * The parts are what make this a value contract instead of a spelling check. + * "Takes a value" cannot tell `--session-id ` from `--session-id `, + * and a launch that supplied a UUID to the second would be naming a session by + * something the build does not accept as one. So the placeholder is retained + * whole, and so is the description an entry uses to say what its value is. + */ +interface OptionDeclaration { + /** Every spelling this entry declares — `-r` and `--resume` alike. */ + readonly flags: readonly string[]; + /** The declared value with its brackets stripped, absent for a bare flag. */ + readonly placeholder: string | undefined; + /** Whether the value is required (``) rather than optional (`[value]`). */ + readonly required: boolean; + /** Everything after the head, whitespace-normalized and lowercased. */ + readonly description: string; + /** The whole entry, for a spelling this adapter reads inside a description. */ + readonly entry: string; +} + +/** Whitespace-insensitive text, for reading prose rather than layout. */ +function normalized(text: string): string { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * One entry, split into the spellings it declares and the value it takes. + * + * An entry's head is its spellings and at most one placeholder; the first token + * that is neither has begun the description. The placeholder is matched against + * the rejoined remainder rather than a single token so a value written with + * spaces inside its brackets is still one value. + */ +function declaredOption(entry: string): OptionDeclaration { + const tokens = entry.split(" ").filter((token) => token.length > 0); const flags: string[] = []; - for (const raw of entry.split(" ")) { + let index = 0; + for (; index < tokens.length; index += 1) { + const raw = tokens[index] ?? ""; const token = raw.endsWith(",") ? raw.slice(0, -1) : raw; - if (/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { - flags.push(token); - continue; + if (!/^-{1,2}[A-Za-z0-9][\w-]*$/.test(token)) { + break; } - // The head of an entry is its spellings and at most one value placeholder. - // Anything else has begun the description, and a description is prose. - return { flags, takesValue: token.startsWith("<") || token.startsWith("[") }; + flags.push(token); } - return { flags, takesValue: false }; + const rest = tokens.slice(index).join(" "); + const value = /^<([^<>]*)>|^\[([^[\]]*)\]/.exec(rest); + return { + flags, + placeholder: value === null ? undefined : (value[1] ?? value[2]), + required: value !== null && rest.startsWith("<"), + description: normalized(value === null ? rest : rest.slice(value[0].length)).toLowerCase(), + entry, + }; +} + +/** A placeholder compared by its letters, so `` and `[sessionId]` agree. */ +function placeholderName(placeholder: string): string { + return placeholder.replace(/[^a-z0-9]/gi, "").toLowerCase(); } /** - * How this executable declares one option, if it declares it at all. + * The one entry declaring `flag`, or nothing when it is absent or ambiguous. * - * `ambiguous` is separate from `absent` on purpose. Two entries declaring one - * spelling is output this adapter cannot read as a single answer, and choosing - * either would be guessing which one a launch would reach. + * Two entries declaring one spelling is output this adapter cannot read as a + * single answer, and choosing either would be guessing which one a launch + * would reach. Both are the same absence of evidence. */ -function declaresOption( - entries: string[], +function soleDeclaration( + options: readonly OptionDeclaration[], flag: string, -): "absent" | "ambiguous" | "flag" | "valued" { - const matched = entries - .map(declaredFlags) - .filter((declaration) => declaration.flags.includes(flag)); - if (matched.length === 0) { - return "absent"; +): OptionDeclaration | undefined { + const matched = options.filter((option) => option.flags.includes(flag)); + return matched.length === 1 ? matched[0] : undefined; +} + +/** + * Whether this help surface is Claude Code's own. + * + * Read from dedicated, unindented lines. Every option entry and every wrapped + * continuation of one is indented, so a product named inside a description is a + * mention — `compatible with Claude Code`, or a usage example quoted in prose, + * says what some other tool interoperates with rather than what this build is. + */ +function declaresClaudeProduct(help: string): boolean { + let named = false; + let usage = false; + for (const line of help.split("\n")) { + named ||= /^Claude Code\b/.test(line); + usage ||= /^Usage: claude(\s|$)/.test(line); + } + return named && usage; +} + +/** + * Whether this build accepts a session identity the caller chose. + * + * The value has to be required and has to be a UUID: that is the whole contract + * a client-allocated identity stands on. An option that will take any name is + * not one this adapter can hand a UUID to and expect the same session back. + */ +function declaresChosenIdentity(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--session-id"); + return ( + declaration?.placeholder !== undefined && + declaration.required && + placeholderName(declaration.placeholder) === "uuid" + ); +} + +/** Placeholders that name the identity itself. */ +const IDENTITY_VALUE = /^(session|conversation)id$/; + +/** Placeholders that commit to nothing, so the entry has to say what it takes. */ +const UNCOMMITTED_VALUE = /^(value|arg|argument|id)$/; + +/** + * Whether this build resumes the exact conversation an identity names. + * + * A placeholder that names the identity answers by itself. A placeholder that + * names something else — a URL, a path, a title — is a positive statement that + * the argument is not an identity, and no description overrides it. Only a + * placeholder committing to nothing is settled by the entry's own words, which + * is how the shipped `[value]` spelling is read without pinning its prose. + */ +function declaresIdentityResume(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--resume"); + if (declaration?.placeholder === undefined) { + return false; } - if (matched.length > 1) { - return "ambiguous"; + const value = placeholderName(declaration.placeholder); + if (IDENTITY_VALUE.test(value)) { + return true; } - return matched[0].takesValue ? "valued" : "flag"; + if (!UNCOMMITTED_VALUE.test(value)) { + return false; + } + return ( + declaration.description.includes("session id") && + declaration.description.includes("conversation") + ); } -/** Whitespace-insensitive text, for reading prose rather than layout. */ -function normalized(text: string): string { - return text.replace(/\s+/g, " "); +/** A placeholder or description saying the value is a file on disk. */ +const FILE_VALUE = /\b(file|filename|filepath|path)\b/; + +/** + * Whether the instruction layer can be handed over as a private file. + * + * Two accepted spellings, because Claude documents the family rather than the + * member: builds that give `--system-prompt-file` no entry of its own name it + * as `--system-prompt[-file]` inside another option's description. That is a + * declaration this executable makes about itself, so it is read from a parsed + * entry — never from a header, a command list, a footer, or free prose, where + * the same characters say only that someone wrote them. + */ +function declaresPrivateInstructionFile(options: readonly OptionDeclaration[]): boolean { + const declaration = soleDeclaration(options, "--system-prompt-file"); + if ( + declaration?.placeholder !== undefined && + (FILE_VALUE.test(placeholderName(declaration.placeholder)) || + FILE_VALUE.test(declaration.description)) + ) { + return true; + } + return options.some((option) => option.entry.includes("--system-prompt[-file]")); } /** @@ -234,11 +360,12 @@ const CLAUDE_PROBE_PROFILE = "claude-help-native-session.v1"; * rewords prose or rewraps lines still declares those, and a release that * stopped declaring one cannot launch whatever it calls itself. * - * The private-file member has two accepted spellings because Claude declares - * the family rather than the member: `--system-prompt-file` appears as the - * documented `--system-prompt[-file]` spelling in builds that do not give it - * its own entry. Both are the same declaration, and neither is inferred from - * the other's absence. + * Each member is a value contract, not a flag spelling. A launch supplies a + * UUID it chose, names that exact conversation again later, and hands over + * instructions as a private file — so what is read is the value each option + * says it takes. A build offering `--session-id ` or `--resume ` + * accepts the spelling and means something else by it, and admitting it would + * be reading agreement out of a coincidence of names. * * The two capabilities are read independently from what is present, never one * from the other: attachment additionally needs the bridge this adapter pins, @@ -251,14 +378,11 @@ function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityPr if (help === undefined) { return { probeProfile: CLAUDE_PROBE_PROFILE, capabilities }; } - const text = normalized(help); - const entries = optionEntries(help); - const product = text.includes("Claude Code") && /(^| )Usage: claude( |$)/.test(text); - const identity = declaresOption(entries, "--session-id") === "valued"; - const resume = declaresOption(entries, "--resume") === "valued"; - const privateFile = - declaresOption(entries, "--system-prompt-file") === "valued" || - text.includes("--system-prompt[-file]"); + const options = optionEntries(help).map(declaredOption); + const product = declaresClaudeProduct(help); + const identity = declaresChosenIdentity(options); + const resume = declaresIdentityResume(options); + const privateFile = declaresPrivateInstructionFile(options); if (product && identity && resume && privateFile) { capabilities.push("native-launch"); diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index b61e61a02..8f15c7939 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4151,6 +4151,208 @@ describe("Tier CP — the Claude capability probe", () => { } }); + /** + * Two real releases, transcribed to the declarations this adapter reads. + * + * Compact rather than whole: the surface is hundreds of lines and pinning it + * would be a snapshot, which is the thing this probe exists not to be. What + * is kept is every part an answer is read from, in the layout the release + * actually printed — including 2.1.263 putting the usage line first, giving + * `--resume` a generic placeholder settled by its own words, and naming the + * private-file spelling only inside `--bare`. + */ + const RELEASE_2_1_241 = [ + "Claude Code - starts an interactive session by default", + "", + "Usage: claude [options] [command] [prompt]", + "", + "Options:", + " --session-id Use a specific session ID for the conversation", + " -r, --resume [sessionId] Resume a conversation", + " --system-prompt-file Load the system prompt from a file", + "", + ].join("\n"); + + const RELEASE_2_1_263 = [ + "Usage: claude [options] [command] [prompt]", + "", + "Claude Code - starts an interactive session by default, use -p/--print for", + "non-interactive output", + "", + "Options:", + " --bare Minimal mode: skip hooks, LSP, plugin", + " sync. Explicitly provide context", + " via: --system-prompt[-file],", + " --append-system-prompt[-file], --add-dir", + " -r, --resume [value] Resume a conversation by session ID, or", + " open interactive picker with optional", + " search term", + " --session-id Use a specific session ID for the", + " conversation (must be a valid UUID)", + " --system-prompt System prompt to use for the session", + " --system-prompt-snapshot Record the system prompt once per session", + "", + ].join("\n"); + + it("CP6: both real releases declare the shape, in the layouts they printed it", function* () { + // Neither is read as a version. 2.1.241 gives the private file its own + // entry and names the resume argument in its placeholder; 2.1.263 leads + // with usage, settles a generic `[value]` in the entry's own words, and + // documents the private-file family inside another option. Same answer. + expect(probe(RELEASE_2_1_241)).toEqual(["native-launch", "client-native-attachment"]); + expect(probe(RELEASE_2_1_263)).toEqual(["native-launch", "client-native-attachment"]); + }); + + it("CP7: an option accepting the spelling but not the value is not the operation", function* () { + // Each row takes a release that does declare the shape and changes exactly + // what the argument is. The flag survives; the contract does not — and a + // launch that read the flag alone would hand a UUID to an option that + // takes a name, or ask for a conversation from one that takes a URL. + const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; + for (const [name, help, remaining] of [ + // Identity. Launch needs it; attachment never did, so it stays. + [ + "an identity that takes any name", + RELEASE_2_1_263.replace("--session-id ", "--session-id "), + ATTACH_ONLY, + ], + [ + "an identity whose value is optional and generic", + RELEASE_2_1_263.replace("--session-id ", "--session-id [value]"), + ATTACH_ONLY, + ], + [ + "two identity entries disagreeing about the value", + RELEASE_2_1_241.replace( + " --session-id Use a specific session ID for the conversation", + " --session-id Use a specific session ID for the conversation\n" + + " --session-id Deprecated: name the session", + ), + ATTACH_ONLY, + ], + // Resume. Both capabilities stand on it, so both go. + [ + "a resume that takes a URL", + RELEASE_2_1_263.replace("--resume [value]", "--resume "), + [], + ], + [ + "a resume that takes a path", + RELEASE_2_1_241.replace("--resume [sessionId]", "--resume "), + [], + ], + [ + "a generic resume whose entry never says which conversation", + RELEASE_2_1_263.replace( + "Resume a conversation by session ID, or", + "Reopen a workspace, or", + ), + [], + ], + // Private instructions. Launch only. + [ + "a private-instruction option that takes text rather than a file", + RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file", + " --system-prompt-file Use this as the system prompt", + ), + ATTACH_ONLY, + ], + [ + "the family spelling written in free prose instead of an entry", + `${RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file\n", + "", + )}\nSee the docs for --system-prompt[-file] and friends.\n`, + ATTACH_ONLY, + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, remaining]); + } + }); + + it("CP8: a product named in passing is not this executable saying what it is", function* () { + // Both readings come from dedicated, unindented lines. Every option entry + // and every wrapped continuation is indented, so a product or a usage + // example quoted inside a description says what someone wrote about this + // build rather than what the build is — and nothing is recognized without + // the product, so both capabilities go. + for (const [name, help] of [ + [ + "a product line that only claims compatibility", + RELEASE_2_1_241.replace( + "Claude Code - starts", + "A wrapper compatible with Claude Code - starts", + ), + ], + [ + "the product named inside an option's description", + RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Session tooling").replace( + " --session-id Use a specific session ID for the conversation", + " --session-id Use a specific session ID, as Claude Code does", + ), + ], + [ + "the usage line quoted in prose", + RELEASE_2_1_241.replace( + "Usage: claude [options] [command] [prompt]", + "Run it as Usage: claude", + ), + ], + [ + "the usage line quoted inside an option's description", + RELEASE_2_1_241.replace( + "Usage: claude [options] [command] [prompt]", + "Usage: wrapper [options]", + ).replace( + " -r, --resume [sessionId] Resume a conversation", + " -r, --resume [sessionId] Resume a conversation, like Usage: claude --resume", + ), + ], + ] as const) { + expect([name, probe(help)]).toEqual([name, []]); + } + }); + + it("CP9: a surface that only talks about the shape declares none of it", function* () { + // Product and usage are the real ones, so the only thing missing is the + // declarations themselves. Every spelling this adapter looks for is here — + // in a header, in a section body, in a command's description, and in + // footer prose — and not one of them is an option this build accepts. + const talksAboutIt = [ + "Claude Code - session tooling", + "", + "Usage: claude [options] [command] [prompt]", + "", + "Session options are forwarded: --session-id , --resume [sessionId],", + "and --system-prompt-file .", + "", + "Commands:", + " resume Resume a conversation by session ID", + "", + "Options:", + " -h, --help Show this message", + "", + "Instructions can be supplied with --system-prompt[-file].", + "", + ].join("\n"); + expect(probe(talksAboutIt)).toEqual([]); + + // The one variable. The same surface, with those three spellings declared + // as options it accepts, is admitted — so what the row above reads is the + // absence of declarations, not the presence of anything else. + const declaresIt = talksAboutIt.replace( + " -h, --help Show this message", + [ + " -h, --help Show this message", + " --session-id Use a specific session ID for the conversation", + " -r, --resume [sessionId] Resume a conversation", + " --system-prompt-file Load the system prompt from a file", + ].join("\n"), + ); + expect(probe(declaresIt)).toEqual(["native-launch", "client-native-attachment"]); + }); + it("CP5: neither capability is inferred from the other", function* () { // Exact resume is what attachment needs, and it is one of the three things // launch needs. A build declaring resume and nothing else attaches and From 3f614b4fcc1fa49b21ed8167375e7fcc89251071 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:36:47 -0400 Subject: [PATCH 54/62] =?UTF-8?q?=F0=9F=94=92=20Recognize=20what=20a=20Cla?= =?UTF-8?q?ude=20help=20entry=20states,=20not=20what=20it=20mentions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces that say the opposite of the contract were admitted by it. `Resume a conversation by URL; session ID is not supported` contained both words the read looked for. `Inline text; file paths are not supported` was accepted from a description while its argument was ``. `Claude Code compatibility wrapper` began a line with the product name. Prose is now read one clause at a time, and a clause that withdraws what it states admits nothing: a sentence states one thing, and its neighbours are not it. A generic `[value]` resume needs the phrase saying the argument is what the conversation is named by, not two words scattered through it. The private instruction file is decided by the argument alone, since a description explains a value rather than changing it. A product line names its subject and then describes it, so words running on into a longer name have named something else. The 2.1.241 and 2.1.263 surfaces, additive options, wrapping and unrelated prose are unchanged, and no version is consulted. --- packages/acp/src/native-launch.ts | 63 +++++++++++++++++++----- packages/acp/tests/native-launch.test.ts | 45 ++++++++++++++++- 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index f51cae896..6bd737f30 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -247,6 +247,19 @@ function soleDeclaration( return matched.length === 1 ? matched[0] : undefined; } +/** + * A line whose whole subject is Claude Code, rather than one beginning with it. + * + * A program description line is a name and then a summary of it, so the name + * ends where the separator starts. `Claude Code compatibility wrapper` does not + * name Claude Code and describe it — it continues the words into the name of + * something else, which is exactly what a wrapper is. + */ +const CLAUDE_PRODUCT_LINE = /^Claude Code\s*(?:[-–—:]|$)/; + +/** The invocation this build documents for itself, not one it mentions. */ +const CLAUDE_USAGE_LINE = /^Usage: claude(?:\s|$)/; + /** * Whether this help surface is Claude Code's own. * @@ -259,12 +272,32 @@ function declaresClaudeProduct(help: string): boolean { let named = false; let usage = false; for (const line of help.split("\n")) { - named ||= /^Claude Code\b/.test(line); - usage ||= /^Usage: claude(\s|$)/.test(line); + named ||= CLAUDE_PRODUCT_LINE.test(line); + usage ||= CLAUDE_USAGE_LINE.test(line); } return named && usage; } +/** + * The separately-stated parts of a description. + * + * Prose is read one clause at a time because a sentence states one thing and + * its neighbours are not it. `Resume a conversation by URL; session ID is not + * supported` contains every word a naive read wants, distributed across two + * clauses that each deny what the read would conclude. + */ +function clauses(description: string): readonly string[] { + return description.split(/[;.]/).map((clause) => clause.trim()); +} + +/** Wording that takes back the clause it appears in. */ +const DENIED = /\b(?:not|never|no|cannot|unsupported|instead of|rather than)\b/; + +/** A clause that states `stated` and does not then withdraw it. */ +function states(description: string, stated: RegExp): boolean { + return clauses(description).some((clause) => stated.test(clause) && !DENIED.test(clause)); +} + /** * Whether this build accepts a session identity the caller chose. * @@ -287,6 +320,14 @@ const IDENTITY_VALUE = /^(session|conversation)id$/; /** Placeholders that commit to nothing, so the entry has to say what it takes. */ const UNCOMMITTED_VALUE = /^(value|arg|argument|id)$/; +/** + * The one thing an uncommitted resume value may be: what the conversation is + * named by. Read as a phrase rather than as words that happen to co-occur — + * `by session ID` says the argument is the identity, where `conversation` and + * `session ID` scattered through a sentence say only that both were mentioned. + */ +const RESUMED_BY_IDENTITY = /\bby (?:its |the |a )?(?:session|conversation) id\b/; + /** * Whether this build resumes the exact conversation an identity names. * @@ -308,18 +349,19 @@ function declaresIdentityResume(options: readonly OptionDeclaration[]): boolean if (!UNCOMMITTED_VALUE.test(value)) { return false; } - return ( - declaration.description.includes("session id") && - declaration.description.includes("conversation") - ); + return states(declaration.description, RESUMED_BY_IDENTITY); } -/** A placeholder or description saying the value is a file on disk. */ -const FILE_VALUE = /\b(file|filename|filepath|path)\b/; +/** A placeholder naming a file on disk, compared by its letters. */ +const FILE_VALUE = /^(?:file|filename|filepath|path)$/; /** * Whether the instruction layer can be handed over as a private file. * + * The argument itself has to be the file. A description is where a build + * explains its value, not where it changes it: `--system-prompt-file ` + * takes the prompt inline whatever its prose goes on to mention about paths. + * * Two accepted spellings, because Claude documents the family rather than the * member: builds that give `--system-prompt-file` no entry of its own name it * as `--system-prompt[-file]` inside another option's description. That is a @@ -331,12 +373,11 @@ function declaresPrivateInstructionFile(options: readonly OptionDeclaration[]): const declaration = soleDeclaration(options, "--system-prompt-file"); if ( declaration?.placeholder !== undefined && - (FILE_VALUE.test(placeholderName(declaration.placeholder)) || - FILE_VALUE.test(declaration.description)) + FILE_VALUE.test(placeholderName(declaration.placeholder)) ) { return true; } - return options.some((option) => option.entry.includes("--system-prompt[-file]")); + return options.some((option) => states(option.entry.toLowerCase(), /--system-prompt\[-file]/)); } /** diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 8f15c7939..70dac58fe 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -4207,7 +4207,10 @@ describe("Tier CP — the Claude capability probe", () => { // Each row takes a release that does declare the shape and changes exactly // what the argument is. The flag survives; the contract does not — and a // launch that read the flag alone would hand a UUID to an option that - // takes a name, or ask for a conversation from one that takes a URL. + // takes a name, or ask for a conversation from one that takes a URL. The + // later rows say the same thing in prose that names the contract only to + // withdraw it, which is a build declining the operation in the clearest + // words it has rather than a build offering it. const ATTACH_ONLY: readonly NativeCapability[] = ["client-native-attachment"]; for (const [name, help, remaining] of [ // Identity. Launch needs it; attachment never did, so it stays. @@ -4249,6 +4252,15 @@ describe("Tier CP — the Claude capability probe", () => { ), [], ], + [ + "a generic resume naming a URL, whose prose mentions session ID to deny it", + RELEASE_2_1_241.replace( + " -r, --resume [sessionId] Resume a conversation", + " -r, --resume [value] Resume a conversation by URL;" + + " session ID is not supported", + ), + [], + ], // Private instructions. Launch only. [ "a private-instruction option that takes text rather than a file", @@ -4258,6 +4270,29 @@ describe("Tier CP — the Claude capability probe", () => { ), ATTACH_ONLY, ], + [ + "a private-instruction option taking text, whose prose mentions paths to deny them", + RELEASE_2_1_241.replace( + " --system-prompt-file Load the system prompt from a file", + " --system-prompt-file Inline text; file paths are not supported", + ), + ATTACH_ONLY, + ], + [ + "the family spelling named by the entry that says it is unavailable", + RELEASE_2_1_263.replace( + [ + " sync. Explicitly provide context", + " via: --system-prompt[-file],", + " --append-system-prompt[-file], --add-dir", + ].join("\n"), + [ + " sync. Context is inline only;", + " --system-prompt[-file] is not supported", + ].join("\n"), + ), + ATTACH_ONLY, + ], [ "the family spelling written in free prose instead of an entry", `${RELEASE_2_1_241.replace( @@ -4276,7 +4311,9 @@ describe("Tier CP — the Claude capability probe", () => { // and every wrapped continuation is indented, so a product or a usage // example quoted inside a description says what someone wrote about this // build rather than what the build is — and nothing is recognized without - // the product, so both capabilities go. + // the product, so both capabilities go. A dedicated line is not enough on + // its own either: a description line names its subject and then describes + // it, so words that run on into a longer name have named something else. for (const [name, help] of [ [ "a product line that only claims compatibility", @@ -4285,6 +4322,10 @@ describe("Tier CP — the Claude capability probe", () => { "A wrapper compatible with Claude Code - starts", ), ], + [ + "a product line that begins with the name but is the name of something else", + RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Claude Code compatibility wrapper"), + ], [ "the product named inside an option's description", RELEASE_2_1_241.replace(/^Claude Code.*$/m, "Session tooling").replace( From 2e27febcfd4677a237459891debe8d02c463a53c Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 09:53:24 -0400 Subject: [PATCH 55/62] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Continue=20client?= =?UTF-8?q?-native=20sessions=20across=20compatible=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 6 +- specs/acp-client-spec.md | 24 +- specs/decisions.md | 74 ++++-- specs/native-agent-session-launch-spec.md | 268 +++++++++++++--------- specs/test-agent-spec.md | 10 +- 5 files changed, 235 insertions(+), 147 deletions(-) diff --git a/architecture.md b/architecture.md index ba63cbf11..939c4f7ac 100644 --- a/architecture.md +++ b/architecture.md @@ -117,8 +117,8 @@ Existing documents and code get aligned to this section retroactively. | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | | construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | which build of a provider executable accepted a client-allocated identity, as a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Digest equality is mandatory, and a retained version must be reproduced when one exists; a build which reports none binds by digest alone. It contains no path: a path says where a build was, which stops being true, and names host layout besides | -| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The Agent name and reported version authorize nothing by themselves: the exact observed executable must advertise the profile's required shape through a read-only metadata query. The executable digest remains the per-session continuity binding, while an optional canonical version is retained only as supplemental evidence and a diagnostic | +| executable build binding | immutable audit evidence about the provider executable observed when a client-allocated identity was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. The V2 route and its prepared journal record retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The exact executable used by each live continuation must independently advertise the requested profile through a read-only metadata query; the Agent name, reported version, executable digest and a predecessor's admission authorize nothing by themselves | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | @@ -4647,7 +4647,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session is bound to one executable build: the build is observed inside ownership before an identity is allocated, the binding is published with the V2 route and retained beside the prepared record, the native child runs the exact observed path in place of the launcher name, and every later create, resume, attachment and incomplete replay reobserves and compares before a process, an ensure or a turn. The trusted host also states its operating system and architecture and admits each native capability only when the resolved adapter protocol, the independently requested capability, a read-only observation of the required CLI shape, and the proved host envelope all agree; neither the Agent name nor reported version authorizes it. That gate follows executable observation and precedes identity allocation, route publication, private-file creation, native process start, attachment ensure and incomplete replay. Reported version is optional diagnostic and per-session continuity evidence; the executable digest remains mandatory for a retained identity, so a compatible upgraded CLI can create new sessions while an existing session never crosses to a changed build implicitly. A `` or `` meeting a bound client-native route attaches to it: it reobserves the build, requires the live capability profile and retained binding both to match, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on missing capability, build drift, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by resolved agent command and binding, each handle is closed by the partition that created it, and a bound partition is torn down when its last handle closes. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an absent or changed required shape or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session retains immutable audit evidence for the executable observed before its identity was allocated: that binding is published with the V2 route and copied into each agreeing prepared record, while every live create, resume, attachment and incomplete replay observes the executable it will actually use and independently admits the requested capability before a process, an ensure or a turn. The trusted host admits only when the route's stable adapter protocol, the independently requested capability, the live executable's positive CLI shape, and the proved host operating system and architecture agree; neither the Agent name, reported version, executable digest nor a predecessor's admission authorizes or denies the continuation. A compatible Claude upgrade therefore resumes the exact retained native identity without allocating, converting, republishing or rewriting the route or journal. A route/journal disagreement still refuses because the two durable accounts no longer describe one preparation, not because the live build changed. The live canonical path replaces the launcher name only for that invocation. A `` or `` meeting a bound client-native route similarly admits attachment against the current executable, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on a protocol, profile, capability or host mismatch, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by the resolved agent command and live executable observation serving them, never by the route's historical binding; each handle is closed by the partition that created it, a live partition is never migrated after an upgrade, and a bound partition is torn down when its last handle and claimed work settle. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an existing V2 session continues across compatible Claude releases; an absent or changed required shape, a different protocol or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index f87857ba4..9714575d1 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -100,16 +100,20 @@ routes an opaque request and cannot manufacture that result. Publication is create-once, so the loser of either order adopts the winner, and no route converts. - A `session()` or subscribed `prompt()` meeting a **bound** `client-native` - route **attaches** to it. Under the same ownership it reobserves the build and - compares the binding exactly, requires any retained ACP arrangement to assert - that route's identity, selects the runtime for `(resolved agent command, - binding)`, calls `ensureSession()` with `resumeSessionId` equal to that - identity, and requires the returned canonical assertion to equal it before a - turn. The observed executable path reaches only that runtime's transient child - environment. A legacy unbound `client-native` route, an agent this host has - not advertised for attachment, build drift, a disagreeing arrangement and a - differing returned identity each refuse before a turn and create no substitute - conversation. + route **attaches** to it. Under the same ownership it reobserves the executable + and independently admits the route's stable adapter protocol, attachment + capability, positive CLI shape and proved host envelope. Its digest or version + may differ from the route's immutable audit evidence. The provider requires + any retained ACP arrangement to assert that route's identity, selects the + runtime for `(resolved agent command, live executable observation)`, calls + `ensureSession()` with `resumeSessionId` equal to that identity, and requires + the returned canonical assertion to equal it before a turn. The observed + executable path reaches only that runtime's transient child environment. A + legacy unbound `client-native` route, an agent this host has not advertised for + attachment, a protocol/profile/capability/host mismatch, a disagreeing + arrangement and a differing returned identity each refuse before a turn and + create no substitute conversation. A compatible release change rewrites + neither route nor provider history. - `withSessionRoute` remains routing only: it selects which partition serves a call and carries no authority to construct, own or answer. - **Base behavior:** with no provider installed, `agent()`, `session()`, diff --git a/specs/decisions.md b/specs/decisions.md index b0b759d80..c490aff52 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -813,6 +813,8 @@ hand. **Date:** 2026-09-06 +**Amended:** 2026-09-06 — compatible cross-release continuation + ### Context Claude's client-allocated launch proof ran against Claude Code 2.1.241 on @@ -821,11 +823,17 @@ fails closed, but it makes a routine Claude Code upgrade disable every new native session even when the executable still advertises the protocol surface the adapter uses. -Executable build binding answers a different question. Its executable digest -and optional canonical reported version let a later operation recognize the -exact executable that accepted one retained identity. They do not globally -authorize native launch or client-native ACP attachment, and a version string -does not describe a capability. +The first shape-based repair still used the executable digest and optional +canonical reported version as a per-session continuity gate. In practice that +let Claude Code 2.1.261 establish a session and made 2.1.263 refuse it before +resume, even though both releases independently advertised the same admitted +protocol. That preserves bytes rather than the provider-native identity the +route exists to preserve. + +The retained build binding remains useful as immutable audit evidence and as a +cross-check between the route and the prepared journal. It does not establish +whether another release implements the same operation. That question belongs +to the live capability profile. The client-allocated contract deliberately accepts two zero-turn outcomes: the same identity resumes, or the provider refuses that exact absent identity and @@ -867,23 +875,38 @@ For a new client-allocated session the provider resolves and hashes the executable, runs the adapter's read-only metadata query against that exact path, and checks the requested capability before identity allocation, route publication, private-file creation or native child start. A bound route is -checked again before native resume, attachment ensure and incomplete replay. A -profile or shape the host has not admitted refuses with -`unsupported-capability`; a live build which differs from the route still -refuses with `executable-binding-refused`. Completed replay and legacy V1 -native-only resume keep their existing behavior. +checked again before native resume, attachment ensure and incomplete replay. +The live executable may differ in digest or reported version from the build the +route records; it acts only when its stable adapter protocol, independently +requested capability, positive observed CLI shape and proved host envelope all +match. A mismatch refuses with `unsupported-capability`. Completed replay and +legacy V1 native-only resume keep their existing behavior. Claude Code's version query is optional metadata. One canonical version line is retained when available; changed wording, noncanonical output, a failed version query or no version output does not deny an otherwise recognized capability shape. New sessions created by such a build retain its exact digest and omit -the version. A retained binding still protects one published identity: digest -equality is mandatory, two present versions must agree, and a retained version -which the same observation can no longer reproduce refuses conservatively. An -upgrade may therefore create new named sessions immediately when its shape and -host envelope are admitted, while a session bound to the previous executable -does not cross the changed digest. Crossing builds for one existing identity -requires a separate migration contract. +the version. The V2 route and each agreeing prepared record keep the binding +they were first written with and never rewrite it after an upgrade. Their exact +agreement proves that the two durable accounts describe one preparation; it +does not require the live executable to reproduce either value. A compatible +upgrade may therefore create a new session or continue an existing one under +the route's exact retained native identity. + +`session-route.v2` fixes the client-native adapter protocol through its exact +provider, agent and launcher contract. The only admitted Claude interpretation +is `claude-client-native.v1`; changing that protocol requires a route contract +which names the new protocol rather than silently reinterpreting V2 history. +This lets an existing V2 route cross executable releases without letting it +cross protocols. + +Incomplete replay keeps the durable boundary. The route and journal must still +agree exactly on provider, native identity, provenance, instruction digest, +launcher and their original build binding. A `prepared`-only replay may create +under that same identity and a `detached` replay may only resume it, but each +uses the newly observed executable only after that executable independently +passes native-launch admission. Neither replay allocates, republishes or +rewrites. Completed replay observes nothing and performs no live work. An already-published route whose exact provider identity is absent remains the authoritative account. Native resume or ACP attachment fails closed without @@ -903,11 +926,18 @@ separate product decision and specification change. The provider's static adapter-name sets are only a coarse selection and cannot authorize client-allocated work by themselves. Admission requires the resolved adapter's stable protocol identifier, its observed capability shape and the -host envelope. The host/provider assembly keeps live platform facts and a -controlled admission seam, so tests can state profiles and observations without -reading the active runtime. The unmerged executable binding makes -`reportedVersion` optional; route and journal identity, request, provider -identity and authored syntax otherwise do not change. +host envelope on every live continuation. The host/provider assembly keeps live +platform facts and a controlled admission seam, so tests can state profiles and +observations without reading the active runtime. Route and journal schemas, +identity, request, provider identity and authored syntax do not change. + +ACP runtime partitions follow the resolved agent command and the live build +that actually serves them, not the historical binding in a route. A compatible +upgrade creates a new live partition after any old partition becomes idle; an +existing partition is never rekeyed or migrated, and every handle still closes +through its creator. Sessions with different historical bindings may share one +current partition only when they are being served by the same live build and +protocol. Different live builds never share a child. The metadata probe is deliberately less than a semantic trial. No side-effect-free query can prove that an implementation has no hidden diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 1c3c1d893..65397c9ff 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -345,10 +345,9 @@ codex resume ### Executable build binding -A client-allocated identity means one thing only while the build that accepted -it can be recognized later. Two builds of one provider accept the same identity -and disagree silently about what it names, so a new client-allocated session -retains which build accepted it: +A new client-allocated session records the executable observed before its +identity was published. This is immutable audit evidence and a durable +cross-check, not a release lock: ```ts interface ExecutableBuildBindingV1 { @@ -359,21 +358,28 @@ interface ExecutableBuildBindingV1 { ``` The digest is always present and exact: it is the lowercase SHA-256 of the -canonical executable target. A matching digest reached at another path is the -same build; a changed build at the same path is not. `reportedVersion` is the -adapter's optional canonical parse of what that exact target reports. One -canonical line is retained as supplemental continuity evidence and may appear -in a diagnostic; raw output is never retained or repeated. A failed version -query, no output, changed wording or several candidate lines leaves the member -absent and does not make an otherwise observable executable or capability -unsupported. - -Continuity always requires digest equality. When the retained binding carries a -version, a later observation must reproduce that same canonical value; a -different or absent value refuses conservatively. A binding created without a -version compares by digest alone, and a later version observation does not -rewrite it. A path is never a member: it says where a build was, which stops -being true, and it names host layout besides. +canonical executable target. `reportedVersion` is the adapter's optional +canonical parse of what that exact target reports. One canonical line is +retained when available; raw output is never retained or repeated. A failed +version query, no output, changed wording or several candidate lines leaves the +member absent and does not make an otherwise observable executable or +capability unsupported. A path is never a member: it says where a build was, +which stops being true, and it names host layout besides. + +The V2 construction route and every prepared journal record derived from it +carry the same original binding exactly. They never replace it with a later +observation. Exact agreement between those durable accounts detects a partial, +foreign or inconsistent preparation; digest or version equality with the live +executable is not required. A compatible later release continues the retained +native identity after it independently passes the same live capability gate. +The retained digest and version may be inspected as audit evidence, but neither +by itself authorizes or denies continuation. + +For the current V2 route, the exact provider, agent and launcher contract pins +the stable `claude-client-native.v1` protocol under which its identity was +published. A future incompatible protocol cannot reinterpret that route; it +requires a route contract which names the new protocol. Executable releases may +therefore change without allowing the session to cross protocol boundaries. The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the @@ -391,7 +397,7 @@ parse a provider's output or detect the active runtime. One observation yields two kinds of value: ```text -durable: the executable build binding, with a version only when reported canonically +durable on first publication: the executable build binding, with a version only when reported canonically live: the canonical executable path and provider-private metadata observations ``` @@ -411,13 +417,14 @@ is installed now, not which one established the conversation. ### Compatibility admission An installed-CLI proof establishes a **capability admission profile**, not an -adapter name or version allow-list. A live admission consists of a stable -adapter protocol, one independently requested capability, an observed CLI shape -that satisfies that protocol, and the host operating system and architecture on -which the applicable real-CLI proof passed. Native launch and client-native -attachment have independent admissions because their proofs ask different -questions. The executable digest remains the exact continuity binding for one -retained session; it is not a global allow-list for one operator's installation. +adapter name, version allow-list or executable-digest allow-list. Every live +admission consists of the route's stable adapter protocol, one independently +requested capability, an observed positive CLI shape that satisfies that +protocol, and the host operating system and architecture on which the +applicable real-CLI proof passed. Native launch and client-native attachment +have independent admissions because their proofs ask different questions. A +predecessor's admission and retained build evidence authorize nothing in the +current run. ```ts interface NativeCapabilityAdmission { @@ -472,27 +479,26 @@ provider runs the read-only probe against that exact path and requires the requested capability in its result. This precedes identity allocation, route publication, private-file creation and native child start. Existing bound routes are checked again before native resume, client-native attachment or -incomplete replay can contact the provider. A canonical version line is -evidence about the observed build, not capability admission. +incomplete replay can contact the provider. A canonical version line and digest +are evidence about the observed build, not capability admission or a +cross-release refusal. A protocol, shape, capability or host mismatch is `unsupported-capability`. The diagnostic may identify the agent, capability, adapter protocol and canonical reported version when one exists; it carries no raw help or version output, executable path, digest, environment or host message. Platform and -probe facts remain live admission inputs. The route's build binding continues -to answer whether this is the exact executable that accepted the retained -identity, with optional `reportedVersion` as specified above. +probe facts remain live admission inputs. A live digest or version different +from the route's retained audit evidence is not a mismatch. The version query is optional and independent of the help probe. A future Claude build with the same admitted protocol shape on macOS arm64 remains usable when its version changes, its version wording changes, or it omits -version output. A build that omits a canonical version creates a binding with -only its digest. An upgraded executable may establish new sessions, while a -route bound to an earlier digest remains bound to that build and refuses rather -than migrating its identity implicitly. The observed zero-turn behavior of a -newer Claude that retains no conversation is not by itself a regression: claim -5 explicitly permits the provider to refuse that exact identity, provided XMD -fails closed without substitution. +version output. It may establish a new session or continue an existing V2 +session. Continuation adopts only the route's exact native identity and leaves +the route, journal and provider history unchanged. The observed zero-turn +behavior of a newer Claude that retains no conversation is not by itself a +regression: claim 5 explicitly permits the provider to refuse that exact +identity, provided XMD fails closed without substitution. Compatibility admission adds no materialization turn. Launch still performs no model turn, and bootstrap turns remain outside this contract. The metadata @@ -515,8 +521,12 @@ afterwards. Its attachment claim was proven by `packages/acp/src/ClaudeNativeToAcp.test.md`: one native turn planted a random marker, a checked-in marker-free ACP `` recovered it under the same -identity and the same observed build, and an independent route naming an absent -identity refused before a turn without creating history in its place. +identity, and an independent route naming an absent identity refused before a +turn without creating history in its place. That real proof used one observed +build. The cross-release contract relies on each later executable independently +passing the same proved profile and keeps the proof's exact-identity and +no-substitution checks at the live boundary; the controlled evidence below +changes only the executable release between construction and continuation. Those command shapes are adapter implementation details, not authored document values. A custom ACP agent without a declared native launcher fails with an @@ -581,13 +591,13 @@ owner to release — what has to be settled first is which conversation this is: resolved adapter protocol, requested capability, observed protocol shape and trusted host operating system and architecture must match one admitted profile; a mismatch ends with `unsupported-capability`, still before an - identity or session-state mutation. Reported version does not participate - in that decision. The launch retains the refusal at `prepared` without an - identity, as it does every other preparation refusal. A route that already - names a different exact digest, or cannot reproduce a version its binding - retained, ends with `executable-binding-refused`. A legacy unbound route is - the exception: it observes nothing, resumes under the launcher name, and - gains no binding. + identity or session-state mutation. Reported version and digest do not + participate in that decision. The launch retains the refusal at `prepared` + without an identity, as it does every other preparation refusal. An existing + V2 route may carry different build evidence: after live admission the launch + adopts its exact identity and leaves that evidence unchanged. A legacy + unbound route is the exception: it observes nothing, resumes under the + launcher name, and gains no binding. Whether an identity is needed at all is decided next. An existing compatible `client-native` route already names this conversation, so its retained identity is adopted and **nothing is allocated** — a second @@ -608,9 +618,12 @@ owner to release — what has to be settled first is which conversation this is: launcher won — neither account repairs the other, so the launch refuses the same way. A `created` or `resumed` record is built from the compatible winning route - rather than from this launch's candidate, so the two accounts agree by - construction rather than by comparison. A refusal is not: it prepared no - identity, so it retains the failure the authoritative winner produced + rather than from this launch's candidate, including that route's original + build evidence, so the two durable accounts agree by construction. A + concurrently adopted route may have been published after a different + compatible executable observation; its digest and version do not defeat the + winner after this launch's own live capability admission. A refusal prepared + no identity, so it retains the failure the authoritative winner produced without mirroring that route's identity or provenance — no session id, and the weaker provenance claim, because nobody chose one. It is retained at `prepared` and reaches no private file, no detach and no spawn. @@ -664,22 +677,30 @@ this sequence, and every step happens while the coordinator holds the session: 4. A legacy unbound `client-native` route refuses with `executable-binding-refused`, and an agent this host has not advertised for attachment refuses with `unsupported-capability`. -5. Reobserve the executable and compare the binding exactly. +5. Reobserve the executable and independently admit + `client-native-attachment` for the route's stable protocol, the live positive + CLI shape and the proved host envelope. A changed digest or version is audit + evidence, not a refusal. 6. Inspect any retained ACP arrangement without creating one: absence may enter exact resume, because exact resume is the operation being attempted; a record must assert this route's identity and nothing else. -7. Select the live runtime for `(resolved agent command, binding)` and give the - observed path only to that runtime's child environment. +7. Select the live runtime for `(resolved agent command, live executable + observation)` and give the observed path only to that runtime's child + environment. 8. Ensure with `resumeSessionId` equal to the route's identity. 9. Require the provider's canonical assertion to equal it. Absence or disagreement closes the handle and refuses before a turn. 10. Only then return a `Session`, or start the subscribed turn. -Runtime partitions are scope-owned. Different bindings never share an ACP child, -a managed handle remembers the partition that created it, and every turn, close, -detach, cancellation and stale-handle release goes through that same partition. -When a bound partition's last handle closes it is removed and torn down; a later -attachment reobserves and builds another. +Runtime partitions are scope-owned and follow the executable serving live work, +not the historical binding in a route. Different live builds never share an ACP +child. Sessions carrying different historical bindings may share when the same +resolved agent command, current executable and protocol serve them. A managed +handle remembers the partition that created it, and every turn, close, detach, +cancellation and stale-handle release goes through that same partition. An +upgrade never rekeys or migrates a live partition. When its last handle closes +and its claimed work settles it is removed and torn down; a later attachment +reobserves and may build a partition for the upgraded executable. A partition is kept exactly as long as something is standing on it, and two different things can be: a handle nobody has closed, and work that has claimed @@ -704,11 +725,12 @@ are met rather than a further invariant beside them: - **Claimed work that produced no handle releases its claim.** The runtime is built before the ensure that would use it, so a rejection would otherwise - strand a partition holding a live path for work that never happened — and a - binding compares a version and a digest, so the same build found somewhere - else is the same partition key and a different file to run. Success transfers - the claim into ownership of the handle instead, in one step: a moment where - neither count is held is a moment another operation could evict. + strand a partition holding a live path for work that never happened. The live + observation, not the route's historical evidence, supplies the partition key; + the same current build found somewhere else is the same partition and a + different live path to run. Success transfers the claim into ownership of the + handle instead, in one step: a moment where neither count is held is a moment + another operation could evict. - **Cancellation observes the ensure it started, and settles it before quiescence.** Starting an ensure is not the same as owning it: the call runs whether or not anybody is still waiting, so a cancellation is not the end of @@ -1102,16 +1124,25 @@ provider-returned preparation carries none, and a refusal that prepared no identity invents none. Every incomplete replay requires exact agreement between its journal and its -route on identity, provenance, instruction digest, launcher and build binding -before its first live effect, and then requires the live capability point to be -admitted and the live build to equal that binding. Neither account repairs or -republishes the other: a replay that found a disagreement has discovered that -the session it was going to continue is not the session it prepared, and -retains `identity-unavailable` without starting a child. Equal instructions may -resume the retained identity; different instructions retain +route on identity, provenance, instruction digest, launcher and the original +build binding before its first live effect. It then observes the executable it +will use and independently requires the native-launch capability for the same +stable adapter protocol, positive CLI shape and proved host envelope. The live +digest and version need not equal the retained evidence. Neither durable account +repairs, republishes or rewrites the other: a replay that found a disagreement +has discovered that the session it was going to continue is not the session it +prepared, and retains `identity-unavailable` without starting a child. Equal +instructions may continue the retained identity; different instructions retain `instructions-refused` and replace neither the layer, the route, the identity, nor any provider state. +A `prepared`-only replay still creates under the exact retained identity because +its handoff never began. A `detached` replay still resumes that identity and +never falls back to creation because a predecessor may have started. Both use +the current admitted executable path, allocate nothing and leave the historical +binding unchanged. A completed replay still observes no executable and performs +no live work. + An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can show which build has that session's history, and resuming anyway would answer @@ -1213,14 +1244,15 @@ different layer, exactly as a session a native UI has been in is. A `` that only placed one has established nothing, so a launch inside it constructs the session it named rather than meeting one. -A build this run cannot show is the build behind the session fails with -`executable-binding-refused`. Resolution, canonicalization, executable-file -validation, digesting, schema recognition, digest equality, failure to reproduce -a retained canonical version, and a session established before any build was -recorded all end there. The diagnostic names the stable class and launcher and -may name canonical versions which were actually observed; it carries no -executable path, raw metadata output, host error, argv, environment, credential, -instruction text or provider payload. +An executable this run cannot resolve, canonicalize, validate, hash or observe +fails with `executable-binding-refused`, as does a session established before +any build was recorded on the paths which require a bound route. A different +live digest or canonical version does not. The diagnostic names the stable class +and launcher and may name canonical versions which were actually observed; it +carries no executable path, raw metadata output, host error, argv, environment, +credential, instruction text or provider payload. A malformed build binding or +a route and journal carrying different bindings is a durable-account mismatch +and refuses before live work; it is never repaired from the current executable. An observed executable whose adapter protocol, requested capability, required CLI shape, operating system or architecture has no admitted profile fails with @@ -1413,22 +1445,26 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding is read and compared conservatively — a moved matching - digest is accepted, a changed digest is not, a retained version must be - reproduced, a binding created without one compares by digest, and an - inexact record refuses rather than being read past; +18. a build binding remains immutable audit evidence shared exactly by the V2 + route and its prepared journal record; a changed live digest, changed or + absent version and moved executable are accepted after fresh capability + admission, while malformed evidence or disagreement between the two durable + accounts refuses rather than being read past or repaired; 19. new client-native construction observes the build before it allocates, publishes a bound V2 route, and retains a preparation that agrees with it, while a legacy V1 route resumes natively under the launcher name and gains nothing; 20. a `` or `` on a bound route supplies the route identity as - the exact resume identity, delivers the observed path only to the matching - child's transient environment, and refuses before ensure on a missing - attachment gate, a missing observer, build drift, a disagreeing retained - arrangement or a returned identity that is not the route's; and -21. ACP runtimes are partitioned by resolved agent command and binding, a handle - is closed by the partition that created it, the last close evicts a bound - partition, and provider teardown settles what remains; + the exact resume identity, delivers the current admitted executable path + only to the matching child's transient environment, continues across a + compatible release, and refuses before a turn on a missing attachment gate, + a missing observer, a disagreeing retained arrangement or a returned + identity that is not the route's; and +21. ACP runtimes are partitioned by resolved agent command and live executable + observation rather than historical route evidence, a handle is closed by + the partition that created it, an upgrade never migrates a live partition, + the last close and claim release evict it, and provider teardown settles + what remains; 22. claimed runtime work that produced no handle releases its claim, a partition is evicted only with no handles and no work in flight, a handle that came back survives every later refusal bound to its creator whichever path @@ -1447,7 +1483,8 @@ Focused tests prove: requested capability, required read-only CLI shape, operating system and architecture; neither an Agent name nor version string admits it. A newer canonical version, omitted version and additive unrelated help are accepted - when that shape and host envelope match, while a missing or ambiguous + when that shape and host envelope match, including for an existing V2 route + carrying a different digest or version, while a missing or ambiguous required member, another adapter protocol, or an unproved host is refused; 26. the metadata probe runs against the exact resolved executable and carries no terminal, stdin, session identity, instructions, credential or provider @@ -1460,7 +1497,17 @@ Focused tests prove: cannot launch, and a bound route whose exact identity is absent remains authoritative and is never substituted. A settled exact-resume refusal acknowledges quiescence after cleanup, while a planted unproved teardown - leaves the recovery tombstone active. + leaves the recovery tombstone active; and +28. a V2 route and journal created with Claude Code 2.1.261 continue through an + independently admitted 2.1.263 executable for both native resume and ACP + attachment: no identity is allocated, the exact retained native identity is + passed and asserted, the historical binding is not rewritten, and the live + partition uses 2.1.263. The same fixtures refuse before live provider work + when protocol, requested capability, positive shape or host differs, and + close without a turn or substitution when the provider reports an absent or + different identity. Prepared-only and detached incomplete replays prove + create-versus-resume remains phase-driven across the same upgrade, while + completed replay probes nothing. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1536,11 +1583,12 @@ provider-native identity that is either asserted by the provider or allocated by the adapter before the provider exists, retained explicitly and never inferred; a strict create-once construction route beside the coordinator's own records, in a released unbound form and a bound one; the host-owned executable observer -and the build binding it produces; proof-scoped capability admission over the -adapter protocol, independently requested capability, observed CLI shape and -live host platform; ACP -attachment to a bound client-native session under its exact retained identity, -through runtime partitions keyed by agent command and build; +and the immutable audit binding it produces on first publication; proof-scoped +capability admission over the stable adapter protocol, independently requested +capability, live observed CLI shape and live host platform on every +continuation; compatible cross-release native resume and ACP attachment to a +bound client-native session under its exact retained identity, through runtime +partitions keyed by agent command and the live executable observation; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1569,10 +1617,11 @@ degrading: and `Session.Launch` expose no model prop or launch option. A provider may report the current model as observational evidence, but native launch neither selects nor changes it. -- **Executable upgrade migration is unbuilt.** A V2 route freezes one build for - that logical session, and a later build refuses with - `executable-binding-refused` rather than modifying the route or the provider's - history. Rebinding old provider history to a new build is a separate design. +- **Protocol migration is unbuilt.** A V2 route fixes the stable + `claude-client-native.v1` protocol through its provider, agent and launcher + contract. A compatible executable release may continue it, but another + protocol cannot reinterpret, rewrite or adopt it. Supporting that transition + requires a route contract which names the new protocol. - **A legacy unbound client-native session never attaches.** It was constructed before XMD recorded which build accepted its identity, so nothing available now can show this run is talking to that build. It keeps native resume and @@ -1624,9 +1673,11 @@ Implementation review checks these frozen invariants: reaches neither argv nor environment. 17. Private setup and child-creation failures are normalized before they cross a public or durable boundary. -18. A new client-native session is bound to one observed executable build, and - every later create, resume, attachment and incomplete replay reobserves and - compares before a process, an ensure or a turn. +18. A new client-native session retains immutable evidence for the executable + observed before its identity is published. The V2 route and prepared journal + agree on that evidence and never rewrite it, while every later create, + resume, attachment and incomplete replay independently admits the live + executable without requiring its digest or version to equal the evidence. 19. The canonical executable path is live only: it enters no route, journal, retained provider state, public result, diagnostic or global environment, and no partition that outlives its last handle. @@ -1677,10 +1728,11 @@ Implementation review checks these frozen invariants: client-allocated profile mismatch is refused after read-only observation but before allocation or any provider or session-state mutation; its identity-free launch refusal is retained. The profile is checked again - before bound resume, attachment and incomplete replay. An exact absent - identity remains authoritative and unavailable; neither that refusal nor an - unproved profile creates a replacement conversation or a materialization - turn. + before bound resume, attachment and incomplete replay, and a compatible live + release continues the exact retained identity without allocating, + republishing or rewriting durable evidence. An exact absent identity remains + authoritative and unavailable; neither that refusal nor an unproved profile + creates a replacement conversation or a materialization turn. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached diff --git a/specs/test-agent-spec.md b/specs/test-agent-spec.md index 4b92f7fc8..91c73b249 100644 --- a/specs/test-agent-spec.md +++ b/specs/test-agent-spec.md @@ -268,10 +268,12 @@ owned by a coordinator of its own. The observer is the whole seam, replaced whole. It answers one stable canonical version and one stable digest for the life of a partition, and a harness that -wants to watch a build drift changes what it answers rather than reaching for a -control the production path also has. There is no author-facing binding or path -prop: an authored document sees attachment and refusal, and nothing about which -file was observed. +wants to exercise a compatible release change supplies a later independently +admitted observation rather than reaching for a control the production path +also has. The original route and journal evidence stays unchanged while the live +runtime partition follows that later observation. There is no author-facing +binding or path prop: an authored document sees attachment or refusal, and +nothing about which file was observed. `` also installs a controlled native launcher for its body. The test agent's native UI is fictional in the way its agent is — the worker asserts a From d74e090e8bce85fe94d88fb601ed654642cdcfd7 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 10:17:23 -0400 Subject: [PATCH 56/62] =?UTF-8?q?=F0=9F=94=93=20Continue=20a=20client-nati?= =?UTF-8?q?ve=20session=20across=20compatible=20releases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A route's `ExecutableBuildBindingV1` was a release lock: every continuation held the live observation against the retained one, so upgrading Claude stranded every session the previous build had opened. It is now immutable audit evidence — written once at first publication, never rewritten, and never compared with a live build. What authorizes acting on an existing session is what the installed executable independently proves: the route's stable adapter protocol, the capability the work needs, the shape its own probe recognizes, and this host. Which build accepted the identity first is history, and history is not a capability. Removes the four live-vs-retained comparisons (attachment, native resume, concurrent winner, replay) and the `buildDrift` helper they shared. The one remaining comparison stays: a route and its prepared journal are two durable accounts of a single observation, so a difference between them is still a replay that cannot say which session it is resuming. Tier XR takes a 2.1.261 session to a 2.1.263 build across native resume, ACP attachment, prepared-only replay, detached replay, a concurrently published winner, and a build reporting no release — each asserting the exact retained identity, unchanged audit evidence, no allocation or republication, and the live executable's own path. XR7 and XR8 hold the fail-closed boundary that a release change is not a protocol change. --- packages/acp/src/provider.ts | 97 ++-- packages/acp/tests/native-launch.test.ts | 643 ++++++++++++++++++++--- packages/core/src/agent/launch.ts | 27 +- 3 files changed, 607 insertions(+), 160 deletions(-) diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 717f96ad6..feab8df16 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -370,7 +370,7 @@ interface BoundBuild { */ interface RuntimeEntry { runtime: ProbeCapableRuntime; - /** The `(agent command, binding)` partition, or nothing for the unbound one. */ + /** The live-executable partition, or nothing for the unbound one. */ partition: string | undefined; /** Handles created through this runtime that have not been closed. */ handles: number; @@ -911,11 +911,16 @@ function* useAcpxProviderState( } /** - * The `(agent command, build)` partition a bound runtime is kept under. + * The `(agent command, live executable)` partition a bound runtime is kept + * under. * - * The digest is what separates two builds, so a build that reported no - * version still shares no partition with a different one — the version is - * kept beside it only so a partition names what a record names. + * Keyed by the observation serving this work, never by what a route retained: + * a partition holds a live path and a running child, so what may share one is + * decided by which executable is being run now. Two sessions whose routes + * record different builds share this child when the same release serves them + * both, and two live releases never do. The digest is what separates them, so + * a build that reported no version still shares no partition with a different + * one. */ function partitionOf(build: BoundBuild): string { return [ @@ -1704,9 +1709,9 @@ function* useAcpxProviderState( * * Every way this can fail — no observer, resolution, canonicalization, an * unreadable or non-executable file — ends in one stable class, because they - * are all the same question: is this the build that established the session. - * A version this adapter does not recognize is not among them; that is - * optional evidence, and its absence leaves a build bound by its digest. + * are all the same question: can this run see the executable it would use at + * all. A version this adapter does not recognize is not among them; that is + * optional evidence, and its absence leaves a build described by its digest. */ function* observeBuild( agentName: string, @@ -1768,10 +1773,13 @@ function* useAcpxProviderState( * a private file, a child, an ACP ensure — because a capability nobody proved * is not a thing to discover halfway through. * - * Distinct from build drift beside it, which is a different question. Drift - * asks whether this is still the build that accepted one retained identity; - * this asks whether the build works at all. A session may fail either while - * passing the other. + * It is the whole authorization for acting on an existing session, too. What + * a retained identity needs is an executable that implements the operation, + * and this asks exactly that; which executable happened to accept the identity + * first is history, and history is not a capability. So a release that changed + * under the same command continues the conversation once it passes here on its + * own, and one that no longer implements the operation is refused even if it + * is byte-for-byte the build that opened it. */ function admitCapability( agentName: string, @@ -1803,29 +1811,6 @@ function* useAcpxProviderState( }; } - /** - * The stable comparison two builds of one session fail. - * - * Canonical versions appear when they exist because they are the readable - * half of the answer, and a build that reported none says so rather than - * substituting its digest — a digest is host-observable evidence and belongs - * in no message. - */ - function buildDrift( - sessionKey: string, - retained: ExecutableBuildBindingV1, - live: ExecutableBuildBindingV1, - ): LaunchFailure { - const named = (binding: ExecutableBuildBindingV1) => - binding.reportedVersion ?? "a build reporting no version this adapter recognizes"; - return { - class: "executable-binding-refused", - message: - `session "${sessionKey}" was created by ${named(retained)} and this run ` + - `would use ${named(live)}, so the conversation it names cannot be confirmed`, - }; - } - /** * What a client-native route's provider arrangement already asserts. * @@ -1861,8 +1846,8 @@ function* useAcpxProviderState( * `` and `` are eager, so a session nobody constructed is * constructed here as `acp-first` before ensure. A session a native process * constructed is attached to — never converted, never republished — and only - * when this host has proven that capability for this adapter and can still - * show it is talking to the build that created it. + * when this host has proven that capability for the executable it is about to + * run, whichever release that has become. */ function* constructRoute( agentName: string, @@ -1914,19 +1899,15 @@ function* useAcpxProviderState( attaching.protocol, attaching.binding, ); - // Before the comparison and long before the ensure. A build this host has - // not proved attachment on is refused whether or not it happens to be the - // build that created the session — being the right one is not evidence that - // joining the conversation through ACP works on it. + // Long before the ensure, and the whole authorization. Being the executable + // that created the session was never evidence that joining the conversation + // through ACP works on it, and the route's retained binding is an account of + // a past observation rather than a claim about the one running now. What has + // to hold is that this executable independently implements the operation. const unproved = admitCapability(agentName, "client-native-attachment", build); if (unproved) { throw new AttachmentRefused(unproved); } - if (!sameExecutableBuild(route.executableBinding, build.binding)) { - throw new AttachmentRefused( - buildDrift(prepared.sessionKey, route.executableBinding, build.binding), - ); - } yield* retainedAssertion(prepared.sessionKey, route.nativeSessionId); return { build, resumeSessionId: route.nativeSessionId }; } @@ -2448,13 +2429,6 @@ function* useAcpxProviderState( if (unproved) { return refusal(unproved.class, unproved.message, known); } - if ( - route?.route === "client-native" && - !sameExecutableBuild(route.executableBinding, build.binding) - ) { - const drift = buildDrift(sessionKey, route.executableBinding, build.binding); - return refusal(drift.class, drift.message, known); - } // A session that already has an identity is resumed under it, and nothing // is allocated at all: a second candidate for a conversation that already @@ -2513,14 +2487,14 @@ function* useAcpxProviderState( invocation.fresh.set(sessionKey, false); return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } - if (!sameExecutableBuild(winner.executableBinding, build.binding)) { - const drift = buildDrift(sessionKey, winner.executableBinding, build.binding); - return refusal(drift.class, drift.message, known); - } - // The record is built from the winner rather than from the candidate, so - // the two accounts agree by construction rather than by comparison. Losing - // the race means this session already exists and is resumed. + // The record is built from the winner rather than from the candidate — its + // identity and its binding alike — so the two durable accounts agree by + // construction rather than by comparison. A winner published from a + // different compatible observation keeps the evidence it was written with: + // this launch already admitted the executable it will actually run, and the + // winner's account of an earlier one is not a second thing to satisfy. + // Losing the race means this session already exists and is resumed. const fresh = winner.nativeSessionId === candidate; invocation.fresh.set(sessionKey, fresh); invocation.bound.set(sessionKey, { build, adapter }); @@ -3108,9 +3082,6 @@ function* useAcpxProviderState( if (unproved) { return unproved; } - if (!sameExecutableBuild(route.executableBinding, build.binding)) { - return buildDrift(prepared.sessionKey, route.executableBinding, build.binding); - } invocation.bound.set(prepared.sessionKey, { build, adapter }); return undefined; } diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 70dac58fe..5fc997bc0 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -208,6 +208,20 @@ const DIGEST_ONLY_BUILD: ExecutableBuildBindingV1 = { /** The canonical path that same observation reports. */ const OBSERVED_PATH = "/opt/builds/claude"; +/** + * Two real Claude releases, and where a build of the later one is installed. + * + * A session opened under the first and continued under the second is the case + * the cross-release contract exists for, so it is named once here rather than + * assembled per tier: the releases differ, the bytes differ, and the path + * differs, which is every way a durable account can stop describing what is + * installed. + */ +const RETAINED_RELEASE = "2.1.261 (Claude Code)"; +const LATER_RELEASE = "2.1.263 (Claude Code)"; +const LATER_DIGEST = "b".repeat(64); +const LATER_PATH = "/opt/builds/claude-2.1.263"; + /** * One controlled observation, varying only what a case is about. * @@ -2912,48 +2926,69 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toEqual([]); }); - it("CA5: a build this run cannot name, or cannot reach, refuses before ensure", function* () { - for (const [name, mutate] of [ - [ - "another build at the same command", - (observer: FakeObserverHarness) => { - observer.observation.digest = "b".repeat(64); - }, - ], - [ - "another version of the same bytes", - (observer: FakeObserverHarness) => { - observer.observation.metadata.version = answered("2.1.242 (Claude Code)\n"); - }, - ], - [ - "a build that no longer reproduces the version this route retained", - (observer: FakeObserverHarness) => { - observer.observation.metadata.version = answered("claude version 2.1.241\n"); - }, - ], - [ - "an executable that could not be observed at all", - (observer: FakeObserverHarness) => { - observer.failure = "not-executable"; - }, - ], + it("CA5: an executable this run cannot reach refuses before ensure", function* () { + // What stops an attachment is not knowing which release is installed — it + // is not being able to see the executable at all. Every way of failing to + // reach one ends in the same class, because none of them produced an + // account of a build this run could then admit. + for (const [name, failure] of [ + ["an executable that is not there", "not-found"], + ["a path that is not an executable file", "not-executable"], ] as const) { yield* scoped(function* () { const observer = createFakeObserver(); - mutate(observer); + observer.failure = failure; const space = yield* installAttachment(bound(), { observer: observer.observer }); const raised = yield* attach(); expect([name, raised === undefined]).toEqual([name, false]); expect([name, space.harness.ensureCalls]).toEqual([name, []]); - // A moved build is still that build, so nothing here may name a path. + expect([name, space.harness.createdOptions]).toEqual([name, []]); expect([name, raised?.message.includes(OBSERVED_PATH)]).toEqual([name, false]); }); } }); + it("CA5b: a release this route never met attaches on its own admission", function* () { + // The counterpart, and the whole of the new rule at this seam. A route's + // binding is an account of the build that opened the conversation; joining + // it is a question about the build running now. Each of these is a live + // executable the retained account does not describe — other bytes, another + // release, a release this adapter cannot read — and each independently + // declares the attachment shape on the proved host, so each joins. + for (const [name, live] of [ + ["another build at the same command", observation({ digest: "b".repeat(64) })], + ["another release of the same bytes", observation({ version: `${LATER_RELEASE}\n` })], + [ + "a release this adapter cannot read", + observation({ version: "claude, the coding agent\n" }), + ], + ] as const) { + yield* scoped(function* () { + const route = bound(); + const observer = createFakeObserver(live); + const space = yield* installAttachment(route, { observer: observer.observer }); + + const session = yield* Agent.operations.session(); + + // The route's own identity crossed, unchanged and unreplaced. + expect([name, session.agentSessionId]).toEqual([name, ALLOCATED]); + expect([name, space.harness.ensureCalls.map((call) => call.resumeSessionId)]).toEqual([ + name, + [ALLOCATED], + ]); + // Served by the executable observed now, not by the one recorded then. + expect([name, space.harness.createdOptions.at(-1)?.agentProcessEnv]).toEqual([ + name, + { CLAUDE_CODE_EXECUTABLE: live.path }, + ]); + // And the account of how it started is exactly as it was written. + expect([name, yield* space.routes.read(KEY)]).toEqual([name, route]); + }); + } + }); + it("CA6: a moved build is the same build", function* () { // Equality is over what was retained — a version and a digest — so the same // bytes reached at another path attach exactly as they would have before. @@ -3304,11 +3339,13 @@ describe("Tier CA — client-native attachment", () => { expect(space.harness.createdOptions).toHaveLength(2); }); - it("CA14: a route's retained version that this build will not reproduce refuses before a child, an ensure or a turn", function* () { + it("CA14: a build that names no release attaches on its shape, and repeats nothing", function* () { // One canonical line is an answer. Several is a list of builds, and taking - // the first would be choosing one — so this build reports no version. That - // is not a refusal in itself; what refuses is that the route retained one, - // and a claim the live build no longer makes cannot be confirmed. + // the first would be choosing one — so this build reports no version at + // all. That settles nothing about whether it can join a conversation, which + // is a question about the shape it declares; the shape is there, so it + // does. The route retained a release, and this build makes no claim about + // it either way. const observer = createFakeObserver({ metadata: { help: answered(claudeHelp()), @@ -3317,15 +3354,14 @@ describe("Tier CA — client-native attachment", () => { }); const space = yield* installAttachment(bound(), { observer: observer.observer }); - const raised = yield* attach(); + const session = yield* Agent.operations.session(); - expect(raised?.message).toContain("cannot be confirmed"); - // Nothing was repeated back: the output is the provider's, not the reader's. - expect(raised?.message).not.toContain("2.1.242"); - expect(space.harness.createdOptions).toEqual([]); - expect(space.harness.ensureCalls).toEqual([]); - expect(space.harness.turns).toEqual([]); - expect(space.trace.launches).toEqual([]); + expect(session.agentSessionId).toBe(ALLOCATED); + expect(space.harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + // Nothing the build said was repeated back through the session it opened. + expect(JSON.stringify(space.harness.createdOptions)).not.toContain("2.1.242"); + // The route still says what the run that published it could stand behind. + expect(yield* space.routes.read(KEY)).toEqual(bound()); }); it("CA9: a legacy unbound route refuses rather than attaching", function* () { @@ -3459,19 +3495,70 @@ describe("Tier RT — bound runtime partitions", () => { }); yield* Agent.operations.session(); - // The same command, a different build behind it, and a route that names - // the old one: the attachment refuses rather than reusing the child. + // The same command and the same route, with a different build behind it + // now. The session continues — that is the cross-release contract — but it + // continues through the executable that is actually installed, so the + // second attachment is served by a child of its own. observer.observation.digest = "c".repeat(64); observer.observation.path = "/opt/builds/other-claude"; - let raised: Error | undefined; - try { - yield* Agent.operations.session(); - } catch (error) { - raised = error as Error; - } + yield* Agent.operations.session(); - expect(raised?.message).toContain("cannot be confirmed"); - // One runtime, for the one build that was ever accepted. + // Two builds, two children, and each one given the path it was built for. + expect(environments(harness)).toEqual([OBSERVED_PATH, "/opt/builds/other-claude"]); + // Nothing was rekeyed: each handle went back through its own runtime. + expect(harness.closeCalls).toHaveLength(2); + expect(harness.closeRuntimes).toEqual([OBSERVED_PATH, "/opt/builds/other-claude"]); + }); + + it("RT2b: one live build serves routes that record different builds", function* () { + // The other direction, and the one the old rule could not express. These + // two sessions were opened by different releases and their routes still + // say so. What decides whether they may share a child is which executable + // is running now, and it is the same one — so they do. + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = createMemorySessionRouteStore(); + const observer = createFakeObserver(); + const other: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }; + const held = deriveSessionKey(AGENT_COMMAND, CWD, "held"); + const beside = deriveSessionKey(AGENT_COMMAND, CWD, "beside"); + yield* routes.publish(route(held, FIRST)); + yield* routes.publish(route(beside, SECOND, other)); + yield* installLaunchStack(harness, trace, { + adapters: { claude: adapter() }, + routeStore: routes, + observer: observer.observer, + }); + + // A partition is only shared while something is holding it, so the first + // session is kept in flight rather than allowed to return — otherwise the + // second would find an evicted partition and build its own either way. + const gate = withResolvers(); + const arrived = withResolvers(); + harness.ensureGate = (input) => { + if (input.sessionKey === held) { + arrived.resolve(); + return gate.operation; + } + return undefined; + }; + + const first = yield* spawn(() => Agent.operations.session("held")); + yield* arrived.operation; + const later = yield* Agent.operations.session("beside"); + const built = harness.createdOptions.length; + gate.resolve(); + const settled = yield* first; + + expect([settled.agentSessionId, later.agentSessionId]).toEqual([FIRST, SECOND]); + // Both were observed — neither was taken on the strength of its route — + // and one child answered for both. + expect(observer.observed).toEqual(["claude", "claude"]); + expect(built).toBe(1); expect(environments(harness)).toEqual([OBSERVED_PATH]); }); @@ -4495,7 +4582,7 @@ describe("Tier NP — proved native capability admissions", () => { /** The build the shipped Claude proof ran against, and one that follows it. */ const PROVED_VERSION = "2.1.241 (Claude Code)"; - const LATER_VERSION = "2.1.263 (Claude Code)"; + const LATER_VERSION = LATER_RELEASE; /** * The same help surface with exactly one required declaration withdrawn. @@ -4921,32 +5008,37 @@ describe("Tier NP — proved native capability admissions", () => { } }); - it("NP5: an admitted point still refuses a build the route does not name", function* () { - // Admission and continuity are different questions, and passing the first - // is not an answer to the second. The live build is exactly the proved one; - // the route names an earlier one, so this is the drift refusal, not the - // capability one. + it("NP5: an admitted point continues a session the route's build did not open", function* () { + // Admission is the whole authorization, and it is a question about the + // executable this run would use. The route names bytes that are not + // installed any more; this build declares the shape on the proved host, so + // it continues the conversation rather than being asked to account for its + // predecessor. const harness = createFakeRuntime(); const trace = newTrace(); const seen = boundaries(); const routes = countedRoutes(seen); - yield* routes.publish( - bound({ - schema: "executable-build.v1", - reportedVersion: PROVED_VERSION, - executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, - }), - ); + const published = bound({ + schema: "executable-build.v1", + reportedVersion: PROVED_VERSION, + executableDigest: { algorithm: "sha256", value: "e".repeat(64) }, + }); + yield* routes.publish(published); yield* installLaunchStack(harness, trace, { adapters: { claude: countedAdapter(seen) }, routeStore: routes, }); - const failure = yield* attempt(trace, INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - expect(failure?.class).toBe("executable-binding-refused"); - expect(seen.resumes).toBe(0); - expect(trace.launches).toEqual([]); + expect(trace.records.some((record) => record.failure)).toBe(false); + // The exact retained identity, resumed rather than recreated, and no + // second identity reached for. + expect([seen.resumes, seen.creates, seen.allocations]).toEqual([1, 0, 0]); + expect(trace.launches[0]!.command).toEqual([OBSERVED_PATH, "--resume", ALLOCATED]); + // The audit evidence is what it was, and nothing published over it. + expect(seen.published.length).toBe(1); + expect(yield* routes.read(KEY)).toEqual(published); }); it("NP5b: a build whose release cannot be read is still admitted, and binds by digest", function* () { @@ -5024,28 +5116,28 @@ describe("Tier NP — proved native capability admissions", () => { expect(before).not.toContain(LATER_VERSION); }); - it("NP5d: a retained release must still be reproduced, and a digest decides regardless", function* () { - // The two halves of the asymmetry. A route that named a release is a claim - // the live build has to still make: one that reports another, or none this - // adapter can read, is not the build that history belongs to. And the - // digest is the one that always decides — a build whose shape is admitted - // and whose release matches is still refused when it is a different build. + it("NP5d: no way a live build can differ from the retained one stops the resume", function* () { + // Every shape the difference can take, because the rule is that the + // comparison is not made at all — not that some differences are tolerated. + // A later release, a release this adapter cannot read, other bytes under + // the same release, other bytes where the route named no release: each is + // admitted on its own and each resumes the one identity there is. for (const [name, retained, live] of [ [ - "a live build reporting another release", + "a live build reporting a later release", OBSERVED_BUILD, - observation({ version: `${LATER_VERSION}\n` }), + observation({ version: `${LATER_RELEASE}\n` }), ], ["a live build whose release cannot be read", OBSERVED_BUILD, observation({ version: "" })], [ - "a different build, admitted and reporting the retained release", + "a different build reporting the release the route retained", OBSERVED_BUILD, - observation({ digest: "b".repeat(64) }), + observation({ digest: LATER_DIGEST }), ], [ - "a different build, admitted, where the route named no release", + "a different build where the route named no release", DIGEST_ONLY_BUILD, - observation({ digest: "b".repeat(64) }), + observation({ digest: LATER_DIGEST }), ], ] as const) { yield* scoped(function* () { @@ -5061,17 +5153,18 @@ describe("Tier NP — proved native capability admissions", () => { observer: createFakeObserver(live).observer, }); - const failure = yield* attempt(trace, INSTRUCTIONS); + yield* launch(INSTRUCTIONS); - expect([name, failure?.class]).toEqual([name, "executable-binding-refused"]); - expect([name, seen.resumes + seen.creates]).toEqual([name, 0]); - expect([name, harness.ensureCalls]).toEqual([name, []]); - expect([name, trace.launches]).toEqual([name, []]); + expect([name, trace.records.some((record) => record.failure)]).toEqual([name, false]); + // Resumed under the retained identity; nothing created, nothing allocated. + expect([name, [seen.resumes, seen.creates, seen.allocations]]).toEqual([name, [1, 0, 0]]); + expect([name, trace.launches[0]?.command]).toEqual([ + name, + [live.path, "--resume", ALLOCATED], + ]); + // The durable account is untouched: one publication, byte-identical. expect([name, seen.published.length]).toEqual([name, 1]); expect([name, JSON.stringify(yield* routes.read(KEY))]).toEqual([name, before]); - // The refusal says which releases are involved and never the digest. - expect([name, failure?.message.includes("a".repeat(64))]).toEqual([name, false]); - expect([name, failure?.message.includes("b".repeat(64))]).toEqual([name, false]); }); } }); @@ -5349,3 +5442,379 @@ describe("Tier NP — proved native capability admissions", () => { expect(JSON.stringify(yield* routes.read(KEY))).toBe(before); }); }); + +/** + * Tier XR — one session across two releases + * (specs/native-agent-session-launch-spec.md §Cross-release continuation). + * + * A session opened by 2.1.261 and continued by 2.1.263. The retained binding is + * an account of the observation that opened it, written once and never again; + * what authorizes acting on that session now is what the executable running now + * independently proves — the route's stable protocol, the capability the work + * needs, the shape it declares, and this host. The two are never held to each + * other, which is the whole of what these cases discriminate: every one of them + * fails under a rule that compares them, and none of them is a fixture change. + * + * What that must not cost is the fail-closed boundary. A release changing is not + * a protocol changing, so the last cases here take the same 2.1.263 build to a + * host that proved something else, and to a journal that disagrees with its + * route about the build history it retained. + */ +describe("Tier XR — one session across two releases", () => { + const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; + /** What a candidate would allocate if this run ever reached for an identity. */ + const CANDIDATE = "beef0000-1111-2222-3333-444444444444"; + + /** The audit evidence 2.1.261 wrote when it opened the session. */ + const RETAINED_BUILD: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "a".repeat(64) }, + }; + + /** The 2.1.263 build installed now: other bytes, other release, other path. */ + function live(overrides: { version?: string | false } = {}): FakeObservation { + return observation({ + path: LATER_PATH, + digest: LATER_DIGEST, + version: overrides.version === undefined ? `${LATER_RELEASE}\n` : overrides.version, + }); + } + + const KEY = { provider: "acpx", agent: AGENT_COMMAND, sessionKey: SESSION_KEY }; + + interface Seen { + allocations: number; + creates: number; + resumes: number; + published: AgentSessionRoute[]; + } + + function seen(): Seen { + return { allocations: 0, creates: 0, resumes: 0, published: [] }; + } + + /** The adapter, with every identity-bearing act it can perform counted. */ + function countedAdapter(counts: Seen): NativeAdapter { + return { + launcher: "claude", + protocol: CLAUDE_PROTOCOL, + identity: "client-allocated", + binding: TEST_BINDING, + allocate: () => { + counts.allocations += 1; + return CANDIDATE; + }, + create: (nativeSessionId: string, instructionFile: string) => { + counts.creates += 1; + return ["claude", "--session-id", nativeSessionId, "--system-prompt-file", instructionFile]; + }, + resume: (nativeSessionId: string) => { + counts.resumes += 1; + return ["claude", "--resume", nativeSessionId]; + }, + }; + } + + function countedRoutes(counts: Seen, inner = createMemorySessionRouteStore()) { + return { + read: (key: Parameters[0]) => inner.read(key), + *publish(candidate: AgentSessionRoute) { + counts.published.push(candidate); + return yield* inner.publish(candidate); + }, + } satisfies AgentSessionRouteStore; + } + + /** The route 2.1.261 published, which no run after it may rewrite. */ + function route(binding: ExecutableBuildBindingV1 = RETAINED_BUILD): AgentSessionRoute { + return { + schema: "session-route.v2", + route: "client-native", + provider: "acpx", + agent: AGENT_COMMAND, + sessionKey: SESSION_KEY, + nativeSessionId: ALLOCATED, + identityProvenance: "client-allocated", + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + launcher: "claude", + executableBinding: binding, + }; + } + + /** The journal 2.1.261 left, agreeing with that route exactly. */ + function prepared(overrides: Partial = {}): PreparedLaunchRecord { + return { + phase: "prepared", + agent: "claude", + sessionKey: SESSION_KEY, + provider: "acpx", + nativeSessionId: ALLOCATED, + sessionState: "created", + instructionChannel: "claude.systemPromptFile", + instructionReconciliation: "installed", + identityProvenance: "client-allocated", + executableBinding: RETAINED_BUILD, + instructionsDigest: createHash("sha256").update(INSTRUCTIONS).digest("hex"), + instructions: INSTRUCTIONS, + cwd: CWD, + additionalDirectories: [], + permissionMode: "approve-reads", + launcher: "claude", + ...overrides, + }; + } + + interface Continued { + harness: FakeRuntimeHarness; + trace: Trace; + routes: AgentSessionRouteStore; + observer: FakeObserverHarness; + counts: Seen; + } + + /** + * The published 2.1.261 session, with 2.1.263 the executable under the command. + * + * Everything else is the ordinary installation, so what each case varies is + * the one thing it is about. + */ + function* continuing( + options: { + published?: AgentSessionRoute; + observation?: FakeObservation; + routes?: AgentSessionRouteStore; + nativeCapabilityPolicy?: NativeCapabilityPolicy | false; + } = {}, + ): Operation { + const counts = seen(); + const harness = createFakeRuntime(); + const trace = newTrace(); + const routes = options.routes ?? countedRoutes(counts); + const observer = createFakeObserver(options.observation ?? live()); + yield* routes.publish(options.published ?? route()); + yield* installLaunchStack(harness, trace, { + adapters: { claude: countedAdapter(counts) }, + routeStore: routes, + observer: observer.observer, + ...(options.nativeCapabilityPolicy === undefined + ? {} + : { nativeCapabilityPolicy: options.nativeCapabilityPolicy }), + }); + return { harness, trace, routes, observer, counts }; + } + + /** Everything a continuation must leave exactly as 2.1.261 wrote it. */ + function* unchanged(space: Continued, published: AgentSessionRoute = route()): Operation { + // The account of how this session started, read back whole: the identity + // it names, and the build that opened it, neither converted nor supplemented + // by the release that is continuing it. + expect(yield* space.routes.read(KEY)).toEqual(published); + // And read as the one publication this case made before the provider + // existed, so "unchanged" is not a rewrite that happened to agree. + expect(space.counts.published).toEqual([published]); + expect(space.counts.allocations).toBe(0); + } + + it("XR1: a native resume runs the installed release under the retained identity", function* () { + const space = yield* continuing(); + + yield* launch(INSTRUCTIONS); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + // The one identity this session has, resumed by the executable installed + // now — not the path the route's build was found at. + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + yield* unchanged(space); + }); + + it("XR2: an ACP attachment joins through the installed release", function* () { + const space = yield* continuing(); + + const session = yield* Agent.operations.session(); + + // Attachment is admitted on its own capability, and the identity that + // crosses is the route's — required back from the provider before a turn. + expect(session.agentSessionId).toBe(ALLOCATED); + expect(space.harness.ensureCalls.map((call) => call.resumeSessionId)).toEqual([ALLOCATED]); + expect(space.harness.createdOptions.map((options) => options.agentProcessEnv)).toEqual([ + { CLAUDE_CODE_EXECUTABLE: LATER_PATH }, + ]); + yield* unchanged(space); + }); + + it("XR3: a prepared-only replay creates under the retained identity", function* () { + // The predecessor never detached, so creation may still be owed. It is + // owed under the identity 2.1.261 allocated, performed by 2.1.263, with an + // instruction file this run wrote. + const space = yield* continuing(); + space.trace.replay = { prepared: prepared(), suffix: "prepared" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + const command = space.trace.launches[0]!.command; + expect(command[0]).toBe(LATER_PATH); + expect(command).toContain("--session-id"); + expect(command).toContain(ALLOCATED); + expect(command).not.toContain("--resume"); + expect([space.counts.creates, space.counts.resumes]).toEqual([1, 0]); + yield* unchanged(space); + }); + + it("XR4: a detached replay resumes, and never falls back to creating", function* () { + // The journal proves the session was already handed to a native process, + // so there is a conversation under this identity. A creation here would + // open a second one, and no change of release makes that the fallback. + const space = yield* continuing(); + space.trace.replay = { prepared: prepared(), suffix: "prepared+detached" }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + expect(space.harness.ensureCalls).toEqual([]); + yield* unchanged(space); + }); + + it("XR5: a live build that reports no release continues just the same", function* () { + // The version query is evidence beside the digest, not a gate, and a build + // that will not answer it says nothing about the session either way. Both + // continuations, so neither seam is quietly reading the release. + const space = yield* continuing({ observation: live({ version: false }) }); + + yield* launch(INSTRUCTIONS); + const session = yield* Agent.operations.session(); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect(session.agentSessionId).toBe(ALLOCATED); + yield* unchanged(space); + }); + + it("XR6: a compatible winner is adopted with the evidence it was written with", function* () { + // What losing a publication race looks like from inside the loser: it read + // no route, so it prepared one of its own, and the store handed back the + // account that got there first. That winner records a build this run never + // observed. It is adopted whole — its identity and its audit evidence — + // because the winner is the session, and this run has already admitted the + // executable it is about to run. + const inner = createMemorySessionRouteStore(); + const routes: AgentSessionRouteStore = { + // The read this invocation made happened before the winner existed, and + // a compare-and-set publication is where it finds out otherwise. + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + publish: (candidate) => inner.publish(candidate), + }; + const space = yield* continuing({ routes, published: route() }); + + yield* launch(INSTRUCTIONS); + + expect(space.trace.records.some((record) => record.failure)).toBe(false); + // The winner's identity, resumed — never the one this run allocated. + expect(space.trace.launches.map((request) => request.command)).toEqual([ + [LATER_PATH, "--resume", ALLOCATED], + ]); + expect(JSON.stringify(space.trace.launches)).not.toContain(CANDIDATE); + expect([space.counts.resumes, space.counts.creates]).toEqual([1, 0]); + // The winner's own binding survived the adoption, unrewritten. + const adopted = space.trace.records[0] as PreparedLaunchRecord; + expect(adopted.nativeSessionId).toBe(ALLOCATED); + expect(adopted.executableBinding).toEqual(RETAINED_BUILD); + expect(yield* inner.read(KEY)).toEqual(route()); + }); + + it("XR7: a release changing is not a protocol changing", function* () { + // The fail-closed half. Continuation asks the installed executable to prove + // the route's protocol, the capability, the shape and this host — so a + // 2.1.263 build that is admitted for something else, or on some other + // machine, continues nothing. Both seams, because they are admitted apart. + for (const [name, policy] of [ + ["another adapter protocol", admitting("some-other-native.v1")], + ["another probe profile", admitting(CLAUDE_PROTOCOL, "some-other-help.v1")], + ["another platform", { ...admitting(), host: { platform: "linux", architecture: "arm64" } }], + [ + "another architecture", + { ...admitting(), host: { platform: "darwin", architecture: "x64" } }, + ], + ["a host that proved nothing", false], + ] as const) { + yield* scoped(function* () { + const space = yield* continuing({ nativeCapabilityPolicy: policy }); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect([name, failure?.class]).toEqual([name, "unsupported-capability"]); + expect([name, raised === undefined]).toEqual([name, false]); + // In front of every live boundary, on both paths. + expect([name, space.trace.launches]).toEqual([name, []]); + expect([name, space.harness.ensureCalls]).toEqual([name, []]); + expect([name, space.harness.createdOptions]).toEqual([name, []]); + expect([name, [space.counts.resumes, space.counts.creates]]).toEqual([name, [0, 0]]); + yield* unchanged(space); + }); + } + }); + + it("XR8: a journal and route that disagree about the build refuse before the live phase", function* () { + // The one place two build accounts are still held to each other, and the + // reason it survives: these are two durable records of a single + // observation, so a difference between them is a replay that cannot say + // which session it is resuming. Asked in both directions, because a record + // saying less than its route has lost evidence just as surely as one saying + // something else. + const quiet: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + executableDigest: RETAINED_BUILD.executableDigest, + }; + const elsewhere: ExecutableBuildBindingV1 = { + schema: "executable-build.v1", + reportedVersion: RETAINED_RELEASE, + executableDigest: { algorithm: "sha256", value: "f".repeat(64) }, + }; + const disagreements: [string, ExecutableBuildBindingV1, ExecutableBuildBindingV1][] = [ + ["a journal naming other bytes", RETAINED_BUILD, elsewhere], + ["a journal that named no release", RETAINED_BUILD, quiet], + ["a route that named no release", quiet, RETAINED_BUILD], + ]; + + for (const suffix of ["prepared", "prepared+detached"] as const) { + for (const [name, retained, journalled] of disagreements) { + const label = `${suffix}/${name}`; + yield* scoped(function* () { + const published = route(retained); + const space = yield* continuing({ published }); + space.trace.replay = { + prepared: prepared({ executableBinding: journalled }), + suffix, + }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failed = space.trace.records.findLast((record) => record.failure); + expect([label, failed?.failure?.class]).toEqual([label, "identity-unavailable"]); + expect([label, space.trace.launches]).toEqual([label, []]); + expect([label, [space.counts.resumes, space.counts.creates]]).toEqual([label, [0, 0]]); + yield* unchanged(space, published); + }); + } + } + }); +}); diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index d8a3b57ec..f29f48b77 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -125,27 +125,34 @@ export interface ExecutableBuildBindingV1 { } /** - * Whether `live` is the build `retained` names. + * Whether `observed` is the build `retained` names. * * Asymmetric, because the two arguments are not the same kind of claim. The * digest decides: identical bytes are the same build reached through whatever * path, and different bytes are a different build however it describes itself. * - * A retained version is then a claim the live build must still make. A record + * A retained version is then a claim the other account must still make. One * that named a release and now meets one calling itself something else — or - * nothing at all — has lost the evidence it was written with. A record that - * named none never had that evidence, so a version appearing later adds - * nothing to reproduce and does not rewrite what was retained. + * nothing at all — has lost the evidence it was written with. One that named + * none never had that evidence, so a version appearing later adds nothing to + * reproduce and does not rewrite what was retained. + * + * This says which build an account describes, and nothing about what an + * installed executable can do. Continuation is not decided by asking it about a + * live observation — a session crosses releases on a fresh capability admission + * instead. What it is for is holding two durable accounts of one preparation to + * each other, which is exact when asked in both directions. */ export function sameExecutableBuild( retained: ExecutableBuildBindingV1, - live: ExecutableBuildBindingV1, + observed: ExecutableBuildBindingV1, ): boolean { return ( - retained.schema === live.schema && - retained.executableDigest.algorithm === live.executableDigest.algorithm && - retained.executableDigest.value === live.executableDigest.value && - (retained.reportedVersion === undefined || retained.reportedVersion === live.reportedVersion) + retained.schema === observed.schema && + retained.executableDigest.algorithm === observed.executableDigest.algorithm && + retained.executableDigest.value === observed.executableDigest.value && + (retained.reportedVersion === undefined || + retained.reportedVersion === observed.reportedVersion) ); } From d1be015ac884daf4fa9c404cab44c1e8e0332869 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 14:58:47 -0400 Subject: [PATCH 57/62] =?UTF-8?q?=F0=9F=94=92=20Pin=20a=20V2=20route's=20n?= =?UTF-8?q?ative=20protocol=20to=20its=20route=20contract=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A V2 construction route fixes the stable protocol its identity was published under through its provider, agent and launcher contract. Continuation read that protocol off whatever adapter happened to be registered under the launcher instead, so an adapter declaring another protocol — beside a host policy that had proved exactly that protocol, for that capability, in that shape, on this machine — could adopt a session it never constructed. ACP attachment also never held the route's retained launcher to the live adapter at all. The pin is compiled in and reachable through no host-supplied dependency, and it is asked before the executable is observed: which conversation this is was never a question about a build. Native resume, ACP attachment, concurrent-winner adoption and both incomplete replay phases now require the live adapter's launcher and protocol to be the ones the retained contract names, and the independent admission of the current executable's capability, shape and host follows it. A refusal observes nothing, allocates nothing, launches, ensures, prompts and constructs nothing, and leaves the route and the journal exactly as their first publication left them. Executable bindings stay what they became: immutable audit evidence, and a cross-check between two durable accounts of one observation — never a live same-build gate. The source contracts that still described them as one are reconciled. Evidence: XR9 takes an injected adapter speaking another protocol, with the host policy admitting that protocol, through native continuation, ACP attachment, both replay phases and concurrent-winner adoption, and asserts the executable is never even observed. XR10 refuses an attachment whose retained launcher is not the live adapter's. Planting each seam's absence back one at a time fails only that seam's case, and reading the pin off the adapter fails both cases and nothing else. --- packages/acp/src/native-launch.ts | 51 ++++++-- packages/acp/src/provider.ts | 94 +++++++++++++- packages/acp/src/session-route.ts | 17 ++- packages/acp/tests/native-launch.test.ts | 150 +++++++++++++++++++++-- packages/core/src/agent/launch.ts | 10 +- 5 files changed, 294 insertions(+), 28 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index 6bd737f30..df8725c7b 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -67,10 +67,12 @@ export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNati /** * What an adapter knows about the build behind its executable. * - * A session whose identity XMD chose only means something while the build that - * accepted it can be recognized later: two builds of one provider accept the - * same identity and disagree silently about what it names. Everything here is - * that adapter's private dialect — which command to observe, which read-only + * One observation answers two separate questions: what this run may do with the + * executable it is about to spawn, and what to write down once about the build + * that first accepted an identity. The first is the whole authorization — a + * capability is a claim about a build, asked fresh every time — and the second + * is audit evidence that is never asked again. Everything here is that + * adapter's private dialect — which command to observe, which read-only * questions to ask it, how to read the answers, and what the ACP adapter child * needs in order to run the same build. None of it reaches a document. */ @@ -506,12 +508,13 @@ export interface ClientAllocatedAdapter extends AdapterCommands { /** The argv that creates a session under `id` with that instruction layer. */ create(nativeSessionId: string, instructionFile: string): string[]; /** - * Which build of this adapter's executable a session is bound to. + * How this adapter's executable is observed, admitted and recorded. * - * Required, because an identity XMD chose is only meaningful beside the build - * that accepted it. The argv `create` and `resume` return still begins with - * the stable launcher name, which is what durable records carry; a run - * replaces that first member with the exact path it observed. + * Required, because an adapter that names its own sessions may not act on one + * until the build it would run has been admitted for what it is about to do. + * The argv `create` and `resume` return still begins with the stable launcher + * name, which is what durable records carry; a run replaces that first member + * with the exact path it observed. */ binding: NativeBinding; } @@ -598,6 +601,36 @@ const ADAPTERS: Readonly> = { }, }; +/** + * The stable protocol each published client-native route contract fixes. + * + * Written here, compiled in, and reachable through no dependency a host can + * supply — because it is the one thing about a route that a later installation + * may not answer. Everything else on the live side is discovered: which adapter + * is registered, which executable is found, what it declares. If the protocol + * were discovered too, then registering an adapter under the same launcher name + * would be enough to adopt a conversation constructed by something else, and a + * host policy that admitted the newcomer's own protocol would call that proved. + * + * Keyed by the launcher because that is the durable member of the contract that + * names an implementation. A route's provider says which provider published it + * and its agent is the command it is filed under; those are matched by the + * caller that read the record, and this answers the remaining question of what + * the thing behind that launcher was speaking at the time. + * + * An absent entry is a refusal, not a default. There is no protocol migration: + * a different protocol needs a route contract that names it, so a launcher this + * build has fixed no protocol for is one whose sessions it cannot continue. + */ +const ROUTE_PROTOCOLS: Readonly> = { + claude: "claude-client-native.v1", +}; + +/** The protocol a client-native route naming `launcher` was published under. */ +export function pinnedRouteProtocol(launcher: string): string | undefined { + return Object.hasOwn(ROUTE_PROTOCOLS, launcher) ? ROUTE_PROTOCOLS[launcher] : undefined; +} + /** * The adapters this host will consider for native launch at all. * diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index feab8df16..784ff67f5 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -60,7 +60,11 @@ import type { import { allocatesIdentity } from "./native-launch.ts"; import type { ClientAllocatedAdapter, NativeBinding } from "./native-launch.ts"; import { AgentSessionRouteError } from "./session-route.ts"; -import type { AgentSessionRoute, AgentSessionRouteStore } from "./session-route.ts"; +import type { + AgentSessionRoute, + AgentSessionRouteStore, + AgentSessionRouteV2, +} from "./session-route.ts"; import { createAcpRuntime, createAgentRegistry, createRuntimeStore } from "./acpx-runtime.ts"; import type { AcpAgentRegistry, @@ -102,6 +106,7 @@ import { ADVERTISED_NATIVE_LAUNCH, knownNativeAdapters, nativeAdapterFor, + pinnedRouteProtocol, } from "./native-launch.ts"; import type { NativeAdapter } from "./native-launch.ts"; import { admitsNativeCapability } from "./native-capability.ts"; @@ -1811,6 +1816,54 @@ function* useAcpxProviderState( }; } + /** + * Whether the adapter this run would use is the one a published V2 route + * names. + * + * Asked before the executable is observed at all, because it is not a question + * about a build. A route fixes the protocol its identity was published under + * through its provider, agent and launcher contract, and that fixing is not + * open to reinterpretation: what is installed under the launcher tomorrow does + * not get to say what yesterday's conversation was. So the retained launcher + * has to be this adapter's, and the protocol this adapter speaks has to be the + * one that contract fixes. + * + * Deliberately not read off the adapter. An adapter is registered, and a + * registration is a live fact — accepting whatever protocol it declares would + * let anything filed under this launcher adopt the session, and a host policy + * that had proved that newcomer's own protocol would then call it admitted. + * The pin is compiled in, so the admission below answers "can this build do + * the thing" for the protocol the session already has rather than for whatever + * protocol happens to be asking. + * + * A refusal here reads nothing further and writes nothing at all: the route + * and the journal keep the identity and the build evidence they were published + * with, and no identity, private file, child, ensure or turn follows. + */ + function admitRouteContract( + sessionKey: string, + route: AgentSessionRouteV2, + adapter: NativeAdapter, + ): LaunchFailure | undefined { + const pinned = + route.provider === ACPX_PROVIDER ? pinnedRouteProtocol(route.launcher) : undefined; + if ( + pinned !== undefined && + adapter.launcher === route.launcher && + adapter.protocol === pinned + ) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `session "${sessionKey}" was constructed under a native protocol this run's adapter ` + + `does not speak, and a construction route is never reinterpreted by whatever is ` + + `installed under its launcher later. Continue it with the adapter it was constructed ` + + `under, or name a different .`, + }; + } + /** * What a client-native route's provider arrangement already asserts. * @@ -1893,6 +1946,15 @@ function* useAcpxProviderState( }); } const attaching = adapterFor(agentName) as ClientAllocatedAdapter; + // Which conversation this is, before anything about which build is + // installed. Joining through ACP acts on the session a native process was + // handed, so the adapter doing the joining has to be the one that contract + // names — a different launcher, or a different protocol behind the same + // launcher, is a different conversation whatever this host has proved. + const foreign = admitRouteContract(prepared.sessionKey, route, attaching); + if (foreign) { + throw new AttachmentRefused(foreign); + } const build = yield* observeBuild( agentName, agentCommand, @@ -2410,6 +2472,17 @@ function* useAcpxProviderState( return retained(agentName, adapter, route, instructions, sessionCwd, "resumed"); } + // A published route settles which protocol this session is, and it settles + // it before any question about builds is asked. Refused here, nothing has + // been observed, allocated or written, and the route still says exactly what + // its first publication said. + if (route?.route === "client-native" && route.schema === "session-route.v2") { + const foreign = admitRouteContract(sessionKey, route, adapter); + if (foreign) { + return refusal(foreign.class, foreign.message, known); + } + } + // Observed before an identity exists, so a build this run cannot name stops // the launch before anything durable is written. let build: BoundBuild; @@ -2488,6 +2561,16 @@ function* useAcpxProviderState( return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } + // The winner is the account that governs, so it is the account whose + // protocol this adapter has to speak. A concurrent publication that reached + // the namespace first describes a session this launch did not construct, and + // adopting it is acting on it: the same contract check the read above makes, + // asked again of the record that actually won. + const foreign = admitRouteContract(sessionKey, winner, adapter); + if (foreign) { + return refusal(foreign.class, foreign.message, known); + } + // The record is built from the winner rather than from the candidate — its // identity and its binding alike — so the two durable accounts agree by // construction rather than by comparison. A winner published from a @@ -3065,6 +3148,15 @@ function* useAcpxProviderState( `so the conversation it prepared cannot be confirmed`, }; } + // Still ahead of the observation, and ahead of every phase a replay could + // act with. A replay resumes a conversation someone else's process may + // already be in, so what it must establish first is that this adapter is + // what that conversation was constructed under — not that some adapter under + // this launcher is admitted for something. + const foreign = admitRouteContract(prepared.sessionKey, route, adapter); + if (foreign) { + return foreign; + } let build: BoundBuild; try { build = yield* observeBuild(prepared.agent, agentCommand, adapter.protocol, adapter.binding); diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index d20cfa06e..eed913f8a 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -64,9 +64,20 @@ export type AgentSessionRouteV1 = * The exact V2 record, which exists only for `client-native`. * * V2 adds the one fact V1 never had: which build of the provider executable - * accepted the identity XMD chose. That fact is what lets a later ACP - * attachment know it is talking to the same build the native UI is in, so it - * is required rather than optional here. + * accepted the identity XMD chose. Required rather than optional, because it is + * what makes this record a complete account of that publication — audit + * evidence, and the thing a prepared journal derived from it must still agree + * with exactly. It is never compared with a build observed later: a release + * that changed under the same launcher continues this session once it is + * admitted on its own, and one that no longer implements the operation is + * refused even if it is byte-for-byte the build that opened it. + * + * What this record does fix is the protocol. The exact provider, agent and + * launcher contract it carries names the stable native protocol its identity + * was published under, and no member here can be reinterpreted by whatever is + * installed under that launcher afterwards. A different protocol needs a route + * contract that names it; there is no migration, and this record is never + * rewritten into one. * * There is no V2 `acp-first`. ACP-first construction gained no fact, so a * second schema for it would be a version number with nothing behind it. diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index 5fc997bc0..a92d4d82e 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -3409,10 +3409,12 @@ describe("Tier CA — client-native attachment", () => { * Tier RT — one runtime per agent command and build * (specs/acp-client-spec.md §ACPX provider). * - * A child running the wrong build accepts the session identity and disagrees - * silently about what it names, so sessions bound to different builds never - * share one. A partition holds a live executable path for the work it owns, and - * when the last handle it made closes it is gone rather than kept for nobody. + * A partition holds a live executable path and the child running it, so what may + * share one is decided by the observation serving the work now — never by what a + * route retained. Sessions whose routes record different builds share a child + * when one installed release serves them both, and two live releases never do. + * When the last handle a partition made closes it is gone rather than kept for + * nobody. */ describe("Tier RT — bound runtime partitions", () => { const FIRST = "aaaaaaaa-1111-2222-3333-444444444444"; @@ -5457,8 +5459,11 @@ describe("Tier NP — proved native capability admissions", () => { * * What that must not cost is the fail-closed boundary. A release changing is not * a protocol changing, so the last cases here take the same 2.1.263 build to a - * host that proved something else, and to a journal that disagrees with its - * route about the build history it retained. + * host that proved something else, to a journal that disagrees with its route + * about the build history it retained, and to an adapter registered under this + * launcher speaking a protocol the route never named — that last one with the + * host's policy admitting exactly what the newcomer declares, so the only thing + * left refusing is the contract the route itself fixes. */ describe("Tier XR — one session across two releases", () => { const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; @@ -5494,11 +5499,27 @@ describe("Tier XR — one session across two releases", () => { return { allocations: 0, creates: 0, resumes: 0, published: [] }; } - /** The adapter, with every identity-bearing act it can perform counted. */ - function countedAdapter(counts: Seen): NativeAdapter { + /** + * A protocol no route contract in this build fixes, and a launcher no adapter + * here shares with the route below. + */ + const FOREIGN_PROTOCOL = "claude-fork-native.v1"; + const FOREIGN_LAUNCHER = "codex"; + + /** + * The adapter, with every identity-bearing act it can perform counted. + * + * `contract` is what a host registered under this agent name declares about + * itself. Varying it is how a case asks whether a live declaration is allowed + * to answer what a published route already settled. + */ + function countedAdapter( + counts: Seen, + contract: { launcher?: string; protocol?: string } = {}, + ): NativeAdapter { return { - launcher: "claude", - protocol: CLAUDE_PROTOCOL, + launcher: contract.launcher ?? "claude", + protocol: contract.protocol ?? CLAUDE_PROTOCOL, identity: "client-allocated", binding: TEST_BINDING, allocate: () => { @@ -5585,6 +5606,7 @@ describe("Tier XR — one session across two releases", () => { observation?: FakeObservation; routes?: AgentSessionRouteStore; nativeCapabilityPolicy?: NativeCapabilityPolicy | false; + adapter?: { launcher?: string; protocol?: string }; } = {}, ): Operation { const counts = seen(); @@ -5594,7 +5616,7 @@ describe("Tier XR — one session across two releases", () => { const observer = createFakeObserver(options.observation ?? live()); yield* routes.publish(options.published ?? route()); yield* installLaunchStack(harness, trace, { - adapters: { claude: countedAdapter(counts) }, + adapters: { claude: countedAdapter(counts, options.adapter) }, routeStore: routes, observer: observer.observer, ...(options.nativeCapabilityPolicy === undefined @@ -5817,4 +5839,110 @@ describe("Tier XR — one session across two releases", () => { } } }); + + it("XR9: an adapter speaking another protocol continues nothing, however well proved", function* () { + // The route fixes the protocol its identity was published under, and no + // amount of live evidence reopens that. Here the host has proved this + // adapter's own protocol, for both capabilities, in the shape the installed + // executable declares, on the machine actually running — everything an + // admission asks for, all of it about the wrong protocol. So the policy is + // saying yes and the refusal is the pin, which is what makes this case + // discriminate the production contract rather than a mismatched host. + const foreign = { + adapter: { protocol: FOREIGN_PROTOCOL }, + nativeCapabilityPolicy: admitting(FOREIGN_PROTOCOL), + } as const; + + yield* scoped(function* () { + const space = yield* continuing(foreign); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + // Ahead of the observation, not after it. Which conversation this is was + // never a question about the build, so the build is never asked. + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); + + yield* scoped(function* () { + const space = yield* continuing(foreign); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect(raised === undefined).toBe(false); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); + + for (const suffix of ["prepared", "prepared+detached"] as const) { + yield* scoped(function* () { + const space = yield* continuing(foreign); + space.trace.replay = { prepared: prepared(), suffix }; + + yield* Agent.operations.launch(launchRequest(INSTRUCTIONS)); + + const failed = space.trace.records.findLast((record) => record.failure); + expect([suffix, failed?.failure?.class]).toEqual([suffix, "unsupported-capability"]); + expect([suffix, space.trace.launches]).toEqual([suffix, []]); + expect([suffix, [space.counts.resumes, space.counts.creates]]).toEqual([suffix, [0, 0]]); + expect([suffix, space.observer.observed]).toEqual([suffix, []]); + yield* unchanged(space); + }); + } + + yield* scoped(function* () { + // Adoption, where the route arrives after the read that found none. The + // winner is the account that governs, so it is the account this adapter + // has to have been constructed under — and it was not. + const inner = createMemorySessionRouteStore(); + const routes: AgentSessionRouteStore = { + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + publish: (candidate) => inner.publish(candidate), + }; + const space = yield* continuing({ ...foreign, routes, published: route() }); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); + // The winner kept its identity and the evidence it was written with. + expect(yield* inner.read(KEY)).toEqual(route()); + }); + }); + + it("XR10: an attachment refuses a route another launcher constructed", function* () { + // Attachment acts on the conversation a native process was handed, so the + // route's launcher is as much a part of what it may join as its identity is. + // The protocol this adapter declares is the pinned one, so nothing but the + // launcher is refusing. + const space = yield* continuing({ adapter: { launcher: FOREIGN_LAUNCHER } }); + + let raised: Error | undefined; + try { + yield* Agent.operations.session(); + } catch (error) { + raised = error as Error; + } + + expect(raised === undefined).toBe(false); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + expect(space.observer.observed).toEqual([]); + yield* unchanged(space); + }); }); diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index f29f48b77..e94316cd8 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -101,10 +101,12 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; /** * Which build of a provider executable a session was established against. * - * A client-allocated session is only meaningful while the build that created - * it can be reproduced. Two builds of the same provider accept the same - * identity and disagree silently about what it names, so a session whose build - * cannot be reproduced is refused rather than resumed. + * Written once, when the identity is first published, and never rewritten. It + * is audit evidence about a past observation and a cross-check between the two + * durable accounts of that one observation — never a gate on the build + * installed now. Whether a session may be continued is decided by admitting the + * live executable on its own terms; which build accepted the identity first is + * history, and history is not a capability. * * What is retained is deliberately not a path: a path says where a build was, * which stops being true, while a digest says which build it was, which does From 8a736ab22b7a430591fd57221a8be8826e1d9e27 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 15:21:58 -0400 Subject: [PATCH 58/62] =?UTF-8?q?=F0=9F=94=92=20Settle=20a=20client-native?= =?UTF-8?q?=20route's=20protocol=20before=20the=20race=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An empty route read settles nothing: a concurrent publication is only revealed by publishing. Until now a run whose read found no route fell straight through to observing the executable, allocating an identity and writing a candidate, and asked whether it could account for the protocol only after meeting the winner — having already acted on behalf of a session it was never entitled to construct. The pin is now asked twice from one reading. Prospectively, before any observation or allocation, against the live adapter's launcher; and again of the record that actually won, whose route may differ from the candidate's. The legacy V1 path is untouched. Reconcile the remaining same-build continuation wording across the touched source set. A retained binding is immutable audit evidence and is only ever held to the prepared journal derived from the same observation, never to a build installed later. --- packages/acp/src/native-launch.ts | 4 +- packages/acp/src/provider.ts | 127 +++++++++++++++++----- packages/acp/src/session-route.ts | 11 +- packages/acp/tests/native-launch.test.ts | 94 +++++++++++++++- packages/core/src/agent/launch-journal.ts | 10 +- packages/core/src/agent/launch.ts | 36 ++++-- 6 files changed, 226 insertions(+), 56 deletions(-) diff --git a/packages/acp/src/native-launch.ts b/packages/acp/src/native-launch.ts index df8725c7b..3aa30bc04 100644 --- a/packages/acp/src/native-launch.ts +++ b/packages/acp/src/native-launch.ts @@ -77,7 +77,7 @@ export type NativeCapabilityProbe = (metadata: ExecutableMetadata) => ProbedNati * needs in order to run the same build. None of it reaches a document. */ export interface NativeBinding { - /** The command whose build is observed, bound and retained. */ + /** The command whose build is observed, admitted and recorded. */ command: string; /** * The read-only questions one observation asks that exact file. @@ -448,7 +448,7 @@ function claudeNativeProbe(pinnedBridge: string | undefined): NativeCapabilityPr * two or more is output it cannot read as one answer, and taking the first * would be picking a build out of a list of them. Both mean no version was * reported, which is an ordinary answer rather than a refusal — the digest is - * what binds the build. + * what names the build. */ function claudeReportedVersion(metadata: ExecutableMetadata): string | undefined { const output = answered(metadata, "version"); diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index 784ff67f5..4d2106795 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -337,7 +337,9 @@ export interface AcpxProviderDependencies { } /** - * One observed build, ready to be bound to a session. + * One observed build: what this run may do with the executable it is about to + * spawn, and the account it writes down if this observation is the one that + * first accepts an identity. * * `livePath` is the canonical path this run spawns and hands to the matching * ACP child through `environment`. It appears in no record, route, diagnostic, @@ -877,12 +879,16 @@ function* useAcpxProviderState( ); /** - * One ACP runtime per `(agent command, executable build)`, plus the unbound + * One ACP runtime per `(agent command, live executable)`, plus the unbound * one ordinary ACP-first work has always used. * - * Sessions established against different builds never share an ACP child. - * That is what observing a build is for: a child running the wrong Claude - * accepts the session identity and disagrees silently about what it names. + * Two live releases never share an ACP child. That is what observing a build + * is for here, and it is a fact about the executable this work runs rather + * than about the one a session was established against: a child is a running + * thing, and two of them running different Claudes accept the same session + * identity and disagree silently about what it names. Sessions whose durable + * accounts record different builds share this child freely once one installed + * release serves them both. */ let unbound: RuntimeEntry | undefined; const runtimes = new Map(); @@ -1756,7 +1762,8 @@ function* useAcpxProviderState( binding: { schema: "executable-build.v1", // Present only when this build said something the adapter recognized. - // A quiet build is bound by its bytes, which is what binds either way. + // A quiet build is named by its bytes, which is what names one either + // way. ...(version === undefined ? {} : { reportedVersion: version }), executableDigest: observed.digest, }, @@ -1816,6 +1823,55 @@ function* useAcpxProviderState( }; } + /** + * Whether this adapter speaks the protocol a V2 route naming its launcher + * fixes. + * + * The single reading of the pin, so "the protocol this session is" means one + * thing whether it is asked of a route that exists or of the route this run + * is about to publish. Deliberately not read off the adapter's own + * declaration: an adapter is registered, and a registration is a live fact — + * taking its word would let anything filed under this launcher speak for the + * launcher, and a host policy that had proved that newcomer's own protocol + * would then call it admitted. + */ + function speaksPinnedProtocol(adapter: NativeAdapter): boolean { + const pinned = pinnedRouteProtocol(adapter.launcher); + return pinned !== undefined && adapter.protocol === pinned; + } + + /** + * Whether this run may construct a V2 route under this adapter at all. + * + * Asked before the executable is observed, before an identity is allocated, + * and before anything is published — because a publication is the durable + * account of which conversation this is, and an adapter whose protocol this + * build fixes no route contract for cannot be the thing that account names. + * Deciding it afterwards would mean reaching a concurrent winner having + * already observed a build, allocated an identity and written a candidate for + * a session this run was never entitled to construct. + * + * A launcher with no compiled interpretation is refused rather than trusted. + * There is no protocol migration and no default: what a session is was fixed + * when it was published, and a build that fixes nothing for this launcher has + * no account of that to offer. + */ + function admitConstructionContract( + sessionKey: string, + adapter: NativeAdapter, + ): LaunchFailure | undefined { + if (speaksPinnedProtocol(adapter)) { + return undefined; + } + return { + class: "unsupported-capability", + message: + `session "${sessionKey}" would be constructed under a native protocol this build fixes ` + + `no construction route for, and a route it cannot interpret is one it must not publish. ` + + `Launch it with the adapter this build knows that launcher by.`, + }; + } + /** * Whether the adapter this run would use is the one a published V2 route * names. @@ -1828,14 +1884,6 @@ function* useAcpxProviderState( * has to be this adapter's, and the protocol this adapter speaks has to be the * one that contract fixes. * - * Deliberately not read off the adapter. An adapter is registered, and a - * registration is a live fact — accepting whatever protocol it declares would - * let anything filed under this launcher adopt the session, and a host policy - * that had proved that newcomer's own protocol would then call it admitted. - * The pin is compiled in, so the admission below answers "can this build do - * the thing" for the protocol the session already has rather than for whatever - * protocol happens to be asking. - * * A refusal here reads nothing further and writes nothing at all: the route * and the journal keep the identity and the build evidence they were published * with, and no identity, private file, child, ensure or turn follows. @@ -1845,12 +1893,10 @@ function* useAcpxProviderState( route: AgentSessionRouteV2, adapter: NativeAdapter, ): LaunchFailure | undefined { - const pinned = - route.provider === ACPX_PROVIDER ? pinnedRouteProtocol(route.launcher) : undefined; if ( - pinned !== undefined && + route.provider === ACPX_PROVIDER && adapter.launcher === route.launcher && - adapter.protocol === pinned + speaksPinnedProtocol(adapter) ) { return undefined; } @@ -1925,9 +1971,11 @@ function* useAcpxProviderState( return undefined; } if (route.schema === "session-route.v1") { - // Constructed before any build was recorded. A build observed now says - // which build is installed today, not which one established this - // conversation, so there is nothing to compare and nothing to attach to. + // Constructed under the native-only contract, which published no account + // of the build that accepted the identity. Attaching was released + // alongside that account and is not retroactive: writing one now from a + // build observed today would claim knowledge of a publication XMD never + // witnessed, so a legacy session stays a native-only one. throw new AttachmentRefused({ class: "executable-binding-refused", message: @@ -2391,9 +2439,15 @@ function* useAcpxProviderState( * The order is the contract, and every step happens while the coordinator * holds this session: * - * route + existing ACPX state -> refuse conversion -> allocate a UUID -> + * route + existing ACPX state -> refuse conversion -> settle which protocol + * this session is -> observe and admit the build -> allocate a UUID -> * publish or adopt the route -> retain a record that matches it exactly. * + * The protocol is settled first because it is the only step that says which + * conversation this is. Everything after it acts on that answer, and a run + * that reached the publication before asking would already have observed a + * build and reached for an identity on a session's behalf. + * * Nothing is created through ACP here. A client-native session is * materialized by the native process itself, which is why the route has to be * settled before that process exists: two accounts of one session, published @@ -2483,6 +2537,17 @@ function* useAcpxProviderState( } } + // And when the read found none, the same pin asked prospectively. The read + // is not the last word on whether this session already exists — a concurrent + // publication is revealed by publishing — so an adapter that could not + // account for a route it met cannot be allowed to observe a build, allocate + // an identity and write a candidate first and find that out afterwards. + // Refused here, the race has nothing of this run's in it to lose. + const unfixed = admitConstructionContract(sessionKey, adapter); + if (unfixed) { + return refusal(unfixed.class, unfixed.message, known); + } + // Observed before an identity exists, so a build this run cannot name stops // the launch before anything durable is written. let build: BoundBuild; @@ -2561,11 +2626,12 @@ function* useAcpxProviderState( return retained(agentName, adapter, winner, instructions, sessionCwd, "resumed"); } - // The winner is the account that governs, so it is the account whose - // protocol this adapter has to speak. A concurrent publication that reached - // the namespace first describes a session this launch did not construct, and - // adopting it is acting on it: the same contract check the read above makes, - // asked again of the record that actually won. + // The winner is the account that governs, and it is not this run's + // candidate: a publication that reached the namespace first describes a + // session this launch did not construct, under whatever contract its writer + // gave it. This adapter having a fixed protocol says nothing about that + // record, so the contract is asked again — of the route that actually won. + // Adopting one is acting on it. const foreign = admitRouteContract(sessionKey, winner, adapter); if (foreign) { return refusal(foreign.class, foreign.message, known); @@ -3115,9 +3181,10 @@ function* useAcpxProviderState( ); } // A launch that never got as far as the native process, prepared under a - // contract that recorded no build. Nothing here can show which build has - // this session's history, and resuming anyway would be answering the - // question by ignoring it. A completed launch never reaches this code. + // contract that published no account of the build that accepted the + // identity. The cross-check below is what makes an incomplete launch + // resumable, and with only one account there is nothing to hold to + // anything. A completed launch never reaches this code. if (route.schema === "session-route.v1" || prepared.executableBinding === undefined) { return { class: "executable-binding-refused", diff --git a/packages/acp/src/session-route.ts b/packages/acp/src/session-route.ts index eed913f8a..d3a417de8 100644 --- a/packages/acp/src/session-route.ts +++ b/packages/acp/src/session-route.ts @@ -162,10 +162,13 @@ function declared( /** * Read a retained build binding strictly. * - * The digest is required and exact — it is what binds a session to a build. - * A present `reportedVersion` must still be a real one: a member written as an - * empty string is a claim about a release nobody can reproduce, which is not - * the same as having made no claim. + * The digest is required and exact — it is what names the build this record + * describes. Reading it strictly is not a gate on the executable installed now: + * this account is audit evidence, and the only thing it is ever held to is the + * prepared journal derived from the same observation. A present + * `reportedVersion` must still be a real one: a member written as an empty + * string is a claim about a release nobody can reproduce, which is not the same + * as having made no claim. */ function parseExecutableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { if (!isRecord(value) || !declared(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { diff --git a/packages/acp/tests/native-launch.test.ts b/packages/acp/tests/native-launch.test.ts index a92d4d82e..27c3b0c41 100644 --- a/packages/acp/tests/native-launch.test.ts +++ b/packages/acp/tests/native-launch.test.ts @@ -5464,6 +5464,13 @@ describe("Tier NP — proved native capability admissions", () => { * launcher speaking a protocol the route never named — that last one with the * host's policy admitting exactly what the newcomer declares, so the only thing * left refusing is the contract the route itself fixes. + * + * The contract is asked twice, because a read that finds no route settles + * nothing: a publication is where a concurrent one is revealed. So it is asked + * prospectively, before a build is observed or an identity exists, and again of + * the record that actually won — and the two are discriminated apart, by a case + * that reaches the race with an adapter this build fixes nothing for and one + * that reaches it entitled to construct and meets someone else's account. */ describe("Tier XR — one session across two releases", () => { const ALLOCATED = "5eed0000-1111-2222-3333-444444444444"; @@ -5901,24 +5908,42 @@ describe("Tier XR — one session across two releases", () => { } yield* scoped(function* () { - // Adoption, where the route arrives after the read that found none. The - // winner is the account that governs, so it is the account this adapter - // has to have been constructed under — and it was not. + // The race, from the side that reads no route. An empty read is not a + // session nobody has — a concurrent publication is revealed by publishing + // — so a run that met this adapter here is a run that could not have + // accounted for whatever the publication returns. It is refused + // prospectively, and the whole point is what that costs: nothing at all. const inner = createMemorySessionRouteStore(); + const publications: AgentSessionRoute[] = []; const routes: AgentSessionRouteStore = { // deno-lint-ignore require-yield *read() { return undefined; }, - publish: (candidate) => inner.publish(candidate), + *publish(candidate) { + publications.push(candidate); + return yield* inner.publish(candidate); + }, }; const space = yield* continuing({ ...foreign, routes, published: route() }); + // The one publication this case made before the provider existed. + expect(publications).toEqual([route()]); const failure = yield* attempt(space.trace, INSTRUCTIONS); expect(failure?.class).toBe("unsupported-capability"); + // Nothing was asked of the executable: not which file it is, and not one + // of the read-only questions the adapter would have put to it. + expect([space.observer.observed, space.observer.queried]).toEqual([[], []]); + // Nothing was reached for on the session's behalf, and nothing offered to + // the namespace — so the candidate that would have raced does not exist. + expect(space.counts.allocations).toBe(0); + expect(publications).toEqual([route()]); + // And no live effect on either side of the handoff. expect(space.trace.launches).toEqual([]); expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); // The winner kept its identity and the evidence it was written with. expect(yield* inner.read(KEY)).toEqual(route()); @@ -5945,4 +5970,65 @@ describe("Tier XR — one session across two releases", () => { expect(space.observer.observed).toEqual([]); yield* unchanged(space); }); + + it("XR11: a winner this run cannot account for is not adopted, however it got there", function* () { + // The other half of the race, and the one the prospective check cannot + // stand in for: this adapter is exactly what this build fixes for its + // launcher, so it was entitled to observe a build and prepare a route of + // its own — and the account that reached the namespace first is still not + // one it can read. That account names another provider, which is the + // difference a candidate's own contract says nothing about: same launcher, + // same instruction layer, same schema, published by something else. + // + // Losing a race is ordinary, and XR6 adopts its winner whole. What + // separates them is whose account the winner is, so this run refuses before + // it acts through the winner at all. + const inner = createMemorySessionRouteStore(); + const foreignKey = { ...KEY, provider: "other-provider" }; + const foreignWinner: AgentSessionRoute = { ...route(), provider: "other-provider" }; + yield* inner.publish(foreignWinner); + + const publications: AgentSessionRoute[] = []; + const routes: AgentSessionRouteStore = { + // deno-lint-ignore require-yield + *read() { + return undefined; + }, + *publish(candidate) { + // The publication is where the concurrent account is revealed, and it + // is read back rather than remembered, so a rewrite would show here. + publications.push(candidate); + const winner = yield* inner.read(foreignKey); + return winner ?? candidate; + }, + }; + const space = yield* continuing({ routes }); + // The one publication this case made before the provider existed. + expect(publications).toEqual([route()]); + + const failure = yield* attempt(space.trace, INSTRUCTIONS); + + expect(failure?.class).toBe("unsupported-capability"); + // The observation this run was entitled to make is allowed to have + // happened; what may not is anything downstream of adopting the winner. + expect(space.trace.launches).toEqual([]); + expect([space.counts.resumes, space.counts.creates]).toEqual([0, 0]); + expect(space.harness.ensureCalls).toEqual([]); + expect(space.harness.createdOptions).toEqual([]); + // Neither identity was acted on: not the winner's, and not the candidate + // this run offered and lost with. + expect(JSON.stringify(space.trace.records)).not.toContain(ALLOCATED); + expect(JSON.stringify(space.trace.records)).not.toContain(CANDIDATE); + // This run's own candidate, offered exactly once under its own provider — + // and never offered again to make its account the true one. + expect(publications.length).toBe(2); + expect(publications[1]).toMatchObject({ + schema: "session-route.v2", + provider: "acpx", + nativeSessionId: CANDIDATE, + launcher: "claude", + }); + // Read back through the same strict reader the durable store uses. + expect(yield* inner.read(foreignKey)).toEqual(foreignWinner); + }); }); diff --git a/packages/core/src/agent/launch-journal.ts b/packages/core/src/agent/launch-journal.ts index 6a38ee181..071af4f83 100644 --- a/packages/core/src/agent/launch-journal.ts +++ b/packages/core/src/agent/launch-journal.ts @@ -166,10 +166,12 @@ function declaredMembers( /** * Read a retained build binding strictly. * - * The digest is required and exact — it is what binds a session to a build. - * A present `reportedVersion` must still be a real one: a member written as an - * empty string is a claim about a release nobody can reproduce, which is not - * the same as having made no claim. + * The digest is required and exact — it is what names the build this account + * describes, and the account is only ever held to the other durable record of + * the same observation, never to a build installed later. A present + * `reportedVersion` must still be a real one: a member written as an empty + * string is a claim about a release nobody can reproduce, which is not the same + * as having made no claim. */ function executableBinding(value: unknown): ExecutableBuildBindingV1 | undefined { if (!isRecord(value) || !declaredMembers(value, BINDING_MEMBERS, BINDING_OPTIONAL_MEMBERS)) { diff --git a/packages/core/src/agent/launch.ts b/packages/core/src/agent/launch.ts index e94316cd8..c8b45d066 100644 --- a/packages/core/src/agent/launch.ts +++ b/packages/core/src/agent/launch.ts @@ -71,12 +71,18 @@ export type LaunchFailureClass = * last owner never proved it stopped, so nothing here can say the session is * free, and no elapsed time, pid or released lock changes that. * - * `executable-binding-refused` is the build question: the session was - * established by one build of a provider executable, and this run could not - * show it is talking to that same build. Resolution, canonicalization, - * executable-file validation, version parsing, digesting, schema recognition, - * equality, and a session established before any build was recorded all end - * here. + * `executable-binding-refused` is the question of whether this run can account + * for the build behind a provider command at all — never whether that build is + * the one a session was opened by. Failing to see it ends here: no observer on + * this host, resolution, canonicalization, executable-file validation, and + * digesting. So does a client-allocated session whose durable records were + * written before one of them said which build accepted the identity, because a + * launch nobody completed is resumed by holding those two records to each + * other, and there is nothing there to hold. + * + * Whether an executable this run *can* see may act is a different question with + * a different answer: it is admitted on its own terms, and refusing it is + * `unsupported-capability`. */ export interface LaunchFailure { class: LaunchFailureClass; @@ -112,10 +118,10 @@ export type IdentityProvenance = "provider-returned" | "client-allocated"; * which stops being true, while a digest says which build it was, which does * not. That also keeps the record free of host layout. * - * The digest is what binds. `reportedVersion` is optional evidence beside it: - * an executable that will not say which release it is, says something this - * provider does not recognize, or says several things is bound by its bytes - * alone rather than refused for being quiet. + * The digest is what names the build. `reportedVersion` is optional evidence + * beside it: an executable that will not say which release it is, says + * something this provider does not recognize, or says several things is + * described by its bytes alone rather than refused for being quiet. */ export interface ExecutableBuildBindingV1 { readonly schema: "executable-build.v1"; @@ -187,8 +193,14 @@ export interface PreparedLaunchRecord { identityProvenance: IdentityProvenance; /** * Which build accepted the client-allocated identity, present exactly when - * this provider binds one. A provider that returns its own identity owns its - * own session lifetime and binds nothing, so it carries none. + * this provider records one. Copied from the construction route rather than + * observed here, so the journal and the route are two accounts of a single + * observation instead of two observations. A provider that returns its own + * identity owns its own session lifetime and records none. + * + * It is never held against a build installed later; the one comparison it + * takes part in is with the route it was copied from, which is how an + * incomplete launch proves which session it is resuming. * * Optional because the client-allocated path was released before any build * was observed. A record without it is legacy history: readable, resumable by From 6049184bb3766a3d300d4ec9b0fca559f71690e8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Sun, 6 Sep 2026 16:05:01 -0400 Subject: [PATCH 59/62] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Compose=20Codex?= =?UTF-8?q?=20native=20launch=20with=20terminal=20grids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architecture.md | 11 +- specs/acp-client-spec.md | 78 ++-- specs/decisions.md | 130 ++++++- specs/executable-mdx-spec.md | 3 +- specs/native-agent-session-launch-spec.md | 448 ++++++++++++++++------ 5 files changed, 498 insertions(+), 172 deletions(-) diff --git a/architecture.md b/architecture.md index 939c4f7ac..0cea2a8f6 100644 --- a/architecture.md +++ b/architecture.md @@ -102,10 +102,10 @@ Existing documents and code get aligned to this section retroactively. | host profile | a named production assembly a trusted host offers a testing harness — `run` today — reused after argument parsing rather than restated; a host that cannot offer one refuses rather than approximating it | | execution outcome | what one nested document execution ended as, as the harness publishes it: a settlement carrying the exact completion `Result`, or, for a workflow attempt, a durable suspension carrying the run and suspension identities. A suspension is neither success nor failure | | trusted host | the code that decides what an execution is for — a CLI entrypoint or a workflow runner — as distinct from the document, the components it expands, and the middleware packages composed around it | -| native session launch | preparing one durable coding-agent session from a document and then handing its provider's own interactive UI the terminal for that exact session. It is session construction followed by an ownership handoff, not a chat frontend: XMD performs no model turn, proxies no native turn, and journals no native conversation | +| native session launch | preparing one durable coding-agent session from a document and then handing its provider's own interactive UI the terminal for that exact session. It is session construction followed by an ownership handoff, not a chat frontend: XMD proxies no native turn and journals no native conversation; it performs no model turn except a protocol-declared materialization turn for a conversation that this launch just created | | provider-native session identity | the identity an agent asserts for its own durable session state, distinct from the logical XMD session key and from the ACP/ACPX record identity. Only an asserted one crosses a handoff; XMD never infers that a string a native CLI would accept is one | | pending session placement | an exact provider-issued `Session` capability carrying where a logical session will live and which agent it is compatible with, and nothing more: no construction route, no provider handle, no resumable identity. A fresh `` produces one, and the first consuming operation chooses how the session is constructed | -| session materialization | the transition that makes a placement's chosen route and its backend history resumable. ACP-first materialization happens only when the backend reports that it accepted the session's first turn; client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | +| session materialization | the transition that makes a placement's chosen route and its backend history resumable. Ordinary ACP-first materialization happens only when the backend reports that it accepted the authored session's first turn; a provider-returned native launch may instead spend its protocol's one fixed, tool-free materialization turn for the conversation it just created, and client-native materialization is the native launch's existing retained construction. Nothing else promotes a placement — not a returning ensure, a first output, a terminal result, a checkpoint token, an error code or a diagnostic | | established session | a placement whose immutable construction route and durable provider or native identity both already exist, and which is therefore validated eagerly: reattached, compared against its retained history, and refused when either is missing or names another conversation | | instruction layer | the provider-native session, system or developer instructions a launch installs before the native UI accepts its first user turn. It is not a user message, and it is not conversation history | | foreground-terminal lease | the one exclusive claim on a document execution's foreground experience. A root native launch holds it for one inherited terminal; a terminal grid holds it for one composite presentation. A host with no terminal refuses it, and no second root launch or grid can hold it concurrently | @@ -116,11 +116,12 @@ Existing documents and code get aligned to this section retroactively. | launch request | the frozen, one-use value public launch middleware routes. It carries the facts of one launch and `with()`, and nothing that can settle one. Identity is object identity: a rebuilt look-alike describes the same ask and authorizes none of it | | provider authority | what core delivers to the provider factory it installs, as an argument that factory closes over. It validates the routed request, runs each absent phase once, cross-checks and retains what comes back, and derives the result. There is no reader for one, no context holding one, and no request member carrying one | | session coordinator | the host-built capability that answers who owns one logical agent session right now, across processes. It is passed directly into the provider that needs it and is deliberately not contextual: a decision document middleware could replace is not one. Acquisition never waits | -| construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Two schemas are readable: the released `session-route.v1`, and `session-route.v2`, which exists only for `client-native` and adds a required executable build binding. New client-native construction publishes V2; a V1 client-native route stays legacy-unbound, is never upgraded in place, and authorizes native resume only | -| executable build binding | immutable audit evidence about the provider executable observed when a client-allocated identity was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. The V2 route and its prepared journal record retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | +| construction route | how one logical agent session was first constructed — through ACP, or under an identity XMD chose — as strict create-once durable state beside the coordinator's own records. It answers a different question from ownership and grants no right to act: the coordinator remains the single live authority. A route never converts, and a session ACP already established is recorded as ACP-first rather than reclassified. Three schemas are readable: released unbound `session-route.v1`; `session-route.v2`, which exists only for the `claude-client-native.v1` contract; and `session-route.v3`, which exists only for the bound `codex-provider-returned.v1` ACP-first contract. New Claude client-native construction publishes V2, new Codex provider-returned construction publishes V3, and V1 routes stay legacy-unbound and are never upgraded in place | +| executable build binding | immutable audit evidence about the provider executable observed when a bound construction route was first published: a schema, the lowercase SHA-256 of the canonical executable target and an optional adapter-canonical reported version. Its V2 or V3 route and agreeing launch journal retain the same value exactly and never rewrite it, but neither digest nor version authorizes or denies a later continuation. It contains no path: a path says where a build was, which stops being true, and names host layout besides | | native capability admission profile | the adapter protocol and observable command shape for which one native capability's real-CLI proof passed, inside its proved host operating system and architecture. The exact executable used by each live continuation must independently advertise the requested profile through a read-only metadata query; the Agent name, reported version, executable digest and a predecessor's admission authorize nothing by themselves | | executable observer | the host-built capability that resolves a launcher command through the host's real execution environment, canonicalizes the target, requires an executable regular file, hashes that target and runs the adapter's declared read-only metadata queries against that same path. Passed directly into the provider like the coordinator beside it, and deliberately not contextual: executable validation decides which retained history may be accepted, and a resolver document middleware could replace could point the observation at one binary while the run spawns another | | client-native attachment | joining a conversation a native process constructed, through ACP, under the identity the route already names. It is attachment, never conversion: the route stays client-native, `resumeSessionId` receives that exact identity, and the provider's canonical assertion must equal it before the first turn. Advertised separately from native launch, because handing a session to a native UI and later joining it prove different things | +| provider-native continuation | rejoining through ACP a provider-returned conversation that a native launch handed to the provider UI. It is admitted separately from native launch, uses the exact retained provider assertion, and never allocates, substitutes or converts an identity | | identity provenance | who chose a provider-native session identity: the provider returned it, or the client allocated it before the provider existed. Retained rather than inferred, because after the fact both are just a string in the record. A released record written before the distinction existed reads as provider-returned, which is the only compatibility inference | | quiescence | the one-use acknowledgement an ownership body gives. It does not mean "I finished" — it means nothing this owner started can still act on the session, which is why a handoff that could not release its handle never gives it | | recovery tombstone | an ownership record left active because its owner never proved it stopped. A crash releases the kernel lock and not this, and no pid, elapsed time, released lock or empty transcript clears it | @@ -4647,7 +4648,7 @@ Status is measured against main. | nested run-profile Agent and elicitation declarations | lets one `` declare one child-scoped `` scenario set and one non-delegating `` matcher set; only frozen test data crosses the harness request, the trusted host constructs both providers inside the isolated child, siblings share no session or provider state, ordinary component shadowing remains in force, and the child journal retains only the selected Prompt and Elicit components' ordinary results. A controlled `` may author an exact scenario label that this host alone maps to Plan's derived conversation identity; declaration selection uses the label while runtime state stays keyed by the opaque identity and child, with no matcher or fallback added to ordinary TestAgent sessions | built on the #641 stack; controlled Plan routing added on the #728 stack | | `Config` run deadline / exec default / Fetch default / verbosity | three independently owned contextual timeouts, absent unless configured, each read by exactly one consumer, and contextual verbosity — a boolean that is false unless configured, seeded by the command line and overridable for a lexical subtree, bounding nothing and owning no authority | built on this stack | | terminal grid (`` / ``) | replaces the root foreground terminal with one provider-neutral composite whose statically declared direct panes begin concurrently, stay independently interactive, preserve their final statuses until the reader closes the composite, and tear down completely before document execution continues. A paired pane expands isolated document flow; a self-closing pane runs the host's default shell. The grid owns one foreground-terminal lease, each pane owns a separate pane-terminal lease, and a pane-scoped native launcher lets `` use that pane without weakening the independent Agent session coordinator. The launcher terminates at the composite's required provider-neutral pane-execution operation; the authored ordinal stays in core's live closure, and the native request carries no pane identity. Core validates the complete row-major layout before provider contact, attaches only after every pane is ready, contains post-attach pane failures until close, and records the ordered provider-neutral outcomes. Completed replay contacts no terminal or Agent provider; partial replay rebuilds a fresh composite, restores completed panes as statuses, and continues incomplete pane effects under their existing durable identities. Provider commands, sockets, process topology and layout identifiers remain live-only inside the provider closure | defined for #717; #726 proves the persistent tmux pane-worker topology and its observable teardown boundary on macOS; structure and layout built in #729, provider-neutral execution and durability in #730, pane claim admission and native-launch middleware in #731; the required composite pane-execution endpoint is specified and implemented in #732, which is what gives a pane's `` that pane's terminal rather than the root's; the controlled non-tmux provider remains the authority for core lifecycle semantics; the tmux provider is built in #732 for the Deno and compiled foreground hosts — one invocation-private server per grid, authenticated persistent pane workers carrying exact argv, cwd and environment outside tmux parsing, explicit row-major layout imposed by pane swaps, a required composite `launch()` that gives a pane's `` its own terminal rather than the root's, and one ordered teardown that proves worker quiescence, channel closure and server disappearance before the document continues; its evidence uses a fake tmux with real workers and real sockets, and real tmux behaviour on macOS remains #726's; Node and Bun catalog and validate the same grids and install neither the provider nor the process observer, refusing before pane start; DEC-016 specifies the final behavior-preserving extraction into `@executablemd/terminal` and `@executablemd/terminal-tmux`, with every repository import moved to the canonical packages and the unshipped old terminal paths deleted | -| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of `` and hands the provider's native UI the terminal for that exact session, then continues the document after it exits. The body renders completely first and only what it rendered crosses as the instruction layer; the launch performs no model turn; at the root it takes the run's foreground-terminal lease before an agent is resolved, while a launch inside `` takes that pane's lease through its pane-scoped native launcher. A host with no applicable terminal refuses without probing for an installed CLI. A session is constructed once, by one of two mechanisms, and its create-once construction route says which. Where the provider returns the identity, the ACPX provider creates the session, installs the layer at creation, releases ACP ownership before the spawn, and marks its handle stale so a later `` reattaches. Where the adapter names its own sessions, it allocates the identity inside ownership before any process exists, the native process creates the session under that name from a private mode-0600 instruction file, and ACP creates nothing — the instruction text reaches neither argv nor environment, and the file is removed on success, failure and cancellation alike while ownership is still held. Neither route converts into the other, and which one governs is chosen by the first operation that consumes the placement rather than by the `` that made it: a fresh `` publishes no route and establishes nothing, so a `` nested inside one constructs the session it placed, while a first subscribed `` publishes ACP-first before it ensures and keeps that account even if the turn that follows is never accepted. An established route is validated eagerly by a later ``, and a launch meeting a published ACP-first route refuses before an identity exists. A `` or `` meeting a bound client-allocated route attaches under the route's exact identity; a legacy unbound route or an unavailable attachment capability refuses before a turn and creates no substitute conversation. Phases are retained as `agent_session_launch` records under one expansion identity — `prepared` before ownership is released, then `detached`, then `exited` — so a completed replay launches nothing, a replay holding only `prepared` proves the handoff never began and may still create under the retained identity, and one holding `detached` resumes and never falls back. The public route carries an opaque one-use launch request and answers nothing; authority to run and retain a phase is delivered to the installed provider directly, so neither a returned completion nor a rebuilt request authors a launch. Every operation that can act on an advertised session takes exclusive ownership under one natural key first, through a coordinator the host built and passed in; contention refuses instead of queueing, and an owner that never proved it stopped leaves a recovery tombstone. A host that cannot say who owns a session refuses every advertised operation, and one that cannot say how a session was constructed additionally refuses an agent that names its own — before any provider effect. Every private setup or child-creation failure is normalized to `process-creation-failed` with fixed provider-owned text, carrying no path, argv, environment or host message. No launch path discards persistent provider state. A client-allocated session retains immutable audit evidence for the executable observed before its identity was allocated: that binding is published with the V2 route and copied into each agreeing prepared record, while every live create, resume, attachment and incomplete replay observes the executable it will actually use and independently admits the requested capability before a process, an ensure or a turn. The trusted host admits only when the route's stable adapter protocol, the independently requested capability, the live executable's positive CLI shape, and the proved host operating system and architecture agree; neither the Agent name, reported version, executable digest nor a predecessor's admission authorizes or denies the continuation. A compatible Claude upgrade therefore resumes the exact retained native identity without allocating, converting, republishing or rewriting the route or journal. A route/journal disagreement still refuses because the two durable accounts no longer describe one preparation, not because the live build changed. The live canonical path replaces the launcher name only for that invocation. A `` or `` meeting a bound client-native route similarly admits attachment against the current executable, requires any retained provider arrangement to assert that same conversation, calls ensure with the route identity as `resumeSessionId`, and requires the provider to report that identity before a turn — refusing on a protocol, profile, capability or host mismatch, missing history or a differing assertion without creating a substitute conversation. A route whose exact provider identity is absent remains authoritative: the provider's explicit refusal is an accepted zero-turn outcome and XMD neither allocates, creates, converts, deletes, rewrites nor substitutes a conversation. ACP runtimes are partitioned by the resolved agent command and live executable observation serving them, never by the route's historical binding; each handle is closed by the partition that created it, a live partition is never migrated after an upgrade, and a bound partition is torn down when its last handle and claimed work settle. A legacy V1 client-native route keeps exactly the released native-only behavior and never attaches | built on the #517 stack, extended by the #519 and #561 stacks; Deno and the compiled binary assemble the host — coordinator, route store, executable observer, capability-profile admission and live platform facts — and Node and Bun assemble none of that authority, so every advertised operation refuses before provider work; the Claude client-native protocol is admitted independently for launch and attachment when its required help shape is observed on the proved macOS/arm64 host envelope, including compatible versions after 2.1.241 and builds which omit version output; an existing V2 session continues across compatible Claude releases; an absent or changed required shape, a different protocol or an unproved platform refuses, while an exact missing-session refusal remains an accepted client-allocated outcome; Codex remains unadvertised because nothing has run its provider-returned claims against an installed Codex; `Agent.AddDir` is unbuilt | +| native session launch (`` / `launchAgentSession()`) | prepares one durable coding-agent session from the rendered body of ``, hands the provider's native UI the selected root or pane terminal for that exact session, and continues after it exits. Claude uses the client-allocated `claude-client-native.v1` protocol: XMD allocates the identity, publishes V2, and the native process creates it from a private instruction file. Codex uses the provider-returned `codex-provider-returned.v1` protocol: ACP creates and canonically asserts the identity, XMD publishes V3, and one fixed tool-free `codex-materialization.v1` turn makes a newly created thread resumable before ownership is released. No other turn is performed. `prepared`, optional `materialized`, `detached` and `exited` phases retain the handoff; uncertain materialization is never repeated and completed replay performs no live work. V2 and V3 retain their original executable binding as exact route/journal audit evidence, while every live construction, native resume, ACP continuation and incomplete replay independently admits the current executable's stable protocol, requested capability, structural CLI shape and proved host envelope. Reported version and digest never lock a compatible release. V2 fixes the Claude protocol and V3 fixes the Codex protocol without a replaceable pin; neither route converts, rewrites, reallocates or substitutes an identity. ACP runtimes partition by the live executable and protocol, not historical binding, and each handle closes through its creator. Every advertised operation retains the existing coordinator ownership, privacy, cancellation, quiescence and terminal-grid pane-worker teardown contracts. Neutral launch and notification behavior stays in `@executablemd/terminal`, tmux transport stays in `@executablemd/terminal-tmux`, and no deleted core, runtime or CLI terminal path returns | Claude and Codex are advertised independently for the capabilities their installed-CLI proofs established on macOS arm64: Claude native launch and client-native attachment from the 2.1.241 proof family, and Codex native launch plus provider-native continuation from the `codex-cli` 0.153.2 / vendored ACP bridge proof. Compatible later releases remain admitted only when their current positive shape matches; protocol/profile/host/capability or exact-identity disagreement fails closed. Deno and compiled hosts assemble the authority; Node and Bun do not. Exact V1, V2 and V3 routes are readable; new Claude writes V2, new Codex writes V3, and older V1/V2-only builds correctly refuse V3 as unknown rather than treating the occupied key as absent. `Agent.AddDir` remains unbuilt | | `` | performs one XMD-mediated HTTP read through contextual `API.Fetch`, admitting the whole request before transport, and retains the normalized request and the detached response as one `fetch` durable observation; capture decides whether a status is data or a failure, and the trusted host's destination ceiling sits below the component | built on the #456 stack; a generated fragment may name the pinned identity only for a request the trusted host stated exactly, on the #369 stack | | `API.Files` | routes every document filesystem operation to the installed provider, with no host default and structural failure data. Its mandatory semantic operations include `ensureDirectory`, which recursively creates or adopts one directory and returns Unit; separately loaded copies compose through the stable Api name | built on the #227 stack; directory ensure added by #643 | | `` | removes one file the document names, inside the contextual working directory. An ordinary overridable core default with a closed schema of one required non-empty `path`, **self-closing only** — a paired spelling never enters its body, because the component declares its one form and canonical invocation-form dispatch enters that body only for the form the scan recorded, before `Env.cwd` is read and before the provider is reached. Neither the composable `Component.hasContent()` chain nor a method on whatever object a caller handed over takes part. It renders the empty string, declares no `returns` and hands back no receipt, so an ordinary `as` captures that empty string; absence is the same success, so deleting a path twice succeeds twice. One regular file or one final symbolic link goes — the link rather than its target, inside or outside — and every directory is refused, an empty one included. Empty, absolute, lexically escaping and parent-link-escaping paths are refused before any removal, and a printed error names only the path the document wrote. One semantic `API.Files.deleteFile` call and no filesystem access of its own; under a workflow run it is one `workspace_file` effect retaining `{ kind: "deleted" }`. The standard Deno workflow profile admits it to generated XMD as the exact self-closing identity `@executablemd/core#File.Delete`, third in the write table, where it performs that same ordinary effect and contributes no evaluator result | built on the #567 stack | diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index 9714575d1..8e44eb954 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -59,16 +59,17 @@ routes an opaque request and cannot manufacture that result. its `SessionLaunchResult`; the route is where public middleware sees the ask, and authority to perform it reaches the installed provider directly rather than travelling on this chain - (specs/native-agent-session-launch-spec.md). A launch performs no model turn, - and a provider that answers `prompt()` does not thereby answer it: native - session launch is its own capability, installed on its own. -- Built-in **`claude` is advertised**, for two separate capabilities: native - launch, and attaching ACP to a session a native process constructed. Its - sessions are named by XMD and created by the native process - (specs/native-agent-session-launch-spec.md). So the ownership, - construction-route and executable-observation requirements below apply to it - on every host, and only the hosts that assemble all three — Deno and the - compiled binary — can serve it. `codex` remains unadvertised for both. + (specs/native-agent-session-launch-spec.md). A launch returns no model answer + and performs no model turn except the one fixed materialization turn declared + by `codex-provider-returned.v1`; a provider that answers `prompt()` does not + thereby answer native launch, which remains its own capability. +- Built-in **`claude` and `codex` are advertised** for native launch under their + independently admitted live profiles. Claude's client-native protocol is also + advertised for client-native ACP attachment. Codex's provider-returned + protocol is advertised for provider-native continuation after native handoff. + The ownership, construction-route and executable-observation requirements + below apply to both agents, and only the hosts that assemble all three — Deno + and the compiled binary — can serve them. - Every operation that can act on an **advertised** session — `session()`, a subscribed `prompt()` stream, a launch, and an incomplete launch replay — takes exclusive ownership of it first, through the session coordinator its @@ -79,26 +80,33 @@ routes an opaque request and cannot manufacture that result. install none, so default Claude session work refuses there before any provider effect — no availability probe, no runtime, no route read, no identity, no child. An agent that is not advertised keeps ordinary ACP behavior on every - host, Codex included. + host. - The host also passes in a **construction-route store** and an **executable observer**, directly and from the same trusted root as the coordinator. Both - are required only for an advertised agent whose adapter names its own - sessions: a provider that returns the identity constructs nothing a route - governs and binds no build, and keeps its behavior unchanged on a host that - has neither. A host missing either refuses that agent before any provider - effect, on the same terms as a missing coordinator. The observer is not a - contextual Api for the same reason the coordinator is not: executable + are required for every advertised bound adapter. Claude publishes V2 before + client-native construction; Codex publishes V3 before provider-returned ACP + construction. A host missing either dependency refuses that agent before any + provider effect, on the same terms as a missing coordinator. The observer is + not a contextual Api for the same reason the coordinator is not: executable validation decides which retained history may be accepted. - While ownership is held and before any provider construction effect, the provider reconciles the route. A first `session()` or subscribed `prompt()` publishes or adopts `acp-first` before runtime creation, `ensureSession()` or a turn, and an ensure that fails afterwards leaves that route standing. A - launch by an adapter that names its own sessions publishes `client-native` - under an identity that adapter allocated, unless existing durable ACPX state - or an existing route says the session was constructed through ACP — in which - case it publishes or adopts `acp-first` and retains `identity-unavailable`. + bound provider-returned launch admits its current executable and publishes or + adopts V3 before ACP construction. A launch by an adapter that names its own + sessions publishes V2 `client-native` under an identity that adapter + allocated, unless existing durable ACPX state or an existing route says the + session was constructed through ACP — in which case it publishes or adopts + V1 `acp-first` and retains `identity-unavailable`. Publication is create-once, so the loser of either order adopts the winner, and no route converts. +- A `session()` or subscribed `prompt()` meeting V3 validates the compiled + `codex-provider-returned.v1` interpretation, observes and independently admits + the live provider-native-continuation profile, then ensures through the live + executable partition. It requires the exact provider assertion retained by + the launch before a turn. Missing or different identity, protocol, capability, + shape or host refuses without allocation, conversion or materialization. - A `session()` or subscribed `prompt()` meeting a **bound** `client-native` route **attaches** to it. Under the same ownership it reobserves the executable and independently admits the route's stable adapter protocol, attachment @@ -704,9 +712,12 @@ none. life, so a second `` naming the same placement is answered with it and a `` given it is acting on the thing that was pinned. -- **Materialization.** A pending ACP-first placement is constructed by the first - subscribed ``, and only the backend's acceptance of that turn makes it - a conversation. The provider ensures with +- **Materialization.** An ordinary pending ACP-first placement is constructed by + the first subscribed ``, and only the backend's acceptance of that + turn makes it a conversation. A V3 provider-returned native launch uses the + same acceptance boundary for its protocol's one fixed internal turn, retains + that prompt before sending it, and exposes neither its response nor usage as + document output. The provider ensures with `materialization: "first-turn-acceptance"`, which makes ACPX persist a provisional record: the key is occupied and the serialized `agentSessionId` is absent, so the record is occupancy rather than an assertion. @@ -735,7 +746,9 @@ none. The durable order is therefore: route, provisional non-asserting record, backend acceptance, asserting record, host mapping, and only then the events - the turn produced. If materialization fails the provider removes no route, + the turn produced. A native-launch materialization additionally requires the + canonical provider assertion and refuses any tool call or non-`end_turn` + result. If materialization fails the provider removes no route, publishes no identity, calls no host mapping, gives up the handle so the next attempt creates rather than resumes, and leaves the exact `Session` value pending for a retry. If it succeeds and the turn then fails, the session stays @@ -766,10 +779,14 @@ none. Promise-returning leaves are consumed with `until`; the provider's only Promise-producing adapter is the `onPermissionRequest` callback, and the bridge itself is operation-based. -- **Runtime partitions.** Ordinary ACP-first work uses one unbound runtime. A - bound attachment uses one runtime per `(resolved agent command, executable - build binding)`, created with the observed path in `agentProcessEnv` and torn - down when its last handle closes. Acquiring a runtime to ensure through claims +- **Runtime partitions.** Ordinary unbound ACP-first work uses one unbound + runtime. Bound client-native attachment and V3 provider-returned construction + or continuation use one runtime per `(resolved agent command, live executable + observation, adapter protocol)`, never the historical route binding. The + runtime is created with the observed path in `agentProcessEnv` and torn down + when its last handle closes. An upgrade never rekeys a live partition; a + later operation observes again and enters the partition for the current + executable. Acquiring a runtime to ensure through claims the partition, and an `ensureSession()` that rejects gives that claim up — so the next attempt observes again rather than inheriting a live path nothing is holding, while a sibling ensure still in flight keeps the partition alive. @@ -791,7 +808,8 @@ none. exists and what removes it. - **Host-owned dependencies.** `AcpxProviderDependencies` carries what a host, rather than a document, decides: `advertiseNativeLaunch` and - `advertiseClientNativeAttachment` are two separate lists, and a profile whose + the client-native-attachment and provider-native-continuation advertisements + are separate lists, and a profile whose session authority differs from ordinary `xmd run` states both explicitly rather than inheriting the package's defaults by omission; `executableObserver` says how this host observes the build behind an diff --git a/specs/decisions.md b/specs/decisions.md index c490aff52..00489aa2c 100644 --- a/specs/decisions.md +++ b/specs/decisions.md @@ -835,12 +835,12 @@ cross-check between the route and the prepared journal. It does not establish whether another release implements the same operation. That question belongs to the live capability profile. -The client-allocated contract deliberately accepts two zero-turn outcomes: the -same identity resumes, or the provider refuses that exact absent identity and -XMD fails closed. A provider which persists no conversation until its first user -turn therefore does not, by that fact alone, violate the contract. Adding a -hidden materialization turn would instead violate the accepted rule that native -launch performs no model turn. +The client-allocated Claude contract deliberately accepts two zero-turn +outcomes: the same identity resumes, or the provider refuses that exact absent +identity and XMD fails closed. A provider which persists no conversation until +its first user turn therefore does not, by that fact alone, violate that +contract. A materialization turn is never inferred from this observation; +DEC-018 records the separately proved Codex exception explicitly. ### Decision @@ -916,10 +916,10 @@ child teardown, cleanup and handle release acknowledge quiescence even when the child exited unsuccessfully; any unproved teardown keeps the recovery tombstone active. -No XMD-owned materialization turn is added. A provider-native creation protocol -which retains the exact identity without a turn may replace the current command -only after its own real-CLI proof. Allowing a fixed bootstrap/model turn is a -separate product decision and specification change. +Capability admission never adds a materialization turn. Claude remains +zero-turn. A provider protocol requiring a fixed turn must declare and prove it +as a separate product contract; DEC-018 does so for Codex without changing the +Claude or generic admission rule. ### Consequences @@ -952,3 +952,113 @@ This is a repair to the unmerged terminal/native-session stack and lands directly atop its delivery head. It is not a separate Story: the terminal grid made the native-launch path observable but did not create this capability boundary. + +## DEC-018: Codex native launch materializes one provider-returned conversation + +**Status:** Decided + +**Date:** 2026-09-06 + +### Context + +The accepted two-agent terminal-grid document needs both Claude and Codex to be +real product paths. Claude can create a named conversation directly in its +native process. Codex cannot: its ACP App Server chooses the thread identity, +and `codex resume ` cannot read that thread until its first accepted turn +has written a rollout. + +The #755 installed-CLI proof established the complete provider-returned path on +macOS arm64 with `codex-cli 0.153.2` and the vendored +`@agentclientprotocol/codex-acp` 1.6.2 snapshot: ACP created one conversation, +the adapter canonically asserted its App Server thread, one fixed turn made it +resumable, the native UI resumed that exact identity, and ACP later rejoined the +same conversation. The version and digest identify the evidence. DEC-017 means +they cannot become a release allow-list or continuity lock. + +The sibling implementation wrote strict `session-route.v3` records into the +shared machine store. A V1/V2 reader refuses those records as unknown. Treating +them as absent would permit a second conversation under an occupied logical key, +which is precisely what strict route parsing prevents. + +### Decision + +Codex is advertised for `native-launch` and the separate +`provider-native-continuation` capability. Its stable protocol is +`codex-provider-returned.v1`. The Deno and compiled hosts admit each capability +only when the prospective live adapter has that compiled non-replaceable +protocol interpretation, the resolved executable structurally declares the +Codex product and required `resume` identity semantics, and the macOS arm64 host +envelope matches. A reported version and executable digest remain optional audit +evidence and never authorize or deny a compatible cross-release continuation. + +New Codex native construction publishes strict V3 before ACP runtime creation: + +```ts +interface AgentSessionRouteV3 { + schema: "session-route.v3"; + route: "acp-first"; + provider: string; + agent: string; + sessionKey: string; + executableBinding: ExecutableBuildBindingV1; +} +``` + +The exact V3 schema/route/provider/agent form fixes +`codex-provider-returned.v1`; it has no replaceable protocol field. Its original +binding and the agreeing launch journal remain immutable audit evidence. A +future incompatible provider-returned protocol requires a new route schema. +The integrated reader accepts exact V1, V2 and V3, writes V2 for new Claude +client-native construction and V3 for new Codex provider-returned construction, +and never upgrades or rewrites an existing route. Older V1/V2-only builds must +continue refusing V3 rather than deleting it, treating it as absent or creating +another session. + +`codex-provider-returned.v1` declares exactly one +`codex-materialization.v1` turn for a conversation the launch just created. Its +prompt bytes are fixed in the native-session-launch specification, contain no +authored instructions or host values, deny every tool and external action, and +are announced before the cost is incurred. Core retains the ordinary prompt +operation before sending it, then retains `materialized` with the provider's +exact canonical identity and filtered response/usage evidence. That response is +not document output. A tool call, failed or cancelled turn, non-`end_turn` stop, +missing acceptance signal, missing or changed identity, or failed checkpoint +fails the launch before native execution. + +Replay never risks spending the turn twice. A retained prompt outcome can +reconstruct a missing `materialized` phase; a prior invocation with only the +prepared plan is `session-recovery-required`; a materialized or detached launch +continues the exact retained identity; a completed launch observes and executes +nothing. Deliberately stopped work is never revived. + +Every live V3 construction, native resume, provider-native ACP continuation and +incomplete replay independently validates the stable protocol and admits the +current executable before ensure, materialization, native execution or a turn. +Route/journal disagreement, provider identity absence or substitution, protocol, +profile, capability or host mismatch remains fail-closed. Compatible Codex +releases continue the exact retained identity without allocation, route rewrite +or same-build comparison. ACP runtime partitions use the current resolved agent +command, executable observation and protocol rather than the historical V3 +binding; a live partition is never migrated and each handle closes through its +creator. + +The materialization notification and native child use the selected root or pane +terminal. Neutral notification and launch mechanics belong to +`@executablemd/terminal`; tmux-specific transport remains in +`@executablemd/terminal-tmux`. No deleted runtime, core or CLI terminal path is +restored. + +### Consequences + +A newly constructed Codex native session costs one explicit model turn before +its native UI appears. Claude remains zero-turn, an established Codex session +receives no second materialization turn, and ordinary ACP-first prompting keeps +its existing semantics. The authored two-agent terminal-grid document is +therefore preserved without adding provider syntax or a demo-only fallback. + +The #755 implementation is ported semantically on top of the terminal-package +and cross-release stack; it is not cherry-picked as a sibling commit. Its +same-build comparisons and old core/runtime terminal paths are superseded. The +frozen real-session proofs remain evidence and are not rerun for this +integration. Controlled fixtures prove protocol/shape discrimination, V3 +compatibility, replay, mixed Claude/Codex pane launch, ownership and teardown. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index a794797a3..c71cade5c 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -11308,7 +11308,7 @@ Each row names the derivation it kills. | AF16-AF20 | Prompt-failure policy | Absent by default; forces `throwOnError` when it says yes; an explicit `throwOnError` wins without consulting it; a repository `Prompt` never consults it | | AF21 | `` is a caller, not a second implementation | It renders its body and calls `launchAgentSession()` with what it rendered; the phase sequencing, retention and result belong to that one canonical operation, and the component holds none of them | | AF22 | A refused launch is an observation | The failure is raised as an error segment whose cause carries `phase` and `failureClass`, so `` binds a value an author can assert which refusal it was on, rather than the wording of a message | -| AF23 | A `` joins what a launch constructed | The same named `` after a client-native `` attaches to the conversation the native process made, and a `` in it answers from that conversation's history rather than from a new one. The route is unchanged: not republished, not converted, and no second identity allocated. Authored whole in `packages/test-agent/src/NativeSessionLaunch.test.md`; a provider that reported another conversation fails it | +| AF23 | A `` joins what a launch constructed | The same named `` after a client-native or provider-returned `` attaches to the exact conversation the launch retained, and a `` in it answers from that conversation's history rather than from a new one. V2 and V3 routes are unchanged: not republished, converted, rebound or given a second identity. Authored whole in `packages/test-agent/src/NativeSessionLaunch.test.md`; a provider that reports another conversation fails it | | AF24 | A Session pins the exact value it was issued | A fresh `` calls `session()` once and hands the same object — by identity, not by key — to every `` nested inside it. A provider decides whether a session may be acted on by that identity, so a rebuilt look-alike is a value nobody issued | | AF25 | A fresh Session performs no provider effect | A self-closing `` places one and renders nothing: no prompt is started, and nothing about the placement appears in the document where the element stood | @@ -11341,6 +11341,7 @@ test derives a core result from a provider identifier. | TG19 | Reader close crossed with parent cancellation | A controlled live pane enters a signal-held finalizer after reader close takes effect. Parent cancellation begins while teardown is blocked; releasing the finalizer lets pane and provider teardown complete, retains the pane as `closed` and the grid with its reader-close result, and only then delivers cancellation to the parent. A continuation neither contacts the provider nor enters pane work, does not hang, and proceeds from the retained grid outcome. Provider-resource and following-sibling observations prove both sides of the ordering; no elapsed duration is evidence | | TG20 | Pane-native physical endpoint | A paired pane's native launch passes through nearer launcher middleware and then the required composite operation for its authored ordinal. Production tmux evidence observes the exact argv, cwd, and environment at that pane's authenticated worker while a root-foreground-launcher sentinel is never entered. Distinct pane workers accept concurrent launches. Cancellation settles only after worker-reported child settlement and pane-terminal quiescence. A root launch still enters the root foreground launcher unchanged, and a composite unable to execute a pane launch refuses without fallback | | TG21 | Package boundary and canonical imports | Static dependency evidence proves terminal imports neither runtime, core, CLI nor terminal-tmux; terminal-tmux imports terminal and none of runtime, core or CLI; runtime has no terminal dependency; and CLI alone composes the document engine with the provider and host. The old runtime, core and CLI terminal modules and exports are absent, every repository terminal import names a canonical package surface, and each contextual descriptor and public error constructor has one definition. The relocated neutral, tmux, cross-package Agent and Deno/compiled host suites retain TG1–TG20 without changing syntax, provider identity, hidden-worker grammar, protocol, durable records or diagnostics; Node and Bun still install neither observer nor provider | +| TG22 | Mixed native providers | One controlled grid launches Claude and Codex concurrently through distinct pane endpoints. Codex's fixed materialization completes before its native child spawn; neither that turn nor Claude preparation is pane readiness. Both launches reach their authenticated workers, the root launcher is not entered, and close settles materialization/ACP handles, children, worker quiescence and Agent-session ownership before the grid completes. No real Agent session or demo run supplies this evidence | ### Tier CR — Component registration and resolution diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index 65397c9ff..30d48ca58 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -161,11 +161,13 @@ nonzero exit, signal, and cancellation do not produce it. It contains only filtered stable evidence and exposes no process handle, ACP client, credential, raw environment, or executable argument vector. -`launchAgentSession()` is distinct from `prompt()`: +`launchAgentSession()` is distinct from an authored `prompt()`: - `prompt()` performs one model turn through ACP and returns the agent response; -- `launchAgentSession()` performs no model turn, transfers the session to a - native UI, and returns only after that UI exits. +- `launchAgentSession()` returns no agent response. It transfers the session to + a native UI and returns only after that UI exits. It performs no model turn + except a protocol-declared materialization turn for a provider-returned + conversation this launch just created. The base `Agent.launch(request)` routing handler fails. A provider must install the route explicitly; availability of `agent()`, `session()`, and `prompt()` @@ -193,12 +195,16 @@ source position and expansion identity ``` The provider applies the instructions as a provider-native session, system, or -developer instruction layer before the native UI accepts its first user turn. -It does not send the instructions as a user message and does not invoke a model. +developer instruction layer before the native UI accepts its first authored +user turn. It does not send the instructions as a user message. A protocol may +declare one fixed materialization turn solely to make a newly created +provider-returned conversation resumable; that turn carries none of the +prepared instructions and cannot perform the prepared task. -A provider that cannot establish instructions without a model turn refuses the -launch. V1 has no bootstrap-prompt fallback. An explicit, observable bootstrap -mode is a separate future feature tracked by issue #514. +A provider that cannot establish the instruction layer before that first +authored turn refuses the launch. V1 has no author-supplied bootstrap-prompt +fallback. An explicit bootstrap mode remains a separate feature tracked by +issue #514. Prepared instructions are the current attachment's instruction layer. A relaunch with the same layer resumes the existing provider session and does not @@ -224,7 +230,9 @@ An empty cached transcript is not proof that a retained or previously handed-off session has no conversation. Native UI turns are provider-owned and need not be mirrored into ACPX or XMD state. Relaunch therefore never silently keeps a stale layer, discards unobserved native history, substitutes a new provider session -for retained continuity, or performs a bootstrap turn. +for retained continuity, or performs a bootstrap turn. A materialization turn +is not a bootstrap turn: it runs only for a conversation this launch just +created, carries no authored content, and never runs against a retained one. The prepared text and filesystem authority are different capabilities: @@ -291,10 +299,12 @@ provenance, because the two constructions make different claims. A **provider-returned** adapter proves all seven of the following: -1. session creation materializes durable state the native UI can resume; +1. session creation materializes durable state the native UI can resume, + either at creation or through the adapter's declared materialization turn; 2. its returned native ID names that exact state; -3. prepared instructions are effective on the first native user turn without a - bootstrap model turn; +3. prepared instructions are effective on the first native user turn without + an authored bootstrap turn, and a declared materialization turn cannot see + or act on those instructions; 4. cwd and permissions survive the handoff without being widened; 5. the ACP owner can release the session before native attachment; 6. the native process can exit without deleting the resumable session; and @@ -345,8 +355,9 @@ codex resume ### Executable build binding -A new client-allocated session records the executable observed before its -identity was published. This is immutable audit evidence and a durable +A new bound session records the executable observed before its route is +published and before either its client-allocated identity or provider-returned +conversation is created. This is immutable audit evidence and a durable cross-check, not a release lock: ```ts @@ -366,14 +377,14 @@ member absent and does not make an otherwise observable executable or capability unsupported. A path is never a member: it says where a build was, which stops being true, and it names host layout besides. -The V2 construction route and every prepared journal record derived from it +The V2 or V3 construction route and every launch journal record derived from it carry the same original binding exactly. They never replace it with a later observation. Exact agreement between those durable accounts detects a partial, foreign or inconsistent preparation; digest or version equality with the live executable is not required. A compatible later release continues the retained -native identity after it independently passes the same live capability gate. -The retained digest and version may be inspected as audit evidence, but neither -by itself authorizes or denies continuation. +native identity after it independently passes the applicable live capability +gate. The retained digest and version may be inspected as audit evidence, but +neither by itself authorizes or denies continuation. For the current V2 route, the exact provider, agent and launcher contract pins the stable `claude-client-native.v1` protocol under which its identity was @@ -381,6 +392,11 @@ published. A future incompatible protocol cannot reinterpret that route; it requires a route contract which names the new protocol. Executable releases may therefore change without allowing the session to cross protocol boundaries. +For V3, the exact schema, `acp-first` route, provider and Codex agent contract +pins `codex-provider-returned.v1`. V3 gains no optional protocol member whose +meaning a loaded copy could replace. Another provider-returned protocol requires +a new route schema rather than reinterpreting V3 history. + The host supplies an executable observer directly to the provider, alongside the coordinator and the route store. It resolves the launcher command through the host's real execution environment, canonicalizes the target, requires an @@ -429,7 +445,10 @@ current run. ```ts interface NativeCapabilityAdmission { readonly adapterProtocol: string; - readonly capability: "native-launch" | "client-native-attachment"; + readonly capability: + | "native-launch" + | "client-native-attachment" + | "provider-native-continuation"; readonly probeProfile: string; readonly platform: string; readonly architecture: string; @@ -474,14 +493,29 @@ the same help shape on another operating system or architecture is insufficient until the applicable proof passes there. Shared provider code detects no runtime. -After resolving and hashing a client-allocated adapter's executable, the -provider runs the read-only probe against that exact path and requires the -requested capability in its result. This precedes identity allocation, route -publication, private-file creation and native child start. Existing bound -routes are checked again before native resume, client-native attachment or -incomplete replay can contact the provider. A canonical version line and digest -are evidence about the observed build, not capability admission or a -cross-release refusal. +The built-in Codex profile recognizes the Codex product and structurally parses +the root command and `resume` subcommand help. Native launch requires the first +declared positional to be `[SESSION_ID]`, with semantics that accept a UUID as +the session identity, before the optional `[PROMPT]` positional. Its optional +spelling is intentional: omitting it enters Codex's picker, while the adapter +always supplies the exact retained identity. Provider-native continuation +independently requires the vendored ACP bridge's pinned +`codex-provider-returned.v1` assertion and resume contract. A substring mention +of `resume`, `SESSION_ID` or UUID, a renamed or reordered positional, changed +identity semantics, a different product, or an injected adapter protocol admits +nothing. The applicable proof passed with `codex-cli 0.153.2` on macOS arm64 and the vendored +`@agentclientprotocol/codex-acp` 1.6.2 snapshot; a later Codex release continues +only when its live executable independently advertises the same required shape +on that proved host envelope. + +After resolving and hashing a bound adapter's executable, the provider runs the +read-only probe against that exact path and requires the requested capability +in its result. This precedes identity allocation, route publication, ACP ensure +or creation, materialization, private-file creation and native child start. +Existing bound routes are checked again before native resume, client-native +attachment, provider-native continuation or incomplete replay can contact the +provider. A canonical version line and digest are evidence about the observed +build, not capability admission or a cross-release refusal. A protocol, shape, capability or host mismatch is `unsupported-capability`. The diagnostic may identify the agent, capability, adapter protocol and @@ -491,30 +525,30 @@ probe facts remain live admission inputs. A live digest or version different from the route's retained audit evidence is not a mismatch. The version query is optional and independent of the help probe. A future -Claude build with the same admitted protocol shape on macOS arm64 remains +Claude or Codex build with the same admitted protocol shape on macOS arm64 remains usable when its version changes, its version wording changes, or it omits -version output. It may establish a new session or continue an existing V2 +version output. It may establish a new session or continue an existing V2 or V3 session. Continuation adopts only the route's exact native identity and leaves the route, journal and provider history unchanged. The observed zero-turn behavior of a newer Claude that retains no conversation is not by itself a regression: claim 5 explicitly permits the provider to refuse that exact identity, provided XMD fails closed without substitution. -Compatibility admission adds no materialization turn. Launch still performs no -model turn, and bootstrap turns remain outside this contract. The metadata -query is not a disposable session and proves no hidden implementation behavior; -it is the CLI's side-effect-free declaration that the previously proved -protocol remains present. A provider that cannot make that declaration, or -needs a model turn to establish it, remains unsupported. Enabling a different -protocol requires its own real-CLI proof and admission profile; a version string -or adapter name is never enough. - -### Attachment capability - -Native launch and client-native ACP attachment are separate trusted-host -choices. Both may inspect one adapter protocol shape, but neither capability is -inferred from the other or from the Agent name. An adapter may be proven to hand -a session to a native UI without being proven to join that conversation +Compatibility admission never implies a materialization turn. Claude remains +zero-turn. Only `codex-provider-returned.v1` declares +`codex-materialization.v1`, and bootstrap turns remain outside this contract. +The metadata query is not a disposable session and proves no hidden +implementation behavior; it is the CLI's side-effect-free declaration that the +previously proved protocol remains present. Enabling a different protocol +requires its own real-CLI proof and admission profile; a version string or +adapter name is never enough. + +### Continuation capabilities + +Native launch, client-native ACP attachment and provider-native continuation are +separate trusted-host choices. They may inspect one adapter protocol shape, but +none is inferred from another or from the Agent name. An adapter may be proven +to hand a session to a native UI without being proven to join that conversation afterwards. `claude` is advertised for both only when each capability's live profile matches. @@ -543,9 +577,28 @@ the failure this contract asks for rather than a hopeful spawn. `claude` is advertised only when the applicable profile is observed inside the proved **macOS arm64** host envelope. Its client-allocated protocol claims were established through the production CLI at Claude Code 2.1.241; that version is -evidence for the profile, not its upper or lower bound. `codex` is unadvertised: -its command shape and adapter contract tests exist, and nothing has run its -provider-returned claims against an installed Codex. +evidence for the profile, not its upper or lower bound. `codex` is advertised +for native launch and provider-native continuation under the same host envelope. +Its provider-returned claims passed against `codex-cli 0.153.2` and the pinned +ACP bridge snapshot, including a later ACP rejoin to the exact native +conversation. Those versions identify the proof, not an allow-list. + +Only Codex owes a materialization turn. The App Server writes the rollout that +`codex resume ` reads at a thread's first accepted turn, so an ACP-created +conversation with no turn is not yet resumable by name. The stable +`codex-materialization.v1` protocol sends exactly this fixed text: + +```text +This turn only makes the Codex conversation resumable. Do not perform the prepared task, inspect or modify files, call tools, or take any external action. Reply with a brief acknowledgement only. +``` + +It contains no authored instruction, path, identity or environment value. XMD +announces the cost before sending it, denies every tool and external action, +requires the provider's canonical accepted-turn signal and exact native identity, +retains its response and reported usage as filtered evidence, and exposes +neither as document output. A tool call, failed or cancelled turn, non-`end_turn` +stop, missing acceptance signal, missing or changed identity, or failed +checkpoint retains `materialization-failed`; the native UI never starts. ## Runtime sequence @@ -571,13 +624,20 @@ session one of them constructs through ACP and the other does not. **Provider-returned.** ACP owns the session first and has to hand it over: -9. The provider creates or resumes the durable provider session and applies the - prepared instruction layer and contextual cwd configuration. -10. The provider verifies a native-resume capability and obtains the exact - native session ID. -11. XMD commits the prepared launch record before releasing ownership. -12. The provider closes or detaches the ACP session and waits for that owner to - terminate, and XMD commits that too. +9. A bound adapter first observes the exact executable it will use, admits + `native-launch` for its stable protocol and host envelope, and publishes or + adopts its create-once route. The observation precedes route publication, + runtime construction, ensure and every turn. +10. The provider creates or resumes the durable provider session, applies the + prepared instruction layer and contextual cwd configuration, and accepts + only its canonical provider-returned identity. +11. XMD commits the prepared launch record. When that record declares a + materialization plan, core retains the fixed turn as its ordinary durable + prompt before the provider runs it, then retains `materialized` with the + exact asserted identity before releasing ACP ownership. A protocol with no + plan spends no turn and writes no `materialized` phase. +12. The provider closes or detaches the ACP session, waits for that owner to + terminate, and XMD commits `detached` before any native child can start. **Client-allocated.** Nothing is created through ACP at all, so there is no owner to release — what has to be settled first is which conversation this is: @@ -692,6 +752,16 @@ this sequence, and every step happens while the coordinator holds the session: disagreement closes the handle and refuses before a turn. 10. Only then return a `Session`, or start the subscribed turn. +A `` or `` meeting V3 follows the ordinary ACP-first shape with +one extra gate. While holding the same coordinator ownership, it validates the +non-replaceable `codex-provider-returned.v1` interpretation, observes the +current executable, independently admits `provider-native-continuation`, and +selects the runtime for that live observation before ensure or a turn. The +ensured provider assertion must equal the exact native identity retained by the +materialized launch. Missing history, another identity, an incompatible live +protocol/profile/host, or an unavailable provider fails closed without +allocation, route conversion, materialization or substitution. + Runtime partitions are scope-owned and follow the executable serving live work, not the historical binding in a route. Different live builds never share an ACP child. Sessions carrying different historical bindings may share when the same @@ -964,7 +1034,7 @@ route grants no right to ensure, prompt, detach, spawn or accept history. It says only which kind of thing this session is, so a later operation cannot quietly treat a conversation that already exists as one it may name. -Two schemas are readable. The exact V1 record is: +Three schemas are readable. The exact V1 record is: ```ts type AgentSessionRouteV1 = @@ -1005,13 +1075,37 @@ interface AgentSessionRouteV2 { } ``` -There is no V2 `acp-first`: ACP-first construction gained no fact, and a second -schema for it would be a version number with nothing behind it. New +There is no V2 `acp-first`: ordinary unbound ACP-first construction gained no +fact, and a second schema for it would be a version number with nothing behind +it. New client-native construction publishes V2 and observes the build before it allocates an identity. Serialization preserves the schema it was given, so nothing here upgrades a route. -Every member of both schemas is exact. A path, adapter command, environment, +V3 is the bound ACP-first route for `codex-provider-returned.v1`, and only that +protocol: + +```ts +interface AgentSessionRouteV3 { + schema: "session-route.v3"; + route: "acp-first"; + provider: string; + agent: string; + sessionKey: string; + executableBinding: ExecutableBuildBindingV1; +} +``` + +It carries no native identity because the provider asserts that later, and no +launcher because its exact schema/route/provider/agent form already fixes the +non-replaceable protocol interpretation. New Codex provider-returned +construction publishes V3 after live admission and before runtime construction. +Existing V3 records written by the #755 proof are read under this same protocol; +their reported version and digest remain immutable audit evidence, not a live +release lock. Another provider or incompatible Codex protocol requires a new +route schema. + +Every member of all three schemas is exact. A path, adapter command, environment, argv, instruction text, credential, transcript, process fact or temporary path is not a member, and a record carrying one is refused rather than read partially. A binding beside a V1 record is such a member, which is what keeps a @@ -1019,10 +1113,20 @@ V1 record from being read as a V2 one. So are missing, malformed, unknown-schema, moved and natural-key-mismatched records. Only a file that is not there means the session has not been constructed yet. -A V1 `client-native` route is legacy-unbound. It remains valid for native resume -under the contract that created it, authorizes no ACP attachment, and is never -overwritten, supplemented or upgraded. A user who needs attachment creates a -differently named logical session under the bound contract. +That strictness is the intended shared-store behavior across unmerged branches. +A V1/V2-only build meeting V3 refuses the unknown record rather than treating +the occupied key as absent, deleting it or creating another session. The +integrated build reads V1, V2 and V3, writes V2 for new Claude client-native +construction and V3 for new Codex provider-returned construction, and never +rewrites a route merely because another executable release now serves it. + +A V1 route is legacy-unbound. A V1 `client-native` route remains valid for +native resume under the contract that created it, authorizes no ACP attachment, +and is never overwritten, supplemented or upgraded. A V1 `acp-first` route +remains ordinary ACP history, but a bound provider-returned adapter cannot +reinterpret it as V3 or use it for native launch. A user who needs either bound +continuation creates a differently named logical session under the bound +contract. The route shares the coordinator's namespace, natural key and digest, so one session names one lease, one ownership record and one route. The route directory @@ -1045,12 +1149,18 @@ construction effect: caller observed the failure, and preserving the route is what stops that uncertainty from later being reclassified. An `acp-first` route by itself is not establishment; -3. a launch by an adapter that names its own sessions reads both the route and +3. a launch by a bound provider-returned adapter admits the live executable, + reads both durable accounts, and publishes or adopts V3 before runtime + construction; existing V1 ACP-first history refuses rather than being + upgraded, while an exact V3 winner is adopted only after its stable protocol + is independently validated; +4. a launch by an adapter that names its own sessions reads both the route and existing durable ACPX state; existing state publishes or adopts `acp-first`, and otherwise the adapter allocates a candidate and publishes `client-native`; -4. a launch that adopts `acp-first` retains `identity-unavailable` at `prepared`, +5. a client-allocated launch that adopts `acp-first` retains + `identity-unavailable` at `prepared`, before allocation, private-file creation, detach or spawn; -5. a `session()` or subscribed prompt that meets `client-native` raises the +6. a `session()` or subscribed prompt that meets `client-native` raises the provider's typed route error before runtime creation, ensure, turn, close or accepted history. It retains no launch failure, because no launch was asked for. @@ -1088,7 +1198,7 @@ expansion identity `` derives. Its records contain preparation and lifecycle phases rather than the native conversation: ```text -prepared -> detached -> launched -> exited +prepared -> [materialized] -> detached -> launched -> exited ``` Each phase the launch completes is one retained record under that identity, @@ -1104,6 +1214,18 @@ deliberately not retained: an interrupted native process leaves `detached` as the last retained phase, and resuming reattaches the native UI to that same provider session. +`materialized` exists only when the prepared record carries a versioned +materialization plan. Core gives that fixed request an ordinary durable +`agent_prompt` identity and retains the prompt outcome before it retains the +launch phase, so replay cannot infer that an unrecorded paid turn is safe to +repeat. If the prompt outcome is retained but `materialized` is absent, replay +reconstructs the phase from the exact prompt identity and provider assertion. +If a prior invocation retained only the plan and no prompt outcome, replay +retains `session-recovery-required` and performs no turn: it cannot distinguish +"not sent" from "accepted before interruption." A turn completed and retained +is never sent again. Completed launch replay still reads only its journal and +performs no observation or provider work. + For a session XMD named, the retained phase is what decides the only safe continuation, because `detached` is retained before the exit phase is invoked: @@ -1117,31 +1239,42 @@ continuation, because `detached` is retained before the exit phase is invoked: allocates nothing at all. A prepared record carries `executableBinding` exactly when the route it agrees -with is bound. It is optional for compatibility: the client-allocated path was -released before any build was observed, so a record without it is legacy history -— readable, and resumable only under the native-only contract that wrote it. A -provider-returned preparation carries none, and a refusal that prepared no -identity invents none. - -Every incomplete replay requires exact agreement between its journal and its -route on identity, provenance, instruction digest, launcher and the original -build binding before its first live effect. It then observes the executable it -will use and independently requires the native-launch capability for the same -stable adapter protocol, positive CLI shape and proved host envelope. The live -digest and version need not equal the retained evidence. Neither durable account -repairs, republishes or rewrites the other: a replay that found a disagreement -has discovered that the session it was going to continue is not the session it -prepared, and retains `identity-unavailable` without starting a child. Equal -instructions may continue the retained identity; different instructions retain -`instructions-refused` and replace neither the layer, the route, the identity, -nor any provider state. - -A `prepared`-only replay still creates under the exact retained identity because -its handoff never began. A `detached` replay still resumes that identity and -never falls back to creation because a predecessor may have started. Both use -the current admitted executable path, allocate nothing and leave the historical -binding unchanged. A completed replay still observes no executable and performs -no live work. +with is bound. It is optional for compatibility: both construction paths were +released before every bound form existed, so a record without it is legacy +history and gains no current bound capability. A bound provider-returned +preparation carries the V3 route's exact original binding; a refusal that +prepared no identity invents none. + +Every incomplete replay requires exact agreement between its journal and route +on every fact both carry before its first live effect: provider, agent, logical +session, construction/provenance and original build binding. V2 additionally +agrees on native identity, instruction digest and launcher. V3's schema form +fixes the exact provider-returned protocol; its materialized journal identity +must agree with the canonical ACPX assertion and host mapping rather than with a +route that deliberately carries no native identity. Replay then observes the +executable it will use and independently +requires the applicable capability for the same stable adapter protocol, +positive CLI shape and proved host envelope. The live digest and version need +not equal the retained evidence. Neither durable account repairs, republishes or +rewrites the other: disagreement retains `identity-unavailable` without a turn, +ensure or child. Equal instructions may continue the retained identity; +different instructions retain `instructions-refused` and replace neither the +layer, route, identity nor provider state. + +For client-allocated history, a `prepared`-only replay still creates under the +exact retained identity because its handoff never began. A `detached` replay +still resumes that identity and never falls back to creation because a +predecessor may have started. + +For V3 provider-returned history, a materialized identity is the only identity +native launch may resume. A retained materialization outcome supplies that exact +assertion without another turn; a retained `detached` phase resumes it directly. +A prepared-only uncertain turn refuses as described above. Each live V3 +continuation validates the compiled non-replaceable protocol first, then observes +and admits the current executable before ACP ensure, materialization or native +resume. Both provenances leave the historical binding unchanged and allocate no +replacement. Completed replay still observes no executable and performs no live +work. An incomplete replay of a legacy unbound client-allocated launch retains `executable-binding-refused` before any live work: nothing available to it can @@ -1269,6 +1402,14 @@ adapter that cannot resume by name, a retained provider arrangement asserting another conversation or none, and a returned identity that differs from the route's are one answer, and none of them creates a substitute conversation. +A declared materialization that calls a tool, lacks the canonical acceptance +signal or exact provider identity, ends unsuccessfully, is cancelled, reports a +non-`end_turn` stop, or cannot retain its checkpoint fails with +`materialization-failed`. The prepared V3 route and provider state remain +authoritative, the native UI does not start, and replay never guesses that the +turn is safe to spend again. Cancellation settles the turn and its ACP handle +before session quiescence. + A native resume which the provider refuses because the exact retained identity has no conversation is the other permitted zero-turn answer. Its child outcome is retained normally and the authoritative route stays in place; a later @@ -1422,7 +1563,9 @@ Focused tests prove: 1. help discovers roles and performs no preparation or launch; 2. selecting one role excludes sibling preparation; 3. rendered instructions exactly match the selected files and computations; -4. no Agent prompt occurs during preparation or launch; +4. Claude preparation and launch perform no Agent prompt; Codex performs only + the exact retained `codex-materialization.v1` turn for a freshly created + conversation and never repeats it; 5. cwd, the explicit empty additional-directory list, and permission configuration reach the provider exactly; 6. a provider without native-launch capability fails before detach; @@ -1445,8 +1588,8 @@ Focused tests prove: natural key, contention refuses instead of queueing, a crashed owner leaves a recovery tombstone, and a host with no coordinator refuses before contacting an agent; -18. a build binding remains immutable audit evidence shared exactly by the V2 - route and its prepared journal record; a changed live digest, changed or +18. a build binding remains immutable audit evidence shared exactly by its V2 + or V3 route and launch journal; a changed live digest, changed or absent version and moved executable are accepted after fresh capability admission, while malformed evidence or disagreement between the two durable accounts refuses rather than being read past or repaired; @@ -1483,18 +1626,21 @@ Focused tests prove: requested capability, required read-only CLI shape, operating system and architecture; neither an Agent name nor version string admits it. A newer canonical version, omitted version and additive unrelated help are accepted - when that shape and host envelope match, including for an existing V2 route + when that shape and host envelope match, including for an existing V2 or V3 + route carrying a different digest or version, while a missing or ambiguous required member, another adapter protocol, or an unproved host is refused; 26. the metadata probe runs against the exact resolved executable and carries no terminal, stdin, session identity, instructions, credential or provider - state. It completes before identity allocation, route publication, - private-file creation, native child start, attachment ensure and incomplete - replay; its raw output reaches no route, journal, result or diagnostic, and + state. It completes before identity allocation, route publication, ACP + ensure or creation, materialization, private-file creation, native child + start, attachment ensure and incomplete replay; its raw output reaches no + route, journal, result or diagnostic, and completed replay runs no probe; -27. native launch and client-native attachment are admitted independently. A - launch-only observation cannot attach, an attachment-only observation - cannot launch, and a bound route whose exact identity is absent remains +27. native launch, client-native attachment and provider-native continuation are + admitted independently. A launch-only observation cannot continue through + ACP, an attachment- or continuation-only observation cannot launch, and a + bound route whose exact identity is absent remains authoritative and is never substituted. A settled exact-resume refusal acknowledges quiescence after cleanup, while a planted unproved teardown leaves the recovery tombstone active; and @@ -1507,7 +1653,29 @@ Focused tests prove: close without a turn or substitution when the provider reports an absent or different identity. Prepared-only and detached incomplete replays prove create-versus-resume remains phase-driven across the same upgrade, while - completed replay probes nothing. + completed replay probes nothing; and +29. V3 is parsed strictly and only as `codex-provider-returned.v1`; an existing + #755 record remains readable, its route and launch journal binding must agree + exactly, and a compatible Codex release continues the exact retained + provider identity without rewriting either. A V1/V2-only reader refuses V3 + as unknown rather than treating the key as absent; and +30. Codex materialization spends exactly one fixed tool-free turn for a new + conversation, retains the prompt before `materialized`, refuses every + unsafe outcome, and does not repeat the turn after cancellation or replay. + Prompt-retained replay reconstructs the phase, prepared-only uncertainty + refuses, detached replay resumes the retained identity, and completed replay + performs no observation, runtime or turn; and +31. Codex product and `resume` positional declarations are structurally parsed + from the frozen real help fixture. The first positional is the optional + `[SESSION_ID]` whose declared value accepts a UUID, before optional + `[PROMPT]`; mutations of product identity, order, identity value semantics, + protocol, capability and host each refuse, while a compatible version change + and unrelated additive help do not; and +32. one controlled mixed terminal grid launches Claude and Codex concurrently + through distinct pane endpoints, reports readiness only at each native child + spawn, never enters the root foreground launcher, and on close settles the + materialization/ACP handles, native children, pane workers and session + ownership before the grid completes. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1560,6 +1728,14 @@ documents own the schemas, the assertions and everything an operator reads, and no verdict may carry terminal output, argv, environment, prepared text, the history marker or a private path. +Codex's frozen proofs are `packages/acp/src/CodexNativeLaunch.test.md` and +`packages/acp/src/CodexZeroNativeTurnExit.test.md`. They establish the native +handoff, fixed materialization turn, exact App Server thread assertion, native +resume and later ACP rejoin against `codex-cli 0.153.2` on macOS arm64 with the +pinned adapter snapshot. This integration ports and preserves that evidence; its +focused test battery uses captured fixtures and controlled providers and does +not rerun a real agent session or the terminal-grid demo. + A proof also removes what it created, and that is the harness's own act rather than anything the product does. Production keeps the construction route and the retained phases — a launch that deleted them would break the continuity claim 4 @@ -1587,8 +1763,10 @@ and the immutable audit binding it produces on first publication; proof-scoped capability admission over the stable adapter protocol, independently requested capability, live observed CLI shape and live host platform on every continuation; compatible cross-release native resume and ACP attachment to a -bound client-native session under its exact retained identity, through runtime -partitions keyed by agent command and the live executable observation; +bound client-native session, plus compatible cross-release Codex native resume +and provider-native ACP continuation, under each route's exact retained +identity, through runtime partitions keyed by agent command and the live +executable observation; an inherited root- or pane-terminal interactive child with cancellation and bounded reaping; composition with the terminal grid's independent pane leases without changing session ownership or durable launch identity; @@ -1598,14 +1776,13 @@ model. The following capabilities remain outside V1 and fail closed rather than degrading: -- **Only the Claude client-native protocol has admitted profiles**, and - separately for each capability. Its applicable profiles recognize the - required CLI help shape on macOS arm64, where the claims under - *Provider-native identity* passed at Claude Code 2.1.241. A compatible newer +- **Only the Claude client-native and Codex provider-returned protocols have + admitted profiles**, separately for each capability. Their applicable + profiles recognize the required CLI help shape on macOS arm64, where the + claims under *Provider-native identity* passed at Claude Code 2.1.241 and + `codex-cli` 0.153.2 with the pinned ACP bridge snapshot. A compatible newer version remains admitted; a missing required shape or different platform does - not. `codex` has a command shape and contract tests and is not launch-capable, - because nothing has proven its provider-returned claims against an installed - Codex. A launch naming an unadvertised protocol or profile is refused with + not. A launch naming an unadvertised protocol or profile is refused with `unsupported-capability` before anything of the session moves, and so is an attachment whose own capability was not proved. - **`Agent.AddDir` is unbuilt**, so a launch declares no additional roots. The @@ -1617,11 +1794,12 @@ degrading: and `Session.Launch` expose no model prop or launch option. A provider may report the current model as observational evidence, but native launch neither selects nor changes it. -- **Protocol migration is unbuilt.** A V2 route fixes the stable - `claude-client-native.v1` protocol through its provider, agent and launcher - contract. A compatible executable release may continue it, but another - protocol cannot reinterpret, rewrite or adopt it. Supporting that transition - requires a route contract which names the new protocol. +- **Protocol migration is unbuilt.** V2 fixes `claude-client-native.v1` through + its exact provider, agent and launcher contract, and V3 fixes + `codex-provider-returned.v1` through its exact schema, route, provider and + agent contract. A compatible executable release may continue either, but + another protocol cannot reinterpret, rewrite or adopt one. Supporting that + transition requires a route contract which names the new protocol. - **A legacy unbound client-native session never attaches.** It was constructed before XMD recorded which build accepted its identity, so nothing available now can show this run is talking to that build. It keeps native resume and @@ -1634,15 +1812,17 @@ stays unsupported rather than weakening `Session.Launch` semantics. Native UI event mirroring, XMD-rendered interactive chat, simultaneous root foreground sessions outside a terminal grid, automatic nested `AGENTS.md` -discovery, bootstrap model turns, and workflow role scheduling are outside this -contract. +discovery, authored bootstrap model turns, and workflow role scheduling are +outside this contract. ## Structural checklist Implementation review checks these frozen invariants: 1. Only explicitly rendered `Session.Launch` content crosses as instructions. -2. Launch performs no model turn. +2. Launch performs no model turn beyond a protocol-declared materialization + turn, which runs at most once, only for a provider-returned conversation the + launch just created, with exact fixed bytes and no tool authority. 3. Provider-native identity is asserted, never inferred. 4. ACP and the native UI never concurrently own one session. 5. XMD remains the supervising parent and the document stays suspended. @@ -1673,8 +1853,8 @@ Implementation review checks these frozen invariants: reaches neither argv nor environment. 17. Private setup and child-creation failures are normalized before they cross a public or durable boundary. -18. A new client-native session retains immutable evidence for the executable - observed before its identity is published. The V2 route and prepared journal +18. A new bound session retains immutable evidence for the executable observed + before its route is published. The V2 or V3 route and prepared journal agree on that evidence and never rewrite it, while every later create, resume, attachment and incomplete replay independently admits the live executable without requiring its digest or version to equal the evidence. @@ -1683,8 +1863,9 @@ Implementation review checks these frozen invariants: and no partition that outlives its last handle. 20. Attachment supplies the route's exact identity as the resume identity, and the provider's canonical assertion must equal it before the first turn. -21. Native-launch advertisement and client-native attachment advertisement are - separate trusted-host choices, and neither is inferred from the other. +21. Native-launch, client-native attachment and provider-native-continuation + advertisement are separate trusted-host choices, and none is inferred from + another. 22. A released V1 route and a completed legacy journal remain readable, and neither authorizes ACP attachment or incomplete live replay. 23. A failed acquisition retains no partition and no live path; a partition is @@ -1725,14 +1906,29 @@ Implementation review checks these frozen invariants: 31. A real-CLI proof admits only its adapter protocol, independently requested capability, required observable CLI shape, operating system and architecture. Version output is optional evidence, not an allow-list key. A - client-allocated profile mismatch is refused after read-only observation but - before allocation or any provider or session-state mutation; its + bound profile mismatch is refused after read-only observation but before + allocation, route publication or any provider/session-state mutation; its identity-free launch refusal is retained. The profile is checked again before bound resume, attachment and incomplete replay, and a compatible live release continues the exact retained identity without allocating, republishing or rewriting durable evidence. An exact absent identity remains authoritative and unavailable; neither that refusal nor an unproved profile - creates a replacement conversation or a materialization turn. + creates a replacement conversation or an undeclared materialization turn. +32. V3 has one compiled, non-replaceable `codex-provider-returned.v1` + interpretation. Its original binding remains exact route/journal audit + evidence, while every new construction, native resume, provider-native + continuation and incomplete replay independently admits the current Codex + executable before observation-dependent provider work; completed replay + observes nothing. +33. `codex-materialization.v1` is retained before it can be spent and is never + repeated. A retained prompt outcome reconstructs the launch phase, an + uncertain prepared-only replay refuses, and no failure, cancellation or + concurrent winner can allocate, substitute or rewrite the provider identity. +34. Codex launch notification and native execution use the same root or pane + terminal capability. Terminal-domain code remains in + `@executablemd/terminal` and provider-specific tmux behavior remains in + `@executablemd/terminal-tmux`; the integration restores no deleted core, + runtime or CLI terminal path. Item 12 is the 2026-08-20 architecture amendment. ACPX fixes `systemPrompt` at session creation, while native turns are not authoritative in its cached From 2ba2da8c626623686858d6cd32984b1871952a74 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 04:54:03 -0400 Subject: [PATCH 60/62] =?UTF-8?q?=F0=9F=90=9B=20Align=20the=20extracted=20?= =?UTF-8?q?terminal=20package's=20@effectionx/node=20pin=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/terminal` is created by this stack's extraction commit, which was authored while the repository pinned `@effectionx/node` 0.2.4. Main has since moved to 0.2.5 (#748). A file a commit creates conflicts with nothing, so the rebase carried the stale pin forward beside a root that now asks for 0.2.5, and `deno task setup` refused: the frozen lockfile carries no `npm:@effectionx/node@0.2.4` specifier for a workspace member to resolve through. The pin now matches the one every other package and the root already use. The 0.2.4 entries that remain in `deno.lock` are `@effectionx/process@0.8.1`'s own transitive dependency, and they are present on main unchanged. No other dependency version moves. --- deno.lock | 2 +- packages/terminal/package.json | 2 +- pnpm-lock.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deno.lock b/deno.lock index f04adcbe3..d1d801112 100644 --- a/deno.lock +++ b/deno.lock @@ -4134,7 +4134,7 @@ "dependencies": [ "npm:@effectionx/context-api@0.6.0", "npm:@effectionx/fs@0.3.0", - "npm:@effectionx/node@0.2.4", + "npm:@effectionx/node@0.2.5", "npm:@effectionx/process@0.8.1", "npm:effection@4.1.0" ] diff --git a/packages/terminal/package.json b/packages/terminal/package.json index 568dd92ab..50fd6b489 100644 --- a/packages/terminal/package.json +++ b/packages/terminal/package.json @@ -13,7 +13,7 @@ "dependencies": { "@effectionx/context-api": "0.6.0", "@effectionx/fs": "0.3.0", - "@effectionx/node": "0.2.4", + "@effectionx/node": "0.2.5", "@effectionx/process": "0.8.1", "@executablemd/durable-streams": "workspace:*", "effection": "4.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2987519c7..f6b70e210 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -336,8 +336,8 @@ importers: specifier: 0.3.0 version: 0.3.0(effection@4.1.0) '@effectionx/node': - specifier: 0.2.4 - version: 0.2.4(effection@4.1.0) + specifier: 0.2.5 + version: 0.2.5(effection@4.1.0) '@effectionx/process': specifier: 0.8.1 version: 0.8.1(effection@4.1.0) From 1cc1dad0c83c050a0197a841942b08a5f86416b0 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 05:02:11 -0400 Subject: [PATCH 61/62] =?UTF-8?q?=F0=9F=90=9B=20Reconcile=20the=20terminal?= =?UTF-8?q?=20stack=20with=20main's=20renamed=20surfaces=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three call sites this stack added kept compiling against surfaces main has since changed. Each pair of edits touched different lines, so the rebase merged both sides without a conflict and the mismatch only surfaced under `deno check`. `xmd plan` used to settle a whole agent stack through a local three-argument helper. Main replaced that with the authorship-only resolver, which takes two arguments because the command starts no program, so the terminal-grid installer this stack threaded through no longer has a parameter to occupy. `xmd run` is unchanged and still installs it: the installer reaches `installRunAgentStack` through `resolveAgentStack`, which is the one path with a terminal to give away. The syntax suite's two terminal-grid rows still named `syntaxCatalog` and the local `parseCatalog`, which main renamed to `syntaxSymbols` and `parseSymbols`. Only the names move; both rows assert the same origins, authored forms, descriptions and reserved-name exclusions they always did. The launcher suite imported `reap` from the provider-neutral contract after this stack split the module in two. `reap` ends a POSIX child, so it comes from `posix-launcher.ts` beside `installForegroundLauncher`, and the contract import keeps only what a consumer that never reaches a process needs. --- packages/cli/src/cli.ts | 1 - packages/cli/tests/syntax-cli.test.ts | 4 ++-- packages/terminal/tests/native-launcher.test.ts | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9ad13d20a..8f274409c 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2511,7 +2511,6 @@ function* dispatch( const authorship = yield* resolveAuthorshipStack( { agentProvider: config.agentProvider, defaultAgent: config.defaultAgent }, sessions, - installTerminalGrid, ); if (!authorship.ok) { console.error(authorship.error.message); diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index bf719c5ec..19d7afbcb 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -305,7 +305,7 @@ describe("Tier SX — the run profile the command describes", () => { throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); }, }); - return yield* syntaxCatalog([]); + return yield* syntaxSymbols([]); }); const [structural, builtIn] = catalog.categories; @@ -573,7 +573,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(markdown.stdout).toContain(''); const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); - const structural = parseCatalog(json.stdout).categories[0].entries; + const structural = parseSymbols(json.stdout).categories[0].entries; expect(names(structural)).toContain("Terminal.Grid"); expect(names(structural)).toContain("Terminal"); }); diff --git a/packages/terminal/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts index 76f4584ea..4662e2db0 100644 --- a/packages/terminal/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -28,10 +28,9 @@ import { nativeLaunch, NativeLauncher, NO_TERMINAL, - reap, reserveTerminal, } from "../src/native-launcher.ts"; -import { installForegroundLauncher } from "../src/posix-launcher.ts"; +import { installForegroundLauncher, reap } from "../src/posix-launcher.ts"; const SENTINEL = "SENTINEL-PREPARED-CONTEXT-4b17"; From 44826a982965c3456710754565a52647e730e051 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Mon, 7 Sep 2026 07:34:50 -0400 Subject: [PATCH 62/62] =?UTF-8?q?=F0=9F=90=9B=20Bind=20the=20terminal=20st?= =?UTF-8?q?ack's=20event=20listeners=20to=20their=20owners=20(#717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase reintroduced fourteen listeners the ownership rule refuses: a raw `once()` in the POSIX launcher's spawned callback, subscriptions whose `ensure()` was yielded after them — itself a suspension an owner can be halted in — removals hidden behind a helper the pairing cannot be read from, a self-removing socket handler a cancelled scope never reaches, and three process signals registered under a computed name. Every one of them now names its receiver, event and handler on both sides, with the removal established before the subscription or held by a `finally` around it. The launcher keeps its inline exit/error race, its complete reap and a spawned callback that fires exactly once: the handler comes off when the start arrives and again on every way out, so a cancelled or failed launch publishes no readiness at all. `usePaneChannels` remembers each accepted socket with the handler it carries and detaches every one of them at teardown, including a socket that closed on its own. FL10 reads the child's own listener counts after delivery, after a startup failure and after cancellation, then replays `spawn` on it: restore the raw `once()` and the row fails. TW14 already held the tmux half, and fails if the new channel detach is removed. Terminal-grid TG3 expects the catalog version main's Syntax v2 migration publishes. Co-Authored-By: Claude --- packages/cli/src/grid-host.ts | 8 +- packages/core/tests/syntax-catalog.test.ts | 2 +- packages/terminal-tmux/src/attach-client.ts | 15 ++-- packages/terminal-tmux/src/pane-channel.ts | 28 ++++-- packages/terminal-tmux/src/pane-child.ts | 16 ++-- packages/terminal-tmux/src/pane-protocol.ts | 13 ++- packages/terminal-tmux/src/pane-worker.ts | 19 ++-- .../tests/terminal-grid-tmux.test.ts | 72 +++++++++------ packages/terminal/src/posix-launcher.ts | 62 ++++++++----- .../terminal/tests/native-launcher.test.ts | 87 +++++++++++++++++++ 10 files changed, 238 insertions(+), 84 deletions(-) diff --git a/packages/cli/src/grid-host.ts b/packages/cli/src/grid-host.ts index 591d773b3..2842bf8a3 100644 --- a/packages/cli/src/grid-host.ts +++ b/packages/cli/src/grid-host.ts @@ -167,12 +167,14 @@ export function useHangup(): Operation> { return resource>(function* (provide) { const hung = withResolvers(); const onHangup = (): void => hung.resolve(); - process.on("SIGHUP", onHangup); + // Removed with the run that installed it. A listener that outlived its + // grid would answer for a terminal the next one is using — and the removal + // is established before the subscription, because entering an ensure() is + // itself a suspension. yield* ensure(() => { - // Removed with the run that installed it. A listener that outlived its - // grid would answer for a terminal the next one is using. process.off("SIGHUP", onHangup); }); + process.on("SIGHUP", onHangup); yield* provide(hung.operation); }); } diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 83baec843..f840b6356 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -352,7 +352,7 @@ describe("Tier SY: structural vocabulary", () => { const catalog = yield* catalogFor({}, []); const entries = structural(catalog); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(find(entries, "Terminal.Grid")).toEqual({ kind: "structural", name: "Terminal.Grid", diff --git a/packages/terminal-tmux/src/attach-client.ts b/packages/terminal-tmux/src/attach-client.ts index 33eeda07d..b0717be28 100644 --- a/packages/terminal-tmux/src/attach-client.ts +++ b/packages/terminal-tmux/src/attach-client.ts @@ -142,8 +142,7 @@ export function useAttachClient(options: { // Named, and removed by this scope. `exit` stays through the wait that // establishes the client is gone, which is exactly why it is removed with // the resource rather than after one delivery. - // One of the two arrives, and whichever does takes both off. `exit` stays: - // establishing this client is gone is what waits on it. + // One of the startup pair arrives, and whichever does takes both off. const settleStartup = (): void => { child?.off("spawn", onSpawn); child?.off("error", onError); @@ -162,13 +161,17 @@ export function useAttachClient(options: { gone = true; exited.resolve(); }; - child.on("spawn", onSpawn); - child.on("error", onError); - child.on("exit", onExit); + // Established before the subscriptions and naming every one of them: + // entering an ensure() is itself a suspension, so a scope halted while it + // registers unwinds with nothing on it at all. yield* ensure(() => { - settleStartup(); + child?.off("spawn", onSpawn); + child?.off("error", onError); child?.off("exit", onExit); }); + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); // The pid, or whatever arrived instead of a start. const pid = yield* race([started.operation, failed.operation]); diff --git a/packages/terminal-tmux/src/pane-channel.ts b/packages/terminal-tmux/src/pane-channel.ts index 907781614..50f1457c9 100644 --- a/packages/terminal-tmux/src/pane-channel.ts +++ b/packages/terminal-tmux/src/pane-channel.ts @@ -126,6 +126,8 @@ export function usePaneChannels( const slots = new Map(); const servers: Server[] = []; const live = new Set(); + /** Every socket ever accepted, with the `close` listener this scope put on it. */ + const watching = new Map void>(); const refusals: string[] = []; const arrivals = createSignal<{ ordinal: number; socket: Socket }, never>(); /** Closures that have actually happened, by their own events. */ @@ -209,6 +211,14 @@ export function usePaneChannels( } yield* ensure(function* () { + // One accepted connection cannot be named at teardown, so each is + // remembered with the handler it carries and removed from the socket it + // was recorded against. Every socket ever accepted, not just the ones + // still open: a socket that closed on its own is off `live` and still + // carries what this scope put on it. + for (const [socket, onSocketClose] of watching) { + socket.off("close", onSocketClose); + } yield* closeAll(); }); @@ -225,24 +235,28 @@ export function usePaneChannels( // Named, every one of them. `createServer(cb)` and `listen(cb)` both // register anonymous listeners that nothing can take off again. const server = net.createServer(); + servers.push(server); + options.onServer?.(server); + closable++; const onConnection = (socket: Socket): void => { - live.add(socket); - closable++; const onSocketClose = (): void => { + // A socket that closed is nobody's to close again. live.delete(socket); - socket.off("close", onSocketClose); }; + live.add(socket); + watching.set(socket, onSocketClose); + closable++; socket.on("close", onSocketClose); options.onSocket?.(socket); arrivals.send({ ordinal, socket }); }; - server.on("connection", onConnection); - servers.push(server); - options.onServer?.(server); - closable++; + // Established before the subscription, because entering an ensure() is + // itself a suspension: a scope halted while it registers unwinds with + // nothing on it at all. yield* ensure(() => { server.off("connection", onConnection); }); + server.on("connection", onConnection); const listening = withResolvers(); const onListening = (): void => listening.resolve(); diff --git a/packages/terminal-tmux/src/pane-child.ts b/packages/terminal-tmux/src/pane-child.ts index d96b3db70..f8ce9b47d 100644 --- a/packages/terminal-tmux/src/pane-child.ts +++ b/packages/terminal-tmux/src/pane-child.ts @@ -168,15 +168,19 @@ export function usePaneChild( exited.resolve(settled); }; observe?.(child); - child.on("spawn", onSpawn); - child.on("error", onError); - child.on("exit", onExit); + // Established before the subscriptions and naming every one of them: + // entering an ensure() is itself a suspension, so a scope halted while it + // registers unwinds with nothing on it at all. The startup pair is usually + // gone by then; `exit` is this scope's until the end, because a settlement + // may still be waiting on it. yield* ensure(() => { - // The startup pair is usually gone already; `exit` is this scope's until - // the end, because a settlement may still be waiting on it. - settleStartup(); + child?.off("spawn", onSpawn); + child?.off("error", onError); child?.off("exit", onExit); }); + child.on("spawn", onSpawn); + child.on("error", onError); + child.on("exit", onExit); yield* provide({ started: started.operation, exited: exited.operation, settle }); }); diff --git a/packages/terminal-tmux/src/pane-protocol.ts b/packages/terminal-tmux/src/pane-protocol.ts index 61ece9ffc..8ac9c3206 100644 --- a/packages/terminal-tmux/src/pane-protocol.ts +++ b/packages/terminal-tmux/src/pane-protocol.ts @@ -295,12 +295,19 @@ export function readFrames( socket.destroy(); } + // Still the resource's, for the paths that terminate nothing: a cancelled + // scope, and a socket that simply never says anything. Established before + // the subscriptions, because entering an ensure() is itself a suspension, + // and naming each pair at the teardown that makes it good rather than + // behind `detach`. + yield* ensure(() => { + socket.off("data", onData); + socket.off("close", onClose); + socket.off("error", onError); + }); socket.on("data", onData); socket.on("close", onClose); socket.on("error", onError); - // Still the resource's, for the paths that terminate nothing: a cancelled - // scope, and a socket that simply never says anything. - yield* ensure(detach); yield* provide(queue); }); diff --git a/packages/terminal-tmux/src/pane-worker.ts b/packages/terminal-tmux/src/pane-worker.ts index b4d7d283e..0d7910f34 100644 --- a/packages/terminal-tmux/src/pane-worker.ts +++ b/packages/terminal-tmux/src/pane-worker.ts @@ -154,18 +154,19 @@ interface Live { */ export function useForegroundSignals(): Operation { return resource(function* (provide) { - const foreground: NodeJS.Signals[] = ["SIGINT", "SIGQUIT", "SIGTSTP"]; const ignore = (): void => {}; - for (const name of foreground) { - process.on(name, ignore); - } + // Installed and removed by the scope that runs this worker, so a worker + // that has finished stops answering for a pane it no longer owns. Each + // signal is named on both sides, and the removals are established before + // the first registration: entering an ensure() is itself a suspension. yield* ensure(() => { - // Installed and removed by the scope that runs this worker, so a worker - // that has finished stops answering for a pane it no longer owns. - for (const name of foreground) { - process.off(name, ignore); - } + process.off("SIGINT", ignore); + process.off("SIGQUIT", ignore); + process.off("SIGTSTP", ignore); }); + process.on("SIGINT", ignore); + process.on("SIGQUIT", ignore); + process.on("SIGTSTP", ignore); yield* provide(); }); } diff --git a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts index 3b8341878..ffaf47034 100644 --- a/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts +++ b/packages/terminal-tmux/tests/terminal-grid-tmux.test.ts @@ -30,6 +30,7 @@ import { withResolvers, } from "effection"; import type { Operation } from "effection"; +import { once } from "@effectionx/node/events"; import { spawn as spawnChild } from "node:child_process"; import type { ChildProcess } from "node:child_process"; import net from "node:net"; @@ -344,15 +345,9 @@ function useWorker(directory: string, ordinal: number): Operation }); yield* ensure(function* () { child.kill("SIGKILL"); - yield* until( - new Promise((resolve) => { - if (child.exitCode !== null || child.signalCode !== null) { - resolve(); - return; - } - child.once("exit", () => resolve()); - }), - ); + if (child.exitCode === null && child.signalCode === null) { + yield* once(child, "exit"); + } }); yield* provide(child); }); @@ -376,14 +371,25 @@ function untilFrame(link: PaneLink, type: FromWorker["type"]): Operation { return resource(function* (provide) { - const socket = net.createConnection(paneSocketPath(directory, ordinal)); - const connected = withResolvers(); - socket.once("connect", () => connected.resolve()); - socket.once("error", (error: Error) => connected.reject(error)); - yield* connected.operation; + let opened: net.Socket | undefined; + // Registered before the connection exists, so a halt between opening a + // socket and registering its cleanup cannot leave one open. yield* ensure(() => { - socket.destroy(); + opened?.destroy(); }); + const socket = net.createConnection(paneSocketPath(directory, ordinal)); + opened = socket; + // Both waits are interpreted inline, in the same synchronous run as the + // connection, so whichever the socket reports is already being waited for. + yield* race([ + (function* (): Operation { + yield* once(socket, "connect"); + })(), + (function* (): Operation { + const [error] = yield* once<[Error]>(socket, "error"); + throw error; + })(), + ]); yield* provide(socket); }); } @@ -391,13 +397,14 @@ function useImpostor(directory: string, ordinal: number): Operation /** Settle when a socket closes, or say it did not within the grace given. */ function closedWithin(socket: net.Socket, limitMs: number): Operation { return (function* (): Operation { - const closed = withResolvers(); if (socket.destroyed) { return true; } - socket.once("close", () => closed.resolve(true)); return yield* race([ - closed.operation, + (function* (): Operation { + yield* once(socket, "close"); + return true; + })(), (function* (): Operation { yield* sleep(limitMs); return false; @@ -2142,38 +2149,51 @@ function useTeardown(options: { // this row freezes is per-pane rather than per-event. let panes = 0; const belongs = new Map(); - const detachments: (() => void)[] = []; + /** Each socket this row watched, with the `close` listener it put on it. */ + const socketClosings = new Map void>(); + /** The same, for the servers. */ + const serverClosings = new Map void>(); + /** The same, for the servers whose connections this row attributes. */ + const connections = new Map void>(); const noteSocket = (socket: Socket, what: () => string): void => { handles.push(socket); const onClose = (): void => { log.push(what()); }; + socketClosings.set(socket, onClose); socket.on("close", onClose); - detachments.push(() => socket.off("close", onClose)); }; const noteServer = (server: Server, what: () => string): void => { handles.push(server); const onClose = (): void => { log.push(what()); }; + serverClosings.set(server, onClose); server.on("close", onClose); - detachments.push(() => server.off("close", onClose)); }; + // Established before a single handle exists. A listener this row installed + // is this row's, and a cancelled row never sees the closes it was waiting + // for, so each pair is removed from the emitter it was recorded against. yield* ensure(() => { - // This row's own listeners, off the emitters this row put them on. - for (const detach of detachments) { - detach(); + for (const [socket, onClose] of socketClosings) { + socket.off("close", onClose); + } + for (const [server, onClose] of serverClosings) { + server.off("close", onClose); + } + for (const [server, onConnection] of connections) { + server.off("connection", onConnection); } }); const channels = yield* usePaneChannels(options.workers.length, { onSocket: (socket) => noteSocket(socket, () => `socket-closed:${belongs.get(socket) ?? -1}`), - onServer: (server) => { + onServer: (server: Server) => { const ordinal = panes++; const onConnection = (socket: Socket): void => { belongs.set(socket, ordinal); }; + connections.set(server, onConnection); server.on("connection", onConnection); - detachments.push(() => server.off("connection", onConnection)); noteServer(server, () => `server-closed:${ordinal}`); }, }); diff --git a/packages/terminal/src/posix-launcher.ts b/packages/terminal/src/posix-launcher.ts index 75d6029ee..de3523d34 100644 --- a/packages/terminal/src/posix-launcher.ts +++ b/packages/terminal/src/posix-launcher.ts @@ -45,6 +45,8 @@ export interface ForegroundLauncherOptions { isTerminal?: () => boolean; /** Everything this host has still to show the reader. */ drain?: () => Operation; + /** Handed each process, so a suite can ask the emitter what it still holds. */ + observe?: (child: ChildProcess) => void; } /** @@ -90,7 +92,7 @@ export function* installForegroundLauncher( yield* drainStream(process.stderr); }, *launch([request, spawned]) { - return yield* runForeground(request, spawned); + return yield* runForeground(request, spawned, options.observe); }, }, { at: "min" }, @@ -125,6 +127,7 @@ function drainStream(stream: DrainableStream): Operation { function runForeground( request: NativeLaunchRequest, spawned: () => void, + observe?: (child: ChildProcess) => void, ): Operation { return scoped(function* (): Operation { const [command, ...args] = request.command; @@ -150,32 +153,45 @@ function runForeground( stdio: "inherit", }); child = started; + observe?.(started); // The runtime's own start event, and the only thing reported as one. A // spawn that fails emits `error` instead, so a child that never ran never - // reports having started. - started.once("spawn", () => spawned()); + // reports having started. Off on arrival and off again in the `finally`, + // so one start is reported exactly once and a launch that was cancelled or + // never started reports none at all. + const onSpawn = (): void => { + started.off("spawn", onSpawn); + spawned(); + }; - // Raced inline, in the same synchronous run as the spawn, so both arms are - // attached before the child can report anything — a spawned race attaches - // a turn later. Whichever loses is halted, which is what detaches it. - return yield* race([ - (function* (): Operation { - const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); - const outcome: NativeLaunchOutcome = {}; - if (code !== null) { - outcome.exitCode = code; - } - if (signal !== null) { - outcome.signal = signal; - } - return outcome; - })(), - (function* (): Operation { - const [error] = yield* once<[Error]>(started, "error"); - throw error; - })(), - ]); + try { + started.on("spawn", onSpawn); + + // Raced inline, in the same synchronous run as the spawn, so both arms + // are attached before the child can report anything — a spawned race + // attaches a turn later. Whichever loses is halted, which is what + // detaches it. + return yield* race([ + (function* (): Operation { + const [code, signal] = yield* once<[number | null, string | null]>(started, "exit"); + const outcome: NativeLaunchOutcome = {}; + if (code !== null) { + outcome.exitCode = code; + } + if (signal !== null) { + outcome.signal = signal; + } + return outcome; + })(), + (function* (): Operation { + const [error] = yield* once<[Error]>(started, "error"); + throw error; + })(), + ]); + } finally { + started.off("spawn", onSpawn); + } }); } diff --git a/packages/terminal/tests/native-launcher.test.ts b/packages/terminal/tests/native-launcher.test.ts index 4662e2db0..41ab2d7c7 100644 --- a/packages/terminal/tests/native-launcher.test.ts +++ b/packages/terminal/tests/native-launcher.test.ts @@ -23,6 +23,7 @@ import * as path from "node:path"; import * as os from "node:os"; import process from "node:process"; import { spawn as spawnChild } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; import { flushOutput, nativeLaunch, @@ -281,6 +282,92 @@ describe("Tier FL — the foreground native launcher", () => { yield* sleep(200); expect(yield* beats(heartbeat)).toBe(before); }); + + /** + * Counted on the child itself, after each launch has ended, which is when the + * removal is supposed to have happened — and then the start event is replayed + * on it, because a handler left attached is one that would report a start for + * a launch nobody is waiting on any more. + */ + it("FL10: a launch leaves nothing on its child, and none of them reports a late start", function* () { + const dir = yield* useTempDir(); + const children: ChildProcess[] = []; + const order: string[] = []; + const listeners = (): number => + children.reduce( + (total, child) => + total + + (["spawn", "error", "exit"] as const).reduce( + (count, name) => count + child.listenerCount(name), + 0, + ), + 0, + ); + const watch = (child: ChildProcess): void => { + children.push(child); + }; + + // Delivery: a child that starts and exits. + const fake = yield* useFake(dir, "claude", { exitCode: 0 }); + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + yield* NativeLauncher.operations.launch({ command: [fake.command], cwd: dir }, () => + order.push("started"), + ); + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + expect(order).toEqual(["started"]); + + // Startup failure: `error` arrives and `spawn` never will, so the handler + // that would report a start is one only the launch's own end takes off. + children.length = 0; + order.length = 0; + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + try { + yield* NativeLauncher.operations.launch( + { command: [path.join(dir, "not-a-program")], cwd: dir }, + () => order.push("started"), + ); + } catch { + // That it refuses is FL9's claim; this row reads what it left behind. + } + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + // A child that never ran does not become one that started, however late + // the event arrives. + children[0]?.emit("spawn"); + expect(order).toEqual([]); + + // Cancellation, while the child is live and may not yet have been reported. + children.length = 0; + order.length = 0; + const hanging = yield* useFake(dir, "hangs", { hang: true }); + yield* scoped(function* () { + yield* installForegroundLauncher({ isTerminal: () => true, observe: watch }); + yield* reserveTerminal(); + const running = yield* spawn(function* () { + yield* NativeLauncher.operations.launch({ command: [hanging.command], cwd: dir }, () => + order.push("started"), + ); + }); + // Coordinated by the child existing, never by a duration. + while (children.length === 0) { + yield* sleep(15); + } + yield* running.halt(); + }); + expect(children.length).toBe(1); + expect(listeners()).toBe(0); + // Whatever this launch reported before it was cancelled, it reports no more. + const reported = [...order]; + children[0]?.emit("spawn"); + expect(order).toEqual(reported); + }); }); describe("native launcher — the reaper's own listener", () => {