Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 11 additions & 0 deletions .oxlint-prose.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"ignorePatterns": [
"**/node_modules/**",
"site/_fresh/**",
"site/vendor/**",
"site/*.timestamp-*.mjs"
],
"categories": { "correctness": "off" },
"jsPlugins": [{ "name": "local", "specifier": "./scripts/oxlint-plugin.js" }],
"rules": { "local/no-banned-jsdoc-words": "error" }
}
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

"rules": {
"eslint/curly": "error",
"local/no-banned-jsdoc-words": "error",
"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-sync-filesystem": "error",
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,12 @@ the corpus or enabling in-shard concurrency is not an answer to a miss.
alternatives, defensive qualifications, and exhaustive detail that does not
help a reader understand or use the design.

### Banned words

Avoid words in the canonical [banned-words list](scripts/banned-words.js) when
writing architecture prose or JSDoc. Choose wording for the specific meaning;
keep literal code references intact. `deno task lint` enforces the list.

### Component descriptions

Write a component description for an author deciding what to type. Lead with an
Expand Down
171 changes: 86 additions & 85 deletions architecture.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
"test:node": "tsx scripts/runtime-tests.ts node",
"test:bun": "bun scripts/runtime-tests.ts bun",
"test:deno": "deno task test",
"lint": "oxlint -c .oxlintrc.json --ignore-pattern 'scripts/tests/fixtures/**' --ignore-pattern '**/npm/**' --ignore-pattern 'packages/workflow/vendor/cloudflare-computer-dofs/**' --ignore-pattern 'packages/acp/vendor/acpx/**' --ignore-pattern 'packages/cli/src/repl/vendor/freedom/**' packages scripts && oxfmt --check packages scripts && pnpm run lint:dead-code",
"lint": "oxlint -c .oxlintrc.json --ignore-pattern 'scripts/tests/fixtures/**' --ignore-pattern '**/npm/**' --ignore-pattern 'packages/workflow/vendor/cloudflare-computer-dofs/**' --ignore-pattern 'packages/acp/vendor/acpx/**' --ignore-pattern 'packages/cli/src/repl/vendor/freedom/**' packages scripts && oxlint -c .oxlint-prose.json site smoke-test && node scripts/lint-architecture.js && oxfmt --check packages scripts && pnpm run lint:dead-code",
"lint:dead-code": "knip --config knip.json --no-progress --no-config-hints --include files,exports,types",
"fmt": "oxfmt --write packages scripts"
},
Expand Down
145 changes: 47 additions & 98 deletions packages/acp/src/adapter-snapshots.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,27 @@
/**
* The ACP adapters a workflow run executes, and how they reach the disk
* (`packages/acp/vendor/adapters/PROVENANCE.md`).
*
* A workflow Prompt needs an adapter that names the turn it just completed, on
* `_meta`. No published adapter does yet, so this repository carries one npm
* tarball per provider — packed from an exact patched upstream commit by that
* project's own build — and runs those instead of whatever `npx` would resolve.
*
* ## Why the bytes travel in the module graph
*
* A tarball on disk is unreachable from a compiled binary and from the npm
* artifact. The same bytes are therefore carried as a generated module, which
* every distribution inlines identically, and this module writes them out. One
* mechanism, so "the source tree, the npm package and the binary all run the
* same adapter" is a fact rather than three arrangements that happen to agree.
*
* ## Content-addressed, so identity is the location
*
* A snapshot materializes under its own SHA-256. Two runs asking for the same
* adapter name the same directory, a changed snapshot names a different one,
* and nothing has to decide whether an existing directory is current — the
* question does not arise.
*
* ## Identity is not the launch path
*
* Two different things are needed, and conflating them was a defect: an adapter
* has a **stable identity** — its provider, package, version and snapshot
* digest — and a **host-local launch path** under whatever root this machine
* materialized into.
*
* Only the identity is durable. It is what a retained Agent session records and
* what a sealed artifact carries, so two machines running the same snapshot
* agree, and a run exported from one is readable on the other. The path appears
* nowhere durable: it names a directory that exists on one host, and putting it
* in an artifact would make where a file happened to live a compatibility term.
*
* The launch path is quoted, because a root containing a space is an ordinary
* thing on a developer's machine and an unquoted command would split there.
*
* ## Published atomically, verified before use
*
* Materialization happens in a private temporary directory and is published by
* one rename. A concurrent run either wins that rename or finds the directory
* already there — both then verify before using it, so nobody executes a tree
* another process is still filling.
*
* Replacing an *abandoned* tree is the one step that cannot be done by rename
* alone, because a rename will not overwrite a non-empty directory. Removing it
* first is a decision that goes stale: another attempt may publish a valid tree
* between the observation and the removal, and deleting that would pull the
* files out from under an adapter already running. So recovery is serialized on
* an exclusive claim, and the holder re-checks under it before removing
* anything.
*
* The claim is a ticket: the directory `claims/<n>`, taken by renaming a
* prepared directory onto that name, which only one attempt can win and which
* is never handed out a second time.
*
* Two properties come from that, and the claim needs both. Because numbers are
* never reissued, finishing a dead holder's ticket can only ever act on the
* exact ticket that was inspected. Because they are taken in ascending order,
* an attempt arriving later cannot obtain a lower number, so it cannot come to
* precede a holder that has already been elected.
*
* The holder is the lowest ticket still outstanding. Releasing is marking one
* finished, and a process killed mid-recovery leaves a ticket whose owner a
* later attempt reads as dead and finishes on its behalf.
*
* ## It refuses; it never falls back
*
* A digest that does not match, an extraction that fails, a dependency install
* that fails, a missing entry file: each raises. Running the published adapter
* instead would be running one that emits no token, which is the state this
* exists to leave — silently, and after the run had already been told it was
* continuing an exact conversation.
* Materialize the patched ACP adapters that report completed-turn `_meta`
* (`packages/acp/vendor/adapters/PROVENANCE.md`). Their npm tarballs come from
* exact patched upstream commits and travel as a generated module so source, npm
* and compiled distributions execute identical bytes.
*
* A snapshot materializes under its SHA-256. Durable identity is provider, package,
* version and digest; the quoted host-local launch path is never retained in a
* session or sealed artifact.
*
* Stage privately, publish by one rename and verify the published tree before
* use, including when another attempt won. Recovery of an abandoned non-empty
* tree requires exclusive ownership of recovery and a fresh check before
* removal, otherwise a stale observation could delete another process's newly
* published valid adapter.
*
* Recovery tickets are prepared directories atomically renamed to `claims/<n>`.
* Tickets increase monotonically and are never reused: late arrivals cannot
* precede an elected holder, and finishing a dead holder cannot affect a later
* ticket. The lowest unfinished ticket owns recovery; release marks it
* finished, and a later attempt may finish a dead owner's ticket.
*
* Digest, extraction, installation or missing-entry failures raise. Falling back
* to a published adapter would lose the turn identity continuation depends on.
*/

