Skip to content

Commit 1310da1

Browse files
feat(resync): notify-chain subscribes a binding to its ancestors' layers (#367)
Resync notifies a carrier's owner. For a Resource Profile whose layers compose along the supervisor edge, that leaves every descendant's effective view dependent on carriers nothing tells it about: when an ancestor's layer changes, the descendant keeps acting on the view it read at launch. Add `notify-chain` to a profile declaration. A binding through such a profile also subscribes to the same-scheme carriers its supervisor ancestors declare, resolved entirely in resolve_watch_set — subscriptions, coalescing, digest gating, admission and delivery are untouched. The walk reuses each ancestor's own declared URI against that ancestor's own directory rather than synthesizing a subject: st2 owns no profile's URI grammar, and this is the identical call the ancestor's own subscription makes, so containment holds unchanged. Matching is by profile scheme, never by binding label, because labels are agent-local and replaceable. Each ancestor's layer carries the key `<label>@<ancestor-bus-id>` so two ancestors cannot collapse onto one supersession key. Retired ancestors are skipped and the walk continues through them to the root, honoring both declaration spellings. The traversal DING already needed is lifted into src/supervisor_chain.rs rather than copied a third time. Implements the design in #365. agent-identity: dev3.direct.claude.gvacdkt7 agent-persona: generalist agent-supervisor: unavailable agent-tool: Claude Code agent-tool-version: 2.1.237 agent-runtime: Claude Code 2.1.237 tooling-profile: dotfiles@cab57ad
1 parent 92bd954 commit 1310da1

8 files changed

Lines changed: 480 additions & 56 deletions

File tree

crates/agent-spec/src/profile.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ impl std::fmt::Display for ProfileClass {
7373
pub struct ResourceProfile {
7474
pub scheme: String,
7575
pub source: ProfileSource,
76+
/// When set, a binding through this profile also subscribes to the same-scheme carriers its
77+
/// declared `supervisor` ancestors bind. Off by default: only a profile whose layers compose
78+
/// along that edge has any reason to widen a subscription past the binding agent.
79+
pub notify_chain: bool,
7680
}
7781

7882
/// Where one profile's denotation comes from. Wasm-only per Q10.
@@ -103,6 +107,7 @@ impl ResourceProfile {
103107
class,
104108
containment_root: None,
105109
},
110+
notify_chain: false,
106111
}
107112
}
108113

@@ -122,9 +127,22 @@ impl ResourceProfile {
122127
class,
123128
containment_root: Some(containment_root),
124129
},
130+
notify_chain: false,
125131
}
126132
}
127133

134+
/// Declare that carriers of this profile notify along the `supervisor` chain.
135+
#[must_use]
136+
pub fn with_notify_chain(mut self, notify_chain: bool) -> Self {
137+
self.notify_chain = notify_chain;
138+
self
139+
}
140+
141+
/// Whether this profile's bindings subscribe to their ancestors' same-scheme carriers.
142+
pub fn notify_chain(&self) -> bool {
143+
self.notify_chain
144+
}
145+
128146
/// The declared class for carriers this profile resolves.
129147
pub fn class(&self) -> ProfileClass {
130148
match &self.source {

flake.nix

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,8 @@
171171
"--test"
172172
"resync"
173173
"--test"
174+
"resync_notify_chain"
175+
"--test"
174176
"profile_wasm"
175177
];
176178
});

src/catalog.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ pub struct DeclaredProfile {
3939
pub wasm: String,
4040
/// How carriers resolved through this profile notify; defaults to coalesced.
4141
pub class: ProfileClass,
42+
/// Whether a binding through this profile also subscribes to its ancestors' same-scheme
43+
/// carriers; defaults to off.
44+
pub notify_chain: bool,
4245
}
4346

4447
/// What `<catalog>/catalog.kdl` declares. An absent file leaves every field empty.
@@ -159,6 +162,8 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result<DeclaredProfile> {
159162
let mut wasm: Option<String> = None;
160163
let mut class = ProfileClass::Coalesced;
161164
let mut seen_class = false;
165+
let mut notify_chain = false;
166+
let mut seen_notify_chain = false;
162167
for child in children.nodes() {
163168
if child.children().is_some() {
164169
anyhow::bail!(
@@ -204,8 +209,20 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result<DeclaredProfile> {
204209
)
205210
})?;
206211
}
212+
"notify-chain" => {
213+
if seen_notify_chain {
214+
anyhow::bail!("profile '{scheme}' declares notify-chain more than once");
215+
}
216+
seen_notify_chain = true;
217+
notify_chain = child.get(0).and_then(|v| v.as_bool()).ok_or_else(|| {
218+
anyhow::anyhow!(
219+
"profile '{scheme}': notify-chain takes a boolean, e.g. notify-chain #true"
220+
)
221+
})?;
222+
}
207223
other => anyhow::bail!(
208-
"unknown profile field '{other}' in profile '{scheme}' (expected wasm or class)"
224+
"unknown profile field '{other}' in profile '{scheme}' \
225+
(expected wasm, class, or notify-chain)"
209226
),
210227
}
211228
}
@@ -216,6 +233,7 @@ fn parse_profile(node: &kdl::KdlNode) -> anyhow::Result<DeclaredProfile> {
216233
scheme: scheme.to_owned(),
217234
wasm,
218235
class,
236+
notify_chain,
219237
})
220238
}
221239

