Skip to content

Commit c745eac

Browse files
feat(resource): add demand observation protocol
agent-identity: dev3.direct.omp.536sbpvb agent-persona: generalist agent-supervisor: unavailable agent-tool: OMP agent-tool-version: 18.0.11 agent-runtime: OMP 18.0.11 tooling-profile: dotfiles@000f2b3
1 parent 9bcda78 commit c745eac

16 files changed

Lines changed: 2649 additions & 206 deletions

INVARIANTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,5 @@ materialization, messaging, DING, or presence must preserve them.
3434
| **Parked tasks are visible and individually recoverable** | A parked task is reported alongside an unmodified runtime observation as a complete known fault; only an unbelievable marker fails closed. Park markers and unpark requests share the exact canonical catalog-folder plus host ownership scope, and the projected recovery argv carries both axes, so same-host supervisors cannot see, delete, consume, or advertise recovery into each other's channels even for the same task ID. A projected park whose supervisor generation is gone is positively not parked. An explicit per-task unpark clears that task's park and spent budget so it is launchable again and stays recovered past `interval`, releases no other parked task, restarts no healthy peer, and restores the agent's derived DING. | `src/flapping.rs::unpark_restores_a_launchable_task_not_just_a_cleared_flag`; `src/flapping.rs::unpark_is_per_task_and_reports_whether_it_changed_anything`; `src/park.rs::same_host_supervisors_isolate_markers_and_requests_by_catalog`; `src/park.rs::a_marker_from_a_dead_supervisor_reads_as_not_parked`; `src/park.rs::published_parks_are_readable_and_clear_when_the_task_recovers`; `src/park.rs::an_unbelievable_marker_is_indeterminate_not_absent`; `src/park.rs::a_request_is_consumed_exactly_once`; `src/task_inventory.rs::a_parked_task_reports_its_fault_alongside_a_truthful_runtime_state`; `src/task_inventory.rs::an_unbelievable_park_marker_makes_the_envelope_incomplete`; `tests/task_inventory_cli.rs::projected_recovery_targets_its_exact_catalog_and_host_despite_ambient_defaults`; `tests/run.rs::an_operator_recovers_one_parked_task_without_disturbing_a_healthy_peer`; `tests/run.rs::an_unpark_request_for_a_task_that_is_not_parked_says_so` |
3535
| **Tracked workspaces fail closed** | Materialization simulates content operations before writing and refuses a real change to any Git-tracked target. Byte-identical tracked, untracked, and non-Git targets retain useful behavior. | `tests/materialize.rs::every_content_directive_refuses_to_change_a_tracked_target_before_any_write`; `tests/materialize.rs::byte_identical_tracked_target_is_allowed_without_modification`; `tests/materialize.rs::untracked_and_non_git_targets_remain_materializable` |
3636
| **Native flat root** | Without an authored override, catalog tasks, eval messaging, shell helpers, and DING all use the catalog itself as `ST_ROOT`; no nested bus directory is synthesized. | `src/eval_run.rs::bus_root_expands_st_root_else_defaults`; `tests/eval_run_e2e.rs::st2_eval_runs_a_benign_folder_to_a_pass_verdict`; `tests/pty.rs` |
37+
| **Demand observation is declared, fenced, settled, and clock-free** | `st2 resource refresh` reaches only the resident Resource Profile runtime and only when its catalog runtime declares `capability "demand"`. Every observe dispatch and settlement carries the exact owner, binding ID, and registration; one outstanding dispatch plus one latest trailing watermark coalesces bursts without losing in-flight arrivals. Only exact unchanged, published, failed, stale-generation, or provider-unavailable evidence closes accepted work. Writer backpressure retains queued demand, restart rescans after installing the watch, client disconnect does not cancel it, and the CLI wait bound never participates in correctness or provider scheduling. | `crates/st2-resource-protocol/src/lib.rs::host_frames_have_exact_json_shape_and_newline`; `crates/st2-resource-protocol/src/lib.rs::runtime_frames_have_exact_json_shape_and_padded_base64`; `src/resource_profile_supervisor.rs::demand_batches_coalesce_bursts_and_keep_one_trailing_watermark`; `src/resource_profile_supervisor.rs::full_writer_queue_is_reported_without_consuming_the_frame`; `tests/resource_profile_supervisor_e2e.rs::demand_observation_is_watermark_settled_coalesced_and_restart_safe`; `tests/agent_resource.rs::refresh_cli_reports_exact_receipts_and_wait_expiry_keeps_the_request` |
3738
| **Proof references resolve** | Every qualified test named in this table exists in its named source file, so stale invariant claims fail the suite instead of silently surviving a refactor. | `tests/invariants.rs::qualified_proof_references_resolve` |

