Fork tasks from snapshots; snapshot only when text follows tool calls

This commit is contained in:
Ian Gulliver
2026-10-15 11:36:51 -07:00
parent 3fe0374ac5
commit 0000db1f86
25 changed files with 893 additions and 421 deletions
+13 -7
View File
@@ -179,22 +179,28 @@ The view shows a breadcrumb from `worktree` down to the current directory or fil
## 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.
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 event the snapshot belongs to: for `message`, the task's latest event when the triggering text was recorded; for `turn_end`, the latest when the snapshot started, 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.
- `message`: the agent writes text (a top-level `agent.message`) after one or more tool calls since its last text or the start of the turn (`toolsSinceText` on the live session, set by a top-level tool call and cleared by the snapshot and by each new turn). Subagent activity doesn't count. The snapshot runs in the background, so a tool call the agent starts in the meantime can land in its tree.
- `turn_end`: after byre's end-of-turn commit, which catches a turn that ends on tool calls with no text after them.
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.
Snapshots taken by earlier builds also carry `tool_call`, `ask_human` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so the end-of-turn snapshot after a final message usually is. 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. 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.
`GET /api/tasks/{taskId}/changes?snapshot=<n>` reports a snapshot instead of the live worktree: its `tree` is the snapshot's tree, and `base` is the merge-base of the snapshot's commit and the default branch, or with `step=true` the previous snapshot's tree (the snapshot commit's parent for snapshot 1). It reads the stored shas, so it works only while the task is active and its refs keep the objects; `since` with `snapshot` is a 400. `GET /api/tasks/{taskId}/diff` takes that `base` and `tree` as usual.
The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `web/src/state.ts`). Each snapshot is marked `#n` at the right of the transcript row it belongs to: for `tool_call`, `timer` and `ask_human`, the last tool call, `ask_human` note or turn end whose `seq` is at most the snapshot's `event_seq` (the PostToolUse hook fires before the tool's result is recorded, so that is the call that made it); for `turn_end`, that turn's end line, which may be the turn after `event_seq` since the snapshot and `turn.ended` race. A row with several snapshots shows `#a–#b`, and a collapsed group of tool calls shows the range of all its calls' snapshots. Clicking a mark pins the latest snapshot it covers (`state.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
The transcript is the timeline. The UI learns a task's snapshots from the `snapshot.created` events in its transcript (`snapshots` in `web/src/state.ts`). A snapshot is a point between the agent's events, not part of one, so each gets its own row in the transcript, a divider reading `snapshot #n` (its trigger and time in the tooltip), placed where its `snapshot.created` event falls in the task's event order: after the text that triggered it for `message`, next to the turn end for `turn_end`. A snapshot row ends the current group of tool calls, so calls after it start a new group. The row's **changes** button pins the snapshot (`state.pinned`, cleared when the selected task changes) and switches the right pane to the Changes tab; its **fork** button forks it (see "Forking") and points the right pane at the new task. Pinned, the Changes header reads `snapshot <n> · <trigger> · <time>` with ← and → to step to the neighbouring snapshots, and the range buttons are **Since main** and **This step** (`step=true`, the default when pinning) plus **Live**, which unpins. A pinned view doesn't refresh on tool results, since a snapshot never changes.
## Forking
`POST /api/tasks/{taskId}/snapshots/{n}/fork` (the **fork** button on a snapshot's transcript row) starts a new task from a snapshot (`internal/agents/fork.go`). Its branch and worktree start from the snapshot's commit, so the fork's worktree holds exactly the snapshot's tree, uncommitted work of the time included, as the branch's first commit. The task is titled `<title> (fork of #n)`, keeps the source's `merge_title` and `todo_item`, and records `forked_from` (the source task) and `forked_at` (n) in the `tasks` columns of those names. It gets an idle session, created by the requesting user (who needs a git identity), on the snapshot session's model, with `forked_from` set to that session's `vendor_session_id` and `forked_at` to the cut point below. Nothing runs until the first message is sent to it; that turn starts Claude Code with `--resume=<forked_from> --resume-session-at=<forked_at> --fork-session --session-id=<new id>`, so the new conversation is the source's up to the cut, under its own id, and later starts resume the new id as usual. Refused (409) while the source task is closed (its snapshot refs are gone) and when there is no cut point; 404 for an unknown snapshot.
The cut point comes from `vendor_uuid`: every top-level `agent.message`, `agent.thinking`, `agent.tool_call` and `agent.tool_result` event (not a subagent's) carries the `uuid` of the Claude Code stream-json line it came from, which is the id of that entry in the CLI's own session transcript. `forkPoint` takes the last such event of the snapshot's session at or before the snapshot's `event_seq`, then extends the cut through the following tool calls and results until every tool call it has kept has its result, so the fork never resumes on an unanswered tool call. For a `message` snapshot the cut is normally the triggering text itself, so the fork carries on after it. Snapshots whose session recorded no `vendor_uuid` by then, such as those from before byre recorded it, can't be forked.
The fork's session guidance gets one more line (`forkGuidance` in `internal/agents/guidance.go`) naming the source task, the snapshot, the fork's branch and worktree and the source's worktree, telling the agent that everything before the fork happened in the other worktree and to work only in its own. Claude Code can reuse the system prompt recorded for the source conversation on a fork, the source's cwd included, so the `UserPromptSubmit` hook's copy is what reliably tells it. The fork's transcript starts with a note from its `task.created` event, "forked this task from <source> at snapshot #n", linking the source while it is active; the lead's signal for it reads "<task> was forked by <user> from <source> at snapshot n", and `list_tasks` notes the source. The fork's transcript shows only its own events, not the source's history.
## Events