diff --git a/README.md b/README.md index a46884ed..ce0cc277 100644 --- a/README.md +++ b/README.md @@ -499,13 +499,22 @@ Use the typed inventory instead of parsing `doctor` prose: st2 tasks --catalog "$CATALOG" --host --json ``` -The `st2.task-inventory.v1` envelope joins the selected host's desired PTY and exec tasks to +The `st2.task-inventory.v2` envelope joins the selected host's desired PTY and exec tasks to read-only runtime evidence. A complete observation exits zero. Catalog parse errors, declaration drift during observation, duplicate runtime IDs, timeouts, malformed output, PID reuse, and otherwise unprovable generations emit `complete: false` and exit non-zero. Missing runtime rows become `absent` only when the corresponding backend observation is complete; uncertainty remains `indeterminate`. +Every runtime has a tagged `resourceTarget`. A live Linux process reports +`{"type":"linuxCgroupV2","path":"/..."}` from its exact unified +`/proc//cgroup` membership; a live Darwin process reports +`{"type":"darwinProcessTree","rootPid":...}` as a best-effort tree root. All +other cases report `{"type":"unavailable","reason":"..."}` from a bounded +reason set. These are per-observation locators, not identity: consumers keep +using `runtimeId` as the stable task key and rediscover the target on each +sample. + A PTY root positively absent at admission is not passed to `pty` and remains absent; an absent exec state root likewise remains absent. If an admitted PTY root is concurrently removed, the result is incomplete because its filesystem identity changed, but the external `pty list` implementation may diff --git a/docs/vrs/.decisions/0017-task-resource-targets-are-strict-observations.md b/docs/vrs/.decisions/0017-task-resource-targets-are-strict-observations.md new file mode 100644 index 00000000..18d8baa6 --- /dev/null +++ b/docs/vrs/.decisions/0017-task-resource-targets-are-strict-observations.md @@ -0,0 +1,97 @@ +# Task resource targets are strict observations + +Status: accepted + +Accepted on 2026-09-05 for R23 fail-closed task inventory. + +## Context + +A downstream sampler needs to attribute host CPU and memory observations to a +running st2 task every 15 seconds. st2 already has the authoritative read-only +join between desired tasks and observed PTY or exec generations, but its +version-1 inventory exposes only process metadata. Linux sampling is truthful +at the task boundary only when it uses the live process's unified cgroup-v2 +membership. Systemd unit names, scope names, and task naming conventions are +not observations of that membership. Darwin has no equivalent cgroup locator; +a process-tree walk rooted at the observed PID is necessarily best effort. + +The locator can disappear or change while it is read. PID alone cannot reject +reuse, and retaining scope membership in st2 would add a second state plane +whose lifecycle could diverge from the backend and kernel. + +## Options + +| Option | Tradeoffs | +| --- | --- | +| Add a required strict tagged target to task-inventory v2 — selected | Makes every row self-describing, represents degradation explicitly, and lets the sampler remain downstream. | +| Derive Linux targets from systemd unit names | Rejected because direct/degraded launches and cgroup migration make the name an untruthful proxy for current membership. | +| Add optional or nullable locator fields | Rejected because absence cannot distinguish non-running, indeterminate, unsupported, raced, and degraded observations. | +| Persist a runtime-ID-to-scope registry in st2 | Rejected because it creates stale ownership and reconciliation obligations without improving kernel evidence. | +| Sample and export resource metrics from st2 | Rejected because cadence, retention, and metric transport belong to the downstream observer, not the task control plane. | + +## Evidence and Argument + +The focused experiment separated strict cgroup parsing from the +process-generation fence. One exact unified `0::` entry preserved its +slash-prefixed path, including spaces and colons, while absent, duplicate, +relative, repeated-separator, trailing-separator, and traversal-capable shapes +were rejected. Stable start-token observations admitted the candidate target. +A deterministic PTY regression replaced the PID generation between the +registry snapshot and start-token capture; the socket-backed stats row's +changed creation generation rejected that token. A two-live-task fixture +proved one unscoped stats snapshot serves the whole inventory. A token mismatch +before target read, a token change after it, and process exit each emitted only +a bounded unavailable result and never the candidate locator. + +A live Darwin exec observation joined the current PID as +`darwinProcessTree.rootPid` without rewriting its retained generation record, +and a real PTY replacement changed generation identity while retaining the +same resource-target wire contract. The complete task-inventory unit and CLI +integration surfaces preserved absence, indeterminate, park, timeout, +declaration-drift, and read-only behavior. + +This evidence favors a required tagged union over nullable fields: each sample +states both whether a locator exists and why it does not. Reading kernel +membership is also strictly stronger than reconstructing it from unit naming, +while keeping sampling downstream avoids adding lifecycle and retention state +to st2. + +## Decision + +`st2 tasks --json` advances to `st2.task-inventory.v2`. Every runtime object has +one `resourceTarget` internally tagged by `type`: + +- `{"type":"linuxCgroupV2","path":"/"}`; +- `{"type":"darwinProcessTree","rootPid":}`; or +- `{"type":"unavailable","reason":}`. + +Unavailable reasons are exactly `notRunning`, `runtimeIndeterminate`, +`processUnavailable`, `generationChanged`, `cgroupV2Unavailable`, and +`unsupportedPlatform`. + +On Linux, st2 reads exactly one unified `0::` entry from the observed +process's `/proc//cgroup`. Exec compares the target fence to the token in +its generation record. PTY captures each candidate daemon token after the +registry snapshot, then takes one unscoped, socket-backed `pty stats --json` +snapshot for the entire inventory. A candidate is admitted only when its named +live stats row reports the same daemon PID and creation generation and a second +token read is unchanged; that admitted token then fences the target read. A +missing, exited, or recycled process never publishes the candidate locator. +Darwin fences the current process in the same way before exposing its PID as a +best-effort tree root. All targets are rediscovered on each inventory command. + +Runtime ID remains stable task identity. PID, creation time, generation ID, +cgroup path, root PID, unit, and incarnation remain observation locators only. + +## Consequences + +- A downstream 15-second sampler can consume one versioned inventory without + parsing scope names or asking st2 to sample metrics. +- Resource degradation is bounded and machine-readable while the inventory's + existing fail-closed catalog/runtime semantics remain intact. +- The Linux path is slash-prefixed and relative to the cgroup-v2 mount; `/` + denotes the mount root. +- st2 adds no persistent locator registry, metrics server, sampling cadence, or + resource history. +- Version-1 strict consumers must explicitly adopt schema version 2. +- Evidence is recorded in the [task resource target experiment](../.experiments/2026-09-05-task-resource-target-observation.md). diff --git a/docs/vrs/.experiments/2026-09-05-task-resource-target-observation.md b/docs/vrs/.experiments/2026-09-05-task-resource-target-observation.md new file mode 100644 index 00000000..a50783b5 --- /dev/null +++ b/docs/vrs/.experiments/2026-09-05-task-resource-target-observation.md @@ -0,0 +1,84 @@ +# Task resource target observation + +Date: 2026-09-05 +Fixture: deterministic process-generation seams plus a live Darwin process + +## Question + +Can task inventory expose a useful Linux cgroup-v2 or Darwin process-tree +locator without treating PID, systemd scope naming, or retained st2 state as +stable task identity, and without associating a locator after process exit or +PID-generation replacement? + +## Method + +The inventory resource-target boundary was split into two narrow operations: +strict parsing of the Linux `/proc//cgroup` payload and a process-start +fence around target acquisition. Deterministic unit fixtures supplied stable, +changed, and disappearing start tokens around a candidate target. A PTY fixture +then reused the same numeric daemon PID with a different creation generation +between the registry snapshot and token capture; the live socket-backed stats +row tested that exact pre-fence window. A second fixture gave one inventory two +live candidates, recorded the single unscoped stats invocation, and changed +target proof from unavailable to available without changing either PTY +generation ID. Parser +fixtures covered a unified-only record, a hybrid record, root membership, +spaces and colons, duplicate unified entries, missing v2 entries, malformed +fields, carriage returns, repeated/trailing separators, and traversal +components. + +A live-process fixture read the current process start token through st2's +platform process observer and asked the production target observer to associate +that exact generation. On Darwin it asserted the strict +`darwinProcessTree/rootPid` wire shape. CLI integration reused a live legacy +exec record to prove the target is joined to the correct runtime row without +rewriting exec state. Existing complete, missing-root, malformed, timeout, +park, declaration-drift, and generation-replacement inventory cases remained +on the same command surface. + +Focused commands: + +```sh +nix develop -c cargo test --lib task_inventory::tests +nix develop -c cargo test --lib run::tests::pty_stats_rejects_pid_reuse_between_registry_snapshot_and_start_token_capture +nix develop -c cargo test --lib run::tests::pty_resource_observation_uses_one_stats_snapshot_and_preserves_generation_id +nix develop -c cargo test --test task_inventory_cli +``` + +## Result + +| Probe | Observation | +| --- | --- | +| Unified cgroup parser | Preserved one exact slash-prefixed `0::` path, including spaces and colons; rejected ambiguous, absent, relative, or traversal-capable shapes. | +| Stable start token | Published the candidate resource target. | +| Changed start token | Published only `unavailable/generationChanged`; the candidate locator was discarded. | +| PTY reuse before token capture | The socket-backed stats row observed the changed creation generation and returned `unavailable/generationChanged`; the replacement token was never admitted. | +| PTY stats fan-out and identity | Two live candidates used one `pty stats --json` invocation with no session argument; transient target-proof failure then success preserved each PTY generation ID. | +| Process disappeared during read | Published only `unavailable/processUnavailable`; the candidate locator was discarded. | +| Stable target read failed | Published the bounded degraded reason `unavailable/cgroupV2Unavailable`. | +| Live Darwin process | Published `{"type":"darwinProcessTree","rootPid":}` for the proven current generation. | +| Wire shape | Schema v2 requires one of three tagged variants; non-running and indeterminate runtime states also carry explicit unavailable reasons. | +| Existing inventory behavior | The same fail-closed envelope and non-zero behavior remains for unprovable catalog or runtime evidence; resource-target degradation alone remains a complete bounded observation. | + +## Conclusion + +A resource target can remain a pure observation. PTY captures the daemon token, +then uses one socket-backed stats snapshot to prove every candidate's live +daemon PID and creation generation before rereading and admitting the token; +the token subsequently fences the target read. This prevents publication +across the measured pre-fence reuse, exit, and generation-change races. Strict +cgroup parsing supplies the exact kernel locator without scope-name +parsing. Darwin exposes only the weaker process-tree root the platform can +support. Runtime ID remains the task identity and no persistent registry or +sampling loop is required. This supports [R23 fail-closed task inventory](../requirements.md) +and [decision 0017](../.decisions/0017-task-resource-targets-are-strict-observations.md). + +## VRS Impact + +- `requirements.md` requires a fenced, explicit resource target while keeping + inventory fail-closed and read-only. +- `ontology.md` distinguishes runtime resource targets and observation locators + from task identity. +- `spec.md` fixes schema version 2, exact tagged shapes, parser rules, bounded + unavailable reasons, and platform behavior. +- Decision 0017 records the strict-shape and no-registry/no-sampler boundary. diff --git a/docs/vrs/ontology.md b/docs/vrs/ontology.md index 6080f0b7..fa2e88e9 100644 --- a/docs/vrs/ontology.md +++ b/docs/vrs/ontology.md @@ -38,6 +38,35 @@ A terminal-backed or terminal-free unit declared for an agent. Authority: [Agent Spec task contract](https://github.com/compoundingtech/evals/blob/main/AGENT-SPEC.md#compact-and-explicit-tasks); [`Task`](../../crates/agent-spec/src/spec.rs#L121-L148) +### runtime resource target + +A short-lived locator from one task inventory observation that tells an +external sampler where the observed task's process resources can be read. +Linux uses the exact unified cgroup-v2 path from the live process's +`/proc//cgroup`; Darwin uses a best-effort process-tree root PID. An +unavailable target carries one bounded reason instead of a guessed or nullable +locator. + +This target is not task identity, ownership, a declaration, or a retained +registry entry. Runtime ID identifies the task; PID, process generation, +cgroup path, and process-tree root locate only the observation that produced +them. + +Authority: [R23 fail-closed task inventory](requirements.md); +[`task_inventory`](../../src/task_inventory.rs) + +### observation locator + +Ephemeral evidence used to find one observed runtime generation, never to +identify the task across generations. PID, creation time, generation ID, +runtime resource target, cgroup path, unit name, and incarnation can all be +locators in their owning contexts; only a contract that explicitly exposes one +may be used by a consumer. In the task inventory, systemd unit and scope names +are deliberately not exposed as resource locators. + +Authority: [R23 fail-closed task inventory](requirements.md); +[decision 0017](.decisions/0017-task-resource-targets-are-strict-observations.md) + ### launch argv The ordered, opaque OS-string sequence comprising a task program and each of diff --git a/docs/vrs/requirements.md b/docs/vrs/requirements.md index 402384e1..5f388036 100644 --- a/docs/vrs/requirements.md +++ b/docs/vrs/requirements.md @@ -283,29 +283,47 @@ accepted. - **R23 Fail-closed task inventory:** One read-only machine command exposes every desired local PTY and exec task by immutable agent ID, nullable current bus address, task name, runtime id, kind, lifecycle, retirement, desired - state, runtime state, PID, creation time, and opaque runtime-generation id. + state, runtime state, PID, creation time, opaque runtime-generation id, and a + required resource-observation target. Runtime ID remains task identity. PID, + creation time, generation ID, cgroup path, and process-tree root are + observation locators only and may change between samples. + The resource target is a closed tagged union. A proved live Linux generation + exposes its exact unified cgroup-v2 path read from that process's + `/proc//cgroup`; unit and scope names are not locators. A proved live + Darwin generation exposes its PID as a best-effort process-tree root. For + PTY, st2 captures the candidate daemon's kernel start token, takes one + socket-backed `pty stats --json` snapshot for all candidates, and admits the + token only when the named live session reports the same daemon PID and + creation generation and a second token read is unchanged. That token fences + target observation, as the recorded token does for exec, but its transient + availability never changes the PTY generation ID. A changed, recycled, or + exited process is never associated with the target. + Non-running, indeterminate, degraded, and unsupported observations expose a + bounded explicit unavailable reason rather than a nullable or guessed locator. + Resource-target unavailability is itself a complete observation and does not + turn a trustworthy task inventory into an incomplete one. A proved non-routable retired subject has a null bus address without making coverage incomplete. Unknown, duplicate, malformed, unreadable, timed-out, - PID-reused, or otherwise unprovable evidence is indeterminate and makes the - versioned envelope incomplete and the command unsuccessful; it is never - reported as absence. The command projects a - supervisor's known park fault and per-task recovery action alongside the - unmodified runtime observation. The action is structured executable argv - carrying the exact canonical catalog folder and selected host, so invoking it - never falls back to ambient catalog or host defaults. A believable park - remains complete, while an unbelievable park marker fails closed like other - unprovable evidence. + PID-reused, or otherwise unprovable runtime evidence is indeterminate and + makes the versioned envelope incomplete and the command unsuccessful; it is + never reported as absence. The command projects a supervisor's known park + fault and per-task recovery action alongside the unmodified runtime + observation. The action is structured executable argv carrying the exact + canonical catalog folder and selected host, so invoking it never falls back + to ambient catalog or host defaults. A believable park remains complete, + while an unbelievable park marker fails closed like other unprovable + evidence. Observation detects semantic declaration drift across its runtime probe, does not invoke a backend for a root positively absent at admission, and - performs no reconciliation, cleanup, lifecycle change, or state rewrite. An - admitted PTY root that changes - filesystem identity during the backend probe makes the observation - incomplete; the external backend may already have recreated a concurrently - removed registry. This diagnostic boundary is not transactionally serialized - with catalog or runtime writers and is not control-plane cutover authority. - It samples the durable catalog generation and incomplete marker around - discovery and runtime observation; any marker, malformed fence, or generation - change makes the envelope incomplete. + performs no reconciliation, cleanup, lifecycle change, state rewrite, + resource sampling, or persistent locator registration. An admitted PTY root + that changes filesystem identity during the backend probe makes the + observation incomplete; the external backend may already have recreated a + concurrently removed registry. This diagnostic boundary is not + transactionally serialized with catalog or runtime writers and is not + control-plane cutover authority. It samples the durable catalog generation + and incomplete marker around discovery and runtime observation; any marker, + malformed fence, or catalog-generation change makes the envelope incomplete. - **R24 Immutable agent ID and mutable agent address:** Each logical agent subject has one explicit, catalog-global, immutable agent ID. IDs are unique across the live catalog and structural archive, across hosts and desired diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index b08c6c90..a690cee6 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -827,14 +827,47 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler resume or replacement authority. - **R23:** `st2 tasks --json` is a read-only diagnostic boundary. It emits one - `st2.task-inventory.v1` envelope for the selected host. Rows are sorted by + `st2.task-inventory.v2` envelope for the selected host. Rows are sorted by immutable agent ID, task name, and runtime ID and cover both PTY and terminal-free exec tasks. Each row includes immutable agent ID and nullable current bus address; a proved non-routable retired subject has a null address without weakening completeness. `complete=false` plus a non-zero exit is a closed result: a consumer must not turn a missing row into absence. A running - row always carries a PID, creation time, and opaque generation ID derived from - stable backend evidence. + row always carries a PID, creation time, opaque generation ID derived from + stable backend evidence, and the required `runtime.resourceTarget`. + + `resourceTarget` is internally tagged by `type` and has exactly these wire + shapes: + + | `type` | Fields | Meaning | + | --- | --- | --- | + | `linuxCgroupV2` | `path` | The slash-prefixed unified-hierarchy path exactly as read from `/proc//cgroup`; `/` is the cgroup-v2 mount root. | + | `darwinProcessTree` | `rootPid` | The observed generation's PID, used as a best-effort process-tree root. | + | `unavailable` | `reason` | No safe target: one of `notRunning`, `runtimeIndeterminate`, `processUnavailable`, `generationChanged`, `cgroupV2Unavailable`, or `unsupportedPlatform`. | + + Linux accepts exactly one `0::` entry. It preserves the path bytes + represented by UTF-8 text, including spaces and colons, but rejects a + non-absolute path, NUL or carriage return, repeated separators, trailing + separators, `.` or `..` components, duplicate unified entries, and input + with no unified entry. It does not derive the path from a systemd unit, + scope name, runtime ID, or naming convention. + + PTY observation captures each candidate daemon's kernel process-start token + after the registry snapshot, then executes `pty stats --json` exactly once + without a session argument. The returned array is socket-backed live + evidence for every candidate. A token is admitted only when exactly one + stats row has the task name, `process.alive=true`, the same `daemon.pid`, and + the same `createdAt`, and a second kernel-token read equals the first. + Resource observation then reads the token before and after the target. Exec + compares those target-fence reads directly to the token already bound into + its generation record. A missing process is `processUnavailable`; a stats or + token mismatch, or a token that changes across a read, is + `generationChanged`. Neither result exposes the candidate path or root PID. + Darwin uses the same fence around its root PID. A bounded + `unavailable` target is a truthful successful field and does not by itself + make the envelope incomplete. Locators are sampled afresh on each command: + st2 stores no scope registry, performs no resource sampling, and gives no + target continuity guarantee. Runtime ID remains task identity. Discovery runs before and after runtime observation. A semantic declaration change across those passes makes the result incomplete. The reader also @@ -842,17 +875,18 @@ validate ──► materialize ──► host-local st2 scheduler/reconciler discovery and runtime observation. Every successful declaration writer advances and fsyncs that monotonic generation after its durable commit; apply does so after live verification and before clearing its marker. Even a - completed declaration ABA is therefore incomplete. This remains an observational - seqlock and does not serialize catalog writers. A runtime root positively absent at admission is - empty and is not passed to its backend. An admitted PTY root that is removed - or replaced during `pty list` is indeterminate; because the external backend - creates an absent registry, concurrent root deletion is not a zero-write - boundary. Malformed state, PID reuse, timeouts, duplicate ids, and observer - failures are likewise indeterminate. Existing plain-PID exec records are - opened read-only without following symlinks and verified by retained file - identity, unchanged content and metadata, the final path identity, process - start token, and record mtime without rewriting them. If that proof is - unavailable on a supported OS, the generation remains indeterminate. + completed declaration ABA is therefore incomplete. This remains an + observational seqlock and does not serialize catalog writers. A runtime root + positively absent at admission is empty and is not passed to its backend. An + admitted PTY root that is removed or replaced during `pty list` is + indeterminate; because the external backend creates an absent registry, + concurrent root deletion is not a zero-write boundary. Malformed state, PID + reuse, timeouts, duplicate ids, and observer failures are likewise + indeterminate. Existing plain-PID exec records are opened read-only without + following symlinks and verified by retained file identity, unchanged content + and metadata, the final path identity, process start token, and record mtime + without rewriting them. If that proof is unavailable on a supported OS, the + generation remains indeterminate. Each row retains the task-level `desiredState` (`running` or `absent`) and appends the declaration-level `agentDesiredState` plus diff --git a/src/exec_backend.rs b/src/exec_backend.rs index b789a096..072c2484 100644 --- a/src/exec_backend.rs +++ b/src/exec_backend.rs @@ -58,6 +58,7 @@ pub enum ExecGenerationObservation { pid: u32, created_at: String, generation_id: String, + start_time_ticks: u64, }, Exited { pid: u32, @@ -258,6 +259,7 @@ impl ExecBackend { pid, created_at, generation_id, + .. } => (pid, created_at, generation_id), ExecGenerationObservation::Exited { .. } => { self.remove(id)?; @@ -296,6 +298,7 @@ impl ExecBackend { pid, created_at, generation_id, + .. } if pid == expected.0 && created_at == expected.1 && generation_id == expected.2 => {} ExecGenerationObservation::Exited { pid, @@ -660,6 +663,7 @@ fn observe_open_strict_generation( pid: generation.pid, created_at: generation.created_at, generation_id: generation.generation_id, + start_time_ticks: generation.start_time_ticks, }, GenerationProcessState::Exited => ExecGenerationObservation::Exited { pid: generation.pid, @@ -785,6 +789,7 @@ fn observe_open_legacy_generation( pid: pid as u32, created_at, generation_id, + start_time_ticks, } } @@ -1363,6 +1368,7 @@ mod generation_observation_tests { pid: observed, ref created_at, ref generation_id, + .. }) if observed == pid && crate::task_inventory::is_rfc3339_utc_millis(created_at) && generation_id.starts_with("sha256:") diff --git a/src/run.rs b/src/run.rs index bc83788c..81b67353 100644 --- a/src/run.rs +++ b/src/run.rs @@ -37,8 +37,9 @@ use crate::reconcile::{ compile_generated_tasks, }; use crate::task_inventory::{ - DesiredRuntime, ObservationBatch, ObservedState, RuntimeGeneration, RuntimeObservation, - RuntimeObserver, generation_id, + DesiredRuntime, ObservationBatch, ObservedState, ResourceTarget, + ResourceTargetUnavailableReason, RuntimeGeneration, RuntimeObservation, RuntimeObserver, + generation_id, observe_resource_target, }; use agent_spec::spec::TaskKind; @@ -403,6 +404,53 @@ struct PtyListEntry { tags: BTreeMap, } +/// One live session returned by the socket-backed `pty stats --json` snapshot. +#[derive(Debug, Deserialize)] +struct PtyStatsEntry { + name: String, + #[serde(default)] + process: Option, + #[serde(default)] + daemon: Option, + #[serde(rename = "createdAt", default)] + created_at: Option, +} + +#[derive(Debug, Deserialize)] +struct PtyStatsProcess { + alive: bool, +} + +#[derive(Debug, Deserialize)] +struct PtyStatsDaemon { + pid: u32, +} + +fn confirm_pty_generation( + initial: &PtyListEntry, + stats: &[PtyStatsEntry], +) -> Result<(), ResourceTargetUnavailableReason> { + let mut matching = stats.iter().filter(|candidate| candidate.name == initial.name); + let Some(current) = matching.next() else { + return Err(ResourceTargetUnavailableReason::ProcessUnavailable); + }; + if matching.next().is_some() { + return Err(ResourceTargetUnavailableReason::RuntimeIndeterminate); + } + let (Some(process), Some(daemon), Some(created_at)) = + (¤t.process, ¤t.daemon, ¤t.created_at) + else { + return Err(ResourceTargetUnavailableReason::RuntimeIndeterminate); + }; + if !process.alive { + return Err(ResourceTargetUnavailableReason::ProcessUnavailable); + } + if Some(daemon.pid) != initial.pid || Some(created_at) != initial.created_at.as_ref() { + return Err(ResourceTargetUnavailableReason::GenerationChanged); + } + Ok(()) +} + #[derive(Serialize)] struct PtyMetadataPatch<'a> { #[serde(rename = "displayName", skip_serializing_if = "Option::is_none")] @@ -687,6 +735,30 @@ impl PtyCli { }; } }; + // `pty list` identifies the daemon generation but is registry state, + // not a live socket proof. Capture each candidate daemon's kernel start + // token, query all live session sockets once, then accept a token only + // when stats reports the same name, daemon PID, and createdAt and a + // second token read is unchanged. + let start_tokens = entries + .iter() + .filter(|entry| { + desired_ids.contains(entry.name.as_str()) + && entry.status == "running" + && entry.pid.is_some() + && entry.created_at.is_some() + }) + .map(|entry| { + ( + entry.name.clone(), + crate::exec_backend::process_start_time_ticks(entry.pid.unwrap() as i32).ok(), + ) + }) + .collect::>(); + let stats_entries = start_tokens + .values() + .any(Option::is_some) + .then(|| self.stats_entries_at(root)); let final_metadata = match std::fs::metadata(root) { Ok(final_metadata) => final_metadata, Err(error) => { @@ -722,9 +794,50 @@ impl PtyCli { let state = match entry.status.as_str() { "running" => match (entry.pid, entry.created_at.as_deref()) { (Some(pid), Some(created_at)) => { + let start_time_ticks = start_tokens.get(&entry.name).copied().flatten(); + let resource_target = match (start_time_ticks, stats_entries.as_ref()) { + (Some(start_time_ticks), Some(Ok(stats))) => { + match confirm_pty_generation(&entry, stats) { + Ok(()) => { + let final_start = + crate::exec_backend::process_start_time_ticks( + pid as i32, + ); + match final_start { + Ok(final_start) if final_start == start_time_ticks => { + observe_resource_target( + pid, + Some(start_time_ticks), + ) + } + Ok(_) => ResourceTarget::unavailable( + ResourceTargetUnavailableReason::GenerationChanged, + ), + Err(_) => ResourceTarget::unavailable( + ResourceTargetUnavailableReason::ProcessUnavailable, + ), + } + } + Err(reason) => ResourceTarget::unavailable(reason), + } + } + (Some(_), Some(Err(_))) => ResourceTarget::unavailable( + ResourceTargetUnavailableReason::RuntimeIndeterminate, + ), + _ => ResourceTarget::unavailable( + ResourceTargetUnavailableReason::ProcessUnavailable, + ), + }; + // Transient resource proof must not participate in + // stable PTY generation identity. let generation_id = generation_id("pty", &entry.name, pid, created_at, None); - match RuntimeGeneration::new(pid, created_at.to_owned(), generation_id) { + match RuntimeGeneration::new( + pid, + created_at.to_owned(), + generation_id, + resource_target, + ) { Ok(generation) => ObservedState::Running(generation), Err(error) => { let message = format!( @@ -809,6 +922,24 @@ impl PtyCli { serde_json::from_slice(&out.stdout) .map_err(|error| anyhow::anyhow!("parsing `pty list --json`: {error}")) } + + fn stats_entries_at(&self, root: &Path) -> anyhow::Result> { + let out = output_full_stdout_with_timeout( + Command::new(&self.bin) + .args(["stats", "--json"]) + .env("PTY_ROOT", root), + PTY_LIST_TIMEOUT, + ) + .map_err(|error| anyhow::anyhow!("`pty stats --json` failed: {error}"))?; + if !out.status.success() { + anyhow::bail!( + "`pty stats --json` failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + serde_json::from_slice(&out.stdout) + .map_err(|error| anyhow::anyhow!("parsing `pty stats --json`: {error}")) + } } /// Apply both assignments and removals from an inner command to its isolation wrapper. @@ -1012,8 +1143,14 @@ impl RuntimeObserver for SystemRunner { pid, created_at, generation_id, + start_time_ticks, })) => { - let state = match RuntimeGeneration::new(pid, created_at, generation_id) { + let state = match RuntimeGeneration::new( + pid, + created_at, + generation_id, + observe_resource_target(pid, Some(start_time_ticks)), + ) { Ok(generation) => ObservedState::Running(generation), Err(error) => { let message = format!( @@ -6564,6 +6701,120 @@ mod tests { ); } + #[test] + fn pty_stats_rejects_pid_reuse_between_registry_snapshot_and_start_token_capture() { + let initial = PtyListEntry { + name: "h.worker".into(), + status: "running".into(), + exit_code: None, + pid: Some(41), + created_at: Some("2026-09-05T10:00:00.000Z".into()), + display_name: None, + tags: BTreeMap::new(), + }; + let stats = |alive: bool, created_at: &str| PtyStatsEntry { + name: "h.worker".into(), + process: Some(PtyStatsProcess { alive }), + daemon: Some(PtyStatsDaemon { pid: 41 }), + created_at: Some(created_at.into()), + }; + + // PID 41 has been reused. A token captured after the registry snapshot + // would describe the replacement, but its live socket reports a new + // creation generation and prevents that token from being admitted. + let replacement = stats(true, "2026-09-05T10:00:01.000Z"); + assert_eq!( + confirm_pty_generation(&initial, &[replacement]), + Err(ResourceTargetUnavailableReason::GenerationChanged) + ); + + let exited = stats(false, "2026-09-05T10:00:00.000Z"); + assert_eq!( + confirm_pty_generation(&initial, &[exited]), + Err(ResourceTargetUnavailableReason::ProcessUnavailable) + ); + let stable = stats(true, "2026-09-05T10:00:00.000Z"); + assert_eq!(confirm_pty_generation(&initial, &[stable]), Ok(())); + } + + #[test] + fn pty_resource_observation_uses_one_stats_snapshot_and_preserves_generation_id() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("pty"); + std::fs::create_dir(&root).unwrap(); + let invocations = tmp.path().join("invocations"); + let failed_once = tmp.path().join("stats-failed-once"); + let pid = std::process::id(); + let fake = tmp.path().join("pty-bin"); + std::fs::write( + &fake, + format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> {invocations:?} +case "$1" in + list) + printf '%s\n' '[{{"name":"h.a","status":"running","pid":{pid},"createdAt":"2026-09-05T10:00:00.000Z"}},{{"name":"h.b","status":"running","pid":{pid},"createdAt":"2026-09-05T10:00:01.000Z"}}]' + ;; + stats) + if [ ! -e {failed_once:?} ]; then + : > {failed_once:?} + exit 1 + fi + printf '%s\n' '[{{"name":"h.a","process":{{"alive":true}},"daemon":{{"pid":{pid}}},"createdAt":"2026-09-05T10:00:00.000Z"}},{{"name":"h.b","process":{{"alive":true}},"daemon":{{"pid":{pid}}},"createdAt":"2026-09-05T10:00:01.000Z"}}]' + ;; +esac +"#, + invocations = invocations, + failed_once = failed_once, + ), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&fake).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&fake, permissions).unwrap(); + + let cli = PtyCli { + bin: fake.display().to_string(), + catalog_root: tmp.path().join("catalog"), + }; + let desired_ids = HashSet::from(["h.a", "h.b"]); + let unavailable = cli.task_observations_at_root(&desired_ids, &root); + let available = cli.task_observations_at_root(&desired_ids, &root); + assert!(unavailable.complete, "{:?}", unavailable.errors); + assert!(available.complete, "{:?}", available.errors); + assert_eq!(available.observations.len(), 2); + assert_eq!( + std::fs::read_to_string(invocations).unwrap(), + "list --json\nstats --json\nlist --json\nstats --json\n" + ); + for (before, after) in unavailable + .observations + .iter() + .zip(&available.observations) + { + let (ObservedState::Running(before), ObservedState::Running(after)) = + (&before.state, &after.state) + else { + panic!("live PTY lost generation"); + }; + assert!(matches!( + before.resource_target(), + ResourceTarget::Unavailable { .. } + )); + assert!(!matches!( + after.resource_target(), + ResourceTarget::Unavailable { .. } + )); + assert_eq!( + before.generation_id(), + after.generation_id(), + "transient target proof changed stable PTY generation identity" + ); + } + } + #[test] fn pty_task_observation_preserves_exact_generation_and_closed_states() { use std::os::unix::fs::PermissionsExt; diff --git a/src/task_inventory.rs b/src/task_inventory.rs index 237ce2a4..f45bab3f 100644 --- a/src/task_inventory.rs +++ b/src/task_inventory.rs @@ -4,6 +4,8 @@ //! expose a reconcile plan and cannot mutate catalog or runtime state. use std::collections::{BTreeMap, BTreeSet}; +#[cfg(target_os = "linux")] +use std::fs; use std::path::{Path, PathBuf}; use agent_spec::spec::{TaskKind, TaskLifecycle}; @@ -13,7 +15,7 @@ use sha2::{Digest as _, Sha256}; use crate::Discovered; use crate::park::{ParkObserver, ParkState}; -pub const TASK_INVENTORY_SCHEMA: &str = "st2.task-inventory.v1"; +pub const TASK_INVENTORY_SCHEMA: &str = "st2.task-inventory.v2"; /// Opaque identity over one backend's stable process-generation evidence. pub(crate) fn generation_id( @@ -78,6 +80,43 @@ pub(crate) fn is_rfc3339_utc_millis(value: &str) -> bool { year > 0 && (1..=days).contains(&day) && hour <= 23 && minute <= 59 && second <= 60 } +/// One sampling locator proven against the observed process generation. +/// +/// These are observation locators, not task identity. A caller must rediscover +/// them on every inventory sample rather than retaining them as ownership. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ResourceTarget { + LinuxCgroupV2 { + path: String, + }, + DarwinProcessTree { + #[serde(rename = "rootPid")] + root_pid: u32, + }, + Unavailable { + reason: ResourceTargetUnavailableReason, + }, +} + +impl ResourceTarget { + pub(crate) fn unavailable(reason: ResourceTargetUnavailableReason) -> Self { + Self::Unavailable { reason } + } +} + +/// Closed reasons why no truthful resource locator was available. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum ResourceTargetUnavailableReason { + NotRunning, + RuntimeIndeterminate, + ProcessUnavailable, + GenerationChanged, + CgroupV2Unavailable, + UnsupportedPlatform, +} + /// One positively identified live runtime generation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeGeneration { @@ -86,10 +125,16 @@ pub struct RuntimeGeneration { created_at: String, /// Opaque generation identity derived from stable backend evidence. generation_id: String, + resource_target: ResourceTarget, } impl RuntimeGeneration { - pub fn new(pid: u32, created_at: String, generation_id: String) -> Result { + pub fn new( + pid: u32, + created_at: String, + generation_id: String, + resource_target: ResourceTarget, + ) -> Result { if pid == 0 { return Err("runtime pid must be positive".into()); } @@ -105,6 +150,7 @@ impl RuntimeGeneration { pid, created_at, generation_id, + resource_target, }) } @@ -119,6 +165,111 @@ impl RuntimeGeneration { pub fn generation_id(&self) -> &str { &self.generation_id } + + pub fn resource_target(&self) -> &ResourceTarget { + &self.resource_target + } +} + +/// Fence a process-derived locator between two reads of the kernel's process +/// start token. This rejects exit and PID reuse during observation, including +/// races that happen while the cgroup membership itself is being read. +fn fence_resource_target( + expected_start_time_ticks: Option, + mut read_start: ReadStart, + read_target: ReadTarget, +) -> ResourceTarget +where + ReadStart: FnMut() -> Result, + ReadTarget: FnOnce() -> Result, +{ + let Ok(initial_start) = read_start() else { + return ResourceTarget::unavailable(ResourceTargetUnavailableReason::ProcessUnavailable); + }; + if expected_start_time_ticks.is_some_and(|expected| expected != initial_start) { + return ResourceTarget::unavailable(ResourceTargetUnavailableReason::GenerationChanged); + } + + let candidate = read_target(); + let Ok(final_start) = read_start() else { + return ResourceTarget::unavailable(ResourceTargetUnavailableReason::ProcessUnavailable); + }; + if final_start != initial_start + || expected_start_time_ticks.is_some_and(|expected| expected != final_start) + { + return ResourceTarget::unavailable(ResourceTargetUnavailableReason::GenerationChanged); + } + candidate.unwrap_or_else(ResourceTarget::unavailable) +} + +/// Parse the unified hierarchy entry without treating a legacy/hybrid entry as +/// cgroup v2 and without admitting a path that can escape the cgroup mount. +#[cfg(any(target_os = "linux", test))] +fn parse_unified_cgroup_path(raw: &str) -> Result { + let mut unified = None; + for line in raw.split_terminator('\n') { + if line.is_empty() || line.contains('\r') || line.contains('\0') { + return Err(()); + } + let mut fields = line.splitn(3, ':'); + let hierarchy = fields.next().ok_or(())?; + let controllers = fields.next().ok_or(())?; + let path = fields.next().ok_or(())?; + if hierarchy != "0" || !controllers.is_empty() { + continue; + } + if unified.is_some() + || !path.starts_with('/') + || (path.len() > 1 && path.ends_with('/')) + || (path != "/" + && path + .split('/') + .skip(1) + .any(|component| component.is_empty() || component == "." || component == "..")) + { + return Err(()); + } + unified = Some(path.to_owned()); + } + unified.ok_or(()) +} + +#[cfg(target_os = "linux")] +pub(crate) fn observe_resource_target( + pid: u32, + expected_start_time_ticks: Option, +) -> ResourceTarget { + fence_resource_target( + expected_start_time_ticks, + || crate::exec_backend::process_start_time_ticks(pid as i32).map_err(|_| ()), + || { + let raw = fs::read_to_string(format!("/proc/{pid}/cgroup")) + .map_err(|_| ResourceTargetUnavailableReason::CgroupV2Unavailable)?; + parse_unified_cgroup_path(&raw) + .map(|path| ResourceTarget::LinuxCgroupV2 { path }) + .map_err(|()| ResourceTargetUnavailableReason::CgroupV2Unavailable) + }, + ) +} + +#[cfg(target_os = "macos")] +pub(crate) fn observe_resource_target( + pid: u32, + expected_start_time_ticks: Option, +) -> ResourceTarget { + fence_resource_target( + expected_start_time_ticks, + || crate::exec_backend::process_start_time_ticks(pid as i32).map_err(|_| ()), + || Ok(ResourceTarget::DarwinProcessTree { root_pid: pid }), + ) +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub(crate) fn observe_resource_target( + _pid: u32, + _expected_start_time_ticks: Option, +) -> ResourceTarget { + ResourceTarget::unavailable(ResourceTargetUnavailableReason::UnsupportedPlatform) } /// Closed runtime observation. Invalid state/generation/error products are unrepresentable. @@ -239,6 +390,7 @@ struct RuntimeJson { pid: Option, created_at: Option, generation_id: Option, + resource_target: ResourceTarget, error: Option, } @@ -427,21 +579,51 @@ pub fn inventory( } } }); - let (state, pid, created_at, generation_id, error) = match observation.state { - ObservedState::Running(generation) => ( - "running", - Some(generation.pid), - Some(generation.created_at), - Some(generation.generation_id), - None, - ), - ObservedState::Exited => ("exited", None, None, None, None), - ObservedState::Vanished => ("vanished", None, None, None, None), - ObservedState::Absent => ("absent", None, None, None, None), - ObservedState::Indeterminate(error) => { - ("indeterminate", None, None, None, Some(error)) - } - }; + let (state, pid, created_at, generation_id, resource_target, error) = + match observation.state { + ObservedState::Running(generation) => ( + "running", + Some(generation.pid), + Some(generation.created_at), + Some(generation.generation_id), + generation.resource_target, + None, + ), + ObservedState::Exited => ( + "exited", + None, + None, + None, + ResourceTarget::unavailable(ResourceTargetUnavailableReason::NotRunning), + None, + ), + ObservedState::Vanished => ( + "vanished", + None, + None, + None, + ResourceTarget::unavailable(ResourceTargetUnavailableReason::NotRunning), + None, + ), + ObservedState::Absent => ( + "absent", + None, + None, + None, + ResourceTarget::unavailable(ResourceTargetUnavailableReason::NotRunning), + None, + ), + ObservedState::Indeterminate(error) => ( + "indeterminate", + None, + None, + None, + ResourceTarget::unavailable( + ResourceTargetUnavailableReason::RuntimeIndeterminate, + ), + Some(error), + ), + }; let parked = match parks.states.remove(&task.runtime_id) { Some(ParkState::Parked(record)) => Some(ParkedJson { since: record.parked_at, @@ -486,6 +668,7 @@ pub fn inventory( pid, created_at, generation_id, + resource_target, error, }, parked, @@ -585,6 +768,18 @@ mod tests { } fn running(id: &str, pid: u32) -> RuntimeObservation { + running_with_target( + id, + pid, + ResourceTarget::unavailable(ResourceTargetUnavailableReason::ProcessUnavailable), + ) + } + + fn running_with_target( + id: &str, + pid: u32, + resource_target: ResourceTarget, + ) -> RuntimeObservation { RuntimeObservation { runtime_id: id.into(), state: ObservedState::Running( @@ -592,6 +787,7 @@ mod tests { pid, "2026-07-31T10:00:00.000Z".into(), format!("sha256:g-{pid}"), + resource_target, ) .unwrap(), ), @@ -652,6 +848,10 @@ mod tests { "pid": 11, "createdAt": "2026-07-31T10:00:00.000Z", "generationId": "sha256:g-11", + "resourceTarget": { + "type": "unavailable", + "reason": "processUnavailable" + }, "error": null }, "parked": null @@ -896,8 +1096,13 @@ mod tests { assert!(!is_rfc3339_utc_millis("2025-02-29T10:00:00.000Z")); assert!(is_rfc3339_utc_millis("2024-02-29T10:00:00.000Z")); assert!( - RuntimeGeneration::new(0, "2026-07-31T10:00:00.000Z".into(), "sha256:g".into()) - .is_err() + RuntimeGeneration::new( + 0, + "2026-07-31T10:00:00.000Z".into(), + "sha256:g".into(), + ResourceTarget::unavailable(ResourceTargetUnavailableReason::ProcessUnavailable), + ) + .is_err() ); let tmp = tempfile::tempdir().unwrap(); @@ -963,6 +1168,125 @@ mod tests { assert_eq!(value["tasks"][2]["runtime"]["state"], "indeterminate"); } + #[test] + fn unified_cgroup_parser_accepts_one_exact_v2_path_and_rejects_ambiguous_or_unsafe_input() { + assert_eq!( + parse_unified_cgroup_path("9:cpu,cpuacct:/legacy\n0::/user.slice/st2:worker scope\n"), + Ok("/user.slice/st2:worker scope".into()) + ); + assert_eq!(parse_unified_cgroup_path("0::/\n"), Ok("/".into())); + + for malformed in [ + "", + "9:cpu:/legacy\n", + "0:memory:/not-unified\n", + "0::relative\n", + "0::/a/../b\n", + "0::/a//b\n", + "0::/a/\n", + "0::/first\n0::/second\n", + "0::/ok\r\n", + "0::/ok\n\n0::/also\n", + ] { + assert_eq!( + parse_unified_cgroup_path(malformed), + Err(()), + "{malformed:?} was admitted" + ); + } + } + + #[test] + fn process_fence_accepts_only_the_same_live_generation() { + let expected = ResourceTarget::LinuxCgroupV2 { + path: "/st2/task".into(), + }; + let mut stable = [Ok(41), Ok(41)].into_iter(); + assert_eq!( + fence_resource_target(Some(41), || stable.next().unwrap(), || Ok(expected.clone())), + expected + ); + + let mut target_read = false; + assert_eq!( + fence_resource_target( + Some(41), + || Ok(42), + || { + target_read = true; + Ok(ResourceTarget::LinuxCgroupV2 { + path: "/already-recycled".into(), + }) + } + ), + ResourceTarget::unavailable(ResourceTargetUnavailableReason::GenerationChanged) + ); + assert!(!target_read, "a mismatched generation read the target"); + + let mut recycled = [Ok(41), Ok(42)].into_iter(); + assert_eq!( + fence_resource_target( + Some(41), + || recycled.next().unwrap(), + || Ok(ResourceTarget::LinuxCgroupV2 { + path: "/wrong-generation".into(), + }) + ), + ResourceTarget::unavailable(ResourceTargetUnavailableReason::GenerationChanged) + ); + + let mut exited = [Ok(41), Err(())].into_iter(); + assert_eq!( + fence_resource_target( + Some(41), + || exited.next().unwrap(), + || Ok(ResourceTarget::LinuxCgroupV2 { + path: "/exited".into(), + }) + ), + ResourceTarget::unavailable(ResourceTargetUnavailableReason::ProcessUnavailable) + ); + } + + #[test] + fn degraded_linux_and_darwin_targets_have_strict_tagged_wire_shapes() { + let mut stable = [Ok(7), Ok(7)].into_iter(); + let degraded = fence_resource_target( + Some(7), + || stable.next().unwrap(), + || Err(ResourceTargetUnavailableReason::CgroupV2Unavailable), + ); + assert_eq!( + serde_json::to_value(degraded).unwrap(), + serde_json::json!({ + "type": "unavailable", + "reason": "cgroupV2Unavailable" + }) + ); + assert_eq!( + serde_json::to_value(ResourceTarget::DarwinProcessTree { root_pid: 73 }).unwrap(), + serde_json::json!({ + "type": "darwinProcessTree", + "rootPid": 73 + }) + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn current_process_resource_target_is_associated_with_its_proven_generation() { + let pid = std::process::id(); + let start_time_ticks = crate::exec_backend::process_start_time_ticks(pid as i32).unwrap(); + let target = observe_resource_target(pid, Some(start_time_ticks)); + #[cfg(target_os = "linux")] + assert!(matches!( + target, + ResourceTarget::LinuxCgroupV2 { ref path } if path.starts_with('/') + )); + #[cfg(target_os = "macos")] + assert_eq!(target, ResourceTarget::DarwinProcessTree { root_pid: pid }); + } + #[test] fn semantic_discovery_drift_is_detected_but_comment_only_edits_are_equivalent() { let tmp = tempfile::tempdir().unwrap(); diff --git a/tests/predecessor_ding_migration.rs b/tests/predecessor_ding_migration.rs index 10a63ca6..63992e23 100644 --- a/tests/predecessor_ding_migration.rs +++ b/tests/predecessor_ding_migration.rs @@ -449,7 +449,7 @@ fn predecessor_drains_only_legacy_ding_then_candidate_adopts_provider_and_replac .unwrap(); assert_success(&tasks, "strict post-migration inventory"); let inventory: Value = serde_json::from_slice(&tasks.stdout).unwrap(); - assert_eq!(inventory["schema"], "st2.task-inventory.v1"); + assert_eq!(inventory["schema"], "st2.task-inventory.v2"); assert_eq!(inventory["complete"], true); assert_eq!(inventory["errors"], serde_json::json!([])); let rows = inventory["tasks"].as_array().unwrap(); diff --git a/tests/task_inventory_cli.rs b/tests/task_inventory_cli.rs index b7589a12..328e2c77 100644 --- a/tests/task_inventory_cli.rs +++ b/tests/task_inventory_cli.rs @@ -181,7 +181,7 @@ fn projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_default #[test] fn tasks_cli_emits_stable_complete_generation_without_mutation() { let (tmp, catalog, bin) = fixture( - r#"[{"name":"h.worker","status":"running","pid":77,"createdAt":"2026-07-31T10:00:00.000Z"},{"name":"human.scratch","status":"running","pid":88,"createdAt":"2026-07-31T10:00:01.000Z"}]"#, + r#"[{"name":"h.worker","status":"running","pid":4000000,"createdAt":"2026-07-31T10:00:00.000Z"},{"name":"human.scratch","status":"running","pid":4000001,"createdAt":"2026-07-31T10:00:01.000Z"}]"#, ); let agent = catalog.join("agents/h/worker/agent.kdl"); let catalog_config = catalog.join("catalog.kdl"); @@ -197,7 +197,7 @@ fn tasks_cli_emits_stable_complete_generation_without_mutation() { ); assert_eq!(first.stdout, second.stdout, "unchanged generation drifted"); let value: serde_json::Value = serde_json::from_slice(&first.stdout).unwrap(); - assert_eq!(value["schema"], "st2.task-inventory.v1"); + assert_eq!(value["schema"], "st2.task-inventory.v2"); assert_eq!(value["complete"], true); assert_eq!(value["tasks"].as_array().unwrap().len(), 1); assert_eq!(value["tasks"][0]["agent"], "h.worker"); @@ -206,7 +206,7 @@ fn tasks_cli_emits_stable_complete_generation_without_mutation() { assert_eq!(value["tasks"][0]["desiredState"], "running"); assert_eq!(value["tasks"][0]["agentDesiredState"], "running"); assert!(value["tasks"][0]["agentDesiredStateReason"].is_null()); - assert_eq!(value["tasks"][0]["runtime"]["pid"], 77); + assert_eq!(value["tasks"][0]["runtime"]["pid"], 4_000_000); assert_eq!( value["tasks"][0]["runtime"]["createdAt"], "2026-07-31T10:00:00.000Z" @@ -217,6 +217,13 @@ fn tasks_cli_emits_stable_complete_generation_without_mutation() { .unwrap() .starts_with("sha256:") ); + assert_eq!( + value["tasks"][0]["runtime"]["resourceTarget"], + serde_json::json!({ + "type": "unavailable", + "reason": "processUnavailable" + }) + ); assert_eq!(fs::read(agent).unwrap(), before_agent); assert_eq!(fs::read(catalog_config).unwrap(), before_catalog); assert!(!state.exists(), "read-only inventory created runtime state"); @@ -326,7 +333,7 @@ fn completed_catalog_aba_during_runtime_observation_is_incomplete() { let b_spec = prepared_b.join("agents/h/worker/agent.kdl"); let bytes = fs::read_to_string(&b_spec) .unwrap() - .replace("agent \"worker\" {", "agent \"worker\" {\n retired #true"); + .replace("agent \"worker\" {", "agent \"worker\" {\n name \"temporary\""); fs::write(&b_spec, bytes).unwrap(); let observer_ready = tmp.path().join("observer-ready"); @@ -357,6 +364,9 @@ fn completed_catalog_aba_during_runtime_observation_is_incomplete() { } let apply = |prepared: &Path, expected: &str| { + let input_sha256 = st2::catalog_transaction::digest_prepared(&catalog, prepared) + .unwrap() + .root_sha256; Command::new(env!("CARGO_BIN_EXE_st2")) .args([ "catalog", @@ -365,6 +375,8 @@ fn completed_catalog_aba_during_runtime_observation_is_incomplete() { catalog.to_str().unwrap(), "--prepared", prepared.to_str().unwrap(), + "--input-sha256", + &input_sha256, "--expect-sha256", expected, "--json", @@ -630,6 +642,20 @@ agent "worker" { value["tasks"][0]["runtime"]["pid"], u64::from(std::process::id()) ); + #[cfg(target_os = "macos")] + assert_eq!( + value["tasks"][0]["runtime"]["resourceTarget"], + serde_json::json!({ + "type": "darwinProcessTree", + "rootPid": std::process::id() + }) + ); + #[cfg(target_os = "linux")] + { + let target = &value["tasks"][0]["runtime"]["resourceTarget"]; + assert_eq!(target["type"], "linuxCgroupV2"); + assert!(target["path"].as_str().unwrap().starts_with('/')); + } assert_eq!(fs::read_to_string(&pid_file).unwrap(), legacy); let after_entries = fs::read_dir(&exec_state) .unwrap() @@ -664,6 +690,13 @@ fn missing_pty_root_is_positive_absence_without_creation_or_invocation() { assert!(output.status.success()); let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(value["tasks"][0]["runtime"]["state"], "absent"); + assert_eq!( + value["tasks"][0]["runtime"]["resourceTarget"], + serde_json::json!({ + "type": "unavailable", + "reason": "notRunning" + }) + ); assert!(!missing.exists()); assert!(!tmp.path().join("PTY-WAS-CALLED").exists()); } @@ -711,7 +744,7 @@ fn missing_catalog_and_missing_json_flag_fail_explicitly() { .unwrap(); assert!(!missing_output.status.success()); let value: serde_json::Value = serde_json::from_slice(&missing_output.stdout).unwrap(); - assert_eq!(value["schema"], "st2.task-inventory.v1"); + assert_eq!(value["schema"], "st2.task-inventory.v2"); assert_eq!(value["complete"], false); assert!(value["tasks"].as_array().unwrap().is_empty()); @@ -825,6 +858,20 @@ fn packaged_tasks_tracks_real_pty_generation_replacement() { .as_str() .unwrap() .to_owned(); + #[cfg(target_os = "macos")] + assert_eq!( + first_json["tasks"][0]["runtime"]["resourceTarget"], + serde_json::json!({ + "type": "darwinProcessTree", + "rootPid": first_json["tasks"][0]["runtime"]["pid"] + }) + ); + #[cfg(target_os = "linux")] + { + let target = &first_json["tasks"][0]["runtime"]["resourceTarget"]; + assert_eq!(target["type"], "linuxCgroupV2"); + assert!(target["path"].as_str().unwrap().starts_with('/')); + } remove_real_pty(&pty_root, "h.worker"); let second_launch = launch();