use std::sync::Arc;
use crate::core::session::SessionId;
use crate::core::stop_spool;
use crate::daemon::api::HookPost;
use crate::daemon::state::DaemonState;
pub async fn drain_unposted_stops(state: &Arc<DaemonState>) -> usize {
let root = state.framework_root().to_path_buf();
let swept = stop_spool::sweep_stale_temp_files(&root);
if swept > 0 {
tracing::warn!(
"stop-spool: swept {swept} temp file(s) a killed hook left mid-write, older than the \
drain window (#6556)"
);
}
let parked = stop_spool::read_unposted_stops(&root);
if parked.is_empty() {
return 0;
}
let mut replayed = 0usize;
let mut discarded = 0usize;
for (path, body) in parked {
match body
.and_then(|v| serde_json::from_value::<HookPost>(v).ok())
.and_then(|post| replay_target(state, post))
{
Some(post) => {
match crate::daemon::rpc::sessions_legacy_ops::ingest_hook(state, post).await {
Ok(_) => replayed += 1,
Err(e) => {
discarded += 1;
tracing::warn!(
record = %path.display(),
"stop-spool: the daemon refused a parked SubagentStop ({e}); discarding \
it rather than retrying a body that cannot become acceptable (#6556)"
);
}
}
}
None => {
discarded += 1;
tracing::warn!(
record = %path.display(),
"stop-spool: a parked record is not a replayable SubagentStop — unreadable, \
not a stop, or naming an agent_id no live record carries; discarding it \
rather than letting it stale a sibling by agent_type (#6556)"
);
}
}
stop_spool::discard_unposted_stop(&path);
}
tracing::info!(
"stop-spool: recovered {replayed} SubagentStop record(s) the hook could not deliver, \
discarded {discarded} (#6556)"
);
replayed
}
fn replay_target(state: &Arc<DaemonState>, mut post: HookPost) -> Option<HookPost> {
if post.event != crate::core::hook::HookEvent::SubagentStop {
return None;
}
let agent_id = post.payload.get("agent_id")?.as_str()?.to_string();
let session = uuid::Uuid::parse_str(&post.session_id)
.ok()
.map(SessionId)?;
state.find_delegation(session, |d| {
d.agent_id.as_deref() == Some(agent_id.as_str()) && !d.status.is_terminal()
})?;
if let Some(obj) = post.payload.as_object_mut() {
obj.remove("idle_parking");
}
Some(post)
}
#[cfg(test)]
mod stop_spool_drain_tests {
use super::*;
use crate::core::agent::{Delegation, DelegationStatus};
use crate::core::paths::FrameworkPaths;
fn hermetic() -> (Arc<DaemonState>, tempfile::TempDir, SessionId) {
let dir = tempfile::tempdir().expect("temp dir");
let paths = FrameworkPaths::under(dir.path());
let state = Arc::new(DaemonState::with_paths(&paths));
(state, dir, SessionId(uuid::Uuid::new_v4()))
}
fn running(state: &DaemonState, session: SessionId, agent_id: &str) {
let mut d = Delegation::observed(session, "rust-engineer", "task", Some("toolu_1".into()));
d.status = DelegationStatus::Running;
d.agent_id = Some(agent_id.to_string());
state.upsert_delegation(d);
}
fn parked_body(session: SessionId, agent_id: &str) -> serde_json::Value {
serde_json::json!({
"session_id": session.0.to_string(),
"event": "SubagentStop",
"payload": {"agent_id": agent_id, "tool": "Agent"},
})
}
#[tokio::test]
async fn a_parked_stop_terminalizes_its_delegation() {
let (state, dir, session) = hermetic();
running(&state, session, "agent-1");
let root = FrameworkPaths::under(dir.path()).root;
stop_spool::record_unposted_stop(&root, &parked_body(session, "agent-1")).expect("parked");
assert_eq!(drain_unposted_stops(&state).await, 1);
let records = state.delegations_for(session);
assert_eq!(records.len(), 1);
assert!(
!records[0].status.is_live(),
"the recovered stop ended the record, status {:?}",
records[0].status
);
assert!(
stop_spool::read_unposted_stops(&root).is_empty(),
"a replayed record must not replay again"
);
}
#[tokio::test]
async fn a_replay_whose_agent_id_matches_nothing_never_stales_a_sibling() {
let (state, dir, session) = hermetic();
let mut sibling =
Delegation::observed(session, "rust-engineer", "task", Some("toolu_new".into()));
sibling.status = DelegationStatus::Running;
state.upsert_delegation(sibling);
let root = FrameworkPaths::under(dir.path()).root;
let mut body = parked_body(session, "agent-gone");
body["payload"]["agent_type"] = serde_json::json!("rust-engineer");
stop_spool::record_unposted_stop(&root, &body).expect("parked");
assert_eq!(
drain_unposted_stops(&state).await,
0,
"a stop no live record answers is not a recovery"
);
let records = state.delegations_for(session);
assert_eq!(records.len(), 1);
assert_eq!(
records[0].status,
DelegationStatus::Running,
"the newly dispatched sibling must be untouched"
);
assert!(stop_spool::read_unposted_stops(&root).is_empty());
}
#[tokio::test]
async fn a_parked_non_stop_event_is_discarded_unreplayed() {
let (state, dir, session) = hermetic();
running(&state, session, "agent-1");
let root = FrameworkPaths::under(dir.path()).root;
let mut body = parked_body(session, "agent-1");
body["event"] = serde_json::json!("PreToolUse");
stop_spool::record_unposted_stop(&root, &body).expect("parked");
assert_eq!(drain_unposted_stops(&state).await, 0);
assert!(
state.delegations_for(session)[0].status.is_live(),
"a non-stop event changes nothing"
);
}
#[tokio::test]
async fn a_replay_cannot_reassert_an_idle_parking_nudge() {
let (state, _dir, session) = hermetic();
running(&state, session, "agent-1");
let mut body = parked_body(session, "agent-1");
body["payload"]["idle_parking"] = serde_json::json!(true);
let post: HookPost = serde_json::from_value(body).expect("a hook body");
let accepted = replay_target(&state, post).expect("an exact id match replays");
assert!(
accepted.payload.get("idle_parking").is_none(),
"the nudge flag does not survive the park: {:?}",
accepted.payload
);
}
#[tokio::test]
async fn a_corrupt_record_is_discarded_rather_than_wedging_the_drain() {
let (state, dir, _session) = hermetic();
let root = FrameworkPaths::under(dir.path()).root;
let spool = stop_spool::unposted_stops_dir(&root);
std::fs::create_dir_all(&spool).expect("dir");
std::fs::write(spool.join("0000-0-0.json"), "{ not json").expect("write");
std::fs::write(spool.join("0001-0-0.json"), r#"{"event":"SubagentStop"}"#).expect("write");
assert_eq!(drain_unposted_stops(&state).await, 0);
assert!(stop_spool::read_unposted_stops(&root).is_empty());
}
#[tokio::test]
async fn an_empty_spool_drains_nothing() {
let (state, _dir, _session) = hermetic();
assert_eq!(drain_unposted_stops(&state).await, 0);
}
#[tokio::test]
async fn the_drain_sweeps_temp_litter_before_replaying() {
let (state, dir, _session) = hermetic();
let root = FrameworkPaths::under(dir.path()).root;
let spool = stop_spool::unposted_stops_dir(&root);
std::fs::create_dir_all(&spool).expect("dir");
let litter = spool.join(".tmpKilled");
std::fs::write(&litter, "half a record").expect("write");
let aged = std::time::SystemTime::now()
- std::time::Duration::from_secs(stop_spool::SPOOL_DRAIN_GRACE_SECS + 5);
std::fs::File::options()
.write(true)
.open(&litter)
.expect("open")
.set_times(std::fs::FileTimes::new().set_modified(aged))
.expect("backdate");
assert_eq!(drain_unposted_stops(&state).await, 0);
assert!(!litter.exists(), "the drain swept the litter");
}
}