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
4 changes: 2 additions & 2 deletions INVARIANTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ materialization, messaging, DING, or presence must preserve them.
| **Mutation-only filesystem wakeups** | Supervisor and DING filesystem watchers ignore read/open access events and wake early only for create, modify, rename, or remove events. Their own catalog and inbox reads therefore cannot bypass the bounded timer cadence or form a Linux inotify CPU loop. | `src/watch.rs::only_mutations_wake_watch_loops`; `src/watch.rs::linux_reads_are_silent_but_real_mutations_wake`; `src/ding/mod.rs::idle_ding_does_not_spin_on_its_own_inbox_reads`; `src/run.rs::idle_supervisor_does_not_spin_on_its_own_catalog_reads` |
| **Bounded DING PTY probe churn** | An unsafe or active composer retains its FIFO notice but deferred delivery retries use a bounded backoff, so each inbox poll cannot spawn another short-lived PTY probe. | `src/ding/mod.rs::deferred_delivery_backoff_bounds_short_lived_pty_attempts` |
| **Agent-declared presence discipline** | The shipped bus contract requires agents to declare `busy` before executing work, use `available` only while yielding or ready, and reserve `dnd` for an explicit hold. Both native harnesses materialize that contract. Busy remains observable but does not suppress DING; fresh `dnd` is the only delivery gate. | `tests/native_only.rs::clean_path_executes_the_maintained_native_authoring_guide`; `src/ding/mod.rs::pending_delivery_ignores_busy_but_respects_fresh_dnd_archive_and_retry` |
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, activity, and inbox counts. Presence remains independent from desired lifecycle. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state` |
| **Agent-declared presence** | Refresh preserves non-DND declared status and only advances liveness; a missing status starts as `available`, while `dnd` is never refreshed and an unrefreshed declaration ages to `unknown`. | `src/status.rs::refresh_preserves_value_and_bumps_mtime`; `src/status.rs::refresh_leaves_dnd_to_age_out`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::stale_mtime_reads_as_unknown_regardless_of_contents` |
| **Stable roster JSON** | `st2 agents --json [--enrich]` preserves field names, order, null handling, presence, typed desired state and rationale, the retirement compatibility projection, opaque declared Resource descriptors, origin-timed activity, and inbox counts. Presence remains independent from desired lifecycle. | `src/agents.rs::agents_json_has_stable_wire_shape`; `src/agents.rs::agents_json_preserves_opaque_declared_resource_descriptors`; `tests/status_agents.rs::roster_json_and_human_output_distinguish_retirement_from_presence`; `tests/status_agents.rs::roster_keeps_presence_separate_from_suspended_desired_state` |
| **Agent-declared presence** | Refresh preserves non-DND declared status and advances the embedded heartbeat; a missing status starts as `available`. Legacy DND migrates without renewing its hold, version 1 DND is never refreshed, and stale, malformed, or implausibly future heartbeats read as `unknown`. | `src/status.rs::refresh_preserves_value_and_changes_heartbeat_bytes`; `src/status.rs::refresh_upgrades_legacy_dnd_without_renewing_the_hold`; `src/status.rs::refresh_missing_writes_available_default`; `src/status.rs::version_1_staleness_and_future_skew_are_bounded`; `src/status.rs::malformed_versioned_record_is_unknown_without_mtime_fallback` |
| **Retirement health** | A retired declaration is healthy only after every declared task ID is absent. Any live or dead declared task record reports incomplete retirement; retired declarations do not require presence. Live declarations retain their existing task and presence checks. | `tests/doctor.rs::retired_declaration_is_healthy_when_tasks_and_presence_are_absent`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_declared_task_is_alive`; `tests/doctor.rs::retired_declaration_is_unhealthy_while_a_dead_task_record_remains` |
| **Suspension health** | A suspended declaration is healthy when no declared task is live and every retained dead record is explicitly keep-pinned. It requires no presence, but this weaker result never proves retirement. Resume preserves ordinary keep and adopt-only policy. | `tests/doctor.rs::suspended_declaration_is_healthy_when_tasks_are_absent_without_presence`; `tests/doctor.rs::suspended_declaration_distinguishes_live_dead_keep_and_dead_nonkeep`; `tests/reconcile.rs::resuming_uses_ordinary_reconcile_and_does_not_override_keep` |
| **Crash loops surface** | A task parked by a fail-mode restart policy notifies its supervisor once over the bus. | `tests/run.rs::surface_crash_loop_notifies_the_supervisor_over_the_bus` |
Expand Down
154 changes: 149 additions & 5 deletions docs/vrs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,150 @@ atomic inbox file → DING attempt → agent reads → archive receipt
- **R10:** Fleet identities are agents. General-purpose identity kinds are
unsupported.

### Presence record and freshness (R08)

This section answers the presence part of DQ3. The version 1 implementation
follows this contract.

#### Version 1 record

The presence record path is `<agent-dir>/status`.

The status file uses this exact version 1 shape:

```text
available
v1 1785802653486
```

Line one is one settable state: `offline`, `available`, `busy`, `away`, or
`dnd`. `unknown` remains derived and is never written.

Line two is `v1`, one ASCII space, and an unsigned base-10 timestamp. The
timestamp counts milliseconds from the Unix epoch.

The record ends with one newline. It has no other non-empty lines. Both lines
form one atomic record.

The state remains on line one for old readers. An old reader can ignore line
two and continue to parse the state.

#### Writers and atomicity

`st2 status --set` writes the requested state with the current timestamp. A
live DING sidecar refreshes valid non-DND records every five minutes.

A missing record becomes `available` with the current timestamp. DING does not
refresh `dnd`, `unknown`, or malformed records.

Every new writer emits version 1. It writes a temporary sibling and atomically
renames the complete record over the target.

A healthy periodic refresh changes the timestamp bytes. Replication can order
that content change without using the source file mtime.

#### Clock, freshness, and skew

The timestamp uses the writer's UTC wall clock. A monotonic clock cannot cross
a process restart or a host boundary.

Participating hosts must keep their UTC clocks within sixty seconds. A larger
clock error makes cross-host presence unknown.

The stale interval remains fifteen minutes. A valid record is fresh while its
age is less than fifteen minutes.

A record becomes `unknown` when its age reaches fifteen minutes. This rule
applies to every settable state, including `offline` and `dnd`.

A timestamp up to sixty seconds in the reader's future is allowed. The reader
uses zero age for this bounded future value.

A timestamp more than sixty seconds in the future produces `unknown`. The
reader does not use file mtime as a fallback for malformed version 1.

Current readers treat a future legacy status mtime as fresh because they cannot
calculate its age. A sufficiently future `dnd` mtime can therefore suppress
delivery until the reader's clock catches up. The version 1 skew rules close
this defect. They clamp only bounded future time and map larger future time to
`unknown`.

An unrecognized state still produces `offline`. A literal `unknown` produces
`unknown`. A valid state with a malformed version, timestamp, or extra line
also produces `unknown`.

The sixty-second allowance is smaller than the five-minute refresh margin. It
can extend a fresh DND hold by no more than sixty seconds.

#### Why readers use origin time

st2 does not require one catalog transport. Fabric is preferred, and Git over
SSH or a plain copy remains supported.

Git does not preserve file modification times. A checkout gives files the
checkout time. Therefore, presence freshness lives in record bytes. No
supported transport must preserve file metadata.

Replica arrival time measures transport delay, not agent activity. The
embedded writer time protects presence freshness, DND expiry, and the status
contribution to `lastActivity`.

The same reason applies to the context boot freshness check. A replica arrival
must not make old context appear fresh. This proposal does not change the
context record.

#### DND behavior

A fresh `dnd` record suppresses DING delivery. The sidecar leaves its timestamp
unchanged, so an abandoned hold ages out.

A stale or invalid DND record does not suppress delivery. It reads as
`unknown`, which preserves the existing fresh-DND rule.

Replication delay cannot renew a DND hold. The reader uses the embedded write
time, not the replica materialization time.

#### Legacy rollout

A legacy record contains one valid state line and no version line. The first
version 1 reader release uses legacy file mtime for freshness.

Version 1 writers never emit a legacy record. A live non-DND sidecar upgrades
its legacy record at its next five-minute refresh.

A version 1 sidecar upgrades a legacy DND record once. It uses the legacy mtime
as the embedded timestamp, so the migration cannot renew the hold.

After that migration, the sidecar does not refresh DND. If the legacy mtime is
unavailable, the sidecar leaves the record unchanged.

A malformed two-line record is not legacy. Readers must not hide a bad version
1 record behind the legacy mtime fallback.

Fallback removal is a separate reviewed change. Removal requires all three
receipts below:

1. Every supported deployed status writer emits version 1.
2. Two fleet scans, separated by fifteen minutes, find no active legacy record.
3. No supported or retained rollback binary can emit a legacy record.

After removal, a one-line record produces `unknown`. No presence freshness
decision then depends on status file mtime.

#### `lastActivity`

For a version 1 status record, `lastActivity` uses the embedded timestamp. It
does not use the replica materialization mtime.

The reader clamps an allowed future timestamp to its current time. It omits a
malformed version 1 timestamp from the activity calculation.

Inbox and archive entries continue to use their local file mtimes. During the
legacy window, a one-line status record also contributes its file mtime.

This choice reports when the agent wrote its heartbeat. A delayed replica
cannot make an old heartbeat appear to be new agent activity.

## Provider session-start restoration (R07, R09, R17, R33)

```text
Expand Down Expand Up @@ -794,11 +938,11 @@ the resident supervisor continues to reconcile the complete local catalog.
changes may defer delivery. Resolve the remaining gap with a stronger evented
signal or other measured classifier; a small on-device model is an optional
experiment, not a required architecture.
- **DQ3 Catalog agent state:** Define the catalog paths, schemas, freshness
rules, and atomic update semantics for presence, activity status, current
plan, and current plan step. Prove that stale state is distinguishable and
that a supervisor can follow plan progress without inspecting a PTY before
adding the shape to `AGENT-SPEC.md`.
- **DQ3 Remaining catalog agent state:** The R08 presence record above
defines the presence path, schema, freshness, and atomic update rules.
Activity status, current plan, and current plan step remain undefined. Prove
their stale-state and supervisor-following behavior before adding their shape
to `AGENT-SPEC.md`.
- **DQ4 Relaunch boundary (R29-R30):** Preserve R11's nondisruptive adoption
while making launch drift visible. For each declared task, derive the desired
launch fingerprint from a deterministic, versioned encoding of only:
Expand Down
35 changes: 14 additions & 21 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::UNIX_EPOCH;

