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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"allow": ["EnterWorktree"]
}
}
81 changes: 63 additions & 18 deletions crates/voro/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,29 +82,42 @@ const BRANCH_REBASE_SENTENCE: &str = "If the branch conflicts with the project's
inside the worktree and rebase or merge onto `origin/<base>`, resolving the \
conflicts there — never modify the primary checkout, and never push.";

/// Shared by both branch blocks so the isolation instruction cannot drift. An
/// agent whose harness owns worktree creation must use that mechanism rather
/// than `git worktree add`: Claude Code refuses file edits until its own
/// `EnterWorktree` tool has run, and pointing that tool at an already-made
/// worktree raises an approval prompt no headless session can answer. Since the
/// harness names that branch itself, the sentence ends with the rename that puts
/// the work on the branch Voro tracks. `{name}` is the assigned branch or the
/// `<name>` the agent will choose.
const BRANCH_ISOLATE_SENTENCE: &str = "Isolate your work in a worktree, through your harness's own mechanism for that \
where it has one — in Claude Code that is the `EnterWorktree` tool, which \
creates and enters a worktree under `.claude/worktrees/` unprompted, on a \
branch it names itself, so run `git switch -c {name}` inside the worktree \
before you commit. Lacking such a mechanism, make the throwaway worktree \
yourself: `git worktree add <path> -b {name}`.";

/// The `{branch}` block for a task that carries an intended git branch (task
/// #81): the agent is told the name, to do its work in a throwaway worktree on
/// it (never the primary checkout), to register it early via
/// [`BRANCH_REGISTER_SENTENCE`], to confirm it at completion, and to self-serve
/// a rebase onto a moved base via [`BRANCH_REBASE_SENTENCE`].
/// it rather than the primary checkout via [`BRANCH_ISOLATE_SENTENCE`], to
/// register it early via [`BRANCH_REGISTER_SENTENCE`], to confirm it at
/// completion, and to self-serve a rebase onto a moved base via
/// [`BRANCH_REBASE_SENTENCE`].
const ASSIGNED_BRANCH_TEMPLATE: &str = "\n\n\
This task is assigned the git branch `{name}`. You are spawned in the project
checkout — never modify it. Create a throwaway git worktree of the checkout on
this branch (e.g. `git worktree add <path> -b {name}`) and do all your work
inside that worktree — Voro runs no git, so the branch and its worktree are yours
to make — and {register}. Confirm the branch your work landed on with
`voro done {task_id}{db} --branch {name}`. {rebase}";

/// The `{branch}` block when no branch is assigned: the agent picks its own name
/// and must still register it early via [`BRANCH_REGISTER_SENTENCE`], so Voro
/// learns the branch while the task runs rather than only at `done`, and
/// self-serve a rebase onto a moved base via [`BRANCH_REBASE_SENTENCE`].
checkout — never modify it. {isolate} Voro runs no git, so the branch and its
worktree are yours to make; {register}. Confirm the branch your work landed on
with `voro done {task_id}{db} --branch {name}`. {rebase}";

/// The `{branch}` block when no branch is assigned: the agent picks its own
/// name, isolates the same way via [`BRANCH_ISOLATE_SENTENCE`], and must still
/// register the name early via [`BRANCH_REGISTER_SENTENCE`], so Voro learns the
/// branch while the task runs rather than only at `done`, and self-serve a
/// rebase onto a moved base via [`BRANCH_REBASE_SENTENCE`].
const UNASSIGNED_BRANCH_TEMPLATE: &str = "\n\n\
Pick a git branch for this work. You are spawned in the project checkout — never
modify it. Create a throwaway git worktree of the checkout on that branch (e.g.
`git worktree add <path> -b <branch>`) and do all your work inside that worktree
— Voro runs no git, so the branch and its worktree are yours to make — and
{register}; `<name>` is the name you choose. {rebase}";
modify it. {isolate} Voro runs no git, so the branch and its worktree are yours
to make; {register}; `<name>` is the name you choose. {rebase}";

/// Rendered per planning session (DESIGN.md §8) and written as the whole
/// prompt — unlike dispatch there is no task body to prepend it to, because
Expand Down Expand Up @@ -232,13 +245,17 @@ fn render_preamble(
docs: &[(String, String)],
) -> String {
let db_flag = db_flag(db_path);
let register = BRANCH_REGISTER_SENTENCE.replace("{name}", branch.unwrap_or("<name>"));
let name = branch.unwrap_or("<name>");
let register = BRANCH_REGISTER_SENTENCE.replace("{name}", name);
let isolate = BRANCH_ISOLATE_SENTENCE.replace("{name}", name);
let branch_block = match branch {
Some(name) => ASSIGNED_BRANCH_TEMPLATE
.replace("{isolate}", &isolate)
.replace("{register}", &register)
.replace("{rebase}", BRANCH_REBASE_SENTENCE)
.replace("{name}", name),
None => UNASSIGNED_BRANCH_TEMPLATE
.replace("{isolate}", &isolate)
.replace("{register}", &register)
.replace("{rebase}", BRANCH_REBASE_SENTENCE),
};
Expand Down Expand Up @@ -1332,6 +1349,34 @@ mod tests {
);
}

#[test]
fn preamble_puts_the_harness_worktree_tool_ahead_of_git_worktree_add() {
// A harness that owns worktree creation must be used on its own terms:
// Claude Code blocks edits until `EnterWorktree` has run, and aiming it
// at a hand-made worktree asks for an approval a headless session
// cannot give. `git worktree add` survives only as the fallback, and
// because the harness names the branch itself, both cases spell out the
// rename onto the branch Voro tracks.
for (branch, name) in [(None, "<name>"), (Some("feat/parser"), "feat/parser")] {
let rendered = render_preamble(62, &Store::default_db_path(), branch, &[]);
assert!(rendered.contains("`EnterWorktree` tool"), "{rendered}");
assert!(
rendered.contains(&format!("git switch -c {name}")),
"{rendered}"
);
let enter = rendered.find("EnterWorktree").unwrap();
let manual = rendered.find("git worktree add").unwrap();
assert!(
enter < manual,
"the harness mechanism must lead, the manual worktree follow: {rendered}"
);
assert!(
rendered.contains(&format!("git worktree add <path> -b {name}")),
"{rendered}"
);
}
}

#[test]
fn preamble_names_a_task_s_linked_documents_at_their_resolved_locations() {
// A task linked to a plan gets it handed over rather than having to
Expand Down
2 changes: 1 addition & 1 deletion docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ Cheap actions need one further guard, or the pricing swaps one swamping for anot

**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 does its work in a throwaway git worktree of the project checkout it creates itself — the dispatch preamble instructs this, and 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.
**Worktree lifecycle.** A dispatched agent does its work in a throwaway git worktree of the project checkout it creates itself — the dispatch preamble instructs this, and Voro runs no git during dispatch (below), so the branch and its worktree are the agent's to make. *How* it makes one is the agent's own business, and the preamble prefers the harness's mechanism to a hand-rolled `git worktree add` where one exists: Claude Code refuses file edits until its `EnterWorktree` tool has run, and aiming that tool at an already-made worktree raises an approval prompt a headless session cannot answer, so an agent following the manual instruction literally hangs at the first edit. Such a tool names the branch itself, so the preamble also spells out the `git switch -c <branch>` that puts the work on the branch Voro tracks. Where that worktree lands is immaterial to everything downstream — `open` and the cleanup below find it through `git worktree list` on the checkout, which sees a worktree nested under the checkout (Claude Code puts them in `.claude/worktrees/`) exactly as it sees a sibling one. 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:

Expand Down
Loading