crates/st2-resource-protocol/src/lib.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub const MAX_PROTOCOL_LINE_BYTES: usize = 2 * 1024 * 1024;
1313
pub const MAX_SNAPSHOT_BYTES: usize = 1024 * 1024;
1414
pub const MAX_SELECTOR_BYTES: usize = 16 * 1024;
1515
pub const MAX_HEALTH_DETAIL_BYTES: usize = 16 * 1024;
16+
pub const MAX_OBSERVATION_DIAGNOSTIC_BYTES: usize = 16 * 1024;
1617
const MAX_OPAQUE_ID_BYTES: usize = 16 * 1024;
1718

1819
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -213,7 +214,7 @@ fn encode_base64(bytes: &[u8]) -> String {
213214
}
214215

215216
fn decode_base64(encoded: &str) -> Result<Vec<u8>, Base64Error> {
216-
if encoded.len() % 4 != 0 {
217+
if !encoded.len().is_multiple_of(4) {
217218
return Err(Base64Error("base64 length is not a multiple of four"));
218219
}
219220
let maximum_encoded = MAX_SNAPSHOT_BYTES.div_ceil(3) * 4;
@@ -372,6 +373,11 @@ pub enum HostMessage {
372373
binding_id: BindingId,
373374
registration: RegistrationToken,
374375
},
376+
Observe {
377+
owner: RuntimeOwner,
378+
binding_id: BindingId,
379+
registration: RegistrationToken,
380+
},
375381
}
376382

377383
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -383,6 +389,14 @@ pub enum RuntimeHealthState {
383389
Failed,
384390
}
385391

392+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
393+
#[serde(rename_all = "camelCase")]
394+
pub enum ObservationOutcome {
395+
Unchanged,
396+
Published,
397+
Failed,
398+
}
399+
386400
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387401
#[serde(
388402
tag = "type",
@@ -412,6 +426,17 @@ pub enum RuntimeMessage {
412426
#[serde(skip_serializing_if = "Option::is_none")]
413427
detail: Option<String>,
414428
},
429+
ObservationSettled {
430+
owner: RuntimeOwner,
431+
binding_id: BindingId,
432+
registration: RegistrationToken,
433+
demand_watermark: u64,
434+
outcome: ObservationOutcome,
435+
#[serde(skip_serializing_if = "Option::is_none")]
436+
digest: Option<SnapshotDigest>,
437+
#[serde(skip_serializing_if = "Option::is_none")]
438+
diagnostic: Option<String>,
439+
},
415440
}
416441

