Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/instance_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,105 @@ fn cleanup_stale_remote_instances(db: &HcomDb) {
}
}

/// Detect and clean up instances whose processes died (e.g. system reboot).
///
/// After a reboot, hcom instances with PIDs in the DB are stale — their
/// processes no longer exist. This function finds them, saves a stopped
/// snapshot for resume, and removes the dead row. Hook-based agents without
/// tracked PIDs are handled by the existing heartbeat staleness detection
/// the next time `hcom list` runs.
///
/// Returns the number of instances marked dead.
pub fn mark_dead_instances(db: &HcomDb) -> i32 {
let Ok(instances) = db.iter_instances_full() else {
return 0;
};
let mut marked = 0;

for inst in &instances {
if inst.status == ST_INACTIVE || inst.status == ST_LAUNCHING {
continue;
}

let is_remote = inst.origin_device_id.as_deref().is_some_and(|v| !v.is_empty());
if is_remote {
continue;
}

let pid = match inst.pid {
Some(p) if p > 0 => p as u32,
_ => continue,
};

if crate::pidtrack::is_alive(pid) {
continue;
}

let snapshot = serde_json::json!({
"name": inst.name,
"transcript_path": inst.transcript_path,
"session_id": inst.session_id,
"tool": inst.tool,
"directory": inst.directory,
"parent_name": inst.parent_name,
"tag": inst.tag,
"wait_timeout": inst.wait_timeout,
"subagent_timeout": inst.subagent_timeout,
"hints": inst.hints,
"pid": inst.pid,
"created_at": inst.created_at,
"background": inst.background,
"agent_id": inst.agent_id,
"launch_args": inst.launch_args,
"origin_device_id": inst.origin_device_id,
"background_log_file": inst.background_log_file,
"last_event_id": inst.last_event_id,
});

if let Some(ref session_id) = inst.session_id {
let _ = db.conn().execute(
"DELETE FROM session_bindings WHERE session_id = ?",
rusqlite::params![session_id],
);
let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE session_id = ?",
rusqlite::params![session_id],
);
}

let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE instance_name = ?",
rusqlite::params![inst.name],
);
let _ = db.delete_notify_endpoints(&inst.name);
let _ = db.cleanup_subscriptions(&inst.name);

if db
.log_life_event(
&inst.name,
"stopped",
"system",
"exit:reboot",
Some(snapshot),
)
.is_ok()
{
let _ = db.delete_instance(&inst.name);
marked += 1;
crate::log::log_info(
"lifecycle",
"mark_dead",
&format!(
"instance={} pid={} tool={}",
inst.name, pid, inst.tool,
),
);
}
}

marked
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
15 changes: 15 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ fn main() -> Result<()> {
log::log_error("native", "panic", &format!("{} at {}", message, location));
}));

// Detect and clean up instances whose processes died (e.g. system reboot).
// Runs after config init so logging is available, before dispatch so the
// TUI/CLI see accurate state. Only operates when there is a DB to check.
// Best-effort: failures are logged and don't block startup.
if let Ok(db) = crate::db::HcomDb::open() {
let count = instance_lifecycle::mark_dead_instances(&db);
if count > 0 {
log::log_info(
"startup",
"mark_dead",
&format!("marked {} dead instance(s) from previous session", count),
);
}
}

// Dispatch via router (replaces manual MainAction matching)
router::dispatch()
}
Expand Down