@@ -383,9 +401,11 @@ pub fn declared_profiles(catalog_root: &Path) -> anyhow::Result<ResourceProfileR
383401
relative,
384402
declared.class,
385403
)
404+
.with_notify_chain(declared.notify_chain)
386405
}
387406
ResolvedProfileModule::External(module) => {
388407
ResourceProfile::wasm(declared.scheme, module, declared.class)
408+
.with_notify_chain(declared.notify_chain)
389409
}
390410
};
391411
Ok(registry.with_profile(profile))
@@ -497,11 +517,13 @@ mod tests {
497517
scheme: "dev.example.goal".into(),
498518
wasm: "resolvers/goal.wasm".into(),
499519
class: ProfileClass::Coalesced,
520+
notify_chain: false,
500521
},
501522
DeclaredProfile {
502523
scheme: "dev.example.tree".into(),
503524
wasm: "/abs/resolvers/tree.wasm".into(),
504525
class: ProfileClass::Silent,
526+
notify_chain: false,
505527
},
506528
]
507529
);

src/ding/mod.rs

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ mod harness;
2828
use crate::message::{self, Message};
2929
use crate::run::{CAPTURE_CAP_BYTES, read_bounded_tail, reap_detached};
3030
use crate::status;
31+
use crate::supervisor_chain::{SUPERVISOR_CHAIN_LIMIT, chain_bus_ids, resolve_spec};
3132

3233
use composer::{ComposerState, classify_composer, classify_receipt};
3334
use harness::ReceiptState;
@@ -36,7 +37,6 @@ const BRACKETED_PASTE_START: &str = "\x1b[200~";
3637
const BRACKETED_PASTE_END: &str = "\x1b[201~";
3738
const SUBJECT_MAX_CHARS: usize = 160;
3839
const SENDER_MAX_CHARS: usize = 80;
39-
const SUPERVISOR_CHAIN_LIMIT: usize = 64;
4040
/// The marker for a declared non-agent event source. A fixed st2-chosen literal — never
4141
/// producer-supplied text — so the bounded-notice proofs are unaffected.
4242
const SOURCE_MARKER: &str = "»";
@@ -97,51 +97,6 @@ fn normalize_field(value: Option<&str>, fallback: &str, max_chars: usize) -> Str
9797
}
9898
}
9999

100-
fn resolve_spec<'a>(
101-
specs: &'a [crate::AgentSpec],
102-
identity: &str,
103-
local_host: &str,
104-
) -> Option<&'a crate::AgentSpec> {
105-
let mut matches = specs.iter().filter(|spec| {
106-
spec.bus_id(local_host) == identity
107-
|| (spec.resolved_host(local_host) == local_host && spec.identity == identity)
108-
});
109-
let resolved = matches.next()?;
110-
matches.next().is_none().then_some(resolved)
111-
}
112-
113-
#[derive(Debug, PartialEq, Eq)]
114-
enum SupervisorChainError {
115-
Cycle,
116-
MissingSupervisor,
117-
DepthLimit,
118-
}
119-
120-
fn supervisor_chain(
121-
specs: &[crate::AgentSpec],
122-
start: &crate::AgentSpec,
123-
this_host: &str,
124-
) -> Result<Vec<String>, SupervisorChainError> {
125-
let mut chain = Vec::new();
126-
let mut visited = HashSet::new();
127-
let mut current = start;
128-
129-
for _ in 0..SUPERVISOR_CHAIN_LIMIT {
130-
let bus_id = current.bus_id(this_host);
131-
if !visited.insert(bus_id.clone()) {
132-
return Err(SupervisorChainError::Cycle);
133-
}
134-
chain.push(bus_id);
135-
let Some(supervisor) = current.supervisor.as_deref() else {
136-
return Ok(chain);
137-
};
138-
current = resolve_spec(specs, supervisor, current.resolved_host(this_host))
139-
.ok_or(SupervisorChainError::MissingSupervisor)?;
140-
}
141-
142-
Err(SupervisorChainError::DepthLimit)
143-
}
144-
145100
struct RelationshipResolver {
146101
specs: Vec<crate::AgentSpec>,
147102
valid: bool,
@@ -178,10 +133,10 @@ fn relationship_marker(
178133
if sender_id == recipient_id {
179134
return "↺".to_string();
180135
}
181-
let Ok(recipient_chain) = supervisor_chain(&resolver.specs, recipient, this_host) else {
136+
let Ok(recipient_chain) = chain_bus_ids(&resolver.specs, recipient, this_host) else {
182137
return "?".to_string();
183138
};
184-
let Ok(sender_chain) = supervisor_chain(&resolver.specs, sender, this_host) else {
139+
let Ok(sender_chain) = chain_bus_ids(&resolver.specs, sender, this_host) else {
185140
return "?".to_string();
186141
};
187142

@@ -1509,8 +1464,8 @@ mod tests {
15091464
let recipient = resolve_spec(&resolver.specs, "h.recipient", "h").unwrap();
15101465

15111466
assert_eq!(
1512-
supervisor_chain(&resolver.specs, recipient, "h"),
1513-
Err(SupervisorChainError::Cycle),
1467+
chain_bus_ids(&resolver.specs, recipient, "h"),
1468+
Err(crate::supervisor_chain::SupervisorChainError::Cycle),
15141469
"cycle detection must be distinct from the independent depth limit"
15151470
);
15161471

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ pub mod resync;
4343
pub mod run;
4444
pub mod service;
4545
pub mod status;
46+
pub mod supervisor_chain;
4647
pub mod task_inventory;
4748
pub mod telemetry;
4849
pub mod validate;

0 commit comments

Comments
 (0)