feat(agents): classify agents by lifecycle and reap the finished and orphaned
A mission mints a crew, and the only thing that reaped one was DELETING the mission. A mission that merely completed left its agents in the roster forever, and a crew whose reap was skipped or failed left agents bound to nothing — indistinguishable in the UI from the operator's own staff. Four states, from one query: owned no agent_template_link row → hand-created. NEVER reaped. active on a running/draft mission → working right now. Kept. completed every mission terminal → reaped after a 24h grace. orphaned minted, bound to nothing → reaped. The discriminator is `agent_template_link`, which mission_orchestrator writes per minted claw. This matters more than it looks: verified on live data, a hand-created agent and an orphaned crew member both have ZERO team links and are structurally identical by binding alone. Judging orphanhood by "no team" would delete the user's workforce. Provenance is the only honest signal. The grace window exists because the results view, the World's 24h replay and "who did this work?" all read the crew AFTER the run ends; reaping on the terminal transition deletes the answer exactly when the question gets asked. A completed crew with no usable timestamp is KEPT — a missing date must never read as "old enough to delete". Also fixes the delete summary, which reported how many claws were FOUND rather than purged: "reaped 4 claw(s)" was printed by a delete that purged none, which is precisely the log you would read while wondering why the agents are still there. It now reports purged / kept / FAILED, and failed > 0 is the orphan case. Verified against live data — all four states observed, including the two that look alike. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
eb120a10dd
commit
a494634f81
@@ -414,7 +414,9 @@ pub async fn artifact_download(
|
||||
[
|
||||
(
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
artifact.mime.unwrap_or_else(|| "application/octet-stream".into()),
|
||||
artifact
|
||||
.mime
|
||||
.unwrap_or_else(|| "application/octet-stream".into()),
|
||||
),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
@@ -898,6 +900,14 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
// all DB rows. Shared with the batch-delete reaper so this path cannot
|
||||
// drift back into skipping the container teardown.
|
||||
let provisioner = crate::runtime_provision::RuntimeProvisioner::from_env();
|
||||
// Counted, not assumed. The summary below used to report `claw_ids.len()`,
|
||||
// which is how many claws were FOUND — including every one skipped as still
|
||||
// employed and every one whose purge failed. So "reaped 4 claw(s)" was
|
||||
// printed by a delete that purged none, which is exactly the log you would
|
||||
// read while wondering why the agents are still there.
|
||||
let mut purged = 0usize;
|
||||
let mut kept = 0usize;
|
||||
let mut failed = 0usize;
|
||||
for cid in &claw_ids {
|
||||
// Only claws this mission is the LAST holder of.
|
||||
//
|
||||
@@ -921,6 +931,7 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
eprintln!(
|
||||
"missions::delete: keeping claw {cid} — {shared} other mission(s) still employ it"
|
||||
);
|
||||
kept += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -931,8 +942,12 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
cm_domain::AgentId::from(*cid),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = report.counts {
|
||||
eprintln!("missions::delete: hard_purge claw {cid} failed (continuing): {e}");
|
||||
match report.counts {
|
||||
Ok(_) => purged += 1,
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
eprintln!("missions::delete: hard_purge claw {cid} failed (continuing): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -969,10 +984,14 @@ async fn reap_mission_resources(state: &AppState, mission_id: Uuid) {
|
||||
}
|
||||
}
|
||||
|
||||
// Say what actually happened. `failed > 0` means the mission row is about to
|
||||
// be deleted while its claws survive with nothing left pointing at them —
|
||||
// the orphan case, and the only way to notice it after the fact.
|
||||
eprintln!(
|
||||
"missions::delete: reaped {} claw(s), {} team(s) for mission {mission_id}",
|
||||
claw_ids.len(),
|
||||
team_ids.len()
|
||||
"missions::delete: mission {mission_id}: {purged} claw(s) purged, {kept} kept (still \
|
||||
employed), {failed} FAILED, {} team(s) deleted, {} claw(s) considered",
|
||||
team_ids.len(),
|
||||
claw_ids.len()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1727,7 +1746,8 @@ pub async fn workforce(
|
||||
.await?;
|
||||
|
||||
let mut missions: Vec<Value> = Vec::new();
|
||||
let mut seen_mission: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut seen_mission: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for r in rows {
|
||||
let mid: String = r.get("mission_id");
|
||||
let idx = match seen_mission.get(&mid) {
|
||||
@@ -1755,7 +1775,9 @@ pub async fn workforce(
|
||||
"status": r.get::<String, _>("agent_status"),
|
||||
});
|
||||
// A claw bound to two NODES of the same mission is still one colleague.
|
||||
let list = missions[idx]["agents"].as_array_mut().expect("agents array");
|
||||
let list = missions[idx]["agents"]
|
||||
.as_array_mut()
|
||||
.expect("agents array");
|
||||
let id = agent["id"].clone();
|
||||
if !list.iter().any(|a| a["id"] == id) {
|
||||
list.push(agent);
|
||||
@@ -1863,7 +1885,8 @@ mod artifact_tests {
|
||||
"one resolver"
|
||||
);
|
||||
assert_eq!(
|
||||
src.matches(concat!("resolve_", "artifact_path(&artifact.path)")).count(),
|
||||
src.matches(concat!("resolve_", "artifact_path(&artifact.path)"))
|
||||
.count(),
|
||||
2,
|
||||
"and both routes must go through it"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user