417442
#[derive(Debug)]
@@ -422,6 +447,8 @@ pub enum ProtocolError {
422447
LineTooLarge { actual: usize },
423448
SelectorTooLarge { actual: usize },
424449
HealthDetailTooLarge { actual: usize },
450+
ObservationDiagnosticTooLarge { actual: usize },
451+
InvalidDemandWatermark,
425452
InvalidTopics(&'static str),
426453
InvalidHealthScope,
427454
Json(serde_json::Error),
@@ -445,6 +472,13 @@ impl fmt::Display for ProtocolError {
445472
formatter,
446473
"health detail is {actual} bytes; maximum is {MAX_HEALTH_DETAIL_BYTES}"
447474
),
475+
Self::ObservationDiagnosticTooLarge { actual } => write!(
476+
formatter,
477+
"observation diagnostic is {actual} bytes; maximum is {MAX_OBSERVATION_DIAGNOSTIC_BYTES}"
478+
),
479+
Self::InvalidDemandWatermark => {
480+
formatter.write_str("observation demand watermark must be positive")
481+
}
448482
Self::InvalidTopics(reason) => write!(formatter, "invalid topics: {reason}"),
449483
Self::InvalidHealthScope => formatter
450484
.write_str("binding-scoped health must carry both bindingId and registration"),
@@ -544,6 +578,23 @@ fn validate_runtime_message(message: &RuntimeMessage) -> Result<(), ProtocolErro
544578
}
545579
Ok(())
546580
}
581+
RuntimeMessage::ObservationSettled {
582+
demand_watermark,
583+
diagnostic,
584+
..
585+
} => {
586+
if *demand_watermark == 0 {
587+
return Err(ProtocolError::InvalidDemandWatermark);
588+
}
589+
if let Some(diagnostic) = diagnostic
590+
&& diagnostic.len() > MAX_OBSERVATION_DIAGNOSTIC_BYTES
591+
{
592+
return Err(ProtocolError::ObservationDiagnosticTooLarge {
593+
actual: diagnostic.len(),
594+
});
595+
}
596+
Ok(())
597+
}
547598
}
548599
}
549600

@@ -611,6 +662,16 @@ mod tests {
611662
encode_host_line(&unregister).unwrap(),
612663
b"{\"type\":\"unregister\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\"}\n"
613664
);
665+
666+
let observe = HostMessage::Observe {
667+
owner: owner(),
668+
binding_id: BindingId::new("binding").unwrap(),
669+
registration: RegistrationToken::new("registration").unwrap(),
670+
};
671+
assert_eq!(
672+
encode_host_line(&observe).unwrap(),
673+
b"{\"type\":\"observe\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\"}\n"
674+
);
614675
}
615676

616677
#[test]
@@ -630,6 +691,19 @@ mod tests {
630691
encode_runtime_line(&health).unwrap(),
631692
b"{\"type\":\"health\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"state\":\"ready\"}\n"
632693
);
694+
let settled = RuntimeMessage::ObservationSettled {
695+
owner: owner(),
696+
binding_id: BindingId::new("binding").unwrap(),
697+
registration: RegistrationToken::new("registration").unwrap(),
698+
demand_watermark: 7,
699+
outcome: ObservationOutcome::Published,
700+
digest: Some(SnapshotDigest::of(b"one byte")),
701+
diagnostic: None,
702+
};
703+
assert_eq!(
704+
encode_runtime_line(&settled).unwrap(),
705+
b"{\"type\":\"observationSettled\",\"owner\":{\"incarnation\":\"incarnation\",\"claim\":\"claim\"},\"bindingId\":\"binding\",\"registration\":\"registration\",\"demandWatermark\":7,\"outcome\":\"published\",\"digest\":\"23c70185c68c8fa6326887db7feb4bb951c1cad2a7b2a9acf70c3d2e466aced0\"}\n"
706+
);
633707
assert_eq!(
634708
decode_runtime_line(&encode_runtime_line(&publish(b"one byte")).unwrap()).unwrap(),
635709
publish(b"one byte")
@@ -658,6 +732,16 @@ mod tests {
658732
decode_host_line(uppercase_digest),
659733
Err(ProtocolError::Json(_))
660734
));
735+
let unknown_settlement_field = b"{\"type\":\"observationSettled\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\"},\"bindingId\":\"b\",\"registration\":\"r\",\"demandWatermark\":1,\"outcome\":\"unchanged\",\"extra\":true}\n";
736+
assert!(matches!(
737+
decode_runtime_line(unknown_settlement_field),
738+
Err(ProtocolError::Json(_))
739+
));
740+
let zero_watermark = b"{\"type\":\"observationSettled\",\"owner\":{\"incarnation\":\"i\",\"claim\":\"c\"},\"bindingId\":\"b\",\"registration\":\"r\",\"demandWatermark\":0,\"outcome\":\"failed\"}\n";
741+
assert!(matches!(
742+
decode_runtime_line(zero_watermark),
743+
Err(ProtocolError::InvalidDemandWatermark)
744+
));
661745
}
662746

