Skip to content

Commit 2238fab

Browse files
fix(resync): keep live notify chains complete (#371)
Co-authored-by: Johannes Schickling <schickling.j@gmail.com>
1 parent 1310da1 commit 2238fab

7 files changed

Lines changed: 482 additions & 51 deletions

File tree

docs/vrs/06-resync/requirements.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,20 +62,22 @@ root [`R05`](../requirements.md); Resource bindings are defined by
6262

6363
- **RESYNC-R01 Watch set:** While an agent is running on this host, the
6464
supervisor watches exactly the local files denoted by that agent's active
65-
resource bindings plus its own declaration file. The watch attaches to each
66-
carrier's parent directory non-recursively and tracks directory identity so
67-
whole-file replacement by rename keeps working. Nothing outside this set is
68-
watched; installation failure degrades to timer-based digest polling rather
69-
than losing the capability.
65+
resource bindings, the opt-in ancestor carriers required by `RESYNC-R09`,
66+
plus its own declaration file. The watch attaches to each carrier's parent
67+
directory non-recursively and tracks directory identity so whole-file
68+
replacement by rename keeps working. Nothing outside this set is watched;
69+
installation failure degrades to timer-based digest polling rather than
70+
losing the capability.
7071
- **RESYNC-R02 Explicit watch contract:** The resync watcher states what it
7172
watches and why, denies everything else by default, ignores read/open
7273
access events, and never traverses payload trees — refining [`R14`](../requirements.md)
7374
and the mutation-only wakeup invariant.
74-
- **RESYNC-R03 Seeded baseline:** On supervisor start every watchable carrier
75-
is recorded as either `present(<content-digest>)` or `missing` without
76-
emitting. Only a transition between proven states produces an event; a
77-
restart alone wakes nobody. Permission and transient I/O failures establish
78-
neither state and are retried rather than interpreted as absence.
75+
- **RESYNC-R03 Seeded baseline:** On supervisor start and at every live-seat
76+
transition, every watchable carrier is recorded as either
77+
`present(<content-digest>)` or `missing` without emitting. Only a transition
78+
between proven states produces an event; a restart alone wakes nobody.
79+
Permission and transient I/O failures establish neither state and are retried
80+
rather than interpreted as absence.
7981

8082
### Must classify before notifying
8183

@@ -122,6 +124,22 @@ root [`R05`](../requirements.md); Resource bindings are defined by
122124
resumes, so resume re-observes current state silently and only later
123125
transitions notify.
124126

127+
### Must compose and install complete live views
128+
129+
- **RESYNC-R09 Opt-in supervisor-chain coverage:** When a profile declares
130+
`notify-chain`, a live agent's watch set also contains every active
131+
same-profile-scheme carrier declared by each non-retired supervisor ancestor.
132+
Traversal continues through a retired middle ancestor without including that
133+
ancestor's carrier. Profiles without the flag and native bindings retain
134+
agent-local coverage. A missing, cyclic, ambiguous, or otherwise unwalkable
135+
supervisor chain is diagnosed; it never becomes a silent guessed chain.
136+
- **RESYNC-R10 Launch-boundary completeness:** A canonical seat's complete
137+
catalog-aware watch set is installed synchronously when reconciliation first
138+
proves the seat live and before any later launch target may block. Reinstalling
139+
an already-live seat is idempotent: unchanged own and ancestor subscriptions
140+
retain their baselines and pending transitions. Full refresh remains the final
141+
authority for lifecycle removal and hot profile reload.
142+
125143
## Evidence
126144

127145
The composition (parent-directory watch → classification → carrier-state-keyed

docs/vrs/06-resync/spec.md

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@ falls back to a guessed local rule or stops the supervisor. Watchability of
4848
every active binding is projected into `st2 agents --json` as part of the
4949
declared Resource projection so absence of coverage is observable.
5050

51+
## Supervisor-chain composition (RESYNC-R09..R10)
52+
53+
```text
54+
root goal carrier ───────────────┐
55+
v
56+
root seat <- supervisor - lead seat <- supervisor - worker seat
57+
goal carrier ────────────────> worker watch set
58+
worker goal carrier ────────────────────────────> worker watch set
59+
```
60+
61+
For each active binding whose registered profile declares `notify-chain`, watch
62+
resolution first resolves the binding's own carrier, then validates and walks
63+
the seat's supervisor chain against the pass's complete discovered spec vector.
64+
At every non-retired ancestor it resolves active bindings with the same profile
65+
scheme against that ancestor's own declaration directory. Each inherited
66+
subscription retains the ancestor's binding label qualified by owner bus ID
67+
(`goal@hetz.root`), so mutations in two layers cannot share a supersession key.
68+
A retired ancestor contributes no layer, but traversal continues to its
69+
ancestors.
70+
71+
Chain membership comes only from a profile's trusted opt-in flag and exact URI
72+
scheme. Binding labels do not define composition, native local bindings never
73+
gain it implicitly, and profiles without the flag remain agent-local. A
74+
missing, ambiguous, cyclic, cross-host, or otherwise unwalkable supervisor edge
75+
produces a reconcile diagnostic and no invented ancestor subscription. Profile
76+
resolution failures remain contained to the failed carrier.
77+
78+
The live-install API takes both the canonical seat and the complete discovered
79+
spec vector. It resolves the complete own-plus-ancestor set under the
80+
supervisor's current profile registry, sends one targeted watch-set replacement,
81+
and waits for the worker acknowledgement. Reinstalling the same identity and
82+
paths preserves carrier baselines, occurrence sequences, dirty state, and
83+
pending transitions.
84+
5185
## Classification
5286

5387
Classification is decided before a path enters the watch set:
@@ -79,18 +113,25 @@ meaningful.
79113
same-path replacement, mirroring `CatalogDeclarationWatcher`.
80114
- The watcher owns no reconcile authority: a resync mutation does not wake a
81115
full-catalog pass. It shares only the observation primitives.
82-
- After lifecycle execution, each reconcile pass atomically replaces the watch
116+
- Before lifecycle execution, each pass synchronously upserts the complete
117+
catalog-aware watch set for every successfully compiled canonical seat it
118+
observed alive. A successful canonical launch or restart performs the same
119+
acknowledged upsert at the exact live transition before execution advances
120+
to any later task. Both paths resolve supervisor-chain carriers against the
121+
complete discovered spec vector, never a one-spec view.
122+
- After lifecycle execution, each reconcile pass atomically refreshes the watch
83123
set with agents whose canonical seat was observed alive or successfully
84-
launched/restarted in that pass. Desired declarations, dead keep-retained
85-
seats, and companion-only launches never become watched. If strict discovery
86-
temporarily rejects a declaration whose exact canonical seat remains
87-
observed alive, its prior declaration subscription survives with its carrier
88-
state and pending transition; it drops as soon as that seat is not live.
89-
Existing valid subscriptions are matched by declaration path and binding
90-
label. Each refresh takes bus id, canonical seat id, carrier path, label, and
91-
class from the current declaration while retaining carrier state, the
92-
per-subscription occurrence sequence, any immutable pending transition, and
93-
dirty state; only new subscriptions seed silently with sequence zero.
124+
launched/restarted in that pass. This refresh owns removals, malformed
125+
declaration retention, and hot profile replacement. Desired declarations,
126+
dead keep-retained seats, and companion-only launches never become watched.
127+
If strict discovery temporarily rejects a declaration whose exact canonical
128+
seat remains observed alive, its prior declaration subscription survives with
129+
its carrier state and pending transition; it drops as soon as that seat is
130+
not live. Existing valid subscriptions are matched by declaration path and
131+
binding label. Each refresh takes bus id, canonical seat id, carrier path,
132+
label, and class from the current declaration while retaining carrier state,
133+
the per-subscription occurrence sequence, any immutable pending transition,
134+
and dirty state; only new subscriptions seed silently with sequence zero.
94135
- A previously blind path is state-diffed both before and after its recovered
95136
parent watch is registered, closing the poll-to-registration gap.
96137
- Installation failure degrades to timer-based carrier polling over the watch
@@ -149,3 +190,5 @@ ring.
149190
- No remote/non-local carrier watching.
150191
- No write attribution beyond static classes (RESYNC-T01).
151192
- No catch-up replay of missed changes (RESYNC-T03).
193+
- No implicit supervisor-chain inference for native bindings or profiles that
194+
do not declare `notify-chain`.

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,15 @@ accepted rationale is recorded in
120120
`catalog.kdl` that names them and retires old module bytes only after that
121121
declaration stops naming them.
122122

123+
### Must opt into chain notification
124+
125+
- **PROFILE-R11 Explicit chain notification:** A profile may opt into
126+
supervisor-chain notification. For a binding through that profile, resync
127+
includes active same-scheme carriers from every non-retired supervisor
128+
ancestor in the descendant's watch set. The flag is false by default, does
129+
not change path containment or task launch, and an invalid supervisor chain
130+
is reported rather than silently approximated.
131+
123132
## Evidence
124133

125134
The mechanism choice and sandbox bounds are supported by the

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

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,14 @@ modules, and the handoff of resolved carriers to
1515
remote access, Agent Spec binding grammar, event delivery, or task lifecycle.
1616
Those remain downstream profile concerns or existing root contracts.
1717

18-
## Architecture (PROFILE-R01..R10)
18+
## Architecture (PROFILE-R01..R11)
1919

2020
```text
2121
Agent Spec resource URI (opaque, byte-preserved)
2222
|
2323
v exact RFC 3986 scheme
2424
<catalog>/catalog.kdl
25-
profile "<scheme>" { wasm "<module>"; class "<class>"; }
25+
profile "<scheme>" { wasm "<module>"; class "<class>"; notify-chain #true; }
2626
|
2727
+--> catalog-relative module -- normalized no-follow projection
2828
| + catalog root hash / transaction
@@ -92,6 +92,7 @@ catalog {
9292
profile "dev.schickling.agent-goal" {
9393
wasm "resolvers/agent-goal.wasm"
9494
class "immediate"
95+
notify-chain #true
9596
}
9697
```
9798

@@ -101,20 +102,22 @@ Grammar:
101102
profile <non-empty-scheme> { # exactly one positional value; no properties
102103
wasm <non-empty-path> # exactly once
103104
class immediate|coalesced|silent # zero or one; default coalesced
105+
notify-chain <boolean> # zero or one; default false
104106
}
105107
```
106108

107109
The profile scheme follows RFC 3986: it begins with an ASCII letter, then
108110
accepts ASCII alphanumeric characters plus `+`, `-`, and `.`, and rejects `/`;
109111
lookup remains exact and case-sensitive. The profile
110-
node takes exactly one quoted positional scheme and no properties. Each child
111-
takes exactly one quoted positional value. Unknown or extra entries, unknown
112-
children, duplicate `wasm`, duplicate `class`, a missing `wasm`, unsupported
113-
class values, and duplicate profile schemes fail parsing. A literal absolute
114-
module path remains an external runtime input. Every other declaration expands
115-
`$CATALOG` and environment variables, resolves lexically against the catalog
116-
root, and must remain strictly beneath that root; internal `.`/`..` components
117-
normalize away, while traversal outside the root fails validation.
112+
node takes exactly one quoted positional scheme and no properties. `wasm` and
113+
`class` each take one quoted positional value; `notify-chain` takes one boolean.
114+
Unknown or extra entries, unknown children, duplicate children, a missing
115+
`wasm`, unsupported class values, and duplicate profile schemes fail parsing.
116+
A literal absolute module path remains an external runtime input. Every other
117+
declaration expands `$CATALOG` and environment variables, resolves lexically
118+
against the catalog root, and must remain strictly beneath that root; internal
119+
`.`/`..` components normalize away, while traversal outside the root fails
120+
validation.
118121

119122
`st2 validate` reports malformed declarations and missing or unsafe
120123
catalog-relative modules. `st2 up` loads declared profiles before it spawns
@@ -271,7 +274,7 @@ feature. Default binaries therefore retain the baseline dependency surface.
271274
Building a Rust guest for `wasm32-unknown-unknown` requires `lld`, which the
272275
repository dev shell supplies.
273276

274-
## Resync composition (PROFILE-R08..R09)
277+
## Resync composition (PROFILE-R08..R09, PROFILE-R11)
275278

276279
For each active Resource binding, resync applies this precedence:
277280

@@ -288,6 +291,17 @@ After a path enters the watch set, Resource Profiles add no event semantics.
288291
Parent-directory observation, rename replacement, digest seeding, equal-byte
289292
deduplication, deterministic transition identity, bounded windows, and built-in
290293
`resync` delivery remain the [`06-resync`](../06-resync/spec.md) pipeline.
294+
295+
`notify-chain #true` extends only subscription selection. For each active
296+
binding through that profile, resync validates the bound agent's supervisor
297+
chain against the complete catalog and adds every active same-scheme carrier
298+
declared by non-retired ancestors. Each ancestor URI is resolved unchanged
299+
against that ancestor's own directory and remains inside that directory's host
300+
containment root. Retired ancestors are skipped without severing traversal.
301+
Owner-qualified event keys keep layers independent. An invalid chain or failed
302+
ancestor resolution produces a reconcile warning; st2 never synthesizes a URI
303+
or silently claims complete chain coverage. The default `false` preserves the
304+
agent-local behavior above.
291305
Profile resolution is observation metadata only and never enters task launch
292306
targets.
293307

src/resync.rs

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -584,9 +584,15 @@ impl ResyncSupervisor {
584584
/// Synchronously install one newly proven-live canonical seat before reconciliation advances
585585
/// to another launch target. The acknowledgement closes the gap between a successful spawn and
586586
/// the worker's silent baseline seed; later full refreshes still own removals and malformed
587-
/// declaration retention. Profile resolution uses the supervisor's current registry and
588-
/// returns contained resolver failures for the reconcile report.
589-
pub fn install_live(&self, spec: &AgentSpec, this_host: &str) -> Vec<String> {
587+
/// declaration retention. Profile and supervisor-chain resolution use the supervisor's current
588+
/// registry and the pass's complete catalog view, and return contained resolver failures for the
589+
/// reconcile report.
590+
pub fn install_live(
591+
&self,
592+
spec: &AgentSpec,
593+
specs: &[AgentSpec],
594+
this_host: &str,
595+
) -> Vec<String> {
590596
if spec.resolved_host(this_host) != this_host || !spec.desired_state.is_running() {
591597
return Vec::new();
592598
}
@@ -595,15 +601,7 @@ impl ResyncSupervisor {
595601
.profiles
596602
.lock()
597603
.unwrap_or_else(std::sync::PoisonError::into_inner);
598-
// Only this spec is in hand, so a `notify-chain` binding resolves no ancestor
599-
// carriers here; the next full refresh adds them. Those subscriptions seed silently,
600-
// so an ancestor carrier change inside that window is not announced at all.
601-
resolve_watch_set(
602-
spec,
603-
std::slice::from_ref(spec),
604-
this_host,
605-
&profiles.begin_refresh(),
606-
)
604+
resolve_watch_set(spec, specs, this_host, &profiles.begin_refresh())
607605
};
608606
let (ack_tx, ack_rx) = channel();
609607
if self

0 commit comments

Comments
 (0)