From 0000db1f864714919e6fa932ae9e74c00c7fe6b7 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Thu, 15 Oct 2026 11:36:51 -0700 Subject: [PATCH] Fork tasks from snapshots; snapshot only when text follows tool calls --- TODO.md | 3 +- api/openapi.yaml | 30 +- docs/claude-protocol.md | 3 +- docs/operations.md | 20 +- internal/agents/fork.go | 102 ++++++ internal/agents/fork_internal_test.go | 49 +++ internal/agents/guidance.go | 24 +- internal/agents/inbox.go | 2 - internal/agents/lead.go | 3 + internal/agents/leadtools.go | 3 + internal/agents/manager.go | 65 +++- internal/agents/manager_test.go | 75 ++++- internal/agents/snapshots.go | 59 ++-- internal/agents/snapshots_internal_test.go | 10 +- internal/agents/tasks.go | 26 +- internal/api/api.gen.go | 346 ++++++++++++--------- internal/claude/session.go | 13 + internal/claude/session_test.go | 61 ++++ internal/server/server.go | 20 ++ internal/store/store.go | 55 +++- web/dist/task.js | 117 +++---- web/dist/task.js.map | 2 +- web/src/api.gen.ts | 61 +++- web/src/task.ts | 137 +++----- web/style.css | 28 +- 25 files changed, 893 insertions(+), 421 deletions(-) create mode 100644 internal/agents/fork.go create mode 100644 internal/agents/fork_internal_test.go diff --git a/TODO.md b/TODO.md index 376148b..8114378 100644 --- a/TODO.md +++ b/TODO.md @@ -4,8 +4,6 @@ # M2 — Seeing the work -- Forking from a snapshot - A new worktree from a snapshot commit plus a new agent session. Same vendor: the vendor's native session fork or resume. Different vendor: a handoff bundle from `export_context`, labelled in the UI as a handoff, not a true fork. - Terminals PTYs via `github.com/creack/pty`, rendered with xterm.js, one WebSocket per open terminal (the only WebSocket use in byre). Scoped to the task's worktree by default. Shown as a pane tab. Terminals don't survive a byre restart. - Per-project setup config @@ -69,6 +67,7 @@ An `export_context(purpose)` MCP tool producing a bundle with the goal, decisions made and why, current state, dead ends tried, open questions and relevant files. Used for cross-vendor forks, stage-to-stage handoff, and the durable record of a task. Compaction already writes the same contents. - Same-vendor forks - Cross-vendor handoffs + Forking a snapshot into a different vendor: the new session starts from a handoff bundle from `export_context` instead of the vendor's native fork, and the UI labels it a handoff, not a true fork. Same-vendor forks from a snapshot exist (see "Forking" in `docs/operations.md`). - Fork-and-compare A fan-out of N forks with different vendors or prompts from one snapshot, followed by a judge stage that produces a comparison with visuals. - Just-in-time fan-out with limits diff --git a/api/openapi.yaml b/api/openapi.yaml index 2c45108..be89eae 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -503,6 +503,27 @@ paths: items: { $ref: "#/components/schemas/Snapshot" } "404": { $ref: "#/components/responses/Error" } + /api/tasks/{taskId}/snapshots/{n}/fork: + parameters: + - { name: taskId, in: path, required: true, schema: { type: string } } + - { name: n, in: path, required: true, schema: { type: integer } } + post: + operationId: forkSnapshot + description: > + Creates a new task whose branch and worktree start from the snapshot's commit, with an idle agent session that + continues the snapshot's agent conversation as it stood at the snapshot (the vendor's own session fork). The + new session's first turn is the first message sent to it. Refused while the task is closed, and for a snapshot + with no recorded agent message at or before it. + responses: + "201": + description: The new task. + content: + application/json: + schema: { $ref: "#/components/schemas/Task" } + "400": { $ref: "#/components/responses/Error" } + "404": { $ref: "#/components/responses/Error" } + "409": { $ref: "#/components/responses/Error" } + /api/tasks/{taskId}/sessions: parameters: - { name: taskId, in: path, required: true, schema: { type: string } } @@ -820,6 +841,8 @@ components: closed_at: { type: string, format: date-time, description: When the task was merged or discarded. } merge_commit: { type: string, description: "merged: the squash commit on the default branch; empty when there was nothing to merge." } todo_item: { type: string, description: "The title of the TODO.md item the task was started from; absent for other tasks." } + forked_from: { type: string, description: "The task this one was forked from; absent for other tasks." } + forked_at: { type: integer, description: "With forked_from: the n of the snapshot it was forked at." } Todo: type: object @@ -945,8 +968,9 @@ components: 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] + enum: [message, turn_end, tool_call, ask_human, timer] + x-enum-varnames: [SnapshotTriggerMessage, SnapshotTriggerTurnEnd, SnapshotTriggerToolCall, SnapshotTriggerAskHuman, SnapshotTriggerTimer] + description: "message: the agent wrote text after one or more tool calls; turn_end: after byre's end-of-turn commit. tool_call, ask_human and timer appear only on snapshots taken by earlier builds." created_at: { type: string, format: date-time } AgentSession: @@ -967,6 +991,8 @@ components: role: { type: string, enum: [implementer, lead] } created_by: { type: string } created_at: { type: string, format: date-time } + forked_from: { type: string, description: "A fork's first start resumes this vendor session, cut at forked_at, under a new vendor_session_id; absent for other sessions." } + forked_at: { type: string, description: "With forked_from: the vendor's id of the last message the fork keeps." } SessionCreate: type: object diff --git a/docs/claude-protocol.md b/docs/claude-protocol.md index 5c77672..10e9333 100644 --- a/docs/claude-protocol.md +++ b/docs/claude-protocol.md @@ -12,11 +12,12 @@ claude --output-format stream-json --input-format stream-json --verbose \ --permission-mode bypassPermissions --allow-dangerously-skip-permissions \ --settings '{"attribution":{"commit":"","pr":""}}' \ --mcp-config '{"mcpServers":{"byre":{"type":"sdk","name":"byre"}}}' \ - [--model M] [--resume=SESSION_ID] [--fork-session] + [--model M] [--session-id=ID] [--resume=SESSION_ID] [--resume-session-at=UUID] [--fork-session] ``` - No `--print`; stream-json input implies non-interactive. - `--resume=` uses the equals form so a dash-leading value can't be parsed as a flag. +- `--resume-session-at` is hidden from `--help`. With `--resume` it keeps the conversation only up to and including the entry with that uuid, the `uuid` of an `assistant` or `user` stream-json line. With `--fork-session` and `--session-id` too, the result is a new session with that id, and the original is untouched. Resuming by id works from any working directory, so a fork can run in another worktree. - Environment: `CLAUDECODE` is removed so the child doesn't think it is nested; `GIT_AUTHOR_*`/`GIT_COMMITTER_*` set the commit identity; `attribution` set to empty strings removes the co-author trailer. ## Framing diff --git a/docs/operations.md b/docs/operations.md index bc00e64..9150912 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 ()`, and points `refs/byre/tasks//snap/` 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 ()`, and points `refs/byre/tasks//snap/` 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//` 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=` 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 · ·