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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions crates/voro-core/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3207,8 +3207,8 @@ mod tests {
}
}

/// The round's guard rails: a task that is not `proposed` cannot start one,
/// and one that is not `refining` cannot conclude one.
/// The round's guard rails: a task past `proposed`/`ready` cannot start
/// one, and one that is not `refining` cannot conclude one.
#[test]
fn refine_transitions_are_refused_from_the_wrong_state() {
use crate::transition::{Action, Triage};
Expand All @@ -3220,13 +3220,13 @@ mod tests {
Err(Error::InvalidTransition { .. })
));

s.apply(t.id, Action::Triage(Triage::Ready)).unwrap();
s.apply(t.id, Action::Triage(Triage::Parked)).unwrap();
assert!(matches!(
s.record_refine_launch(t.id, "too late", "claude", None, None),
Err(Error::InvalidTransition { .. })
));
// The refused launch wrote nothing — no session, no state change.
assert_eq!(s.task(t.id).unwrap().state, TaskState::Ready);
assert_eq!(s.task(t.id).unwrap().state, TaskState::Parked);
assert!(s.sessions_for(t.id).unwrap().is_empty());
}

Expand Down
127 changes: 113 additions & 14 deletions crates/voro-core/src/transition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ pub enum Triage {
pub enum Action {
/// proposed → parked | ready | rejected
Triage(Triage),
/// proposed → refining; the string is the operator's refine note, which
/// rides the transition as a `refined` event (empty for the interactive
/// flavour, which is a conversation rather than a brief).
/// proposed | ready → refining; the string is the operator's refine note,
/// which rides the transition as a `refined` event (empty for the
/// interactive flavour, which is a conversation rather than a brief).
Refine(String),
/// refining → proposed; the round is over, however it ended.
ConcludeRefine(RefineOutcome),
Expand Down Expand Up @@ -167,9 +167,11 @@ impl Store {
}

/// Refine's atomic write (DESIGN.md §6), the shape [`record_dispatch`]
/// established: move the task `proposed → refining` and open the round's
/// session in one transaction, so a refining task always has a session to
/// probe and a refine session always names a task that is refining. The
/// established: move the task `proposed | ready → refining` and open the
/// round's session in one transaction, so a refining task always has a
/// session to probe and a refine session always names a task that is
/// refining. A round launched from `ready` still concludes to `proposed`,
/// which is what sends the rewritten body back through triage. The
/// note rides the transition; the empty string is the interactive flavour,
/// which is a conversation rather than a brief. Spawning the process is the
/// caller's job, before this commits.
Expand All @@ -195,8 +197,11 @@ impl Store {
/// trigger comes through here — the agent's own `set --body-file`
/// (`Applied`), a reconciled dead agent (`Failed`), a quit or cancelled
/// session (`Cancelled`) — so the returned proposal's markers read from one
/// place. Refused on a task that is not refining, like any other
/// transition.
/// place. The landing is `proposed` whatever the round started from: the
/// round keeps no memory of its origin, so a task refined out of `ready`
/// comes back for a fresh verdict on the body that replaced the one the old
/// verdict was issued against. Refused on a task that is not refining, like
/// any other transition.
pub fn conclude_refine(&mut self, task_id: i64, outcome: RefineOutcome) -> Result<Task> {
self.apply(task_id, Action::ConcludeRefine(outcome))
}
Expand Down Expand Up @@ -385,10 +390,11 @@ fn apply_action(tx: &Connection, task_id: i64, action: Action) -> Result<TaskSta
(Proposed, Action::Triage(Triage::Parked)) => Parked,
(Proposed, Action::Triage(Triage::Ready)) => Ready,
(Proposed, Action::Triage(Triage::Reject)) => Rejected,
// A refine round: out of the triage queue while an agent rewrites the
// body, back to `proposed` for a real verdict when it concludes
// (DESIGN.md §6).
(Proposed, Action::Refine(_)) => Refining,
// A refine round: out of the queue while an agent rewrites the body,
// back to `proposed` for a real verdict when it concludes — from
// `ready` as much as from `proposed`, since a verdict issued against a
// body that no longer exists has to be reissued (DESIGN.md §6).
(Proposed | Ready, Action::Refine(_)) => Refining,
(Refining, Action::ConcludeRefine(_)) => Proposed,
(Ready | Stalled, Action::Start) => Running,
// A human task cannot be blocked on a decision — the executor *is* the
Expand Down Expand Up @@ -796,7 +802,7 @@ mod tests {
(Proposed, Action::Triage(Triage::Parked)) => Some(Parked),
(Proposed, Action::Triage(Triage::Ready)) => Some(Ready),
(Proposed, Action::Triage(Triage::Reject)) => Some(Rejected),
(Proposed, Action::Refine(_)) => Some(Refining),
(Proposed | Ready, Action::Refine(_)) => Some(Refining),
(Refining, Action::ConcludeRefine(_)) => Some(Proposed),
(Ready | Stalled, Action::Start) => Some(Running),
(Ready | Stalled, Action::Park) => Some(Parked),
Expand Down Expand Up @@ -844,7 +850,7 @@ mod tests {
/// `legal_actions` is the transition *menu*, so it matches `apply` on every
/// action but one: launching a refine is a legal transition the menu
/// deliberately withholds, because that menu collects verdicts and refine is
/// not one (DESIGN.md §6) — it answers from its own key over the proposal.
/// not one (DESIGN.md §6) — it answers from its own key over the row.
#[test]
fn legal_actions_agrees_with_apply() {
for state in TaskState::ALL {
Expand Down Expand Up @@ -879,6 +885,99 @@ mod tests {
}
}

// --- refine rounds (DESIGN.md §6): where they start and where they land ---

mod refine {
use super::*;

/// The note-driven and interactive flavours are one action carrying
/// different notes, so both must open a round from `ready` and both
/// must open its session in the same write.
#[test]
fn a_ready_task_can_be_refined_in_either_flavour() {
for note in ["the body names no files", ""] {
let (mut s, p) = store_with_project();
let id = create(&mut s, p, TaskState::Ready);

let (task, session) = s
.record_refine_launch(id, note, "claude", Some(4242), None)
.unwrap();
assert_eq!(task.state, TaskState::Refining);
assert_eq!(session.task_id, id);
assert!(session.ended_at.is_none());
assert!(
s.events_for(id)
.unwrap()
.iter()
.any(|e| e.detail.as_deref() == Some("ready -> refining")),
"the transition is logged"
);
}
}

/// However the round ends, and whichever state it started from, it
/// lands on `proposed`: the verdict a `ready` task already carried was
/// issued against a body that no longer exists (DESIGN.md §6).
#[test]
fn every_round_concludes_to_proposed_whatever_it_started_from() {
for from in [TaskState::Proposed, TaskState::Ready] {
for outcome in [
RefineOutcome::Applied,
RefineOutcome::Failed,
RefineOutcome::Cancelled,
] {
let (mut s, p) = store_with_project();
let id = create(&mut s, p, from);
s.record_refine_launch(id, "thin body", "claude", Some(1), None)
.unwrap();

let task = s.conclude_refine(id, outcome).unwrap();
assert_eq!(task.state, TaskState::Proposed, "{from} + {outcome:?}");
}
}
}

/// `parked` is deliberately outside the widening, and so is every state
/// past triage — a refine rewrites a brief, and by `running` the brief
/// is already being worked.
#[test]
fn refine_is_refused_everywhere_but_proposed_and_ready() {
for state in TaskState::ALL {
if matches!(state, TaskState::Proposed | TaskState::Ready) {
continue;
}
let (mut s, p) = store_with_project();
let id = task_in_state(&mut s, p, state);
let result = s.apply(id, Action::Refine("thin body".into()));
assert!(
matches!(result, Err(Error::InvalidTransition { .. })),
"refine from {state} should be refused"
);
}
}

/// The dispatch race is closed by the state rather than by a guard
/// (DESIGN.md §6): a `ready` task under refinement is not in the
/// scheduler's input at all, so no window can hand it out while its
/// body is being rewritten.
#[test]
fn a_refining_task_leaves_the_ready_work_queue() {
let (mut s, p) = store_with_project();
s.set_weight(p, 3).unwrap();
let id = create(&mut s, p, TaskState::Ready);
assert!(crate::scheduler::focus(&s.candidates().unwrap()).is_some());

s.record_refine_launch(id, "thin body", "claude", Some(1), None)
.unwrap();
let candidates = s.candidates().unwrap();
assert!(
!candidates.iter().any(|c| c.task.id == id),
"a refining task is not a scheduler candidate"
);
assert!(crate::scheduler::focus(&candidates).is_none());
}
}

// --- human-only tasks (DESIGN.md §3/§6): the shortened path ---

mod human {
Expand Down
63 changes: 42 additions & 21 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1409,20 +1409,22 @@ impl App {
}
}

/// Whether a task is still awaiting triage — what gates the refine keys,
/// on the queue and in the triage menu alike.
pub fn is_proposed(&self, task_id: i64) -> bool {
self.all
.iter()
.any(|r| r.task.id == task_id && r.task.state == TaskState::Proposed)
}

/// Refine the selected proposal (DESIGN.md §6). Refine is an event on a
/// proposal rather than a verdict on one, so it answers from the queue —
/// where the operator reads the body and notices it is sub-standard — and
/// not only from behind the triage menu, which collects verdicts. A
/// selection that is not a proposal reports why via the status line, the
/// same no-op-with-explanation style as the other action keys.
/// Whether a task's body is still a brief rather than work under way — what
/// gates the refine keys. A `ready` task qualifies as much as a `proposed`
/// one: its verdict was issued against the body, so rewriting the body sends
/// it back through triage (DESIGN.md §6).
pub fn is_refinable(&self, task_id: i64) -> bool {
self.all.iter().any(|r| {
r.task.id == task_id && matches!(r.task.state, TaskState::Proposed | TaskState::Ready)
})
}

/// Refine the selected task (DESIGN.md §6). Refine is an event on a brief
/// rather than a verdict on one, so it answers from the queue — where the
/// operator reads the body and notices it is sub-standard — and not only
/// from behind the triage menu, which collects verdicts. A selection whose
/// body is no longer a brief awaiting work reports why via the status line,
/// the same no-op-with-explanation style as the other action keys.
fn refine_selected(&mut self, flow: RefineFlow) {
let Some(task) = self.selected_task() else {
return;
Expand All @@ -1434,9 +1436,9 @@ impl App {
));
return;
}
if state != TaskState::Proposed {
if !matches!(state, TaskState::Proposed | TaskState::Ready) {
self.status = Some(format!(
"task is {state} — refine works on a proposal awaiting triage"
"task is {state} — refine works on a proposal or a ready task"
));
return;
}
Expand All @@ -1454,8 +1456,8 @@ impl App {

/// Note-driven refine (DESIGN.md §6): hand the body, the note, and the
/// discovered-from context to a headless agent that rewrites the body in
/// place. No transition — the task stays `proposed` and comes back round
/// for a verdict on the improved version.
/// place. The task leaves the queue for `refining` while the round runs and
/// comes back `proposed` for a verdict on the improved version.
fn refine_with_note(&mut self, task_id: i64, note: &str) {
match crate::dispatch::refine(&mut self.store, &self.dispatch_ctx, task_id, note) {
Ok(summary) => {
Expand Down Expand Up @@ -3151,11 +3153,30 @@ mod tests {
assert_eq!(app.store.task(task_id).unwrap().state, TaskState::Proposed);
}

/// On anything but a proposal the queue's refine keys are a no-op that says
/// why, the same style as the other action keys — not a silent swallow.
/// A task triaged `ready` against a body the operator has since soured on
/// refines from the queue exactly as a proposal does (DESIGN.md §6).
#[test]
fn the_queue_refine_keys_explain_themselves_on_a_non_proposal() {
fn refine_key_answers_on_a_ready_task_too() {
let mut app = app_with(&[TaskState::Ready]);
let task_id = app.selected_task_id().expect("the ready row is selected");

key(&mut app, KeyCode::Char('r'));
match &app.mode {
Mode::Prompt {
task_id: id,
kind: PromptKind::RefineNote,
..
} => assert_eq!(*id, task_id),
_ => panic!("r on a queued ready task should open the refine-note prompt"),
}
}

/// Past `ready` the body is a brief already being worked, so the queue's
/// refine keys are a no-op that says why, the same style as the other action
/// keys — not a silent swallow.
#[test]
fn the_queue_refine_keys_explain_themselves_on_work_under_way() {
let mut app = app_with(&[TaskState::Review]);

key(&mut app, KeyCode::Char('r'));
assert!(matches!(app.mode, Mode::Normal));
Expand Down
34 changes: 19 additions & 15 deletions crates/voro/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,16 +203,19 @@ transitions
the three verdicts move the proposal; refine
is not a verdict — it dispatches an agent to
rewrite the body against --note TEXT (or
--note-file PATH), moving the task proposed →
refining until the round concludes. It returns
to proposed marked ↻ refined for a re-triage
of the improved version, or ⚠ refine failed if
the agent died having written nothing. A
verdict on a refining task is refused: it is
out of the queue while the rewrite is in
flight. The note-less interactive variant is a
conversation with the agent, so it lives in
the TUI, on `R` over a proposal
--note-file PATH), moving the task proposed or
ready → refining until the round concludes. It
returns to proposed marked ↻ refined for a
re-triage of the improved version, or ⚠ refine
failed if the agent died having written
nothing. A task refined out of ready comes back
through triage because the verdict it carried
was issued against the replaced body. A verdict
on a refining task is refused: it is out of the
queue while the rewrite is in flight. The
note-less interactive variant is a conversation
with the agent, so it lives in the TUI, on `R`
over the row
start <task-id> ready → running
ask <task-id> --question TEXT running → needs-input
resume <task-id> needs-input → running, once you have answered
Expand Down Expand Up @@ -2202,11 +2205,12 @@ fn ask_verb(store: &mut Store, args: AskArgs) -> Result<String, String> {

/// Triage a proposal (DESIGN.md §6). Three of the four outcomes are verdicts
/// that transition the task; `refine` is the fourth — it dispatches an agent to
/// rewrite the body against the operator's note and leaves the task `proposed`,
/// so the improved version comes back round for a real verdict. A note is
/// required here: the note-less interactive variant is an agent conversation,
/// which is TUI-only for the same reason planning sessions are (§8) — the CLI is
/// how an LLM drives Voro.
/// rewrite the body against the operator's note, so the improved version comes
/// back round for a real verdict. The verdicts act on a proposal alone; refine
/// also accepts a `ready` id, whose rewritten body then returns through triage.
/// A note is required here: the note-less interactive variant is an agent
/// conversation, which is TUI-only for the same reason planning sessions are
/// (§8) — the CLI is how an LLM drives Voro.
fn triage_verb(store: &mut Store, args: TriageArgs, ctx: &DispatchCtx) -> Result<String, String> {
let note = text_or_file(args.note, args.note_file)?;
match Triage::try_from(args.target) {
Expand Down
Loading
Loading