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
46 changes: 41 additions & 5 deletions crates/voro-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,20 @@ pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
/// and that supervisor refuses a headless `--resume` for as long as it lives,
/// so the plain resume was a send that could never land (DESIGN.md §8).
///
/// It carries `--permission-mode` for the same reason `dispatch` does. The flag
/// is per invocation rather than a property of the session, so a resumed turn
/// without it runs in the default ask mode against a stdin at `/dev/null`: every
/// edit and every command outside the allowlist stops for an approval nobody can
/// It carries `--permission-mode` for the same reason `dispatch` does: the mode
/// belongs to a launch rather than to a verb (DESIGN.md §8). The flag is per
/// invocation rather than a property of the session, so a resumed turn without
/// it runs in the default ask mode against a stdin at `/dev/null`: every edit
/// and every command outside the allowlist stops for an approval nobody can
/// give, and the refusals land in the launch log rather than the TUI. A send
/// like that appears to have been delivered and quietly does nothing, which is
/// the one failure a fire-and-forget channel cannot report.
/// the one failure a fire-and-forget channel cannot report — and since `message`
/// carries every rejection's feedback, a rework session missing the flag cannot
/// even run `voro done`, so its finished work goes unreported and reconcile
/// lands the task in `stalled`, reading as an agent that died.
///
/// `resume` deliberately carries no mode: it hands the operator a terminal and
/// no prompt, so the ask-mode default is answerable by whoever is sitting there.
///
/// The claude `logs` verb replays a background session's screen, which is the
/// only place a usage cap is legible (DESIGN.md §8): `claude agents --json`
Expand Down Expand Up @@ -2088,6 +2095,35 @@ mod tests {
assert_ne!(again.new_session_ref, Some(new_ref));
}

/// The permission mode belongs to a launch rather than to a verb: every
/// built-in `claude` template that hands an agent a prompt to act on
/// carries it, headless or not. `resume` carries none because it carries no
/// prompt either — it reopens a session for the operator and starts no work
/// of its own, so the ask-mode default is answerable by the person sitting
/// in front of it.
#[test]
fn every_builtin_claude_launch_that_prompts_carries_a_permission_mode() {
let claude = &builtin_agents()["claude"];
for (verb, template) in [
("dispatch", claude.dispatch()),
("message", claude.message().expect("claude defines message")),
("plan", claude.plan().expect("claude defines plan")),
] {
assert!(
template.contains(PROMPT_FILE_PLACEHOLDER),
"{verb} is meant to be a prompted launch: {template}"
);
assert!(
template.contains("--permission-mode auto"),
"{verb} asks an agent to act, so it cannot stop for an approval \
nobody is there to give: {template}"
);
}
let resume = claude.resume().expect("claude defines resume");
assert!(!resume.contains(PROMPT_FILE_PLACEHOLDER), "{resume}");
assert!(!resume.contains("--permission-mode"), "{resume}");
}

/// The placeholder is bound only where a send happens; on any other verb it
/// would reach the shell as literal braces, so it is refused at load.
#[test]
Expand Down
4 changes: 3 additions & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ Cheap actions need one further guard, or the pricing swaps one swamping for anot