import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
Expand Down Expand Up @@ -120,10 +68,10 @@ const RECOVERY_ATTEMPTS = 400;
const OWNER = "owner";
const DONE = "done";

/** A ceiling on ticket numbers, so a pathological claims directory cannot spin. */
/** A ceiling on ticket numbers, so a pathological recovery-ticket directory cannot spin. */
const MAX_TICKETS = 10_000;

/** Who holds a recovery claim, so a later attempt can tell live from dead. */
/** Who holds a recovery ticket, so a later attempt can tell live from dead. */
interface RecoveryOwner {
readonly pid: number;
readonly host: string;
Expand All @@ -150,7 +98,7 @@ function parseOwner(raw: string): RecoveryOwner | undefined {
* Signal 0 asks without delivering anything. `EPERM` means it exists and
* belongs to somebody else, which is still alive; only `ESRCH` means gone. An
* answer this cannot interpret is treated as alive, so an unreadable state
* never becomes a reason to take somebody's claim.
* never becomes a reason to take somebody's recovery ownership.
*/
function alive(pid: number): boolean {
try {
Expand Down Expand Up @@ -431,10 +379,10 @@ export function createEmbeddedAdapters(
* on. An attempt arriving later cannot obtain a number below one already
* taken, so it cannot come to sit ahead of a holder.
*
* Ordering names by time could not offer that. Two claims made in the same
* millisecond were separated only by the random part of their names, and a
* clock adjustment could hand a later arrival an earlier name — either way an
* elected holder could be preceded after the fact.
* Ordering names by time could not offer that. Two recovery tickets issued in
* the same millisecond were separated only by the random part of their names,
* and a clock adjustment could hand a later arrival an earlier name — either
* way an elected holder could be preceded after the fact.
*/
function* takeTicket(claims: string): Operation<number> {
yield* ensureDir(claims);
Expand Down Expand Up @@ -465,15 +413,15 @@ export function createEmbeddedAdapters(
}

/**
* Whether this attempt now holds the claim.
* Whether this attempt now owns recovery.
*
* The holder is the lowest ticket still outstanding, so every number below
* this attempt's own is examined in order: a finished ticket is passed over, a
* ticket whose owner is a dead process on this host is finished here, and a
* this attempt's own is examined in order: a finished ticket is passed over,
* a ticket whose owner is a dead process on this host is finished here, and a
* live owner, an owner on another machine, or a record that cannot be read
* each stop the election where it stands. The last one says so, because
* refusing to act on what it cannot read is the only safe reading of somebody
* else's claim.
* else's recovery ticket.
*
* Finishing a dead holder's ticket is safe for the reason the election is:
* the number is never reissued, so this can only ever act on the exact ticket
Expand Down Expand Up @@ -518,17 +466,18 @@ export function createEmbeddedAdapters(
}

/**
* Replace an abandoned tree at the target, holding an exclusive claim.
* Replace an abandoned tree at the target, under exclusive ownership of
* recovery.
*
* Reached only when the rename failed and what is there does not verify. The
* claim makes "check, then remove" one decision instead of two: without it, a
* second attempt could publish a valid tree in the gap and this one would
* delete it, taking the files out from under an adapter that had already
* resolved and might already be running.
* Reached only when the rename failed and what is there does not verify.
* Exclusive recovery ownership makes "check, then remove" one decision
* instead of two: without it, a second attempt could publish a valid tree in
* the gap and this one would delete it, taking the files out from under an
* adapter that had already resolved and might already be running.
*
* An attempt that does not hold the claim never removes anything. It waits
* and answers from what the holder published — including when the wait ends
* with this attempt elected, because a holder finishes its ticket only after
* An attempt that does not own recovery never removes anything. It waits and
* answers from what the holder published — including when the wait ends with
* this attempt elected, because a holder finishes its ticket only after
* publishing.
*/
function* recover(
Expand Down Expand Up @@ -690,9 +639,9 @@ export function createEmbeddedAdapters(
*
* A closed registry was the wrong shape for the same reason. It made "this
* build has no Codex snapshot problem to solve for Gemini" into "this build
* refuses Gemini", which is a much larger claim than the one being made, and it
* would have changed the retained identity of agents the checkpoint work has
* nothing to say about.
* refuses Gemini", which is a much broader restriction than the one being made,
* and it would have changed the retained identity of agents the checkpoint work
* has nothing to say about.
*
* The overlay is *qualified*: an embedded provider never falls through. A
* snapshot that cannot be verified or materialized refuses the agent, and does
Expand Down
36 changes: 8 additions & 28 deletions packages/acp/src/checkpoint.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,14 @@
/**
* Which provider turn a completed Prompt was, as this package reads it.
* Read a completed Prompt's opaque provider turn identity from `_meta`.
*
* ACP carries per-response metadata under `_meta`, and an adapter that knows
* how to name its own turns puts that name there. The runtime carries the whole
* value across untouched and recognizes nothing in it, because which keys mean
* something is this package's decision rather than a transport's.
* Recognized identities are `_meta.codex.turnId` (App Server turn id) and
* `_meta.claudeCode.assistantMessageUuid` (assistant message uuid). The runtime
* passes metadata through; this package discards every other field.
*
* Two namespaces mean something here, one per adapter, and each is the
* adapter's own:
*
* | Adapter | Where it says it | What that is |
* | --- | --- | --- |
* | Codex | `_meta.codex.turnId` | the App Server turn id |
* | Claude | `_meta.claudeCode.assistantMessageUuid` | the assistant message uuid |
*
* Everything else in `_meta` is discarded. Quota, failure detail and whatever
* else an adapter reports are that adapter's business; nothing here reads them,
* and nothing here treats an unfamiliar key as a checkpoint because it happens
* to look like one.
*
* The value is opaque. It is compared, never interpreted: this does not check
* it against a UUID grammar, does not shorten or normalize it, and does not
* reconstruct one from anything else. Transcript text repeats, another turn's
* token is a different turn, a provider's current head is a later point in the
* conversation, and prompt or journal position is order rather than identity —
* so a completion that names no turn simply names none.
*
* Ambiguity is refused for the same reason. Two adapters both claiming one turn
* is two answers to which conversation this was, and there is no rule for
* choosing between them, so such a completion carries no checkpoint at all.
* Exactly one recognized non-empty string yields a checkpoint. Missing or
* ambiguous identities yield none; transcript text, current session head and
* journal position never substitute for one. Values are compared unchanged,
* without UUID validation or normalization.
*/

import type { AgentPromptCheckpoint } from "@executablemd/core";
Expand Down
38 changes: 10 additions & 28 deletions packages/acp/src/native-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,17 @@
* Native launcher adapters (specs/native-agent-session-launch-spec.md
* §Provider-native identity).
*
* An adapter knows one thing a document must never state: the argv that makes
* a particular coding-agent CLI resume one exact provider-native session.
* `claude --resume <id>` and `codex resume <id>` are adapter implementation
* details, not authored values, and neither an executable path nor a session
* id ever appears in a document.
* Adapters own native resume argv; executable paths and provider-native session
* ids are never document inputs. Command knowledge alone grants no launch:
* `provider-returned` adapters must prove ACP and the native UI reach the same
* conversation; `client-allocated` adapters must prove native creation uses the
* allocated identity, applies private instructions to the first user turn without
* a bootstrap, and later resumes that conversation.
*
* Knowing the command shape is not the same as being launch-capable.
* Advertisement is separate, and what an adapter has to prove depends on who
* names its sessions. An adapter the provider names proves that the session
* ACP created is the session the native UI resumes; an adapter that names its
* own sessions creates one directly, so what it proves instead is that the
* native process makes that exact conversation, that the private instruction
* layer governs its first user turn without a bootstrap, and that a later
* invocation resumes the same identity rather than making a second one.
* Until an adapter has proven its own contract against the installed CLI,
* `<Session.Launch>` refuses that agent before anything of the session moves —
* before a provider-returned adapter's ACP session is released, and before a
* client-allocated one allocates an identity or writes a private file. That is
* the failure the contract asks for rather than a hopeful spawn.
*
* Adapters differ along two structural axes, and both are discriminated rather
* than inferred. The first is who chooses the provider-native session identity:
* a `provider-returned` adapter can only resume a session something else
* created and named, while a `client-allocated` adapter names the session first
* and hands that name to the native process, which is what lets a launch
* construct a conversation instead of merely reattaching to one. The second is
* whether the adapter pins the build behind its executable, and it is
* independent of the first: a name means one conversation only beside the build
* that issued or accepted it, whichever side chose the name.
* Until that contract is proven against the installed CLI, launch refuses before
* ACP detach, client identity allocation or private-file creation. Build pinning
* is an independent discriminator: either construction route may bind a session
* to the exact executable build that issued or accepted its identity.
*/

import { randomUUID } from "node:crypto";
Expand Down
Loading
Loading