Skip to content

Commit b7f9082

Browse files
feat(resync): notify-chain subscribes a binding to its ancestors' layers
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 4c0c760 commit b7f9082

8 files changed

Lines changed: 484 additions & 61 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: 9 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ const BRACKETED_PASTE_START: &str = "\x1b[200~";
3636
const BRACKETED_PASTE_END: &str = "\x1b[201~";
3737
const SUBJECT_MAX_CHARS: usize = 160;
3838
const SENDER_MAX_CHARS: usize = 80;
39-
const SUPERVISOR_CHAIN_LIMIT: usize = 64;
4039
/// The marker for a declared non-agent event source. A fixed st2-chosen literal — never
4140
/// producer-supplied text — so the bounded-notice proofs are unaffected.
4241
const SOURCE_MARKER: &str = "»";
@@ -97,51 +96,6 @@ fn normalize_field(value: Option<&str>, fallback: &str, max_chars: usize) -> Str
9796
}
9897
}
9998

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-
14599
struct RelationshipResolver {
146100
specs: Vec<crate::AgentSpec>,
147101
valid: bool,
@@ -166,22 +120,22 @@ fn relationship_marker(
166120
if !resolver.valid {
167121
return "?".to_string();
168122
}
169-
let Some(sender) = claimed_sender.and_then(|id| resolve_spec(&resolver.specs, id, this_host))
123+
let Some(sender) = claimed_sender.and_then(|id| crate::supervisor_chain::resolve_spec(&resolver.specs, id, this_host))
170124
else {
171125
return "?".to_string();
172126
};
173-
let Some(recipient) = resolve_spec(&resolver.specs, recipient, this_host) else {
127+
let Some(recipient) = crate::supervisor_chain::resolve_spec(&resolver.specs, recipient, this_host) else {
174128
return "?".to_string();
175129
};
176130
let sender_id = sender.bus_id(this_host);
177131
let recipient_id = recipient.bus_id(this_host);
178132
if sender_id == recipient_id {
179133
return "↺".to_string();
180134
}
181-
let Ok(recipient_chain) = supervisor_chain(&resolver.specs, recipient, this_host) else {
135+
let Ok(recipient_chain) = crate::supervisor_chain::chain_bus_ids(&resolver.specs, recipient, this_host) else {
182136
return "?".to_string();
183137
};
184-
let Ok(sender_chain) = supervisor_chain(&resolver.specs, sender, this_host) else {
138+
let Ok(sender_chain) = crate::supervisor_chain::chain_bus_ids(&resolver.specs, sender, this_host) else {
185139
return "?".to_string();
186140
};
187141

@@ -1506,11 +1460,11 @@ mod tests {
15061460
let message = msg("1785070000000-abc123.md", "h.loop", Some("cycle"));
15071461
let expected = "[DING] ? h.loop: cycle [id:abc123]";
15081462
let resolver = RelationshipResolver::read(catalog.path());
1509-
let recipient = resolve_spec(&resolver.specs, "h.recipient", "h").unwrap();
1463+
let recipient = crate::supervisor_chain::resolve_spec(&resolver.specs, "h.recipient", "h").unwrap();
15101464

15111465
assert_eq!(
1512-
supervisor_chain(&resolver.specs, recipient, "h"),
1513-
Err(SupervisorChainError::Cycle),
1466+
crate::supervisor_chain::chain_bus_ids(&resolver.specs, recipient, "h"),
1467+
Err(crate::supervisor_chain::SupervisorChainError::Cycle),
15141468
"cycle detection must be distinct from the independent depth limit"
15151469
);
15161470

@@ -1571,10 +1525,10 @@ mod tests {
15711525
#[test]
15721526
fn supervisor_depth_limit_fails_soft() {
15731527
let catalog = tempfile::tempdir().unwrap();
1574-
for depth in 0..=SUPERVISOR_CHAIN_LIMIT {
1528+
for depth in 0..=crate::supervisor_chain::SUPERVISOR_CHAIN_LIMIT {
15751529
let identity = format!("agent-{depth}");
15761530
let supervisor =
1577-
(depth < SUPERVISOR_CHAIN_LIMIT).then(|| format!("agent-{}", depth + 1));
1531+
(depth < crate::supervisor_chain::SUPERVISOR_CHAIN_LIMIT).then(|| format!("agent-{}", depth + 1));
15781532
declare_agent(catalog.path(), "h", &identity, supervisor.as_deref());
15791533
}
15801534

src/lib.rs

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

0 commit comments

Comments
 (0)