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
57 changes: 55 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,12 @@ never stops the loop.
## Persistence and durable execution

One database: `~/.bullpen/bullpen.db`, WAL mode, `busy_timeout` set, schema
versioned by `pragma user_version`. Session ids resolve by unique prefix.
`BULLPEN_HOME` overrides the directory (see README, "Where state lives").
versioned by `pragma user_version` (v6). Session ids resolve by unique
prefix. `BULLPEN_HOME` overrides the directory (see README, "Where state
lives"). v6 added `sessions.worktree_path` / `worktree_branch`, both NULL
for a session that shares the caller's checkout; a session's `cwd` stays the
directory it was dispatched from, which is what still points at the
repository when the worktree itself is gone.

The durability rule, the reduction idea, and the recovery discipline below
are adapted from pi's `harness-v2.md` design spec — see
Expand Down Expand Up @@ -265,6 +269,10 @@ Milestones, in order. Each lands as its own crate or a bounded extension:
derived state (Working = running + live pid, Failed = running + dead pid
i.e. crashed, Completed, Idle), dispatches from an input line, and peeks a
session's latest output. `bullpen logs <id>` tails captured output.
`--bg --worktree` (opt-in) additionally gives a session its own git
worktree on a run-unique branch, so concurrent background sessions stop
editing each other's files; the location is recorded on the session row
and wins over the caller's cwd on resume (see "Worktree retention" below).
Stage 2 (committed): interactive attach to and detach from a *live*
process, leaving it running (needs a per-session control socket),
needs-input state (needs the approvals feature), notifications. Stage 2+
Expand Down Expand Up @@ -336,3 +344,48 @@ that is still the default. Linux has no out-of-process confinement yet
(Landlock is the intended mechanism; the in-process write check already
works there). Do not point v0 at anything you wouldn't hand to a
contractor's laptop.

### Worktree retention (fail-closed)

`--worktree` isolates a background session in its own git worktree. Nothing
in bullpen removes that worktree or its branch — not when the run completes,
not when it fails, not on any later invocation. The asymmetry is the whole
argument: a leftover directory costs disk, while an eager cleanup can
destroy the only copy of what an agent did — an uncommitted diff, an
interrupted rebase, a file it wrote but never mentioned. Uncertainty
retains. Even the resume path that restores a deleted worktree uses
`git worktree add --force` rather than pruning the stale entry, because
pruning is a removal.

That constrains any future `bullpen prune`: it has to earn deletion from
proof that the work was published — the branch is merged or pushed, the
worktree is clean — rather than inherit an optimistic rule like age or
session status. A `completed` session is not evidence its diff was kept.

The resume rule follows the same posture. The recorded location beats the
directory the command was typed in; a missing directory whose branch
survives is recreated from that branch with one stderr notice; a missing
directory *and* missing branch is an error naming both, never a silent run
somewhere else. A directory that exists but is not that worktree — an
ordinary one restored at the path, a worktree of another repository — is a
third error rather than a fourth place to run, so the recorded path is
checked against the session's repository, not merely stat'ed.
`--worktree` outside a git repository is likewise an error, because falling
back to the shared checkout would silently reintroduce exactly the
interference the flag exists to remove. For the same reason the path and
branch are written to the session row *before* `git worktree add` runs: a
row pointing at a directory that failed to appear is refused on resume,
while a row pointing at nothing would read as a plain shared-cwd session.

`--sandbox` has to be widened to compose with this. A linked worktree's
`.git` is a file; its index, refs and objects all live under the *main*
repository, outside the worktree, so a sandbox confined to the worktree
alone leaves an agent able to edit files and unable to stage or commit
them — which under the rule above is fatal, since a commit is the only
evidence that would ever justify reclaiming its directory. The run therefore
adds the worktree's git dirs to the write roots. That is the whole shared
common directory, because git's ref store cannot be sliced by path — `git
gc` prunes `refs/heads/bullpen/` and moves the refs into a `packed-refs`
file at the top of it — so a sandboxed agent in a worktree can also write
the main repository's config and hooks. Confining that needs a mechanism
other than a path allowlist.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ bullpen run -r 6ee4acc9 "now write the fix"

```bash
bullpen run --bg "audit the auth module" # detached, returns immediately
bullpen run --bg --worktree "refactor it" # …in its own git worktree
bullpen agents # the dashboard in the GIF above
bullpen logs 6ee4acc9 # tail a background session
```
Expand All @@ -92,6 +93,16 @@ bullpen logs 6ee4acc9 # tail a background session
lets you dispatch from the input line, `Space` to peek at output, `Esc` to
quit. Quitting stops nothing.

Plain `--bg` sessions share your checkout, so two of them edit the same
files. `--worktree` gives a session a git worktree of its own on a
run-unique `bullpen/<id>` branch, under `$BULLPEN_HOME/worktrees/<session>`;
the path shows up in `bullpen sessions`, in `bullpen sessions --json`, and
in the peek panel, and `bullpen run -r <id>` returns to it from anywhere. Outside a
git repository the flag fails rather than quietly sharing the checkout.
**Nothing removes a worktree or its branch** — not on success, not on
failure, not later. A worktree can hold the only copy of what an agent did,
so cleaning up is yours to decide.

## The pen

The model can delegate bounded work to child agents through the `agent`
Expand Down Expand Up @@ -130,8 +141,9 @@ can't be opened read-only without their shared-memory file:
sqlite3 "file:${BULLPEN_HOME:-$HOME/.bullpen}/bullpen.db?immutable=1" "select id, status from sessions"
```

Set `BULLPEN_HOME` to move the whole directory — database, `auth.json`, and
background logs land directly in it, with no `.bullpen` segment appended.
Set `BULLPEN_HOME` to move the whole directory — database, `auth.json`,
background logs, and `--worktree` checkouts (`worktrees/<session-id>`) land
directly in it, with no `.bullpen` segment appended.

## Status

Expand Down
3 changes: 3 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ crossterm.workspace = true
libc.workspace = true
tokio.workspace = true
tracing-subscriber.workspace = true

[dev-dependencies]
tempfile = "3"
81 changes: 59 additions & 22 deletions crates/cli/src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,34 +302,19 @@ fn draw_peek(f: &mut Frame, app: &App) {
let area = centered(70, 60, f.area());
f.render_widget(Clear, area);

let mut lines = vec![
Line::from(Span::styled(
format!("{} ({})", &session.id[..8], session.provider),
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
];
// Latest assistant text from the durable transcript.
match Store::open(&Store::default_path()).and_then(|s| s.path_messages(&session.id)) {
Ok(messages) => {
let latest = messages
let latest =
match Store::open(&Store::default_path()).and_then(|s| s.path_messages(&session.id)) {
Ok(messages) => messages
.iter()
.rev()
.find(|m| m.role == bullpen_llm::Role::Assistant)
.map(|m| m.text())
.filter(|t| !t.is_empty())
.unwrap_or_else(|| "(no output yet)".into());
for line in latest.lines() {
lines.push(Line::from(line.to_string()));
}
}
Err(e) => lines.push(Line::from(format!("(could not read transcript: {e})"))),
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("continue with: bullpen run -r {} \"…\"", &session.id[..8]),
Style::default().fg(Color::DarkGray),
)));
.unwrap_or_else(|| "(no output yet)".into()),
Err(e) => format!("(could not read transcript: {e})"),
};
let lines = peek_lines(session, &latest);

let block = Block::default()
.borders(Borders::ALL)
Expand All @@ -342,6 +327,34 @@ fn draw_peek(f: &mut Frame, app: &App) {
);
}

/// The peek panel's contents. Pure — the transcript read happens in the
/// caller — so what the panel says about a session can be tested without a
/// terminal or a store.
fn peek_lines(session: &Session, latest: &str) -> Vec<Line<'static>> {
let mut lines = vec![Line::from(Span::styled(
format!("{} ({})", &session.id[..8], session.provider),
Style::default().add_modifier(Modifier::BOLD),
))];
// An isolated session's output is not in the directory the dashboard was
// started from, so the panel has to say where it is.
if let Some(path) = &session.worktree_path {
lines.push(Line::from(Span::styled(
format!("worktree {path}"),
Style::default().fg(Color::DarkGray),
)));
}
lines.push(Line::from(""));
for line in latest.lines() {
lines.push(Line::from(line.to_string()));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!("continue with: bullpen run -r {} \"…\"", &session.id[..8]),
Style::default().fg(Color::DarkGray),
)));
lines
}