use serde::Serialize;

Expand All @@ -31,8 +31,8 @@ pub struct AgentRow {
pub desired_state_reason: Option<String>,
/// Typed Resource bindings declared directly by the agent.
pub resources: Vec<Resource>,
/// Newest mtime (unix ms) across the agent's inbox, archive, and status file; `None` if nothing
/// has been touched. `--enrich` only.
/// Newest activity time across inbox, archive, and status. Version 1 status uses its embedded
/// writer timestamp; message files and legacy status use local mtime. `--enrich` only.
pub last_activity_ms: Option<f64>,
/// Count of canonical message files in the agent's inbox. `--enrich` only.
pub inbox: usize,
Expand Down Expand Up @@ -62,7 +62,7 @@ pub fn roster_from_discovered(found: &Discovered, this_host: &str) -> Vec<AgentR
desired_state: s.desired_state.as_str().to_owned(),
desired_state_reason: s.desired_state.reason().map(str::to_owned),
resources: s.resources.clone(),
last_activity_ms: newest_mtime_ms(agent_dir),
last_activity_ms: newest_activity_ms(agent_dir),
inbox: inbox_count(agent_dir),
})
})
Expand Down Expand Up @@ -149,9 +149,9 @@ fn inbox_count(agent_dir: &Path) -> usize {
.unwrap_or(0)
}

/// Newest mtime (unix ms) across the agent's inbox files, archive files, and status file. `None` if
/// none of those exist.
fn newest_mtime_ms(agent_dir: &Path) -> Option<f64> {
/// Newest activity time across status and message state. A version 1 status contributes its origin
/// timestamp. Inbox, archive, and legacy status retain their local-mtime behavior.
fn newest_activity_ms(agent_dir: &Path) -> Option<f64> {
let mut candidates: Vec<PathBuf> = Vec::new();
for dir in [
message::inbox_dir(agent_dir),
Expand All @@ -161,20 +161,20 @@ fn newest_mtime_ms(agent_dir: &Path) -> Option<f64> {
candidates.extend(rd.flatten().map(|e| e.path()));
}
}
candidates.push(status::status_path(agent_dir));

let mut newest: Option<SystemTime> = None;
let mut newest = status::activity_time_ms(&status::status_path(agent_dir));
for p in candidates {
if let Ok(m) = fs::metadata(&p)
&& let Ok(t) = m.modified()
&& newest.is_none_or(|n| t > n)
&& let Ok(duration) = t.duration_since(UNIX_EPOCH)
{
newest = Some(t);
let timestamp = duration.as_secs_f64() * 1000.0;
if newest.is_none_or(|current| timestamp > current) {
newest = Some(timestamp);
}
}
}
newest
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs_f64() * 1000.0)
}

#[cfg(test)]
Expand Down Expand Up @@ -232,14 +232,7 @@ mod tests {

#[test]
fn agents_json_preserves_opaque_declared_resource_descriptors() {
let mut resource_row = row(
"hetz.worker",
State::Available,
None,
false,
None,
0,
);
let mut resource_row = row("hetz.worker", State::Available, None, false, None, 0);
resource_row.resources.push(
Resource::new(
"work".into(),
Expand Down
10 changes: 4 additions & 6 deletions src/ding/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ impl SessionWatch {
pub struct DingConfig {
/// Fallback poll cadence and liveness-check cadence.
pub poll: Duration,
/// Presence mtime refresh cadence while the target session is alive.
/// Presence heartbeat refresh cadence while the target session is alive.
pub status_refresh: Duration,
}

Expand Down Expand Up @@ -3110,11 +3110,9 @@ Enter to select · ↑/↓ to navigate · Esc to cancel";
flush_without_catalog(Some(&status_path), &mut pending, &poker);
assert_eq!(pending.len(), 1, "fresh dnd suppresses delivery");

let stale = std::time::SystemTime::now() - status::STATUS_STALE - Duration::from_secs(1);
std::fs::File::open(&status_path)
.unwrap()
.set_modified(stale)
.unwrap();
let stale_ms = crate::message::now_ms()
- u64::try_from((status::STATUS_STALE + Duration::from_secs(1)).as_millis()).unwrap();
std::fs::write(&status_path, format!("dnd\nv1 {stale_ms}\n")).unwrap();
flush_without_catalog(Some(&status_path), &mut pending, &poker);
assert!(
pending.is_empty(),
Expand Down
Loading
Loading