**Where a session cannot be resumed, it is forked, and the session reference follows the fork.** The `message` template may carry a third, optional placeholder, `{new_session}`, which Voro binds to a freshly generated v4 UUID: an agent declares by using it that its sessions are joined by forking rather than resumed in place, and the built-in `claude` message verb does exactly that (`--fork-session --session-id {new_session}`), because forking is the one scriptable channel into a supervisor-held session. The fork continues the same conversation under a reference the caller names up front, which is what makes it usable here — Voro records that reference on the session row once the send is confirmed, so the next message, the next jump-in, and the reconciler all address the conversation where it actually continued. A verb without the placeholder resumes in place and keeps the reference it had, so the headless-resume agents are unaffected. One consequence reaches reconciliation: a forked `-p` turn does not appear in the agent's own session listing at all, so the listing would report the session gone while the message it was just sent is still being answered. The row's recorded pid settles it, in one direction only — a *live* pid proves the session is live whatever the listing says, since the quick message replaces that pid with the process carrying its turn, while a dead pid still proves nothing (a dispatch's pid is a launcher that exits at birth) and falls back to the listing verdict.

**A permission mode is a property of a launch, not of a verb.** `--permission-mode` is per invocation rather than something the session remembers, so every built-in template that hands an agent a prompt and expects it to *act* carries it — `dispatch`, `plan` and `message` alike — and a turn launched without it runs in the default ask mode, stopping on approvals against a stdin at `/dev/null`. The built-in `claude` `resume` is the deliberate exception rather than an oversight: it carries no prompt and starts no work of its own, handing the operator a terminal in which the ask-mode default is answerable by the person sitting in front of it. Omitting the flag on a prompted launch fails in the way that is hardest to read back. The session thinks, is refused its edits and its commands, and so cannot run `voro done` — it exits having done real work Voro never hears about, the reconciler finds the process gone, and the task lands in `stalled`. A missing flag therefore surfaces as a *dead agent*, sending the operator to liveness (which is working) rather than to the launch that could not act, which is why the property is asserted over the built-in templates by a test instead of being left to each verb's spelling.

**Session lifecycle.** A session's life follows the *task*, not the agent's process listing. An open session therefore no longer implies the task is *executing*: a refine round (§6) opens one too, in the same transaction as `proposed → refining`, and closes it on the transition back — `completed` when the rewritten body landed, `failed` when the agent died, `aborted` when the round was quit or cancelled. What a session means is "an agent Voro launched is working on this task", and which kind of work it is comes from the task's state, which is why every session-consuming query reads that state rather than the session's existence: the running strip lists `running` and `refining` (§9), reconciliation probes those two and leaves the rest alone, and dispatch's preconditions never look at sessions at all. The dispatch half of that life is unchanged: a session 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.

A session's entry in the *agent's own* registry follows its row in the same way: closing the row stops the session, so the agent's listing converges on work actually in flight. The listing is how the operator finds a session to attach to and how Voro reads liveness, and both get worse the longer it grows — a `claude --bg` session outlives its work twice over, keeping its entry in `claude agents` and a supervisor process that runs until the machine reboots, so an operator who dispatches all week reads their session list through a wall of finished ones. The stop rides an *optional* `stop` verb (below), fired at the closing session's reference, detached, its output going to the launch log: an agent that defines none — or one whose `stop` fails — degrades to exactly what Voro did before, a lingering listing entry and nothing broken. The transition never waits on it and never rolls back for it. Which closes stop is deliberately narrower than which closes happen: the operator's closing verdicts (`Accept`, `Abort`, `Abandon`) stop, and so do the reconciler's finalisations, both the dead-dispatch stall and the stale-row heal, since by then the session is over and Voro has said so. Sessions that stay open — `needs-input`, `review`, `waiting` — are never stopped; the operator still answers and rejects into them. A refine round's conclusion is the one close that does not stop, because its commonest trigger is the rewriting agent's own `voro set --body-file`, a call made from inside the session and mid-turn: stopping there would kill the agent that just reported. `voro-core` decides *whether* a close stops (`Store::apply_closing` hands back the session a verdict retired, `Store::reconcile_session` the one a pass finalised); the `voro` crate supplies the spawn, beside the other process seams.
Expand Down Expand Up @@ -327,7 +329,7 @@ Three properties keep that badge honest. It carries **no state change**: `stalle

It goes out through the existing `message` verb rather than through any new channel. The verb forks (`--fork-session` with a pre-assigned `{new_session}`), which is what makes it land at all: a supervisor-owned session refuses a plain headless `--resume` for as long as its supervisor lives, and a capped session's supervisor is alive by definition. A forked send has been confirmed accepted against a live, supervisor-held session mid-turn — a strictly harder case than a capped one, which sits idle with its turn already ended — so no `tmux send-keys` channel or supervisor IPC is needed, and none is built. The send is recorded exactly as a quick message is, with the forked reference and the pid now carrying the turn, so a nudged session stays as visible to the reconciler as a messaged one; the badge is dropped the moment the send lands, so a second press cannot put a second agent on the same worktree, and it returns on the next reading if the session is still held.

Two guards are deliberately stood down for it, and the cap reading is what earns that. A quick message is refused on a `running` task because its session is mid-turn, and refused again when the session is listed live — but a capped session is `running`, listed live, and *not* mid-turn, which is the one combination nothing else in the cockpit can recognise. Nothing else may skip those guards. The sweep fires only when pressed, and that is a staging decision rather than a principle: automatic resumption once the window reopens is wanted, and this is deliberately the half it can be built on top of. Manual first buys the evidence automation needs — that a nudge reliably lands, and that the badge it would key on does not false-positive — while a wrong reading still costs one keypress instead of an unwatched agent. What automation adds is a trigger, not a channel: the reset-passed test the badge already computes, evaluated on the tick rather than on the key, plus a bound so a session that will not restart is not nudged around the clock. A cap whose reset time never parsed is swept too — the operator pressing the key is the judgement the clock could not supply, and a send that turns out to be early is refused by the agent rather than doing harm — and that is precisely a case automation must decide differently, since no keypress would stand behind it. Because the nudged turn does real work, the `message` verb carries `--permission-mode` like `dispatch` does: the flag is per invocation rather than a property of the session, so a resumed turn without it runs in ask mode against a stdin at `/dev/null`, stopping on approvals nobody can give and landing the refusals in the launch log — a send that appears delivered and quietly does nothing, which is the one failure a fire-and-forget channel cannot report.
Two guards are deliberately stood down for it, and the cap reading is what earns that. A quick message is refused on a `running` task because its session is mid-turn, and refused again when the session is listed live — but a capped session is `running`, listed live, and *not* mid-turn, which is the one combination nothing else in the cockpit can recognise. Nothing else may skip those guards. The sweep fires only when pressed, and that is a staging decision rather than a principle: automatic resumption once the window reopens is wanted, and this is deliberately the half it can be built on top of. Manual first buys the evidence automation needs — that a nudge reliably lands, and that the badge it would key on does not false-positive — while a wrong reading still costs one keypress instead of an unwatched agent. What automation adds is a trigger, not a channel: the reset-passed test the badge already computes, evaluated on the tick rather than on the key, plus a bound so a session that will not restart is not nudged around the clock. A cap whose reset time never parsed is swept too — the operator pressing the key is the judgement the clock could not supply, and a send that turns out to be early is refused by the agent rather than doing harm — and that is precisely a case automation must decide differently, since no keypress would stand behind it. Because the nudged turn does real work, it depends on the `message` verb's permission mode (above) exactly as a dispatch does: without it the refusals land in the launch log and the send appears delivered while quietly doing nothing, which is the one failure a fire-and-forget channel cannot report.

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`.

Expand Down