diff --git a/architecture.md b/architecture.md index 88f1bdaf..a134cfb9 100644 --- a/architecture.md +++ b/architecture.md @@ -107,6 +107,7 @@ Existing documents and code get aligned to this section retroactively. | 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 | | 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 | +| invocation-scoped Agent session | an agent conversation that exists only while the provider that opened it does. Its ACP records live in memory that provider owns and no other store can read, it publishes no construction route, asserts no durable identity and is never materialized or established, and its handle is closed at teardown — so nothing about it is retained, resumed, or handed to another process. Which agents are invocation-scoped is the trusted host's declaration, made from the resolved agent name before any adapter is prepared, any availability probe runs and any store is read, and never inferred from a command, adapter metadata, a record, a title or anything an agent answered. A host that cannot serve the lifetime an agent needs refuses there, which is what puts every durable-only operation's refusal ahead of contacting that agent | | 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 | @@ -3828,8 +3829,8 @@ Status is measured against main. | --- | --- | --- | | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset | built on the #632 stack | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | -| `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, and ordinary help and version. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, `--verbose`/`-V`, `--journal`/`-j`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No invocation writes a journal, and no permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack | -| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy) | +| `xmd plan` | turns one Prompt into approved Plan source and delivers it, by executing exactly one root document and starting no program. That root is the packaged plan command document, under the internal `` identity — an adapter that projects the request into `` and returns what comes back — and inside that, the packaged `` Component under the authorship profile its own `` installs: one enclosing Session, a host ceiling of one host-owned directory dedicated to that logical session — under `~/.xmd/plan/sessions` by default, keyed by the digest of the name, never the name, created empty and required to be empty before the provider exists or a session is materialized, refused rather than cleaned when it is not, durable when the caller named the session and handed back non-recursively after teardown when it did not — with no additional directories, no MCP servers, no native tools and a private strict denial no permission flag widens, no Files, command, service or network capability for that document, no repository component search, and the Component's own private ``, whose closed assessment reports the structural defects the draft authored. The invocation settles one structural check — `validateDocumentStructure` under the ordinary run-profile registry, the `` identity, the caller's includes and the run profile's declarations — and ``, `` and the command's own final gate all ask that one, so what they can disagree about is when it was asked rather than what was asked. There is no caller-source defect to tell them apart from: the grammar is fixed and complete before a draft exists, because no generated document adds an option to a command whose result *is* the document. Its instructions require every Plan to begin with one descriptive level-one title and to keep the Prompt's outcomes as readable steps with each component beside the step it performs, through repairs and revisions alike; that is an authorship and human-review requirement, and `` never enforces it. A tenth draft that still has problems may be stopped or explained: the explanation is one more ordinary turn in the same Session carrying only the final diagnostics, is inert text, reopens no draft limit, and ends the command. The host's instruction layer states only that an answer belongs to the message that asked for it, so which shape a turn wants stays in the document. Authorship sits outside durability: it runs on an invocation-owned in-memory stream that is never journaled, persisted, reused or replayed. Then, only after every provider, Prompt task and Elicitation resource inside the authorship frame has torn down, the Component's own `` structurally admits the exact approved bytes, and after that execution ends the host asks the same check once more — about a tree the whole teardown has had time to move, so a component the approved Plan names and something removed after that admission is refused here and nowhere else, and a Plan declaring properties a later run will supply is admitted rather than refused — and delivers it to exactly one sink: stdout byte for byte by default, or an exclusively created `--output` path. It starts no later root and retains no later execution, so the caller's own composition decides whether the program runs: `xmd plan … | xmd run -`, or an `--output` artifact a later `xmd run` names. Its grammar is what that leaves: one request, `--include`, `--agent-provider`, `--default-agent`, `--session`, `--timeout`, `--output`, and ordinary help and version. Every option that configured the former execution — `--run`, the aggregate and generated root properties, `--raw`, `--verbose`/`-V`, `--journal`/`-j`, the exec and fetch deadlines, the three permission flags and both secret-detection spellings — is refused by name in fixed preflight, before the general parser can drop or coerce a token and before `--help` can short-circuit the dispatch, in either order; `--run` reports the migration that names both compositions, and every other one reports that `xmd run` is where a program is configured. The same options are unchanged under `xmd run`. No invocation writes a journal, and no permission mode is settled at all. The retired `prompt` spelling is not a command and is not absorbed by the default `run` grammar, which would read it as a document reference and execute a file of that name: an invocation whose exact first token is `prompt` is refused before any scan, selection or path lookup, establishing nothing, while `xmd run ./prompt` still executes a document legitimately called that | built on the #660 stack. Writing a Plan is a conversation a caller may name and return to, so a settled default agent whose sessions end with the invocation supplies no Agent context at all: the command refuses on the existing no-context sentence before a directory, a provider or a session exists, and a named session does not change that answer | +| `` | writes and reviews one Plan, from a Prompt an ordinary document wrote. `` expands its paired body once with the capabilities the calling document already has — the complete untrimmed rendering is the Prompt, and it is never emitted separately — and produces the exact approved Plan source. It is a paired **exact text** component: the bare form emits that source into the calling document's own rendering, and the `as` form captures the same bytes instead. Neither form evaluates what it produced. It is the public name of the packaged `` Component: exact first-party Markdown declared to every ordinary run, so a repository `Plan.md`, a workflow bundle, a registration, `Component.importComponent` middleware and another loaded copy can none of them answer for it. Paired only, with one optional non-empty `session` prop and an optional `as`; a body that renders to nothing fails before any catalog, directory, Session, turn, review or check exists. The Agent writes under exactly the `xmd plan` fixed policy however broad the calling document's authority is, and a host that supplies no Agent context refuses before placement — including the `` child an `xmd test` document launches, which is the run profile and therefore resolves the same protected bytes rather than reporting a missing component, and which supplies one when it declares a canonical ``. An authored `session` keeps the existing durable named-directory lifetime, so the same name at the same site reaches the same conversation next time, while an omitted one is site- and iteration-unique, replay-stable and handed back after teardown; sibling sites stay distinct even when they write one name, and the name never becomes a path. Every turn, answer, check, approval and admission belongs to the enclosing document's journal, so a continuation restores completed authorship instead of repeating it; there is no second journal. Complete authorship teardown precedes structural admission, which precedes the emission or the binding — and the admission is structure alone, so a Plan declaring properties a later run will supply is produced rather than refused. It creates no file and executes nothing it produced | built on the #660 stack; no delivery, custom root, policy prop or replacement selector exists (#536 owns constrained caller-authored policy). A host whose settled default agent is invocation-scoped supplies no Agent context, so `` is refused on the same no-context sentence before placement whether or not it named a `session` | | `` / `` | chooses one branch by comparing a value with `===`. `` decides its whole case structure from source before evaluating anything, then evaluates the selector once and each non-default matcher at most once in source order, expands the first `===` match — or the final default, or nothing — inline and transparently, and appends no journal event | built on the #692 stack | | `xmd upgrade` | replaces the standalone binary that ran it with a published release, by executing one root document: the packaged upgrade command document, under the internal `` identity, with an empty component search path and no Files, Process, Service, command, Fetch, Agent, Elicitation, workflow or repository capability. That document is an **ordinary streaming text root** — it declares no `returns` and uses neither `` nor `` — so its rendered body is the command's output: each root segment reaches the reader as it completes, and a branch the command did not take contributes no prose, no phase call and no result. Its durable events go to one invocation-local in-memory stream, or to the file `--journal` named and the CLI exclusively created; neither is ever read back, and neither grants any resume or retry authority. Markdown owns the whole of the policy — the exact-tag grammar, which release is selected, semantic-version comparison through the npm `semver` package, which consent an install needs, the status, already-current and installation branches, and the wording of every refusal and every report. A compiled macOS or Linux binary whose platform the release publishes for is the only host that declares the four phases that policy may reach, ``, ``, `` and ``, and it declares them to canonical execution rather than through any contextual Api, middleware, repository lookup, ordinary `xmd run` profile or public syntax catalog; every other entrypoint states its provenance and no authority at all, so an npm, Bun, Deno-source or compiled Windows invocation has no phase to reach and stops at its own refusal before release lookup or any filesystem change. That host alone owns the private half: the exact `process.execPath` spelling it will replace and never a link it resolved, one non-blocking exclusive advisory lock on a stable sidecar beside that file, the bounded anonymous GitHub reads under a scope-bound abort signal, the downloaded bytes, the digest, the staged candidate it runs for its version, and one same-directory rename. Opaque identity is the boundary between the two halves — a release identity per admitted release, then one candidate advancing `downloaded → verified → committed` exactly once, with one installation attempt per invocation — so the document chooses among the releases it was shown and can name no other release, target, asset or destination, skip verification or replay a phase. Before the rename every failure and cancellation leaves the installed file byte-identical; after it the candidate is authoritative and no cleanup restores the old bytes | built on the #659 stack | | `` / `printErrors(fn)` | prints failures | built on main | @@ -3864,7 +3865,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 | +| 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. An invocation-scoped agent has no durable identity for a native UI to be handed or to resume, so a launch naming one is retained as `unsupported-capability` before placement, ownership, an adapter lookup, an ensure, a detach or any native process work, and a `` wrapped around it stays inert and local before the same refusal | 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 | +| invocation-scoped Agent session under `xmd run` | keeps one live Devin conversation for the length of one ordinary run and nothing beyond it. The run host resolves `devin` to exactly `devin acp`, which is the command ACPX recognizes and answers as Windsurf, and declares that agent invocation-scoped before an adapter is prepared or an availability probe spawns anything. A fresh `` is inert; the first subscribed `` probes availability, then ensures in persistent mode without first-turn-acceptance materialization, keeps that handle for the rest of the provider's life, and waits on no acceptance the provider does not publish. A second `` under the same `` continues that conversation and is serialized behind the first; a different `` gets its own handle and may hold a turn at the same time. The provider owns one in-memory ACPX store for this lifetime and a separate unbound runtime built on it, so no load or save crosses between lifetimes and a mixed run holds Devin and a durable agent without either seeing the other's records; an invocation-scoped session never enters a build-bound partition. Nothing it reports becomes durable: not the display title, the ACP session id, the ACPX record id, or Devin's `cognition.ai/userMessageId`, which stays unrecognized beside the Codex and Claude checkpoint namespaces. Halting a live turn sends ACP cancellation and waits for it; teardown attempts every active cancellation and every distinct owned handle close through the runtime that made it, reporting a failed close only after the rest were attempted, and the store becomes unreachable with the provider. An ordinary `--journal` remains this invocation's own trace and never continuation state. Lifetime is part of a provider-issued `Session`'s compatibility, compared before the resolved command and independently of it: two agent names can resolve to one command and be declared with opposite lifetimes, so consuming a Session through a disagreeing lifetime is refused before any further store access, route, ensure, turn or retention | built on the #740 stack; Devin is the only invocation-scoped agent, and durable Devin sessions, transcript injection, conversation forking and native Devin launch are unbuilt. Nothing here certifies the live provider: qualifying one against a real agent is a provider-neutral agent-ACP capability that does not exist yet, so this contract rests on the vendored ACPX Devin shim and the black-box evidence below rather than on a Devin-specific document | | `` | 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 | @@ -3893,7 +3895,7 @@ Status is measured against main. | Agent session portability evidence in an XMD artifact | classifies every logical Agent session that contributed a retained Prompt as portable — with ordered provider checkpoint tokens and an opaque Agent session bundle — or as explicitly unavailable, as two content kinds inside the existing version-1 manifest and identity | specified by `specs/xmd-artifact-spec.md` §2.5; the closed union, both content kinds and the complete post-identity profile verifier are built on the #621 stack, Deno provider only. Provider bundle capture, Agent-aware export, intrinsic Agent-aware inspection and artifact-backed fork are unbuilt | | historical authored source | retains an authored durable operation's normalized `SourcePosition` beside its identity, and history parses it or refuses the entry | built on the #367 stack | | history fork | creates a new run from one compatible checkpoint and retained Workspace root, under a new immutable definition and normalized props | built on the #368 stack, Deno provider only | -| workflow Agent session | a workflow document's `` runs under a profile the host attaches only for a live or partial run: an empty host-owned working directory instead of any Workspace, checkout or caller path, no MCP servers, an empty requested native tool set, and `deny-all` with a permission path that denies every native request and fails the turn that asked without reaching the public permission chain. Within a run a session is identified by the Agent/Session expansion identity the engine derived — the authored name is descriptive, so two sibling `` elements are two sessions — routed inside a placement bound to its element and good for one use, so a kept placement cannot be substituted for the next. The conversation is retained as a row in the run's own database with the provider, resolved agent command and policy fingerprint beside it as compatibility attributes. The order is placement, the backend's acceptance of the session's first turn, the provider's canonical tagged assertion, then the mapping commit — and only then is anything that turn produced exposed. A placement is inert: it creates no provider session and writes no row. Occupancy of a provider key is not an assertion, and a record held for a first turn nobody accepted asserts nothing at all; the pre-commit window reconciles only from exactly one canonical assertion, and a missing, conflicting, replaced or ambiguous assertion is one explicit refusal that starts no replacement. Deleting a run removes the row with the run and the provider-session directory beside it, and reports the categories. The profile selects ACP-only capability explicitly — no native-launch advertisement and no client-native attachment advertisement — rather than inheriting the provider package's ordinary-run sets by omission, and it supplies no machine session coordinator, construction-route store or executable observer: a workflow session belongs to a run, and the machine-wide account describes a different thing entirely | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling | +| workflow Agent session | a workflow document's `` runs under a profile the host attaches only for a live or partial run: an empty host-owned working directory instead of any Workspace, checkout or caller path, no MCP servers, an empty requested native tool set, and `deny-all` with a permission path that denies every native request and fails the turn that asked without reaching the public permission chain. Within a run a session is identified by the Agent/Session expansion identity the engine derived — the authored name is descriptive, so two sibling `` elements are two sessions — routed inside a placement bound to its element and good for one use, so a kept placement cannot be substituted for the next. The conversation is retained as a row in the run's own database with the provider, resolved agent command and policy fingerprint beside it as compatibility attributes. The order is placement, the backend's acceptance of the session's first turn, the provider's canonical tagged assertion, then the mapping commit — and only then is anything that turn produced exposed. A placement is inert: it creates no provider session and writes no row. Occupancy of a provider key is not an assertion, and a record held for a first turn nobody accepted asserts nothing at all; the pre-commit window reconciles only from exactly one canonical assertion, and a missing, conflicting, replaced or ambiguous assertion is one explicit refusal that starts no replacement. Deleting a run removes the row with the run and the provider-session directory beside it, and reports the categories. The profile selects ACP-only capability explicitly — no native-launch advertisement and no client-native attachment advertisement — rather than inheriting the provider package's ordinary-run sets by omission, and it supplies no machine session coordinator, construction-route store or executable observer: a workflow session belongs to a run, and the machine-wide account describes a different thing entirely. It also states which agents it can serve at all: a workflow continues its conversation across executions by reattaching the row its run database names, so an invocation-scoped agent is refused from agent resolution — ahead of adapter preparation, the availability probe and any turn — on a first use and on a partial continuation whose next unrecorded operation is a Prompt alike | built on the #302 stack, with the explicit ACP-only selection from #561; the portable proof that an adapter honours an empty tool set is tracked by #496 and does not widen the ceiling | | generated-XMD admission | admits one Agent-generated fragment through the trusted-host seam: host policy is a `read` table and a `write` table of exact pinned identities, each carrying the authored forms it is admitted for, and an authored `allow` selects a canonical non-empty subset of the closed classes — omitted means `read`. The complete source is preflighted inside one `generated_xmd` durable effect before its first generated effect; only the pinned identity the selected classes hold for that name **and** that form executes; and the admitted source, class selection, selected root, every selected entry with its forms, the identity and form of each element named, and the normalized request policy are retained in that effect's own result — so a continuation restores the decision without reading the current candidate and expands only the retained source. The roots are an as-of-admission retained basis checked by membership — the run's own later root publications and an advanced retained current root pass, while a lost admission root or lost selected root refuses — and every non-root term is checked exactly, refusing a run whose classes, identities, forms or requests have moved. The admission and every nested generated effect are offered inline by the owning expansion in authored order, so a partial continuation restores each completed one without another live execution. Each admitted effect is retained by its own ordinary record, and a read's value is collected while a mutation's is not | built on the #369 stack, continuation basis amended by #589; core owns the mechanics and the workflow policy wrapper is internal | | `` | the workflow host's component an authored document writes where an observation should happen. The host does not register it: it **declares** it to the execution through `ExecutionInstallation.components`, captured before any installation runs, and canonical execution calls its factory once for that attachment with the claimant it minted and registers what comes back. Registration provides availability only — a name a trusted document may write — and carries none of the authority. Its schema is closed on one required `source` string and one optional `allow` array selecting a non-empty duplicate-free subset of the closed effect classes `read` and `write` — omitted means `read` — and paired content is refused. It declares no `returns` and answers with a detached value — `{ observations: [{ name, value }], output }`, each admitted read's own returned value under the name the fragment invoked it by, in invocation order, with whatever the fragment rendered under `output` rather than instead of them, and the pinned identity that produced one left in the retained admission rather than copied here — so an admitted ``, which renders nothing at all, still reaches the document. An admitted mutation contributes no entry and no receipt, so a write-only fragment binds `{ observations: [], output: "" }`; `as` is valid for every selection and binds that same shape. An ordinary `as` captures that value by reference, and an authored `` renders it into the next ``: deciding how a value becomes text is the document's. Every ceiling comes from values the host captured at installation — the run's retained roots and its authoritative current root read from the run's own storage at invocation, as-of-admission provenance a continuation holds by membership so the run's own later publications and an advanced retained current root invalidate nothing, core's pinned self-closing `` read, the write table of core's paired ``, this package's lexical `` built from the definition the ordinary registration owns, and core's self-closing ``, and `` only when the captured request ceiling is non-empty — and no prop, binding, context or middleware return value supplies or widens one. `allow` selects among those tables and adds nothing to them; approval, when a workflow needs one, is authored control flow before the element. Its durable operation is named through that claimant, on the exact invocation the engine handed it and in that invocation's own frame — not from a context a document could rebind, a contextual Api answer, a definition, or a registry answer. Generated source never resolves through the registration: the evaluator consults only its own closed table of pinned identities. It is deliberately not wrapped in `printErrors`, so a refused fragment stops the authored loop rather than becoming text the next turn could read as a read that happened | built on the #302 stack, extended by #369 | | generated mutation proposals | lets an Agent propose constrained executable changes that a separate admission then performs against the run's own Workspace | built on the #369 and #567 stacks, with directory creation added by #643: the standard Deno profile's write table is core's paired `File:write`, the paired `@executablemd/workflow/composition/dir-v2#Dir` and core's self-closing `File.Delete`, in that retained order and followed by any host extension. `allow={["write"]}` intentionally authorizes Dir's persistent recursive directory creation; its versioned identity makes every continuation retained under the former non-mutating Dir identity refuse before generated execution. Admitted mutations run as the ordinary components they are through the run's effect transactions, and the evaluator adds no mutation API or receipt. Approval is authored control flow before the write-enabled element. Local Git, Git-host, issue, process, execution, credential and external-write effects are outside the class | diff --git a/packages/acp/mod.ts b/packages/acp/mod.ts index c39c348b..73f55beb 100644 --- a/packages/acp/mod.ts +++ b/packages/acp/mod.ts @@ -25,6 +25,7 @@ export type { AcpxProviderDependencies, AcpxSessionContext, AcpxSessionIdentity, + AcpxSessionLifetime, AcpxSessionPlacement, AcpxSessionPolicy, SessionRouteContext, diff --git a/packages/acp/src/provider.ts b/packages/acp/src/provider.ts index a7dc7ba9..d72d8470 100644 --- a/packages/acp/src/provider.ts +++ b/packages/acp/src/provider.ts @@ -150,6 +150,17 @@ export interface AcpxSessionPlacement { readonly state: "pending" | "established"; } +/** + * How long a session with one agent can be continued. + * + * `durable` is what every agent this provider serves has always been: the + * conversation outlives the invocation that created it, and a later run + * continues the one its record names. `invocation` is the opposite claim — the + * conversation exists only while this provider does, and nothing about it may + * be retained, resumed, or handed to another process. + */ +export type AcpxSessionLifetime = "durable" | "invocation"; + /** What ACPX asserted about a session it established. */ export interface AcpxSessionIdentity { readonly agentSessionId?: string; @@ -212,6 +223,20 @@ export interface AcpxProviderDependencies { * asks for one prepares nothing. */ prepareAgent?: (agentName: string) => Operation; + /** + * How long this host can continue one agent's sessions. + * + * Asked with the resolved agent name before this provider prepares an + * adapter, probes availability, reads a store, places a session, or starts a + * child. Only a successful answer is remembered; a host that cannot serve the + * lifetime an agent needs answers `Err`, and the operation refuses before the + * agent is contacted at all. + * + * Absent means `durable`, which is what a host with no opinion has always + * been saying. Nothing infers a lifetime from a command, adapter metadata, a + * session record, a title, or anything an agent answered. + */ + sessionLifetime?: (agentName: string) => Result; /** * The native adapters this host has proven and is therefore willing to hand * a session to. Absent means none: knowing an adapter's command shape is not @@ -344,6 +369,14 @@ interface RuntimeEntry { runtime: ProbeCapableRuntime; /** The `(agent command, binding)` partition, or nothing for the unbound one. */ partition: string | undefined; + /** + * The lifetime whose store this runtime was built with. + * + * Part of the runtime's identity rather than of the work it serves: a runtime + * carries one session store, and durable and invocation-scoped work must + * never see each other's records through a shared one. + */ + lifetime: AcpxSessionLifetime; /** Handles created through this runtime that have not been closed. */ handles: number; /** @@ -368,6 +401,22 @@ interface RuntimeEntry { * is what reattaches ACP to whatever holds the session now — a native UI it was * handed to, or nothing at all. */ +/** + * What this provider holds about one placement. + * + * `pending` and `established` keep their durable meanings: where a session will + * live, and one whose construction route and durable identity both exist. + * `live-invocation` is neither. The conversation exists right now, inside this + * provider, and nothing durable names it — so it is never promoted, retained or + * reported as established. + */ +type ManagedState = "pending" | "established" | "live-invocation"; + +/** What a placement may say about an entry. A live invocation names nothing durable. */ +function placementStateOf(state: ManagedState): "pending" | "established" { + return state === "established" ? "established" : "pending"; +} + interface DetachedSession { agentCommand: string; cwd: string; @@ -379,7 +428,15 @@ interface DetachedSession { * structural copy carrying the same key was issued by nobody. */ session: Session; - state: "pending" | "established"; + state: ManagedState; + /** + * The lifetime this placement was made under. + * + * Carried on the entry rather than looked up again, so every later record + * read, runtime election, refresh and close reaches the store this session + * was placed in rather than whichever one a caller happens to resolve to. + */ + lifetime: AcpxSessionLifetime; } interface ManagedSession extends DetachedSession { @@ -405,10 +462,11 @@ interface ManagedSession extends DetachedSession { /** Read-only session resolution; the placement linearization point. */ type Prepared = - | { kind: "existing"; sessionKey: string; entry: ManagedSession } + | { kind: "existing"; sessionKey: string; lifetime: AcpxSessionLifetime; entry: ManagedSession } | { kind: "placement"; sessionKey: string; + lifetime: AcpxSessionLifetime; agentCommand: string; placement: AcpxSessionPlacement; /** The exact value already issued for this placement, when there is one. */ @@ -419,6 +477,26 @@ function toError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); } +/** + * An ACPX session store that exists only in memory, for one provider scope. + * + * Keyed exactly as ACPX's own file store is — by the record id it saves under + * and loads by — so the runtime built on it behaves the same way, and nothing + * it writes reaches a disk, another store, or another invocation. + */ +function createInvocationStore(): AcpSessionStore { + const records = new Map(); + return { + load(sessionId: string): Promise { + return Promise.resolve(records.get(sessionId)); + }, + save(record: AcpSessionRecord): Promise { + records.set(record.acpxRecordId, record); + return Promise.resolve(); + }, + }; +} + /** The provider identity retained in a launch record. */ const ACPX_PROVIDER = "acpx"; @@ -818,6 +896,7 @@ function* useAcpxProviderState( const executableObserver = dependencies?.executableObserver; const agentCwd = dependencies?.agentCwd ?? cwd; const prepareAgent = dependencies?.prepareAgent; + const sessionLifetime = dependencies?.sessionLifetime; const mcpServers = dependencies?.mcpServers; const newSessionOptions = dependencies?.newSessionOptions; const sessions = dependencies?.sessions; @@ -843,14 +922,57 @@ function* useAcpxProviderState( ); /** - * One ACP runtime per `(agent command, executable build)`, plus the unbound - * one ordinary ACP-first work has always used. + * The store an invocation-scoped session's records live in. + * + * Memory this provider state owns, reachable through nothing else: it is not + * exported, not written to disk, not copied into the durable store, and gone + * when this scope closes. A second provider — another invocation, or a + * sibling partition — starts with an empty one, which is what makes a Devin + * placement fresh however it is spelled. + */ + const invocationStore = createInvocationStore(); + + /** Which store one lifetime's records are read from and written to. */ + function storeFor(lifetime: AcpxSessionLifetime): AcpSessionStore { + return lifetime === "invocation" ? invocationStore : store; + } + + /** + * What this host says about one agent's sessions, asked once. + * + * Only a successful answer is remembered: a host that could not answer is + * asked again rather than having its refusal cached as a decision. + */ + const lifetimes = new Map(); + function lifetimeFor(agentName: string): AcpxSessionLifetime { + if (sessionLifetime === undefined) { + return "durable"; + } + const remembered = lifetimes.get(agentName); + if (remembered !== undefined) { + return remembered; + } + const declared = sessionLifetime(agentName); + if (!declared.ok) { + throw declared.error; + } + lifetimes.set(agentName, declared.value); + return declared.value; + } + + /** + * One ACP runtime per `(agent command, executable build)`, plus one unbound + * runtime per lifetime. * * 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. + * + * The unbound runtimes are separated the same way, because a runtime carries + * one session store: durable and invocation-scoped work sharing one would be + * sharing every record either of them wrote. */ - let unbound: RuntimeEntry | undefined; + const unbound = new Map(); const runtimes = new Map(); /** * Every handle this provider created and has not successfully closed. @@ -866,11 +988,11 @@ function* useAcpxProviderState( const activeTurns = new Set(); const cleanupErrors: Error[] = []; - function* runtimeOptions(): Operation { + function* runtimeOptions(lifetime: AcpxSessionLifetime): Operation { const dir = yield* agentCwd(); const options: AcpRuntimeOptions = { cwd: dir, - sessionStore: store, + sessionStore: storeFor(lifetime), agentRegistry: registry, permissionMode: providerOptions.permissionMode, nonInteractivePermissions: "deny", @@ -915,8 +1037,11 @@ function* useAcpxProviderState( * step: an election that suspends part-way through is an election another * operation can act between. */ - function* runtimeBlueprint(build?: BoundBuild): Operation { - const base = yield* runtimeOptions(); + function* runtimeBlueprint( + lifetime: AcpxSessionLifetime, + build?: BoundBuild, + ): Operation { + const base = yield* runtimeOptions(lifetime); const options: AcpRuntimeOptions = { ...base, // The acpx callback boundary: `scope.run` returns a Promise-compatible @@ -946,8 +1071,12 @@ function* useAcpxProviderState( * longer names and the next operation builds a second child for the same * build. Here the entry is published already claimed. */ - function electRuntime(partition: string | undefined, options: AcpRuntimeOptions): RuntimeEntry { - const held = partition === undefined ? unbound : runtimes.get(partition); + function electRuntime( + partition: string | undefined, + lifetime: AcpxSessionLifetime, + options: AcpRuntimeOptions, + ): RuntimeEntry { + const held = partition === undefined ? unbound.get(lifetime) : runtimes.get(partition); if (held) { held.active++; return held; @@ -955,11 +1084,12 @@ function* useAcpxProviderState( const entry: RuntimeEntry = { runtime: createRuntime(options), partition, + lifetime, handles: 0, active: 1, }; if (partition === undefined) { - unbound = entry; + unbound.set(lifetime, entry); } else { runtimes.set(partition, entry); } @@ -985,6 +1115,7 @@ function* useAcpxProviderState( cwd: entry.cwd, session: entry.session, state: entry.state, + lifetime: entry.lifetime, }); } @@ -1061,15 +1192,26 @@ function* useAcpxProviderState( * yields between the handle arriving and that flag being set. */ function ensureThrough( + lifetime: AcpxSessionLifetime, build: BoundBuild | undefined, input: AcpRuntimeEnsureInput, toSession: (handle: AcpRuntimeHandle, entry: RuntimeEntry) => ManagedSession, ): Operation { return scoped(function* (): Operation { + if (build !== undefined && lifetime === "invocation") { + // A build binding is retained history: it says which executable + // established a conversation a later run may continue. An + // invocation-scoped session has no such history, so there is no + // combination to fall back to — the two claims contradict each other. + throw new Error( + `agent "${build.agentName}" keeps its sessions only for this invocation, so its ` + + `sessions cannot be bound to an executable build`, + ); + } const partition = build ? partitionOf(build) : undefined; // Every suspension this needs, taken before anything is decided. What // follows must not yield, so nothing it depends on may. - const options = yield* runtimeBlueprint(build); + const options = yield* runtimeBlueprint(lifetime, build); let claimed: RuntimeEntry | undefined; let pending: Promise | undefined; let settled = false; @@ -1121,7 +1263,7 @@ function* useAcpxProviderState( // claimed if this is the first work to want one, and the ensure is // started. Nothing yields in here, so no sibling runs between the // publication and the claim that keeps it alive. - const entry = electRuntime(partition, options); + const entry = electRuntime(partition, lifetime, options); claimed = entry; pending = entry.runtime.ensureSession(input); @@ -1163,6 +1305,11 @@ function* useAcpxProviderState( function* resolveAgent(name: string | undefined): Operation { const selected = name ?? providerOptions.defaultAgent; + // First, and before anything is put on disk or spawned. What this host can + // serve for this agent decides whether the rest of resolution happens at + // all, and a host that cannot serve it refuses here rather than after + // preparing an adapter for a session it will not allow. + const lifetime = lifetimeFor(selected); // Before the probe, because the probe spawns this agent's command: a host // that materializes its own adapter has to have done so by now, and a // failure here refuses the agent rather than reporting it unavailable for a @@ -1170,6 +1317,14 @@ function* useAcpxProviderState( if (prepareAgent !== undefined) { yield* prepareAgent(selected); } + // An invocation-scoped agent is not probed here. Probing spawns it, and the + // operations that cannot support such a session — a native launch, a + // workflow turn, Plan authorship — have to be able to refuse before this + // agent is contacted at all. A Prompt is the one operation that can support + // it, and it probes at its own placement. + if (lifetime === "invocation") { + return selected; + } // Resolution is read-only for an agent whose sessions XMD names. Probing // spawns an ACP child, and that is provider work on a session whose // construction has not been settled yet — it would run before the route is @@ -1177,20 +1332,28 @@ function* useAcpxProviderState( // before a host missing either capability has said so. Nothing on that path // needs the answer: the session is created by a native process, and where // ACP does serve one, the establishment itself reports being unable to. - if (!validatedAgents.has(selected) && !namesOwnSessions(selected)) { - const base = yield* runtimeOptions(); - const probe = createRuntime({ ...base, probeAgent: selected }); - const report = yield* until(probe.doctor()); - if (!report.ok) { - const code = report.code ? ` [${report.code}]` : ""; - const details = report.details?.length ? ` (${report.details.join("; ")})` : ""; - throw new Error(`agent "${selected}" is unavailable${code}: ${report.message}${details}`); - } - validatedAgents.add(selected); + if (!namesOwnSessions(selected)) { + yield* probeAvailability(selected, lifetime); } return selected; } + /** Spawn this agent once to find out whether it is there, and remember that it is. */ + function* probeAvailability(agentName: string, lifetime: AcpxSessionLifetime): Operation { + if (validatedAgents.has(agentName)) { + return; + } + const base = yield* runtimeOptions(lifetime); + const probe = createRuntime({ ...base, probeAgent: agentName }); + const report = yield* until(probe.doctor()); + if (!report.ok) { + const code = report.code ? ` [${report.code}]` : ""; + const details = report.details?.length ? ` (${report.details.join("; ")})` : ""; + throw new Error(`agent "${agentName}" is unavailable${code}: ${report.message}${details}`); + } + validatedAgents.add(agentName); + } + // Read-only session resolution. For a Session value it validates the // existing managed entry; otherwise it derives the placement (the // nearest-existing session), so the RESOLVED sessionKey — not the @@ -1199,6 +1362,7 @@ function* useAcpxProviderState( agentName: string, option: string | Session | undefined, callerCwd: string, + lifetime: AcpxSessionLifetime, sessionIdentity?: string, ): Operation { if (typeof option === "object") { @@ -1213,6 +1377,23 @@ function* useAcpxProviderState( `come from this provider's session()`, ); } + // Before the command, because two agent names can resolve to one command + // and still be served under opposite lifetimes: ACPX falls back to the + // agent name itself as the command, so a document naming the raw + // `devin acp` reaches the same child as the canonical `devin` while this + // host declares only the canonical name invocation-scoped. Reading the + // lifetime off the retained entry would let that placement decide what + // this Prompt is, which is how a session would silently change store, + // runtime and retention half way through. + // + // Refused here, where nothing has been touched yet: no second store + // access, no route, no ensure, no turn, no retention. + if (entry.lifetime !== lifetime) { + throw new Error( + `agent "${agentName}" (${lifetime} sessions) does not match session ` + + `"${option.sessionKey}" (${entry.lifetime} sessions)`, + ); + } const agentCommand = registry.resolve(agentName); if (agentCommand !== entry.agentCommand) { throw new Error( @@ -1228,16 +1409,17 @@ function* useAcpxProviderState( return { kind: "placement", sessionKey: entry.session.sessionKey, + lifetime: entry.lifetime, agentCommand: entry.agentCommand, placement: { sessionKey: entry.session.sessionKey, cwd: entry.cwd, - state: entry.state, + state: placementStateOf(entry.state), }, issued: entry.session, }; } - return { kind: "existing", sessionKey: option.sessionKey, entry }; + return { kind: "existing", sessionKey: option.sessionKey, lifetime: entry.lifetime, entry }; } const agentCommand = registry.resolve(agentName); if (sessions) { @@ -1247,10 +1429,18 @@ function* useAcpxProviderState( session: option, ...(sessionIdentity === undefined ? {} : { sessionIdentity }), }); - return placedPrepared(agentCommand, placed); + return placedPrepared(agentCommand, placed, lifetime); } - const placement = yield* resolveSessionPlacement(store, agentCommand, callerCwd, option); - return placedPrepared(agentCommand, placement); + // The store this lifetime's records live in, never a generic current one: + // asking the durable store where an invocation-scoped session lives would + // place it on another lifetime's history. + const placement = yield* resolveSessionPlacement( + storeFor(lifetime), + agentCommand, + callerCwd, + option, + ); + return placedPrepared(agentCommand, placement, lifetime); } /** @@ -1260,11 +1450,16 @@ function* useAcpxProviderState( * the exact object is what provenance is, and minting a replacement would * leave the first one unusable. */ - function placedPrepared(agentCommand: string, placement: AcpxSessionPlacement): Prepared { + function placedPrepared( + agentCommand: string, + placement: AcpxSessionPlacement, + lifetime: AcpxSessionLifetime, + ): Prepared { const held = managed.get(placement.sessionKey); const prepared: Prepared = { kind: "placement", sessionKey: placement.sessionKey, + lifetime, agentCommand, placement, }; @@ -1306,12 +1501,12 @@ function* useAcpxProviderState( const sessionKey = prepared.sessionKey; const held = managed.get(sessionKey); if (held && isLive(held)) { - return { kind: "existing", sessionKey, entry: held }; + return { kind: "existing", sessionKey, lifetime: held.lifetime, entry: held }; } const agentCommand = agentCommandOf(prepared); const cwd = held?.cwd ?? (prepared.kind === "existing" ? prepared.entry.cwd : prepared.placement.cwd); - const record = yield* until(store.load(sessionKey)); + const record = yield* until(storeFor(prepared.lifetime).load(sessionKey)); const established = held?.state === "established" || (record !== undefined && record.sessionMaterialization?.state !== "pending"); @@ -1320,7 +1515,7 @@ function* useAcpxProviderState( cwd, state: established ? "established" : "pending", }; - return placedPrepared(agentCommand, placement); + return placedPrepared(agentCommand, placement, prepared.lifetime); } /** @@ -1344,6 +1539,7 @@ function* useAcpxProviderState( cwd: prepared.placement.cwd, session, state: "pending", + lifetime: prepared.lifetime, }); return session; } @@ -1360,7 +1556,7 @@ function* useAcpxProviderState( interface EnsureIntent { attachment?: { build: BoundBuild; resumeSessionId: string }; /** The placement's state, as the caller resolved it. */ - state: "pending" | "established"; + state: ManagedState; /** * Let ACPX hold this record as occupancy until the backend accepts the * first turn, rather than asserting an identity the moment it is created. @@ -1394,6 +1590,7 @@ function* useAcpxProviderState( let managedEntry: ManagedSession; try { managedEntry = yield* ensureThrough( + prepared.lifetime, attachment?.build, { sessionKey: prepared.placement.sessionKey, @@ -1424,6 +1621,7 @@ function* useAcpxProviderState( cwd: prepared.placement.cwd, session, state: intent.state, + lifetime: prepared.lifetime, }; }, ); @@ -1458,7 +1656,13 @@ function* useAcpxProviderState( `did not report, so a turn taken here would not belong to it`, }); } - if (sessions?.established && intent.deferEstablished !== true) { + // Never for an invocation-scoped session: retention is what a host does + // with a durable identity, and this session has none to hand it. + if ( + prepared.lifetime === "durable" && + sessions?.established && + intent.deferEstablished !== true + ) { const identity: AcpxSessionIdentity = { ...(handle.agentSessionId === undefined ? {} : { agentSessionId: handle.agentSessionId }), ...(handle.acpxRecordId === undefined ? {} : { acpxRecordId: handle.acpxRecordId }), @@ -1742,8 +1946,12 @@ function* useAcpxProviderState( * disagree — a record asserting another conversation, or asserting none at * all, describes provider state this session cannot account for. */ - function* retainedAssertion(sessionKey: string, nativeSessionId: string): Operation { - const record = yield* until(store.load(sessionKey)); + function* retainedAssertion( + lifetime: AcpxSessionLifetime, + sessionKey: string, + nativeSessionId: string, + ): Operation { + const record = yield* until(storeFor(lifetime).load(sessionKey)); if (record === undefined) { return; } @@ -1788,7 +1996,8 @@ function* useAcpxProviderState( }, // An existing managed entry, or a durable record ACPX already kept, is // provider state — and existing history is never reclassified. - prepared.kind === "existing" || (yield* until(store.load(prepared.sessionKey))) !== undefined, + prepared.kind === "existing" || + (yield* until(storeFor(prepared.lifetime).load(prepared.sessionKey))) !== undefined, ); if (route.route !== "client-native") { return undefined; @@ -1821,7 +2030,7 @@ function* useAcpxProviderState( buildDrift(prepared.sessionKey, route.executableBinding, build.binding), ); } - yield* retainedAssertion(prepared.sessionKey, route.nativeSessionId); + yield* retainedAssertion(prepared.lifetime, prepared.sessionKey, route.nativeSessionId); return { build, resumeSessionId: route.nativeSessionId }; } @@ -2020,6 +2229,14 @@ function* useAcpxProviderState( return { *[Symbol.iterator]() { const agentName = yield* Agent.operations.agent(options?.agent); + const lifetime = lifetimeFor(agentName); + // The probe an invocation-scoped agent did not run at resolution, taken + // here because a Prompt is the one operation that can serve such a + // session — and taken before its placement, so an unreachable agent is + // still an availability failure rather than a failed turn. + if (lifetime === "invocation") { + yield* probeAvailability(agentName, lifetime); + } const callerCwd = resolve(yield* agentCwd()); const context: SessionRouteContext = { agentName, @@ -2030,7 +2247,7 @@ function* useAcpxProviderState( // Where this prompt lands. Resolving it constructs nothing, which is // what lets the queue below be entered before any provider effect. const placed = yield* withSessionRoute(context, () => - prepare(agentName, options?.session, callerCwd), + prepare(agentName, options?.session, callerCwd, lifetime), ); // The session's FIFO first, and before ownership. Two prompts on one @@ -2072,15 +2289,22 @@ function* useAcpxProviderState( // continues what its predecessor established instead of constructing // a second conversation beside it. const prepared = yield* requeried(placed); - const state = + // An invocation-scoped placement is live or it is nothing. It never + // becomes pending occupancy waiting for acceptance, and it never + // becomes established: no route, no host mapping, no durable + // identity, and therefore nothing to publish before the turn runs. + const state: ManagedState = prepared.kind === "existing" ? prepared.entry.state - : yield* placementState(agentName, prepared.agentCommand, prepared.placement); + : lifetime === "invocation" + ? "live-invocation" + : yield* placementState(agentName, prepared.agentCommand, prepared.placement); // Inside ownership, before the runtime exists and before a turn: a // first Prompt constructs this session through ACP, so that is what // its construction route says — and a session a native process // constructed is attached to under the identity it already has. - const attachment = yield* constructRoute(agentName, prepared); + const attachment = + lifetime === "invocation" ? undefined : yield* constructRoute(agentName, prepared); // The pending ACP-first branch, and the only one that defers: an // attachment resumes an identity that already exists, and an // established placement has one of its own. @@ -2103,7 +2327,7 @@ function* useAcpxProviderState( // scope's policy. const refresh = () => (function* () { - const record = yield* until(store.load(recordKey)); + const record = yield* until(storeFor(entry.lifetime).load(recordKey)); if (!record) { return undefined; } @@ -2271,7 +2495,7 @@ function* useAcpxProviderState( let existing; let route: AgentSessionRoute | undefined; try { - existing = yield* until(store.load(sessionKey)); + existing = yield* until(storeFor(prepared.lifetime).load(sessionKey)); route = yield* routeStore!.read(sessionKeyOf(agentCommand, sessionKey)); // A session ACP already established, from before this session had a route @@ -2507,7 +2731,7 @@ function* useAcpxProviderState( // return — a store read that fails, an instruction layer this provider will // not replace — and a claim taken before them is one those exits would have // to remember to give back. - const existing = yield* until(store.load(sessionKey)); + const existing = yield* until(storeFor(prepared.lifetime).load(sessionKey)); let sessionState: "created" | "resumed" = existing ? "resumed" : "created"; let reconciliation: InstructionReconciliation = existing ? "resumed" : "installed"; @@ -2537,6 +2761,7 @@ function* useAcpxProviderState( // can refuse, and a handle only the managed map knew about is one teardown // could not close through its creator. const managedEntry = yield* ensureThrough( + prepared.lifetime, undefined, { sessionKey, @@ -2559,6 +2784,7 @@ function* useAcpxProviderState( cwd: sessionCwd, session, state: "established" as const, + lifetime: prepared.lifetime, }; }, ); @@ -2721,6 +2947,24 @@ function* useAcpxProviderState( function launch(request: AgentLaunchRequest, authority: AgentProviderAuthority): Operation { return scoped(function* (): Operation { const agentName = request.agent; + const lifetime = lifetimeFor(agentName); + if (lifetime === "invocation") { + // Retained before placement, ownership, an adapter lookup, an ensure, a + // detach or a native process. A native UI resumes a session by its + // durable identity, and an invocation-scoped session has none — so + // preparing one to find that out would be contacting the agent to learn + // what this host already knows. + yield* authority.refuse( + request, + refusal( + "unsupported-capability", + `agent "${agentName}" keeps a session only for the invocation that created it, so ` + + `there is no conversation a native UI could be handed or resume`, + { agent: agentName }, + ), + ); + return; + } const callerCwd = resolve(yield* agentCwd()); const context: SessionRouteContext = { agentName, @@ -2729,7 +2973,7 @@ function* useAcpxProviderState( }; const placement = yield* withSessionRoute(context, () => - prepare(agentName, request.session, callerCwd), + prepare(agentName, request.session, callerCwd, lifetime), ); // This launch's own state, reachable only through the phase callbacks @@ -3050,14 +3294,24 @@ function* useAcpxProviderState( } const agentName = yield* Agent.operations.agent(); + const lifetime = lifetimeFor(agentName); const callerCwd = resolve(yield* agentCwd()); const context: SessionRouteContext = { agentName, session: named, cwd: callerCwd }; const prepared = yield* withSessionRoute(context, () => - prepare(agentName, named, callerCwd, sessionIdentity), + prepare(agentName, named, callerCwd, lifetime, sessionIdentity), ); + if (lifetime === "invocation") { + // An invocation-scoped `` names where a conversation would live + // and constructs nothing. There is no durable identity to validate + // eagerly and no ownership to take, so the element stays inert until a + // Prompt subscribes — and a live one is already open, so its own value + // comes back rather than a second ensure. + requireAssembly(agentName, prepared.sessionKey); + return prepared.kind === "existing" ? prepared.entry.session : placePending(prepared); + } const state = prepared.kind === "existing" - ? prepared.entry.state + ? placementStateOf(prepared.entry.state) : yield* placementState(agentName, prepared.agentCommand, prepared.placement); if (prepared.kind === "placement" && state === "pending") { // A fresh places a session; it does not create one. It has diff --git a/packages/acp/tests/provider.test.ts b/packages/acp/tests/provider.test.ts index b6b693f2..2b300294 100644 --- a/packages/acp/tests/provider.test.ts +++ b/packages/acp/tests/provider.test.ts @@ -6,13 +6,14 @@ */ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped, sleep, spawn, until, withResolvers } from "effection"; -import type { Operation } from "effection"; +import { Ok, scoped, sleep, spawn, until, withResolvers } from "effection"; +import type { Operation, Result } from "effection"; import { Agent, Config } from "@executablemd/core"; import type { AgentLaunchRequest, AgentPromptEvent, AgentProviderAuthority, + PreparedLaunchRecord, PromptOptions, Session, } from "@executablemd/core"; @@ -23,8 +24,10 @@ import { } from "../src/provider.ts"; import { TOOL_PERMISSION_REFUSED } from "../src/permission-bridge.ts"; import type { + AcpxProviderDependencies, AcpxSessionContext, AcpxSessionIdentity, + AcpxSessionLifetime, AcpxSessionPlacement, AcpxSessionPolicy, } from "../src/provider.ts"; @@ -39,7 +42,12 @@ import { useFlatWorld, useGitWorld, } from "./helpers.ts"; -import type { AcpPermissionRequest, AcpRuntimeTurnResult } from "../src/acpx-runtime.ts"; +import type { + AcpPermissionRequest, + AcpRuntimeTurnResult, + AcpSessionRecord, + AcpSessionStore, +} from "../src/acpx-runtime.ts"; import type { FakeRuntimeHarness } from "./helpers.ts"; const CWD = "/work"; @@ -1780,3 +1788,502 @@ describe("Tier APC — Prompt checkpoint metadata", () => { expect(checkpoints).toEqual([]); }); }); + +/** + * Tier AI — invocation-scoped Agent sessions + * (specs/acp-client-spec.md §Session lifetime). + * + * A host declares how long it can continue one agent's sessions, and Devin is + * the agent the ordinary run host declares `invocation` for. What this tier is + * about is everything that follows from that declaration: which store a session + * reads and writes, which runtime it goes through, that a live conversation is + * reused within one invocation and reachable from no other, and that every + * operation needing durable continuity refuses before Devin is contacted. + * + * The lifetime is stated to the provider exactly as the CLI states it, so a + * case reads this provider's own decision rather than a substitute for one. + */ + +const DEVIN = "devin"; +const DEVIN_COMMAND = "devin acp"; + +/** The ordinary run host's declaration, as `agent-stack.ts` writes it. */ +function declaredLifetimes(agentName: string): Result { + return Ok(agentName === DEVIN ? "invocation" : "durable"); +} + +/** A durable store that remembers every key it was asked about. */ +interface WatchedStore extends AcpSessionStore { + records: Map; + loaded: string[]; + saved: string[]; +} + +function watchedStore(): WatchedStore { + const inner = makeStore(); + const loaded: string[] = []; + const saved: string[] = []; + return { + records: inner.records, + loaded, + saved, + load(sessionId: string): Promise { + loaded.push(sessionId); + return inner.load(sessionId); + }, + save(record: AcpSessionRecord): Promise { + saved.push(record.acpxRecordId); + return inner.save(record); + }, + }; +} + +function* installLifetimeProvider( + harness: FakeRuntimeHarness, + store: AcpSessionStore, + authority: AgentProviderAuthority = stubAuthority(), + extra: Partial = {}, +): Operation { + yield* useFlatWorld(CWD); + const factory = createAcpxProvider({ + createRuntime: harness.create, + sessionStore: store, + agentRegistry: makeRegistry({ [DEVIN]: DEVIN_COMMAND, codex: "codex-cmd" }), + sessionLifetime: declaredLifetimes, + ...extra, + }); + yield* factory({ defaultAgent: DEVIN, permissionMode: "deny-all" }, authority); +} + +/** Which created runtime each session store belongs to, by index. */ +function storesOf(harness: FakeRuntimeHarness): AcpSessionStore[] { + return harness.createdOptions.map((options) => options.sessionStore); +} + +/** + * The two agent names one Devin command is reachable through. + * + * ACPX resolves an unknown agent name to the name itself, so a document that + * writes the raw command reaches the same child as the canonical name — while + * this host declares only the canonical name invocation-scoped, because a + * lifetime is never inferred from a command. That is the production shape, not + * a contrivance: `agent-stack.ts` overlays exactly `devin`. + */ +const DEVIN_RAW = "devin acp"; + +function* installAliasedProvider( + harness: FakeRuntimeHarness, + store: AcpSessionStore, + observed: { established: AcpxSessionIdentity[]; routes: string[] }, + /** Which of the two names places the Session, as an enclosing `` decides. */ + defaultAgent: string, + /** What this host declares for each name. The production split by default. */ + lifetimes: (agentName: string) => Result = declaredLifetimes, +): Operation { + yield* useFlatWorld(CWD); + const factory = createAcpxProvider({ + createRuntime: harness.create, + sessionStore: store, + // Both names, one command. + agentRegistry: makeRegistry({ [DEVIN]: DEVIN_COMMAND, [DEVIN_RAW]: DEVIN_COMMAND }), + sessionLifetime: lifetimes, + routeStore: { + // deno-lint-ignore require-yield + *read() { + observed.routes.push("read"); + return undefined; + }, + // deno-lint-ignore require-yield + *publish(route) { + observed.routes.push("publish"); + return route; + }, + }, + sessions: { + // deno-lint-ignore require-yield + *place(context: AcpxSessionContext): Operation { + return { + sessionKey: deriveSessionKey(context.agentCommand, CWD, context.session), + cwd: CWD, + state: "pending", + }; + }, + // deno-lint-ignore require-yield + *established(_placement, identity): Operation { + observed.established.push(identity); + }, + }, + }); + yield* factory({ defaultAgent, permissionMode: "deny-all" }, stubAuthority()); +} + +/** Every effect a mismatched consumption must leave exactly as it found it. */ +function effects( + harness: FakeRuntimeHarness, + store: WatchedStore, + observed: { established: AcpxSessionIdentity[]; routes: string[] }, +): string { + return JSON.stringify({ + loaded: store.loaded, + saved: store.saved, + ensured: harness.ensureCalls, + turns: harness.turns.length, + established: observed.established, + routes: observed.routes, + }); +} + +/** Consume `session` through `agent`, and answer with the refusal it raised. */ +function* refusedThrough(agent: string, session: Session): Operation { + try { + yield* collectPrompt("go", { agent, session }); + } catch (error) { + return error instanceof Error ? error : new Error(String(error)); + } + throw new Error(`consuming the session through "${agent}" was not refused`); +} + +describe("Tier AI — invocation-scoped Agent sessions", () => { + it("AI1: two Prompts in one Devin session share one ensure and one live handle", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + const placements: AcpxSessionPlacement[] = []; + const retained: AcpxSessionIdentity[] = []; + yield* scoped(function* () { + const sessions: AcpxSessionPolicy = { + *place(context: AcpxSessionContext): Operation { + const placement: AcpxSessionPlacement = { + sessionKey: deriveSessionKey(context.agentCommand, CWD, context.session), + cwd: CWD, + state: "pending", + }; + placements.push(placement); + return placement; + }, + // deno-lint-ignore require-yield + *established(_placement, identity): Operation { + retained.push(identity); + }, + }; + yield* installLifetimeProvider(harness, store, stubAuthority(), { sessions }); + + const first = yield* collectPrompt("one"); + const second = yield* collectPrompt("two"); + + expect(first.close).toBe("hello world"); + expect(second.close).toBe("hello world"); + // One ensure, one handle, and both turns went through it. A second ensure + // here would be a second live Devin conversation for one session. + expect(harness.ensureCalls).toHaveLength(1); + expect(harness.handleIds).toHaveLength(1); + expect(harness.turns).toHaveLength(2); + expect(harness.turns[0]!.input.handle).toBe(harness.turns[1]!.input.handle); + // In order: the second turn started only after the first had finished. + expect(harness.turns.map((turn) => turn.input.text)).toEqual(["one", "two"]); + + // Persistent, so the conversation is kept — and not deferred + // materialization, which waits for an acceptance Devin never publishes. + expect(harness.ensureCalls[0]!.mode).toBe("persistent"); + expect(harness.ensureCalls[0]!.materialization).toBe(undefined); + // Nothing was retained: there is no durable identity to hand a host. + expect(retained).toEqual([]); + expect(placements).toHaveLength(2); + }); + }); + + it("AI2: two Devin sessions get their own handles and hold turns at once", function* () { + const harness = createFakeRuntime(); + harness.script({ manual: true }); + yield* scoped(function* () { + yield* installLifetimeProvider(harness, watchedStore()); + + const here = yield* spawn(() => collectPrompt("first", { session: "alpha" })); + const there = yield* spawn(() => collectPrompt("second", { session: "beta" })); + yield* harness.startedTurns(2); + + // Two conversations, two handles, both in flight. + expect(harness.ensureCalls).toHaveLength(2); + expect(new Set(harness.handleIds).size).toBe(2); + expect(harness.turns[0]!.input.handle).not.toBe(harness.turns[1]!.input.handle); + + for (const turn of harness.turns) { + turn.finish([{ type: "text_delta", text: "done", stream: "output" }], { + status: "completed", + stopReason: "end_turn", + }); + } + yield* here; + yield* there; + }); + }); + + it("AI3: halting a live Devin turn cancels it, and teardown attempts every handle", function* () { + const harness = createFakeRuntime(); + harness.script({ manual: true }); + harness.script({ manual: true }); + let teardown: Error | undefined; + try { + yield* scoped(function* () { + yield* installLifetimeProvider(harness, watchedStore()); + + const cancelled = yield* spawn(() => collectPrompt("hold", { session: "alpha" })); + const other = yield* spawn(() => collectPrompt("also", { session: "beta" })); + yield* harness.startedTurns(2); + yield* cancelled.halt(); + // The halted turn was told to stop, and the other was left alone. + expect(harness.turns[0]!.cancelled).toBe(true); + expect(harness.turns[1]!.cancelled).toBe(false); + + harness.turns[1]!.finish([{ type: "text_delta", text: "done", stream: "output" }], { + status: "completed", + stopReason: "end_turn", + }); + yield* other; + // The first close fails; the rest must still be attempted. + harness.closeFailure = new Error("this child would not close"); + }); + } catch (error) { + teardown = error instanceof Error ? error : new Error(String(error)); + } + + // Both handles were closed through the runtime that made each, and the + // failure was reported only after every one had been tried. + expect(harness.closeCalls).toHaveLength(2); + expect(new Set(harness.closeRuntimeIndexes).size).toBe(1); + expect(teardown).toBeDefined(); + }); + + it("AI4: Devin's records never reach the durable store, and a second provider starts fresh", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + yield* scoped(function* () { + yield* installLifetimeProvider(harness, store); + yield* collectPrompt("one"); + }); + + const sessionKey = deriveSessionKey(DEVIN_COMMAND, CWD); + // The durable store was never asked about this session and never written + // to. Placement, the ensure, the permission refresh and teardown all went + // through the provider's own memory. + expect(store.loaded).toEqual([]); + expect(store.saved).toEqual([]); + expect(store.records.size).toBe(0); + expect(storesOf(harness).every((used) => used !== store)).toBe(true); + + // A second provider, same placement, same directory: it establishes the + // conversation again rather than continuing one nothing retained. + const next = createFakeRuntime(); + yield* scoped(function* () { + yield* installLifetimeProvider(next, store); + yield* collectPrompt("one"); + }); + expect(next.ensureCalls.map((call) => call.sessionKey)).toEqual([sessionKey]); + expect(harness.ensureCalls[0]!.sessionKey).toBe(sessionKey); + expect(storesOf(next).every((used) => used !== store)).toBe(true); + // Nothing of the first invocation is reachable from the second. + expect(store.records.size).toBe(0); + }); + + it("AI5: a mixed invocation keeps two runtimes with two stores, and Codex is unchanged", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + yield* scoped(function* () { + yield* installLifetimeProvider(harness, store); + + yield* collectPrompt("ask devin"); + yield* collectPrompt("ask codex", { agent: "codex" }); + + // Two unbound runtimes, and neither one's store is the other's. + const used = storesOf(harness).filter( + (_, index) => harness.createdOptions[index]!.probeAgent === undefined, + ); + expect(new Set(used).size).toBe(2); + expect(used.filter((one) => one === store)).toHaveLength(1); + + const devinEnsure = harness.ensureCalls.find((call) => call.agent === DEVIN); + const codexEnsure = harness.ensureCalls.find((call) => call.agent === "codex"); + // Codex still constructs through deferred first-turn acceptance; Devin + // never does, because Devin publishes no acceptance to wait for. + expect(codexEnsure?.materialization).toBe("first-turn-acceptance"); + expect(devinEnsure?.materialization).toBe(undefined); + // Only the durable session's records are on the durable store. + expect(store.saved.every((key) => key !== devinEnsure?.sessionKey)).toBe(true); + expect(store.saved).toContain(codexEnsure!.sessionKey); + }); + }); + + it("AI6: a native launch of Devin refuses before placement, ownership or a probe", function* () { + const harness = createFakeRuntime(); + const refusals: PreparedLaunchRecord[] = []; + const authority = stubAuthority(); + const recording: AgentProviderAuthority = { + ...authority, + checkpoint: authority.checkpoint, + sessionIdentity: authority.sessionIdentity, + perform: authority.perform, + *refuse(request, record): Operation { + refusals.push(record); + yield* authority.refuse(request, record); + }, + }; + yield* scoped(function* () { + yield* installLifetimeProvider(harness, watchedStore(), recording); + + yield* Agent.operations.launch({ ...fakeRequest(), agent: DEVIN } as AgentLaunchRequest); + + expect(authority.performed).toBe(0); + expect(refusals).toHaveLength(1); + expect(refusals[0]!.failure?.class).toBe("unsupported-capability"); + expect(refusals[0]!.failure?.message).toContain("invocation that created it"); + // Nothing was created, probed, established, or started. + expect(harness.createdOptions).toEqual([]); + expect(harness.doctorCalls).toBe(0); + expect(harness.ensureCalls).toEqual([]); + expect(harness.turns).toEqual([]); + }); + }); + + it("AI7: nothing Devin reports becomes a route, a retained identity or a checkpoint", function* () { + const harness = createFakeRuntime(); + harness.script({ + events: [{ type: "text_delta", text: "ok", stream: "output" }], + result: { + status: "completed", + stopReason: "end_turn", + _meta: { "cognition.ai/userMessageId": "user-message-1" }, + }, + }); + const store = watchedStore(); + const published: string[] = []; + const retained: AcpxSessionIdentity[] = []; + const authority = stubAuthority(); + yield* scoped(function* () { + yield* installLifetimeProvider(harness, store, authority, { + routeStore: { + // deno-lint-ignore require-yield + *read() { + published.push("read"); + return undefined; + }, + *publish(route) { + published.push("publish"); + return yield* (function* () { + return route; + })(); + }, + }, + sessions: { + // deno-lint-ignore require-yield + *place(context: AcpxSessionContext): Operation { + return { + sessionKey: deriveSessionKey(context.agentCommand, CWD, context.session), + cwd: CWD, + state: "pending", + }; + }, + // deno-lint-ignore require-yield + *established(_placement, identity): Operation { + retained.push(identity); + }, + }, + }); + + const { close } = yield* collectPrompt("go"); + expect(close).toBe("ok"); + + // The handle carries an ACPX record id and a backend session id, and the + // terminal result carries Devin's own user-message metadata. None of the + // three is a durable identity, so none of them is written anywhere. + expect(published).toEqual([]); + expect(retained).toEqual([]); + expect(authority.checkpoints).toEqual([]); + expect(store.saved).toEqual([]); + }); + }); + + it("AI8: a durable Session refuses a Prompt whose agent is invocation-scoped", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + const observed = { established: [] as AcpxSessionIdentity[], routes: [] as string[] }; + yield* scoped(function* () { + yield* installAliasedProvider(harness, store, observed, DEVIN_RAW); + + // Placed under the raw command name, which this host serves durably. + const session = yield* Agent.operations.session("review"); + const before = effects(harness, store, observed); + + // The canonical name reaches the same command and the same child, and is + // invocation-scoped. Reading the lifetime off the placement would have + // run this turn durably against a session nothing declared that way. + const refusal = yield* refusedThrough(DEVIN, session); + expect(refusal.message).toContain(`agent "${DEVIN}" (invocation sessions)`); + expect(refusal.message).toContain("(durable sessions)"); + + // Nothing moved: no second store access, no route, no ensure, no turn, + // and nothing retained. + expect(effects(harness, store, observed)).toBe(before); + expect(harness.ensureCalls).toEqual([]); + expect(harness.turns).toEqual([]); + expect(observed.established).toEqual([]); + expect(observed.routes).toEqual([]); + }); + }); + + it("AI9: an invocation-scoped Session refuses a Prompt whose agent is durable", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + const observed = { established: [] as AcpxSessionIdentity[], routes: [] as string[] }; + yield* scoped(function* () { + yield* installAliasedProvider(harness, store, observed, DEVIN); + + const session = yield* Agent.operations.session("review"); + const before = effects(harness, store, observed); + + // The other direction, which is the one that would have written a Devin + // conversation into the durable store. + const refusal = yield* refusedThrough(DEVIN_RAW, session); + expect(refusal.message).toContain(`agent "${DEVIN_RAW}" (durable sessions)`); + expect(refusal.message).toContain("(invocation sessions)"); + + expect(effects(harness, store, observed)).toBe(before); + expect(store.saved).toEqual([]); + expect(harness.ensureCalls).toEqual([]); + expect(harness.turns).toEqual([]); + expect(observed.established).toEqual([]); + expect(observed.routes).toEqual([]); + }); + }); + + it("AI10: one lifetime through two names reaches one live conversation", function* () { + const harness = createFakeRuntime(); + const store = watchedStore(); + const observed = { established: [] as AcpxSessionIdentity[], routes: [] as string[] }; + yield* scoped(function* () { + // Both names on one command, and this time a host that declares the same + // lifetime for each. What AI8 and AI9 refuse is disagreement about the + // lifetime, not the alias itself, so crossing the alias while agreeing + // must still be ordinary. + yield* installAliasedProvider(harness, store, observed, DEVIN_RAW, () => Ok("invocation")); + + // Placed through one name... + const session = yield* Agent.operations.session("review"); + // ...and consumed through the other, twice. + const first = yield* collectPrompt("one", { agent: DEVIN, session }); + const second = yield* collectPrompt("two", { agent: DEVIN, session }); + + expect(first.close).toBe("hello world"); + expect(second.close).toBe("hello world"); + // One ensure and one live handle: the placing name and the consuming name + // reached one conversation rather than opening a second beside it. + expect(harness.ensureCalls).toHaveLength(1); + expect(harness.handleIds).toHaveLength(1); + expect(harness.turns).toHaveLength(2); + expect(harness.turns[0]!.input.handle).toBe(harness.turns[1]!.input.handle); + // And on the key the shared command derives, which is what makes the two + // names one placement rather than two that happen to agree. + expect(harness.ensureCalls[0]!.sessionKey).toBe( + deriveSessionKey(DEVIN_COMMAND, CWD, "review"), + ); + }); + }); +}); diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index 8788b2ae..d350fc53 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -23,12 +23,17 @@ import { import type { AgentProviderFactory, PermissionMode } from "@executablemd/core"; import { installForegroundLauncher, env as readEnv } from "@executablemd/runtime"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; -import type { AcpxProviderDependencies } from "@executablemd/acp"; +import type { + AcpAgentRegistry, + AcpxProviderDependencies, + AcpxSessionLifetime, +} from "@executablemd/acp"; // A separate entrypoint because the embedded adapters are temporary (#636) and // must not become part of the package's stable surface. import { createEmbeddedAdapters, embeddedAdapterDependencies, + overlaidAdapterRegistry, } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import { Err, Ok } from "effection"; @@ -50,6 +55,80 @@ import type { MachineSessionAssembly } from "./session-coordinator.ts"; */ export const DEFAULT_ADAPTER_ROOT: string = join(homedir(), ".xmd", "adapters"); +/** The agent name a document writes for Devin. */ +const DEVIN_AGENT = "devin"; + +/** + * The command Devin's ACP mode is started with. + * + * Stated by this host rather than resolved: ACPX's registry has no Devin entry, + * so the name would otherwise fall through to the bare command `devin`, which + * is Devin's interactive CLI and speaks no protocol. ACPX recognizes exactly + * this shape and answers it as Windsurf, which is what Devin's backend expects + * (specs/acp-client-spec.md §Command-line configuration). + */ +const DEVIN_COMMAND = "devin acp"; + +/** + * The baseline registry with Devin's command over the top. + * + * An overlay, like this build's embedded adapters beneath it: `devin` resolves + * to the one command that speaks ACP, it appears once in the list, and every + * other name keeps the answer it already had. + */ +function withDevinCommand(baseline: AcpAgentRegistry): AcpAgentRegistry { + return { + resolve: (agentName: string) => + agentName === DEVIN_AGENT ? DEVIN_COMMAND : baseline.resolve(agentName), + list: () => [...new Set([...baseline.list(), DEVIN_AGENT])], + }; +} + +/** The agent registry every non-workflow command resolves through. */ +export function hostAgentRegistry(adapters: EmbeddedAdapters): AcpAgentRegistry { + return withDevinCommand(overlaidAdapterRegistry(adapters)); +} + +/** + * How long an ordinary `xmd run` can continue one agent's sessions. + * + * Devin's ACP surface publishes no acceptance event and no provider-native + * conversation identity, so nothing about one of its sessions can be written + * down and continued later. This host says so before the agent is prepared or + * spawned, which is what lets a durable-only operation refuse without + * contacting it. Every other agent is durable, exactly as it was. + */ +export function runAgentSessionLifetime(agentName: string): Result { + return Ok(agentName === DEVIN_AGENT ? "invocation" : "durable"); +} + +/** + * How long a workflow attachment can continue one agent's sessions. + * + * A workflow continues its conversation across executions by reattaching the + * session its run database names, so an agent whose sessions end with the + * invocation cannot serve one at all. Refusing here is what keeps that refusal + * ahead of adapter preparation, the availability probe and any turn. + */ +export function workflowAgentSessionLifetime(agentName: string): Result { + if (agentName === DEVIN_AGENT) { + return Err( + new Error( + `agent "${agentName}" keeps a session only for the invocation that created it, and a ` + + `workflow continues one across executions. Run it with xmd run, or name a workflow ` + + `agent whose sessions can be continued.`, + ), + ); + } + return Ok("durable"); +} + +/** Whether this host serves `agentName` only for the invocation that asks. */ +export function invocationScopedAgent(agentName: string): boolean { + const declared = runAgentSessionLifetime(agentName); + return declared.ok && declared.value === "invocation"; +} + /** * Who writes, and what this host launches them with. * @@ -136,7 +215,16 @@ export function* resolveAgentStack( */ export function hostAcpDependencies(stack: AuthorshipStack): AcpxProviderDependencies { const { sessions } = stack; - const adapters = embeddedAdapterDependencies(stack.adapters); + const adapters: AcpxProviderDependencies = { + ...embeddedAdapterDependencies(stack.adapters), + // Over the embedded overlay, so `devin` resolves to the one command that + // speaks ACP while Codex and Claude keep this build's own snapshots. + agentRegistry: hostAgentRegistry(stack.adapters), + // Declared here rather than at each consumer: a run, a Plan and a nested + // execution all assemble their provider from this one answer, and a + // consumer that stated its own could state a weaker one. + sessionLifetime: runAgentSessionLifetime, + }; if (sessions === undefined) { return adapters; } diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index cf0f291a..98552ec0 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -51,7 +51,7 @@ import { InMemoryStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; import { FormOpener } from "@executablemd/web"; -import { hostAcpDependencies } from "./agent-stack.ts"; +import { hostAcpDependencies, invocationScopedAgent } from "./agent-stack.ts"; import type { AuthorshipStack } from "./agent-stack.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; @@ -220,6 +220,16 @@ export function planAgentContext( if (stack.provider !== "acpx") { return Err(new Error(noAgentContextFrom(stack.provider))); } + // Before a directory, a provider or a session exists, and therefore before + // anything could be placed or spawned. Writing a Plan is a conversation a + // caller may name and return to, so an agent whose sessions end with the + // invocation cannot hold one — and this is the one path both `xmd plan` and + // `` in an ordinary run settle their context through. An explicit + // `--session` or `` names a conversation that still could not + // be continued, so it does not change the answer. + if (invocationScopedAgent(stack.defaultAgent)) { + return Err(new Error(noAgentContextFrom(stack.provider))); + } return Ok({ defaultAgent: stack.defaultAgent, *installProvider(invocation: PlanAuthorshipInvocation): Operation { diff --git a/packages/cli/src/workflow-agent.ts b/packages/cli/src/workflow-agent.ts index 0df4e77e..4f32a93e 100644 --- a/packages/cli/src/workflow-agent.ts +++ b/packages/cli/src/workflow-agent.ts @@ -114,6 +114,7 @@ import { WorkflowAgentSessionError, } from "@executablemd/workflow/deno"; import type { WorkflowRunDatabase } from "@executablemd/workflow"; +import { workflowAgentSessionLifetime } from "./agent-stack.ts"; import type { AgentSessionIdentity, ProviderAssertion, @@ -421,6 +422,11 @@ export function* useWorkflowAgentProfile(options: WorkflowAgentProfileOptions): const factory: AgentProviderFactory = createAcpxProvider({ sessionStore: store, + // Asked before an adapter is prepared and before the availability probe, so + // an agent whose sessions end with the invocation is refused before the + // workflow contacts it — on a first use and on a continuation whose next + // unrecorded operation is a Prompt alike. + sessionLifetime: workflowAgentSessionLifetime, // ACPX's own registry with this build's two patched snapshots over the top, // and the preparation that puts one on disk. Codex and Claude resolve to the // adapter that names its turns; every other agent resolves to the command it diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index 226db41f..db72811c 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -30,9 +30,10 @@ import { DEFAULT_ADAPTER_ROOT, hostAcpDependencies, resolveAgentStack, + workflowAgentSessionLifetime, } from "../src/agent-stack.ts"; import type { AgentStack } from "../src/agent-stack.ts"; -import { authorshipDependencies } from "../src/authorship-profile.ts"; +import { authorshipDependencies, planAgentContext } from "../src/authorship-profile.ts"; import type { AuthorshipProviderInputs } from "../src/authorship-profile.ts"; import { runPlan } from "../src/plan.ts"; import { AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts"; @@ -215,6 +216,72 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { }); }); + it("AE7: the run path resolves Devin to the one command that speaks ACP", function* () { + const root = adapterRoot(); + const dependencies = hostAcpDependencies(stackWith(createEmbeddedAdapters(root))); + const registry = dependencies.agentRegistry; + if (registry === undefined) { + throw new Error("the run path handed its provider no agent registry"); + } + + // ACPX's registry has no Devin entry, so the bare name falls through to the + // interactive CLI, which speaks no protocol. This host states the exact + // command ACPX recognizes and answers as Windsurf. + expect(registry.resolve("devin")).toBe("devin acp"); + // Once, beside the names that were already there rather than instead of + // them: an overlay, like this build's own adapters underneath it. + expect(registry.list().filter((agent) => agent === "devin")).toEqual(["devin"]); + for (const agent of EMBEDDED) { + expect(registry.resolve(agent)).toBe(createEmbeddedAdapters(root).command(agent)); + } + expect(registry.resolve("gemini")).not.toContain(root); + expect(registry.resolve("gemini")).not.toBe("devin acp"); + }); + + it("AE8: the run path declares Devin invocation-scoped and everything else durable", function* () { + const dependencies = hostAcpDependencies(stackWith(createEmbeddedAdapters(adapterRoot()))); + const declare = dependencies.sessionLifetime; + if (declare === undefined) { + throw new Error("the run path told its provider nothing about session lifetimes"); + } + + // The decision is the host's, and it is made from the agent name alone — + // before an adapter is prepared, before a probe, and before any store. + expect(declare("devin")).toEqual({ ok: true, value: "invocation" }); + for (const agent of [...EMBEDDED, "gemini"]) { + expect(declare(agent)).toEqual({ ok: true, value: "durable" }); + } + }); + + it("AE9: a workflow attachment refuses Devin and keeps every other agent durable", function* () { + // The other half of the same decision, and the reason it is a `Result`: a + // workflow continues its conversation across executions, so an agent whose + // sessions end with the invocation cannot serve one at all. + const refused = workflowAgentSessionLifetime("devin"); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error.message).toContain("invocation that created it"); + } + for (const agent of [...EMBEDDED, "gemini"]) { + expect(workflowAgentSessionLifetime(agent)).toEqual({ ok: true, value: "durable" }); + } + }); + + it("AE10: an invocation-scoped default agent gives a Plan no Agent context", function* () { + const adapters = createEmbeddedAdapters(adapterRoot()); + // Settled before a directory, a provider or a session exists, which is what + // makes `` and `xmd plan` refuse before either contacts Devin. + const refused = planAgentContext({ ...stackWith(adapters), defaultAgent: "devin" }); + expect(refused.ok).toBe(false); + if (!refused.ok) { + expect(refused.error.message).toBe( + "The acpx provider did not provide an Agent context for . No Plan was returned.", + ); + } + // And a durable default still has one. + expect(planAgentContext(stackWith(adapters)).ok).toBe(true); + }); + it("AE5: a settled stack carries this host's own adapter root", function* () { yield* API.Env.around({ // deno-lint-ignore require-yield diff --git a/packages/cli/tests/devin-agent.test.ts b/packages/cli/tests/devin-agent.test.ts new file mode 100644 index 00000000..1897a237 --- /dev/null +++ b/packages/cli/tests/devin-agent.test.ts @@ -0,0 +1,565 @@ +/** + * Tier DV — Devin through the production run path + * (specs/acp-client-spec.md §Session lifetime). + * + * Everything else about invocation-scoped sessions is decided in process, where + * a fake runtime stands in for ACPX. Two things cannot be: that `devin` on this + * machine's `PATH` is started as exactly `devin acp`, and that ACPX's own + * Windsurf compatibility shim then speaks the protocol Devin's backend expects. + * Both live behind a command string and a child process, so this tier runs the + * real CLI and puts a real executable named `devin` where it will find it. + * + * The executable is an ACP agent of this suite's own: it refuses any argument + * vector other than `acp`, speaks ndJSON JSON-RPC over stdio, and records the + * structural facts each connection carried. It costs nothing, needs no + * credential, and reaches no network. + * + * The fixture's source is written here rather than kept beside the test, + * because nothing else consumes it and a fixture two files describe is one + * neither of them owns. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { until } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, readTextFile, writeTextFile } from "@effectionx/fs"; +import { chmod, readdir } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { realpath } from "node:fs/promises"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import { useTempDirectory } from "@executablemd/test-support/temp"; +import { stat } from "@executablemd/runtime"; + +/** The Deno source entrypoint, which is the `xmd` this suite runs. */ +const ENTRYPOINT = fileURLToPath(new URL("../src/deno.ts", import.meta.url)); + +/** + * The ACP agent this suite installs as `devin`. + * + * Written as a module the child runs directly, so nothing about the suite's own + * imports, configuration or lock file reaches it. It answers the four requests + * ACPX makes, sends the two messages Devin's compatibility shim exists for, and + * appends one JSON line per connection to a report file. + * + * Its reply carries a token minted once per process. That is what makes a + * second Prompt's answer depend on having reached the same live child: a token + * can only be repeated by the process that minted it. + */ +const FAKE_AGENT = String.raw` +const report = Deno.env.get("DEVIN_FAKE_REPORT"); +const argv = Deno.args; + +// Exactly "acp", and nothing else. The bare command is Devin's interactive CLI, +// and a host that ran it would be starting a program that speaks no protocol. +if (argv.length !== 1 || argv[0] !== "acp") { + Deno.writeTextFileSync(report, JSON.stringify({ kind: "refused", argv }) + "\n", { + append: true, + }); + console.error("this agent runs only as: devin acp"); + Deno.exit(64); +} + +const token = crypto.randomUUID(); +const connection = { + kind: "connection", + token, + prompts: [], + diagnostics: null, + clientInfo: null, + clientCapabilities: null, +}; +let turns = 0; + +// Appended after every observation rather than once at the end. A child that is +// killed mid-connection still leaves what it had seen, and the reader takes the +// last snapshot each token wrote. +function record() { + Deno.writeTextFileSync(report, JSON.stringify(connection) + "\n", { append: true }); +} + +const encoder = new TextEncoder(); +function send(message) { + Deno.stdout.writeSync(encoder.encode(JSON.stringify(message) + "\n")); +} + +function respond(id, result) { + send({ jsonrpc: "2.0", id, result }); +} + +const DIAGNOSTICS_ID = 1000; + +function handle(message) { + // The client's answer to the one request this agent makes. Read from the same + // loop as everything else: an agent that waited for it inline would stop + // reading its own input and never see the reply. + if (message.id === DIAGNOSTICS_ID && message.method === undefined) { + connection.diagnostics = message.result ?? null; + record(); + return; + } + switch (message.method) { + case "initialize": { + connection.clientInfo = message.params?.clientInfo ?? null; + connection.clientCapabilities = message.params?.clientCapabilities ?? null; + record(); + respond(message.id, { + protocolVersion: 1, + agentCapabilities: { loadSession: false, promptCapabilities: {} }, + authMethods: [], + }); + // The two things ACPX's Devin shim exists for: a diagnostics request only + // a Windsurf-identified client answers, and vendor traffic a client is + // expected to ignore rather than fail on. + send({ + jsonrpc: "2.0", + id: DIAGNOSTICS_ID, + method: "_cognition.ai/request_diagnostics", + params: {}, + }); + send({ jsonrpc: "2.0", method: "_cognition.ai/telemetry", params: { seen: true } }); + return; + } + case "session/new": { + // Devin reports a display title and no provider-native conversation + // identity. Both are stated here, so a run that promoted either into + // durable state would be visible. + respond(message.id, { + sessionId: "devin-session-" + token, + _meta: { "cognition.ai/title": "Release review" }, + }); + return; + } + case "session/prompt": { + turns += 1; + const text = (message.params?.prompt ?? []) + .map((block) => (typeof block?.text === "string" ? block.text : "")) + .join(""); + connection.prompts.push(text.slice(0, 24)); + record(); + // The token is minted once per process, so a second turn can only repeat + // it by having reached the same live child. + const reply = turns === 1 ? "MARK " + token : "AGAIN " + token; + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: message.params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: reply }, + }, + }, + }); + respond(message.id, { + stopReason: "end_turn", + _meta: { "cognition.ai/userMessageId": "user-message-" + turns }, + }); + return; + } + case "session/cancel": + return; + default: { + if (message.id !== undefined) { + send({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "method not found: " + message.method }, + }); + } + } + } +} + +const decoder = new TextDecoder(); +let buffered = ""; +for await (const chunk of Deno.stdin.readable) { + buffered += decoder.decode(chunk, { stream: true }); + let newline = buffered.indexOf("\n"); + while (newline >= 0) { + const line = buffered.slice(0, newline).trim(); + buffered = buffered.slice(newline + 1); + if (line.length > 0) { + handle(JSON.parse(line)); + } + newline = buffered.indexOf("\n"); + } +} +`; + +/** A document that takes two turns in one Devin session. */ +const DOCUMENT = [ + "# Two turns in one live Devin session", + "", + '', + '', + "", + '', + "Say the marker.", + "", + "", + '', + "Say it again.", + "", + "", + "", + "", + "", + "", + "first={first}", + "second={second}", + "", + "", +].join("\n"); + +/** + * A document whose Session and Prompt name one Devin two different ways. + * + * ACPX resolves an unknown agent name to the name itself, so `devin acp` and + * `devin` reach the same child through the same command — but this host + * declares only the canonical `devin` invocation-scoped, because a lifetime is + * never inferred from a command. Nothing about this is contrived: it is what a + * document that writes the raw command produces. + */ +const MIXED_LIFETIME_DOCUMENT = [ + "# One Devin, named two ways", + "", + '', + '', + "", + '', + "Say the marker.", + "", + "", + "", + "", + "", + "", + "reply={reply}", + "", + "", +].join("\n"); + +/** One connection the fake agent reported, reduced to what a case may read. */ +interface Connection { + kind: string; + token: string; + prompts: string[]; + diagnostics: unknown; + clientInfo: { name?: string; version?: string } | null; + clientCapabilities?: { _meta?: Record } | null; + argv?: string[]; +} + +interface Harness { + /** The directory the CLI is invoked from, and the document lives in. */ + work: string; + /** The home this invocation reads and writes provider state under. */ + home: string; + /** Where the fake agent appends what each connection carried. */ + report: string; + environment: Record; +} + +function* useDevinHarness(): Operation { + // Canonical, so what this fixture names and what a child reports are one + // string on a platform whose temporary root is a symlink. + const root = yield* until(realpath(yield* useTempDirectory("xmd-devin-"))); + const bin = join(root, "bin"); + const work = join(root, "work"); + const home = join(root, "home"); + for (const directory of [bin, work, home]) { + yield* ensureDir(directory); + } + const agent = join(root, "devin-agent.mjs"); + const report = join(root, "connections.jsonl"); + yield* writeTextFile(agent, FAKE_AGENT); + yield* writeTextFile(join(work, "document.md"), DOCUMENT); + yield* writeTextFile(join(work, "mixed-lifetime.md"), MIXED_LIFETIME_DOCUMENT); + + // The paths are written into the shim rather than read from its environment: + // what a spawned agent inherits is ACPX's business, and a fixture that + // depended on it would be testing that instead. + const shim = [ + "#!/bin/sh", + `exec ${JSON.stringify(process.execPath)} run --allow-all --quiet --no-config ` + + `${JSON.stringify(agent)} "$@"`, + "", + ].join("\n"); + const devin = join(bin, "devin"); + yield* writeTextFile(devin, shim); + yield* until(chmod(devin, 0o755)); + + return { + work, + home, + report, + environment: { + PATH: `${bin}:${process.env.PATH ?? ""}`, + // A home of this invocation's own, which is where ACPX would keep a + // durable session record — so "nothing durable names Devin" is a claim + // about a directory this suite created and can read. + HOME: home, + DEVIN_FAKE_REPORT: report, + // Stated rather than inherited from `HOME`: the module cache belongs to + // the machine, and a child pointed at an empty home would refetch the + // whole dependency graph over the network. + DENO_DIR: denoDirectory(), + }, + }; +} + +/** + * The module cache this machine resolves through. + * + * Asked of Deno rather than derived from a platform rule, because the answer + * differs by platform and by whether `DENO_DIR` is set, and a fixture that + * guessed wrong would fail as a network error. + */ +function denoDirectory(): string { + const configured = process.env.DENO_DIR; + if (configured !== undefined && configured !== "") { + return configured; + } + const info = spawnSync(process.execPath, ["info", "--json"], { encoding: "utf8" }); + const reported: unknown = JSON.parse(typeof info.stdout === "string" ? info.stdout : "{}"); + if ( + typeof reported === "object" && + reported !== null && + "denoDir" in reported && + typeof reported.denoDir === "string" + ) { + return reported.denoDir; + } + throw new Error("this runtime reported no module cache directory"); +} + +interface Invocation { + status: number | null; + stdout: string; + stderr: string; +} + +/** One `xmd run`, exactly as an operator would type it. */ +function runXmd(harness: Harness, args: readonly string[]): Invocation { + const outcome = spawnSync(process.execPath, ["run", "--allow-all", ENTRYPOINT, ...args], { + cwd: harness.work, + env: harness.environment, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + status: outcome.status, + stdout: typeof outcome.stdout === "string" ? outcome.stdout : "", + stderr: typeof outcome.stderr === "string" ? outcome.stderr : "", + }; +} + +/** + * What each connection had seen by the time it stopped writing. + * + * The agent appends a snapshot per observation, so the last line a token wrote + * is the whole of what that connection carried. Grouping by token is also what + * separates the availability probe's child — which is closed before it can read + * the client's answer — from the one that served the turns. + */ +function* connections(harness: Harness): Operation { + const found = yield* stat(harness.report); + if (!found.exists) { + return []; + } + const text = yield* readTextFile(harness.report); + const latest = new Map(); + const refusals: Connection[] = []; + for (const line of text.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const entry: Connection = JSON.parse(line); + if (entry.kind === "refused") { + refusals.push(entry); + continue; + } + latest.set(entry.token, entry); + } + return [...refusals, ...latest.values()]; +} + +/** + * The failure each retained Prompt recorded, in order. + * + * The journal is where a Prompt's outcome is written down, so it says why a run + * failed even when nothing printed the reason. Read as text and parsed per + * line, because a trace is one JSON record per line. + */ +function* promptFailures(journal: string): Operation { + const text = yield* readTextFile(journal); + const messages: string[] = []; + for (const line of text.split("\n")) { + if (line.trim().length === 0) { + continue; + } + const event: unknown = JSON.parse(line); + const value = reachInto(event, ["description", "type"]); + if (value !== "agent_prompt") { + continue; + } + const message = reachInto(event, ["result", "value", "error", "message"]); + if (typeof message === "string") { + messages.push(message); + } + } + return messages; +} + +/** One nested member, or nothing. Parsed rather than asserted: a journal is data. */ +function reachInto(value: unknown, path: readonly string[]): unknown { + let current = value; + for (const key of path) { + if (typeof current !== "object" || current === null) { + return undefined; + } + current = Reflect.get(current, key); + } + return current; +} + +/** Every marker the run reported, in order. */ +function markers(stdout: string): string[] { + return [...stdout.matchAll(/(?:MARK|AGAIN) ([0-9a-f-]{36})/g)].map((match) => match[1]!); +} + +describe("Tier DV — Devin through the production run path", () => { + it("DV1: one invocation runs `devin acp` and keeps one live conversation across two Prompts", function* () { + const harness = yield* useDevinHarness(); + + const first = runXmd(harness, ["run", "document.md", "--default-agent", "devin"]); + expect(`${first.status}: ${first.stderr}`).toBe("0: "); + + // Both Prompts came back, and the second repeated the token the first + // minted — which only the process that minted it can do. One session, one + // live child, two turns. + const seen = markers(first.stdout); + expect(seen).toHaveLength(2); + expect(seen[0]).toBe(seen[1]); + expect(first.stdout).toContain("first=MARK "); + expect(first.stdout).toContain("second=AGAIN "); + + const reported = yield* connections(harness); + // Nothing was ever started with another argument vector. The agent refuses + // anything but `acp` and records that it did, so an empty list here is the + // absence of a refusal rather than the absence of a run. + expect(reported.filter((entry) => entry.kind === "refused")).toEqual([]); + expect(reported).not.toEqual([]); + // Windsurf, because that is the client identity Devin's backend expects, + // and the capability that makes the diagnostics exchange legal. Every + // connection carries it, the availability probe's included. + for (const entry of reported) { + expect(entry.clientInfo?.name).toBe("windsurf"); + expect(typeof entry.clientInfo?.version).toBe("string"); + expect(entry.clientCapabilities?._meta).toMatchObject({ + "cognition.ai/requestDiagnostics": true, + }); + } + // The connection that served the turns is the one that stayed open long + // enough to read the client's answer: an empty object, exactly as ACPX's + // shim replies. The vendor notification sent beside it failed nothing. + const prompting = reported.filter((entry) => entry.prompts.length > 0); + expect(prompting).toHaveLength(1); + expect(prompting[0]!.diagnostics).toEqual({}); + expect(prompting[0]!.prompts).toHaveLength(2); + expect(prompting[0]!.token).toBe(seen[0]); + + // A second invocation, same directory and same home: a fresh conversation + // rather than a continuation, because nothing about the first was retained. + const second = runXmd(harness, ["run", "document.md", "--default-agent", "devin"]); + expect(`${second.status}: ${second.stderr}`).toBe("0: "); + const later = markers(second.stdout); + expect(later).toHaveLength(2); + expect(later[0]).toBe(later[1]); + expect(later[0]).not.toBe(seen[0]); + }); + + it("DV2: nothing durable names Devin, and an ordinary journal is this run's trace", function* () { + const harness = yield* useDevinHarness(); + + const journal = join(harness.work, "trace.jsonl"); + const outcome = runXmd(harness, [ + "run", + "document.md", + "--default-agent", + "devin", + "--journal", + journal, + ]); + expect(`${outcome.status}: ${outcome.stderr}`).toBe("0: "); + + // ACPX's durable store lives beneath the home this invocation was given, + // and a session it retained is a file in it. A durable agent does write one + // there — Tier AI's mixed case is the other side of this comparison, taken + // in process where both lifetimes can be watched at once. + const stored = join(harness.home, ".acpx", "sessions"); + const records: string[] = []; + if ((yield* stat(stored)).exists) { + for (const entry of yield* until(readdir(stored))) { + records.push(yield* readTextFile(join(stored, entry))); + } + } + expect(records).toEqual([]); + + // The journal was created and holds this invocation's own Prompt trace. + // It is a trace, not continuation state: the second run below creates a + // new one and starts a new conversation all the same. + expect((yield* stat(journal)).exists).toBe(true); + const trace = yield* readTextFile(journal); + expect(trace).toContain("Prompt"); + + const before = markers(outcome.stdout)[0]; + const again = runXmd(harness, [ + "run", + "document.md", + "--default-agent", + "devin", + "--journal", + join(harness.work, "second.jsonl"), + ]); + expect(`${again.status}: ${again.stderr}`).toBe("0: "); + expect(markers(again.stdout)[0]).not.toBe(before); + }); + + it("DV3: a Session and a Prompt naming one Devin two ways fails closed", function* () { + const harness = yield* useDevinHarness(); + + const journal = join(harness.work, "mixed.jsonl"); + const outcome = runXmd(harness, ["run", "mixed-lifetime.md", "--journal", journal]); + + // The Session is placed under the raw command name, which this host serves + // durably; the Prompt names the canonical one, which it serves for the + // invocation only. One command, one child, two lifetimes — and continuing + // that placement would have run a Devin turn as a durable session. + expect(outcome.status).not.toBe(0); + + // Why it failed, from the record rather than from what a terminal printed: + // a run that failed for some other reason would satisfy the exit status. + const failures = yield* promptFailures(journal); + expect(failures).toHaveLength(1); + expect(failures[0]).toContain('agent "devin" (invocation sessions)'); + expect(failures[0]).toContain("(durable sessions)"); + + // No turn reached the agent: the refusal is ahead of the ensure. + const reported = yield* connections(harness); + expect(reported.filter((entry) => entry.prompts.length > 0)).toEqual([]); + + // And nothing durable was written for either name. + const stored = join(harness.home, ".acpx", "sessions"); + const records: string[] = []; + if ((yield* stat(stored)).exists) { + for (const entry of yield* until(readdir(stored))) { + records.push(yield* readTextFile(join(stored, entry))); + } + } + expect(records).toEqual([]); + }); +}); diff --git a/packages/cli/tests/fixtures/workflow-agent/invocation-scoped-agent.md b/packages/cli/tests/fixtures/workflow-agent/invocation-scoped-agent.md new file mode 100644 index 00000000..6d0797d1 --- /dev/null +++ b/packages/cli/tests/fixtures/workflow-agent/invocation-scoped-agent.md @@ -0,0 +1,30 @@ +# A run whose agent keeps a session only for one invocation + +A workflow has its conversation across executions: the session a Prompt joins is +named by a row in the run's own database, and continuing the run reattaches it. +An agent whose sessions end with the invocation that created them has no such +session to name, so this document cannot run at all. + +What it must not do is find that out by asking. The Workspace write below +commits first, so an interrupted attempt leaves something to restore and the +next live operation of a continuation is the Prompt itself — which is where the +refusal has to arrive, before an adapter is prepared, before availability is +probed, and before any turn. + +the release checklist is three items long + + + + + +Reply with one sentence about the release. + + + + + + +The agent said: + +{reply} + diff --git a/packages/cli/tests/plan-cli.test.ts b/packages/cli/tests/plan-cli.test.ts index bbbdeec0..9e3f5f35 100644 --- a/packages/cli/tests/plan-cli.test.ts +++ b/packages/cli/tests/plan-cli.test.ts @@ -850,6 +850,42 @@ describe( expect("PlanExecutionConfig" in cliModule).toBe(false); }); + it("PS11: an agent whose sessions end with the invocation writes no Plan", function* () { + // Both spellings, because the point is that naming the conversation + // changes nothing: a name asks for a conversation to return to, and this + // agent has none to return to. + for (const session of [undefined, "release"]) { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const harness = createPlanHarness({ authorshipRoot }); + + const { value: code, lines } = yield* reported(() => + runPlan( + { + ...planning(dir, "out.md", session), + stack: { ...STACK, defaultAgent: "devin" }, + }, + harness.deps, + ), + ); + + expect(code).toBe(1); + expect(lines.join("\n")).toContain("did not provide an Agent context"); + // Settled before the profile exists, so every phase the provider owns + // is at zero and no artifact reached either sink. The catalog is not + // one of them: `runPlan` renders it before it asks for an Agent + // context, so it is built and then thrown away with the invocation. + expect({ + runtimes: harness.fake.created.length, + started: harness.fake.started, + turns: harness.fake.prompts.length, + reviews: harness.reviews.length, + }).toEqual({ runtimes: 0, started: false, turns: 0, reviews: 0 }); + expect(yield* exists(join(dir, "out.md"))).toBe(false); + expect(yield* until(readdir(authorshipRoot))).toEqual([]); + }); + } + }); + it("PS12: the shipped documentation states what each command does", function* () { // The defect this catches is prose, and prose is what a person reads // before they type anything: a page still promising `xmd plan --run` diff --git a/packages/cli/tests/plan-component.test.ts b/packages/cli/tests/plan-component.test.ts index 1cac4004..b953bfbe 100644 --- a/packages/cli/tests/plan-component.test.ts +++ b/packages/cli/tests/plan-component.test.ts @@ -306,6 +306,59 @@ describe("Tier PC — in an ordinary document", () => { }); }); + it("PC27: an agent whose sessions end with the invocation writes no Plan", function* () { + yield* useWorkingDirectory(function* () { + const root = yield* authorshipRoot(); + const run = yield* runDocument({ + source: ['Write a program.', ""].join("\n"), + root, + reviews: [], + stack: { + provider: "acpx", + defaultAgent: "devin", + permissionMode: "deny-all", + adapters: ADAPTERS, + }, + }); + + // A Plan is a conversation about text that a caller may name and return + // to, and an agent whose sessions end with the invocation can hold none. + // The provider that would have served it is named, as it is for any other + // configuration it cannot serve. + expect(run.failure).toBe( + "The acpx provider did not provide an Agent context for . No Plan was returned.", + ); + // Before placement: no directory was made, and no turn was taken. + expect(run.leftover).toEqual([]); + expect(run.harness.fake.prompts).toEqual([]); + }); + }); + + it("PC28: naming the session does not change that answer", function* () { + yield* useWorkingDirectory(function* () { + const root = yield* authorshipRoot(); + const harness = yield* planDeclarationHarness({ + surface: "component", + authorshipRoot: root, + stack: { provider: "acpx", defaultAgent: "devin", adapters: ADAPTERS }, + }); + const run = yield* runDocument({ + source: ['Write a program.', ""].join("\n"), + root, + harness, + reviews: [], + }); + + // A named session asks for a conversation to be continued later, which is + // exactly what this agent cannot do — so naming one narrows nothing. + expect(run.failure).toBe( + "The acpx provider did not provide an Agent context for . No Plan was returned.", + ); + expect(run.leftover).toEqual([]); + expect(run.harness.fake.prompts).toEqual([]); + }); + }); + it("PC6: the private capabilities resolve nowhere a document can write", function* () { yield* useWorkingDirectory(function* () { for (const name of ["PlanInputs", "PlanAuthorship", "CheckDraft", "AdmitPlan"]) { diff --git a/packages/cli/tests/workflow-agent.test.ts b/packages/cli/tests/workflow-agent.test.ts index fed965e3..53e966b3 100644 --- a/packages/cli/tests/workflow-agent.test.ts +++ b/packages/cli/tests/workflow-agent.test.ts @@ -75,6 +75,11 @@ function* baselineAgentSource(): Operation { return yield* readTextFile(join(FIXTURES, "baseline-agent.md")); } +/** The one-Prompt document that asks an agent whose sessions end with the invocation. */ +function* invocationScopedAgentSource(): Operation { + return yield* readTextFile(join(FIXTURES, "invocation-scoped-agent.md")); +} + function* documentWithNote(): Operation { return `${NOTE.trim()}\n\n${yield* documentSource()}`; } @@ -1080,6 +1085,48 @@ describe("Tier WAL — the workflow Agent observation loop", () => { }); }); + it("WAL17: an agent whose sessions end with the invocation is refused before any contact", function* () { + const root = yield* useStorageRoot(); + const source = yield* invocationScopedAgentSource(); + + yield* withStorage(root, function* () { + const database = yield* createRun(); + // One store across both attempts, as a restart would have. + const store = makeStore(); + + const reached: string[] = []; + const first = yield* runFixture(root, database, source, { + createRuntime: tripwireAcp((what) => reached.push(what)), + sessionStore: store, + }); + + // A workflow continues its conversation across executions by reattaching + // the session its run database names, and there is no such session to + // name. The refusal names why, and the provider was never entered — not + // even to create a runtime. + expect(first.failure).toContain("invocation that created it"); + expect(reached).toEqual([]); + expect(store.records.size).toBe(0); + // The Workspace write before the Prompt did commit, so the next live + // operation of a continuation is the Prompt itself. + const committed = first.events.length; + expect(committed).toBeGreaterThan(0); + + const again: string[] = []; + const second = yield* runFixture(root, database, source, { + createRuntime: tripwireAcp((what) => again.push(what)), + sessionStore: store, + }); + + // The partial journal is restored and the run reaches the same Prompt + // live, where it refuses again without contacting anything. + expect(second.failure).toContain("invocation that created it"); + expect(again).toEqual([]); + expect(second.events.length).toBe(committed); + expect(store.records.size).toBe(0); + }); + }); + it("WAL7: a workflow with no Agent starts no provider and allocates no sidecar", function* () { const root = yield* useStorageRoot(); diff --git a/scripts/runtime-test-exclusions.ts b/scripts/runtime-test-exclusions.ts index 22765142..d1c86b61 100644 --- a/scripts/runtime-test-exclusions.ts +++ b/scripts/runtime-test-exclusions.ts @@ -53,6 +53,12 @@ const DENO_ONLY_TOOLING: RuntimeExclusion[] = [ "asks each distribution for its module graph through ` info`, a subcommand only the Deno CLI has — under Node and Bun the same argument vector names a file called `info`; that each distribution *carries* the snapshots is a claim about Deno's own build outputs, and launching from them is Tier EA, which does run under all three", issue: DERIVED_SCOPE, }, + { + path: "packages/cli/tests/devin-agent.test.ts", + reason: + "runs the real `xmd run` Deno source entrypoint as a subprocess against an ACP agent it installs on the child's PATH, and both are started as ` run --allow-all ` — an argument vector only the Deno CLI understands; the provider decisions it fences are proved portably by Tier AI", + issue: DERIVED_SCOPE, + }, { path: "scripts/tests/adapter-npm-package.test.ts", reason: diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index f87857ba..d5ed99f3 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -310,6 +310,14 @@ interrupted launch resumes the provider session it already prepared rather than creating a replacement; the shape is specified in specs/native-agent-session-launch-spec.md. +An ordinary `xmd run --journal` remains valid for an invocation-scoped Agent +session, because it is a trace of this invocation rather than continuation +state: the run creates a new journal file and loads no earlier one. Nothing +treats an `agent_prompt` record as provider continuation — the conversation it +describes ended with the provider that held it, and a later run establishes a +new one. Replay that needs another live turn from such a session is a different +question, and the profiles that need it refuse the agent instead. + ## Config `Config` (`@executablemd/runtime`, re-exported from `@executablemd/core`) is the @@ -399,6 +407,15 @@ received. `allowedTools` and `mcpServers` are stated as empty arrays rather than omitted: omission is ACPX's own default, and this host is making a different statement. +The profile also states which agents it can serve at all. A workflow continues +its conversation across executions by reattaching the session its run database +names, so an agent whose sessions end with the invocation can serve none: this +profile's `sessionLifetime` answers `Err` for one, and agent resolution refuses +there — ahead of adapter preparation, the availability probe and any turn. That +holds on a first use and on a partial continuation whose next unrecorded +operation is a Prompt: the completed work replays from the journal, and the live +Prompt that follows it refuses without contacting anything. + The profile states both native capability sets empty for the same reason. A workflow session belongs to a run — named by a row in the run's own database, arranged in the run's own sidecar, continued by reattaching that row — and the @@ -553,6 +570,20 @@ what the first put there. An embedded agent never falls through to the published adapter ACPX's own table pins: a snapshot that cannot be verified or materialized refuses that agent. +Both commands also resolve `devin` to exactly `devin acp`, over ACPX's baseline +table rather than instead of it: the name appears once in the agent list, and +every other name keeps the command it already resolved to. ACPX has no Devin +entry, so the bare name would otherwise reach Devin's interactive CLI, which +speaks no protocol; `devin acp` is the shape ACPX recognizes, and recognizing it +is what makes it identify as Windsurf, advertise +`_meta["cognition.ai/requestDiagnostics"]`, answer +`_cognition.ai/request_diagnostics` with `{}` and ignore Devin's vendor +notifications. + +`xmd run` also declares `devin` an **invocation-scoped Agent session** and every +other agent durable. That declaration is the host's, made from the resolved +agent name alone, and it decides what follows for every surface below. + ### The `xmd plan` authorship profile `xmd plan` resolves that configuration once, for the one document it executes: @@ -583,6 +614,16 @@ ceiling that has nothing in it; they configure the approved document later. A provider that cannot establish this ceiling refuses before session materialization or a turn. +Writing a Plan is a conversation a caller may name and return to, so the settled +default agent has to be one whose sessions can be continued. When it is +invocation-scoped, this host supplies **no Agent context**: the same refusal a +host that supplies none gives, settled before the session directory, the +provider or any session exists. One decision covers both surfaces, because +`xmd plan` and a `` written in an ordinary run settle their Agent context +through the same path, and an explicit `--session` or `` names a +conversation that still could not be continued, so it does not change the +answer. + The command document itself is given no Files, command, service or XMD-mediated network capability, and the host decides for that whole execution that a failing `` ends it — so a turn that streamed text and then failed presents @@ -653,6 +694,13 @@ the failed operation does not render, no prompt failure is aggregated, and the run exits non-zero. A turn that fails for any other reason remains an ordinary prompt failure. +An invocation-scoped agent is not probed at resolution. Probing spawns the +agent, and the operations that cannot serve such a session — a native launch, a +workflow turn, Plan authorship — have to be able to refuse before it is +contacted at all. A `` is the one operation that can serve one, and it +probes at its own placement, immediately before the ensure: an unreachable agent +is still an availability failure rather than a failed turn. + ## ACPX provider `@executablemd/acp` implements the `rootProvider` seam over the `acpx` runtime. @@ -666,9 +714,76 @@ prompt nobody bounded. A prompt carries the duration its caller supplied, from `` or the enclosing ``, and otherwise none. +- **Session lifetime.** `sessionLifetime(agentName)` is a host-owned + `Result` on `AcpxProviderDependencies`, answering + `"durable"` or `"invocation"`. The provider asks it with the resolved agent + name before it prepares an adapter, probes availability, reads a store, places + a session or starts any child, and remembers only a successful answer. An + absent dependency is `Ok("durable")`, which is what every existing host and + agent already was. An `Err` refuses the operation there, before the agent is + contacted. Nothing infers a lifetime from a command string, adapter metadata, + a session record, a title, an ACP identifier or anything an agent answered. + + A durable session behaves exactly as the rest of this section describes. An + **invocation-scoped Agent session** exists only while the provider that opened + it does. The provider owns one private in-memory ACPX store for that lifetime, + keyed as ACPX's own file store is, reachable from nothing else and gone with + the provider scope; placement, the ensure, every later record read, the + permission-routing refresh and teardown all use the store and the runtime the + session was placed with, and no load or save crosses between lifetimes. The + unbound runtime is partitioned by lifetime for the same reason: a runtime + carries one store. Durable build-bound partitions are unchanged, and an + invocation-scoped session never enters one — a build binding is retained + history, and this session has none. + + A fresh invocation-scoped `` is inert: it places a key, takes no + ownership, builds no runtime and contacts nothing, and a second `` + naming the same placement is answered with the value already issued. The first + subscribed `` probes availability, then ensures in `persistent` mode + **without** `materialization: "first-turn-acceptance"`. It waits on no + `materialized` barrier, calls no `sessions.established()`, publishes no + construction route and retains no provider or checkpoint identity. The handle + stays live until provider teardown, so a second `` under the same + `` continues that conversation behind the first on the session's own + queue, while a different `` gets its own handle and may hold a turn at + the same time. Internally such a session carries its own state; `pending` and + `established` keep their durable meanings and are never used for one. + + **Lifetime is part of a Session's compatibility, independently of its + command.** A `Session` retains the lifetime it was placed under, and consuming + one requires the consuming agent's declared lifetime to equal it — even when + both agent names resolve to the same command. They can: ACPX resolves an agent + name it does not know to the name itself, so a document naming the raw + `devin acp` reaches the same child as the canonical `devin` while a host + declares only the canonical name invocation-scoped, because a lifetime is + never inferred from a command. Taking the lifetime from the placement instead + would let one session change store, runtime and retention half way through. + + The lifetime is compared before the resolved command, and the exact-object, + lifetime and command tests are three independent guards. What they refuse is + disagreement, not the alias: two names a host declares the same lifetime for + reach one placement, so a Session placed through one and consumed through the + other continues that one conversation. A mismatch is an + ordinary Session compatibility refusal naming the consuming agent with its + lifetime and the Session's retained lifetime, raised before any further store + access, route publication, ensure, turn or `sessions.established()` call. + + Nothing an invocation-scoped session reports becomes durable identity: not a + display title, an ACP session id, an ACPX record id, or Devin's + `cognition.ai/userMessageId`, which `checkpoint.ts` continues not to recognize + beside its explicit Codex and Claude namespaces. + + Cancellation and teardown are the ordinary ones. Halting an unfinished turn + removes it from the active set, sends ACP cancellation and waits for it; + provider teardown attempts every remaining cancellation and every distinct + owned handle close through the runtime that created it, reports a failed close + only after the rest were attempted, and leaves the store unreachable. There is + no background cleanup and no global registry. - **Availability.** The first use of an agent validates it through a disposable probe runtime's `doctor()`; a non-ok report throws with the agent's code and - details. Results are cached per agent. + details. Results are cached per agent. An invocation-scoped agent is probed at + its first supported `` instead, through runtime options carrying that + lifetime's store. - **Sessions.** `session()` places a session by (agent, logical session, contextual cwd): placement walks from the cwd up to the Git root and reuses the nearest existing record for the same agent command and cwd, otherwise names the @@ -696,13 +811,14 @@ none. the exact object it issued rather than the key inside it: a structural copy, a value another provider copy produced, and one whose provider scope has been torn down are all refused before any provider work, as is a value used with a - different resolved agent. The provider keeps that object for the placement's + different resolved agent or a different session lifetime. The provider keeps that object for the placement's 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.** Materialization is a durable session's contract; an + invocation-scoped one is never materialized and never established. 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: "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. @@ -762,8 +878,9 @@ 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 +- **Runtime partitions.** Ordinary ACP-first work uses one unbound runtime per + session lifetime, so durable and invocation-scoped work never share the store a + runtime carries. 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 the partition, and an `ensureSession()` that rejects gives that claim up — so @@ -786,7 +903,9 @@ none. vendored ACP runtime; `packages/acp/vendor/acpx/PROVENANCE.md` records why it exists and what removes it. - **Host-owned dependencies.** `AcpxProviderDependencies` carries what a host, - rather than a document, decides: `advertiseNativeLaunch` and + rather than a document, decides: `sessionLifetime` says how long this host can + continue one agent's sessions, and is asked before anything else; + `advertiseNativeLaunch` and `advertiseClientNativeAttachment` are two 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; @@ -803,4 +922,5 @@ none. them defaults to the `xmd run` behavior above. - **Teardown.** Provider-scope teardown cancels active turns and closes each distinct runtime handle with an all-settled strategy, throwing a single error or - an `AggregateError` from the provider scope. + an `AggregateError` from the provider scope. An invocation-scoped session's + handle is one of them, and its store becomes unreachable with the provider. diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 0f488f2b..e1be1c9d 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -10609,6 +10609,48 @@ Each row names the derivation it kills. | 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 | | 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 | +| AF25a | A launch needs a session that outlives the invocation | `` naming an agent this host serves only for the invocation is refused as `unsupported-capability` before placement, ownership, an adapter lookup, an ensure, a detach or a native process, and a `` wrapped around it stays inert before the same refusal (Tier AI) | + +### Tier AI/DV — Devin as an invocation-scoped Agent session + +Defined in [ACP client](./acp-client-spec.md) §Session lifetime. Tier AI drives +the provider through its dependencies with the scriptable fake runtime, stating +the host's lifetime declaration exactly as `packages/cli/src/agent-stack.ts` +states it, so no agent process starts. Tier DV is the black-box half: it puts a +real ACP agent named `devin` on a child's `PATH` and runs the production `xmd +run` against it, which is the only way to reach the command string and ACPX's +own Windsurf compatibility shim. Neither needs a credential or spends a model +turn. The refusals this lifetime causes belong to the tiers that own those +surfaces — AF25a for native launch, C16 for `` and `xmd plan`, and WAL17 +for a workflow. Nothing here certifies the live provider: qualifying an agent +against a real backend is a provider-neutral agent-ACP capability that does not +exist yet, so what these tiers cover is this host's decisions and the wire the +vendored ACPX shim speaks, and no evidence in them depends on a Devin +account. + +The host's own two decisions — which command `devin` resolves to, and which +lifetime each agent gets — are read from the dependencies each path hands +`createAcpxProvider`, because what a provider was built from is not observable +through a provider. + +| # | Test | Verify | +|---|------|--------| +| AE7 | The production command | The run path resolves `devin` to exactly `devin acp`, lists the name once beside the ones already there, and leaves this build's Codex and Claude snapshots and ACPX's answer for every other name unchanged | +| AE8 | The run host's declaration | `devin` is invocation-scoped and every other agent durable, decided from the agent name alone | +| AE9 | The workflow host's declaration | A workflow attachment answers `Err` for `devin`, naming the continuation it cannot give, and `durable` for every other name | +| AE10 | The Plan host's declaration | An invocation-scoped default agent produces no Plan Agent context, while a durable one still produces the ordinary production context | +| AI1 | Same-session continuity | Two sequential Prompts under one Devin `` perform one ensure and one handle, run FIFO, ask for `persistent` mode without first-turn-acceptance materialization, and retain nothing through the host's `established` callback | +| AI2 | Session independence | Two distinct Devin `` elements get distinct handles and hold turns in flight at the same time | +| AI3 | Cancellation and teardown | Halting a live turn cancels that turn and no other; teardown attempts every owned handle close through the runtime that made it, and reports a failed close only after the rest were attempted | +| AI4 | No cross-invocation retention | The durable store is never loaded from, saved to or used as a runtime's store for a Devin session, and a second provider with the identical placement establishes the conversation again | +| AI5 | Lifetime isolation | A mixed Devin/durable run creates two unbound runtimes with two stores; Codex still constructs through first-turn acceptance and its records still reach the durable store | +| AI6 | Native launch refuses first | A launch naming Devin is retained as `unsupported-capability` with no `perform`, no runtime created, no doctor call, no ensure and no turn | +| AI7 | No inferred identity | A handle carrying an ACPX record id and a backend session id, and a terminal result carrying `cognition.ai/userMessageId`, produce no route, no retained identity, no checkpoint token and no durable record | +| AI8/AI9 | Lifetime is Session compatibility | Two agent names mapped to one command and declared with opposite lifetimes refuse in both directions when a Session placed under one is consumed through the other, naming the consuming agent's lifetime and the Session's; the store, route, ensure, turn and establishment observations taken after the Session was created are unchanged | +| AI10 | Agreement crosses the alias | With both of those names declared the same lifetime, a Session placed through one and consumed through the other reaches one ensure, one live handle and both turns, on the key their shared command derives — so what AI8 and AI9 refuse is disagreement about the lifetime, not the alias | +| DV1 | The production command and the ACPX shim | `xmd run --default-agent devin` starts the `devin` on `PATH` as exactly `devin acp` — an agent that records any other argument vector records none — with Windsurf `clientInfo`, the `cognition.ai/requestDiagnostics` client capability, a `{}` answer to `_cognition.ai/request_diagnostics` and a quietly ignored vendor notification; two Prompts in one Session reach one live child, and a second invocation from the same directory and home reaches a fresh one | +| DV2 | Trace-only journal | The same run under `--journal` creates its trace and retains no ACPX session record under the home it was given, and the invocation after it still starts a fresh conversation | +| DV3 | The production alias shape | An outer ``, its `` and an inner `` fail the run closed; the retained Prompt record names the lifetime mismatch, no turn reaches the agent, and no ACPX record is written under the home the run was given | ### Tier CR — Component registration and resolution @@ -10785,6 +10827,7 @@ every refusal is proven by the phase tripwires that stayed at zero. | PS6–PS9 | Validation, delivery and endings | The invocation settles one structural check, and the host asks it again after the command document has completely torn down — a component removed immediately after a successful `` is refused there and nowhere else; the exact bytes then reach exactly one of stdout or an exclusively created `--output` file, and an existing path is refused unchanged; stopping, exhaustion, a failed turn, missing Agent context, cancellation, teardown failure and that host refusal each deliver nothing at all | | PS10, C14 | No execution, and the result | A named session continues the planning conversation and still starts no program, and no execution callback, program journal or second-root identity exists; the shipped generation, repair and revision instructions each carry the complete titled-Plan rule, and a titled Plan of prose interleaved with components survives approval byte for byte into stdout and a file alike | | PS12 | Product copy | Architecture, specifications, README and the homepage state that Plan produces source, Run executes source, and composition decides when it runs | +| PS13 | Durable-only authorship | A settled default agent whose sessions end with the invocation gives `xmd plan` and `` no Agent context: both refuse on the existing no-context sentence with the directory, provider, session, turn and review phases at zero, and naming the session changes nothing | ### Tier UG — The `xmd upgrade` command @@ -11377,6 +11420,11 @@ against the production Deno adapter, on real run files. Defined in [ACP client](./acp-client-spec.md) §The workflow Agent profile. +Which agents this profile can serve at all is a separate clause of the same +ceiling: it declares a session lifetime, and refuses an agent whose sessions end +with the invocation. The declaration is AE9 and what it does to a live run is +WAL17. + | # | Test | Verify | |---|------|--------| | WAP1 | Strict inputs | The runtime is created with the host's own directory and `mcpServers: []`, each session with `allowedTools: []`, under `deny-all` and non-interactive denial; no Workspace or caller path appears in any provider input | @@ -11484,6 +11532,7 @@ Defined in [Workflow workspaces](./workflow-workspace-spec.md) §8. | WAL8 | ACP-only Claude | The profile states both ordinary-run native capability sets empty, so an agent `xmd run` would own machine-wide is served here over ACP alone. A provider handed a coordinator, a route store and an executable observer under those empty sets reaches none of them for a Claude prompt | | WAL8 | An observed response reaches the next turn | An admitted ``'s complete retained response — name, status, headers and body — is rendered into the next `` with ``, and a completed replay restores it without asking the server or the agent again | | WAL9 | Authored approval | The same proposal under the production profile: the approved branch reaches ``, records one admission naming both write identities and the class, and leaves the change where an ordinary read in the run's Workspace finds it; the refused branch never expands the element, so no admission and no file effect exist | +| WAL17 | Durable-only continuation | A workflow whose `` names an agent this host serves only for the invocation is refused before any provider contact — no runtime created, no doctor, no ensure, no turn and no retained record — on the run's first use and again on the partial continuation whose next live operation is that Prompt, which journals nothing new | ### Tier WFX — A killed run resumes from its frontier diff --git a/specs/native-agent-session-launch-spec.md b/specs/native-agent-session-launch-spec.md index e7e07677..90a57fa9 100644 --- a/specs/native-agent-session-launch-spec.md +++ b/specs/native-agent-session-launch-spec.md @@ -132,6 +132,15 @@ interface AgentLaunchRequest { } ``` +An agent whose sessions this host serves only for the invocation that asked has +no durable identity for a native UI to be handed or to resume. A launch naming +one is retained as an `unsupported-capability` refusal at the start of the +provider's launch operation — before placement, ownership, an adapter lookup, an +ensure, a detach or any native process work — and a `` wrapped around +it stays inert and local before the same refusal. That answer is the host's +session-lifetime declaration, read from the resolved agent name, so nothing is +probed to reach it (see [Session lifetime](./acp-client-spec.md)). + `launchAgentSession(instructions, options)` is the canonical operation and the only owner of what a launch settles on: it reserves the terminal, normalizes the request, mints its durable identity, retains every phase, and derives the @@ -1119,7 +1128,10 @@ 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. a launch of an invocation-scoped agent is refused as + `unsupported-capability` with the doctor, ensure, turn and native-process + counters at zero. The authored half of this is one executable Markdown document, `packages/test-agent/src/NativeSessionLaunch.test.md`, run whole. It authors the @@ -1288,7 +1300,10 @@ Implementation review checks these frozen invariants: separate trusted-host choices, and neither is inferred from the other. 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 +23. An agent whose sessions end with the invocation is refused before placement, + ownership, adapter lookup, ensure, detach or any native process work, from + the host's lifetime declaration rather than from anything the agent said. +24. A failed acquisition retains no partition and no live path; a partition is evicted only when it holds no handle and no work in flight; every handle-producing path enters one ownership account bound to its creator the moment its handle exists; a cancellation observes and settles an ensure it diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 0d80ac0b..bc249dd3 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -418,6 +418,17 @@ authorship carries a provider and a default agent and nothing else. A provider that cannot establish this ceiling refuses before session materialization or a turn; there is no silent downgrade. +Both authorship surfaces also require an agent whose sessions can be continued. +A Plan is written in a conversation a caller may name and return to — that is +what `--session` and `` are for, and what the durable session +directory below exists to serve — so an **invocation-scoped Agent session** +cannot hold one. When the settled default agent is invocation-scoped this host +supplies no Agent context, and the command and the Component both take the +existing no-context refusal path: no session directory is established, no +provider is built, no session is placed and no turn is taken. Naming the session +explicitly does not change that answer, because a name asks for exactly the +continuation this agent has no way to give. + The profile's working directory is `~/.xmd/plan/sessions/`. It is dedicated to that one logical session rather than shared by every invocation, so two conversations @@ -717,7 +728,9 @@ grammar), `packages/cli/tests/plan-command-document.test.ts` (the packaged document executed as itself), `packages/cli/tests/plan.test.ts` (the host and the packaged document writing a Plan together) and `packages/cli/tests/plan-cli.test.ts` (the command lifecycle, its grammar as an -operator meets it, and the artifact). +operator meets it, and the artifact) and +`packages/cli/tests/plan-component.test.ts` (the same Component reached from an +ordinary document). The ACPX runtime is a scriptable fake, the review provider is a scripted `Elicitation` handler, and the contextual working directory is a temporary one: @@ -742,3 +755,4 @@ neither observation never interpreted what it wrote. | PS11 | Adapter and Component | The command document remains the exact thin adapter, and `` remains a bare-or-captured exact text component | | PS12 | Product copy | Architecture, specifications, README and the homepage state that Plan produces source, Run executes source, and composition decides when it runs | | C2–C5, C8, C9, C13, C14 | Authorship | The packaged adapter and Component, one Session, the profile ceiling, the repair and review bounds, safe presentation, the authored endings, directory lifetime and narrative preservation are unchanged by this command producing source only, and keep their evidence | +| PS13 | Durable-only authorship | A settled default agent whose sessions end with the invocation gives `xmd plan` and `` no Agent context: both refuse on the existing no-context sentence with the directory, provider, session, turn and review phases at zero, and naming the session changes nothing | diff --git a/specs/workflow-workspace-spec.md b/specs/workflow-workspace-spec.md index 349c66f2..ff412020 100644 --- a/specs/workflow-workspace-spec.md +++ b/specs/workflow-workspace-spec.md @@ -2144,6 +2144,18 @@ owns a session, how it was constructed and which executable build accepted its identity; a workflow session is named by the row below instead, and an agent served here is served over ACP alone. Composing the two is a separate design. +**Only an agent whose sessions can be continued is served here.** Continuing a +run reattaches the session its row names, so an agent this host serves only for +the invocation that asked can hold no workflow session at all. The profile +declares that to the provider as its session lifetime, and the provider refuses +from agent resolution — ahead of adapter preparation, the availability probe, +any placement and any turn. That is true on the run's first use of the agent and +on a partial continuation whose next unrecorded operation is a Prompt: the +completed work replays from the journal, and the live Prompt that follows it +refuses without contacting anything. The refusal names the run's own +continuation as the reason and points at `xmd run`, which is where such an agent +is supported. + **The mapping lives in the run.** It is a row in the run's own database, committed in the run's own transaction — a mapping that could commit while the run did not would describe a session the run never had. The table is part of the