Skip to content
Closed
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -713,10 +713,40 @@ is reattached under its recorded name after the same label and identity checks;
newly declared volume is created fresh; a retained volume the new file no longer
declares stays owned and unmounted until `hack down --remove-data` (Compose also
keeps project volumes). Retained data is only ever reattached to the run and
namespace that created it. Graphs with dependency caches, live source or a pending
source-device rebind keep the narrower rules below. `hack restart` of a stopped
graph refuses and points to `hack up`.
For a shared-source graph admitted with a retained compatibility contract, ordinary
namespace that created it.

Graphs with `hack.dependencies.*` caches reconcile the same way. A cache's identity
covers only its own inputs: the initializer's service definition and resolved
command, entrypoint, environment and user, the cache declaration, its image, the
declared lockfile and runtime-file contents, and the cache volume layout: every
service that mounts the cache volume, with its target, subpath and `:ro` mode. Any
of those services can write the shared contents, so adding, removing or changing
such a mount is a cache input; mounts stay as declared, as in Compose and v4, and
are not forced read-only. Caches are shared across linked worktrees and branch
instances only while every service other than the initializers mounts them
read-only. A service that mounts the cache read-write can change its contents
through its own image, command, environment or source, which the identity does not
cover, so that cache is scoped to this checkout and branch instance and never
shared. To share it again, mount it with `:ro` in the other services. A run created
by an earlier build whose cache has such a writer moves to its own instance cache
once instead of reusing the shared one. Any other edit to another service, such as an
environment value on the app, keeps the cache. An edit to one of those inputs,
including a lockfile, selects the cache for the new identity as a fresh `up` would, so the initializer installs into it; the old cache
is shared and is only released by this run. Each such `up` publishes the current
source snapshot. A retained named volume is never reused as a dependency cache:
declaring one refuses and names the service and the `hack.dependencies.cache-volume`
field. Caches recorded by earlier builds, whose identity also covered the whole
Compose file, are kept while that file is unchanged and replaced once on the first
edit. Live source, a cache scope carried across a source-device rebind or a pending
initializer cache release keep the narrower rules below: a Compose edit other than
a routed hostname change refuses there with `graph_plan_change_unsupported`, and a
hostname change mixed with other edits refuses with `graph_hostname_change` and the
service name. Every refusal says to revert the edit, or to run
`hack down --remove-data` and then `hack up`. `hack restart` of a stopped graph
refuses and points to `hack up`.
For a shared-source graph that reconciliation does not select (live source, a
carried cache scope, a pending source-device rebind or initializer cache release)
and that was admitted with a retained compatibility contract, ordinary
source-content edits may change the reviewed plan ID: restart checks the stable
execution, exclusion, mount and dependency-cache inputs before cleanup and again
under the provider lease. The original installer publication and graph ownership
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/v5-model-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ must be attached to the eventual exact integration revision.
| Foreground Compose startup failing after resources started; unconfirmed cleanup; unauthenticated final inspection | native-startup-owner | `recordFailedStartupOwner` in [native-project-start](../../src/backends/native-project-start.ts) publishes the authenticated failed run with a finalization lifetime; [start regressions](../../tests/native-project-start.test.ts) cover recording, an unauthenticated receipt and a retained mapping | A refused final inspection still leaves only the runtime journal; crash between finalization registration and mapping publication; physical guest acceptance. |
| Canceled/expired startup; reader cancellation versus EOF; producer/group uncertainty | pre-ready-retirement, authority-lifetime, native-file-material | Native graph finalizer/onSettled, exact Removed, hard-signal freshness, host stop/close; local pre-Ready retained/removed regressions, including separate live/foreign/hard-canceled fresh-teardown-admission assertions (HACK-1801) | Physical guest acceptance; current root aggregate; explicit executable unknown host-close control; async proof-to-stop and partial binding-retirement crash windows; post-stop source/session replacement or hard cancellation before Retire (Retire does not recheck these inputs; Safe has no post-stop interference predicate). |
| Crash between reserve, publish, sync and release; stale recovery | native-frontend-recovery, publication, publication-journal, native-storage-witness | Durable intent/enrolled/released records, fresh lease and expected binding; recovery and sync-failure tests | Fsync guarantees, process-kill windows, real restarted provider observations. No missing-path authority. |
| Data lifetime independent of compute; replaced/aliased metadata; retained-data handoff across Compose plan changes | native-persistent-enrollment, native-storage-witness, native-file-material, native-plan-reconcile | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests; `provider/graph/plan_reconcile.rs` projection tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. |
| Data lifetime independent of compute; replaced/aliased metadata; retained-data handoff across Compose plan changes, including dependency caches | native-persistent-enrollment, native-storage-witness, native-file-material, native-plan-reconcile, native-cache-reconcile | Existing-only enrollment, exact birth/witness and reference fencing; copied-label/replacement tests; `provider/graph/plan_reconcile.rs` projection and cache-selection tests | Real SQL/content retention, external writer assumptions, allocated-byte evidence. |
| Settled retained Compose route-attempt retirement; pinned references; interrupted deletion | **Coverage gap:** filesystem journal compaction is not modeled; native-route-stop covers a different stop protocol | Instance mutation lock and exact surviving claim witnesses in [journal compaction](../../src/lib/native-compose-route-claims.ts); [claim regressions](../../tests/native-compose-route-claims.test.ts) and [retained lifecycle integration](../../tests/native-compose-adoption-routing-generation.test.ts) | 24 successful operations keep two attempt directories and constant Linux descriptors; four integrated lifecycle operations check directory counts. Injected unlink failures cover same-store and fresh-store cleanup retries. Assumes cooperative same-user writers, the caller's mutation lock and working filesystem synchronization. No process-kill/power-loss durability proof or bound on unresolved/over-budget histories. |
| Graph cleanup container stop batch: bounded in-flight stops, shared worker queue, connect-stage retry, batch-wide unreachable state, deadline expiry, worker panic | **Coverage gap:** no maintained model covers scheduling inside the stop batch; native-route-stop covers a different stop protocol | `dispatch` and `attempt` in [the stop batch](../../packages/runtime-core/src/provider/engine/stop.rs): at most 4 stops in flight, every worker joined; only `connect`/`connect_timeout` failures retry (25 ms doubling to 500 ms, within the batch deadline and 30 s of a stop's first connect failure); once one stop exhausts its window, later connect failures return at once; a deadline reached during backoff keeps the connect stage; `response`, `timeout` and `transport` never retry. Regressions in the same file's `provider::engine::stop` tests (bounded in-flight socket server, late socket, missing socket, 14-stop retry counts, persistent connect failures, oversleeping backoff, non-retried stages, worker panic) and `fourteen_service_connect_failures_keep_resumable_progress_and_retry_only_unstopped` | Tests cover exact attempt counts and the in-flight bound for fixed interleavings; no model explores all worker schedules or proves at most one delivered request per stop. Assumes a failed connect sent no request bytes and that the stop budget covers fully serialized grace periods. Graph-level resume after a partial batch is covered by tests only. Host qualification of the descriptor-pressure root cause (HACK-1302) remains open. |
| Concurrent stop/publication; route claim and relay lifetime | native-route-stop, shared-https-lifetime, previous-boot-shared-https, relay-lifecycle-barrier, relay-authorization, relay-staging, relay-fence | Fenced route removal, relay owner, ready/removed protocol, authenticated observations | Native DNS/TLS/browser behavior; old-boot uncertainty and external routing. |
Expand All @@ -67,6 +67,7 @@ liveness/reachability controls have their own documented assumptions.
| Model | Positive distinct states | Configurations |
| --- | ---: | ---: |
| [native-plan-reconcile](../../tests/models/tla/native-plan-reconcile/Reconcile.tla) | 747,656 | 4 |
| [native-cache-reconcile](../../tests/models/tla/native-cache-reconcile/CacheReconcile.tla) | 2,208 | 5 |
| [pre-ready-retirement](../../tests/models/tla/pre-ready-retirement/Retirement.tla) | 10,965 | 8 |
| [native-persistent-enrollment](../../tests/models/tla/native-persistent-enrollment/Enrollment.tla) | 38 | 6 |
| [native-frontend-recovery](../../tests/models/tla/native-frontend-recovery/Recovery.tla) | 602 | 10 |
Expand Down
24 changes: 20 additions & 4 deletions packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1451,10 +1451,26 @@ admission would, retained volumes are projected by logical key onto their record
names, newly declared volumes receive unused `reserved` names in this run and are
created exclusively, and retained volumes the plan no longer declares stay in the
receipt. Run, owner, namespace and the ownership plan on every label are unchanged.
`graph source-compatibility` reports `"reconcile": true` for this path. Receipts
with dependency caches, live source or a pending source-device rebind keep the
hostname/shared-source proofs. A volume still `reserved` in a stopped receipt was
never created; selection requires it absent and restore creates it.
`graph source-compatibility` reports `"reconcile": true` for this path. Dependency
caches reconcile too: a cache whose freshly resolved identity (the initializer's own
definition and inputs plus every service mount of the cache volume, not the whole
Compose digest) equals the recorded binding is
kept with its provenance; a changed identity is selected like a fresh admission
(adopted by labels or created) with a freshly published source revision, and the old
shared cache is only released. A cache that a non-initializer service mounts
read-write (`dependency_cache::instance_scoped_volumes`) binds the plan namespace
into its scope, so it is never shared across linked worktrees or branch instances;
such graphs always re-prove the cache through reconciliation, and no earlier
whole-document identity is matched for them. A retained run-owned volume newly declared as a cache
refuses with `graph_plan_reconcile` and a `plan_change` diagnostic naming the service
and field. A receipt recorded under the earlier whole-document identity keeps that
cache while the review still resolves to it (`config::retain_recorded_legacy_caches`).
Receipts with live source, a carried cache scope, pending initializer cache release
or a pending source-device rebind keep the hostname/shared-source proofs; a
non-hostname edit there refuses with `graph_plan_change_unsupported`. A volume still
`reserved` in a stopped receipt was never created; selection requires it absent and
restore creates it, except a reserved dependency cache, which may already exist as a
shared cache.

Selection binds the receipt, current VM boot and verified current volume
observations. Restoration rechecks these before effects, requires the old owner
Expand Down
34 changes: 34 additions & 0 deletions packages/runtime-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,27 @@ pub struct StartupLogsDiagnostic {
pub services: Vec<StartupLogTail>,
}

/// Closed vocabulary for the Compose field of a retained-run edit that cannot be
/// applied in place.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PlanChangeField {
/// A retained run-owned named volume is now declared as a dependency cache.
DependencyCacheVolume,
/// Routed hostnames changed together with something only reconciliation applies.
Hostnames,
}

