Skip to content

docs(api): reconcile snapshot-run with existing ato-api run infra (#896)#911

Closed
Koh0920 wants to merge 875 commits into
nightlyfrom
feat/0.7-snapshot-run-control-reconcile
Closed

docs(api): reconcile snapshot-run with existing ato-api run infra (#896)#911
Koh0920 wants to merge 875 commits into
nightlyfrom
feat/0.7-snapshot-run-control-reconcile

Conversation

@Koh0920

@Koh0920 Koh0920 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Records the decision (approved on #896): snapshot-run extends the existing ato-api runs table / /v1/runs / runner-lease / Cloud Slots (managed-cloud), rather than forking a parallel run system. Only capsule_snapshot_jobs + capsule_snapshots are new (Ready-State sealed artifacts — distinct from source_snapshots). Additive columns on runs; additive read-model on the Store capsules listing.

Follows the merged contract #904. Prevents an orphan-sweep-style duplicate run model before Track A/C implementation.

Koh0920 added 30 commits June 12, 2026 16:36
…ed helpers (#685)

Two infrastructure primitives had drifted into security/correctness-relevant
divergence across crates (#658). Each now resolves through one helper in
capsule-core foundation, with the safe semantics pinned by tests.

Recursive tree copy (capsule_core::common::fs::copy_dir_recursive with an
explicit SymlinkPolicy): the local-source staging path used to copy symlink
*target contents* (is_file() || is_symlink() -> fs::copy), so a source tree
containing `cfg -> ~/.ssh/config` embedded the foreign file into the staged
capsule. Symlinks are now never followed; callers choose Skip (fail closed,
used by local staging and the github cache) or Preserve (recreate the literal
link, Unix only). The executable bit still survives via fs::copy.

Readiness probe (capsule_core::common::readiness::http_status_indicates_ready):
the same manifest readiness_probe evaluated differently per execution path
(strict 200/201/204 vs. any 200..500 where 404 counted as ready). All paths
now share Kubernetes httpGet semantics: ready iff 200 <= status < 400.
Redirects count as ready; a missing probe path (404) is misconfigured, not
ready. nacelle mirrors the predicate inline (it does not depend on
capsule-core) and keeps a comment pointing at the source of truth.

Tests: capsule-core unit tests pin both edge cases (skip drops symlinks,
preserve recreates the literal target, redirect-ready, 404-not-ready); an
ato-cli regression test asserts local staging never materializes a symlink.

Other duplicate clusters listed in #658 (atomic-write, tilde expansion,
version compare, streaming sha256, nacelle home dir, MCP host-cleanup) are
maintainability-only and left for follow-ups to keep this diff scoped to the
two behavioral drifts the issue prioritizes.
Remove dead code surfaced by the code review (no production callers,
grepped across the workspace including tests):

- application/ports.rs: InteractionPort trait + SharedInteractionPort
  alias (zero implementors/references).
- application/pipeline/phases/install.rs: run_publish_install_phase, a
  dead futures::executor::block_on wrapper with a hardcoded None arg.
- application/execution_receipts.rs: write_receipt_atomic / read_receipt
  default-root wrappers (tests use the *_at variants; mark read_receipt_at
  allow(dead_code) to match its test-only sibling).
- routing/router.rs (capsule-core): resolve_target_field +
  resolve_command_string TOML command-form parsers (zero callers), and
  the now-unused module-local 'use shell_words'.
- application/preflight.rs: preflight_oci_image_resolution +
  OciImageResolutionReport and their in-file-only tests; trim the
  now-unused imports.
- adapters/runtime/executors/oci_compose_runner.rs: resolve_images_for_compose,
  a copy of the non-reuse branch of resolve_images_with_lock_replay; route
  the three in-file tests through the production fn with existing_lock: None.
- application/source_inference/mod.rs: use_global_run_state field
  (compile-time-asserted constant-true), WORKSPACE_RUN_SOURCE_INFERENCE_DIR,
  the USE_HOME_RUN_STATE const + assertion, and the dead cwd-based else
  branch in materialize_run_result. Run-attempt state is now unconditionally
  under ~/.ato/runs/source-inference/. Adds a regression test asserting the
  global root is used independent of workspace_root.

Behavior is unchanged: the SessionInfo status/readiness_confirmed pair
(issue item 8, lower priority) is intentionally left as-is to avoid
altering the serialized 'ato run' JSON contract.
…-wire type (#692)

The ExecutionPlan consent-required payload was hand-mirrored in three
places with duplicated validation over two channels:

- consent_store::ConsentRequiredLine (stdout CONSENT-REQUIRED: producer)
- runner_agent::ConsentRequest + is_valid (stdout consumer)
- ato-desktop cli_envelope::ConsentRequiredDetailsDto + its empty-field
  check (stderr details consumer)

A schema bump or field rename compiled on all three sides while silently
making the validators reject, so no consent prompt would appear.

Move the shared identity 5-tuple + summary, the schema/reason constants,
and the single validation into capsule-wire::consent (the DAG-root crate
whose purpose is to make exactly this drift impossible). Both channel
envelopes flatten the shared ConsentIdentity, so the wire bytes are
unchanged; capsule-core re-exports CONSENT_REF_SCHEMA from the wire
constant so the consent_ref hash input cannot diverge from the validator.

Behavior preserved: stdout line and stderr details parse and validate
exactly as before (unrelated E302 still falls through to the fatal-toast
path; empty identity fields still yield no modal). Scoped to the consent
payload unification (issue item 2); the sentinel/error-substring and
consent-pinning items are left to follow-ups as the issue staged.
)

The source/python provision command quoted its setuptools upper bound
with POSIX single quotes (`'setuptools<72'`). cmd.exe does not treat
`'` as a quote character, so the `<` was exposed and parsed as input
redirection, breaking `ato run` on windows/x86_64 with 'Access is
denied.' / 'The system cannot find the file specified.' (issue #629).

Quote the constraint with double quotes, honoured by both POSIX sh and
cmd.exe, and route the Windows lifecycle command through `cmd /D /S /C`
via `raw_arg` so the inner quotes reach cmd verbatim instead of being
backslash-escaped by Rust's argument quoter. `/S` makes cmd strip only
the outer quote pair, leaving "setuptools<72" intact as one
redirection-safe argument.
…runs (#695)

* fix: auto-provision headless state for runner --sandbox runs

A Connected Runner spawns `ato run <source> --sandbox` with no `--state`
binding, so any recipe declaring a `[state.*]` block hard-errored on the
unbound persistent state ("requires an explicit persistent binding") or
failed container creation on the un-creatable `/var/lib/ato/state`
ephemeral base. The desktop/session path already auto-provisions a
per-source state directory; the headless run path did not.

Provide headless state auto-provisioning equivalent to the desktop path:
for sandboxed runs, every declared state still unbound after explicit
`--state` bindings is bound to a stable `~/.ato/state/run/<name>/<state>`
directory. Applied in both the authoritative-input and non-authoritative
branches of run_prepare_phase. Fails closed on any state kind other than
filesystem. Non-sandbox `ato run` keeps its existing fail-closed behavior.

* test: pin #687 fix wiring via resolve_explicit_or_auto_state_source_overrides

The prior tests exercised auto_provision_headless_state_overrides in
isolation (a symbol new to this PR), so they could not regress if the
sandbox dispatch were removed. Add two tests that drive the actual fix
wiring: sandbox mode tolerates a declared-but-unbound persistent state
(deferring it to the auto-provisioner), while non-sandbox still fails
closed with the explicit-binding error.
)

On macOS the native seatbelt sandbox redirects the workload child's
stdout/stderr to a per-workload log file and registers it as a sync child.
nacelle's start_log_forwarding only drains live pipes (dev-mode async
children), so for production sandbox runs it forwarded nothing: when the
child crashed, the real failure (e.g. a Python traceback) lived only in an
opaque temp log under $TMPDIR/nacelle-runs/<id>/logs/ that nacelle never
read back and the runner log tail never saw. The exit code was reported
with no diagnostics on any runner.

nacelle: on a non-zero child exit (both the readiness Exited branch and the
final wait), read the tail of that log file and echo it to nacelle's stderr
(the stream ato-cli forwards into the runner log), framed and [child]-
prefixed. Capped at 16 KiB from the end, starting on a line boundary.
No-ops on success or when no log file exists (dev-mode pipes).

With the child output now observable, the underlying macOS early-exit is a
read-only-filesystem error: ato-cli mounts the writable per-run session-data
dir at the guest path /runs/ato/session and points ATO_DATA_DIR /
DATABASE_PATH there. Linux bwrap remaps that to the host dir, but macOS
seatbelt has no mount namespace -- the child sees host paths -- so a stateful
capsule tried to mkdir /runs on the read-only host root and uvicorn exited
before readiness (OSError [Errno 30] Read-only file system: '/runs'). On
macOS, point the data-path env at the writable host session dir instead.

Verified locally on macos/aarch64: hello-capsule now reaches readiness and
writes app.db under the host session dir; a forced crash surfaces the full
traceback in the runner output.
* fix: wire readiness emission into foreground NodeCompat runs

Foreground NodeCompat runs (Connected Runner dispatch and
`ato run … --sandbox -y`) went through `node_compat::execute`, which
blocked on the child with inherited stdio and emitted no readiness signal:
no TCP probe, no LifecycleEvent pump, no `LIFECYCLE: ready port=N` line,
and no V2 receipt readiness re-stamp. The runner agent therefore never saw
the canonical ready line, and a healthy node capsule serving its declared
port sat in "Assigned to runner…" until the 600s ready timeout. The
background path (`spawn_background`) already wired this via
`spawn_host_lifecycle_events`; the foreground path did not.

Add `node_compat::spawn_foreground`, mirroring `spawn_background`'s
readiness wiring (a `CapsuleProcess` whose `event_rx` is fed by
`spawn_host_lifecycle_events`) but inheriting stdio so guest output still
reaches the terminal. Route the foreground NodeCompat branch through it and
`complete_foreground_source_process`, exactly like the Native and host
fallback branches: TCP-probe the declared port, print the machine
`LIFECYCLE: ready` line, and re-stamp the V2 receipt from the observed
event. Capsules with no declared port keep honest StartedWithoutReadiness
(no fake ready). The shared command assembly is extracted into
`build_orchestration_command` so both spawn paths stay in lockstep.

* test: drive readiness-port derivation the foreground node pump consumes

The two #623 regression tests asserted derive_launch_spec(&plan).port,
which this PR never modifies — they passed against the old buggy code and
exercised none of the new path. Extract the readiness-port derivation
build_orchestration_command performs (override_port(launch_spec.port), the
value spawn_host_lifecycle_events actually probes) into resolve_readiness_port
and assert against that instead. Add a session-override-precedence case (the
#565 remap) that the raw launch_spec.port assertion structurally cannot
express.
…694)

* fix(cli): tear down bwrap-sandboxed keep-alive server on Linux stop

On Linux a non-network `ato run` launches its workload through
`bwrap --unshare-all`, which leads its own process group and hides the
sandboxed command line and cwd behind a PID namespace. The import
keep-alive stop path only signaled process groups it could verify via the
ATO_IMPORT_SESSION_ID marker or a shadow_dir reference, neither of which is
visible for a namespaced child, so the recorded bwrap group was skipped and
the preview server kept listening after `ato stop` returned success.

Verify a recorded process group as Ato-owned when any of its members
descends, via the ppid chain, from the confirmed-owned `ato run` pid. This
is gated on ato_run_owned (root pid + start time matched), so it stays fail
closed and can never kill a group rooted under a recycled or unrelated pid.
Capture ppid in the ps snapshot to support the walk.

Re-enables the two import keep-alive teardown tests that were skipped on
Linux in #636 so they guard against regression.

* test(cli): clarify namespaced grandchild is the unverifiable member

The accept-sandboxed-descendant test comment implied the bwrap monitor
itself carries no shadow_dir reference. In production the bwrap argv does
carry the host shadow_dir via --ro-bind <shadow_dir> /app and may match on
that; the genuinely unverifiable member is the namespaced grandchild whose
host-visible argv uses guest paths and whose cwd resolves inside the
namespace mount. Note this in the fixture so future readers do not conclude
bwrap itself was unverifiable.

* fix(cli): kill bwrap-sandboxed keep-alive subtree by host pid on stop

The Linux import keep-alive server survived `ato stop` because the host
teardown only signaled process groups via kill(-pgid). bwrap launches the
workload with --unshare-all --new-session: --new-session makes the sandboxed
server setsid() into a process group whose pgid is not the recorded bwrap
pgid, so kill(-pgid) never reaches it. --die-with-parent is the only other
teardown and does not propagate to namespaced grandchildren on a force kill.

stop_import_preview_session_record now enumerates every host pid descending
from the recorded ato run pid via the ppid chain (host ps sees namespaced
pids) and SIGKILLs them directly by host pid. The descendant set is captured
before the root is killed, since killing the root reparents the subtree to
pid 1 and severs the chain that identifies it. A bounded post-kill reap loop
re-SIGKILLs any captured pid still alive until the subtree is gone, which is
what closes the keep-alive preview port.

* fix(cli): record durable workload pids and SIGKILL them on stop

The Linux import keep-alive server kept listening after `ato stop` because
the stop path had no durable handle on the sandboxed workload. It relied on
process-group signaling (kill(-pgid)) — which misses a server that bwrap
--new-session puts in its own setsid group — and on a ppid walk from the
recorded ato_run_pid, which can come up empty: the keep-alive supervisor is
the import's detached child, so once import returns "running" the supervisor
is reparented and the stop-time ppid chain no longer reaches the subtree.

Capture the workload subtree's host pids at spawn time instead. The import
readiness loop now walks the supervisor's descendants (the bwrap wrapper plus
its namespaced children) via probe_workload_pids and stores them, each paired
with its OS start time, in the new ImportPreviewSession.workload_pids field.

stop_import_preview_session_record now SIGTERMs then SIGKILLs each recorded
workload pid directly by host pid — guarded by the recorded start time to
defeat pid reuse — in addition to the existing process-group and ppid-walk
teardown, and reaps them in the bounded post-kill loop. Killing the bwrap
wrapper pid directly collapses its PID namespace, so the sandboxed server
dies and the port closes even when the supervisor is already gone.
The Connected Runner now reports its agent_version on register + heartbeat, and
acts on a server self-update directive: when the heartbeat response carries
{ update: { target_version } } and the runner is idle, it self-updates
(axoupdater, async) and re-execs into the new binary (startup reconcile settles
any orphaned leases). A given target is attempted once so an unsatisfiable
target (newer than the latest release) is not retried every heartbeat.

- agent_version() = CARGO_PKG_VERSION, sent on register + heartbeat
- HeartbeatResponse.update parsed → HeartbeatOutcome::Ok.update_target
- run_self_update_async() in update.rs (async-safe; needs install receipt)
- reexec_serve() (unix exec / non-unix spawn+exit)

Pairs with ato-api (runner self-update directive) + ato-pwa (Update button).
3 new tests; 53 runner_agent tests green.
Addresses review:
- Directive field target_version → minimum_version: the runner self-updates to
  LATEST (axoupdater), so the server value is a floor it must reach, not an exact
  version. The runner no longer implies it installs an exact target.
- run_self_update_async returns a classified SelfUpdateOutcome (Updated /
  AlreadyLatest / NoReceipt). The serve loop no longer gives up forever on every
  failure: NoReceipt is terminal; a transient failure retries after a 5m
  cooldown; already-latest-but-below-minimum retries after 1h. Reset when the
  requested minimum changes.

53 runner_agent tests green; rustfmt clean.
…sted runner failure reports (#707)

PR #697 added surface_child_log_tail in nacelle, which echoes a sandboxed
child's raw stdout/stderr tail to nacelle's stderr ([child] …). ato-cli
forwards that stream into the runner log and persisted run-failure reports
(R2/D1). The existing scrub_secrets only redacted ato_rnr_ runner tokens, so a
traceback carrying an API key, a GitHub token, an OpenAI/Anthropic key, or a
.env-style KEY=value secret was persisted in the clear.

Strengthen scrub_secrets — the single common boundary every runner sink already
routes through before persistence — to also redact GitHub tokens
(ghp_/gho_/ghu_/ghs_/ghr_/github_pat_), OpenAI & Anthropic keys (sk-…,
sk-ant-…), npm tokens, AWS access-key ids (AKIA…), Bearer/Authorization
headers, URL userinfo credentials, and .env-style KEY=value / KEY: value secret
assignments. The surrounding traceback shape is preserved; only the secret
value is replaced with [REDACTED]. The dedicated ato_rnr_[REDACTED] runner-token
form is preserved (the regex passes skip already-redacted spans).

All four sinks are covered because they funnel through scrub_secrets:
- saved log_tail: BoundedLog::line scrubs every persisted run-log line
- failure report / run error: scrub_secrets(&format!("{err:#}")) reports
- runner lease error: LeaseReport::Failed message scrubbed in report_lease_status

Add tests asserting an API key, ghp_…, sk-…, and a .env-style secret are
redacted in the persisted form (including via the BoundedLog write path).

Fixes #702
… honor durability (#706)

* fix(cli): key headless state auto-provisioning on source identity and honor durability

Headless state auto-provisioning (#695) keyed the state root on manifest.name
alone (~/.ato/state/run/<name>/<state>), so two different sources sharing a
name SHARE state (isolation hazard), and it bound durability="ephemeral"
state to the stable persistent root, so ephemeral state persisted across runs.

Derive a stable, source-scoped headless_state_instance_id via the existing
canonical_hash helper (JCS + blake3) over (normalized_source_ref, owner scope,
selected target, profile, runner namespace). Per-execution facts (execution
id, ports, session/process/container ids, dynamic env) are deliberately
excluded so the same source reuses its state across runs and revisions; the
materialized-source tree hash is used only as a fallback when the source ref
is unstable. Split the path scheme by durability: persistent state lands under
~/.ato/state/run/<instance-id>/<state>, ephemeral state under a per-run
~/.ato/runs/<token>/state/<state> registered with the run cleanup scope.
Explicit --state bindings still take precedence; kind != filesystem stays
fail-closed.

Fixes #700

* fix(cli): short-circuit headless state no-op path and isolate sample-recipe tests

CI regression on macos+windows: session_start_allows_no_persistent_state_
without_attach_state failed reading a sample-recipe capsule.toml from a tmp
dir that had been torn down mid-test. Two compounding causes:

1. auto_provision_headless_state_overrides derived the source key (and could
   hash_tree the source tree) even when there was nothing to provision. Add a
   pre-scan that collects unbound filesystem states first and returns a pure
   no-op when none remain; only compute headless_state_instance_id when an
   unbound PERSISTENT filesystem state actually needs it. kind != filesystem
   stays fail-closed in the pre-scan. The no-state and ephemeral-only paths now
   touch no source ref / source_tree_hash / ato_state_dir.

2. The two pgweb session tests materialized their recipe under the ambient
   ATO_HOME with no guard, so a parallel #[serial] ATO_HOME test could delete
   the tempdir between the recipe write and the manifest read (wider window on
   macos/windows). Make both pgweb tests hermetic: take env_lock, pin ATO_HOME
   to a private tempdir via TestEnvGuard, and mark them #[serial] — matching the
   other sample-recipe tests.

Add run.rs regression tests proving the no-state, fully-bound, and
ephemeral-only paths perform no source read (unstable ref + non-existent
workspace would error if the key were derived; the no-op returns Ok).
feat(runner): self-update on a server-pushed target version
* feat(runner): attach scrubbed log tail to run-failure reports

On a run failure the Connected Runner now reads the tail of the child's
(already secret-scrubbed) run log and attaches it as error.log_tail on the
existing POST /v1/runner-leases/:id/status callback. ato-api derives a
diagnostic report from it (ato-api#38) so a failed run's logs can be viewed
remotely in the PWA (ato-pwa#52). Best-effort: a missing/empty log just
omits the field; the tail is capped at the API's 16 KiB limit, drops a
partial leading line when truncated, and is scrubbed once more before send.

read_log_tail split into a path-taking core for unit tests. 3 new tests; 54
runner_agent tests green.

* review(#698): precise log-tail truncation doc + no-newline test

Clarify read_log_tail_from: a truncated tail is marked, and the partial leading
line is dropped only when an interior newline exists; a single over-long line
(no newline) is kept verbatim under the marker rather than blanked. Add a test
locking that behavior.

* style(#698): rustfmt the new log_tail tests
)

Add one end-to-end regression test that a NodeCompat capsule dispatched
through the runner's real readiness pump reaches ready, the path that hung
until the 600s runner deadline before #693 added node_compat::spawn_foreground.

The test drives the real run_lease_child with a real `ato run <fixture>
--sandbox -y` child (the exact spawn_run_child shape) against the
installed-relaunch-node fixture (declared port 18880) and asserts
LeaseReport::Ready { port: Some(18880) } - the assertion that previously
timed out. #[ignore]d because it spawns the real ato binary plus a managed
node toolchain (needs node + nacelle), matching the repo convention for
toolchain-dependent E2E tests; run with --ignored.

Fixes #703
…pids (#711)

merge_workload_pids fixed a recorded keep-alive workload pid on first
observation. If the first probe captured start_time_unix_ms=None and a later
probe obtained Some, the value was never filled in, weakening the pid-reuse
guard (process_start_time_matches) used at stop time. Backfill None -> Some on
a re-observed pid; an already-recorded Some is still never overwritten.
feat(#699): headless runner enrollment login (ato runner login --enrollment-token)
#712)

Squash-merged. The one red required check (cargo test `ato_managed_installer_extracts_verified_binary` on ubuntu/windows) is a pre-existing flaky podman-installer test unrelated to this change (macOS full pass; same test flakes on dev). #712 fix verified end-to-end (gitea PASS on Connected Runner).
Replace the single-slot `busy` boolean with an indexed SlotPool so a Connected Runner serves multiple concurrent apps per device. Per-slot proxy ports (base+index, range-validated), honest per-slot public URLs via --public-url-template ({port}/{slot}, placeholder-required) else slot-0-only legacy mapping, heartbeat advertises max_slots/active_slots. Default max_slots=1 preserves single-slot behavior. Partially addresses #632 (runner slice); follow-ups ato-api#59 (capacity dispatch) + ato-pwa#57 (multi-tile UI). Admin override authorized by repo owner: the two red CI jobs are pre-existing #708 debt (podman_install flaky + pkgs.txt CRLF), unrelated; macOS test + check + clippy + lints green.
`verify_binary_runs` execs a freshly-extracted podman binary with
`--version`. On Linux under parallel load that exec races the write and
fails with ETXTBSY ("Text file busy", os error 26) — a transient
condition that clears once the writer handle settles. The single-shot
exec turned that race into a hard `PodmanInstallError::Extract`, which
surfaced as a flaky `cargo test` failure on ubuntu/windows (different
podman_install test failing each run; macOS unaffected) and is also a
real robustness gap for actual installs on loaded Linux hosts.

Wrap the exec in `exec_retrying_busy`: a bounded retry (5 attempts,
20ms·attempt backoff) that retries ONLY the transient busy code
(ETXTBSY 26 on Unix, sharing-violation 32 on Windows) and surfaces every
other error immediately, so genuine exec failures are never masked.

Tests: classification (busy vs ENOENT/other), recovery after N transient
busies, no-retry on a real error, and give-up at MAX_ATTEMPTS. Focused
`cargo test -p ato-cli --lib podman_install` → 49 pass; clippy + fmt clean.

Refs #708 (podman_install flaky portion).
Add Bun as a managed auxiliary runtime tool declarable via `runtime_tools` (parse/fold/lock-pin/cache-invalidation), put the managed Bun shim on the source/node RUN PATH in node-compat (execute/spawn/foreground/background), exact-release pins for v0, plus a networked e2e and a shadow-test ATO_HOME isolation fix. Partially addresses #723 (Managed Cloud bun --version smoke is a tracked follow-up).
…orted_lease_kinds (#731 slice 1) (#733)

* feat(runner): managed-state-root auto-bind + run_capsule lease parse + supported_lease_kinds

First minimal slice of ato#731 (run OCI-image capsules on a Connected Runner).

- ato run --managed-state-root <DIR>: auto-bind unbound persistent
  attach="explicit" state to a stable, path-safe dir
  <DIR>/<blake3(name\nversion\ntarget)>/<sanitized state_key>. Never
  lease-scoped; owner isolation is the caller's job (encoded into <DIR>),
  never derived from capsule input. --state always wins. Reuses the existing
  ensure_registered_state_binding path (no parallel logic).
- runner: parse_run_capsule_command validates a stable capsule identity
  (capsule_id + owner_id required; version/revision/target/run_id optional;
  capsule_slug is display-only, never used for resolution or state).
- runner heartbeat advertises supported_lease_kinds = ["run_source_sandbox"]
  so the control plane can capability-gate dispatch. run_capsule is parsed but
  NOT yet advertised (execution wiring is a follow-up).

Tests: 9 new unit tests (path derivation stability/isolation, sanitize,
managed auto-bind + explicit-wins, run_capsule parse, heartbeat advertising).
Existing state/lease/heartbeat tests unaffected.

* fix(runner): address review — namespace contract, collision-free segments, capsule_id immutability

- managed_state_dir appends ONLY <target>/<state_key>. Per the namespace
  contract, <root> MUST already be scoped by the server-confirmed owner AND a
  stable immutable capsule identity. Dropped name/version hashing: those are
  capsule-controlled and not globally unique, so two distinct capsules sharing
  name/version/target would have collided. Docs + CLI help aligned.
- path_segment is now collision-free + path-safe: <sanitized>-<blake3-16>, so
  inputs that sanitize alike (a/b vs a_b, . vs _) get distinct segments.
- RunCapsuleCommand.capsule_id documented as a revision-resolved IMMUTABLE
  identity (else send revision and treat the pair as the identity).

Tests updated: path_segment collision/path-safety; managed_state_dir appends
only target+state_key + collision-free. All green.
…te lock path (#731 slice 2) (#734)

* feat(runner): execute run_capsule leases + emit OCI readiness + managed-state lock path

#731 follow-up: make run_capsule actually runnable on a Connected Runner.

- resolve_lease_execution dispatches by lease kind: run_source_sandbox (source
  repo, no managed state) vs run_capsule (capsule ref + a runner-managed state
  root <base>/state/<owner>/<immutable-identity>; owner from the server-confirmed
  owner_id, identity = revision | capsule_id). The capsule ref is validated
  against flag/whitespace smuggling. spawn_run_child gains --managed-state-root.
- Advertise run_capsule in SUPPORTED_LEASE_KINDS now that execution is wired, so
  the control-plane capability gate may dispatch it.
- OCI executor now emits the machine-readable `LIFECYCLE: ready port=N` line so
  the runner monitor detects OCI readiness and reports a ready_url. Previously
  only the human "OCI service available" line was emitted, so OCI runs stayed
  provisioning forever on a runner. Runtime-verified.
- Fix --managed-state-root on the lock-route path: the lock branch reused the
  materialized overrides and skipped the managed auto-bind, so a persistent OCI
  capsule failed E999 on a runner. Now augments the lock overrides with managed
  binds. Runtime-verified: dir created at <root>/<target>/<state_key>.
- path_segment is now pub(crate), shared by the runner for the owner/identity
  segments so the managed namespace is consistent end to end.

Tests: 14 unit tests (lease dispatch both kinds, ref validation, state-root
identity fallback, heartbeat advertises run_capsule, path derivation). G3 and
--managed-state-root additionally verified at runtime with a real OCI capsule.

* fix(runner): immutable run_capsule run_ref + hard-fail managed-state lock load (review #734)

- RunCapsuleCommand collapses capsule_id/version/revision/target into a single
  required `run_ref`: the runner executes it AND keys persistent state on it, so
  the executed artifact and the state namespace can never diverge (the prior
  split keyed state on `revision` but executed `capsule_id`, which could point at
  a different, mutable resolution). Contract: the control plane resolves any
  mutable slug to an immutable, point-in-time ref before dispatch. Tests use
  revision-pinned refs; no mutable success example.
- Lock-route `--managed-state-root`: a manifest that cannot be read is now a hard
  error (`with_context(...)?`), not a silent skip that would resurface later as a
  confusing unbound-state failure. `--managed-state-root` is an explicit contract
  for non-interactive runner execution.
Add Ubuntu + NVIDIA GPU host provisioning to the Connected Runner
surface so users can turn a bare Ubuntu server with RTX 3060s into a
GPU-capable Ato runner in one command.

New capsule-core modules:
  - foundation/host_gpu.rs: HostGpuProfile detection (OS, Secure Boot,
    GPU devices, driver, CUDA, Docker, nvidia-container-toolkit).
    Read-only probes — never mutates host state.
  - foundation/provision_receipt.rs: ProvisionReceipt and
    ProvisionMarker types for post-install state recording and
    --resume after reboot.

New ato-cli commands (visible, was hidden):
  - ato runner doctor: read-only GPU host diagnostics with OK/WARN/FAIL
    checks and recommended next steps. JSON output via --json.
  - ato runner provision: installs nvidia-driver-575, Docker Engine,
    and nvidia-container-toolkit on Ubuntu 22.04/24.04. Idempotent
    (--force to reinstall), --resume after reboot, --dry-run for
    preview (no state mutation), --enroll to chain into runner login.
    Writes a provision-receipt.json with full hardware state.
    Exits non-zero on GPU smoke test failure.
  - Secure Boot: detected and warned about (MOK enrollment
    instructions printed), never auto-enrolled.

Safety:
  - Root required for all non-dry-run paths (including --resume).
  - --dry-run never writes receipt or marker.
  - apt-get update runs before every apt install.
  - systemctl and nvidia-ctk exit statuses are checked; bail on failure.
  - ca-certificates, curl, gnupg installed before toolkit repo setup.
  - GPG key temp file uses /tmp, not CWD-relative path.

CLI surface change:
  - ato runner is now visible (hide removed from root command).
    login/serve remain hidden; doctor/provision are user-facing.
feat(runner): GPU host provisioning — doctor, provision
Koh0920 and others added 27 commits July 1, 2026 05:47
3 no-binding apps (tiny-http/darkhttpd, light-python/FastAPI, light-node/express) on
n2-standard-4 / fc v1.16.0 / 512MiB mem, 5 iterations/mode. Median restore ms:

  app          file-cold file-warm uffd-local uffd-hotset uffd-remote
  tiny-http        803      118       180       137        165
  light-python     903      218       433       260        428
  light-node       873      193       443       233        465

Conclusions: (1) UFFD beats File-COLD 3.3-6x (hotset) — its product value is cold
cache / large images. (2) File-WARM (118-218ms) is fastest but needs 512MiB on disk
per capsule; uffd-hotset is competitive (~40ms behind on 1GB apps, 0 bytes
materialized). (3) Hotset is NEEDED: cuts demand faults ~3000->2-4, closes the gap to
warm File. (4) Remote read-through fetched 0 chunks — CAS dedup already puts 52/55 mem
chunks locally; remote buys little unless the image has unique content (keep off until
P4). Feeds U16.

Artifacts under benchmarks/ready-state/uffd-productization/2026-07-01/ (summary.md +
raw/*.json).
snapshot: U9 File-vs-UFFD benchmark harness + real-app results (#876)
Opt-in ATO_READY_STATE_UFFD_DIAGNOSTICS=1: `ato run` computes which mem_backend a
selector WOULD choose (via the pure U14 snapshot::mem_backend_selector) and records it
(mem-backend-diagnostics.json + tracing + reporter notify), then restores via File
EXACTLY as before. Pure observation, no behavior change — the P0 step that connects
the placement contract (#816) without touching the restore path.

Inputs gathered honestly: host_supports_uffd (backend.probe), capsule_no_bindings
(requires_runtime_bindings guard), local_cas_has_memory (memory layer + first chunk
present), validation_mode (ready_state_enabled). Not-yet-wired inputs are the safe
value: hotset=false (until U12), remote=false (until P4).

Off by default → default ato run unchanged. Flag test + diagnostics serialization
test pass; cli builds; no new clippy warnings (fixed an un-awaited notify along the
way — it now actually sends).
…stics

cli: U10 Ready-State mem_backend selection diagnostics (#877)
Opt-in ATO_READY_STATE_UFFD_PREVIEW=1: `ato run` restores a no-binding capsule via
the UFFD local-CAS demand path (instead of eager File rehydrate) on a supported host,
and FAILS CLOSED on an unsupported host. Off by default → File path unchanged.

- snapshot: RestoreReadyStateInput gains `uffd_preview: bool`; FirecrackerBackend
  restore uses Cas (local) mode when set, else the test-only env gate (uffd_mode).
  The product path is now INPUT-driven, not env — the env gate stays for KVM smokes.
- cli: uffd_preview_enabled() flag; run.rs checks eligibility before restore
  (host supports uffd via probe → else bail; memory in local CAS → else bail;
  no-binding already guarded) and threads uffd_preview through restore_and_expose.
- KVM smoke fc_kvm_uffd_preview_input_reaches_health: uffd_preview=true (env unset)
  reaches /health, demand-only, receipt mem_backend="uffd", clean teardown.

Flag test (off by default); snapshot lib 95 pass; clippy clean. Local CAS only;
remote/hotset not engaged (U12/P4).
snapshot+cli: U11 local UFFD preview command (#878)
HotsetProfileStore persists hotset profiles keyed by HotsetKey (capsule_manifest_hash
+ runner_class_id + memory_image_hash + backend_id + page_size + memory_size). load()
returns a profile ONLY for an exact identity match — a different memory image / runner
/ backend / page-size / mem-size is a MISS, so a stale profile is never applied to the
wrong image (the safety invariant; unit-tested). save() is atomic (tmp+rename).

Engine wiring (ATO_FC_UFFD_HOTSET_STORE=<dir>): load-before-serve (keyed store first,
then the explicit ATO_FC_UFFD_HOTSET file for smokes) + save-after-health (persist
this run's pre-health hotset for the next restore). Off unless the store env is set →
no behavior change to the default or U11 preview paths.

Tests: hotset_profile_store_keys_by_identity_and_never_mismatches (unit); KVM smoke
fc_kvm_uffd_hotset_persistence_round_trip (demand run saves → 2nd restore loads +
prefetches → faults cut >2x). snapshot lib green; clippy clean.
…-persistence

snapshot: U12 hotset profile persistence (keyed store) (#879)
Promotes the U5 fail-closed invariant to the path `ato run --experimental-uffd`
actually takes, and hardens the corrupt-profile case:
- KVM smoke fc_kvm_uffd_preview_corrupt_cas_fails_closed: uffd_preview=true (no env
  gate) + corrupt CAS → restore Err, no orphan firecracker/tap (product-path
  fail-closed, mirroring U5).
- Unit hotset_profile_store_ignores_corrupt_file: a corrupt profile file → load()
  None, never a bad prefetch list reaches the page-server.

Existing guarantees U13 relies on: FcProcess::Drop kills the FC child on any restore
Err (no orphan VM); the page-server prefetch skips offsets that don't map
(host_page_for_offset), so a wrong/corrupt profile can never UFFDIO_COPY bad bytes
into guest memory. snapshot lib green; clippy clean.
…e-hardening

snapshot: U13 failure hardening for the product preview path (#880)
ATO_READY_STATE_UFFD_AUTO_PREVIEW=1: the pure U14 selector (decide_mem_backend)
CHOOSES File vs UFFD from the real facts (host UFFD support via probe, no-binding,
local CAS has memory; remote off; hotset auto-loaded by the engine per U12), instead
of the U11 forced-on preview. Graceful File fallback on an unsupported host (never
bails — that's the explicit U11 preview's behavior). Takes precedence over U11's flag.
Records the decision via tracing + reporter notify.

Off by default → File path unchanged. Composes already-validated pieces: the selector
is U14 unit-tested (7 branches); the engaged engine path is U11 KVM-validated
(uffd_preview); the real-app win is U9 (uffd-hotset). Flag test (off by default);
cli builds; no new clippy warnings.
…elect

cli: U15 opt-in auto-selection preview (#882)
…883)

The go/no-go for taking UFFD nightly->dev->main. Synthesizes U0-U6 + U7-U15 (all
hardware-validated): what shipped, the U9 real-app benchmark evidence, the recommended
default policy (default stays File; enable UFFD+hotset for cold/large/no-binding), the
release gate (default-path invariant, frozen receipt schema, one-per-invocation KVM
smokes NOT the flaky combined suite, product-path fail-closed, no-binding-only), known
limitations, and the hard Phase 8 dependency. Verdict: product PREVIEW is GO (opt-in,
no-binding, local CAS, fail-closed); DEFAULT enablement is NO-GO until the placement
contract (P1) + Phase 8 land.
docs(ready-state): U16 UFFD product readiness report + release gate (#883)
PR 1 of the Phase 8a vertical slice — types only, no guest-agent, no `ato run`
behavior change. Cites the merged contract docs/ready-state/binding-lease.md.

New module protocol::binding_lease (serde-only, host + future guest-agent shareable):
- BindingName with validation (safe single path component: [a-z0-9_.-], not ./..).
- BindingLeaseId (opaque, host-generated).
- SecretValue — Debug/Display REDACTED; raw reachable only via expose(); serializes to
  the wire for delivery but never leaks into a log.
- BindingLease { schema_version, id, name, value, issued_at_ms, expires_at_ms,
  revoked_at_ms } with issue()/revoke()/status()/is_active()/receipt(); TTL model on
  caller-supplied unix-millis (deterministic + testable). Debug redacts the value.
- BindingLeaseStatus { Active, Expired, Revoked } (revoked dominates expiry).
- BindingLeaseReceipt — the loggable/recordable form, carries NO secret value.
- BINDING_LEASE_SCHEMA_VERSION = 1.

Tests (5): name validation; SecretValue + whole-lease Debug never leak the secret;
active/expired/revoked transitions + idempotent revoke; receipt has no secret and
round-trips; wire lease round-trips WITH the value. No secret in the snapshot: nothing
here is written to CAS/manifest/rootfs/memory/vmstate. Default run behavior unchanged
(no consumer wires these in yet).
…review)

Addresses the #895 request-changes. Debug/Display redaction alone did not stop an
accidental serde_json::to_string(&lease) from emitting the raw secret. Fix at the type
level:
- SecretValue: no longer derives Serialize/Deserialize (internal host-side only).
- BindingLease: no longer derives Serialize/Deserialize — cannot be JSON-dumped by
  accident. Wire form is the explicit BindingLeaseDelivery; loggable form is the
  value-free BindingLeaseReceipt.
- New WireSecret (transparent Serialize, redacted Debug) + BindingLeaseDelivery: the
  ONLY type whose serialization carries the value, built via BindingLease::to_delivery().

Tests: only the explicit delivery payload serializes the secret (and its Debug still
redacts); a static guard asserts only the value-free/wire types are Serialize
(BindingLease/SecretValue are intentionally not). 6 tests; clippy clean.
protocol: Phase 8a BindingLease core model + receipt (#863)
The coordination artifact for "Store Capsule Ready-State Run E2E": the API
contract every workstream (Snapshot Registry, Build Worker, Run Control, PWA,
Desktop) implements against. No endpoints yet — contract only.

Covers: the 3-server split (Build / Registry / Run Control, no per-app server),
no-binding eligibility rules, Snapshot Registry + Run Control endpoints, job/run
state machines, artifact metadata, minimal DB schema, error codes, the E2E
lifecycle, and flags/gating (nightly + staging/admin only; File default; UFFD
preview-only; binding-required capsules rejected — Phase 8 firewall intact).
PR 2 of Phase 8a — the host<->guest-agent control messages + the pure session state
machine, NO real secret delivery yet. No ato run behavior change.

- protocol::binding_control: HostToAgent { Deliver(BindingLeaseDelivery), Revoke{id},
  QueryBoundReady, Stop } and AgentToHost { Ack, BoundReady{ready,pending}, Scrubbed,
  Error }. Deliver is the ONLY value-bearing message (carries the explicit wire
  payload); every response is value-free. BINDING_CONTROL_SCHEMA_VERSION = 1.
- new crate crates/guest-agent: BindingSession — a pure state machine that consumes the
  control messages and computes the bound-ready gate. Skeleton: it tracks binding
  PRESENCE (name -> id + expiry) for the gate but NEVER stores or writes the secret
  value (the Deliver value is intentionally dropped until PR 3 tmpfs delivery). No
  vsock transport, no /run/ato/bindings I/O yet.

Tests: Deliver is the only value-bearing message + responses are value-free (protocol);
bound-ready only when all required delivered, revoke/expiry/stop-scrub drop readiness,
no-binding session trivially ready (guest-agent). clippy clean.
…leton

guest-agent: Phase 8a control-plane skeleton over vsock (#863)
Real secret delivery inside the guest. On Deliver the value is written to a 0600 file
at /run/ato/bindings/<name>; on Revoke/Stop/expiry the file is scrubbed. The value is
ONLY ever on the sink (tmpfs) — never retained in the agent's memory (the session drops
its in-memory copy after handing it to the sink). No ato run behavior change.

- BindingSink trait: deliver(name, value) / scrub(name).
- TmpfsBindingSink: atomic 0600 write (tmp created 0600 then rename — never briefly
  world-readable), scrub = best-effort wipe + unlink, idempotent. Binding names are
  validated single path components (no traversal). Unix-only (guest is Linux); non-unix
  fails closed.
- BindingSession now delivers through the sink; a sink error FAILS CLOSED (Error, not
  Ack; the binding is not marked present); QueryBoundReady scrubs expired bindings so
  tmpfs never outlives the TTL; Stop scrubs all.

Tests (6): 0600 file created with the value + scrub removes + idempotent; atomic
replace on renew; value reaches the sink; expiry/revoke/stop scrub the sink + drop
bound-ready; sink failure fails closed. clippy clean.
guest-agent: PR 3 tmpfs binding-file delivery (#863)
… handoff, capacity states (#904)

Address the 5 control-plane invariants before the contract is frozen for
parallel implementation:

1. Idempotency (§2.6): Idempotency-Key / client_request_id on POST snapshot-jobs
   and POST runs; same key+scope returns the existing job/run (no duplicate
   builder/runner). DB idempotency_key columns.
2. Auth/ownership (§2.5): enqueue is admin/staging; runs are user-scoped; only
   the run owner or admin may GET/STOP (RUN_FORBIDDEN); public capsule != public
   run control.
3. Artifact integrity (§5.1): runners MUST verify artifact_manifest_hash / CAS
   chunk hashes / snapshot_backend / runner_class_id / execution_id before
   restore; artifact_location is not trusted; mismatch fails closed.
4. Desktop handoff (§3.1): provider=desktop returns handoff_url +
   desktop_session_id or unsupported_reason (not app_url); Desktop reuses the
   Registry metadata, not a re-implementation.
5. Run states (§4): add queued/dispatching before restoring, composing with
   managed Cloud Slots / runner capacity.
…ntract

docs(api): snapshot run-control contract (PR 1, #896)
Record that snapshot-run EXTENDS the existing ato-api runs table / /v1/runs /
runner-lease / Cloud Slots (managed-cloud), rather than forking a parallel run
system. Only capsule_snapshot_jobs + capsule_snapshots are new (Ready-State
sealed artifacts, distinct from source_snapshots). Additive columns on runs;
additive read-model fields on the Store capsules listing.
@Koh0920

Koh0920 commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: the reconciliation was implemented (Tracks A–D) per merged contract #904; this doc no longer reflects open work.

@Koh0920 Koh0920 closed this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant