diff --git a/crates/voro-core/migrations/0019_session_liveness_source.sql b/crates/voro-core/migrations/0019_session_liveness_source.sql new file mode 100644 index 0000000..96dc33b --- /dev/null +++ b/crates/voro-core/migrations/0019_session_liveness_source.sql @@ -0,0 +1,14 @@ +-- Which liveness source is authoritative for a session (DESIGN.md §8, task +-- #387), recorded at launch by the code that spawned the process instead of +-- inferred by reconciliation from the presence of a session ref. 'pid' means +-- the recorded pid is the work itself (a foreground child, or an agent with no +-- 'sessions' verb); 'listing' means the launch handed the work to a supervisor, +-- so only the agent's own listing can answer. Purely additive. +-- +-- Sessions open across the upgrade default to 'listing', which is what every +-- dispatch of an agent with a 'sessions' verb already was. A pre-migration +-- interactive refine round is therefore left alone rather than pid-checked — +-- unprobeable in the direction that never finalises a live session wrongly, and +-- the operator's next transition closes it. +ALTER TABLE sessions ADD COLUMN liveness_source TEXT NOT NULL DEFAULT 'listing' + CHECK (liveness_source IN ('pid','listing')); diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index 239d1d9..fd66e6c 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -18,6 +18,7 @@ use std::sync::LazyLock; use serde::Deserialize; use crate::error::{Error, Result}; +use crate::model::LivenessSource; use crate::scheduler::{AttentionCosts, DEFAULT_MAX_RUNNING}; use crate::template::{render, shell_quote}; @@ -800,6 +801,24 @@ impl ResolvedAgent { render_launch(&self.dispatch, spec, model) } + /// Which liveness source a session launched through this agent's + /// `dispatch` template must be read by (DESIGN.md §8), recorded on the + /// session row at launch. An agent defining a `sessions` verb is one whose + /// launch may hand the work to a supervisor — `claude --bg` does — leaving + /// Voro holding a launcher pid that dies at birth, so its listing is the + /// only source that can answer. An agent without the verb has no listing to + /// consult, and its spawned pid is all there is. + /// + /// This is the *headless* launch's answer, which the interactive `plan` + /// verb does not share: that one is a foreground child Voro owns, so its + /// caller records [`LivenessSource::Pid`] itself. + pub fn dispatch_liveness_source(&self) -> LivenessSource { + match self.sessions { + Some(_) => LivenessSource::Listing, + None => LivenessSource::Pid, + } + } + /// The plan template rendered the same way, when the agent defines the /// verb, with `{model}` resolved to `model_plan` falling back to `model`. /// Planning has no depth: it is interactive reasoning either way, so @@ -2457,6 +2476,37 @@ mod tests { ); } + /// What a headless launch records on its session row (DESIGN.md §8, task + /// #387): an agent with a `sessions` verb may hand the work to a supervisor, + /// so its listing is the authority; one without has only the pid Voro + /// spawned. + #[test] + fn a_sessions_verb_makes_a_launch_listing_authoritative() { + let text = r#" + [agents.supervised] + dispatch = "run --bg {prompt_file}" + sessions = "run sessions --json" + + [agents.plain] + dispatch = "run {prompt_file}" + "#; + let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap(); + assert_eq!( + config + .resolve(Some("supervised")) + .unwrap() + .dispatch_liveness_source(), + LivenessSource::Listing + ); + assert_eq!( + config + .resolve(Some("plain")) + .unwrap() + .dispatch_liveness_source(), + LivenessSource::Pid + ); + } + #[test] fn default_agent_key_sets_the_default() { let text = r#" diff --git a/crates/voro-core/src/lib.rs b/crates/voro-core/src/lib.rs index 388a53c..d6df07c 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -28,8 +28,8 @@ pub use cap::{CAP_SIGNATURES, CapReading, read_cap, strip_ansi}; pub use error::{Error, Result}; pub use import::{GithubIssue, already_imported, issue_new_task, issue_task_body}; pub use model::{ - Dep, DepKind, DepRef, Doc, Event, NextAction, Priority, Project, RefineOutcome, Repo, - RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, + Dep, DepKind, DepRef, Doc, Event, LivenessSource, NextAction, Priority, Project, RefineOutcome, + Repo, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, }; pub use pr::{Mergeability, PrPlan, PrRef, format_review_feedback, parse_mergeable, plan_pr}; pub use review::{ diff --git a/crates/voro-core/src/model.rs b/crates/voro-core/src/model.rs index ae3fcdb..6446372 100644 --- a/crates/voro-core/src/model.rs +++ b/crates/voro-core/src/model.rs @@ -199,6 +199,58 @@ impl ToSql for DepKind { } } +/// Which source of liveness is authoritative for a session (DESIGN.md §8), +/// recorded by the code that spawned the process because only it knows what it +/// spawned. The two differ in one respect: whether the pid the session row +/// holds is the work itself or a launcher that spawned it and exited. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LivenessSource { + /// The recorded pid *is* the work — a foreground child Voro owns, or an + /// agent with no `sessions` verb, where the spawned pid is the only source + /// there is. `kill -0` answers. + Pid, + /// The work belongs to a supervisor the launch handed it to, so the + /// recorded pid dies at birth and only the agent's own `sessions` listing + /// can say whether the session is still working. + Listing, +} + +impl LivenessSource { + pub const ALL: [LivenessSource; 2] = [LivenessSource::Pid, LivenessSource::Listing]; + + pub fn as_str(self) -> &'static str { + match self { + LivenessSource::Pid => "pid", + LivenessSource::Listing => "listing", + } + } + + pub fn parse(s: &str) -> Result { + Self::ALL + .into_iter() + .find(|source| source.as_str() == s) + .ok_or_else(|| Error::Invalid(format!("unknown liveness source '{s}'"))) + } +} + +impl fmt::Display for LivenessSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromSql for LivenessSource { + fn column_result(value: ValueRef<'_>) -> FromSqlResult { + LivenessSource::parse(value.as_str()?).map_err(|e| FromSqlError::Other(Box::new(e))) + } +} + +impl ToSql for LivenessSource { + fn to_sql(&self) -> rusqlite::Result> { + Ok(self.as_str().into()) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SessionOutcome { Completed, @@ -556,6 +608,9 @@ pub struct Session { /// substituted into the agent's attach/resume/continue verb templates. /// `None` when the agent has no capture story or capture failed. pub session_ref: Option, + /// Which source reconciliation must read this session's liveness by, + /// recorded at launch by whichever code spawned the process (DESIGN.md §8). + pub liveness_source: LivenessSource, pub log_path: Option, pub started_at: String, pub ended_at: Option, diff --git a/crates/voro-core/src/scheduler.rs b/crates/voro-core/src/scheduler.rs index 5f3e218..c3705dd 100644 --- a/crates/voro-core/src/scheduler.rs +++ b/crates/voro-core/src/scheduler.rs @@ -473,7 +473,7 @@ pub struct StateCounts { #[cfg(test)] mod tests { use super::*; - use crate::model::TaskState; + use crate::model::{LivenessSource, TaskState}; use crate::store::NewTask; #[test] @@ -661,7 +661,9 @@ mod tests { } fn to_stalled(s: &mut Store, id: i64) { - let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap(); + let (_, session) = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.reconcile_session(session.id, false, false).unwrap(); } @@ -1098,8 +1100,15 @@ mod tests { let mut s = setup(); let p = add_project(&mut s, "p", 5); let refining = add_proposed(&mut s, p, "being rewritten", Priority::P0); - s.record_refine_launch(refining, "thin body", "claude", Some(1), None) - .unwrap(); + s.record_refine_launch( + refining, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) + .unwrap(); let ready = add_task(&mut s, p, "startable", Priority::P3); let candidates = s.candidates().unwrap(); diff --git a/crates/voro-core/src/seed.rs b/crates/voro-core/src/seed.rs index b2dc28c..deb84d7 100644 --- a/crates/voro-core/src/seed.rs +++ b/crates/voro-core/src/seed.rs @@ -9,7 +9,7 @@ //! one by hand when a dev build needs to exercise dispatch. use crate::error::Result; -use crate::model::{DepKind, Priority, SessionOutcome, TaskState}; +use crate::model::{DepKind, LivenessSource, Priority, SessionOutcome, TaskState}; use crate::store::{NewTask, Store}; use crate::transition::{Action, Triage}; @@ -70,7 +70,13 @@ pub fn seed(store: &mut Store) -> Result { // Fixture sessions record no pid: reconcile-on-read finalises a live // session whose process is gone, and an absent pid reads as liveness // unknown, which leaves the row standing. - store.create_session(running.id, "claude", None, Some("/tmp/voro-dev/import.log"))?; + store.create_session( + running.id, + "claude", + None, + LivenessSource::Listing, + Some("/tmp/voro-dev/import.log"), + )?; age(store, running.id, "-40 minutes")?; tasks += 1; @@ -82,7 +88,8 @@ pub fn seed(store: &mut Store) -> Result { Priority::P2, )?; store.apply(asked.id, Action::Start)?; - let asked_session = store.create_session(asked.id, "claude", None, None)?; + let asked_session = + store.create_session(asked.id, "claude", None, LivenessSource::Listing, None)?; store.apply( asked.id, Action::Ask("Should the strip keep showing waiting rows once they have a PR?".into()), @@ -100,8 +107,13 @@ pub fn seed(store: &mut Store) -> Result { )?; store.apply(review.id, Action::Start)?; store.set_branch(review.id, Some("split-review-keys"))?; - let review_session = - store.create_session(review.id, "claude", None, Some("/tmp/voro-dev/keys.log"))?; + let review_session = store.create_session( + review.id, + "claude", + None, + LivenessSource::Listing, + Some("/tmp/voro-dev/keys.log"), + )?; store.apply( review.id, Action::Complete(Some( @@ -144,6 +156,7 @@ pub fn seed(store: &mut Store) -> Result { stalled.id, "claude", Some(0), + LivenessSource::Listing, Some("/tmp/voro-dev/backfill.log"), )?; store.reconcile_session(dead.id, false, false)?; @@ -189,7 +202,7 @@ pub fn seed(store: &mut Store) -> Result { refining.id, Action::Refine("Narrow this to a decision with a threshold, not an open question.".into()), )?; - store.create_session(refining.id, "claude", None, None)?; + store.create_session(refining.id, "claude", None, LivenessSource::Listing, None)?; tasks += 1; let parked = ready_task( diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index c9ccf67..54407b7 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -5,8 +5,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use crate::error::{Error, Result}; use crate::model::{ - Dep, DepKind, DepRef, Doc, Event, Priority, Project, RefineOutcome, Repo, RunningRow, Session, - SessionOutcome, Task, TaskState, location_is_url, + Dep, DepKind, DepRef, Doc, Event, LivenessSource, Priority, Project, RefineOutcome, Repo, + RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, }; const MIGRATIONS: &[&str] = &[ @@ -28,6 +28,7 @@ const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0016_add_refining_state.sql"), include_str!("../migrations/0017_schema_migrations.sql"), include_str!("../migrations/0018_project_viewer.sql"), + include_str!("../migrations/0019_session_liveness_source.sql"), ]; /// Whether a path lies inside a Cargo build directory — a `target` component @@ -1489,14 +1490,17 @@ impl Store { /// Open a session for a running task, stamping `started_at`. `ended_at` and /// `outcome` stay NULL until [`end_session`](Store::end_session). + /// `liveness_source` is which source reconciliation must read the session by + /// (DESIGN.md §8), which only the caller that spawned the process knows. pub fn create_session( &mut self, task_id: i64, agent: &str, pid: Option, + liveness_source: LivenessSource, log_path: Option<&str>, ) -> Result { - let id = insert_session(&self.conn, task_id, agent, pid, log_path)?; + let id = insert_session(&self.conn, task_id, agent, pid, liveness_source, log_path)?; self.session(id) } @@ -1770,8 +1774,7 @@ pub(crate) fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { }) } -pub(crate) const SESSION_COLUMNS: &str = - "id, task_id, agent, pid, session_ref, log_path, started_at, ended_at, outcome"; +pub(crate) const SESSION_COLUMNS: &str = "id, task_id, agent, pid, session_ref, liveness_source, log_path, started_at, ended_at, outcome"; pub(crate) fn get_session(conn: &Connection, id: i64) -> Result> { Ok(conn @@ -1790,10 +1793,11 @@ fn session_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { agent: row.get(2)?, pid: row.get(3)?, session_ref: row.get(4)?, - log_path: row.get(5)?, - started_at: row.get(6)?, - ended_at: row.get(7)?, - outcome: row.get(8)?, + liveness_source: row.get(5)?, + log_path: row.get(6)?, + started_at: row.get(7)?, + ended_at: row.get(8)?, + outcome: row.get(9)?, }) } @@ -1860,13 +1864,14 @@ pub(crate) fn insert_session( task_id: i64, agent: &str, pid: Option, + liveness_source: LivenessSource, log_path: Option<&str>, ) -> Result { close_open_session(conn, task_id, SessionOutcome::Aborted)?; conn.execute( - "INSERT INTO sessions (task_id, agent, pid, log_path, started_at) - VALUES (?1, ?2, ?3, ?4, datetime('now'))", - params![task_id, agent, pid, log_path], + "INSERT INTO sessions (task_id, agent, pid, liveness_source, log_path, started_at) + VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'))", + params![task_id, agent, pid, liveness_source, log_path], )?; Ok(conn.last_insert_rowid()) } @@ -2505,7 +2510,9 @@ mod tests { s.apply(t.id, Action::Ask("A or B?".into())).unwrap(); } TaskState::Stalled => { - let (_, session) = s.record_dispatch(t.id, "claude", Some(1), None).unwrap(); + let (_, session) = s + .record_dispatch(t.id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.reconcile_session(session.id, false, false).unwrap(); } _ => { @@ -2529,7 +2536,8 @@ mod tests { let (mut s, p) = human_fixture(); let t = s.create_task(new_with(p, None, false)).unwrap(); - s.record_dispatch(t.id, "claude", Some(1), None).unwrap(); + s.record_dispatch(t.id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); let err = s.update_task(t.id, edit_of(&t, None, true)).unwrap_err(); assert!(matches!(err, Error::HumanTask { id, .. } if id == t.id)); @@ -2958,8 +2966,15 @@ mod tests { Proposed | Parked | Ready => create(s, state), Refining => { let id = create(s, Proposed); - s.record_refine_launch(id, "thin body", "claude", Some(1), None) - .unwrap(); + s.record_refine_launch( + id, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) + .unwrap(); id } Running => { @@ -2984,7 +2999,9 @@ mod tests { } Stalled => { let id = create(s, Ready); - let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap(); + let (_, session) = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.reconcile_session(session.id, false, false).unwrap(); id } @@ -3779,6 +3796,7 @@ mod tests { " name the files it touches ", "claude", Some(4321), + LivenessSource::Pid, Some("/var/log/refine.log"), ) .unwrap(); @@ -3806,7 +3824,7 @@ mod tests { fn a_note_less_refine_launch_logs_no_note() { let mut s = Store::open_in_memory().unwrap(); let t = proposal(&mut s, "refine me"); - s.record_refine_launch(t.id, "", "claude", Some(1), None) + s.record_refine_launch(t.id, "", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); assert_eq!(s.latest_refine_note(t.id).unwrap(), None); assert_eq!(s.task(t.id).unwrap().state, TaskState::Refining); @@ -3827,7 +3845,7 @@ mod tests { (RefineOutcome::Cancelled, false, false), (RefineOutcome::Applied, true, false), ] { - s.record_refine_launch(t.id, "note", "claude", Some(1), None) + s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); let after = s.conclude_refine(t.id, outcome).unwrap(); assert_eq!(after.state, TaskState::Proposed, "{outcome}"); @@ -3849,7 +3867,7 @@ mod tests { fn a_late_rewrite_corrects_a_failed_round_to_applied() { let mut s = Store::open_in_memory().unwrap(); let t = proposal(&mut s, "refine me"); - s.record_refine_launch(t.id, "note", "claude", Some(1), None) + s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); s.conclude_refine(t.id, RefineOutcome::Failed).unwrap(); assert!(s.refine_failed_flag(t.id).unwrap()); @@ -3881,7 +3899,7 @@ mod tests { for outcome in [RefineOutcome::Applied, RefineOutcome::Cancelled] { let mut s = Store::open_in_memory().unwrap(); let t = proposal(&mut s, "refine me"); - s.record_refine_launch(t.id, "note", "claude", Some(1), None) + s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); s.conclude_refine(t.id, outcome).unwrap(); @@ -3895,7 +3913,7 @@ mod tests { assert!(!s.correct_late_refine(t.id).unwrap()); assert_eq!(s.latest_refine_outcome(t.id).unwrap(), None); - s.record_refine_launch(t.id, "note", "claude", Some(1), None) + s.record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); s.conclude_refine(t.id, RefineOutcome::Failed).unwrap(); s.apply(t.id, Action::Triage(Triage::Ready)).unwrap(); @@ -3918,7 +3936,7 @@ mod tests { let mut s = Store::open_in_memory().unwrap(); let t = proposal(&mut s, "refine me"); let (_, session) = s - .record_refine_launch(t.id, "note", "claude", Some(1), None) + .record_refine_launch(t.id, "note", "claude", Some(1), LivenessSource::Pid, None) .unwrap(); s.conclude_refine(t.id, outcome).unwrap(); @@ -3943,7 +3961,7 @@ mod tests { s.apply(t.id, Action::Triage(Triage::Parked)).unwrap(); assert!(matches!( - s.record_refine_launch(t.id, "too late", "claude", None, None), + s.record_refine_launch(t.id, "too late", "claude", None, LivenessSource::Pid, None), Err(Error::InvalidTransition { .. }) )); // The refused launch wrote nothing — no session, no state change. @@ -3982,7 +4000,13 @@ mod tests { let task_id = task_fixture(&mut s); let opened = s - .create_session(task_id, "claude", Some(4321), Some("/var/log/s.log")) + .create_session( + task_id, + "claude", + Some(4321), + LivenessSource::Pid, + Some("/var/log/s.log"), + ) .unwrap(); assert_eq!(opened.task_id, task_id); assert_eq!(opened.agent, "claude"); @@ -4009,11 +4033,23 @@ mod tests { let sessionless = task_fixture(&mut s); let first = s - .create_session(with_history, "claude", None, Some("/var/log/first.log")) + .create_session( + with_history, + "claude", + None, + LivenessSource::Pid, + Some("/var/log/first.log"), + ) .unwrap(); s.end_session(first.id, SessionOutcome::Failed).unwrap(); let second = s - .create_session(with_history, "codex", None, Some("/var/log/second.log")) + .create_session( + with_history, + "codex", + None, + LivenessSource::Pid, + Some("/var/log/second.log"), + ) .unwrap(); let latest = s.latest_sessions().unwrap(); @@ -4030,7 +4066,9 @@ mod tests { fn session_optional_fields_are_null() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let opened = s.create_session(task_id, "codex", None, None).unwrap(); + let opened = s + .create_session(task_id, "codex", None, LivenessSource::Pid, None) + .unwrap(); assert!(opened.pid.is_none()); assert!(opened.session_ref.is_none()); assert!(opened.log_path.is_none()); @@ -4040,7 +4078,9 @@ mod tests { fn set_session_ref_records_and_rejects_unknown_ids() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let opened = s.create_session(task_id, "claude", None, None).unwrap(); + let opened = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); assert!(opened.session_ref.is_none()); let updated = s @@ -4058,6 +4098,151 @@ mod tests { )); } + /// Each launch records which source reconciliation must read it by + /// (DESIGN.md §8, task #387), and it survives the round trip: a headless + /// launch under a supervisor is listing-authoritative, an interactive round + /// is not, and neither is inferred from anything else on the row. + #[test] + fn a_session_records_the_liveness_source_it_was_launched_with() { + let mut s = Store::open_in_memory().unwrap(); + let task_id = task_fixture(&mut s); + let listing = s + .create_session(task_id, "claude", Some(1), LivenessSource::Listing, None) + .unwrap(); + assert_eq!(listing.liveness_source, LivenessSource::Listing); + assert_eq!( + s.session(listing.id).unwrap().liveness_source, + LivenessSource::Listing + ); + + let pid = s + .create_session(task_id, "manual", Some(1), LivenessSource::Pid, None) + .unwrap(); + assert_eq!(pid.liveness_source, LivenessSource::Pid); + assert_eq!( + s.live_sessions().unwrap()[0].liveness_source, + pid.liveness_source + ); + + // A ref captured later says nothing about the source: that was decided + // at launch, which is the whole point of recording it. + s.set_session_ref(pid.id, "uuid").unwrap(); + assert_eq!( + s.session(pid.id).unwrap().liveness_source, + LivenessSource::Pid + ); + } + + /// The dispatch and refine transactions carry the flavour through to the + /// row they open, so the reconciler reads what the launcher spawned. + #[test] + fn dispatch_and_refine_launches_carry_their_liveness_source() { + let mut s = Store::open_in_memory().unwrap(); + let p = s.create_project("proj", "/tmp/proj").unwrap(); + let ready = s + .create_task(NewTask { + project_id: p.id, + repo_id: None, + title: "run me".into(), + body: String::new(), + priority: Priority::P1, + state: TaskState::Ready, + agent: None, + human: false, + deep: false, + }) + .unwrap(); + let (_, dispatched) = s + .record_dispatch(ready.id, "claude", Some(1), LivenessSource::Listing, None) + .unwrap(); + assert_eq!(dispatched.liveness_source, LivenessSource::Listing); + + let proposal = s + .create_task(NewTask { + project_id: p.id, + repo_id: None, + title: "sloppy".into(), + body: String::new(), + priority: Priority::P2, + state: TaskState::Proposed, + agent: None, + human: false, + deep: false, + }) + .unwrap(); + let (_, headless) = s + .record_refine_launch( + proposal.id, + "name the files", + "claude", + Some(2), + LivenessSource::Listing, + None, + ) + .unwrap(); + assert_eq!(headless.liveness_source, LivenessSource::Listing); + + s.conclude_refine(proposal.id, RefineOutcome::Cancelled) + .unwrap(); + let (_, interactive) = s + .record_refine_launch( + proposal.id, + "", + "claude", + Some(3), + LivenessSource::Pid, + None, + ) + .unwrap(); + assert_eq!(interactive.liveness_source, LivenessSource::Pid); + } + + /// A database from before migration 0017 must open with every existing + /// session listing-authoritative — what a dispatch of an agent with a + /// `sessions` verb already was, and the direction that leaves a session + /// alone rather than finalising a live one — and the CHECK must reject a + /// source that is neither. + #[test] + fn migration_0017_defaults_existing_sessions_to_the_listing() { + let conn = Connection::open_in_memory().unwrap(); + for sql in &MIGRATIONS[..16] { + conn.execute_batch(sql).unwrap(); + } + conn.pragma_update(None, "user_version", 16).unwrap(); + conn.execute("INSERT INTO projects (name) VALUES ('p')", []) + .unwrap(); + conn.execute( + "INSERT INTO repos (project_id, name, path, is_default) + VALUES (1, 'p', '/tmp/p', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO tasks (project_id, title, state, state_since, created_at) + VALUES (1, 'dispatched', 'running', datetime('now'), datetime('now'))", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO sessions (task_id, agent, pid, started_at) + VALUES (1, 'claude', 4242, datetime('now'))", + [], + ) + .unwrap(); + + let store = Store::from_connection(conn).unwrap(); + assert_eq!( + store.session(1).unwrap().liveness_source, + LivenessSource::Listing + ); + + let junk = store.conn.execute( + "UPDATE sessions SET liveness_source = 'guess' WHERE id = 1", + [], + ); + assert!(junk.is_err(), "the CHECK must reject an unknown source"); + } + /// A confirmed send moves the session's process to the one carrying the /// turn, and follows the fork when the agent opened a new reference — but a /// send that resumed in place must not blank the reference it already had. @@ -4066,7 +4251,7 @@ mod tests { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); let opened = s - .create_session(task_id, "claude", Some(1234), None) + .create_session(task_id, "claude", Some(1234), LivenessSource::Pid, None) .unwrap(); s.set_session_ref(opened.id, "first-ref").unwrap(); @@ -4100,8 +4285,12 @@ mod tests { fn sessions_for_returns_newest_first() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let first = s.create_session(task_id, "claude", None, None).unwrap(); - let second = s.create_session(task_id, "claude", None, None).unwrap(); + let first = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); + let second = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); let sessions = s.sessions_for(task_id).unwrap(); assert_eq!( @@ -4114,8 +4303,12 @@ mod tests { fn live_sessions_excludes_ended() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let done = s.create_session(task_id, "claude", None, None).unwrap(); - let live = s.create_session(task_id, "claude", None, None).unwrap(); + let done = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); + let live = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); s.end_session(done.id, SessionOutcome::Failed).unwrap(); let ids = s.live_sessions().unwrap(); @@ -4126,7 +4319,9 @@ mod tests { fn running_rows_join_current_task_fields() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let session = s.create_session(task_id, "claude", None, None).unwrap(); + let session = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); let rows = s.running_rows().unwrap(); assert_eq!(rows.len(), 1); @@ -4142,8 +4337,12 @@ mod tests { fn running_rows_exclude_ended_sessions_and_order_newest_first() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let done = s.create_session(task_id, "claude", None, None).unwrap(); - let live = s.create_session(task_id, "codex", None, None).unwrap(); + let done = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); + let live = s + .create_session(task_id, "codex", None, LivenessSource::Pid, None) + .unwrap(); s.end_session(done.id, SessionOutcome::Completed).unwrap(); let rows = s.running_rows().unwrap(); @@ -4158,7 +4357,9 @@ mod tests { fn running_rows_compute_elapsed_from_started_at() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let session = s.create_session(task_id, "claude", None, None).unwrap(); + let session = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); s.conn .execute( "UPDATE sessions SET started_at = datetime('now', '-90 seconds') WHERE id = ?1", @@ -4210,7 +4411,9 @@ mod tests { fn running_rows_include_task_whose_sessions_all_ended() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - let done = s.create_session(task_id, "claude", None, None).unwrap(); + let done = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); s.end_session(done.id, SessionOutcome::Failed).unwrap(); let rows = s.running_rows().unwrap(); @@ -4225,7 +4428,9 @@ mod tests { fn running_rows_order_live_sessions_before_session_less_tasks() { let mut s = Store::open_in_memory().unwrap(); let live_task = task_fixture(&mut s); - let session = s.create_session(live_task, "claude", None, None).unwrap(); + let session = s + .create_session(live_task, "claude", None, LivenessSource::Pid, None) + .unwrap(); let orphan_task = task_fixture(&mut s); let rows = s.running_rows().unwrap(); @@ -4244,7 +4449,14 @@ mod tests { let mut s = Store::open_in_memory().unwrap(); let t = proposal(&mut s, "refine me"); let (_, session) = s - .record_refine_launch(t.id, "thin body", "claude", Some(1), None) + .record_refine_launch( + t.id, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) .unwrap(); let rows = s.running_rows().unwrap(); @@ -4280,7 +4492,9 @@ mod tests { }) .unwrap() .id; - let (_, opened) = s.record_dispatch(id, "claude", Some(1), None).unwrap(); + let (_, opened) = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); s.set_pr(id, Some("https://github.com/o/r/pull/7")).unwrap(); @@ -4369,24 +4583,27 @@ mod tests { // review keeps its session open, yet must not appear in the strip let review = s.create_task(new("review")).unwrap().id; - s.record_dispatch(review, "claude", Some(1), None).unwrap(); + s.record_dispatch(review, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.apply(review, Action::Complete(None)).unwrap(); assert!(s.sessions_for(review).unwrap()[0].ended_at.is_none()); // done and rejected have their sessions closed by the transition let done = s.create_task(new("done")).unwrap().id; - s.record_dispatch(done, "claude", Some(2), None).unwrap(); + s.record_dispatch(done, "claude", Some(2), LivenessSource::Pid, None) + .unwrap(); s.apply(done, Action::Complete(None)).unwrap(); s.apply(done, Action::Accept).unwrap(); let rejected = s.create_task(new("rejected")).unwrap().id; - s.record_dispatch(rejected, "claude", Some(3), None) + s.record_dispatch(rejected, "claude", Some(3), LivenessSource::Pid, None) .unwrap(); s.apply(rejected, Action::Abort).unwrap(); s.apply(rejected, Action::Abandon).unwrap(); let running = s.create_task(new("running")).unwrap().id; - s.record_dispatch(running, "claude", Some(4), None).unwrap(); + s.record_dispatch(running, "claude", Some(4), LivenessSource::Pid, None) + .unwrap(); let rows = s.running_rows().unwrap(); assert_eq!(rows.len(), 1); @@ -4399,7 +4616,8 @@ mod tests { fn running_rows_ignore_a_stale_open_session_on_a_closed_task() { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); - s.create_session(task_id, "claude", Some(1), None).unwrap(); + s.create_session(task_id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.conn .execute("UPDATE tasks SET state = 'done' WHERE id = ?1", [task_id]) .unwrap(); @@ -4411,7 +4629,9 @@ mod tests { let mut s = Store::open_in_memory().unwrap(); let task_id = task_fixture(&mut s); for outcome in SessionOutcome::ALL { - let opened = s.create_session(task_id, "claude", None, None).unwrap(); + let opened = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); let ended = s.end_session(opened.id, outcome).unwrap(); assert_eq!(ended.outcome, Some(outcome)); } diff --git a/crates/voro-core/src/transition.rs b/crates/voro-core/src/transition.rs index 64583a7..4bef2f7 100644 --- a/crates/voro-core/src/transition.rs +++ b/crates/voro-core/src/transition.rs @@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use rusqlite::{Connection, params}; use crate::error::{Error, Result}; -use crate::model::{RefineOutcome, Session, SessionOutcome, Task, TaskState}; +use crate::model::{LivenessSource, RefineOutcome, Session, SessionOutcome, Task, TaskState}; use crate::store::{ Store, close_open_session, get_session, get_task, insert_session, log_event, set_session_outcome, @@ -149,19 +149,22 @@ impl Store { /// Dispatch's atomic write (DESIGN.md §8): move the task `ready → running` /// (or `stalled → running`) and open its session in one transaction, so a /// running task always has a session and a session always has a running - /// task. Spawning the process is the caller's job, before this commits. + /// task. Spawning the process is the caller's job, before this commits, and + /// so is naming its `liveness_source` (DESIGN.md §8): only the code that + /// launched knows whether the pid it holds is the work or a launcher. pub fn record_dispatch( &mut self, task_id: i64, agent: &str, pid: Option, + liveness_source: LivenessSource, log_path: Option<&str>, ) -> Result<(Task, Session)> { let tx = self.conn.transaction()?; reject_human_dispatch(&tx, task_id)?; reject_archived_dispatch(&tx, task_id)?; apply_action(&tx, task_id, Action::Start)?; - let session_id = insert_session(&tx, task_id, agent, pid, log_path)?; + let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?; tx.commit()?; Ok((self.task(task_id)?, self.session(session_id)?)) } @@ -174,7 +177,9 @@ impl Store { /// which is what sends the rewritten body back through triage. The /// note rides the transition; the empty string is the interactive flavour, /// which is a conversation rather than a brief. Spawning the process is the - /// caller's job, before this commits. + /// caller's job, before this commits, and the flavour it spawned rides in as + /// `liveness_source`: the headless round inherits the `dispatch` template's + /// source, the interactive one is the foreground child's own pid. /// /// [`record_dispatch`]: Store::record_dispatch pub fn record_refine_launch( @@ -183,11 +188,12 @@ impl Store { note: &str, agent: &str, pid: Option, + liveness_source: LivenessSource, log_path: Option<&str>, ) -> Result<(Task, Session)> { let tx = self.conn.transaction()?; apply_action(&tx, task_id, Action::Refine(note.to_string()))?; - let session_id = insert_session(&tx, task_id, agent, pid, log_path)?; + let session_id = insert_session(&tx, task_id, agent, pid, liveness_source, log_path)?; tx.commit()?; Ok((self.task(task_id)?, self.session(session_id)?)) } @@ -729,8 +735,15 @@ mod tests { Proposed | Parked | Ready => create(s, project_id, state), Refining => { let id = create(s, project_id, Proposed); - s.record_refine_launch(id, "thin body", "claude", Some(1), None) - .unwrap(); + s.record_refine_launch( + id, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) + .unwrap(); id } Running => { @@ -755,7 +768,9 @@ mod tests { } Stalled => { let id = create(s, project_id, Ready); - let (_, session) = s.record_dispatch(id, "claude", Some(1), None).unwrap(); + let (_, session) = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.reconcile_session(session.id, false, false).unwrap(); id } @@ -900,7 +915,7 @@ mod tests { let id = create(&mut s, p, TaskState::Ready); let (task, session) = s - .record_refine_launch(id, note, "claude", Some(4242), None) + .record_refine_launch(id, note, "claude", Some(4242), LivenessSource::Pid, None) .unwrap(); assert_eq!(task.state, TaskState::Refining); assert_eq!(session.task_id, id); @@ -928,8 +943,15 @@ mod tests { ] { let (mut s, p) = store_with_project(); let id = create(&mut s, p, from); - s.record_refine_launch(id, "thin body", "claude", Some(1), None) - .unwrap(); + s.record_refine_launch( + id, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) + .unwrap(); let task = s.conclude_refine(id, outcome).unwrap(); assert_eq!(task.state, TaskState::Proposed, "{from} + {outcome:?}"); @@ -967,8 +989,15 @@ mod tests { let id = create(&mut s, p, TaskState::Ready); assert!(crate::scheduler::focus(&s.candidates().unwrap()).is_some()); - s.record_refine_launch(id, "thin body", "claude", Some(1), None) - .unwrap(); + s.record_refine_launch( + id, + "thin body", + "claude", + Some(1), + LivenessSource::Pid, + None, + ) + .unwrap(); let candidates = s.candidates().unwrap(); assert!( !candidates.iter().any(|c| c.task.id == id), @@ -1047,7 +1076,9 @@ mod tests { let (mut s, p) = store_with_project(); let id = create_human(&mut s, p, TaskState::Ready); - let err = s.record_dispatch(id, "claude", Some(1), None).unwrap_err(); + let err = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap_err(); assert!( matches!(err, Error::HumanTask { id: e, .. } if e == id), "expected a human-only refusal, got {err}" @@ -1078,7 +1109,9 @@ mod tests { let (mut s, p) = store_with_project(); let id = create_human(&mut s, p, TaskState::Ready); s.apply(id, Action::Start).unwrap(); - let stray = s.create_session(id, "claude", Some(1), None).unwrap(); + let stray = s + .create_session(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); s.apply(id, Action::Complete(None)).unwrap(); let closed = s.session(stray.id).unwrap(); @@ -1563,7 +1596,13 @@ mod tests { let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); let (task, session) = s - .record_dispatch(id, "claude", Some(4321), Some("/var/log/26.log")) + .record_dispatch( + id, + "claude", + Some(4321), + LivenessSource::Pid, + Some("/var/log/26.log"), + ) .unwrap(); assert_eq!(task.state, TaskState::Running); assert_eq!(session.task_id, id); @@ -1579,7 +1618,7 @@ mod tests { let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Proposed); assert!(matches!( - s.record_dispatch(id, "claude", None, None), + s.record_dispatch(id, "claude", None, LivenessSource::Pid, None), Err(Error::InvalidTransition { .. }) )); // the failed transaction must leave neither state change nor session @@ -1596,7 +1635,7 @@ mod tests { s.set_archived(p, true).unwrap(); let err = s - .record_dispatch(ready, "claude", Some(1), None) + .record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None) .unwrap_err(); assert!(matches!(err, Error::ProjectArchived { .. }), "{err}"); assert_eq!(s.task(ready).unwrap().state, TaskState::Ready); @@ -1604,7 +1643,10 @@ mod tests { // unarchiving reopens the door s.set_archived(p, false).unwrap(); - assert!(s.record_dispatch(ready, "claude", Some(1), None).is_ok()); + assert!( + s.record_dispatch(ready, "claude", Some(1), LivenessSource::Pid, None) + .is_ok() + ); } // --- session lifecycle: one open session, closed by terminal transitions --- @@ -1617,7 +1659,7 @@ mod tests { // `completed` when the review is accepted. let accepted = create(&mut s, p, TaskState::Ready); let sess = s - .record_dispatch(accepted, "claude", Some(1), None) + .record_dispatch(accepted, "claude", Some(1), LivenessSource::Pid, None) .unwrap() .1; s.apply(accepted, Action::Complete(None)).unwrap(); @@ -1633,7 +1675,7 @@ mod tests { // Abort: running -> ready closes the session `aborted`. let aborted = create(&mut s, p, TaskState::Ready); let sess = s - .record_dispatch(aborted, "claude", Some(1), None) + .record_dispatch(aborted, "claude", Some(1), LivenessSource::Pid, None) .unwrap() .1; s.apply(aborted, Action::Abort).unwrap(); @@ -1645,7 +1687,7 @@ mod tests { // Abandon (from review) closes the session `aborted` too. let abandoned = create(&mut s, p, TaskState::Ready); let sess = s - .record_dispatch(abandoned, "claude", Some(1), None) + .record_dispatch(abandoned, "claude", Some(1), LivenessSource::Pid, None) .unwrap() .1; s.apply(abandoned, Action::Complete(None)).unwrap(); @@ -1663,7 +1705,10 @@ mod tests { // session and `resume` only moves the state (DESIGN.md §6/§8). let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Ask("A or B?".into())).unwrap(); assert!(s.session(sess.id).unwrap().ended_at.is_none()); @@ -1682,7 +1727,10 @@ mod tests { // session — the ref survives until the task actually closes (DESIGN.md §8). let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.set_session_ref(sess.id, "ref-1").unwrap(); s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::RejectWork("redo the tests".into())) @@ -1706,7 +1754,10 @@ mod tests { // once the work becomes the operator's move again. let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Complete(None)).unwrap(); let task = s.apply(id, Action::HandOff).unwrap(); @@ -1747,7 +1798,10 @@ mod tests { // it does straight from review (DESIGN.md §8). let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); @@ -1763,7 +1817,10 @@ mod tests { fn abandon_from_waiting_closes_the_session_aborted() { let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); @@ -1779,7 +1836,10 @@ mod tests { fn reclaim_pulls_the_work_back_to_review_keeping_the_session() { let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); @@ -1796,7 +1856,10 @@ mod tests { // agent session. let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.set_session_ref(sess.id, "ref-1").unwrap(); s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); @@ -1819,7 +1882,10 @@ mod tests { // process liveness — nothing to reconcile (DESIGN.md §8). let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s + .record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap() + .1; s.apply(id, Action::Complete(None)).unwrap(); s.apply(id, Action::HandOff).unwrap(); @@ -1839,7 +1905,8 @@ mod tests { // supersede cannot leave two open rows on one task. let (mut s, p) = store_with_project(); let id = create(&mut s, p, TaskState::Ready); - s.record_dispatch(id, "claude", Some(1), None).unwrap(); + s.record_dispatch(id, "claude", Some(1), LivenessSource::Pid, None) + .unwrap(); let second = s.conn.execute( "INSERT INTO sessions (task_id, agent, started_at) VALUES (?1, 'x', datetime('now'))", [id], @@ -1867,7 +1934,13 @@ mod tests { fn dispatch(s: &mut Store, p: i64) -> (i64, i64) { let task_id = create(s, p, TaskState::Ready); let (_, session) = s - .record_dispatch(task_id, "claude", Some(4242), Some("/var/log/s.log")) + .record_dispatch( + task_id, + "claude", + Some(4242), + LivenessSource::Pid, + Some("/var/log/s.log"), + ) .unwrap(); (task_id, session.id) } @@ -1922,7 +1995,14 @@ mod tests { let (mut s, p) = store_with_project(); let task_id = create(&mut s, p, TaskState::Proposed); let (_, session) = s - .record_refine_launch(task_id, "thin body", "claude", Some(4242), None) + .record_refine_launch( + task_id, + "thin body", + "claude", + Some(4242), + LivenessSource::Pid, + None, + ) .unwrap(); // a live agent is left alone: the rewrite may still land @@ -1982,7 +2062,13 @@ mod tests { assert_eq!(s.task(task_id).unwrap().state, TaskState::Stalled); let (task, session) = s - .record_dispatch(task_id, "codex", Some(4343), Some("/var/log/s2.log")) + .record_dispatch( + task_id, + "codex", + Some(4343), + LivenessSource::Pid, + Some("/var/log/s2.log"), + ) .unwrap(); assert_eq!(task.state, TaskState::Running); assert_eq!(session.agent, "codex"); @@ -2173,7 +2259,13 @@ mod tests { assert!(s.session(older_session).unwrap().ended_at.is_some()); let newer_session = s - .record_dispatch(task_id, "claude", Some(4343), Some("/var/log/s2.log")) + .record_dispatch( + task_id, + "claude", + Some(4343), + LivenessSource::Pid, + Some("/var/log/s2.log"), + ) .unwrap() .1 .id; diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index e446601..6a673a7 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -2,9 +2,9 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::ui::Hit; use voro_core::{ - Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event, PrRef, - Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow, ScoreBreakdown, StateCounts, - Store, Task, TaskState, Triage, WipGate, scheduler, + Action, ActionRow, AgentsConfig, CompletionReport, DepKind, DepRef, DigestRow, Event, + LivenessSource, PrRef, Priority, Project, Queue, QueueRow, RefineOutcome, RunningRow, + ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler, }; /// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since @@ -1780,10 +1780,18 @@ impl App { /// since pulling the terminal out from under a conversation the operator is /// already in would be the worse failure. pub fn open_refine_round(&mut self, refine: &crate::dispatch::RefineLaunch, pid: i64) { - if let Err(e) = - self.store - .record_refine_launch(refine.task_id, "", &refine.agent, Some(pid), None) - { + // The interactive round is the own-pid case (DESIGN.md §8): a + // foreground `plan` child Voro spawned, so the pid recorded here is the + // round itself and reconciliation must read it rather than a listing + // this session never appears in. + if let Err(e) = self.store.record_refine_launch( + refine.task_id, + "", + &refine.agent, + Some(pid), + LivenessSource::Pid, + None, + ) { self.status = Some(format!("refine of task {} unrecorded: {e}", refine.task_id)); } } @@ -3259,7 +3267,7 @@ impl App { #[cfg(test)] mod tests { use super::*; - use voro_core::{NewTask, Priority}; + use voro_core::{LivenessSource, NewTask, Priority}; fn key(app: &mut App, code: KeyCode) { app.on_key(KeyEvent::from(code)); @@ -3318,7 +3326,7 @@ mod tests { // jump-in keys read off the strip row. TaskState::Waiting => { store - .record_dispatch(task.id, "claude", None, None) + .record_dispatch(task.id, "claude", None, LivenessSource::Pid, None) .unwrap(); store.apply(task.id, Action::Complete(None)).unwrap(); store.apply(task.id, Action::HandOff).unwrap(); @@ -3327,7 +3335,13 @@ mod tests { // stalls the task (DESIGN.md §8). TaskState::Stalled => { let (_, session) = store - .record_dispatch(task.id, "claude", Some(1), Some("/tmp/demo/s.log")) + .record_dispatch( + task.id, + "claude", + Some(1), + LivenessSource::Pid, + Some("/tmp/demo/s.log"), + ) .unwrap(); store.reconcile_session(session.id, false, false).unwrap(); } @@ -3342,6 +3356,7 @@ mod tests { "thin body", "claude", None, + LivenessSource::Pid, Some("/tmp/demo/refine.log"), ) .unwrap(); @@ -5693,7 +5708,13 @@ mod tests { }) .unwrap(); store - .record_dispatch(task.id, "claude", Some(1), Some("/tmp/demo/open.log")) + .record_dispatch( + task.id, + "claude", + Some(1), + LivenessSource::Pid, + Some("/tmp/demo/open.log"), + ) .unwrap(); store.apply(task.id, Action::Ask("A or B?".into())).unwrap(); @@ -5744,7 +5765,7 @@ mod tests { }) .unwrap(); let (_, session) = store - .record_dispatch(task.id, "claude", Some(1), None) + .record_dispatch(task.id, "claude", Some(1), LivenessSource::Pid, None) .unwrap(); store.reconcile_session(session.id, false, false).unwrap(); diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 7531659..fee3dd5 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -2390,6 +2390,7 @@ fn task_line(task: &Task, project: &str) -> String { #[cfg(test)] mod tests { use super::*; + use voro_core::LivenessSource; fn store() -> Store { Store::open_in_memory().unwrap() @@ -2813,8 +2814,15 @@ mod tests { ok(&mut s, &["add", "demo", "An idea"]); assert!(!ok(&mut s, &["inbox"]).contains("refined")); - s.record_refine_launch(1, "name the files it touches", "claude", None, None) - .unwrap(); + s.record_refine_launch( + 1, + "name the files it touches", + "claude", + None, + LivenessSource::Pid, + None, + ) + .unwrap(); // Mid-round the proposal is out of the queue entirely, and nothing // claims a rewrite that has not landed. assert!(!ok(&mut s, &["inbox"]).contains("awaiting triage")); @@ -2849,8 +2857,15 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "An idea"]); - s.record_refine_launch(1, "name the files", "claude", None, None) - .unwrap(); + s.record_refine_launch( + 1, + "name the files", + "claude", + None, + LivenessSource::Pid, + None, + ) + .unwrap(); s.conclude_refine(1, RefineOutcome::Failed).unwrap(); assert!(ok(&mut s, &["inbox"]).contains("⚠ 1 refine failed")); @@ -2860,7 +2875,7 @@ mod tests { assert!(!out.contains("↻ refined"), "{out}"); // A quit that concluded nothing is a no-op, not a failure: no marker. - s.record_refine_launch(1, "again", "claude", None, None) + s.record_refine_launch(1, "again", "claude", None, LivenessSource::Pid, None) .unwrap(); s.conclude_refine(1, RefineOutcome::Cancelled).unwrap(); assert!(!ok(&mut s, &["list"]).contains("refine failed")); @@ -2878,8 +2893,15 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "An idea", "--body", "thin"]); - s.record_refine_launch(1, "make it dispatchable", "claude", None, None) - .unwrap(); + s.record_refine_launch( + 1, + "make it dispatchable", + "claude", + None, + LivenessSource::Pid, + None, + ) + .unwrap(); let session = s.sessions_for(1).unwrap()[0].id; let path = std::env::temp_dir().join(format!("voro-refine-{}.md", std::process::id())); @@ -2905,8 +2927,15 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "An idea", "--body", "thin"]); - s.record_refine_launch(1, "make it dispatchable", "claude", None, None) - .unwrap(); + s.record_refine_launch( + 1, + "make it dispatchable", + "claude", + None, + LivenessSource::Pid, + None, + ) + .unwrap(); ok(&mut s, &["set", "1", "--priority", "1"]); assert_eq!(s.task(1).unwrap().state, TaskState::Refining); @@ -2925,8 +2954,15 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "An idea", "--body", "thin"]); - s.record_refine_launch(1, "make it dispatchable", "claude", None, None) - .unwrap(); + s.record_refine_launch( + 1, + "make it dispatchable", + "claude", + None, + LivenessSource::Pid, + None, + ) + .unwrap(); s.conclude_refine(1, RefineOutcome::Failed).unwrap(); assert!(s.refine_failed_flag(1).unwrap()); @@ -2956,7 +2992,7 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "An idea"]); - s.record_refine_launch(1, "thin body", "claude", None, None) + s.record_refine_launch(1, "thin body", "claude", None, LivenessSource::Pid, None) .unwrap(); for verdict in ["ready", "parked", "reject"] { @@ -4044,7 +4080,9 @@ mod tests { let mut s = store(); ok(&mut s, &["project", "add", "demo", "/tmp"]); ok(&mut s, &["add", "demo", "T", "--state", "ready"]); - let (_, session) = s.record_dispatch(1, "claude", None, None).unwrap(); + let (_, session) = s + .record_dispatch(1, "claude", None, LivenessSource::Pid, None) + .unwrap(); s.reconcile_session(session.id, false, false).unwrap(); assert_eq!(s.task(1).unwrap().state, TaskState::Stalled); diff --git a/crates/voro/src/dispatch.rs b/crates/voro/src/dispatch.rs index 1f6084e..1a29c53 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -829,15 +829,16 @@ pub fn refine( let log_path = spawned.log_path.clone(); let session_name = spawned.session_name.clone(); - // Recorded after the spawn, exactly as dispatch records its session, so the - // pid the reconciler probes is the real one; a round that could not be - // recorded takes its agent down with it rather than rewriting a body no - // window knows is being rewritten. + // Recorded after the spawn, exactly as dispatch records its session — and + // with the same liveness source, since the round runs that same template; a + // round that could not be recorded takes its agent down with it rather than + // rewriting a body no window knows is being rewritten. let recorded = store.record_refine_launch( task_id, note, &agent.name, Some(pid), + agent.dispatch_liveness_source(), Some(log_path.to_string_lossy().as_ref()), ); let session = match recorded { @@ -1403,6 +1404,7 @@ fn spawn_session( task_id, &agent.name, Some(pid), + agent.dispatch_liveness_source(), Some(log_path.to_string_lossy().as_ref()), ); let (task, session) = match recorded { @@ -1608,7 +1610,7 @@ fn default_base_branch(repo_path: &str) -> String { #[cfg(test)] mod tests { use super::*; - use voro_core::{NewTask, Priority}; + use voro_core::{LivenessSource, NewTask, Priority}; /// A scratch database, a freshly-`git init`ed clean project, and an /// `voro.toml` whose one agent is a stub command that just reads the @@ -1714,9 +1716,12 @@ mod tests { let log = session.log_path.as_deref().unwrap(); assert!(Path::new(log).exists(), "log file {log} should exist"); - // no sessions verb, so no capture was attempted and none is reported + // no sessions verb, so no capture was attempted and none is reported — + // and with no listing to consult, the spawned pid is what the session + // records for reconciliation to read it by (DESIGN.md §8) assert!(session.session_ref.is_none()); assert!(!summary.contains("ref"), "{summary}"); + assert_eq!(session.liveness_source, LivenessSource::Pid); // the prompt carries the task title and body let prompt = std::fs::read_to_string(prompt_files(&ctx).pop().unwrap()).unwrap(); @@ -2840,15 +2845,16 @@ mod tests { let summary = refine(&mut store, &ctx, id, "name the files").unwrap(); assert!(summary.contains("ref refine-uuid"), "{summary}"); - assert_eq!( - store.sessions_for(id).unwrap()[0].session_ref.as_deref(), - Some("refine-uuid") - ); + let session = &store.sessions_for(id).unwrap()[0]; + assert_eq!(session.session_ref.as_deref(), Some("refine-uuid")); + assert_eq!(session.liveness_source, LivenessSource::Listing); assert_eq!(store.task(id).unwrap().state, TaskState::Refining); } /// Capture is best-effort here as it is for a dispatch: a listing that - /// matches nothing leaves the ref NULL, says so, and the round runs on. + /// matches nothing leaves the ref NULL, says so, and the round runs on — + /// still recorded listing-authoritative (task #387), which is what leaves + /// reconcile with nothing to probe rather than a launcher pid to misread. #[test] fn refine_survives_a_ref_it_cannot_capture() { let (mut store, ctx, project) = fixture_toml( @@ -2859,7 +2865,9 @@ mod tests { let summary = refine(&mut store, &ctx, id, "name the files").unwrap(); assert!(summary.contains("session ref not captured"), "{summary}"); - assert!(store.sessions_for(id).unwrap()[0].session_ref.is_none()); + let session = &store.sessions_for(id).unwrap()[0]; + assert!(session.session_ref.is_none()); + assert_eq!(session.liveness_source, LivenessSource::Listing); assert_eq!(store.task(id).unwrap().state, TaskState::Refining); } @@ -3134,7 +3142,7 @@ mod tests { ); let id = proposal(&mut store, &project, false); store - .record_refine_launch(id, "first round", "stub", None, None) + .record_refine_launch(id, "first round", "stub", None, LivenessSource::Pid, None) .unwrap(); let err = refine(&mut store, &ctx, id, "second round").unwrap_err(); diff --git a/crates/voro/src/reconcile.rs b/crates/voro/src/reconcile.rs index 9b8ae42..a28acda 100644 --- a/crates/voro/src/reconcile.rs +++ b/crates/voro/src/reconcile.rs @@ -12,34 +12,38 @@ //! answer or feedback continues the work), and a session still open on a closed //! task is stale and finalised — neither needs a probe. //! -//! Liveness has two sources per agent (task #75). An agent defining a -//! `sessions` verb is queried through [`crate::session_probe`], its listing -//! taken once per pass and cached here across the sessions sharing an agent. -//! This is the only correct source for supervisor-owned launches (`claude -//! --bg`), whose spawned pid is a launcher that exits at birth: the pid the -//! session row holds would declare every such dispatch dead, so it is never -//! consulted for them, and undeterminable liveness (no ref, listing failed) is +//! Liveness has two sources (task #75), and which of them owns a session is +//! recorded on its row at launch by the code that spawned the process +//! (`sessions.liveness_source`, DESIGN.md §8) rather than inferred here. +//! A listing-authoritative session is queried through [`crate::session_probe`], +//! its listing taken once per pass and cached here across the sessions sharing +//! an agent. This is the only correct source for supervisor-owned launches +//! (`claude --bg`), whose spawned pid is a launcher that exits at birth: the pid +//! the session row holds can never declare such a session dead, and +//! undeterminable liveness (no ref, listing failed) is //! left alone rather than guessed. The pid a listing *entry* carries is a //! different pid — the supervisor's — and it is authoritative where present, //! which is what stops a listing that keeps dead sessions at `blocked` forever -//! from reading them all as live. Agents without a `sessions` verb keep the -//! spawned-pid check. +//! from reading them all as live. A pid-authoritative session — a foreground +//! child Voro owns, or any launch by an agent with no `sessions` verb — keeps +//! the spawned-pid check. //! -//! The row's own pid is still read in one direction, for every agent: a pid -//! that is *alive* proves the session is (task #390). A quick message replaces -//! that pid with the process carrying its turn, and where the agent had to fork -//! to be joined at all, that turn is a `-p` run the agent's listing never shows -//! — so the listing would report the session gone while the message it was just -//! sent is still being worked on. A dead pid still proves nothing and falls -//! back to the listing. +//! The row's own pid is still read in one direction, whichever source owns the +//! session: a pid that is *alive* proves the session is (task #390). A quick +//! message replaces that pid with the process carrying its turn, and where the +//! agent had to fork to be joined at all, that turn is a `-p` run the agent's +//! listing never shows — so the listing would report the session gone while the +//! message it was just sent is still being worked on. A dead pid still proves +//! nothing under a supervisor-owned launch and falls back to the listing. //! -//! A refine round reads the same way, because it is launched the same way: the -//! headless flavour renders the agent's own `dispatch` template, so under a -//! `--bg` launcher its recorded pid lies exactly as a dispatch's does, and its -//! ref is captured at launch so the listing can be consulted. The interactive -//! flavour is the one genuinely-own-pid case — a foreground `plan` child, no ref -//! by construction — so a refining session with no ref falls back to its pid -//! rather than becoming unprobeable. +//! A refine round is read by the flavour it was launched as, which is the point +//! of recording the source: the headless flavour renders the agent's own +//! `dispatch` template, so under a `--bg` launcher its recorded pid lies exactly +//! as a dispatch's does, and it is listing-authoritative whether or not its ref +//! capture happened to succeed. The interactive flavour is the genuinely-own-pid +//! case — a foreground `plan` child, no ref by construction — and says so on its +//! row, so a Voro that dies mid-conversation still leaves a round another window +//! can finish. //! //! Whether a *dead* session died capped is read from the same per-agent verb //! set, through an optional `logs` (task #415). Voro's own launch log — the @@ -62,7 +66,9 @@ use std::collections::HashMap; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -use voro_core::{AgentSessionEntry, AgentsConfig, Result, Store, TaskState, read_cap}; +use voro_core::{ + AgentSessionEntry, AgentsConfig, LivenessSource, Result, Store, TaskState, read_cap, +}; use crate::session_probe::{ listing_says_live, pid_is_alive, read_session_cap, run_sessions_command, @@ -81,8 +87,9 @@ pub fn reconcile_live_sessions(store: &mut Store, agents_path: &Path) -> Result< if live.is_empty() { return Ok(0); } - // A missing or invalid voro.toml degrades every session to the pid - // check rather than failing the read that triggered reconciliation. + // A missing or invalid voro.toml costs the listing-authoritative sessions + // their probe — they are left alone — rather than failing the read that + // triggered reconciliation. let config = AgentsConfig::load(agents_path).ok(); // One listing per agent per pass, however many of its sessions are live. let mut listings: HashMap>> = HashMap::new(); @@ -99,30 +106,32 @@ pub fn reconcile_live_sessions(store: &mut Store, agents_path: &Path) -> Result< continue; } - // Work under way: probe liveness. `None` (no ref, listing failed, no - // pid) leaves the session alone rather than wrongly finalising it. - let sessions_cmd = config - .as_ref() - .and_then(|c| c.agent(&session.agent)) - .and_then(|a| a.sessions()); - let alive: Option = match sessions_cmd.zip(session.session_ref.as_deref()) { - Some((cmd, session_ref)) => { - let listing = listings - .entry(session.agent.clone()) - .or_insert_with(|| run_sessions_command(cmd, None)); - listing + // Work under way: probe liveness through the source the launch + // recorded. `None` (no ref, listing failed, no pid) leaves the session + // alone rather than wrongly finalising it. + let alive: Option = match session.liveness_source { + // The recorded pid is the work itself. + LivenessSource::Pid => session.pid.map(pid_is_alive), + // The work went to a supervisor, so only the agent's listing knows. + // Without a ref to look up — or a listing to look it up in — that + // is unanswerable, and pid-checking the launcher Voro spawned would + // declare a working agent dead. + LivenessSource::Listing => { + let sessions_cmd = config .as_ref() - .map(|entries| listing_says_live(entries, session_ref)) + .and_then(|c| c.agent(&session.agent)) + .and_then(|a| a.sessions()); + sessions_cmd + .zip(session.session_ref.as_deref()) + .and_then(|(cmd, session_ref)| { + let listing = listings + .entry(session.agent.clone()) + .or_insert_with(|| run_sessions_command(cmd, None)); + listing + .as_ref() + .map(|entries| listing_says_live(entries, session_ref)) + }) } - // A refining session with no ref is the interactive round: a real - // foreground child, so its pid is the round and is authoritative. - None if task_state == TaskState::Refining => session.pid.map(pid_is_alive), - // A dispatch with no ref is not findable in the listing, and - // pid-checking a supervisor-owned launch would wrongly kill it. - None if sessions_cmd.is_some() => None, - // No sessions verb: the spawned pid is all there is. No pid - // recorded means liveness can't be checked. - None => session.pid.map(pid_is_alive), }; // A recorded process that is still there proves the session is live // whatever the listing says: a quick message forks a `-p` turn that @@ -233,7 +242,13 @@ mod tests { let (mut s, task_id) = running_task(); // this test process's own pid is guaranteed alive let session = s - .create_session(task_id, "claude", Some(std::process::id() as i64), None) + .create_session( + task_id, + "claude", + Some(std::process::id() as i64), + LivenessSource::Pid, + None, + ) .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 0); @@ -251,7 +266,7 @@ mod tests { // a verb-less agent so liveness falls to the pid check let session = s - .create_session(task_id, "manual", Some(dead_pid), None) + .create_session(task_id, "manual", Some(dead_pid), LivenessSource::Pid, None) .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 1); @@ -279,6 +294,7 @@ mod tests { task_id, "manual", Some(dead_pid), + LivenessSource::Pid, Some(log.to_str().unwrap()), ) .unwrap(); @@ -292,7 +308,9 @@ mod tests { #[test] fn sessions_without_a_recorded_pid_are_left_alone() { let (mut s, task_id) = running_task(); - let session = s.create_session(task_id, "claude", None, None).unwrap(); + let session = s + .create_session(task_id, "claude", None, LivenessSource::Pid, None) + .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 0); assert!(s.session(session.id).unwrap().ended_at.is_none()); @@ -311,7 +329,7 @@ mod tests { let dead_pid = child.id() as i64; child.wait().unwrap(); let session = s - .create_session(task_id, "claude", Some(dead_pid), None) + .create_session(task_id, "claude", Some(dead_pid), LivenessSource::Pid, None) .unwrap(); s.apply(task_id, action.clone()).unwrap(); @@ -342,12 +360,18 @@ mod tests { }) .unwrap(); let dead_pid = dead_pid(); - // `claude` defines a `sessions` verb, so a *dispatch* of it with no - // captured ref would be left alone. A ref-less refine round is the - // interactive flavour — a foreground child whose pid Voro holds — so it - // is pid-checked. + // An interactive round records itself pid-authoritative — a foreground + // child whose pid Voro holds — so it is pid-checked whatever verbs its + // agent defines. let (_, session) = s - .record_refine_launch(t.id, "thin body", "claude", Some(dead_pid), None) + .record_refine_launch( + t.id, + "thin body", + "claude", + Some(dead_pid), + LivenessSource::Pid, + None, + ) .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 1); @@ -383,6 +407,7 @@ mod tests { "thin body", "claude", Some(std::process::id() as i64), + LivenessSource::Pid, None, ) .unwrap(); @@ -391,8 +416,9 @@ mod tests { assert_eq!(s.task(t.id).unwrap().state, TaskState::Refining); } - /// A proposal under refinement, ready to hang a round's session off of. - fn refining_task(agent: &str, pid: Option) -> (Store, i64, i64) { + /// A proposal under refinement, ready to hang a round's session off of, + /// launched as the flavour `source` names (DESIGN.md §8). + fn refining_task(agent: &str, pid: Option, source: LivenessSource) -> (Store, i64, i64) { let mut s = Store::open_in_memory().unwrap(); let p = s.create_project("proj", "/tmp/proj").unwrap(); let t = s @@ -409,7 +435,7 @@ mod tests { }) .unwrap(); let (_, session) = s - .record_refine_launch(t.id, "thin body", agent, pid, None) + .record_refine_launch(t.id, "thin body", agent, pid, source, None) .unwrap(); (s, t.id, session.id) } @@ -455,7 +481,13 @@ mod tests { let dead_pid = child.id() as i64; child.wait().unwrap(); let session = s - .create_session(task_id, "claude", Some(dead_pid), None) + .create_session( + task_id, + "claude", + Some(dead_pid), + LivenessSource::Listing, + None, + ) .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); @@ -485,7 +517,13 @@ mod tests { // now, so the listing is what decides (a *live* pid would override // it — see `a_live_pid_outlives_its_absence_from_the_listing`). let session = s - .create_session(task_id, "claude", Some(dead_pid()), None) + .create_session( + task_id, + "claude", + Some(dead_pid()), + LivenessSource::Listing, + None, + ) .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); @@ -518,7 +556,13 @@ mod tests { ); let (mut s, task_id) = running_task(); let session = s - .create_session(task_id, "claude", Some(dead_pid()), None) + .create_session( + task_id, + "claude", + Some(dead_pid()), + LivenessSource::Listing, + None, + ) .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); @@ -546,7 +590,9 @@ mod tests { ), ); let (mut s, task_id) = running_task(); - let session = s.create_session(task_id, "claude", None, None).unwrap(); + let session = s + .create_session(task_id, "claude", None, LivenessSource::Listing, None) + .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 0); @@ -565,7 +611,9 @@ mod tests { fn a_live_pid_outlives_its_absence_from_the_listing() { let (agents_path, dir) = sessions_fixture("message-fork", "[]"); let (mut s, task_id) = running_task(); - let session = s.create_session(task_id, "claude", None, None).unwrap(); + let session = s + .create_session(task_id, "claude", None, LivenessSource::Listing, None) + .unwrap(); s.record_session_send(session.id, Some("forked-uuid"), std::process::id() as i64) .unwrap(); @@ -576,18 +624,25 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// With a `sessions` verb configured but no captured ref, liveness is - /// unknowable: the session is left alone (pid-checking a supervisor-owned - /// launch would wrongly flag it), matching the no-pid case above. + /// A listing-authoritative session with no captured ref has nothing to look + /// up: liveness is unknowable and the session is left alone (pid-checking a + /// supervisor-owned launch would wrongly flag it), matching the no-pid case + /// above. #[test] - fn a_refless_session_of_a_sessions_agent_is_left_alone() { + fn a_refless_listing_session_is_left_alone() { let (agents_path, dir) = sessions_fixture("refless", "[]"); let (mut s, task_id) = running_task(); let mut child = Command::new("true").stdout(Stdio::null()).spawn().unwrap(); let dead_pid = child.id() as i64; child.wait().unwrap(); let session = s - .create_session(task_id, "claude", Some(dead_pid), None) + .create_session( + task_id, + "claude", + Some(dead_pid), + LivenessSource::Listing, + None, + ) .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 0); @@ -615,7 +670,9 @@ mod tests { ) .unwrap(); let (mut s, task_id) = running_task(); - let session = s.create_session(task_id, "claude", Some(1), None).unwrap(); + let session = s + .create_session(task_id, "claude", Some(1), LivenessSource::Listing, None) + .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 0); @@ -664,6 +721,7 @@ mod tests { task_id, "claude", Some(dead_pid()), + LivenessSource::Pid, Some(log.to_str().unwrap()), ) .unwrap(); @@ -693,6 +751,7 @@ mod tests { task_id, "claude", Some(dead_pid()), + LivenessSource::Pid, Some(log.to_str().unwrap()), ) .unwrap(); @@ -721,7 +780,13 @@ mod tests { std::fs::write(&log, "Session limit reached · resets 9pm").unwrap(); let session = s - .create_session(task_id, "manual", Some(dead), Some(log.to_str().unwrap())) + .create_session( + task_id, + "manual", + Some(dead), + LivenessSource::Pid, + Some(log.to_str().unwrap()), + ) .unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 1); assert_eq!( @@ -731,7 +796,7 @@ mod tests { let _ = std::fs::remove_file(&log); } - // --- refine rounds read the same listing (task #379) --- + // --- refine rounds read the source they recorded (tasks #379, #387) --- /// The refine-side twin of /// [`a_listed_live_session_is_left_alone_despite_a_dead_pid`]: a headless @@ -746,7 +811,8 @@ mod tests { r#"[{"sessionId": "refine-uuid", "cwd": "/tmp/proj", "startedAt": 1, "state": "working"}]"#, ); - let (mut s, task_id, session_id) = refining_task("claude", Some(dead_pid())); + let (mut s, task_id, session_id) = + refining_task("claude", Some(dead_pid()), LivenessSource::Listing); s.set_session_ref(session_id, "refine-uuid").unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 0); @@ -758,11 +824,12 @@ mod tests { } /// The other half: a round genuinely gone from the listing is still caught - /// and still marked, however alive the pid on the row looks. + /// and still marked, the launcher pid on its row proving nothing either way. #[test] fn a_refine_round_gone_from_the_listing_is_marked_failed() { let (agents_path, dir) = sessions_fixture("refine-gone", "[]"); - let (mut s, task_id, session_id) = refining_task("claude", Some(dead_pid())); + let (mut s, task_id, session_id) = + refining_task("claude", Some(dead_pid()), LivenessSource::Listing); s.set_session_ref(session_id, "refine-uuid").unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 1); @@ -780,7 +847,8 @@ mod tests { /// for it that pid is right: rounds still reconcile by pid, dead and alive. #[test] fn a_refine_round_of_a_verbless_agent_is_pid_checked() { - let (mut s, task_id, session_id) = refining_task("manual", Some(dead_pid())); + let (mut s, task_id, session_id) = + refining_task("manual", Some(dead_pid()), LivenessSource::Pid); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 1); assert_eq!(s.task(task_id).unwrap().state, TaskState::Proposed); assert!(s.refine_failed_flag(task_id).unwrap()); @@ -789,8 +857,67 @@ mod tests { Some(SessionOutcome::Failed) ); - let (mut s, task_id, _) = refining_task("manual", Some(std::process::id() as i64)); + let (mut s, task_id, _) = refining_task( + "manual", + Some(std::process::id() as i64), + LivenessSource::Pid, + ); assert_eq!(reconcile_live_sessions(&mut s, &no_config()).unwrap(), 0); assert_eq!(s.task(task_id).unwrap().state, TaskState::Refining); } + + /// The tail #379 left open (task #387): a *headless* round whose ref capture + /// timed out. Its recorded pid is a `--bg` launcher, dead within a second of + /// the launch, and inferring the flavour from the missing ref read that + /// round as the interactive one and finalised it `failed` while its agent + /// was still rewriting the body. Recorded listing-authoritative, it is + /// simply unprobeable — left in `refining` until the rewrite lands or the + /// listing says it is gone, exactly as a ref-less dispatch already was. + #[test] + fn a_headless_refine_round_with_no_ref_is_left_alone() { + let (agents_path, dir) = sessions_fixture("refine-refless", "[]"); + let (mut s, task_id, session_id) = + refining_task("claude", Some(dead_pid()), LivenessSource::Listing); + + assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 0); + assert!(s.session(session_id).unwrap().ended_at.is_none()); + assert_eq!(s.task(task_id).unwrap().state, TaskState::Refining); + assert!(!s.refine_failed_flag(task_id).unwrap()); + + let _ = std::fs::remove_dir_all(&dir); + } + + /// The flavour the fallback existed for, now saying so on its row: an + /// interactive round is a foreground `plan` child that appears in no + /// listing, so its pid decides even under an agent whose listing is right + /// there and empty. A Voro that dies mid-conversation therefore still + /// leaves a round another window's reconcile can finish. + #[test] + fn an_interactive_refine_round_is_pid_checked_under_a_sessions_agent() { + let (agents_path, dir) = sessions_fixture("refine-interactive", "[]"); + let (mut s, task_id, session_id) = + refining_task("claude", Some(dead_pid()), LivenessSource::Pid); + + assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 1); + assert_eq!(s.task(task_id).unwrap().state, TaskState::Proposed); + assert!(s.refine_failed_flag(task_id).unwrap()); + assert_eq!( + s.session(session_id).unwrap().outcome, + Some(SessionOutcome::Failed) + ); + + // The live half of the same flavour: a conversation still going is left + // alone, however empty the listing it never joined. + let (agents_path2, dir2) = sessions_fixture("refine-interactive-live", "[]"); + let (mut s, task_id, _) = refining_task( + "claude", + Some(std::process::id() as i64), + LivenessSource::Pid, + ); + assert_eq!(reconcile_live_sessions(&mut s, &agents_path2).unwrap(), 0); + assert_eq!(s.task(task_id).unwrap().state, TaskState::Refining); + + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); + } } diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index 320d5a9..f182956 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -2214,7 +2214,7 @@ pub fn popup_area(frame: &mut Frame, width: u16, height: u16) -> Rect { #[cfg(test)] mod tests { use super::*; - use voro_core::{Priority, Task, TaskState}; + use voro_core::{LivenessSource, Priority, Task, TaskState}; /// The refusal `g` gives on a checkout `gh` cannot address (DESIGN.md §8) — /// the shape every wrapping test here cares about: long, and closing on the @@ -2269,7 +2269,9 @@ mod tests { } let live: Vec = (0..running).map(|i| task(format!("live {i}"))).collect(); for id in live { - store.record_dispatch(id, "claude", None, None).unwrap(); + store + .record_dispatch(id, "claude", None, LivenessSource::Listing, None) + .unwrap(); } let ctx = crate::dispatch::DispatchCtx::without_config(std::path::Path::new( "/nonexistent/voro.db", @@ -2727,7 +2729,9 @@ mod tests { }) .unwrap() .id; - store.record_dispatch(task, "claude", None, None).unwrap(); + store + .record_dispatch(task, "claude", None, LivenessSource::Listing, None) + .unwrap(); store .apply(task, Action::Complete(Some("did it".into()))) .unwrap(); @@ -2794,7 +2798,7 @@ mod tests { store.apply(closed_dependent, Action::Abandon).unwrap(); store - .record_dispatch(handed_off, "claude", None, None) + .record_dispatch(handed_off, "claude", None, LivenessSource::Pid, None) .unwrap(); store.apply(handed_off, Action::Complete(None)).unwrap(); store.apply(handed_off, Action::HandOff).unwrap(); @@ -2802,7 +2806,7 @@ mod tests { .set_pr(handed_off, Some("https://github.com/o/r/pull/9")) .unwrap(); store - .record_dispatch(under_way, "claude", None, None) + .record_dispatch(under_way, "claude", None, LivenessSource::Pid, None) .unwrap(); let ctx = crate::dispatch::DispatchCtx::without_config(std::path::Path::new( @@ -2895,7 +2899,9 @@ mod tests { }) .unwrap() .id; - store.record_dispatch(task, "claude", None, None).unwrap(); + store + .record_dispatch(task, "claude", None, LivenessSource::Listing, None) + .unwrap(); let ctx = crate::dispatch::DispatchCtx::without_config(std::path::Path::new( "/nonexistent/voro.db", @@ -3012,10 +3018,14 @@ mod tests { .collect(); for (id, _) in &running { - store.record_dispatch(*id, "claude", None, None).unwrap(); + store + .record_dispatch(*id, "claude", None, LivenessSource::Listing, None) + .unwrap(); } for (id, _) in &handed_off { - store.record_dispatch(*id, "claude", None, None).unwrap(); + store + .record_dispatch(*id, "claude", None, LivenessSource::Listing, None) + .unwrap(); store.apply(*id, Action::Complete(None)).unwrap(); store.apply(*id, Action::HandOff).unwrap(); } @@ -3080,7 +3090,14 @@ mod tests { }) .unwrap(); store - .record_refine_launch(task.id, "name the files", "claude", None, None) + .record_refine_launch( + task.id, + "name the files", + "claude", + None, + LivenessSource::Pid, + None, + ) .unwrap(); let ctx = crate::dispatch::DispatchCtx::without_config(std::path::Path::new( @@ -3144,7 +3161,14 @@ mod tests { }) .unwrap(); store - .record_refine_launch(task.id, "name the files", "claude", None, None) + .record_refine_launch( + task.id, + "name the files", + "claude", + None, + LivenessSource::Pid, + None, + ) .unwrap(); store .conclude_refine(task.id, RefineOutcome::Failed) @@ -3663,7 +3687,7 @@ mod tests { .create_task(new("died", TaskState::Ready, false)) .unwrap(); let (_, session) = store - .record_dispatch(redispatch.id, "claude", Some(1), None) + .record_dispatch(redispatch.id, "claude", Some(1), LivenessSource::Pid, None) .unwrap(); store.reconcile_session(session.id, false, false).unwrap(); let do_ = store @@ -4028,7 +4052,13 @@ mod tests { }) .unwrap(); let (_, session) = store - .record_dispatch(task.id, "claude", Some(1), Some("/tmp/voro/s.log")) + .record_dispatch( + task.id, + "claude", + Some(1), + LivenessSource::Pid, + Some("/tmp/voro/s.log"), + ) .unwrap(); store.reconcile_session(session.id, false, capped).unwrap(); App::new(store, ctx()).unwrap() @@ -4110,7 +4140,13 @@ mod tests { }) .unwrap(); store - .record_dispatch(task.id, "claude", Some(1), Some("/tmp/voro/open.log")) + .record_dispatch( + task.id, + "claude", + Some(1), + LivenessSource::Pid, + Some("/tmp/voro/open.log"), + ) .unwrap(); store.apply(task.id, Action::Ask("A or B?".into())).unwrap(); @@ -4164,7 +4200,13 @@ mod tests { }) .unwrap(); store - .record_dispatch(task.id, "claude", Some(1), Some("/tmp/voro/open.log")) + .record_dispatch( + task.id, + "claude", + Some(1), + LivenessSource::Pid, + Some("/tmp/voro/open.log"), + ) .unwrap(); store .apply( @@ -4449,7 +4491,7 @@ mod tests { .create_task(task("in review", TaskState::Ready)) .unwrap(); store - .record_dispatch(reviewed.id, "claude", None, None) + .record_dispatch(reviewed.id, "claude", None, LivenessSource::Pid, None) .unwrap(); store.apply(reviewed.id, Action::Complete(None)).unwrap(); // ...and a refine in flight for the cancel slot, which rides the strip @@ -4458,7 +4500,14 @@ mod tests { .create_task(task("being rewritten", TaskState::Proposed)) .unwrap(); store - .record_refine_launch(refining.id, "thin body", "claude", None, None) + .record_refine_launch( + refining.id, + "thin body", + "claude", + None, + LivenessSource::Pid, + None, + ) .unwrap(); let mut app = App::new( store, @@ -4929,7 +4978,7 @@ mod tests { let third = ready_task(&mut store, p.id, "third"); let live = ready_task(&mut store, p.id, "in flight"); store - .record_dispatch(live.id, "claude", None, None) + .record_dispatch(live.id, "claude", None, LivenessSource::Pid, None) .unwrap(); let mut app = test_app(store); diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0174846..5fa0c76 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -146,6 +146,12 @@ CREATE TABLE sessions ( task_id INTEGER NOT NULL REFERENCES tasks(id), agent TEXT NOT NULL, -- the agent actually used pid INTEGER, + session_ref TEXT, -- the agent's own reference for the session (§8), + -- captured after launch; NULL if it never was + liveness_source TEXT NOT NULL DEFAULT 'listing' + CHECK (liveness_source IN ('pid','listing')), + -- which source reconciliation reads this session's + -- liveness by (§8), set by whichever code spawned it log_path TEXT, started_at TEXT NOT NULL, ended_at TEXT, @@ -223,9 +229,9 @@ A round ends by returning to `proposed`, and *how* it ended is what the returned That honesty needs one backstop, because the second trigger can fire on a round that is not actually dead — a liveness probe reading a detached launcher's pid is exactly how (§8) — and the rewrite then arrives at a task already back in `proposed`, where the first trigger can no longer fire: the body lands under a marker saying no rewrite happened, which is the one failure that trains the operator to ignore the marker. So a `voro set` carrying `--body`/`--body-file` on a `proposed` task whose *last* round concluded `failed` corrects that round's recorded outcome to applied, and the row reads `↻ refined`. It is a correction, not a fifth trigger: nothing reopens, the task does not transition, and the round's session keeps the outcome the reconciler observed of its process, since what was observed and what the round achieved are different claims. The TUI's own body editor corrects nothing, because an operator rewriting a body in place is not a round landing late; the correction rides the CLI verb the refine prompts already end in, which is the agent's interface. -Refine runs on the *default* agent whatever override the task carries, since an agent override picks who executes a task, not who writes its brief. It opens a `sessions` row like a dispatch — the pid is what reconcile probes, the log is where the launcher's banner lands, and the strip reads both — which costs nothing against the one-open-session invariant (§8): a proposal has no other open session, and by the time it can be dispatched the refine round has concluded and closed its own. The session is *named* as well — `voro--refine` (§8) — so the operator can find it in the agent's own fleet listing and attach to it, which matters precisely because the launcher exits at birth and the log holds its banner rather than the rewrite. +Refine runs on the *default* agent whatever override the task carries, since an agent override picks who executes a task, not who writes its brief. It opens a `sessions` row like a dispatch — recorded with the same liveness source that agent's dispatch would carry, so reconcile probes the round exactly as it probes a dispatch (§8), the log is where the launcher's banner lands, and the strip reads both — which costs nothing against the one-open-session invariant (§8): a proposal has no other open session, and by the time it can be dispatched the refine round has concluded and closed its own. The session is *named* as well — `voro--refine` (§8) — so the operator can find it in the agent's own fleet listing and attach to it, which matters precisely because the launcher exits at birth and the log holds its banner rather than the rewrite. -Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. It opens a session row like the headless flavour, recorded once the foreground child's pid is known so a Voro that dies mid-conversation leaves a round another window's reconcile can still finish; on return the round concludes as applied if the agent's own `set --body-file` already ended it, and as cancelled otherwise. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer over a selected row whose body is still a brief, proposal or `ready` alike — `r` collects a note, `R` opens the conversation — and *only* there, not from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a refined proposal comes back for a verdict rather than having received one, so putting refine there filed it under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key is. The menu does not keep a second copy: one key in one place is the whole point of moving it, and a duplicate would reintroduce the claim that refine is something the verdict menu does. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` or `ready` task, before a prompt is written or a process spawned. +Refine has a second, interactive intensity for the case where a note is not enough. Given no note it opens the planning session of §8 seeded with the task that already exists — the same `plan` verb and the same foreground round-trip as `N`, ending in `set --body-file` rather than `add`, so it edits in place and creates nothing. It opens a session row like the headless flavour, recorded once the foreground child's pid is known and marked pid-authoritative (§8), since that pid is the round itself rather than a launcher, so a Voro that dies mid-conversation leaves a round another window's reconcile can still finish; on return the round concludes as applied if the agent's own `set --body-file` already ended it, and as cancelled otherwise. Because it is a conversation with an agent it is TUI-only for the same reason planning sessions are: the CLI is how an LLM drives Voro, so a note-less `refine` there errors and points at the TUI. Both intensities answer over a selected row whose body is still a brief, proposal or `ready` alike — `r` collects a note, `R` opens the conversation — and *only* there, not from behind the triage menu, because that menu collects *verdicts* and refine is deliberately not one (above): a refined proposal comes back for a verdict rather than having received one, so putting refine there filed it under a decision it does not make, and hid it one keypress behind the very menu whose three bad options it exists to escape. The operator notices a sub-standard body while reading it in the queue, which is where the key is. The menu does not keep a second copy: one key in one place is the whole point of moving it, and a duplicate would reintroduce the claim that refine is something the verdict menu does. Refresh moves to `ctrl-r` to free the letter, the manual counterpart to the refresh every mutating action already performs. The two intensities share the note-driven path's guards — both are refused on anything but a `proposed` or `ready` task, before a prompt is written or a process spawned. The note-driven path is one instance of a general shape: a terse human intent, expanded by an agent into a formal artefact, applied back through an ordinary CLI verb. Expanding a review rejection's one-line feedback the same way is the obvious next instance, so the seed-context-plus-note → agent → apply-via-verb plumbing is factored (`Expansion` in the `voro` crate) rather than written into refine alone. Its identity comes from the same `Launch` value every launch uses (§8), so the next instance inherits a session name, a prompt/log file slug and a launch-log label by adding a variant rather than computing each of them again. @@ -299,11 +305,13 @@ Cheap actions need one further guard, or the pricing swaps one swamping for anot **Observing the end of a session** is the other half of the loop, and has to answer a wrinkle: the `voro` invocation that dispatched a session may not outlive it — a one-shot `voro dispatch` returns immediately, and a TUI session watching it can simply be closed before the agent finishes. Because healthy sessions are now closed by the transitions above, reconciliation no longer has to be the thing that eventually closes them; it keeps only the job it is uniquely able to do — catch a session whose process died without reporting, on a `running` task or a `refining` one — plus tidy a row left stranded on a task that has already closed. There is no daemon or waiter; instead Voro reconciles on read. Every code path that consults live session or task state — `App::refresh` in the TUI, and every CLI verb — first calls a reconciler that walks `sessions` where `ended_at` is still null and, per session, acts on its task's state: -- task still `running`: check whether the session's process is alive — by the agent's own `sessions` listing where one is configured, else `kill -0` on the pid the session row holds (both run from the `voro` crate; `voro-core` never touches a process, it classifies a listing entry from its fields alone and takes the liveness result as a plain bool to decide what it means). What a listing entry *says* about liveness is the contract every consumer of the listing reads it by, here and in the two below. An entry is dead once its `state` is `done`; failing that, a `pid` it names is authoritative — the session is live exactly while that process exists — and only failing *both* does the state stand alone, where `working` reads live and everything else, an unrecognised state or none at all, reads dead. Not-`done` cannot mean live, because an agent's listing is under no obligation to retire an entry: `claude agents --json` leaves long-dead sessions sitting at `blocked` indefinitely, so the earlier not-`done` rule read a listing of mostly zombies as a fleet of running agents, and the operator's `A` reached for `attach` on a session the agent no longer had. Reading the pid rather than the state vocabulary is what keeps the *other* direction right at the same time: a session genuinely stuck mid-turn — blocked on a permission prompt, supervisor alive — is exactly what the operator wants to attach to, and a rule that trusted only a state word would have thrown it out with the zombies. An entry carrying neither field claims nothing, and a listing whose entries never carry either is one Voro cannot read liveness from at all — which is a defect in that agent's `sessions` verb, not a state Voro guesses around. If the process has gone, the agent ended without calling `done` or `ask`: the session outcome is recorded (`capped` if the log tail matches a short list of known usage-limit phrases, else `failed`) and the task lands **`running → stalled`** in the same transaction, tagged with a distinct `reconcile` event. A vanished session is indistinguishable from a normal completion whose `voro done` has not yet landed, which is what makes `stalled` — an attention state — the safe landing: even the misfire case surfaces as a queue row a human looks at rather than work `voro next` hands out, and because a stalled task is only ever redispatched by hand, a late `done` cannot race a fresh dispatch. That safety does not require refusing the `done` itself: completion is accepted from `stalled` directly (`stalled → review`, §6), reporting the dead session's finished work on its behalf — the operator having read the log, or the missing report finally landing. The session is already closed, so no lifecycle work rides the edge. From `stalled` the human redispatches (with the predecessor's notes/log), completes it into review, parks, or abandons. An orphaned `running` row (§9) means only a task started by hand or one whose liveness could not be determined; the one-open-session invariant means a reconciled session is always the task's current one, so a lingering listing entry for an earlier, already-closed session can never keep the task counted as live. -- task `refining`: the same probe, with a different landing, and read from the same two sources by the same rules. A headless round renders the agent's own `dispatch` template (§6), so it inherits that template's caveat whole: under a `--bg` launcher the pid Voro spawns is a launcher that exits at birth, and pid-checking it declares the round dead seconds after it starts, while the agent works on — which mismarks the rewrite that then lands, and so teaches the operator to disbelieve the very marker that exists to say a rewrite silently never happened. A round therefore captures a session ref at launch, exactly as a dispatch does and from the same listing, and is read from that listing wherever it has one. The interactive round is the genuine own-pid case and the exception the rule now names: a foreground `plan` child, no supervisor and no ref by construction, so a refining session carrying no ref is pid-checked — as is one whose agent defines no `sessions` verb at all, where the spawned pid is the only source there is. If the round has gone, the rewrite never landed: the session is finalised `failed` and the task returns **`refining → proposed`**, marked `⚠ refine failed` so the operator reads the old body knowing so (§6). +- task still `running`: check whether the session's process is alive — by the agent's own `sessions` listing, or by `kill -0` on the pid the session row holds, whichever that row's `liveness_source` names (both run from the `voro` crate; `voro-core` never touches a process, it classifies a listing entry from its fields alone and takes the liveness result as a plain bool to decide what it means). What a listing entry *says* about liveness is the contract every consumer of the listing reads it by, here and in the two below. An entry is dead once its `state` is `done`; failing that, a `pid` it names is authoritative — the session is live exactly while that process exists — and only failing *both* does the state stand alone, where `working` reads live and everything else, an unrecognised state or none at all, reads dead. Not-`done` cannot mean live, because an agent's listing is under no obligation to retire an entry: `claude agents --json` leaves long-dead sessions sitting at `blocked` indefinitely, so the earlier not-`done` rule read a listing of mostly zombies as a fleet of running agents, and the operator's `A` reached for `attach` on a session the agent no longer had. Reading the pid rather than the state vocabulary is what keeps the *other* direction right at the same time: a session genuinely stuck mid-turn — blocked on a permission prompt, supervisor alive — is exactly what the operator wants to attach to, and a rule that trusted only a state word would have thrown it out with the zombies. An entry carrying neither field claims nothing, and a listing whose entries never carry either is one Voro cannot read liveness from at all — which is a defect in that agent's `sessions` verb, not a state Voro guesses around. If the process has gone, the agent ended without calling `done` or `ask`: the session outcome is recorded (`capped` if the log tail matches a short list of known usage-limit phrases, else `failed`) and the task lands **`running → stalled`** in the same transaction, tagged with a distinct `reconcile` event. A vanished session is indistinguishable from a normal completion whose `voro done` has not yet landed, which is what makes `stalled` — an attention state — the safe landing: even the misfire case surfaces as a queue row a human looks at rather than work `voro next` hands out, and because a stalled task is only ever redispatched by hand, a late `done` cannot race a fresh dispatch. That safety does not require refusing the `done` itself: completion is accepted from `stalled` directly (`stalled → review`, §6), reporting the dead session's finished work on its behalf — the operator having read the log, or the missing report finally landing. The session is already closed, so no lifecycle work rides the edge. From `stalled` the human redispatches (with the predecessor's notes/log), completes it into review, parks, or abandons. An orphaned `running` row (§9) means only a task started by hand or one whose liveness could not be determined; the one-open-session invariant means a reconciled session is always the task's current one, so a lingering listing entry for an earlier, already-closed session can never keep the task counted as live. +- task `refining`: the same probe, with a different landing, and read by the same recorded source. A headless round renders the agent's own `dispatch` template (§6), so it inherits that template's caveat whole: under a `--bg` launcher the pid Voro spawns is a launcher that exits at birth, and pid-checking it declares the round dead seconds after it starts, while the agent works on — which mismarks the rewrite that then lands, and so teaches the operator to disbelieve the very marker that exists to say a rewrite silently never happened. A headless round is therefore listing-authoritative like the dispatch it renders, and captures a session ref at launch from the same listing. The interactive round is the genuine own-pid case: a foreground `plan` child, no supervisor and no ref by construction, so it records itself pid-authoritative and is pid-checked — as is any round of an agent defining no `sessions` verb, where the spawned pid is the only source there is. If the round has gone, the rewrite never landed: the session is finalised `failed` and the task returns **`refining → proposed`**, marked `⚠ refine failed` so the operator reads the old body knowing so (§6). - task `needs-input`, `review`, or `waiting`: the session is meant to stay open — the operator answers in it, or a reject-with-feedback returns the work to it — so reconciliation leaves it untouched regardless of process liveness. A lingering `blocked`/`null` listing entry that never says `done` therefore no longer matters: nothing here depends on the listing eventually retiring the session. - task already closed, or otherwise off the active path (`done`/`rejected`): a still-open session is stale — the terminal transition should have closed it — so it is finalised now (`completed` for `done`, else `aborted`), with no event. This is what heals a legacy stranded row (a `done` task still carrying an open session) on the next pass, without manual SQL. +**Which of the two sources owns a session** is a property of the launch, not of anything else the row happens to carry, so it is recorded at the spawn (`sessions.liveness_source`, §5) by the code that performed it rather than inferred at reconciliation. Dispatch and the headless refine record the flavour of the agent's `dispatch` template — `listing` where the agent defines a `sessions` verb, since such a launch may hand the work to a supervisor and the listing is then the only source that can answer, `pid` where it defines none and the spawned pid is all there is — and the interactive refine records `pid`, being a foreground `plan` child Voro owns. The recorded source names which probe answers, not whether the row's pid is read at all: a live pid still proves the session live whichever source owns it (above). The rule this replaces read the flavour off the absence of a session ref: a `refining` session with no ref was taken for the interactive round. That was right for the flavour it was written for and wrong for the one it could not tell apart, because a headless round whose ref capture timed out has no ref either, and pid-checking it finalised the round `failed` within a second of launch while its agent went on rewriting the body. The late-rewrite backstop (§6) corrects the *marker* when the rewrite finally lands, but not the early exit from `refining` — and leaving that state early is what lets a second window hand a verdict to a proposal whose body is about to be replaced, the race the state exists to close. Recorded rather than guessed, such a round is simply unprobeable until its ref appears or its listing answers, and is left alone exactly as a ref-less dispatch already was: liveness Voro cannot determine is never grounds for finalising a session. The column is additive, and sessions already open when it lands default to `listing` — what every dispatch under an agent with a `sessions` verb already was, and the direction that leaves a session alone rather than killing a live one. + **Usage-cap detection** stays a substring match over a few KB of text for phrases like "usage limit" — deliberately narrow, and asymmetric on purpose: a cap worded in a way the list does not know is reported `failed` rather than `capped`, a labelling gap and not a functional one since both outcomes stall the task for redispatch identically, whereas a *false* cap would badge healthy work as stuck and teach the operator to disbelieve the marker. The list is therefore qualified rather than merely widened: "approaching", "80% of your", "not your" each take a match back, because an agent says all three about a limit it has not hit. What the list must cover is what agents actually write, which is not what the original three phrases assumed — Claude Code words a five-hour cap "Session limit reached" and a weekly one "Weekly limit reached", neither of which contains "usage limit", "rate limit" or "quota exceeded", so the generic phrases matched almost no real cap. *Which text* is scanned is the substantive question, and Voro's own launch log is the wrong answer for the launches that matter. Under a supervisor-owned launch (`claude --bg`) the launcher exits at birth having written nothing but the backgrounding banner, so scanning that log could essentially never report `capped` however the session died. The agent's verb set therefore gains an optional **`logs`**: a session in (`{session}`), that session's recent output out. It is an opaque per-agent contract like the rest and degrades like the rest — an agent that defines none is classified from the launch-log tail exactly as before, and the built-in `codex` defines none. The text it returns may be a terminal capture rather than a log, so escape sequences are stripped before matching, with cursor movement becoming a space (it stands for the gap between two words) and colour vanishing (it does not, and would otherwise split the phrase it styles).