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
+35
View File
@@ -288,6 +288,24 @@ paths:
"404": { $ref: "#/components/responses/Error" } "404": { $ref: "#/components/responses/Error" }
"409": { $ref: "#/components/responses/Error" } "409": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/snapshots:
parameters:
- { name: taskId, in: path, required: true, schema: { type: string } }
get:
operationId: listSnapshots
description: >
The task's snapshots, oldest first. Each is a commit of the worktree as if everything were staged, kept at
refs/byre/tasks/{taskId}/snap/{n} until the task is merged or discarded; the rows remain after that.
responses:
"200":
description: The snapshots.
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Snapshot" }
"404": { $ref: "#/components/responses/Error" }
/api/tasks/{taskId}/sessions: /api/tasks/{taskId}/sessions:
parameters: parameters:
- { name: taskId, in: path, required: true, schema: { type: string } } - { name: taskId, in: path, required: true, schema: { type: string } }
@@ -645,6 +663,23 @@ components:
path: { type: string } path: { type: string }
patch: { type: string } patch: { type: string }
Snapshot:
type: object
required: [id, task_id, agent_session_id, n, commit_sha, tree_sha, event_seq, trigger, created_at]
properties:
id: { type: string }
task_id: { type: string }
agent_session_id: { type: string }
n: { type: integer, description: "Position in the task's snapshots, from 1; the ref is refs/byre/tasks/{task_id}/snap/{n}." }
commit_sha: { type: string, description: A commit of tree_sha whose parent is the worktree's HEAD when it was taken. }
tree_sha: { type: string }
event_seq: { type: integer, format: int64, description: "The task's latest event when the snapshot was started: the tree holds at least the work up to it." }
trigger:
type: string
enum: [tool_call, turn_end, ask_human, timer]
x-enum-varnames: [SnapshotTriggerToolCall, SnapshotTriggerTurnEnd, SnapshotTriggerAskHuman, SnapshotTriggerTimer]
created_at: { type: string, format: date-time }
AgentSession: AgentSession:
type: object type: object
required: [id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at] required: [id, task_id, vendor, model, vendor_session_id, state, role, created_by, created_at]
+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. - `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/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/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/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/claude`: the Claude Code stream-json client (see `docs/claude-protocol.md`).
- `internal/push`: web push notifications for new inbox items (see "Notifications"). - `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 ## 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`. `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 ## 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. 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 ## 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. `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. - **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. - **Later:** subagent views and Mermaid rendering.
- **M2 — Seeing the work.** Temp-index diffs and stats, snapshots, timeline scrubbing, terminals, file browser, previews. - **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. - **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:** snapshots, timeline scrubbing, terminals, file browser, previews. - **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. - **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. - **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. - **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.
+2
View File
@@ -122,6 +122,8 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
l.asked = true l.asked = true
m.mu.Unlock() m.mu.Unlock()
m.snapshotSoon(l, api.SnapshotTriggerAskHuman)
if *args.Blocking { if *args.Blocking {
return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: end your turn now. The answer will arrive as your next message.", id), nil return fmt.Sprintf("Question %s filed in the human's inbox. It is blocking: end your turn now. The answer will arrive as your next message.", id), nil
} }
+7
View File
@@ -51,6 +51,9 @@ type Manager struct {
deliverMu sync.Mutex deliverMu sync.Mutex
capMu sync.Mutex capMu sync.Mutex
snapMu sync.Mutex
snaps map[string]*snapState
bad *badLines bad *badLines
watchStop chan struct{} watchStop chan struct{}
} }
@@ -85,6 +88,7 @@ func New(st *store.Store, limits Limits, worktreeRoot string) *Manager {
live: map[string]*live{}, live: map[string]*live{},
rateLimits: map[api.AgentSessionVendor]map[string]any{}, rateLimits: map[api.AgentSessionVendor]map[string]any{},
merges: map[string]string{}, merges: map[string]string{},
snaps: map[string]*snapState{},
bad: newBadLines(), bad: newBadLines(),
watchStop: make(chan struct{}), watchStop: make(chan struct{}),
} }
@@ -565,6 +569,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
Hooks: map[string][]claude.HookMatcher{ Hooks: map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance}}, "UserPromptSubmit": {{Func: remindGuidance}},
"Stop": {{Func: m.bounceProseQuestion(l)}}, "Stop": {{Func: m.bounceProseQuestion(l)}},
"PostToolUse": {{Func: m.snapshotAfterTool(l)}},
}, },
OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) }, OnBadLine: func(line []byte, reason string) { m.recordBadLine(l, line, reason) },
} }
@@ -588,6 +593,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
m.live[sess.Id] = l m.live[sess.Id] = l
go m.pump(l) go m.pump(l)
go m.snapshotTimer(l)
return l, nil return l, nil
} }
@@ -712,6 +718,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if sha != "" { if sha != "" {
payload["commit"] = sha payload["commit"] = sha
} }
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
switch { switch {
case handoff: case handoff:
payload["handoff"] = true payload["handoff"] = true
+18
View File
@@ -187,6 +187,24 @@ func TestTurnAndFollowUp(t *testing.T) {
} }
} }
if !has(events, "snapshot.created") {
events = append(events, e.waitFor("snapshot", func(ev api.Event) bool { return ev.Kind == "snapshot.created" })...)
}
snaps, err := e.store.ListSnapshots(e.ctx, e.task.Id)
if err != nil || len(snaps) == 0 {
t.Fatalf("no snapshots after a turn that wrote a file: %+v %v", snaps, err)
}
latest := snaps[len(snaps)-1]
if ref := gitOut(t, e.repo, "rev-parse", git.SnapshotRef(e.task.Id, latest.N)); ref != latest.CommitSha {
t.Fatalf("snapshot ref %s, want %s", ref, latest.CommitSha)
}
if got := gitOut(t, e.repo, "show", latest.CommitSha+":hi.txt"); strings.TrimSpace(got) != "hi" {
t.Fatalf("hi.txt in the latest snapshot: %q", got)
}
if snaps[0].Trigger != api.SnapshotTriggerToolCall {
t.Fatalf("first snapshot trigger %s, want tool_call", snaps[0].Trigger)
}
window := 0 window := 0
for _, ev := range events { for _, ev := range events {
if ev.Kind == "session.context" { if ev.Kind == "session.context" {
+4
View File
@@ -137,6 +137,10 @@ func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Pro
if err != nil { if err != nil {
return err return err
} }
err = m.dropSnapshots(project.RepoPath, task.Id)
if err != nil {
return err
}
return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch) return git.RemoveWorktree(project.RepoPath, task.WorktreePath, task.Branch)
} }
+158
View File
@@ -0,0 +1,158 @@
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/claude"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
)
const snapshotInterval = 30 * time.Second
type snapState struct {
mu sync.Mutex
running bool
pending api.SnapshotTrigger
closed bool
}
func (m *Manager) snapStateLocked(taskID string) *snapState {
st := m.snaps[taskID]
if st == nil {
st = &snapState{}
m.snaps[taskID] = st
}
return st
}
func (m *Manager) snapshotSoon(l *live, trigger api.SnapshotTrigger) {
m.snapMu.Lock()
defer m.snapMu.Unlock()
st := m.snapStateLocked(l.taskID)
if st.closed {
return
}
if st.running {
st.pending = trigger
return
}
st.running = true
go m.runSnapshots(l, st, trigger)
}
func (m *Manager) runSnapshots(l *live, st *snapState, trigger api.SnapshotTrigger) {
for {
st.mu.Lock()
m.snapMu.Lock()
closed := st.closed
m.snapMu.Unlock()
if !closed {
err := m.takeSnapshot(context.Background(), l, trigger)
if err != nil {
slog.Error("take snapshot", "task", l.taskID, "trigger", trigger, "err", err)
}
}
st.mu.Unlock()
m.snapMu.Lock()
trigger = st.pending
st.pending = ""
if trigger == "" || st.closed {
st.running = false
m.snapMu.Unlock()
return
}
m.snapMu.Unlock()
}
}
func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.SnapshotTrigger) error {
seq, err := m.store.LastTaskSeq(ctx, l.taskID)
if err != nil {
return err
}
tree, err := git.Snapshot(l.dir)
if err != nil {
return err
}
last, err := m.store.LastSnapshot(ctx, l.taskID)
if err != nil && !errors.Is(err, store.ErrNotFound) {
return err
}
if err == nil && last.TreeSha == tree {
return nil
}
n := last.N + 1
commit, err := git.CommitSnapshot(l.dir, tree, git.SnapshotRef(l.taskID, n), fmt.Sprintf("byre snapshot %d (%s)", n, trigger), []string{
"GIT_AUTHOR_NAME=" + l.gitName,
"GIT_AUTHOR_EMAIL=" + l.gitEmail,
"GIT_COMMITTER_NAME=" + l.gitName,
"GIT_COMMITTER_EMAIL=" + l.gitEmail,
})
if err != nil {
return err
}
return m.store.CreateSnapshot(ctx, api.Snapshot{
Id: store.NewID(),
TaskId: l.taskID,
AgentSessionId: l.id,
N: n,
CommitSha: commit,
TreeSha: tree,
EventSeq: seq,
Trigger: trigger,
CreatedAt: time.Now().UTC(),
}, l.projectID)
}
func (m *Manager) dropSnapshots(repo, taskID string) error {
m.snapMu.Lock()
st := m.snapStateLocked(taskID)
st.closed = true
m.snapMu.Unlock()
st.mu.Lock()
defer st.mu.Unlock()
return git.DeleteSnapshotRefs(repo, taskID)
}
func (m *Manager) snapshotAfterTool(l *live) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
return map[string]any{}, nil
}
}
func (m *Manager) snapshotTimer(l *live) {
ticker := time.NewTicker(snapshotInterval)
defer ticker.Stop()
for {
select {
case <-l.sess.Done():
return
case <-ticker.C:
sess, err := m.store.GetSession(context.Background(), l.id)
if err != nil {
slog.Error("check session for snapshot", "session", l.id, "err", err)
continue
}
if sess.State == api.AgentSessionStateRunning {
m.snapshotSoon(l, api.SnapshotTriggerTimer)
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
package agents
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/git"
"g.fc.run/misc/byre/internal/store"
)
func TestSnapshots(t *testing.T) {
ctx := context.Background()
events := make(chan api.Event, 256)
st, err := store.Open(filepath.Join(t.TempDir(), "byre.db"), func(ev api.Event) { events <- ev })
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { st.Close() })
repo := t.TempDir()
gitIn := func(dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Root", "GIT_AUTHOR_EMAIL=root@byre.invalid", "GIT_COMMITTER_NAME=Root", "GIT_COMMITTER_EMAIL=root@byre.invalid")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
gitIn(repo, "init", "--initial-branch=main")
err = os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("one\n"), 0o644)
if err != nil {
t.Fatal(err)
}
gitIn(repo, "add", "tracked.txt")
gitIn(repo, "commit", "--message", "root")
now := time.Now().UTC()
_, err = st.EnsureUser(ctx, "alice", "Alice")
if err != nil {
t.Fatalf("ensure user: %v", err)
}
project := api.Project{Id: "p1", Name: "demo", RepoPath: repo, DefaultBranch: "main", Remote: "none", CreatedBy: "alice", CreatedAt: now}
err = st.CreateProject(ctx, project)
if err != nil {
t.Fatalf("create project: %v", err)
}
task := api.Task{Id: "t1", ProjectId: "p1", Title: "snap", Branch: "byre/t1", WorktreePath: filepath.Join(t.TempDir(), "t1"), Status: api.TaskStatusActive, CreatedBy: "alice", CreatedAt: now}
task.BaseRef, err = git.AddWorktree(repo, task.WorktreePath, task.Branch, "main")
if err != nil {
t.Fatalf("add worktree: %v", err)
}
err = st.CreateTask(ctx, task)
if err != nil {
t.Fatalf("create task: %v", err)
}
err = st.CreateSession(ctx, api.AgentSession{Id: "s1", TaskId: "t1", Vendor: api.Claude, VendorSessionId: "s1", State: api.AgentSessionStateIdle, Role: api.Implementer, CreatedBy: "alice", CreatedAt: now}, "p1")
if err != nil {
t.Fatalf("create session: %v", err)
}
m := New(st, DefaultLimits, t.TempDir())
l := &live{id: "s1", dir: task.WorktreePath, gitName: "Alice Test", gitEmail: "alice@byre.invalid", vendor: api.Claude, taskID: "t1", projectID: "p1"}
write := func(name, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(task.WorktreePath, name), []byte(content), 0o644)
if err != nil {
t.Fatal(err)
}
}
snapshots := func() []api.Snapshot {
t.Helper()
snaps, err := st.ListSnapshots(ctx, "t1")
if err != nil {
t.Fatalf("list snapshots: %v", err)
}
return snaps
}
indexPath := gitIn(task.WorktreePath, "rev-parse", "--path-format=absolute", "--git-path", "index")
indexBefore, err := os.ReadFile(indexPath)
if err != nil {
t.Fatal(err)
}
write("tracked.txt", "two\n")
write("untracked.txt", "new\n")
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerToolCall)
if err != nil {
t.Fatalf("snapshot: %v", err)
}
snaps := snapshots()
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerToolCall || snaps[0].AgentSessionId != "s1" {
t.Fatalf("after the first change: %+v", snaps)
}
first := snaps[0]
if ref := gitIn(repo, "rev-parse", "refs/byre/tasks/t1/snap/1"); ref != first.CommitSha {
t.Fatalf("snap/1 is %s, want %s", ref, first.CommitSha)
}
if tree := gitIn(repo, "rev-parse", first.CommitSha+"^{tree}"); tree != first.TreeSha {
t.Fatalf("commit tree %s, want %s", tree, first.TreeSha)
}
if parent := gitIn(repo, "rev-parse", first.CommitSha+"^"); parent != task.BaseRef {
t.Fatalf("snapshot parent %s, want the worktree HEAD %s", parent, task.BaseRef)
}
if got := gitIn(repo, "show", first.CommitSha+":untracked.txt"); got != "new" {
t.Fatalf("untracked.txt in snapshot: %q", got)
}
if got := gitIn(repo, "show", first.CommitSha+":tracked.txt"); got != "two" {
t.Fatalf("tracked.txt in snapshot: %q", got)
}
indexAfter, err := os.ReadFile(indexPath)
if err != nil {
t.Fatal(err)
}
if string(indexBefore) != string(indexAfter) {
t.Fatalf("snapshot modified the worktree's index")
}
if staged := gitIn(task.WorktreePath, "diff", "--cached", "--name-only"); staged != "" {
t.Fatalf("snapshot staged files in the real index: %s", staged)
}
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerTimer)
if err != nil {
t.Fatalf("unchanged snapshot: %v", err)
}
if snaps := snapshots(); len(snaps) != 1 {
t.Fatalf("an unchanged tree made a snapshot: %+v", snaps)
}
write("untracked.txt", "newer\n")
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
deadline := time.After(30 * time.Second)
for created := false; !created; {
select {
case ev := <-events:
created = ev.Kind == "snapshot.created" && ev.Payload["n"] == float64(2)
case <-deadline:
t.Fatalf("no snapshot.created event")
}
}
snaps = snapshots()
if len(snaps) != 2 || snaps[1].N != 2 || snaps[1].Trigger != api.SnapshotTriggerTurnEnd || snaps[1].EventSeq < first.EventSeq {
t.Fatalf("after the second change: %+v", snaps)
}
if got := gitIn(repo, "show", "refs/byre/tasks/t1/snap/2:untracked.txt"); got != "newer" {
t.Fatalf("untracked.txt in snap/2: %q", got)
}
err = m.RemoveTask(ctx, task, project, "alice")
if err != nil {
t.Fatalf("remove task: %v", err)
}
_, err = st.DiscardTask(ctx, "t1", "alice")
if err != nil {
t.Fatalf("discard: %v", err)
}
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
t.Fatalf("snapshot refs left after discard:\n%s", refs)
}
if snaps := snapshots(); len(snaps) != 2 {
t.Fatalf("snapshot rows after discard: %+v", snaps)
}
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
t.Fatalf("snapshot taken after discard:\n%s", refs)
}
}
+175 -94
View File
@@ -252,6 +252,30 @@ func (e InboxState) Valid() bool {
} }
} }
// Defines values for SnapshotTrigger.
const (
SnapshotTriggerAskHuman SnapshotTrigger = "ask_human"
SnapshotTriggerTimer SnapshotTrigger = "timer"
SnapshotTriggerToolCall SnapshotTrigger = "tool_call"
SnapshotTriggerTurnEnd SnapshotTrigger = "turn_end"
)
// Valid indicates whether the value is a known member of the SnapshotTrigger enum.
func (e SnapshotTrigger) Valid() bool {
switch e {
case SnapshotTriggerAskHuman:
return true
case SnapshotTriggerTimer:
return true
case SnapshotTriggerToolCall:
return true
case SnapshotTriggerTurnEnd:
return true
default:
return false
}
}
// Defines values for TaskStatus. // Defines values for TaskStatus.
const ( const (
TaskStatusActive TaskStatus = "active" TaskStatusActive TaskStatus = "active"
@@ -579,6 +603,28 @@ type SessionCreate struct {
Prompt string `json:"prompt"` Prompt string `json:"prompt"`
} }
// Snapshot defines model for Snapshot.
type Snapshot struct {
AgentSessionId string `json:"agent_session_id"`
// CommitSha A commit of tree_sha whose parent is the worktree's HEAD when it was taken.
CommitSha string `json:"commit_sha"`
CreatedAt time.Time `json:"created_at"`
// EventSeq The task's latest event when the snapshot was started: the tree holds at least the work up to it.
EventSeq int64 `json:"event_seq"`
Id string `json:"id"`
// N Position in the task's snapshots, from 1; the ref is refs/byre/tasks/{task_id}/snap/{n}.
N int `json:"n"`
TaskId string `json:"task_id"`
TreeSha string `json:"tree_sha"`
Trigger SnapshotTrigger `json:"trigger"`
}
// SnapshotTrigger defines model for Snapshot.Trigger.
type SnapshotTrigger string
// Status defines model for Status. // Status defines model for Status.
type Status struct { type Status struct {
ActiveTasks int `json:"active_tasks"` ActiveTasks int `json:"active_tasks"`
@@ -861,6 +907,9 @@ type ServerInterface interface {
// (POST /api/tasks/{taskId}/sessions) // (POST /api/tasks/{taskId}/sessions)
CreateSession(w http.ResponseWriter, r *http.Request, taskId string) CreateSession(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/snapshots)
ListSnapshots(w http.ResponseWriter, r *http.Request, taskId string)
// (GET /api/tasks/{taskId}/unmerged) // (GET /api/tasks/{taskId}/unmerged)
GetUnmerged(w http.ResponseWriter, r *http.Request, taskId string) GetUnmerged(w http.ResponseWriter, r *http.Request, taskId string)
} }
@@ -1741,6 +1790,32 @@ func (siw *ServerInterfaceWrapper) CreateSession(w http.ResponseWriter, r *http.
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
} }
// ListSnapshots operation middleware
func (siw *ServerInterfaceWrapper) ListSnapshots(w http.ResponseWriter, r *http.Request) {
var err error
_ = err
// ------------- Path parameter "taskId" -------------
var taskId string
err = runtime.BindStyledParameterWithOptions("simple", "taskId", r.PathValue("taskId"), &taskId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "", ValueIsUnescaped: true})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "taskId", Err: err})
return
}
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.ListSnapshots(w, r, taskId)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetUnmerged operation middleware // GetUnmerged operation middleware
func (siw *ServerInterfaceWrapper) GetUnmerged(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) GetUnmerged(w http.ResponseWriter, r *http.Request) {
@@ -1906,6 +1981,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/events", wrapper.ListTaskEvents) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/events", wrapper.ListTaskEvents)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/changes", wrapper.GetTaskChanges)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/diff", wrapper.GetTaskDiff)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/snapshots", wrapper.ListSnapshots)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.ListSessions)
m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/tasks/{taskId}/sessions", wrapper.CreateSession)
m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api/sessions/{sessionId}", wrapper.GetSession)
@@ -1928,100 +2004,105 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
// const string: with thousands of chunks the chained `+` fold is several // const string: with thousands of chunks the chained `+` fold is several
// times slower for the Go compiler than parsing a slice literal. // times slower for the Go compiler than parsing a slice literal.
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"3N7+TwAAAP//", "LOHc46nTtCl+2nicDvwdTON92fH5WRDUp1egLjPtJdgrkAqE3SPBpBQ70SYGsKq6oXaOLSuUXFaisHQu",
"SMMKJMqRXDH/E0pKX8/YVXQ7lwcnzpdjnGpCRitkrMIdVHSThISHEOwL+X9i/RBiHRKie2vj4SaRJymP",
"D2l/XJl8oBkBpR1H6svZnw+lguUQkamUldNWg9uksNGFeMNyT7osQMnbuCbw8Hni4c2AT1w/P0aBo6ra",
"v4iuhuvojgksorvrhvEEnrYSzj4PL6M46p6HnF2DEzGbvv3uIrr8LrrqBDcfkbyExt+opzYhqvShkHME",
"p87jXHV0eAp71N3qeKQt6gj/5UcEbXTBRFKefkV3PGF76IZG9NadrZn0P0oFRp7YZPauu97iMbuLwxpT",
"Hcb+fbj9YfYvm3e6vf2fAAAA//8=",
} }
// decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes,
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+65
View File
@@ -0,0 +1,65 @@
package git
import (
"fmt"
"os/exec"
"strings"
)
func snapshotPrefix(taskID string) string {
return "refs/byre/tasks/" + taskID + "/snap/"
}
func SnapshotRef(taskID string, n int) string {
return fmt.Sprintf("%s%d", snapshotPrefix(taskID), n)
}
func CommitSnapshot(dir, tree, ref, message string, env []string) (string, error) {
head, err := Head(dir)
if err != nil {
return "", err
}
commit, err := runEnv(dir, env, "commit-tree", tree, "-p", head, "-m", message)
if err != nil {
return "", err
}
_, err = run(dir, "update-ref", ref, commit, "")
if err != nil {
return "", err
}
return commit, nil
}
func SnapshotRefs(repo, taskID string) ([]string, error) {
out, err := run(repo, "for-each-ref", "--format=%(refname)", snapshotPrefix(taskID))
if err != nil {
return nil, err
}
if out == "" {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
func DeleteSnapshotRefs(repo, taskID string) error {
refs, err := SnapshotRefs(repo, taskID)
if err != nil {
return err
}
if len(refs) == 0 {
return nil
}
b := &strings.Builder{}
for _, ref := range refs {
fmt.Fprintf(b, "delete %s\n", ref)
}
cmd := exec.Command("git", "update-ref", "--stdin")
cmd.Dir = repo
cmd.Stdin = strings.NewReader(b.String())
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("git update-ref --stdin: %w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+15
View File
@@ -452,6 +452,21 @@ func (s *Server) ListTaskEvents(w http.ResponseWriter, r *http.Request, taskID s
writeJSON(w, http.StatusOK, events) writeJSON(w, http.StatusOK, events)
} }
func (s *Server) ListSnapshots(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
snaps, err := s.store.ListSnapshots(r.Context(), taskID)
if err != nil {
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, snaps)
}
func (s *Server) ListSessions(w http.ResponseWriter, r *http.Request, taskID string) { func (s *Server) ListSessions(w http.ResponseWriter, r *http.Request, taskID string) {
_, err := s.store.GetTask(r.Context(), taskID) _, err := s.store.GetTask(r.Context(), taskID)
if err != nil { if err != nil {
+74
View File
@@ -0,0 +1,74 @@
package store
import (
"context"
"database/sql"
"errors"
"g.fc.run/misc/byre/internal/api"
)
const snapshotColumns = `id, task_id, agent_session_id, n, commit_sha, tree_sha, event_seq, trigger, created_at`
func (s *Store) CreateSnapshot(ctx context.Context, snap api.Snapshot, projectID string) error {
return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error {
_, err := tx.ExecContext(ctx, `INSERT INTO snapshots (`+snapshotColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
snap.Id, snap.TaskId, snap.AgentSessionId, snap.N, snap.CommitSha, snap.TreeSha, snap.EventSeq, string(snap.Trigger), formatTime(snap.CreatedAt))
if err != nil {
return err
}
return emit(api.Event{
Kind: "snapshot.created",
ProjectId: &projectID,
TaskId: &snap.TaskId,
AgentSessionId: &snap.AgentSessionId,
Actor: "byre",
Payload: ToPayload(snap),
})
})
}
func (s *Store) ListSnapshots(ctx context.Context, taskID string) ([]api.Snapshot, error) {
rows, err := s.db.QueryContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
snaps := []api.Snapshot{}
for rows.Next() {
snap, err := scanSnapshot(rows)
if err != nil {
return nil, err
}
snaps = append(snaps, snap)
}
return snaps, rows.Err()
}
func (s *Store) LastSnapshot(ctx context.Context, taskID string) (api.Snapshot, error) {
snap, err := scanSnapshot(s.db.QueryRowContext(ctx, `SELECT `+snapshotColumns+` FROM snapshots WHERE task_id = ? ORDER BY n DESC LIMIT 1`, taskID))
if errors.Is(err, sql.ErrNoRows) {
return snap, ErrNotFound
}
return snap, err
}
func (s *Store) LastTaskSeq(ctx context.Context, taskID string) (int64, error) {
seq := int64(0)
err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq), 0) FROM events WHERE task_id = ?`, taskID).Scan(&seq)
return seq, err
}
func scanSnapshot(row scanner) (api.Snapshot, error) {
snap := api.Snapshot{}
trigger := ""
createdAt := ""
err := row.Scan(&snap.Id, &snap.TaskId, &snap.AgentSessionId, &snap.N, &snap.CommitSha, &snap.TreeSha, &snap.EventSeq, &trigger, &createdAt)
if err != nil {
return snap, err
}
snap.Trigger = api.SnapshotTrigger(trigger)
snap.CreatedAt, err = parseTime(createdAt)
return snap, err
}
+13
View File
@@ -179,6 +179,19 @@ CREATE TABLE push_subscriptions (
subscriber TEXT NOT NULL, subscriber TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
`}, {"add-snapshots", `
CREATE TABLE snapshots (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
agent_session_id TEXT NOT NULL REFERENCES agent_sessions(id),
n INTEGER NOT NULL,
commit_sha TEXT NOT NULL,
tree_sha TEXT NOT NULL,
event_seq INTEGER NOT NULL,
trigger TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, n)
);
`}} `}}
type Store struct { type Store struct {
+61
View File
@@ -274,6 +274,25 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/tasks/{taskId}/snapshots": {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
/** @description The task's snapshots, oldest first. Each is a commit of the worktree as if everything were staged, kept at refs/byre/tasks/{taskId}/snap/{n} until the task is merged or discarded; the rows remain after that. */
get: operations["listSnapshots"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks/{taskId}/sessions": { "/api/tasks/{taskId}/sessions": {
parameters: { parameters: {
query?: never; query?: never;
@@ -600,6 +619,25 @@ export interface components {
path: string; path: string;
patch: string; patch: string;
}; };
Snapshot: {
id: string;
task_id: string;
agent_session_id: string;
/** @description Position in the task's snapshots, from 1; the ref is refs/byre/tasks/{task_id}/snap/{n}. */
n: number;
/** @description A commit of tree_sha whose parent is the worktree's HEAD when it was taken. */
commit_sha: string;
tree_sha: string;
/**
* Format: int64
* @description The task's latest event when the snapshot was started: the tree holds at least the work up to it.
*/
event_seq: number;
/** @enum {string} */
trigger: "tool_call" | "turn_end" | "ask_human" | "timer";
/** Format: date-time */
created_at: string;
};
AgentSession: { AgentSession: {
id: string; id: string;
task_id: string; task_id: string;
@@ -1292,6 +1330,29 @@ export interface operations {
409: components["responses"]["Error"]; 409: components["responses"]["Error"];
}; };
}; };
listSnapshots: {
parameters: {
query?: never;
header?: never;
path: {
taskId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The snapshots. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Snapshot"][];
};
};
404: components["responses"]["Error"];
};
};
listSessions: { listSessions: {
parameters: { parameters: {
query?: never; query?: never;