Skip to content
Merged
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
58 changes: 49 additions & 9 deletions src/delivery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2005,7 +2005,10 @@ pub fn run_delivery_loop(

let owns_instance = instance_owns_process_binding(db, &process_id, &current_name);

if matches!(Tool::from_str(&config.tool), Ok(Tool::Antigravity)) {
if matches!(
Tool::from_str(&config.tool),
Ok(Tool::Antigravity | Tool::Omp)
) {
antigravity::cleanup_antigravity_pty_exit(db, &current_name, &process_id, owns_instance);
} else {
cleanup_pty_exit_default(db, &current_name, &process_id, owns_instance);
Expand Down Expand Up @@ -2085,6 +2088,25 @@ pub(crate) fn cleanup_deleted_instance(db: &mut HcomDb, current_name: &str) {
}
}

/// Log why PTY exit cleanup was skipped when this thread no longer owns the instance.
pub(crate) fn log_pty_cleanup_skipped(db: &HcomDb, current_name: &str) {
let reason = if db
.get_status(current_name)
.ok()
.flatten()
.is_some_and(|(status, _)| status == ST_INACTIVE)
{
"instance inactive (soft-finalize); process binding cleared or reassigned"
} else {
"name reassigned to new process"
};
log_info(
"native",
"delivery.cleanup_skipped",
&format!("Skipping instance cleanup for {current_name} — {reason}"),
);
}

fn cleanup_pty_exit_default(
db: &mut HcomDb,
current_name: &str,
Expand All @@ -2094,14 +2116,7 @@ fn cleanup_pty_exit_default(
if owns_instance {
cleanup_deleted_instance(db, current_name);
} else {
log_info(
"native",
"delivery.cleanup_skipped",
&format!(
"Skipping instance cleanup for {} — name reassigned to new process",
current_name
),
);
log_pty_cleanup_skipped(db, current_name);
}

if !process_id.is_empty()
Expand Down Expand Up @@ -2231,6 +2246,31 @@ mod tests {
assert_eq!(events, vec![("samu".to_string(), "killed".to_string())]);
}

#[test]
fn soft_stopped_instance_survives_pty_exit_cleanup() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let mut db = HcomDb::open_raw(&db_path).unwrap();
db.init_db().unwrap();
db.conn()
.execute(
"INSERT INTO instances (name, tool, status, status_context, status_time, created_at, session_id)
VALUES ('luna', 'omp', 'inactive', 'exit:turn_end', 0, 0, 'sid-soft')",
[],
)
.unwrap();
db.set_process_binding("pid-soft", "sid-soft", "luna")
.unwrap();

antigravity::cleanup_antigravity_pty_exit(&mut db, "luna", "pid-soft", true);

assert!(db.get_instance_full("luna").unwrap().is_some());
assert_eq!(
db.get_status("luna").unwrap().map(|(s, _)| s),
Some(ST_INACTIVE.to_string())
);
}

// ---- phase-1 ownership tests ----

#[test]
Expand Down
9 changes: 1 addition & 8 deletions src/delivery/antigravity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,7 @@ pub(crate) fn cleanup_antigravity_pty_exit(
owns_instance: bool,
) {
if !owns_instance {
log_info(
"native",
"delivery.cleanup_skipped",
&format!(
"Skipping instance cleanup for {} — name reassigned to new process",
current_name
),
);
super::log_pty_cleanup_skipped(db, current_name);
} else {
let already_inactive = db
.get_status(current_name)
Expand Down
81 changes: 70 additions & 11 deletions src/hooks/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1199,13 +1199,17 @@ fn stop_instance_inner(
/// agy's real teardown is the PTY exit (`cleanup_antigravity_pty_exit`), which sees
/// the inactive status and preserves the row for `hcom r`.
///
/// Clears session/process bindings and logs a stopped life event with snapshot, but
/// does not delete the instance row.
/// Clears session bindings (and process bindings unless `keep_process_binding`),
/// and logs a stopped life event with snapshot, but does not delete the instance row.
///
/// OMP soft-stop passes `keep_process_binding: true` so the live process can rebind
/// via `bind_session_to_process` on the next turn. Antigravity passes `false`.
pub fn soft_finalize_session(
db: &HcomDb,
instance_name: &str,
reason: &str,
updates: Option<&serde_json::Map<String, Value>>,
keep_process_binding: bool,
) {
log::log_info(
"hooks",
Expand Down Expand Up @@ -1256,17 +1260,21 @@ pub fn soft_finalize_session(
"DELETE FROM session_bindings WHERE session_id = ?",
params![session_id],
);
let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE session_id = ?",
params![session_id],
);
if !keep_process_binding {
let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE session_id = ?",
params![session_id],
);
}
}

let _ = db.delete_notify_endpoints(instance_name);
let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE instance_name = ?",
params![instance_name],
);
if !keep_process_binding {
let _ = db.conn().execute(
"DELETE FROM process_bindings WHERE instance_name = ?",
params![instance_name],
);
}
let _ = db.cleanup_subscriptions(instance_name);

if let Err(e) = db.log_life_event(
Expand Down Expand Up @@ -1945,7 +1953,15 @@ mod tests {
)
.unwrap();

soft_finalize_session(&db, "vine", "unknown", None);
db.conn()
.execute(
"INSERT INTO process_bindings (process_id, session_id, instance_name, updated_at)
VALUES ('pid-soft', 'sess-soft-1', 'vine', ?1)",
rusqlite::params![now],
)
.unwrap();

soft_finalize_session(&db, "vine", "unknown", None, false);

assert!(db.get_instance_full("vine").unwrap().is_some());
let status = db.get_status("vine").unwrap().map(|(s, _)| s);
Expand All @@ -1957,5 +1973,48 @@ mod tests {
.as_deref(),
Some("vine")
);
assert_eq!(db.get_process_binding("pid-soft").unwrap(), None);
}

#[test]
fn soft_finalize_session_can_keep_process_binding() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.db");
let db = crate::db::HcomDb::open_raw(&db_path).unwrap();
db.init_db().unwrap();
let now = chrono::Utc::now().timestamp() as f64;
db.conn()
.execute(
"INSERT INTO instances (name, status, created_at, tool, session_id)
VALUES ('luna', 'listening', ?1, 'omp', 'sess-keep')",
rusqlite::params![now],
)
.unwrap();
db.conn()
.execute(
"INSERT INTO session_bindings (session_id, instance_name, created_at)
VALUES ('sess-keep', 'luna', ?1)",
rusqlite::params![now],
)
.unwrap();
db.conn()
.execute(
"INSERT INTO process_bindings (process_id, session_id, instance_name, updated_at)
VALUES ('pid-keep', 'sess-keep', 'luna', ?1)",
rusqlite::params![now],
)
.unwrap();

soft_finalize_session(&db, "luna", "turn_end", None, true);

assert_eq!(
db.get_process_binding("pid-keep").unwrap(),
Some("luna".to_string())
);
assert_eq!(db.get_session_binding("sess-keep").unwrap(), None);
assert_eq!(
db.get_status("luna").unwrap().map(|(s, _)| s),
Some(ST_INACTIVE.to_string())
);
}
}
2 changes: 1 addition & 1 deletion src/hooks/gemini.rs
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ fn handle_sessionend(db: &HcomDb, _ctx: &HcomContext, payload: &HookPayload) ->
// that's about to take another turn. Real teardown is the PTY exit. Other gemini
// tools have a genuine SessionEnd == process death, so they hard-finalize.
if is_agy {
common::soft_finalize_session(db, &instance.name, &reason, None);
common::soft_finalize_session(db, &instance.name, &reason, None, false);
} else {
common::finalize_session(db, &instance.name, &reason, None);
}
Expand Down
Loading