From b47a9236d00abcefe2e314b812d6fc71dfdd44e1 Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Wed, 12 Aug 2026 14:00:36 +0100 Subject: [PATCH] Record which liveness source owns a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation inferred the flavour of a refine round from the absence of a session ref: no ref meant the interactive round, whose foreground pid genuinely is the work. That could not tell a headless round whose ref capture timed out apart from it, and for that one the pid is a `--bg` launcher dead within a second — so the round was finalised `failed` while its agent went on rewriting the body. The late-rewrite backstop corrects the marker afterwards, but not the early exit from `refining`, which is what lets a second window hand a verdict to a proposal whose body is about to be replaced. Which source is authoritative is a property of the launch, so record it there: `sessions.liveness_source` ('pid' | 'listing'), an additive column set by the code that spawned the process. Dispatch and the headless refine record the flavour of the agent's `dispatch` template — listing where the agent defines a `sessions` verb, pid where it does not — and the interactive refine records pid, being a foreground `plan` child. Reconciliation reads pid-authoritative sessions by pid and listing-authoritative ones by listing, leaving a listing-authoritative round with no ref unprobeable rather than guessed, exactly as a ref-less dispatch already was. Sessions open across the migration default to 'listing' — what every dispatch of an agent with a `sessions` verb already was, and the direction that leaves a session alone rather than finalising a live one. DESIGN.md §5 gains the column (and the `session_ref` one it never recorded), §6 notes what each refine flavour records, and §8 states the rule that replaces the ref-presence inference. Tests cover both flavours in `voro-core` and in reconcile, including the headless round with no ref staying `refining` and the interactive round still reconciling by pid under an agent with a listing. Verified: `cargo test --workspace`, `cargo clippy --workspace --all-targets -- -D warnings` and `cargo fmt --all` clean; the migration applied to a copy of the live database, which opened at user_version 17 with its open sessions defaulting to 'listing' and none wrongly finalised. --- .../0017_session_liveness_source.sql | 14 + crates/voro-core/src/agent.rs | 50 +++ crates/voro-core/src/lib.rs | 4 +- crates/voro-core/src/model.rs | 55 +++ crates/voro-core/src/scheduler.rs | 17 +- crates/voro-core/src/store.rs | 314 +++++++++++++++--- crates/voro-core/src/transition.rs | 162 +++++++-- crates/voro/src/app.rs | 45 ++- crates/voro/src/cli.rs | 64 +++- crates/voro/src/dispatch.rs | 34 +- crates/voro/src/reconcile.rs | 252 ++++++++++---- crates/voro/src/ui.rs | 63 +++- docs/DESIGN.md | 16 +- 13 files changed, 881 insertions(+), 209 deletions(-) create mode 100644 crates/voro-core/migrations/0017_session_liveness_source.sql diff --git a/crates/voro-core/migrations/0017_session_liveness_source.sql b/crates/voro-core/migrations/0017_session_liveness_source.sql new file mode 100644 index 0000000..96dc33b --- /dev/null +++ b/crates/voro-core/migrations/0017_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 f448d04..878c226 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}; @@ -702,6 +703,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 @@ -2246,6 +2265,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 1cd2da7..d73a4cc 100644 --- a/crates/voro-core/src/lib.rs +++ b/crates/voro-core/src/lib.rs @@ -24,8 +24,8 @@ pub use agent::{ 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, - ReviewAction, ReviewMedium, RunningRow, Session, SessionOutcome, Task, TaskState, + Dep, DepKind, DepRef, Doc, Event, LivenessSource, NextAction, Priority, Project, RefineOutcome, + Repo, ReviewAction, ReviewMedium, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, }; pub use pr::{Mergeability, PrPlan, PrRef, format_review_feedback, parse_mergeable, plan_pr}; diff --git a/crates/voro-core/src/model.rs b/crates/voro-core/src/model.rs index 75c8a4e..7e00f97 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, @@ -628,6 +680,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 b830430..79e3f0b 100644 --- a/crates/voro-core/src/scheduler.rs +++ b/crates/voro-core/src/scheduler.rs @@ -472,7 +472,7 @@ pub struct StateCounts { #[cfg(test)] mod tests { use super::*; - use crate::model::TaskState; + use crate::model::{LivenessSource, TaskState}; use crate::store::NewTask; #[test] @@ -660,7 +660,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(); } @@ -1088,8 +1090,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/store.rs b/crates/voro-core/src/store.rs index f99d0d3..d2a4294 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, ReviewAction, - RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, + Dep, DepKind, DepRef, Doc, Event, LivenessSource, Priority, Project, RefineOutcome, Repo, + ReviewAction, RunningRow, Session, SessionOutcome, Task, TaskState, location_is_url, }; const MIGRATIONS: &[&str] = &[ @@ -26,6 +26,7 @@ const MIGRATIONS: &[&str] = &[ include_str!("../migrations/0014_docs.sql"), include_str!("../migrations/0015_dep_kind_in_key.sql"), include_str!("../migrations/0016_add_refining_state.sql"), + include_str!("../migrations/0017_session_liveness_source.sql"), ]; /// Owns the SQLite database. All writes go through this type; task state in @@ -1216,14 +1217,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) } @@ -1475,8 +1479,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 @@ -1495,10 +1498,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)?, }) } @@ -1565,13 +1569,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()) } @@ -1968,7 +1973,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(); } _ => { @@ -1992,7 +1999,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)); @@ -2421,8 +2429,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 => { @@ -2447,7 +2462,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 } @@ -3242,6 +3259,7 @@ mod tests { " name the files it touches ", "claude", Some(4321), + LivenessSource::Pid, Some("/var/log/refine.log"), ) .unwrap(); @@ -3269,7 +3287,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); @@ -3290,7 +3308,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}"); @@ -3312,7 +3330,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()); @@ -3344,7 +3362,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(); @@ -3358,7 +3376,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(); @@ -3381,7 +3399,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(); @@ -3406,7 +3424,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. @@ -3445,7 +3463,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"); @@ -3472,11 +3496,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(); @@ -3493,7 +3529,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()); @@ -3503,7 +3541,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 @@ -3521,6 +3561,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"); + } + #[test] fn end_session_rejects_unknown_id() { let mut s = Store::open_in_memory().unwrap(); @@ -3534,8 +3719,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!( @@ -3548,8 +3737,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(); @@ -3560,7 +3753,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); @@ -3576,8 +3771,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(); @@ -3592,7 +3791,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", @@ -3644,7 +3845,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(); @@ -3659,7 +3862,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(); @@ -3678,7 +3883,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(); @@ -3714,7 +3926,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(); @@ -3803,24 +4017,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); @@ -3833,7 +4050,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(); @@ -3845,7 +4063,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 18e52f8..6f577da 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, DepKind, DepRef, DigestRow, Event, PrRef, Priority, Project, - Queue, QueueRow, RefineOutcome, ReviewAction, ReviewMedium, ReworkReport, RunningRow, - ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler, + Action, ActionRow, AgentsConfig, DepKind, DepRef, DigestRow, Event, LivenessSource, PrRef, + Priority, Project, Queue, QueueRow, RefineOutcome, ReviewAction, ReviewMedium, ReworkReport, + RunningRow, ScoreBreakdown, StateCounts, Store, Task, TaskState, Triage, WipGate, scheduler, }; /// Lines `PgDn`/`PgUp` move the focus card in one press. A fixed step, since @@ -1535,10 +1535,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)); } } @@ -3003,7 +3011,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)); @@ -3062,7 +3070,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(); @@ -3071,7 +3079,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(); } @@ -3086,6 +3100,7 @@ mod tests { "thin body", "claude", None, + LivenessSource::Pid, Some("/tmp/demo/refine.log"), ) .unwrap(); @@ -5133,7 +5148,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(); @@ -5184,7 +5205,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 767f22c..974c2f5 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -2304,6 +2304,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() @@ -2725,8 +2726,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")); @@ -2761,8 +2769,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")); @@ -2772,7 +2787,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")); @@ -2790,8 +2805,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())); @@ -2817,8 +2839,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); @@ -2837,8 +2866,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()); @@ -2868,7 +2904,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"] { @@ -3862,7 +3898,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 6d8db60..84656fd 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -814,15 +814,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 { @@ -1266,6 +1267,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 { @@ -1471,7 +1473,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 @@ -1576,9 +1578,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(); @@ -2694,15 +2699,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( @@ -2713,7 +2719,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); } @@ -2981,7 +2989,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 a15b869..0396d5c 100644 --- a/crates/voro/src/reconcile.rs +++ b/crates/voro/src/reconcile.rs @@ -12,26 +12,30 @@ //! 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 +//! 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 would declare every such session dead, so it is never //! consulted for them, 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. //! -//! 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 it: 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. //! //! There is no daemon watching for process exit. Reconciliation runs on read: //! `App::refresh` and every CLI verb call [`reconcile_live_sessions`] before @@ -42,7 +46,7 @@ use std::collections::HashMap; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; -use voro_core::{AgentSessionEntry, AgentsConfig, Result, Store, TaskState}; +use voro_core::{AgentSessionEntry, AgentsConfig, LivenessSource, Result, Store, TaskState}; use crate::session_probe::{listing_says_live, pid_is_alive, run_sessions_command}; @@ -65,8 +69,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(); @@ -83,30 +88,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), }; let Some(alive) = alive else { continue }; if alive { @@ -193,7 +200,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); @@ -211,7 +224,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); @@ -239,6 +252,7 @@ mod tests { task_id, "manual", Some(dead_pid), + LivenessSource::Pid, Some(log.to_str().unwrap()), ) .unwrap(); @@ -252,7 +266,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()); @@ -271,7 +287,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(); @@ -302,12 +318,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); @@ -343,6 +365,7 @@ mod tests { "thin body", "claude", Some(std::process::id() as i64), + LivenessSource::Pid, None, ) .unwrap(); @@ -351,8 +374,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 @@ -369,7 +393,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) } @@ -415,7 +439,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(); @@ -442,7 +472,13 @@ mod tests { let (agents_path, dir) = sessions_fixture(name, listing); let (mut s, task_id) = running_task(); 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::Listing, + None, + ) .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); @@ -475,7 +511,13 @@ mod tests { ); let (mut s, task_id) = running_task(); 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::Listing, + None, + ) .unwrap(); s.set_session_ref(session.id, "full-uuid-1").unwrap(); @@ -503,7 +545,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); @@ -513,18 +557,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); @@ -552,7 +603,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); @@ -562,7 +615,7 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - // --- 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 @@ -577,7 +630,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); @@ -593,7 +647,11 @@ mod tests { #[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(std::process::id() as i64)); + let (mut s, task_id, session_id) = refining_task( + "claude", + Some(std::process::id() as i64), + LivenessSource::Listing, + ); s.set_session_ref(session_id, "refine-uuid").unwrap(); assert_eq!(reconcile_live_sessions(&mut s, &agents_path).unwrap(), 1); @@ -611,7 +669,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()); @@ -620,8 +679,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 0005c54..b5dea42 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -2082,7 +2082,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}; /// End-to-end: the Config screen renders the read-only agents (with the /// default marked) over the editable named viewers, drawn through the real @@ -2311,7 +2311,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(); @@ -2319,7 +2319,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( @@ -2411,7 +2411,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( @@ -2475,7 +2482,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) @@ -2960,7 +2974,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 @@ -3325,7 +3339,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() @@ -3407,7 +3427,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(); @@ -3461,7 +3487,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( @@ -3587,7 +3619,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 @@ -3596,7 +3628,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, @@ -4067,7 +4106,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 a52d3f8..f0bfe4e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -133,6 +133,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, @@ -210,9 +216,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. @@ -282,11 +288,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 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 is deliberately trivial: a substring match over the last few KB of the log for phrases like "usage limit". It will miss agents that word it differently, in which case the session is reported `failed` rather than `capped` — a labelling gap, not a functional one, since both outcomes stall the task for redispatch identically. A dispatched process must also be reaped once it exits, or it sits as a zombie for the life of the spawning `voro` process — and `kill -0` on a zombie still reports it alive, which would silently defeat this whole mechanism in a long-lived TUI session. Dispatch therefore hands the child to a detached reaper thread the moment the session is recorded, rather than leaving it to `Drop`. **The return path** is a small verb surface agents call from within their sessions. Dispatch advertises it by injecting a preamble at the top of every prompt it writes, ahead of the task body — the dispatcher already owns the prompt file, so prepending a known-good preamble reaches any agent runtime with no per-project install and no reliance on a CLAUDE.md/AGENTS.md snippet or a loaded skill. The preamble is rendered per dispatch from a single template in the `voro` crate — so its wording still cannot drift — with the task's concrete id and, where needed, its database written *into the verb commands themselves* rather than left to inherited environment variables: