Snapshot task worktrees to hidden refs as agents work

This commit is contained in:
Ian Gulliver
2026-10-14 22:52:02 -07:00
parent c3ed243573
commit 9039860594
22 changed files with 926 additions and 202 deletions
+18 -3
View File
@@ -6,7 +6,7 @@
- `api/openapi.yaml` is the API source of truth. `go generate ./internal/api` regenerates `internal/api/api.gen.go`, whose types are also the store's model types.
- `internal/store`: SQLite (WAL). Every mutation and its event commit in one transaction; events are published to SSE subscribers in `seq` order.
- `internal/server`: HTTP handlers, identity, SSE.
- `internal/git`: worktrees, the task changes view (see "Changes"), unmerged-change summaries, squash merges and pushes.
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges and pushes.
- `internal/agents`: the agent session manager. It owns the running Claude processes and records their activity as events.
- `internal/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
- `internal/push`: web push notifications for new inbox items (see "Notifications").
@@ -20,7 +20,7 @@ Schema changes are the `migrations` slice in `internal/store/store.go`: named en
## Closing a task
A task ends `merged` or `discarded`; either way its sessions are stopped, its worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is `running` or `queued`.
A task ends `merged` or `discarded`; either way its sessions are stopped, its snapshot refs, worktree and branch are removed, its `open` inbox items become `dismissed` (resolved by whoever closed it), and its record and events remain. `GET /api/tasks/{id}/unmerged` reports what would be lost or merged: commits on the branch that aren't on the default branch, and files, lines and uncommitted paths that differ from the merge base. The UI's Merge and Discard buttons each ask a one-line confirmation and are disabled while the task's session is `running` or `queued`.
`POST /api/tasks/{id}/merge-in` (the transcript's **Sync from main** button, labelled with the default branch) brings the default branch into the task branch through the agent: it sends `mergeInPrompt` (`internal/agents/mergein.go`) as a `byre` message to the task's session, or starts a session with it when the task has none. The prompt has the agent run `git merge --no-ff --no-commit`, resolve conflicts, regenerate generated files rather than hand-merging them, check the build and tests, and leave the merge uncommitted; byre's end-of-turn commit concludes it as a merge commit. The prompt ends by showing the task's current merge title and asking the agent to set a better one with `set_merge_title` if it no longer describes all the work on the branch. It is refused (409) while a session is `running` or `queued`.
@@ -133,10 +133,25 @@ Answers reach the agent as one message, listing each answer under the question o
## Changes
`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each snapshot writes the changed blobs and a tree into the repo's object store as loose objects.
`GET /api/tasks/{taskId}/changes` snapshots the task's worktree as if everything were staged: it copies the worktree's index to a temporary file, runs `git add --all` against that copy (`GIT_INDEX_FILE`), and `git write-tree`s it, so committed, staged, unstaged and untracked work is included, `.gitignore` is respected, and the worktree's real index is never touched. Starting from the real index rather than `read-tree HEAD` keeps git's stat cache, so unchanged files aren't rehashed. The tree is compared with `git merge-base HEAD <default branch>`, or with `since` when given, using rename detection, giving per-file status and line counts. Work already merged into the default branch therefore drops out. The Changes view toggles between **Since main** and **This turn**, which passes the latest `user.message` event's `head` as `since`; the summary above the inbox always shows since main. `GET /api/tasks/{taskId}/diff` returns one file's patch between a `base` and `tree` from that response. Each request writes the changed blobs and a tree into the repo's object store as loose objects.
The Changes view lists the files with their counts, opens a file's diff on click (open files stay open across refreshes), and refreshes when it opens, when the selected task changes, and 1.5s after the selected task's last tool result or turn end. The Inbox view shows just its summary line (file count, +/− totals, base) for the selected task at the top.
## Snapshots
byre snapshots each task's worktree as it changes (`internal/agents/snapshots.go`, `internal/git/snapshot.go`). A snapshot builds the tree the same way as the Changes view (a copy of the real index, `git add --all`, `git write-tree`), commits it with `git commit-tree` whose parent is the worktree's `HEAD`, authored as the session's creator with the message `byre snapshot <n> (<trigger>)`, and points `refs/byre/tasks/<task-id>/snap/<n>` at it (`git update-ref` with an empty old value, so an existing ref is never overwritten). A row in the `snapshots` table records id, task, session, `n` (from 1 per task), commit and tree shas, `event_seq` (the task's latest event seq read before the tree was built, so the tree holds at least the work up to that event), the trigger and the time, and the same transaction records a `snapshot.created` event carrying the row.
Triggers (`trigger`):
- `tool_call`: a `PostToolUse` hook on every tool, shell commands included. The hook returns at once; the snapshot runs in the background.
- `turn_end`: after byre's end-of-turn commit.
- `ask_human`: when a question is filed.
- `timer`: every `snapshotInterval` (`internal/agents/snapshots.go`) while the session is `running`, as a backstop for long commands.
A snapshot whose tree equals the task's latest snapshot's tree is skipped. Snapshots run one at a time per task: a trigger that arrives while one is running replaces any already waiting one, and the waiting one runs when the current one finishes, so a burst of tool calls costs at most two. Failures are logged and dropped.
Merging or discarding a task stops new snapshots for it, waits for one in progress, and deletes its `refs/byre/tasks/<task-id>/` refs; the rows stay. `GET /api/tasks/{taskId}/snapshots` lists a task's rows, oldest first. `git for-each-ref refs/byre/` in the project's repo lists the live refs. The UI doesn't show snapshots yet.
## Events
`GET /api/events` is server-sent events: `id` is the event's `seq`, `data` the Event JSON. Reconnecting with `Last-Event-ID` (EventSource does this itself) or `?after=` replays everything after that seq before going live. Unstored events (`seq` 0, currently only `agent.delta`) go out live only, without an `id` line, so they don't move `Last-Event-ID`. A client that falls further behind than its subscriber buffer (`subscribe` in `internal/server/hub.go`) is disconnected and catches up by reconnecting. Every 15s the server sends an `event: ping` so clients can tell a live stream from a dead one. The web UI (`subscribe` in `web/src/api.ts`) tracks the last seq it saw and opens a new stream from it when nothing (event or ping) has arrived for 40s, 2s after the browser gives up on one (as it does when `tailscale serve` answers 502 while byre restarts), when the browser comes back online, and on becoming visible if the stream is stale. While disconnected the navbar shows a red dot and "reconnecting…"; partial output (`agent.delta`) sent while disconnected is lost, and the finished `agent.message` replaces it.
+2 -2
View File
@@ -480,8 +480,8 @@ Record transcripts from the spike as the seed of the contract test suite.
- **Done:** sessions, streaming, messaging, interrupt; `ask_human` with validation; each turn's final message filed to the inbox as its summary; inbox with global, project, and task views and every response type, answered as one batch and delivered to the agent as one message; restart-and-resume; global and per-vendor caps with queueing; rate limits as their own inbox state; the web UI (two panes, task switcher, transcript, inbox) with Markdown rendering; the session instructions from "Session configuration", given as an appended system prompt and a per-turn reminder; per-turn commits by byre; bouncing questions asked in plain prose; web push notifications through a service worker, with per-user preferences (blocking questions by default, other kinds opt-in) set in a Settings tab, bursts collapsed into one notification, and a click opening the task. Compaction is done, as a handoff into a fresh session; model switching is done, per message; image input is done, pasted or dropped into the composer and stored as blobs.
- **Later:** subagent views and Mermaid rendering.
- **M2 — Seeing the work.** Temp-index diffs and stats, snapshots, timeline scrubbing, terminals, file browser, previews.
- **Done:** temp-index diffs and stats, with a Changes view.
- **Not done:** snapshots, timeline scrubbing, terminals, file browser, previews.
- **Done:** temp-index diffs and stats, with a Changes view; snapshots on every tool call, turn end, `ask_human` and a timer while a turn runs, kept as hidden refs until the task is merged or discarded, listed through the API.
- **Not done:** timeline scrubbing, terminals, file browser, previews.
- **M3 — Second vendor and review.** Codex adapter with the same interface coverage, review profiles, review triggers, visual review output, cross-vendor review.
- **M4 — Merging and history.** Full merge cycle with squash, authorship and co-author trailers, drafted commit messages, merge-resolution stage, git notes, task completion and archive, archive search, GitHub Issues integration.
- **Done:** Merge and Discard replace "Complete task". Merge squashes the worktree, uncommitted files included, into one commit on the default branch, authored by the merging user, pushes it to the project's `origin`, and then fast-forwards the project checkout; it refuses while a turn is running. Merge first has the agent merge the default branch in (also available alone as "Sync from main"), then squashes when that turn ends. Merge fetches the remote's default branch into the checkout before the merge-in; a refused or failed push is filed as an error and the task stays active for another Merge. The squash commit's title is the task's merge title: the task title until the agent sets another with the `set_merge_title` MCP tool, which the merge-in prompt asks it to do when the current title no longer describes the branch.