Watch
0
0
Fork
You've already forked hyperhive
0

destroying an agent clears its per-agent archetype-flow switch-offs

This commit is contained in:
atlas 2026-11-11 21:22:24 +01:00 • committed by mara
commit d8703f40dc
3 changed files with 117 additions and 0 deletions

View file

@ -162,6 +162,10 @@ within about 10 seconds, and the card has no `edit` button.
just one, and to nobody until you name an agent when it has more. A custom just one, and to nobody until you name an agent when it has more. A custom
flow can also request a review, which an archetype's flow can't. Switch the flow can also request a review, which an archetype's flow can't. Switch the
archetype's flow off to replace it with your copy. archetype's flow off to replace it with your copy.
- Destroying an agent removes its per-agent switch-offs from the list, so
a new agent of the same name starts with every archetype flow on. If that
cleanup fails, the controller logs the agent and leaves the keys; destroying
the agent again retries it.
- A key for a flow or agent that no longer exists stays in the list and does nothing, so - A key for a flow or agent that no longer exists stays in the list and does nothing, so
editing an archetype never breaks the stored flows. The key stays editing an archetype never breaks the stored flows. The key stays
after its flow goes: a later flow with the same archetype and id comes up after its flow goes: a later flow with the same archetype and id comes up

View file

@ -229,6 +229,80 @@ async fn archetype_flows(client: &async_nats::Client) -> Vec<ArchetypeFlowView>
} }
} }
/// `disabled` less the switch-offs for `agent` alone (`archetype:…/…@agent`; no
/// built-in id has an `@`),
/// or `None` when it has none. The per-archetype keys stay.
fn without_agent_keys(disabled: &[String], agent: &str) -> Option<Vec<String>> {
let suffix = format!("@{agent}");
let kept: Vec<String> = disabled
.iter()
.filter(|k| !k.ends_with(&suffix))
.cloned()
.collect();
(kept.len() != disabled.len()).then_some(kept)
}
/// Remove the keys that switch archetype flows off for `agent` from the stored
/// document, in the compare-and-set loop a `PUT` uses, so a concurrent edit
/// is not lost. Answers the keys removed. No write when there are none.
async fn clear_agent_keys(client: &async_nats::Client, agent: &str) -> anyhow::Result<Vec<String>> {
let store = open(client).await?;
for _ in 0..MAX_ATTEMPTS {
let (current, revision) = stored(&store).await?;
let Stored::Document(mut doc) = current else {
return Ok(Vec::new());
};
let Some(kept) = without_agent_keys(&doc.disabled_builtins, agent) else {
return Ok(Vec::new());
};
let removed: Vec<String> = doc
.disabled_builtins
.iter()
.filter(|k| !kept.contains(k))
.cloned()
.collect();
doc.disabled_builtins = kept;
if write_revision(&store, notify_flows::KEY, &doc, revision)
.await?
.is_some()
{
return Ok(removed);
}
}
anyhow::bail!("gave up after {MAX_ATTEMPTS} conflicting writes")
}
/// Clear a destroyed agent's per-agent switch-offs, so a new agent of that
/// name starts with every archetype flow on. A failure is logged with the keys
/// left behind and does not stop the destroy.
pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) {
let cleared = async {
let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?;
clear_agent_keys(&client, agent).await
}
.await;
match cleared {
Ok(removed) if removed.is_empty() => {}
Ok(removed) => {
tracing::info!(
hive,
agent,
?removed,
"destroyed agent's flow switch-offs cleared"
);
}
Err(e) => tracing::error!(
hive,
agent,
error = %format!("{e:#}"),
"clearing this agent's flow switch-offs (`archetype:…@<agent>` in the flows \
document's disabled list) FAILED; it was destroyed anyway, so a new agent of the \
same name starts with those flows off. Declaring the agent destroyed again \
re-runs this."
),
}
}
pub(crate) fn unreadable(why: &DecodeError) -> ProblemDetails { pub(crate) fn unreadable(why: &DecodeError) -> ProblemDetails {
error_problem( error_problem(
StatusCode::CONFLICT, StatusCode::CONFLICT,
@ -653,4 +727,42 @@ mod tests {
assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{bad}"); assert_eq!(refused.status, Some(StatusCode::BAD_REQUEST), "{bad}");
} }
} }
/// Destroying an agent removes its own switch-offs from the document and
/// nothing else; a document without any is not written.
#[tokio::test]
async fn a_destroyed_agents_switch_offs_are_cleared_and_nothing_else() {
let nats = TestNats::start();
let client = nats.client().await;
let state = crate::agent_limits::tests::state(&client);
let keys = [
"archetype:a/f",
"archetype:a/f@alice",
"archetype:a/f@bob",
"archetype:a/g@alice",
"closed",
];
let Json(saved) = put_flows(State(state.clone()), Ok(body(0, &keys, vec![])))
.await
.expect("put");
let removed = clear_agent_keys(&client, "alice").await.expect("clear");
assert_eq!(removed, ["archetype:a/f@alice", "archetype:a/g@alice"]);
let Json(after) = get_flows(State(state.clone())).await.expect("get");
assert_eq!(
after.disabled_builtins,
["archetype:a/f", "archetype:a/f@bob", "closed"]
);
assert!(after.revision > saved.revision);
// Nothing of alice's left: no write.
assert!(clear_agent_keys(&client, "alice").await.unwrap().is_empty());
let Json(same) = get_flows(State(state.clone())).await.expect("get");
assert_eq!(same.revision, after.revision);
// A name that is the end of another's is not it.
assert!(clear_agent_keys(&client, "ob").await.unwrap().is_empty());
let Json(same) = get_flows(State(state)).await.expect("get");
assert_eq!(same.disabled_builtins, after.disabled_builtins);
}
} }

View file

@ -215,6 +215,7 @@ pub(crate) async fn set_agent_state(
revoke_or_complain(&hive, &agent).await; revoke_or_complain(&hive, &agent).await;
agent_permissions::clear_on_destroy(&state, &hive, &agent).await; agent_permissions::clear_on_destroy(&state, &hive, &agent).await;
agent_archetype::clear_on_destroy(&state, &hive, &agent).await; agent_archetype::clear_on_destroy(&state, &hive, &agent).await;
crate::notify_flows::clear_on_destroy(&state, &hive, &agent).await;
agents_md_initial::clear_on_destroy(&state, &hive, &agent).await; agents_md_initial::clear_on_destroy(&state, &hive, &agent).await;
egress_allow::clear_on_destroy(&state, &hive, &agent).await; egress_allow::clear_on_destroy(&state, &hive, &agent).await;
egress_deny::clear_on_destroy(&state, &hive, &agent).await; egress_deny::clear_on_destroy(&state, &hive, &agent).await;