663747
#[test]
@@ -758,6 +842,19 @@ mod tests {
758842
decode_runtime_line(&health_line),
759843
Err(ProtocolError::HealthDetailTooLarge { .. })
760844
));
845+
let settlement = RuntimeMessage::ObservationSettled {
846+
owner: owner(),
847+
binding_id: BindingId::new("binding").unwrap(),
848+
registration: RegistrationToken::new("registration").unwrap(),
849+
demand_watermark: 1,
850+
outcome: ObservationOutcome::Failed,
851+
digest: None,
852+
diagnostic: Some("x".repeat(MAX_OBSERVATION_DIAGNOSTIC_BYTES + 1)),
853+
};
854+
assert!(matches!(
855+
encode_runtime_line(&settlement),
856+
Err(ProtocolError::ObservationDiagnosticTooLarge { .. })
857+
));
761858
assert!(RuntimeIncarnation::new("x".repeat(MAX_OPAQUE_ID_BYTES + 1)).is_err());
762859
}
763860

docs/vrs/.decisions/0014-resource-profiles-are-state-first-read-and-observe-capabilities.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ Two prototypes bounded the design. A synthetic 16-observation pull-request seque
2222
6. The descriptor declares shared or per-binding runtime topology. Both use one normalized host protocol and the same per-binding delivery state.
2323
7. When delivery is unavailable, st2 retains only `pending_relevant_change` beside current and last-delivered digests. Resume emits at most one invalidation for current state.
2424
8. The initial capability set stops at read and observe. Provider mutations, actions, approvals, and a canonical event log require separate research and design.
25+
9. st2 may carry a generic declared, fenced demand-observation scheduling hint
26+
to the resident profile runtime. The profile still owns polling, push,
27+
subscription, provider mechanism, cursor, rate limits, cache, and backoff.
28+
Demand is neither a provider-specific reconcile command nor a provider
29+
write, and exact settlement evidence rather than a host clock closes it.
2530

2631
## Options
2732

@@ -71,7 +76,40 @@ claim, each binding registration receives a token, and host acceptance requires
7176
both to match. A new claim fences all prior output and clears registrations.
7277
Shared and per-binding topologies use the same reducer.
7378