fn status_color(status: AgentStatus) -> Color {
match status {
AgentStatus::Working => Color::Cyan,
Expand Down Expand Up @@ -389,11 +402,20 @@ mod tests {
parent_session_id: None,
status: "idle".into(),
pid: None,
worktree_path: None,
worktree_branch: None,
},
status,
}
}

fn rendered(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}

#[test]
fn arrange_groups_working_first_then_newest() {
let rows = vec![
Expand All @@ -414,4 +436,19 @@ mod tests {
]
);
}

#[test]
fn peek_lines_shows_the_worktree_for_an_isolated_session() {
let mut r = row("0123456789ab", AgentStatus::Working, "2026-08-07 10:00");
r.session.worktree_path = Some("/h/.bullpen/worktrees/0123456789ab".into());
let lines = rendered(&peek_lines(&r.session, "output"));
assert_eq!(lines[1], "worktree /h/.bullpen/worktrees/0123456789ab");
}

#[test]
fn peek_lines_says_nothing_about_worktrees_for_a_shared_cwd_session() {
let r = row("0123456789ab", AgentStatus::Working, "2026-08-07 10:00");
let lines = rendered(&peek_lines(&r.session, "output"));
assert!(!lines.iter().any(|l| l.contains("worktree")), "{lines:?}");
}
}
Loading