From 56363369817e02bb5d29a5a77eb56ed891c894ac Mon Sep 17 00:00:00 2001 From: Michael Johnson Date: Thu, 23 Jul 2026 12:12:12 +0100 Subject: [PATCH 1/2] Signpost needs-input, answer in-session, resume to continue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the headless answer/reject continuation machinery in favour of the signpost flow: an agent that hits a blocker runs `voro ask`, the operator answers in the agent's own session, and the task returns to `running` via a new `resume` transition — no answer text is recorded, since the exchange lives in the session transcript. The old `continue_dispatch` path fell back to a fresh spawn of the full task prompt whenever the agent had no headless `continue` verb (the built-in `claude` has none), so "answering" restarted the task instead of resuming the conversation. Rather than fix headless-continue, remove it: the operator already watches the session directly, which is a strictly better place to answer than a text box in Voro. voro-core: - Rework `Action::Answer(text)` into `Action::Resume` (needs-input → running, no answer text, no `## Answers` body append; the edge and its event stay). - Delete `Store::record_continuation` — no caller remains. - Drop the `continue` agent-config verb entirely (field, validation, builtin codex line, starter-config comment); a stale `continue` line is now an unknown field and refused. voro (TUI/CLI): - Delete `continue_dispatch`, `SpawnKind`, and the `new_input` plumbing; `dispatch` is now the single spawn path. - Add the `resume` CLI verb; remove `answer` and `continue`. Because `continue_dispatch` is gone, `reject` and the TUI reject/answer paths become plain transitions — `review`/`waiting` keep the session open, so the operator addresses feedback in that same live session. - Dispatch preamble gains a `voro resume` line and instructs the agent to run it once its question is answered in-session. - Enter on a needs-input inbox row now performs the resume transition directly (the answer prompt is gone). - Detail pane renders a multi-line question across multiple `Line`s (ratatui does not break a `Span` on newlines); row summaries collapse to the first line + `…`. docs: DESIGN.md §6/§8 and agent-integration.md updated for the signpost flow. Attach/resume bindings were left unchanged: interactive verification against a live/finished session is not possible from a headless background session, and this change does not touch those code paths. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CfXJAsDas87iATfiSgpQpT --- crates/voro-core/src/agent.rs | 71 ++----- crates/voro-core/src/model.rs | 6 +- crates/voro-core/src/store.rs | 12 +- crates/voro-core/src/transition.rs | 258 +++++++++---------------- crates/voro/src/app.rs | 122 ++++-------- crates/voro/src/cli.rs | 195 ++++--------------- crates/voro/src/dispatch.rs | 290 ++++------------------------- crates/voro/src/ui.rs | 103 +++++++++- docs/DESIGN.md | 20 +- docs/agent-integration.md | 31 +-- 10 files changed, 358 insertions(+), 750 deletions(-) diff --git a/crates/voro-core/src/agent.rs b/crates/voro-core/src/agent.rs index a1270bb..aa178fc 100644 --- a/crates/voro-core/src/agent.rs +++ b/crates/voro-core/src/agent.rs @@ -4,9 +4,9 @@ //! resolves which agent a task dispatches with. //! //! An agent is a set of verb templates; only `dispatch` is required (`cmd` is -//! an accepted alias). The optional `sessions`/`attach`/`resume`/`continue` -//! verbs unlock session-aware dispatch and `plan` unlocks the TUI's interactive -//! planning sessions (DESIGN.md §8); each degrades gracefully when absent +//! an accepted alias). The optional `sessions`/`attach`/`resume` verbs unlock +//! session-aware dispatch and `plan` unlocks the TUI's interactive planning +//! sessions (DESIGN.md §8); each degrades gracefully when absent //! (docs/agent-integration.md). Config is layered: built-ins under `voro.toml`, //! which may add agents, override a built-in wholesale, and set `default_agent` //! and viewers. A missing file is not an error. @@ -19,8 +19,8 @@ use serde::Deserialize; use crate::error::{Error, Result}; -/// The prompt-file substitution in `dispatch` and `continue` templates. The -/// working directory is handled by the spawner, not the template. +/// The prompt-file substitution in the `dispatch` template. The working +/// directory is handled by the spawner, not the template. pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}"; /// The task-id substitution in the `dispatch` template, the numeric id of the @@ -28,9 +28,9 @@ pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}"; /// with a session-naming flag can tie the session back to its task. pub const TASK_ID_PLACEHOLDER: &str = "{task_id}"; -/// The session-reference substitution in `attach`, `resume`, and `continue` -/// templates: the agent-opaque reference captured at dispatch (a Claude -/// session UUID, a Codex session id, a tmux session name). +/// The session-reference substitution in the `attach` and `resume` templates: +/// the agent-opaque reference captured at dispatch (a Claude session UUID, a +/// Codex session id, a tmux session name). pub const SESSION_PLACEHOLDER: &str = "{session}"; /// The substitution in a viewer command template (DESIGN.md §11a): the checkout @@ -73,7 +73,6 @@ plan = \"claude --permission-mode auto --model fable \\\"$(cat {prompt_file} [agents.codex] dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\" resume = \"codex resume {session}\" -continue = \"codex exec resume {session} \\\"$(cat {prompt_file})\\\"\" "; /// The order the built-in agents are probed against PATH when no `default` is @@ -112,7 +111,6 @@ const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml). # sessions list the agent's sessions as JSON (liveness + ref capture) # attach open a running session interactively ({session}) # resume reopen a finished session interactively ({session}) -# continue feed a session new input headless ({session} {prompt_file}) # plan run an interactive foreground planning session ({prompt_file}) # See docs/agent-integration.md for the full contract. # * override a built-in — a table named `claude` or `codex` REPLACES that @@ -182,8 +180,6 @@ pub struct AgentTemplate { sessions: Option, attach: Option, resume: Option, - #[serde(rename = "continue")] - continue_: Option, /// An interactive foreground command carrying [`PROMPT_FILE_PLACEHOLDER`], /// run by the TUI's planning flow (DESIGN.md §8) — no `{session}`, since a /// planning session belongs to no task or session row. @@ -212,10 +208,6 @@ impl AgentTemplate { self.resume.as_deref() } - pub fn continue_cmd(&self) -> Option<&str> { - self.continue_.as_deref() - } - pub fn plan(&self) -> Option<&str> { self.plan.as_deref() } @@ -304,11 +296,7 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> "agent '{name}' cmd is missing the {PROMPT_FILE_PLACEHOLDER} placeholder" ))); } - for (verb, template) in [ - ("attach", &agent.attach), - ("resume", &agent.resume), - ("continue", &agent.continue_), - ] { + for (verb, template) in [("attach", &agent.attach), ("resume", &agent.resume)] { if let Some(template) = template && !template.contains(SESSION_PLACEHOLDER) { @@ -317,13 +305,6 @@ fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> ))); } } - if let Some(template) = &agent.continue_ - && !template.contains(PROMPT_FILE_PLACEHOLDER) - { - return Err(invalid(format!( - "agent '{name}' continue is missing the {PROMPT_FILE_PLACEHOLDER} placeholder" - ))); - } if let Some(template) = &agent.plan && !template.contains(PROMPT_FILE_PLACEHOLDER) { @@ -354,7 +335,6 @@ pub struct ResolvedAgent { pub sessions: Option, pub attach: Option, pub resume: Option, - pub continue_cmd: Option, pub plan: Option, } @@ -514,7 +494,6 @@ impl AgentsConfig { sessions: agent.sessions.clone(), attach: agent.attach.clone(), resume: agent.resume.clone(), - continue_cmd: agent.continue_.clone(), plan: agent.plan.clone(), }) } @@ -638,11 +617,6 @@ impl AgentsConfig { ), ("attach", builtin.attach.is_some(), user.attach.is_some()), ("resume", builtin.resume.is_some(), user.resume.is_some()), - ( - "continue", - builtin.continue_.is_some(), - user.continue_.is_some(), - ), ("plan", builtin.plan.is_some(), user.plan.is_some()), ] .into_iter() @@ -935,7 +909,6 @@ mod tests { [agents.codex] dispatch = "codex exec {prompt_file}" resume = "codex resume {session}" - continue = "codex exec resume {session} \"$(cat {prompt_file})\"" "#; #[test] @@ -945,15 +918,11 @@ mod tests { assert_eq!(claude.sessions.as_deref(), Some("claude agents --json")); assert_eq!(claude.attach.as_deref(), Some("claude attach {session}")); assert_eq!(claude.resume.as_deref(), Some("claude --resume {session}")); - assert_eq!(claude.continue_cmd, None); let codex = config.resolve(Some("codex")).unwrap(); assert_eq!(codex.sessions, None); assert_eq!(codex.attach, None); - assert_eq!( - codex.continue_cmd.as_deref(), - Some("codex exec resume {session} \"$(cat {prompt_file})\"") - ); + assert_eq!(codex.resume.as_deref(), Some("codex resume {session}")); } #[test] @@ -966,7 +935,6 @@ mod tests { assert_eq!(resolved.sessions, None); assert_eq!(resolved.attach, None); assert_eq!(resolved.resume, None); - assert_eq!(resolved.continue_cmd, None); } #[test] @@ -999,8 +967,8 @@ mod tests { } #[test] - fn attach_resume_continue_require_the_session_placeholder() { - for verb in ["attach", "resume", "continue"] { + fn attach_and_resume_require_the_session_placeholder() { + for verb in ["attach", "resume"] { let text = format!( "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\ {verb} = \"reopen {{prompt_file}}\"\n" @@ -1076,20 +1044,19 @@ mod tests { ); } + /// A stale `continue` line — from a pre-pivot config or the old codex + /// built-in — is now an unknown field, so the config is refused rather than + /// silently honouring a verb Voro no longer runs (DESIGN.md §6/§8). #[test] - fn continue_requires_the_prompt_file_placeholder() { + fn a_continue_verb_is_now_an_unknown_field() { let text = r#" default_agent = "a" [agents.a] dispatch = "run {prompt_file}" - continue = "reopen {session}" + continue = "reopen {session} {prompt_file}" "#; - let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")) - .unwrap_err() - .to_string(); - assert!(message.contains("{prompt_file}"), "{message}"); - assert!(message.contains("continue"), "{message}"); + assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err()); } #[test] @@ -1301,7 +1268,7 @@ mod tests { assert!(agents.contains_key("claude")); assert!(agents.contains_key("codex")); assert!(agents["claude"].sessions().is_some()); - assert!(agents["codex"].continue_cmd().is_some()); + assert!(agents["codex"].resume().is_some()); } #[test] diff --git a/crates/voro-core/src/model.rs b/crates/voro-core/src/model.rs index 12c3561..cf7b8a8 100644 --- a/crates/voro-core/src/model.rs +++ b/crates/voro-core/src/model.rs @@ -378,9 +378,9 @@ pub struct Task { pub created_at: String, pub closed_at: Option, /// Marks a task no agent can execute — hands-on work at real hardware, say - /// (DESIGN.md §3/§6). Dispatch, continuation, `ask`, and the agent override - /// refuse it; completion goes `running → done` directly. Default `false` - /// means dispatchable. + /// (DESIGN.md §3/§6). Dispatch, `ask`, and the agent override refuse it; + /// completion goes `running → done` directly. Default `false` means + /// dispatchable. pub human: bool, } diff --git a/crates/voro-core/src/store.rs b/crates/voro-core/src/store.rs index 8b5d965..a04c882 100644 --- a/crates/voro-core/src/store.rs +++ b/crates/voro-core/src/store.rs @@ -770,7 +770,7 @@ fn project_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { } /// Insert a session row, stamping `started_at`, and return its id. Shared by -/// [`Store::create_session`] and the dispatch/continuation transactions. +/// [`Store::create_session`] and the dispatch transaction. /// Enforces the one-open-session invariant (DESIGN.md §8): opening a new session /// first closes any predecessor still open (stamped `aborted`). The partial /// unique index is the schema-level backstop. @@ -1768,19 +1768,13 @@ mod tests { .unwrap(); s.apply(task.id, Action::Start).unwrap(); s.apply(task.id, Action::Ask("A or B?".into())).unwrap(); - s.apply(task.id, Action::Answer("B".into())).unwrap(); + s.apply(task.id, Action::Resume).unwrap(); let events = s.events_for(task.id).unwrap(); let kinds: Vec<&str> = events.iter().map(|e| e.kind.as_str()).collect(); assert_eq!( kinds, - vec![ - "created", - "transition", - "transition", - "transition", - "answer" - ] + vec!["created", "transition", "transition", "transition"] ); // ids strictly increase with insertion order assert!(events.windows(2).all(|w| w[0].id < w[1].id)); diff --git a/crates/voro-core/src/transition.rs b/crates/voro-core/src/transition.rs index 4205a41..5ecf82c 100644 --- a/crates/voro-core/src/transition.rs +++ b/crates/voro-core/src/transition.rs @@ -32,8 +32,10 @@ pub enum Action { Start, /// running → needs-input; the string is the question Ask(String), - /// needs-input → running; the answer is appended to the body and logged - Answer(String), + /// needs-input → running; the question was answered in the agent's own + /// session, so this only moves the state — no answer text is recorded, the + /// exchange lives in the session transcript (DESIGN.md §6/§8). + Resume, /// running | stalled → review; the optional string is the completion /// summary, logged as a `summary` event. From `stalled` it reports a dead /// session's finished work on its behalf (DESIGN.md §8). @@ -65,7 +67,7 @@ impl Action { Action::Triage(_) => "triage", Action::Start => "start", Action::Ask(_) => "ask", - Action::Answer(_) => "answer", + Action::Resume => "resume", Action::Complete(_) => "complete", Action::HandOff => "hand off", Action::Reclaim => "reclaim", @@ -102,7 +104,7 @@ impl Store { Action::Complete(None), Action::Abort, ], - NeedsInput => vec![Action::Answer(String::new()), Action::Abandon], + NeedsInput => vec![Action::Resume, Action::Abandon], Review => vec![ Action::Accept, Action::RejectWork(String::new()), @@ -152,35 +154,6 @@ impl Store { Ok((self.task(task_id)?, self.session(session_id)?)) } - /// Record a continuation session (DESIGN.md §6/§8): the answer to a - /// `needs-input` question is fed back not through a live pipe but by - /// dispatching a fresh session over the task body, now carrying the appended - /// `## Answers` section. Unlike [`record_dispatch`](Store::record_dispatch), - /// this asserts the task is *already* `running`, so it never weakens the - /// ready-only rule — it only adds a session to a task the transition API - /// already moved. - pub fn record_continuation( - &mut self, - task_id: i64, - agent: &str, - pid: Option, - log_path: Option<&str>, - ) -> Result<(Task, Session)> { - let tx = self.conn.transaction()?; - let task = get_task(&tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?; - reject_human_dispatch(&tx, task_id)?; - reject_archived_dispatch(&tx, task_id)?; - if task.state != TaskState::Running { - return Err(Error::InvalidTransition { - from: task.state, - action: "continue".to_string(), - }); - } - let session_id = insert_session(&tx, task_id, agent, pid, log_path)?; - tx.commit()?; - Ok((self.task(task_id)?, self.session(session_id)?)) - } - /// Reconcile an open session against its task's state (DESIGN.md §8). The /// session's life follows the task, not the process listing, so the terminal /// transitions close healthy sessions; reconciliation only catches a crash @@ -197,8 +170,8 @@ impl Store { /// dead session's behalf. A stalled task with an open blocker demotes to /// `parked`. /// - `needs-input`/`review`/`waiting`: the session stays open on purpose - /// (reused by the continuation), so this leaves it alone (`Ok(None)`) - /// regardless of liveness. + /// (the operator answers in it, or a reject returns the work to it), so + /// this leaves it alone (`Ok(None)`) regardless of liveness. /// - task already closed or off the active path: the session is stale, so it /// is finalised now (`completed` for `done`, else `aborted`) with no event. pub fn reconcile_session( @@ -354,7 +327,7 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result NeedsInput, - (NeedsInput, Action::Answer(_)) => Running, + (NeedsInput, Action::Resume) => Running, // Completing a human task skips `review`: the human is both executor // and acceptor, so there is no one left to accept the work (§6). (Running | Stalled, Action::Complete(_)) if task.human => Done, @@ -385,9 +358,6 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result { return Err(Error::Invalid("a question is required".into())); } - Action::Answer(a) if a.trim().is_empty() => { - return Err(Error::Invalid("an answer is required".into())); - } Action::RejectWork(f) if f.trim().is_empty() => { return Err(Error::Invalid("rejection feedback is required".into())); } @@ -412,10 +382,6 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result { - append_section(tx, task_id, "Answers", a.trim())?; - log_event(tx, task_id, "answer", Some(a.trim()))?; - } Action::RejectWork(f) => { append_section(tx, task_id, "Feedback", f.trim())?; log_event(tx, task_id, "feedback", Some(f.trim()))?; @@ -428,8 +394,9 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result { close_open_session(tx, task_id, SessionOutcome::Completed)?; @@ -459,8 +426,8 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result Result<()> { let task = get_task(tx, task_id)?.ok_or(Error::TaskNotFound(task_id))?; if task.human { @@ -473,9 +440,9 @@ fn reject_human_dispatch(tx: &Connection, task_id: i64) -> Result<()> { } /// Refuse to open an agent session on a task in an archived project -/// (DESIGN.md §5): the project has left the cockpit, so dispatch, redispatch, -/// and continuation are all side doors. Like the human guard, this runs before -/// any state change or session insert, so the refusal writes nothing. +/// (DESIGN.md §5): the project has left the cockpit, so dispatch and redispatch +/// are both side doors. Like the human guard, this runs before any state change +/// or session insert, so the refusal writes nothing. fn reject_archived_dispatch(tx: &Connection, task_id: i64) -> Result<()> { let (name, archived): (String, bool) = tx.query_row( "SELECT p.name, p.archived FROM projects p @@ -706,7 +673,7 @@ mod tests { Action::Triage(Triage::Reject), Action::Start, Action::Ask("q?".into()), - Action::Answer("a.".into()), + Action::Resume, Action::Complete(None), Action::HandOff, Action::Reclaim, @@ -733,7 +700,7 @@ mod tests { (Running, Action::Ask(_)) => Some(NeedsInput), (Running | Stalled, Action::Complete(_)) => Some(Review), (Running, Action::Abort) => Some(Ready), - (NeedsInput, Action::Answer(_)) => Some(Running), + (NeedsInput, Action::Resume) => Some(Running), (Review, Action::HandOff) => Some(Waiting), (Waiting, Action::Reclaim) => Some(Review), (Review | Waiting, Action::Accept) => Some(Done), @@ -869,20 +836,6 @@ mod tests { assert!(s.sessions_for(id).unwrap().is_empty()); } - #[test] - fn record_continuation_is_refused() { - let (mut s, p) = store_with_project(); - let id = create_human(&mut s, p, TaskState::Ready); - s.apply(id, Action::Start).unwrap(); - - let err = s.record_continuation(id, "claude", None, None).unwrap_err(); - assert!( - matches!(err, Error::HumanTask { id: e, .. } if e == id), - "expected a human-only refusal, got {err}" - ); - assert!(s.sessions_for(id).unwrap().is_empty()); - } - #[test] fn completion_unblocks_dependants() { // running → done is terminal, so it must cascade readiness exactly @@ -996,7 +949,7 @@ mod tests { let id = task_in_state(&mut s, p, TaskState::Running); let task = s.apply(id, Action::Ask(" A or B? ".into())).unwrap(); assert_eq!(task.question.as_deref(), Some("A or B?")); - let task = s.apply(id, Action::Answer("B".into())).unwrap(); + let task = s.apply(id, Action::Resume).unwrap(); assert_eq!(task.state, TaskState::Running); assert!(task.question.is_none()); @@ -1006,42 +959,77 @@ mod tests { } #[test] - fn empty_question_answer_feedback_are_rejected() { + fn empty_question_and_feedback_are_rejected() { let (mut s, p) = store_with_project(); let running = task_in_state(&mut s, p, TaskState::Running); assert!(s.apply(running, Action::Ask(" ".into())).is_err()); - let waiting = task_in_state(&mut s, p, TaskState::NeedsInput); - assert!(s.apply(waiting, Action::Answer("".into())).is_err()); let review = task_in_state(&mut s, p, TaskState::Review); assert!(s.apply(review, Action::RejectWork(" ".into())).is_err()); - // failed applies must not have changed anything - assert_eq!(s.task(waiting).unwrap().state, TaskState::NeedsInput); + // a failed apply must not have changed anything + assert_eq!(s.task(review).unwrap().state, TaskState::Review); } + /// `resume` moves needs-input → running without recording any answer text — + /// the exchange lives in the session transcript (DESIGN.md §6/§8), so the + /// body is untouched and only a transition event is logged. #[test] - fn answers_and_feedback_accumulate_in_body() { + fn resume_records_no_answer_and_leaves_the_body_untouched() { let (mut s, p) = store_with_project(); let id = task_in_state(&mut s, p, TaskState::NeedsInput); - let task = s.apply(id, Action::Answer("Schema B".into())).unwrap(); - assert!(task.body.contains("## Answers")); - assert!(task.body.contains("- Schema B")); + let before = s.task(id).unwrap().body; - s.apply(id, Action::Ask("and the index?".into())).unwrap(); - let task = s.apply(id, Action::Answer("covering".into())).unwrap(); - assert_eq!(task.body.matches("## Answers").count(), 1); - assert!(task.body.contains("- covering")); + let task = s.apply(id, Action::Resume).unwrap(); + assert_eq!(task.state, TaskState::Running); + assert_eq!(task.body, before, "resume must not append to the body"); + assert!(task.question.is_none()); - s.apply(id, Action::Complete(None)).unwrap(); + let events = s.events_for(id).unwrap(); + assert!( + events.iter().all(|e| e.kind != "answer"), + "no answer event is logged: {events:?}" + ); + assert_eq!( + events.last().unwrap().detail.as_deref(), + Some("needs-input -> running") + ); + } + + /// `resume` is refused from every state but `needs-input`. + #[test] + fn resume_is_refused_outside_needs_input() { + use TaskState::*; + for state in [Ready, Running, Review, Waiting, Stalled] { + let (mut s, p) = store_with_project(); + let id = task_in_state(&mut s, p, state); + assert!( + matches!( + s.apply(id, Action::Resume), + Err(Error::InvalidTransition { .. }) + ), + "resume should be refused from {state}" + ); + } + } + + #[test] + fn feedback_accumulates_in_body() { + let (mut s, p) = store_with_project(); + let id = task_in_state(&mut s, p, TaskState::Review); let task = s .apply(id, Action::RejectWork("tests missing".into())) .unwrap(); assert!(task.body.contains("## Feedback")); assert!(task.body.contains("- tests missing")); + + s.apply(id, Action::Complete(None)).unwrap(); + let task = s.apply(id, Action::RejectWork("also lint".into())).unwrap(); + assert_eq!(task.body.matches("## Feedback").count(), 1); + assert!(task.body.contains("- also lint")); assert_eq!( s.events_for(id) .unwrap() .iter() - .filter(|e| e.kind == "answer") + .filter(|e| e.kind == "feedback") .count(), 2 ); @@ -1336,12 +1324,11 @@ mod tests { } #[test] - fn dispatch_and_continuation_refuse_a_task_in_an_archived_project() { + fn dispatch_refuses_a_task_in_an_archived_project() { // An archived project's tasks freeze where they are (DESIGN.md §5): - // dispatch and continuation are side doors and must write nothing. + // dispatch is a side door and must write nothing. let (mut s, p) = store_with_project(); let ready = create(&mut s, p, TaskState::Ready); - let running = task_in_state(&mut s, p, TaskState::Running); s.set_archived(p, true).unwrap(); let err = s @@ -1351,64 +1338,11 @@ mod tests { assert_eq!(s.task(ready).unwrap().state, TaskState::Ready); assert!(s.sessions_for(ready).unwrap().is_empty()); - let err = s - .record_continuation(running, "claude", Some(1), None) - .unwrap_err(); - assert!(matches!(err, Error::ProjectArchived { .. }), "{err}"); - assert!(s.sessions_for(running).unwrap().is_empty()); - // unarchiving reopens the door s.set_archived(p, false).unwrap(); assert!(s.record_dispatch(ready, "claude", Some(1), None).is_ok()); } - // --- record_continuation (DESIGN.md §6/§8: answer → running → continue) --- - - #[test] - fn record_continuation_adds_a_session_without_touching_state() { - let (mut s, p) = store_with_project(); - let id = task_in_state(&mut s, p, TaskState::NeedsInput); - s.apply(id, Action::Answer("B, with a covering index".into())) - .unwrap(); - assert_eq!(s.task(id).unwrap().state, TaskState::Running); - - let (task, session) = s - .record_continuation(id, "claude", Some(777), Some("/var/log/31.log")) - .unwrap(); - assert_eq!(task.state, TaskState::Running); - assert_eq!(session.task_id, id); - assert_eq!(session.agent, "claude"); - assert_eq!(session.pid, Some(777)); - assert_eq!(session.log_path.as_deref(), Some("/var/log/31.log")); - assert!(session.ended_at.is_none()); - assert_eq!(s.sessions_for(id).unwrap().len(), 1); - } - - #[test] - fn record_continuation_refuses_a_task_that_is_not_running() { - let (mut s, p) = store_with_project(); - for state in [ - TaskState::Proposed, - TaskState::Parked, - TaskState::Ready, - TaskState::NeedsInput, - TaskState::Review, - TaskState::Done, - TaskState::Rejected, - ] { - let id = task_in_state(&mut s, p, state); - assert!( - matches!( - s.record_continuation(id, "claude", None, None), - Err(Error::InvalidTransition { .. }) - ), - "continuation should be refused from {state}" - ); - // refusal must not create a session - assert!(s.sessions_for(id).unwrap().is_empty()); - } - } - // --- session lifecycle: one open session, closed by terminal transitions --- #[test] @@ -1459,38 +1393,29 @@ mod tests { } #[test] - fn a_continuation_ends_the_predecessor_open_session() { + fn ask_and_resume_keep_the_session_open_across_needs_input() { + // A question and its answer leave the dispatched session open across + // needs-input -> running, so the operator answers in that same agent + // 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 first = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; + let sess = s.record_dispatch(id, "claude", Some(1), None).unwrap().1; - // ask/answer leave the session open across needs-input -> running... s.apply(id, Action::Ask("A or B?".into())).unwrap(); - s.apply(id, Action::Answer("B".into())).unwrap(); - assert!(s.session(first.id).unwrap().ended_at.is_none()); - - // ...then a continuation supersedes it: the predecessor is closed and - // the new session is the only one open (the invariant). - let second = s - .record_continuation(id, "claude", Some(2), None) - .unwrap() - .1; - assert!(s.session(first.id).unwrap().ended_at.is_some()); - let open: Vec = s - .sessions_for(id) - .unwrap() - .into_iter() - .filter(|x| x.ended_at.is_none()) - .map(|x| x.id) - .collect(); - assert_eq!(open, vec![second.id]); + assert!(s.session(sess.id).unwrap().ended_at.is_none()); + s.apply(id, Action::Resume).unwrap(); + assert!( + s.session(sess.id).unwrap().ended_at.is_none(), + "resume keeps the session open" + ); + assert_eq!(s.task(id).unwrap().state, TaskState::Running); } #[test] fn rejecting_a_review_keeps_the_same_session_open_with_its_ref() { // Rejecting with feedback returns the task to running with its session - // still open, so a continuation reuses the same agent session — the - // ref survives until the task actually closes (DESIGN.md §8). + // still open, so the operator addresses the feedback in that same agent + // 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; @@ -1511,10 +1436,10 @@ mod tests { use super::*; #[test] - fn hand_off_keeps_the_session_open_for_a_later_continuation() { + fn hand_off_keeps_the_session_open_for_a_later_reject() { // review → waiting must keep the session open exactly as review - // does, so a reject-with-feedback can continue the same agent - // session once the work becomes the operator's move again. + // does, so a reject-with-feedback returns to the same agent session + // 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; @@ -1600,10 +1525,11 @@ mod tests { } #[test] - fn reject_from_waiting_continues_the_same_session_with_feedback() { + fn reject_from_waiting_reuses_the_same_session_with_feedback() { // The acceptance path: review → wait → reject-with-feedback returns // the task to running with its original session still open, and the - // feedback recorded, so a continuation reuses that agent session. + // feedback recorded, so the operator addresses it in that same + // 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; diff --git a/crates/voro/src/app.rs b/crates/voro/src/app.rs index f5412e2..e9fb922 100644 --- a/crates/voro/src/app.rs +++ b/crates/voro/src/app.rs @@ -38,7 +38,6 @@ pub struct TaskRow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PromptKind { Ask, - Answer, RejectWork, } @@ -46,7 +45,6 @@ impl PromptKind { pub fn title(self) -> &'static str { match self { PromptKind::Ask => "Question", - PromptKind::Answer => "Answer", PromptKind::RejectWork => "Rejection feedback", } } @@ -54,7 +52,6 @@ impl PromptKind { fn action(self, text: String) -> Action { match self { PromptKind::Ask => Action::Ask(text), - PromptKind::Answer => Action::Answer(text), PromptKind::RejectWork => Action::RejectWork(text), } } @@ -161,7 +158,7 @@ pub fn action_label(action: &Action) -> &'static str { Action::Triage(Triage::Reject) => "triage → rejected", Action::Start => "start → running", Action::Ask(_) => "ask a question → needs-input", - Action::Answer(_) => "answer the question → running", + Action::Resume => "resume (answered in-session) → running", Action::Complete(_) => "complete → review", Action::HandOff => "hand off → waiting", Action::Reclaim => "reclaim → review", @@ -176,8 +173,8 @@ pub fn action_label(action: &Action) -> &'static str { pub struct App { pub store: Store, - /// The dispatch context a continuation dispatch (DESIGN.md §6) uses — the - /// same one the CLI verbs use, so the TUI's answer action behaves identically. + /// The dispatch context the TUI's dispatch/redispatch, planning, and + /// attach/resume actions use — the same one the CLI verbs use. dispatch_ctx: crate::dispatch::DispatchCtx, pub screen: Screen, pub should_quit: bool, @@ -428,8 +425,9 @@ impl App { /// The primary action of the current selection. On the Tasks screen every /// row opens its detail view. On the cockpit — where the detail pane - /// already shows the body — a needs-input task opens the answer prompt - /// directly and any other task opens its transition menu. + /// already shows the body — a needs-input task resumes directly (the + /// operator has answered the question in the agent's own session, DESIGN.md + /// §6/§8) and any other task opens its transition menu. fn activate_selection(&mut self) { if self.screen == Screen::Tasks { if let Some(task_id) = self.selected_task_id() { @@ -439,11 +437,8 @@ impl App { } if let Some(task) = self.selected_task() { if task.state == TaskState::NeedsInput { - self.mode = Mode::Prompt { - task_id: task.id, - kind: PromptKind::Answer, - buffer: String::new(), - }; + let id = task.id; + self.apply_and_refresh(id, Action::Resume); } else { let actions = Store::legal_actions(task.state, task.human); if !actions.is_empty() { @@ -465,7 +460,7 @@ impl App { Screen::Tasks => self.all.get(self.tasks_sel).map(|_| "⏎ view"), Screen::Cockpit => match self.cockpit_rows.get(self.cockpit_sel)? { CockpitRow::Queue(i) => match self.queue.get(*i)?.task.state { - TaskState::NeedsInput => Some("⏎ answer"), + TaskState::NeedsInput => Some("⏎ resume"), TaskState::Proposed => Some("⏎ triage"), TaskState::Review => Some("⏎ review"), _ => Some("⏎ act"), @@ -490,38 +485,13 @@ impl App { self.all.iter().map(|r| &r.task).find(|t| t.id == id) } - /// Apply a transition and refresh. An `Answer` or `RejectWork` on a task - /// with prior session history additionally triggers a continuation dispatch - /// (DESIGN.md §6/§8), the same rule and mechanics `voro answer`/`voro reject` - /// use. A task only ever started by hand has no history and the transition - /// stands alone. + /// Apply a transition and refresh. `resume` (needs-input → running) and + /// reject-with-feedback (review → running) both leave the agent's session + /// open, so the operator answers the question or addresses the feedback in + /// that same session — Voro only moves the state (DESIGN.md §6/§8). fn apply_and_refresh(&mut self, task_id: i64, action: Action) { - let continuation_input = match &action { - Action::Answer(text) | Action::RejectWork(text) => Some(text.clone()), - _ => None, - }; - let has_history = continuation_input.is_some() - && self - .store - .sessions_for(task_id) - .is_ok_and(|sessions| !sessions.is_empty()); let result = self.store.apply(task_id, action); if self.report(result).is_some() { - if has_history { - match crate::dispatch::continue_dispatch( - &mut self.store, - &self.dispatch_ctx, - task_id, - None, - continuation_input.as_deref(), - ) { - Ok(summary) => self.status = Some(summary), - Err(e) => { - self.status = - Some(format!("transition applied, but continuation failed: {e}")) - } - } - } let result = self.refresh(); self.report(result); } @@ -1394,7 +1364,6 @@ impl App { let action = actions[sel].clone(); let kind = match action { Action::Ask(_) => Some(PromptKind::Ask), - Action::Answer(_) => Some(PromptKind::Answer), Action::RejectWork(_) => Some(PromptKind::RejectWork), _ => None, }; @@ -1546,8 +1515,7 @@ mod tests { } /// A `DispatchCtx` that is never actually used to spawn anything in these - /// tests — none of them build up session history on a task before - /// answering it, so `apply_and_refresh`'s continuation path never fires. + /// tests — the transitions they drive (`resume`, reject) only move state. fn dummy_ctx() -> crate::dispatch::DispatchCtx { crate::dispatch::DispatchCtx::from_db_path(std::path::Path::new("/nonexistent/voro.db")) } @@ -1603,27 +1571,24 @@ mod tests { App::new(store, dummy_ctx()).unwrap() } + /// Enter on a needs-input inbox row resumes the task directly — the + /// operator answered in the agent's own session, so there is no answer + /// prompt (DESIGN.md §6/§8), just the `needs-input → running` transition. #[test] - fn enter_on_needs_input_row_answers_and_requeues() { + fn enter_on_needs_input_row_resumes_and_requeues() { let mut app = app_with(&[TaskState::NeedsInput]); assert!(matches!( app.cockpit_rows[app.cockpit_sel], CockpitRow::Queue(_) )); - assert_eq!(app.enter_hint(), Some("⏎ answer")); - - key(&mut app, KeyCode::Enter); - let task_id = match app.mode { - Mode::Prompt { - task_id, - kind: PromptKind::Answer, - .. - } => task_id, - _ => panic!("enter on a needs-input row should open the answer prompt"), - }; + assert_eq!(app.enter_hint(), Some("⏎ resume")); + let task_id = app.queue[0].task.id; - key(&mut app, KeyCode::Char('B')); key(&mut app, KeyCode::Enter); + assert!( + matches!(app.mode, Mode::Normal), + "resume applies directly, opening no prompt" + ); assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Running); assert!(app.queue.is_empty()); } @@ -1674,15 +1639,16 @@ mod tests { (store, ctx, project_path) } - /// The TUI's answer action is the CLI's `voro answer` under a different - /// keybinding (task #31, DESIGN.md §6): a task with prior session history - /// gets a continuation dispatched automatically when answered here too. + /// Resuming a dispatched task from the cockpit keeps its live agent session + /// — the operator answered in that session, so no continuation is spawned + /// (DESIGN.md §6/§8): the task returns to `running` on the one session it + /// already had. #[test] - fn answering_a_task_with_session_history_triggers_a_continuation() { + fn resuming_a_task_with_a_live_session_spawns_no_continuation() { use std::process::{Command, Stdio}; let root = std::env::temp_dir().join(format!( - "voro-app-answer-{}-{}", + "voro-app-resume-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1703,11 +1669,11 @@ mod tests { let db_path = root.join("voro.db"); let agents_path = root.join("voro.toml"); - // The continuation this test triggers must still be alive when - // `apply_and_refresh`'s own `self.refresh()` reconciles-on-read - // immediately afterwards — an instantly-exiting stub (`cat`, as - // `dispatch.rs`'s tests use) would otherwise race that read and get - // finalised as a failed session before the assertions below run. + // The dispatched session must still be alive when `apply_and_refresh`'s + // own `self.refresh()` reconciles-on-read immediately after the resume — + // an instantly-exiting stub (`cat`) would race that read and get + // finalised as a failed session, stalling the task before the + // assertions below run. std::fs::write( &agents_path, "default_agent = \"stub\"\n\n[agents.stub]\ncmd = \"sleep 1 && cat {prompt_file}\"\n", @@ -1740,18 +1706,12 @@ mod tests { let mut app = App::new(store, ctx).unwrap(); key(&mut app, KeyCode::Enter); - key(&mut app, KeyCode::Char('B')); - key(&mut app, KeyCode::Enter); assert_eq!(app.store.task(task.id).unwrap().state, TaskState::Running); - assert_eq!(app.store.sessions_for(task.id).unwrap().len(), 2); - assert!( - app.status - .as_deref() - .unwrap_or("") - .contains("continued task"), - "{:?}", - app.status + assert_eq!( + app.store.sessions_for(task.id).unwrap().len(), + 1, + "resume must not spawn a second session" ); let _ = std::fs::remove_dir_all(&root); @@ -2121,8 +2081,8 @@ mod tests { // `sleep 1 &&` keeps the stub process alive past `dispatch_task`'s own // `refresh()`, whose reconcile-on-read would otherwise race an // instantly-exiting stub and finalise the session as failed/ready - // before the assertions below run (see the answer-continuation test - // above for the same race). + // before the assertions below run (see the resume test above for the + // same race). let (mut store, ctx, project_path) = scratch_env( "dispatch", Some( diff --git a/crates/voro/src/cli.rs b/crates/voro/src/cli.rs index 57cc4ad..d72533a 100644 --- a/crates/voro/src/cli.rs +++ b/crates/voro/src/cli.rs @@ -96,14 +96,6 @@ dispatch dispatch [--agent NAME] spawn a headless agent session on a ready task; --agent overrides the resolved agent - continue [--agent NAME] - continue an already-running task without - changing its state — via the agent's - `continue` verb when its session was - captured, else a fresh session; what - `answer` does automatically for a - previously-dispatched task, exposed to retry - a continuation that failed viewer list list the viewers voro.toml defines; * marks the default used when nothing names one open open a review/running task's checkout in a @@ -123,11 +115,8 @@ transitions triage start ready → running ask --question TEXT running → needs-input - answer TEXT [--no-dispatch] - needs-input → running; if the task has ever - been dispatched, also starts a continuation - session with the answer in the prompt body - (--no-dispatch skips that) + resume needs-input → running, once you have answered + the question in the agent's own session done [--summary TEXT | --summary-file PATH] [--branch NAME] running | stalled → review (from stalled: reporting a dead session's finished work on @@ -201,14 +190,6 @@ enum Verb { #[arg(long)] agent: Option, }, - /// Spawn a fresh session on a task that is already `running`, without - /// touching its state — the mechanics `answer` uses automatically, exposed - /// directly so a continuation that failed can be retried once fixed. - Continue { - task_id: i64, - #[arg(long)] - agent: Option, - }, /// Run a configured viewer on a review/running task's checkout so its diff /// can be seen (DESIGN.md §8/§11a) — the explicit spelling of `pr`'s viewer /// medium, reaching the local diff even on a GitHub project. @@ -226,7 +207,6 @@ enum Verb { }, Reject(RejectArgs), Done(DoneArgs), - Answer(AnswerArgs), Import(ImportArgs), Triage { task_id: i64, @@ -236,6 +216,11 @@ enum Verb { task_id: i64, }, Ask(AskArgs), + /// needs-input → running: the operator answered the question in the + /// agent's own session (DESIGN.md §6/§8), so this only moves the state. + Resume { + task_id: i64, + }, Accept { task_id: i64, #[arg(long)] @@ -379,8 +364,6 @@ struct RejectArgs { text: Vec, #[arg(long)] from_pr: bool, - #[arg(long)] - no_dispatch: bool, } #[derive(Args)] @@ -394,15 +377,6 @@ struct DoneArgs { branch: Option, } -#[derive(Args)] -struct AnswerArgs { - task_id: i64, - #[arg(value_name = "TEXT")] - text: Vec, - #[arg(long)] - no_dispatch: bool, -} - #[derive(Args)] struct ImportArgs { project: String, @@ -465,21 +439,18 @@ pub fn run(store: &mut Store, args: Vec, ctx: &DispatchCtx) -> Result { dispatch::dispatch(store, ctx, task_id, agent.as_deref()) } - Verb::Continue { task_id, agent } => { - dispatch::continue_dispatch(store, ctx, task_id, agent.as_deref(), None) - } Verb::Open { task_id } => dispatch::open(store, ctx, task_id, None), Verb::Viewer { cmd } => viewer_verb(cmd, ctx), Verb::Pr { task_id, yes } => pr_verb(store, task_id, yes, ctx), - Verb::Reject(args) => reject_verb(store, args, ctx), + Verb::Reject(args) => reject_verb(store, args), Verb::Done(args) => done_verb(store, args), - Verb::Answer(args) => answer_verb(store, args, ctx), Verb::Import(args) => import_verb(store, args), Verb::Triage { task_id, target } => { apply_action(store, task_id, Action::Triage(target.into()), false) } Verb::Start { task_id } => apply_action(store, task_id, Action::Start, false), Verb::Ask(args) => ask_verb(store, args), + Verb::Resume { task_id } => apply_action(store, task_id, Action::Resume, false), Verb::Accept { task_id, yes } => apply_action(store, task_id, Action::Accept, yes), Verb::Wait { task_id } => apply_action(store, task_id, Action::HandOff, false), Verb::Reclaim { task_id } => apply_action(store, task_id, Action::Reclaim, false), @@ -680,7 +651,6 @@ fn agent_verb(cmd: AgentCmd, ctx: &DispatchCtx) -> Result { ("sessions", template.sessions()), ("attach", template.attach()), ("resume", template.resume()), - ("continue", template.continue_cmd()), ("plan", template.plan()), ] .into_iter() @@ -921,12 +891,13 @@ fn confirm(question: &str) -> Result { Ok(matches!(line.trim(), "y" | "Y" | "yes" | "Yes")) } -/// `reject [TEXT] [--from-pr] [--no-dispatch]` (DESIGN.md §6/§8/§11c): -/// review → running with feedback. `--from-pr` pulls the tracked PR's review -/// comments as that feedback, with any extra TEXT appended; otherwise TEXT is -/// the feedback. Like `answer`, a task with prior session history then continues -/// the work with the feedback in hand; `--no-dispatch` forces the plain transition. -fn reject_verb(store: &mut Store, args: RejectArgs, ctx: &DispatchCtx) -> Result { +/// `reject [TEXT] [--from-pr]` (DESIGN.md §6/§8/§11c): review | +/// waiting → running with feedback appended to the body. `--from-pr` pulls the +/// tracked PR's review comments as that feedback, with any extra TEXT appended; +/// otherwise TEXT is the feedback. Because `review`/`waiting` keep the session +/// open (§8), the task returns to `running` with its agent session still live — +/// the operator addresses the feedback in that same session (§6/§8). +fn reject_verb(store: &mut Store, args: RejectArgs) -> Result { let id = args.task_id; let feedback = if args.from_pr { let pulled = crate::pr::pull_review_feedback(store, id)?; @@ -939,25 +910,11 @@ fn reject_verb(store: &mut Store, args: RejectArgs, ctx: &DispatchCtx) -> Result } else { joined(&args.text, "rejection feedback")? }; - let has_history = !store - .sessions_for(id) - .map_err(|e| e.to_string())? - .is_empty(); let task = store - .apply(id, Action::RejectWork(feedback.clone())) + .apply(id, Action::RejectWork(feedback)) .map_err(|e| e.to_string())?; - let out = format!("task {} -> {}", task.id, task.state); - - if !has_history || args.no_dispatch { - return Ok(out); - } - match dispatch::continue_dispatch(store, ctx, id, None, Some(&feedback)) { - Ok(summary) => Ok(format!("{out}; {summary}")), - Err(e) => Err(format!( - "{out}, but continuation dispatch failed: {e} — retry with 'voro continue {id}'" - )), - } + Ok(format!("task {} -> {}", task.id, task.state)) } /// `done [--summary TEXT | --summary-file PATH] [--branch NAME]` @@ -1240,36 +1197,6 @@ fn explain_verb(store: &mut Store, id: i64) -> Result { Ok(out) } -/// `answer TEXT [--no-dispatch]` (DESIGN.md §6): needs-input → -/// running, answer appended to the body — the transition applies first. It then -/// dispatches a fresh continuation whose prompt carries the `## Answers` section -/// (the asking session has typically exited by the time a human answers). That -/// only fires for a task with prior session history; `--no-dispatch` forces the -/// plain transition even when history exists. -fn answer_verb(store: &mut Store, args: AnswerArgs, ctx: &DispatchCtx) -> Result { - let id = args.task_id; - let text = joined(&args.text, "answer text")?; - let has_history = !store - .sessions_for(id) - .map_err(|e| e.to_string())? - .is_empty(); - - let task = store - .apply(id, Action::Answer(text.clone())) - .map_err(|e| e.to_string())?; - let out = format!("task {} -> {}", task.id, task.state); - - if !has_history || args.no_dispatch { - return Ok(out); - } - match dispatch::continue_dispatch(store, ctx, id, None, Some(&text)) { - Ok(summary) => Ok(format!("{out}; {summary}")), - Err(e) => Err(format!( - "{out}, but continuation dispatch failed: {e} — retry with 'voro continue {id}'" - )), - } -} - /// `viewer list` (DESIGN.md §8/§11a): the viewers `voro.toml` defines, with the /// default flagged. Like `agent`, config outside the database, so no store. fn viewer_verb(cmd: ViewerCmd, ctx: &DispatchCtx) -> Result { @@ -1545,8 +1472,10 @@ mod tests { ok(&mut s, &["ask", "1", "--question", "Schema A or B?"]); assert!(ok(&mut s, &["inbox"]).contains("Schema A or B?")); - ok(&mut s, &["answer", "1", "B, with a covering index"]); - assert!(ok(&mut s, &["show", "1"]).contains("covering index")); + let out = ok(&mut s, &["resume", "1"]); + assert!(out.contains("-> running"), "{out}"); + // the question is cleared once the task resumes + assert!(!ok(&mut s, &["show", "1"]).contains("Schema A or B?")); ok(&mut s, &["done", "1"]); ok(&mut s, &["reject", "1", "tests missing"]); @@ -2878,7 +2807,8 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } - // --- answer → continuation (task #31, DESIGN.md §6/§8) --- + // --- resume: the operator answers in-session, Voro only signposts + // (DESIGN.md §6/§8) --- /// A scratch database, a freshly-`git init`ed clean project, and an /// `voro.toml` whose one agent is a stub command — the same shape as @@ -2965,50 +2895,31 @@ mod tests { task.id } - /// Acceptance case one: a task with prior session history gets a - /// continuation dispatched automatically, and that continuation's prompt - /// carries the `## Answers` section the answer just appended. + /// `voro resume` on a dispatched task moves needs-input → running without + /// spawning anything: the operator answered in the agent's own session, so + /// the original session stays open and no new prompt is written. #[test] - fn answering_a_previously_dispatched_task_starts_a_continuation() { + fn resuming_a_dispatched_task_moves_to_running_without_a_new_session() { let (mut store, ctx, project) = scratch_env("cat {prompt_file}"); let id = dispatched_and_asked(&mut store, &ctx, &project); + assert_eq!(store.sessions_for(id).unwrap().len(), 1); - let out = run( - &mut store, - vec![ - "answer".into(), - id.to_string(), - "Schema B, with a covering index".into(), - ], - &ctx, - ) - .unwrap(); - assert!(out.contains("-> running"), "{out}"); - assert!(out.contains(&format!("continued task {id}")), "{out}"); + let out = run(&mut store, vec!["resume".into(), id.to_string()], &ctx).unwrap(); + assert_eq!(out, format!("task {id} -> running")); assert_eq!(store.task(id).unwrap().state, TaskState::Running); - // the original dispatch's session plus the continuation's - let sessions = store.sessions_for(id).unwrap(); - assert_eq!(sessions.len(), 2, "{sessions:?}"); - - // the continuation's prompt is the task body, now carrying the answer - let prompts = prompt_files(&ctx); - assert_eq!(prompts.len(), 2); - assert!( - prompts.iter().any(|p| std::fs::read_to_string(p) - .unwrap() - .contains("Schema B, with a covering index")), - "the continuation prompt must carry the answer" - ); + // no continuation is spawned: still the single dispatch session and its + // one prompt file, the exchange having happened in the live session. + assert_eq!(store.sessions_for(id).unwrap().len(), 1, "no new session"); + assert_eq!(prompt_files(&ctx).len(), 1, "no new prompt written"); let _ = std::fs::remove_dir_all(project.parent().unwrap()); } - /// Acceptance case two: a task that was only ever started by hand — no - /// dispatch, no session — still answers as a plain transition; nothing - /// tries to spawn a continuation for it. + /// `voro resume` on a task only ever started by hand — no dispatch, no + /// session — is a plain transition. #[test] - fn answering_a_never_dispatched_task_is_a_plain_transition() { + fn resuming_a_never_dispatched_task_is_a_plain_transition() { let mut store = Store::open_in_memory().unwrap(); let ctx = ctx(); ok(&mut store, &["project", "add", "demo", "/tmp/demo"]); @@ -3020,37 +2931,9 @@ mod tests { ok(&mut store, &["ask", "1", "--question", "A or B?"]); assert!(store.sessions_for(1).unwrap().is_empty()); - let out = run( - &mut store, - vec!["answer".into(), "1".into(), "B".into()], - &ctx, - ) - .unwrap(); + let out = run(&mut store, vec!["resume".into(), "1".into()], &ctx).unwrap(); assert_eq!(out, "task 1 -> running"); assert_eq!(store.task(1).unwrap().state, TaskState::Running); assert!(store.sessions_for(1).unwrap().is_empty()); } - - /// `--no-dispatch` opts out of continuation even when history exists. - #[test] - fn no_dispatch_flag_skips_continuation_despite_history() { - let (mut store, ctx, project) = scratch_env("cat {prompt_file}"); - let id = dispatched_and_asked(&mut store, &ctx, &project); - - let out = run( - &mut store, - vec![ - "answer".into(), - id.to_string(), - "--no-dispatch".into(), - "B".into(), - ], - &ctx, - ) - .unwrap(); - assert_eq!(out, format!("task {id} -> running")); - assert_eq!(store.sessions_for(id).unwrap().len(), 1, "no continuation"); - - let _ = std::fs::remove_dir_all(project.parent().unwrap()); - } } diff --git a/crates/voro/src/dispatch.rs b/crates/voro/src/dispatch.rs index 75024be..81c57fa 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -1,16 +1,14 @@ -//! Dispatching a ready task to a headless agent session (DESIGN.md §8), and -//! continuing an already-`running` one after a human answers a question -//! (DESIGN.md §6). Both are the I/O half of dispatch — resolving the agent, -//! guarding against a dirty checkout, writing the prompt, and spawning the -//! process detached — kept out of voro-core, which stays pure of process and -//! filesystem I/O. The atomic state-plus-session writes are voro-core's -//! `Store::record_dispatch` and `Store::record_continuation`. +//! Dispatching a ready (or stalled) task to a headless agent session (DESIGN.md +//! §8): the I/O half of dispatch — resolving the agent, guarding against a dirty +//! checkout, writing the prompt, and spawning the process detached — kept out of +//! voro-core, which stays pure of process and filesystem I/O. The atomic +//! state-plus-session write is voro-core's `Store::record_dispatch`. //! //! For agents that define a `sessions` verb (task #75), dispatch additionally //! captures the agent's own session reference after launch — by polling the //! `sessions` listing for a session started in this project since the spawn, //! falling back to the `backgrounded · ` line launchers print into the -//! log — and records it on the session row for later attach/resume/continue. +//! log — and records it on the session row for later attach/resume. use std::fs::{File, OpenOptions}; use std::io::Write; @@ -20,9 +18,9 @@ use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use voro_core::{ - AgentsConfig, PROMPT_FILE_PLACEHOLDER, ReviewAction, SESSION_PLACEHOLDER, Session, Store, - TASK_ID_PLACEHOLDER, Task, TaskState, VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, - VIEWER_PATH_PLACEHOLDER, parse_sessions_json, + AgentsConfig, PROMPT_FILE_PLACEHOLDER, ReviewAction, Store, TASK_ID_PLACEHOLDER, TaskState, + VIEWER_BASE_PLACEHOLDER, VIEWER_BRANCH_PLACEHOLDER, VIEWER_PATH_PLACEHOLDER, + parse_sessions_json, }; /// Prepended to every dispatched prompt so the agent learns the return-path @@ -37,12 +35,17 @@ local SQLite database; report progress through these CLI verbs rather than editing that database yourself: voro ask {task_id}{db} --question \"...\" # blocked on a human decision (-> needs-input) + voro resume {task_id}{db} # question answered in this session, carry on (-> running) voro done {task_id}{db} --summary \"...\" # work finished, await review (-> review) voro propose \"\"{db} --body-file <path> # file a follow-up task (-> proposed) Run these commands exactly as shown: they name task {task_id} explicitly, so they -work from anywhere without any environment variable being set. `propose` uses -task {task_id} as the discovered-from source when `--from` is omitted. Finish +work from anywhere without any environment variable being set. After you `ask`, +the operator answers right here in this session — so when your question has been +answered, run `voro resume {task_id}` before continuing, to move the task back to +running (Voro records no answer text; the exchange is already in this transcript). +`propose` uses task {task_id} as the discovered-from source when `--from` is +omitted. Finish with your work committed on a branch and a PR-ready `--summary` on `done` — what changed, why, and how you verified it — since `voro pr` opens the pull request straight from that summary. Never modify the database with raw SQL, which would @@ -207,65 +210,6 @@ pub fn plan_session( }) } -/// Which of the two flows `spawn_session` is performing: they share every -/// mechanic and differ only in the required starting state and which `Store` -/// method records the result. -#[derive(Clone, Copy)] -enum SpawnKind { - /// The task must be `ready` or `stalled` (redispatch, DESIGN.md §6/§8); - /// recording it performs the transition to `running`. - Fresh, - /// The task must already be `running` (`answer` just put it there), so - /// recording it only opens a session, never changes state. - Continuation, -} - -impl SpawnKind { - fn accepts_state(self, state: TaskState) -> bool { - match self { - SpawnKind::Fresh => matches!(state, TaskState::Ready | TaskState::Stalled), - SpawnKind::Continuation => state == TaskState::Running, - } - } - - /// The states `accepts_state` allows, for the rejection message. - fn accepted_states(self) -> &'static str { - match self { - SpawnKind::Fresh => "ready or stalled", - SpawnKind::Continuation => "running", - } - } - - /// Past tense, for both the rejection message and the success summary. - fn verb_past(self) -> &'static str { - match self { - SpawnKind::Fresh => "dispatched", - SpawnKind::Continuation => "continued", - } - } - - fn preposition(self) -> &'static str { - match self { - SpawnKind::Fresh => "to", - SpawnKind::Continuation => "on", - } - } - - fn record( - self, - store: &mut Store, - task_id: i64, - agent: &str, - pid: Option<i64>, - log_path: Option<&str>, - ) -> voro_core::Result<(Task, Session)> { - match self { - SpawnKind::Fresh => store.record_dispatch(task_id, agent, pid, log_path), - SpawnKind::Continuation => store.record_continuation(task_id, agent, pid, log_path), - } - } -} - /// Where dispatch finds its inputs and puts its artefacts. Built from the /// database path in `main`; constructed directly in tests. pub struct DispatchCtx { @@ -337,32 +281,7 @@ pub fn dispatch( task_id: i64, agent_override: Option<&str>, ) -> Result<String, String> { - spawn_session(store, ctx, task_id, agent_override, SpawnKind::Fresh, None) -} - -/// Continue a task that `answer` just moved `needs-input → running` (DESIGN.md -/// §6). When the agent defines a `continue` verb and the task's last session -/// has a captured reference, the answer (`new_input`) is fed to *that session* -/// headless; otherwise it falls back to a fresh spawn of the task body, now -/// carrying the `## Answers` section the answer appended. Unlike [`dispatch`], -/// `record_continuation` asserts the task is already `running` rather than -/// performing the transition, so this cannot smuggle a task into `running` -/// outside the transition API. -pub fn continue_dispatch( - store: &mut Store, - ctx: &DispatchCtx, - task_id: i64, - agent_override: Option<&str>, - new_input: Option<&str>, -) -> Result<String, String> { - spawn_session( - store, - ctx, - task_id, - agent_override, - SpawnKind::Continuation, - new_input, - ) + spawn_session(store, ctx, task_id, agent_override) } /// Open a `review` (or `running`) task's diff in a viewer (DESIGN.md §11a): the @@ -475,8 +394,7 @@ pub fn open( /// How the session reference on the freshly-recorded session row came to be, /// for the summary line. enum RefOutcome { - /// Captured from the `sessions` listing or the log, or inherited from the - /// prior session on a `continue`-verb continuation. + /// Captured from the `sessions` listing or the log. Captured(String), /// The agent defines `sessions` but the reference never showed up. NotCaptured, @@ -489,29 +407,25 @@ fn spawn_session( ctx: &DispatchCtx, task_id: i64, agent_override: Option<&str>, - kind: SpawnKind, - new_input: Option<&str>, ) -> Result<String, String> { let task = store.task(task_id).map_err(|e| e.to_string())?; // Checked here so a human-only task is refused before anything spawns; - // voro-core's record_dispatch/record_continuation are the backstop. + // voro-core's record_dispatch is the backstop. if task.human { return Err(format!( "task {task_id} is human-only — no agent can execute it; work it by hand \ (`voro start {task_id}`, then `voro done {task_id}`)" )); } - if !kind.accepts_state(task.state) { + if !matches!(task.state, TaskState::Ready | TaskState::Stalled) { return Err(format!( - "only {} tasks can be {}; task {task_id} is {}", - kind.accepted_states(), - kind.verb_past(), + "only ready or stalled tasks can be dispatched; task {task_id} is {}", task.state )); } let project = store.project(task.project_id).map_err(|e| e.to_string())?; // Refused here so an archived project is heard before anything spawns; - // voro-core's record_dispatch/record_continuation are the backstop. + // voro-core's record_dispatch is the backstop. if project.archived { return Err(format!( "project '{}' is archived — `voro project unarchive {}` first", @@ -524,18 +438,6 @@ fn spawn_session( .resolve(agent_override.or(task.agent.as_deref())) .map_err(|e| e.to_string())?; - // A continuation reuses the prior session when the agent knows how to - // (`continue` verb) and the session can be addressed (captured ref); - // otherwise it degrades to a fresh spawn of the dispatch template. - let continue_ref = match kind { - SpawnKind::Continuation if agent.continue_cmd.is_some() => store - .sessions_for(task_id) - .map_err(|e| e.to_string())? - .first() - .and_then(|s| s.session_ref.clone()), - _ => None, - }; - guard_clean_tree(&project.path)?; std::fs::create_dir_all(&ctx.runtime_dir) @@ -554,17 +456,10 @@ fn spawn_session( } else { format!("# {}\n\n{}\n", task.title, task.body.trim_end()) }; - // A continued session already carries the preamble and the task from its - // first prompt, so it gets only the new input (the answer); everything - // else gets the full preamble + body. - let prompt = match (&continue_ref, new_input) { - (Some(_), Some(input)) => format!("{input}\n"), - (Some(_), None) => body, - (None, _) => format!( - "{}{body}", - render_preamble(task_id, &ctx.db_path, task.branch.as_deref()) - ), - }; + let prompt = format!( + "{}{body}", + render_preamble(task_id, &ctx.db_path, task.branch.as_deref()) + ); std::fs::write(&prompt_path, prompt) .map_err(|e| format!("cannot write prompt {}: {e}", prompt_path.display()))?; @@ -575,18 +470,10 @@ fn spawn_session( .try_clone() .map_err(|e| format!("cannot open log {}: {e}", log_path.display()))?; - let command = match &continue_ref { - Some(session_ref) => agent - .continue_cmd - .as_deref() - .expect("continue_ref is only set when the verb exists") - .replace(SESSION_PLACEHOLDER, &shell_quote(Path::new(session_ref))) - .replace(PROMPT_FILE_PLACEHOLDER, &shell_quote(&prompt_path)), - None => agent - .dispatch - .replace(PROMPT_FILE_PLACEHOLDER, &shell_quote(&prompt_path)) - .replace(TASK_ID_PLACEHOLDER, &task_id.to_string()), - }; + let command = agent + .dispatch + .replace(PROMPT_FILE_PLACEHOLDER, &shell_quote(&prompt_path)) + .replace(TASK_ID_PLACEHOLDER, &task_id.to_string()); let spawn_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as i64) @@ -605,8 +492,7 @@ fn spawn_session( .map_err(|e| format!("cannot spawn agent '{}': {e}", agent.name))?; let pid = i64::from(child.id()); - let recorded = kind.record( - store, + let recorded = store.record_dispatch( task_id, &agent.name, Some(pid), @@ -638,14 +524,8 @@ fn spawn_session( let _ = child.wait(); }); - let ref_outcome = match (continue_ref, &agent.sessions) { - // A continuation addressed the prior session; the new row keeps its ref. - (Some(session_ref), _) => { - let result = store.set_session_ref(session.id, &session_ref); - result.map_err(|e| e.to_string())?; - RefOutcome::Captured(session_ref) - } - (None, Some(sessions_cmd)) => { + let ref_outcome = match &agent.sessions { + Some(sessions_cmd) => { match capture_session_ref( sessions_cmd, &project.path, @@ -662,7 +542,7 @@ fn spawn_session( None => RefOutcome::NotCaptured, } } - (None, None) => RefOutcome::NotApplicable, + None => RefOutcome::NotApplicable, }; let ref_note = match &ref_outcome { RefOutcome::Captured(session_ref) => format!(", ref {session_ref}"), @@ -671,10 +551,8 @@ fn spawn_session( }; Ok(format!( - "{} task {} {} {} (session {}, pid {}{}) — log {}", - kind.verb_past(), + "dispatched task {} to {} (session {}, pid {}{}) — log {}", task.id, - kind.preposition(), session.agent, session.id, pid, @@ -937,8 +815,9 @@ mod tests { dispatch(&mut store, &ctx, id, None).unwrap(); let prompt = std::fs::read_to_string(prompt_files(&ctx).pop().unwrap()).unwrap(); - // all three return-path verbs name the literal task id, no env var + // the return-path verbs name the literal task id, no env var assert!(prompt.contains(&format!("voro ask {id}")), "{prompt}"); + assert!(prompt.contains(&format!("voro resume {id}")), "{prompt}"); assert!(prompt.contains(&format!("voro done {id}")), "{prompt}"); assert!(prompt.contains("voro propose"), "{prompt}"); assert!(!prompt.contains("VORO_TASK_ID"), "{prompt}"); @@ -1245,101 +1124,6 @@ mod tests { std::fs::remove_file(&log).unwrap(); } - // --- continue-verb continuation (task #75) --- - - /// A task dispatched and left `running`, its session's ref set as if - /// capture had recorded it. - fn dispatched_with_ref(store: &mut Store, ctx: &DispatchCtx, project: &Path) -> i64 { - let id = ready_task(store, project); - dispatch(store, ctx, id, None).unwrap(); - let session_id = store.sessions_for(id).unwrap()[0].id; - store.set_session_ref(session_id, "ref-123").unwrap(); - id - } - - #[test] - fn continuation_reuses_the_session_via_the_continue_verb() { - let (mut store, ctx, project) = fixture_toml("placeholder — rewritten below"); - let root = project.parent().unwrap().to_path_buf(); - let ref_out = root.join("cont-ref.txt"); - let prompt_out = root.join("cont-prompt.txt"); - std::fs::write( - &ctx.agents_path, - format!( - "default_agent = \"stub\"\n\n[agents.stub]\n\ - dispatch = \"cat {{prompt_file}}\"\n\ - continue = \"cp {{prompt_file}} '{}' && echo {{session}} > '{}'\"\n", - prompt_out.display(), - ref_out.display() - ), - ) - .unwrap(); - let id = dispatched_with_ref(&mut store, &ctx, &project); - - let summary = - continue_dispatch(&mut store, &ctx, id, None, Some("Schema B, then.")).unwrap(); - assert!(summary.contains("continued task"), "{summary}"); - assert!(summary.contains("ref ref-123"), "{summary}"); - - // the continue template ran with the prior session's ref substituted - for _ in 0..50 { - if ref_out.exists() && prompt_out.exists() { - break; - } - std::thread::sleep(Duration::from_millis(20)); - } - assert_eq!(std::fs::read_to_string(&ref_out).unwrap().trim(), "ref-123"); - // the prompt fed to the continuation is the answer, not the re-sent - // task body — the session already has the task - let prompt = std::fs::read_to_string(&prompt_out).unwrap(); - assert_eq!(prompt.trim(), "Schema B, then."); - assert!(!prompt.contains("voro done"), "no preamble on a continue"); - - // the continuation's session row inherits the ref it addressed - let sessions = store.sessions_for(id).unwrap(); - assert_eq!(sessions.len(), 2); - assert_eq!(sessions[0].session_ref.as_deref(), Some("ref-123")); - } - - #[test] - fn continuation_without_a_ref_falls_back_to_a_fresh_spawn() { - let (mut store, ctx, project) = fixture_toml( - "default_agent = \"stub\"\n\n[agents.stub]\n\ - dispatch = \"cat {prompt_file}\"\n\ - continue = \"true {session} {prompt_file}\"\n", - ); - let id = ready_task(&mut store, &project); - dispatch(&mut store, &ctx, id, None).unwrap(); - // no set_session_ref: capture never happened for this agent - - continue_dispatch(&mut store, &ctx, id, None, Some("B")).unwrap(); - - let sessions = store.sessions_for(id).unwrap(); - assert_eq!(sessions.len(), 2); - assert!(sessions[0].session_ref.is_none()); - // the fresh spawn re-sends the full prompt: preamble plus task body - let mut prompts = prompt_files(&ctx); - prompts.sort(); - let continuation_prompt = std::fs::read_to_string(prompts.last().unwrap()).unwrap(); - assert!(continuation_prompt.contains("voro done"), "preamble"); - assert!(continuation_prompt.contains("Do the thing"), "task body"); - } - - #[test] - fn continuation_without_a_continue_verb_ignores_prior_refs() { - let (mut store, ctx, project) = fixture("cat {prompt_file}"); - let id = dispatched_with_ref(&mut store, &ctx, &project); - - continue_dispatch(&mut store, &ctx, id, None, Some("B")).unwrap(); - - let sessions = store.sessions_for(id).unwrap(); - assert_eq!(sessions.len(), 2); - assert!( - sessions[0].session_ref.is_none(), - "a fresh spawn does not inherit the old session's ref" - ); - } - /// A ready task moved into `review` through the transition machine, so /// `open`'s state guard is exercised against a genuine review row. fn review_task(store: &mut Store, project_path: &Path) -> i64 { diff --git a/crates/voro/src/ui.rs b/crates/voro/src/ui.rs index e36ee00..4c9d1c7 100644 --- a/crates/voro/src/ui.rs +++ b/crates/voro/src/ui.rs @@ -17,6 +17,19 @@ fn task_ref(id: i64) -> String { format!("{:>4}", format!("#{id}")) } +/// A compact one-line rendering of a possibly multi-line question, for the row +/// summaries where only a single line fits: the first line, with a trailing `…` +/// when there is more (the full text reads in the cockpit detail pane). +fn question_summary(question: &str) -> String { + let mut lines = question.lines(); + let first = lines.next().unwrap_or(""); + if lines.next().is_some() { + format!("{first}…") + } else { + first.to_string() + } +} + pub fn draw(frame: &mut Frame, app: &App) { match app.screen { Screen::Cockpit => draw_cockpit(frame, app), @@ -177,7 +190,7 @@ fn draw_mode(frame: &mut Frame, app: &App) { ]; if let Some(q) = &t.question { lines.push(Line::from(Span::styled( - format!("question: {q}"), + format!("question: {}", question_summary(q)), Style::new().fg(Color::Cyan), ))); } @@ -561,7 +574,7 @@ fn draw_queue(frame: &mut Frame, app: &App, area: Rect) { } if let Some(q) = &c.task.question { spans.push(Span::styled( - format!(" — {q}"), + format!(" — {}", question_summary(q)), Style::new().fg(Color::Cyan), )); } @@ -631,10 +644,18 @@ fn draw_detail(frame: &mut Frame, app: &App, area: Rect) { Line::from(meta), ]; if let Some(q) = &task.question { - lines.push(Line::from(Span::styled( - format!("question: {q}"), - Style::new().fg(Color::Cyan), - ))); + // One `Line` per line of the question: ratatui does not break lines + // inside a `Span`, so a multi-line question rendered as a single span + // would collapse to one line. The detail pane is where the operator + // reads the full question before answering it in-session (DESIGN.md §6). + for (i, qline) in q.lines().enumerate() { + let text = if i == 0 { + format!("question: {qline}") + } else { + qline.to_string() + }; + lines.push(Line::from(Span::styled(text, Style::new().fg(Color::Cyan)))); + } } if let Some(pr) = &task.pr_url { lines.push(Line::from(pr_span(pr))); @@ -1054,6 +1075,18 @@ mod tests { .collect() } + #[test] + fn question_summary_collapses_a_multi_line_question_to_its_first_line() { + // a single-line question is unchanged + assert_eq!(question_summary("Schema A or B?"), "Schema A or B?"); + // a multi-line one collapses to the first line with a trailing ellipsis, + // the marker that there is more to read in the detail pane + assert_eq!( + question_summary("Which schema?\nA: normalised\nB: flat"), + "Which schema?…" + ); + } + #[test] fn parked_row_lists_blockers_with_open_ones_undimmed() { let r = row( @@ -1788,6 +1821,64 @@ mod tests { assert!(labels.contains(&"log"), "{labels:?}"); } + /// A multi-line question renders across multiple lines in the cockpit + /// detail pane (DESIGN.md §6) — ratatui does not break a `Span` on a + /// newline, so each line of the question is its own `Line`. + #[test] + fn detail_pane_renders_a_multi_line_question_across_lines() { + use crate::app::App; + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use voro_core::{Action, NewTask, Store}; + + let mut store = Store::open_in_memory().unwrap(); + let p = store.create_project("voro", "/tmp/voro").unwrap(); + store.set_weight(p.id, 3).unwrap(); + let task = store + .create_task(NewTask { + project_id: p.id, + title: "mid-flight".into(), + body: String::new(), + priority: Priority::P2, + state: TaskState::Ready, + agent: None, + human: false, + }) + .unwrap(); + store + .record_dispatch(task.id, "claude", Some(1), Some("/tmp/voro/open.log")) + .unwrap(); + store + .apply( + task.id, + Action::Ask("Pick a schema:\nAlpha option\nBravo option".into()), + ) + .unwrap(); + + let ctx = crate::dispatch::DispatchCtx::from_db_path(std::path::Path::new( + "/nonexistent/voro.db", + )); + let app = App::new(store, ctx).unwrap(); + let width: u16 = 100; + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + terminal.draw(|f| draw(f, &app)).unwrap(); + // Reassemble the buffer into rows so each question line is checked on + // its own terminal row — a single-span rendering would collapse them. + let rows: Vec<String> = terminal + .backend() + .buffer() + .content() + .chunks(width as usize) + .map(|row| row.iter().map(|c| c.symbol()).collect::<String>()) + .collect(); + assert!( + rows.iter().any(|r| r.contains("question: Pick a schema:")), + "{rows:?}" + ); + assert!(rows.iter().any(|r| r.contains("Alpha option")), "{rows:?}"); + assert!(rows.iter().any(|r| r.contains("Bravo option")), "{rows:?}"); + } + /// The cockpit key line only advertises the score/history toggles and the /// dispatch keys while a task is selected — with an empty queue there is /// nothing for them to act on, so they drop out. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 32ee22b..909fd66 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -112,7 +112,7 @@ CREATE TABLE events ( -- append-only audit of state transitions & ans Ready-work detection — the thing beads would have provided — is one query: a task is *unblocked* when no `blocks` dependency points at a task not in `done`/`rejected`. Only `blocks` gates readiness; `discovered-from`, `parent`, and `related` are navigational metadata. When a task's last blocker closes, the store promotes it `parked → ready` and stamps `state_since`, which is what makes the prepared-prompt pattern work: tomorrow's task, written today and chained behind its blocker, surfaces fully loaded the moment it becomes actionable. Auto-promotion applies only to parked tasks that *have* blockers — a parked task with none is deliberately deferred and moves only by manual unpark. The reverse holds too: adding an open blocker to a `ready` or `stalled` task demotes it back to `parked` in the same write (a demoted stall re-promotes to `ready`, not `stalled` — the stall context is stale by the time the blocker closes), and any state transition that would land a task in `ready` or `stalled` while a blocker is still open — triage, abort, manual unpark, a reconciled stall — is reconciled the same way. The invariant is therefore total: `ready` always means genuinely actionable, so the scheduler can hide blocked work by hiding `parked` alone. -A project that has stopped mattering is **archived** rather than deleted: `voro project archive` (and the projects screen's `A` key) sets the flag, and every cockpit view — the queue, `voro next`, the state counts and `stats`, the running strip — excludes the project and *all* of its tasks, whatever state each holds. This is retirement, not a transition: no task is moved or closed, the event log is untouched, and unarchiving restores the pre-archive view exactly. It is deliberately distinct from weight 0, which is a snooze — a parked project is expected back and its row sits untagged among the rest — whereas an archived project remains only on the projects screen and `voro project list`, dimmed under an `[archived]` tag, so it can be found and unarchived. The flag also closes the side doors: dispatch, redispatch, and continuation refuse a task in an archived project, and `add`/`propose`/import refuse to create new work there — the refusals live in `voro-core` beside the human-task guards, so no interface can smuggle work into a retired project. Deleting a project outright stays reserved for one with no tasks at all; removing a project *and* its history is a separate, deliberate purge. +A project that has stopped mattering is **archived** rather than deleted: `voro project archive` (and the projects screen's `A` key) sets the flag, and every cockpit view — the queue, `voro next`, the state counts and `stats`, the running strip — excludes the project and *all* of its tasks, whatever state each holds. This is retirement, not a transition: no task is moved or closed, the event log is untouched, and unarchiving restores the pre-archive view exactly. It is deliberately distinct from weight 0, which is a snooze — a parked project is expected back and its row sits untagged among the rest — whereas an archived project remains only on the projects screen and `voro project list`, dimmed under an `[archived]` tag, so it can be found and unarchived. The flag also closes the side doors: dispatch and redispatch refuse a task in an archived project, and `add`/`propose`/import refuse to create new work there — the refusals live in `voro-core` beside the human-task guards, so no interface can smuggle work into a retired project. Deleting a project outright stays reserved for one with no tasks at all; removing a project *and* its history is a separate, deliberate purge. Agent definitions are command templates, not state, so they live outside the database. Voro *owns* the common ones — `claude` and `codex` are compiled into `voro-core`, so they version with the binary and every upgrade carries the current verb set (the session verbs of §8) to every install with no re-init. The user's `~/.config/voro/voro.toml` is then layered on top and is for extensions, overrides, and app options: it may add a new agent, replace a built-in wholesale (a `[agents.claude]` table overrides the built-in claude *entirely*, not per-verb — predictable over a partial merge), and set `default_agent` and the viewers. Viewers are command templates too, and live in the same file for the same reason: named `[viewers.<name>]` tables define how a task's diff is shown locally (§8/§11a), `default_viewer` names the one used when nothing picks a viewer by name, and the older single anonymous `[viewer]` table stays valid as that default (a sole named viewer also serves as the default without being named). Which viewer a *project* uses is state, so it lives in the database (`projects.review_action`, §8), referencing these templates by name. A viewer command carries up to three optional placeholders (§8): `{path}` — the task's worktree, or the project checkout when it has none — plus `{branch}` (the task's branch, empty when it has none) and `{base}` (the checkout's default branch), so `{base}...{branch}` spells the review diff's range rather than opening a bare directory. Because it carries app options like the viewers and not just agents, the file is named `voro.toml`. A missing file is not an error; the built-ins alone are a working config, so a fresh install with `claude` on PATH dispatches without any TOML. `voro agent list` shows the effective set with each agent's provenance — built-in, user, or user-override — and warns when a user override of a built-in drops verbs the built-in defined, the one staleness case layering cannot fix; `voro viewer list` does the same for viewers, flagging the default. @@ -140,7 +140,7 @@ cmd = "git -C {path} difftool -d {base}...{branch}" | `parked` | Triaged, real, but out of the running: dependencies open or deliberately deferred. Invisible to scheduler. | triage; dependency added | dependencies close → `ready`; manual unpark → `ready`; abandon → `rejected` | | `ready` | Actionable now. Eligible for the queue's start rows and for `voro next`. | triage; last blocker closes | dispatch or manual start → `running`; park → `parked`; abandon → `rejected` | | `running` | An agent (or the human) is actively on it. | dispatch | agent raises question → `needs-input`; work lands → `review`; abort → `ready`; session dies without reporting → `stalled` (reconcile, §8). | -| `needs-input` | Blocked on a human decision; `question` is set. **First among equals in the queue.** | agent verb; manual flag | answer supplied → `running` (answer appended to the task body under an `## Answers` heading and logged as an event; §8 covers how it then reaches the work); abandon → `rejected` | +| `needs-input` | Blocked on a human decision; `question` is set. **First among equals in the queue.** | agent verb; manual flag | question answered in the agent's own session, then `resume` → `running` (no answer text is recorded — the exchange lives in the session transcript; §8); abandon → `rejected` | | `review` | Agent believes it is done; awaiting human acceptance. | agent completion | accept → `done`; reject with feedback → `running`; hand off → `waiting`; abandon → `rejected` | | `waiting` | Handed off to an external party — a PR awaiting someone else's review or merge. Blocked on someone else's move, so it asks nothing of the operator: no state bonus, out of the queue like `parked` (§7). | hand off from `review` | accept (PR merged) → `done`; reject with feedback (changes requested) → `running`; reclaim ("my move again") → `review`; abandon → `rejected` | | `stalled` | In-flight work whose dispatch died (`failed`/`capped`) — the third attention state. In the queue for redispatch; never handed out by `voro next`. | reconcile on a dead session (§8) | redispatch or manual start → `running`; completion reported on the dead session's behalf → `review` (the misfire case, §8); park → `parked`; abandon → `rejected`. An open blocker demotes it to `parked`, from which it re-promotes to `ready`, not `stalled` — the stall context is stale by then. | @@ -148,11 +148,11 @@ cmd = "git -C {path} difftool -d {base}...{branch}" Three deliberate choices. Any triaged, non-terminal state can be *abandoned* straight to `rejected` — obsolete work must not need walking through the rest of the machine to close, and parking (`ready` → `parked`) has a manual inverse for the same reason. Second, `needs-input`, `review`, and `stalled` are all human-attention states but are kept distinct because they sort differently: at equal score an unanswered question outranks a completed diff, which outranks a dead dispatch, which outranks startable work, which outranks an untriaged proposal — a question stalls in-flight work; a proposal's priority is agent-asserted and untrusted until triage, so it wins nothing but ties it deserves. Third, `proposed` exists precisely so agent-generated tasks can be captured freely without granting them anything: each proposed task competes in the queue on the same score as everything else and cannot be dispatched until a human triages it. Surfacing proposals in the queue rather than behind an approval step keeps the generation pipeline honest without automating it — triage is one keypress away. Under the queue's uniform cap (§7) a low-scoring proposal can fall past the visible rows into the browser, so an always-visible untriaged count is what keeps the pipeline felt when the individual rows drop off. -A **human task** (§3) walks a shortened path through the same machine rather than earning states of its own. Its executor is the human, which collapses two states: `needs-input` is unreachable because the executor cannot be blocked on their own decision — real-world verification of a human task's output is a downstream `blocks`-dependent task (often agent work), not a sub-state of this one — and `review` is unreachable because the human is both executor and acceptor, so completion goes `running → done` directly. The transition API enforces this: `ask` on a human task is refused, `complete` lands in `done`, and dispatch, redispatch, and continuation all refuse to open an agent session on one — the same shape as dispatch's refusal of a non-git project path, an error saying why rather than a silent skip. Setting an agent override on a human task (or flagging human a task that carries one) is refused too, since the override exists only to pick a dispatch agent. To keep the unreachability total, a task currently sitting in `needs-input` or `review`, or one with an agent session still open, cannot be flagged human as it stands — such a task is demonstrably agent-executed; resolve it first. +A **human task** (§3) walks a shortened path through the same machine rather than earning states of its own. Its executor is the human, which collapses two states: `needs-input` is unreachable because the executor cannot be blocked on their own decision — real-world verification of a human task's output is a downstream `blocks`-dependent task (often agent work), not a sub-state of this one — and `review` is unreachable because the human is both executor and acceptor, so completion goes `running → done` directly. The transition API enforces this: `ask` on a human task is refused, `complete` lands in `done`, and dispatch and redispatch both refuse to open an agent session on one — the same shape as dispatch's refusal of a non-git project path, an error saying why rather than a silent skip. Setting an agent override on a human task (or flagging human a task that carries one) is refused too, since the override exists only to pick a dispatch agent. To keep the unreachability total, a task currently sitting in `needs-input` or `review`, or one with an agent session still open, cannot be flagged human as it stands — such a task is demonstrably agent-executed; resolve it first. `review` carries a *sub-state* in its fields rather than splitting into two states: a review task with no `pr_url` is awaiting a PR (the `pr` verb opens one from its completion summary, §8), and one with `pr_url` set has its PR open. The next-action derivation (§3) renders this as the verb *pr* versus *review PR* — the presence of a tracked PR is the sub-state, so no new task state is needed and `review` stays the single "awaiting human acceptance" state. -`waiting` is the state for work in flight on *someone else's* move. Once the operator has run `pr` and the PR is up awaiting another person's review or merge, there is nothing the operator can do, yet `review` is an attention state that would keep the task occupying a queue row (with a state bonus, §7) indefinitely. `waiting` says "in flight, but not my move": it earns no state bonus and is excluded from the queue entirely, like `parked`, appearing only in the task browser and the state counts. It derives no next-action verb (§3). It is reached only from `review`, via the *hand off* transition (`voro wait`), and leaves by four manual moves: *accept* (the PR merged) → `done`, *reject with feedback* (changes requested) → `running` — reusing the review→running feedback-continuation path — *reclaim* (it is the operator's move again) → `review`, and *abandon* → `rejected`. Entering `waiting` only from `review` is deliberate: waiting on a person *before* work starts is what `parked` plus a blocker already expresses, so the more general "blocked on an external party at any point" state is deferred until a concrete need for it appears. Return-path automation — a reconcile that polls `gh pr view` and pulls a merged PR to `done` or a change-requested one back to `review` — is likewise deferred; today every exit is a manual operator move. +`waiting` is the state for work in flight on *someone else's* move. Once the operator has run `pr` and the PR is up awaiting another person's review or merge, there is nothing the operator can do, yet `review` is an attention state that would keep the task occupying a queue row (with a state bonus, §7) indefinitely. `waiting` says "in flight, but not my move": it earns no state bonus and is excluded from the queue entirely, like `parked`, appearing only in the task browser and the state counts. It derives no next-action verb (§3). It is reached only from `review`, via the *hand off* transition (`voro wait`), and leaves by four manual moves: *accept* (the PR merged) → `done`, *reject with feedback* (changes requested) → `running` — reusing the review→running feedback path, which keeps the same agent session open (§8) — *reclaim* (it is the operator's move again) → `review`, and *abandon* → `rejected`. Entering `waiting` only from `review` is deliberate: waiting on a person *before* work starts is what `parked` plus a blocker already expresses, so the more general "blocked on an external party at any point" state is deferred until a concrete need for it appears. Return-path automation — a reconcile that polls `gh pr view` and pulls a merged PR to `done` or a change-requested one back to `review` — is likewise deferred; today every exit is a manual operator move. ## 7. Scoring @@ -190,16 +190,16 @@ Because the state bonus lives in the score, state usually settles itself; the st **Redispatch** is a first-class action born of the same cap case: a dispatch that dies `capped` or `failed` lands its task in `stalled` (the reconciler performs `running → stalled` itself — see *Observing the end of a session* below), and redispatching it offers the agent picker plus the previous session's notes/log so the successor agent does not start cold; dispatch's precondition accepts `stalled → running` beside `ready → running`. `stalled` is a first-class state, not a flag derived from session history: the state machine carries it, the scheduler scores it (§7), and a stalled row's *redispatch* verb comes from the same next-action derivation (§3) as every other row's, reading the state alone. -**Continuation** is how an answer — or a rejection's feedback — reaches the work. §6 says the answer to a `needs-input` question is appended to the body and logged, but stops short of saying how it gets back to the agent — and by the time a human answers, the asking session's *process* has typically exited, so there is no live pipe to feed it to. Its session *row* stays open, though: a session's life follows the task, not the process (see *Session lifecycle* below), so the continuation supersedes that open row rather than racing a closed one. `voro answer` (and the TUI's equivalent action) always performs the `needs-input → running` transition, and additionally dispatches a fresh session whenever the task has *any* prior session history; `voro reject` is symmetric, continuing the work with the feedback in hand after the `review → running` transition — and because `review` keeps the session open, that reuses the *same* agent session where the agent can address it. A task that was only ever started by hand has nothing to continue, and the transition stands alone; this is what keeps `answer`/`reject` usable on tasks nothing has ever dispatched. Continuation reuses dispatch's own mechanics (agent resolution, the dirty-tree guard, prompt/log files) but not its precondition: it asserts the task is already `running` — `answer`/`reject` just put it there — rather than performing the `ready → running` transition itself, so the same code path can never be used to smuggle a task into `running` outside the transition API. `voro continue <task-id>` exposes the mechanism directly, for retrying a continuation that failed (a dirty tree, a misconfigured agent) once the underlying problem is fixed, and `--no-dispatch` opts out of continuation even when history exists. +**Answering a question happens in the session, not through Voro.** When an agent hits a blocker it calls `voro ask`, landing the task in `needs-input` with its `question` set. Voro's job from there is to be an accurate *signpost* — which task is blocked, on what question, surfaced on the inbox row and in the detail pane — not the door into the conversation. The operator opens the agent's own session directly (the `voro-<id>` session it runs under, in a `claude agents` pane or the equivalent) and answers there, where the agent still has its full context; Voro records no answer text, since the exchange lives in the session transcript, addressable through the session ref already captured on the session row. Once answered, `voro resume <task-id>` moves the task `needs-input → running` and nothing more: the dispatch preamble tells the agent to run it in-session after its question is answered (the primary path), and Enter on a `needs-input` inbox row is the operator's backstop for the same transition. This replaces an earlier headless-continuation design — a fresh session re-sent the whole task body carrying the appended answer — whose only reliable path for the built-in `claude`, which has no headless *continue* verb, was to *restart* the task rather than resume the conversation; a human already watching the session is a strictly better place to answer than a text box in Voro. `voro reject` is the symmetric move on the `review → running` (and `waiting → running`) edge: it appends the feedback to the task body under a `## Feedback` heading and returns the task to `running`. Because `review`/`waiting` keep the session open (see *Session lifecycle* below), the feedback lands back on the *same* agent session when its process is still alive — the operator having stayed attached — and otherwise the task stalls on the next reconcile and is redispatched with the feedback now in its body (the redispatch prompt carries it). Both `resume` and `reject` route through the transition API and change only state, so neither can smuggle a task into `running` outside the machine; a task never dispatched still answers or rejects as a plain transition, since nothing about them depends on a prior session. -**Session lifecycle.** A session's life follows the *task*, not the agent's process listing. It is opened at dispatch (in the same transaction as `ready → running`), stays open across `running → needs-input → review` — `review` keeps it open on purpose, so a reject-with-feedback can continue the *same* agent session — and is closed by the terminal transition that tears the running work down, stamped with the matching outcome in the same transaction: `Accept` closes it `completed`, `Abort` and `Abandon` close it `aborted`. `RejectWork` deliberately leaves it open (the task returns to `running` and the session is continued). `waiting` (§6) behaves exactly as `review` here: the hand-off keeps the session open, so a change-requested `RejectWork` from `waiting` reuses the same agent session, and `Accept`/`Abandon` from `waiting` close it (`completed`/`aborted`) precisely as they do from `review`. Reconciliation therefore leaves a `waiting` task's open session untouched regardless of process liveness, the same treatment it gives `needs-input` and `review`. A task holds **at most one open session** as an invariant: opening a continuation or redispatch first closes its predecessor in the same transaction, enforced by a partial unique index on `sessions(task_id) WHERE ended_at IS NULL`. Rows stay one-per-attempt — each keeps its own pid, log, and outcome, and the redispatch flag still derives from the latest one — but two can never be open at once, so a task can never render twice in the running strip. The agent-side session (its captured `session_ref`) is reused for every continuation that can address it, falling back to a fresh agent session only when the old one is unusable (crashed, capped, no ref captured). +**Session lifecycle.** A session's life follows the *task*, not the agent's process listing. It is opened at dispatch (in the same transaction as `ready → running`), stays open across `running → needs-input → review` — `needs-input` keeps it open so the operator answers in that same session, and `review` keeps it open so a reject-with-feedback returns the work to it — and is closed by the terminal transition that tears the running work down, stamped with the matching outcome in the same transaction: `Accept` closes it `completed`, `Abort` and `Abandon` close it `aborted`. `Resume` and `RejectWork` deliberately leave it open (the task returns to `running` on the session it already had). `waiting` (§6) behaves exactly as `review` here: the hand-off keeps the session open, so a change-requested `RejectWork` from `waiting` returns to the same agent session, and `Accept`/`Abandon` from `waiting` close it (`completed`/`aborted`) precisely as they do from `review`. Reconciliation therefore leaves a `waiting` task's open session untouched regardless of process liveness, the same treatment it gives `needs-input` and `review`. A task holds **at most one open session** as an invariant: opening a redispatch first closes any predecessor still open in the same transaction, enforced by a partial unique index on `sessions(task_id) WHERE ended_at IS NULL`. Rows stay one-per-attempt — each keeps its own pid, log, and outcome, and the redispatch flag still derives from the latest one — but two can never be open at once, so a task can never render twice in the running strip. **Worktree lifecycle.** A dispatched agent typically does its work in a throwaway git worktree of the project checkout it creates itself — Voro runs no git during dispatch (below), so the branch and its worktree are the agent's to make. Nothing else prunes those, so a worktree's lifetime is tied to the *session's*: it lives as long as the session does, and is torn down when the session closes — that is, at the terminal transition that closes the task. The teardown is owned by Voro rather than the agent because by then the agent has exited, and because it is an operator action: "Voro runs no git" governs branch *management during dispatch*, not operator-invoked git at task close. Only the closing transitions that discard or accept the work clean up — `Accept` and `Abandon`; `Abort` deliberately does not, since it returns the task to the queue and its in-progress worktree may be wanted on redispatch. Given a task with a branch, Voro finds the worktree of the project checkout on that branch (never the primary checkout) and removes it with a plain, non-forced `git worktree remove`: a dirty worktree makes git refuse, which is reported and left in place rather than force-removed, and the transition stands regardless. With the worktree gone the branch is checked out nowhere and can be deleted too — but only when its work is verifiably upstream, since squash-merging (this repo's convention) leaves the branch tip a non-ancestor of `main` that `git branch -d` will not recognise: a merged PR (checked via the task's `pr_url` with `gh pr view`) authorises a `git branch -D`, and without one a plain `git branch -d` is attempted and the branch left alone if git refuses. An unverified branch is never force-deleted. This on-close cleanup is a CLI-only affair: only `voro accept`/`abandon` perform it, announcing every destructive step before it runs — the operator is shown the worktree path, the branch, and why it is judged safe, and confirms at a `y/N` prompt (with `--yes` to skip it for scripting); declining skips the cleanup but still completes the transition. Closing a task in the TUI does no cleanup at all — the transition applies and the worktree and branch are left in place, to be removed later on the CLI or by hand. The git/`gh` I/O lives in the `voro` crate beside dispatch, so `voro-core` stays free of process and filesystem I/O. **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 `running` session whose process died without reporting — 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 (both run from the `voro` crate; `voro-core` never touches a process, it only takes the liveness result as a plain bool and decides what it means). 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 `needs-input`, `review`, or `waiting`: the session is meant to stay open — it is reused when the answer or feedback continues the work — 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 `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. 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`. @@ -228,15 +228,15 @@ The return path depends on the agent remembering to call it, and for Claude Code Dispatch runs in the project's path with a dirty-tree guard in v1; per-dispatch worktrees are deferred until parallel dispatch within one project is actually wanted (§11). -**Planning sessions** are the same machinery pointed at the *front* of a task's life: agent-assisted task creation, where the operator plans a task interactively with an agent and the deliverable of the session is a Voro task, not a PR. This is TUI-only by design — the CLI is how an LLM drives Voro, so an LLM-drafting verb there would be circular — and it is interactive by design: a one-shot variant (agent expands a description into a pre-filled editor form) was considered and rejected, because task planning is usually a back-and-forth and an interactive session subsumes the one-shot case (say what you want, confirm, exit). From the TUI, `N` (beside `n`'s manual editor, which stays first-class — the same lowercase-default, uppercase-variant pairing as `d`/`D`) picks a project and suspends the terminal in the same round-trip used for `$EDITOR` and attach/resume, launching the default agent's **`plan` verb** in the project's checkout: an optional agent template alongside dispatch/sessions/attach/resume/continue — an interactive *foreground* command carrying `{prompt_file}`, built in for `claude` — that degrades like the other optional verbs, an agent without one yielding a status line saying what to configure. The prompt seeds the session with its job: it is drafting a task for that project; interview the operator as needed; write the body as a self-contained dispatchable prompt (named files, acceptance criteria); and when the operator confirms, create the task with `voro add` — the CLI is the agent's interface exactly as in dispatch, down to the rendered `--db` flag for a non-default store, so Voro gains no new store write path and parses no agent output. When the session exits the TUI refreshes, and the new task appears in the queue as `proposed` for ordinary triage — the human already saw the content, but triage stays uniform. A session that exits without creating a task is a no-op, not an error; no session row is recorded and no dirty-tree guard applies, since planning writes nothing to the tree. The built-in claude verbs pick per-purpose models for their different jobs — a stronger reasoning model on `plan`, a workhorse on `dispatch` — passing the `claude` model aliases (`fable`, `opus`) rather than pinned ids so they track the current model of each class without churning; an operator overrides the agent wholesale in `voro.toml` to change them (docs/agent-integration.md). +**Planning sessions** are the same machinery pointed at the *front* of a task's life: agent-assisted task creation, where the operator plans a task interactively with an agent and the deliverable of the session is a Voro task, not a PR. This is TUI-only by design — the CLI is how an LLM drives Voro, so an LLM-drafting verb there would be circular — and it is interactive by design: a one-shot variant (agent expands a description into a pre-filled editor form) was considered and rejected, because task planning is usually a back-and-forth and an interactive session subsumes the one-shot case (say what you want, confirm, exit). From the TUI, `N` (beside `n`'s manual editor, which stays first-class — the same lowercase-default, uppercase-variant pairing as `d`/`D`) picks a project and suspends the terminal in the same round-trip used for `$EDITOR` and attach/resume, launching the default agent's **`plan` verb** in the project's checkout: an optional agent template alongside dispatch/sessions/attach/resume — an interactive *foreground* command carrying `{prompt_file}`, built in for `claude` — that degrades like the other optional verbs, an agent without one yielding a status line saying what to configure. The prompt seeds the session with its job: it is drafting a task for that project; interview the operator as needed; write the body as a self-contained dispatchable prompt (named files, acceptance criteria); and when the operator confirms, create the task with `voro add` — the CLI is the agent's interface exactly as in dispatch, down to the rendered `--db` flag for a non-default store, so Voro gains no new store write path and parses no agent output. When the session exits the TUI refreshes, and the new task appears in the queue as `proposed` for ordinary triage — the human already saw the content, but triage stays uniform. A session that exits without creating a task is a no-op, not an error; no session row is recorded and no dirty-tree guard applies, since planning writes nothing to the tree. The built-in claude verbs pick per-purpose models for their different jobs — a stronger reasoning model on `plan`, a workhorse on `dispatch` — passing the `claude` model aliases (`fable`, `opus`) rather than pinned ids so they track the current model of each class without churning; an operator overrides the agent wholesale in `voro.toml` to change them (docs/agent-integration.md). The subprocesses Voro launches that are *not* dispatches — the viewer open (§11a), the attach/resume round-trip (§11a), and the planning session above — do not each earn a session row and its per-session log, but their failures are just as easily swallowed: a detached viewer's output would otherwise go to `/dev/null`, and an attach failure is painted over the instant the TUI reinitialises. They share one append-only `launches.log` beside the per-session logs, recording each launch's command, cwd, and exit status. A failing attach or planning launch additionally holds its own error output on screen until a keypress before the TUI redraws over it. Single rolling file, no rotation — the same single-operator argument as the per-session logs above. ## 9. Cockpit -The TUI is built first and is the primary interface throughout. Ratatui, three regions: the **queue** (top), a **detail** pane showing the full body of whichever row is selected (middle), and a **running** strip showing live sessions, their agents, and their states (bottom). The strip filters on task *state* — every `running` task, joined with its open session if it has one — not on "has an open session". A `review` or `needs-input` task keeps its session open behind the scenes for feedback/answer continuation (§8), but it belongs to the queue, so it never appears in the strip — as does a `stalled` task, whose dead dispatch reconcile has already moved to the queue; conversely a `running` task with no open session (started by hand, so nothing was ever dispatched) still shows, as an orphan needing attention. Because a task holds at most one open session (§8), it renders in the strip exactly once — no stale rows for tasks that have closed, and no duplicates. +The TUI is built first and is the primary interface throughout. Ratatui, three regions: the **queue** (top), a **detail** pane showing the full body of whichever row is selected (middle), and a **running** strip showing live sessions, their agents, and their states (bottom). The strip filters on task *state* — every `running` task, joined with its open session if it has one — not on "has an open session". A `review` or `needs-input` task keeps its session open behind the scenes so the operator can address feedback or answer the question in it (§8), but it belongs to the queue, so it never appears in the strip — as does a `stalled` task, whose dead dispatch reconcile has already moved to the queue; conversely a `running` task with no open session (started by hand, so nothing was ever dispatched) still shows, as an orphan needing attention. Because a task holds at most one open session (§8), it renders in the strip exactly once — no stale rows for tasks that have closed, and no duplicates. -The first milestone deliberately restricts scope to three lists and a handful of keybindings — the risk of TUI-first is polishing panes before the workflow is validated, and the mitigation is scope, not sequence. Core interactions, roughly in order of implementation: create/edit a task in `$EDITOR` (title, body, priority, deps, agent override via frontmatter or a form) — or plan one interactively with an agent (§8's planning sessions, on the sibling key); edit project weights on a dedicated projects screen — one row per project, weight set by a single keystroke (*this must be fast — it happens every morning*); answer a queued question inline; dispatch a ready task (default agent) and dispatch-via-picker; accept/reject a review item; triage `proposed` tasks from the queue; redispatch a stalled task; a score-decomposition view folded inline into any task's detail (toggled with `x`, not a popup). +The first milestone deliberately restricts scope to three lists and a handful of keybindings — the risk of TUI-first is polishing panes before the workflow is validated, and the mitigation is scope, not sequence. Core interactions, roughly in order of implementation: create/edit a task in `$EDITOR` (title, body, priority, deps, agent override via frontmatter or a form) — or plan one interactively with an agent (§8's planning sessions, on the sibling key); edit project weights on a dedicated projects screen — one row per project, weight set by a single keystroke (*this must be fast — it happens every morning*); resume a queued question once it is answered in the agent's session; dispatch a ready task (default agent) and dispatch-via-picker; accept/reject a review item; triage `proposed` tasks from the queue; redispatch a stalled task; a score-decomposition view folded inline into any task's detail (toggled with `x`, not a popup). Every action ultimately gets a CLI equivalent so the whole tool is scriptable and agent-legible, but the human-facing CLI trails the TUI rather than preceding it. diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 2b409b1..5f1fb1f 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -30,10 +30,14 @@ You were dispatched by Voro on task $VORO_TASK_ID. When you reach one of these points, run the matching command — Voro surfaces it in the operator's queue: voro ask "$VORO_TASK_ID" --question "Schema A or B? Trade-offs: ..." + voro resume "$VORO_TASK_ID" voro done "$VORO_TASK_ID" --branch "$(git rev-parse --abbrev-ref HEAD)" --summary "Implemented X, tests pass" voro propose <project> "Follow-up title" --body-file plan.md - `ask` when you are blocked on a human decision and cannot proceed. +- `resume` once that question is answered here in this session, to move the task + back to `running` and carry on — Voro records no answer text, the exchange is + already in this transcript. - `done` when the work is complete and ready for review. Record **both** flags on the one call: `--branch` is the git branch your work landed on and `--summary` is a PR-ready account of what changed, why, and how you verified it — on a @@ -48,9 +52,9 @@ points, run the matching command — Voro surfaces it in the operator's queue: `VORO_TASK_ID` and `VORO_DB` are already in your environment — do not set them. ``` -`ask` moves the task to `needs-input`, `done` to `review`, and `propose` files a -`proposed` task discovered-from this one. See DESIGN.md §8 for why the surface is -this small. +`ask` moves the task to `needs-input`, `resume` back to `running`, `done` to +`review`, and `propose` files a `proposed` task discovered-from this one. See +DESIGN.md §8 for why the surface is this small. ## Session verbs: attachable dispatch @@ -76,7 +80,6 @@ plan = "claude --permission-mode auto --model fable \"$(cat {prompt_file})\" [agents.codex] dispatch = "codex exec \"$(cat {prompt_file})\"" resume = "codex resume {session}" -continue = "codex exec resume {session} \"$(cat {prompt_file})\"" ``` - `dispatch` may also carry `{task_id}`, replaced with the task's numeric id. @@ -90,8 +93,6 @@ continue = "codex exec resume {session} \"$(cat {prompt_file})\"" - `attach` opens the *running* session interactively; `{session}` is replaced with the reference Voro captured at dispatch. - `resume` reopens a *finished* session interactively. -- `continue` feeds a session new input headless — `{prompt_file}` holds the - input (an answer), `{session}` addresses the session. - `plan` runs an interactive *foreground* session for the TUI's agent-assisted task creation (DESIGN.md §8): `{prompt_file}` holds the planning brief, and the command owns the terminal until the conversation ends, so it must not @@ -137,15 +138,17 @@ alone. **Jump-in.** In the TUI, `a` on a running task runs the agent's `attach` command with the TUI suspended — the real session, full control, including answering permission prompts; on a review or stalled task it runs `resume` instead, -reopening the finished session. `answer` prefers the `continue` verb when it -exists and the session has a ref — the answer goes to the *same* session, context -intact — and otherwise spawns a fresh session re-sent the whole task body. +reopening the finished session. This is also how a `needs-input` question is +answered: the operator jumps into the agent's own session, answers in the +conversation, and then runs `voro resume` (or presses Enter on the inbox row) to +move the task back to `running`. Voro never records the answer text — the +exchange lives in the session transcript (DESIGN.md §6/§8). Every verb degrades gracefully when absent: no `attach`/`resume` disables the jump-in key for that agent, no `sessions` keeps pid-liveness reconciliation, no -`continue` keeps fresh-spawn continuation, no `plan` turns the TUI's planning key -into a status line saying what to configure. An agent defining only -`dispatch`/`cmd` behaves exactly as before the verbs existed. +`plan` turns the TUI's planning key into a status line saying what to configure. +An agent defining only `dispatch`/`cmd` behaves exactly as before the verbs +existed. ### tmux as a universal fallback @@ -164,8 +167,8 @@ attach = "tmux attach -t {session}" A tmux session vanishes from `list-sessions` when its command exits, which is exactly the drop-out the reconciler treats as finished-without-reporting — it finalises the session and lands the task in `stalled` (DESIGN.md §8), where -redispatch is one key away. There is no honest `resume`/`continue` for a dead -tmux session, so leave those verbs off and let redispatch handle it. +redispatch is one key away. There is no honest `resume` for a dead tmux +session, so leave that verb off and let redispatch handle it. ## Hooks as a fallback From a0107cbf39924700253439097613a156d1395902 Mon Sep 17 00:00:00 2001 From: Michael Johnson <mjohnson459@gmail.com> Date: Thu, 23 Jul 2026 13:03:38 +0100 Subject: [PATCH 2/2] Fix merge with #88: use guard_git_repo, drop stale dirty-tree wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of main (#88, "Drop the dirty-tree dispatch guard") into this branch resolved two overlapping regions to this branch's side, leaving: - `spawn_session` calling the removed `guard_clean_tree` instead of #88's renamed `guard_git_repo` (build break), and the module docstring still describing a dirty-checkout guard. - DESIGN.md §8 (planning sessions) still saying "no dirty-tree guard applies" — #88 rewrote that to "none of dispatch's guards apply, since planning only reads the checkout". Reconcile to #88's semantics (git-repo guard only, no dirty-tree check) while keeping this branch's signpost changes intact. cargo test --workspace (voro 194 + voro-core 209), clippy, and fmt all clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CfXJAsDas87iATfiSgpQpT --- crates/voro/src/dispatch.rs | 10 +++++----- docs/DESIGN.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/voro/src/dispatch.rs b/crates/voro/src/dispatch.rs index aaa975f..2f26ff0 100644 --- a/crates/voro/src/dispatch.rs +++ b/crates/voro/src/dispatch.rs @@ -1,8 +1,8 @@ //! Dispatching a ready (or stalled) task to a headless agent session (DESIGN.md -//! §8): the I/O half of dispatch — resolving the agent, guarding against a dirty -//! checkout, writing the prompt, and spawning the process detached — kept out of -//! voro-core, which stays pure of process and filesystem I/O. The atomic -//! state-plus-session write is voro-core's `Store::record_dispatch`. +//! §8): the I/O half of dispatch — resolving the agent, guarding that the +//! checkout is a git repository, writing the prompt, and spawning the process +//! detached — kept out of voro-core, which stays pure of process and filesystem +//! I/O. The atomic state-plus-session write is voro-core's `Store::record_dispatch`. //! //! For agents that define a `sessions` verb (task #75), dispatch additionally //! captures the agent's own session reference after launch — by polling the @@ -446,7 +446,7 @@ fn spawn_session( .resolve(agent_override.or(task.agent.as_deref())) .map_err(|e| e.to_string())?; - guard_clean_tree(&project.path)?; + guard_git_repo(&project.path)?; std::fs::create_dir_all(&ctx.runtime_dir) .map_err(|e| format!("cannot create {}: {e}", ctx.runtime_dir.display()))?; diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 029ba4a..cfc6050 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -228,7 +228,7 @@ The return path depends on the agent remembering to call it, and for Claude Code Dispatch runs in the project's path, which must be a git repository; the dispatched agent does its work in a throwaway worktree it creates (the preamble instructs this), so the operator's uncommitted changes never enter its diff. Voro-managed per-dispatch worktrees are deferred until parallel dispatch within one project is actually wanted (§11). -**Planning sessions** are the same machinery pointed at the *front* of a task's life: agent-assisted task creation, where the operator plans a task interactively with an agent and the deliverable of the session is a Voro task, not a PR. This is TUI-only by design — the CLI is how an LLM drives Voro, so an LLM-drafting verb there would be circular — and it is interactive by design: a one-shot variant (agent expands a description into a pre-filled editor form) was considered and rejected, because task planning is usually a back-and-forth and an interactive session subsumes the one-shot case (say what you want, confirm, exit). From the TUI, `N` (beside `n`'s manual editor, which stays first-class — the same lowercase-default, uppercase-variant pairing as `d`/`D`) picks a project and suspends the terminal in the same round-trip used for `$EDITOR` and attach/resume, launching the default agent's **`plan` verb** in the project's checkout: an optional agent template alongside dispatch/sessions/attach/resume — an interactive *foreground* command carrying `{prompt_file}`, built in for `claude` — that degrades like the other optional verbs, an agent without one yielding a status line saying what to configure. The prompt seeds the session with its job: it is drafting a task for that project; interview the operator as needed; write the body as a self-contained dispatchable prompt (named files, acceptance criteria); and when the operator confirms, create the task with `voro add` — the CLI is the agent's interface exactly as in dispatch, down to the rendered `--db` flag for a non-default store, so Voro gains no new store write path and parses no agent output. When the session exits the TUI refreshes, and the new task appears in the queue as `proposed` for ordinary triage — the human already saw the content, but triage stays uniform. A session that exits without creating a task is a no-op, not an error; no session row is recorded and no dirty-tree guard applies, since planning writes nothing to the tree. The built-in claude verbs pick per-purpose models for their different jobs — a stronger reasoning model on `plan`, a workhorse on `dispatch` — passing the `claude` model aliases (`fable`, `opus`) rather than pinned ids so they track the current model of each class without churning; an operator overrides the agent wholesale in `voro.toml` to change them (docs/agent-integration.md). +**Planning sessions** are the same machinery pointed at the *front* of a task's life: agent-assisted task creation, where the operator plans a task interactively with an agent and the deliverable of the session is a Voro task, not a PR. This is TUI-only by design — the CLI is how an LLM drives Voro, so an LLM-drafting verb there would be circular — and it is interactive by design: a one-shot variant (agent expands a description into a pre-filled editor form) was considered and rejected, because task planning is usually a back-and-forth and an interactive session subsumes the one-shot case (say what you want, confirm, exit). From the TUI, `N` (beside `n`'s manual editor, which stays first-class — the same lowercase-default, uppercase-variant pairing as `d`/`D`) picks a project and suspends the terminal in the same round-trip used for `$EDITOR` and attach/resume, launching the default agent's **`plan` verb** in the project's checkout: an optional agent template alongside dispatch/sessions/attach/resume — an interactive *foreground* command carrying `{prompt_file}`, built in for `claude` — that degrades like the other optional verbs, an agent without one yielding a status line saying what to configure. The prompt seeds the session with its job: it is drafting a task for that project; interview the operator as needed; write the body as a self-contained dispatchable prompt (named files, acceptance criteria); and when the operator confirms, create the task with `voro add` — the CLI is the agent's interface exactly as in dispatch, down to the rendered `--db` flag for a non-default store, so Voro gains no new store write path and parses no agent output. When the session exits the TUI refreshes, and the new task appears in the queue as `proposed` for ordinary triage — the human already saw the content, but triage stays uniform. A session that exits without creating a task is a no-op, not an error; no session row is recorded and none of dispatch's guards apply, since planning only reads the checkout and writes nothing to it. The built-in claude verbs pick per-purpose models for their different jobs — a stronger reasoning model on `plan`, a workhorse on `dispatch` — passing the `claude` model aliases (`fable`, `opus`) rather than pinned ids so they track the current model of each class without churning; an operator overrides the agent wholesale in `voro.toml` to change them (docs/agent-integration.md). The subprocesses Voro launches that are *not* dispatches — the viewer open (§11a), the attach/resume round-trip (§11a), and the planning session above — do not each earn a session row and its per-session log, but their failures are just as easily swallowed: a detached viewer's output would otherwise go to `/dev/null`, and an attach failure is painted over the instant the TUI reinitialises. They share one append-only `launches.log` beside the per-session logs, recording each launch's command, cwd, and exit status. A failing attach or planning launch additionally holds its own error output on screen until a keypress before the TUI redraws over it. Single rolling file, no rotation — the same single-operator argument as the per-session logs above.