74-
The normalized wire protocol contains only `register`, `unregister`, `publish`,
75-
and `health`. EOF and existing supervisor lifecycle replace `shutdown`;
76-
implementation-owned observation replaces host `reconcile`. Evidence:
79+
The initial normalized wire protocol contained only `register`, `unregister`,
80+
`publish`, and `health`. EOF and existing supervisor lifecycle replace
81+
`shutdown`; implementation-owned observation replaces provider-specific host
82+
`reconcile`. Evidence:
7783
[selector and runtime protocol prototype](../07-resource-profile/.experiments/2026-08-29-selector-and-runtime-protocol-prototype.md).
84+
85+
## Amendment 2: declared demand observation
86+
87+
Generic demand observation extends the same state-first read boundary without
88+
transferring observation ownership to st2. A catalog runtime opts into one
89+
atomic `demand` capability. Only then may the host send fenced `observe`; the
90+
runtime answers a demand-initiated cycle with fenced `observationSettled`
91+
carrying its positive demand watermark and exact `unchanged`, `published`, or
92+
`failed` outcome. The gate protects older strict runtimes from both halves of
93+
the vocabulary.
94+
95+
The resident Resource Profile supervisor remains the sole provider-process
96+
owner. It resolves the exact active owner, binding ID, and registration,
97+
retains one outstanding dispatch and one latest trailing batch, and closes work
98+
only from settlement, re-registration, or provider-process failure evidence.
99+
There is no correctness timer. A client wait bound does not cancel, retract, or
100+
duplicate accepted demand.
101+
102+
The scheduling state is level-triggered: demand arriving during an observation
103+
survives completion and collapses into one later cycle. The runtime remains
104+
free to defer that cycle under its own backoff and retains every provider
105+
cursor, conditional cache, rate-limit decision, and normal observation
106+
schedule. Thus `observe` is not the rejected host `reconcile`; it cannot name a
107+
provider action, reset provider state, or mutate the Resource.
108+
109+
The local client channel uses bounded strict atomic request and receipt records
110+
inside the existing private `(catalog, host)` supervisor scope. The watch is
111+
installed before startup scan and only final basenames are consumed. Records
112+
are intentionally not fsynced: power loss may yield missing evidence and a
113+
retry, never false success. Exact wire, coalescing, trailing demand,
114+
re-registration, restart, disconnect, capability-gate, and CLI outcome proofs
115+
live with the implementation.

docs/vrs/07-resource-profile/requirements.md

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -190,12 +190,17 @@ one latest-state catch-up. The direction is recorded in
190190
authority after missed, duplicated, reordered, or coalesced provider
191191
observations.
192192
- **PROFILE-R15 Implementation-owned observation:** A profile implementation
193-
chooses polling, push, native subscription, or a hybrid and may retain its own
194-
provider cursor. st2 standardizes registration, atomic snapshot publication,
195-
backpressure, cancellation, and health outcomes but does not send
196-
observation-specific reconcile commands or prescribe the provider mechanism.
197-
Provider payloads never bypass snapshot publication to become canonical
198-
delivery records.
193+
chooses polling, push, native subscription, or a hybrid and retains its own
194+
provider mechanism, cursor, rate-limit state, cache, backoff, and repair
195+
policy. A catalog runtime may separately declare the generic fenced demand
196+
capability. When declared, st2 may issue a coalesced demand-observation
197+
scheduling hint and receive exact unchanged, published, or failed settlement
198+
evidence; the hint never prescribes how the observation occurs, resets
199+
provider state, or becomes a provider-specific reconcile command. st2
200+
standardizes registration, atomic snapshot publication, backpressure,
201+
cancellation, demand fencing and settlement, and health outcomes. Provider
202+
payloads never bypass snapshot publication to become canonical delivery
203+
records, and demand observation never authorizes a provider write.
199204
- **PROFILE-R16 Declared runtime topology:** The descriptor declares either one
200205
shared runtime per catalog and exact scheme or one runtime per active binding.
201206
Both modes use one host protocol and per-binding lifecycle state. Each runtime
@@ -207,9 +212,10 @@ one latest-state catch-up. The direction is recorded in
207212
- **PROFILE-R16A Finite protocol and publication bounds:** A selector's
208213
canonical compact JSON is at most 16 KiB. One encoded runtime-protocol line is
209214
at most 2 MiB including its newline. Decoded snapshot bytes are at most 1 MiB.
210-
Health detail is at most 16 KiB of UTF-8. st2 rejects an oversized value
211-
without truncation and contains the failure to the affected runtime or
212-
binding.
215+
Health detail and demand-settlement diagnostic are each at most 16 KiB of
216+
UTF-8. Local demand request and receipt records are bounded, strict schemas
217+
with plain final basenames. st2 rejects an oversized value without truncation
218+
and contains the failure to the affected runtime or binding.
213219

214220
### Must bound attention and catch up to current state
215221

0 commit comments

Comments
 (0)