/// Value-free location of a refused retained-run edit. The service is an
/// admitted Compose identifier, never a value, path or engine ID.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PlanChangeDiagnostic {
pub version: u8,
pub service: String,
pub field: PlanChangeField,
}

#[derive(Debug, Serialize)]
pub struct CandidateError {
pub code: &'static str,
Expand All @@ -66,6 +87,8 @@ pub struct CandidateError {
/// [`StartupLogTail`]. Never persist an error carrying it.
#[serde(skip_serializing_if = "Option::is_none")]
pub startup_logs: Option<Box<StartupLogsDiagnostic>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_change: Option<Box<PlanChangeDiagnostic>>,
}

impl CandidateError {
Expand All @@ -76,9 +99,20 @@ impl CandidateError {
cause_code: None,
stop_failures: None,
startup_logs: None,
plan_change: None,
}
}

/// Name the service and field of a refused retained-run edit.
pub fn with_plan_change(mut self, service: &str, field: PlanChangeField) -> Self {
self.plan_change = Some(Box::new(PlanChangeDiagnostic {
version: 1,
service: service.to_owned(),
field,
}));
self
}

pub fn with_cause_code(mut self, code: String) -> Self {
self.cause_code = Some(code);
self
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime-core/src/provider/graph/cache_provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ impl CacheProvenance {
&& bounded(&c.finished_at)
})
}
/// Keep only initializers the reconciled graph still declares. The volume
/// identity fence is unchanged; removed services' history is dropped so the
/// receipt never names a container it no longer records.
pub(super) fn retain_initializers(&mut self, mut present: impl FnMut(&str) -> bool) {
self.initializers.retain(|service| present(service));
let initializers = &self.initializers;
self.completed
.retain(|service, _| initializers.contains(service));
}
pub(super) fn needs_completion(&self, service: &str) -> bool {
self.origin == Origin::CreatedHere
&& self.initializers.contains(service)
Expand Down
55 changes: 52 additions & 3 deletions packages/runtime-core/src/provider/graph/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,53 @@ pub(super) fn container_base(image: &str, labels: Value) -> Value {
pub(super) struct Prepared {
pub graph: Graph,
pub cache_initializers: BTreeMap<String, String>,
/// Earlier whole-document identities of this review's cache volumes, keyed by
/// logical volume. Only [`retain_recorded_legacy_caches`] may select one.
pub legacy_caches: BTreeMap<String, dependency_cache::CacheBinding>,
pub configs: BTreeMap<String, Value>,
pub probes: BTreeMap<String, super::super::http_probe::HttpProbe>,
pub resources: BTreeMap<String, Resource>,
pub namespace: String,
pub plan_id: String,
}
/// A receipt recorded before cache identities bound only the initializer's own
/// definition keeps its exact cache volume while the review still resolves to
/// that recorded identity under the earlier formula. Nothing else is projected:
/// a changed input yields a different legacy identity and the current one stays.
pub(super) fn retain_recorded_legacy_caches(prepared: &mut Prepared, receipt: &Receipt) {
let mut renamed = BTreeMap::new();
for (volume, legacy) in &prepared.legacy_caches {
let key = format!("volume:{volume}");
let Some(resource) = prepared.resources.get_mut(&key) else {
continue;
};
if resource.cache.as_ref() == Some(legacy)
|| receipt
.resources
.get(&key)
.and_then(|old| old.cache.as_ref())
!= Some(legacy)
{
continue;
}
renamed.insert(resource.name.clone(), legacy.name());
resource.name = legacy.name();
resource.cache = Some(legacy.clone());
}
if renamed.is_empty() {
return;
}
for config in prepared.configs.values_mut() {
if let Some(mounts) = config["HostConfig"]["Mounts"].as_array_mut() {
for mount in mounts.iter_mut().filter(|m| m["Type"] == "volume") {
if let Some(name) = mount["Source"].as_str().and_then(|s| renamed.get(s)) {
mount["Source"] = json!(name);
}
}
}
}
}

/// After source replay admission, preserve the original graph ownership identity
/// on restored requests. Never change resource names, images, routes or cache keys.
pub(super) fn retain_replay_ownership(
Expand All @@ -44,6 +85,7 @@ fn retain_ownership(
receipt: &Receipt,
change: Option<&hostname_change::Verified>,
) -> Result<(), CandidateError> {
retain_recorded_legacy_caches(prepared, receipt);
let same = change.map_or_else(
|| same_resource_bindings(&prepared.resources, &receipt.resources),
|change| change.matches_resources(&prepared.resources, receipt),
Expand Down Expand Up @@ -229,7 +271,10 @@ pub(super) fn prepare_delivery(
"At most 32 services fit this driver profile; aggregate resource limits also apply.",
));
}
let caches = if plan
let dependency_cache::Resolved {
current: caches,
legacy: legacy_caches,
} = if plan
.services
.values()
.any(|service| service.active && service.dependency_cache.is_some())
Expand All @@ -244,14 +289,17 @@ pub(super) fn prepare_delivery(
|| dependency_cache::scope(&plan.source),
|scope| scope.scope(&plan.source),
)?;
dependency_cache::resolve(
dependency_cache::resolve_with_legacy(
plan,
source.current_manifest.as_ref().unwrap_or(&source.manifest),
&scope,
&inputs.services,
)?
} else {
BTreeMap::new()
dependency_cache::Resolved {
current: BTreeMap::new(),
legacy: BTreeMap::new(),
}
};
let publications = loopback_publications(plan, loopback_ports)?;
let mut networks = BTreeSet::new();
Expand Down Expand Up @@ -651,6 +699,7 @@ pub(super) fn prepare_delivery(
.collect();
Ok(Prepared {
cache_initializers,
legacy_caches,
graph,
configs,
probes,
Expand Down
Loading
Loading