Hold turn-end snapshots and reviews while subagents run

This commit is contained in:
Ian Gulliver
2026-10-17 10:37:46 -07:00
parent a76469135b
commit 82d6457d87
2 changed files with 6 additions and 5 deletions
+3 -3
View File
@@ -62,7 +62,7 @@ A Task's `state` (the `tasks.state` column) is what the task is doing, owned by
Subagent count: Claude Code's subagents can run in the background past the `turn.ended` of the turn that started them, while the session is `idle`. A session's `subagents` (the `agent_sessions.subagents` column, on `AgentSession`) counts its running ones: `trackSubagents` in `internal/agents/manager.go` adds a `task_started` `system` event's `task_id` when its `task_type` is `local_agent` (background shell commands are `local_bash` and don't count), and drops it on the `task_notification` naming it. Each change is written with `Store.SetSubagents`, which emits `session.subagents` (`subagents`, the new count). It drops to 0 when the agent's process exits, and on startup for every session left above 0, since a restart ends every process. `sqlite3 byre.db "SELECT id, task_id, owner, subagents FROM agent_sessions WHERE subagents > 0"` shows the sessions with subagents running.
`review_requests` covers the gap between a turn's end and its review starting. `turnEnded` emits `turn.ended` inside `EndTurn`'s transaction, which also sets the session idle and, when the turn's snapshot will be reviewed (no discard or merge pending), adds one to `review_requests`, so the task goes from `running` to `review_pending` in the transaction that publishes the turn's summary. The snapshot is requested after that transaction. Each review request ends exactly once with `EndReviewRequest`, which subtracts one: after `afterSnapshot` returns (by then the review row is `running`, so the task is `reviewing`, or it was skipped or never started, so it is `idle`), or when the request is dropped because a newer one replaced it or the task's snapshots were closed. On startup `recoverReviews` zeroes every task's `review_requests` (`ClearReviewRequests`), since the requests in flight died with the process.
`review_requests` covers the gap between a turn's end and its review starting. `turnEnded` emits `turn.ended` inside `EndTurn`'s transaction, which also sets the session idle and, when the turn's snapshot will be reviewed (no discard or merge pending, and no subagents running), adds one to `review_requests`, so the task goes from `running` to `review_pending` in the transaction that publishes the turn's summary. The snapshot is requested after that transaction. Each review request ends exactly once with `EndReviewRequest`, which subtracts one: after `afterSnapshot` returns (by then the review row is `running`, so the task is `reviewing`, or it was skipped or never started, so it is `idle`), or when the request is dropped because a newer one replaced it or the task's snapshots were closed. On startup `recoverReviews` zeroes every task's `review_requests` (`ClearReviewRequests`), since the requests in flight died with the process.
The UI's labels (see "Task state" under the task switcher) read `state` for **Working**; `task.state` reloads the project's tasks. `sqlite3 byre.db "SELECT id, title, state, review_requests FROM tasks WHERE status = 'active'"` shows them.
@@ -287,9 +287,9 @@ The **Terminal** tab (`web/src/terminal.ts`) draws it with xterm.js, vendored li
## Snapshots
byre snapshots each task's worktree at the end of every turn (`internal/agents/snapshots.go`, `internal/git/snapshot.go`), so a snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. 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 when the snapshot started, at or after the turn's `turn.ended`, 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 at the end of every turn that has no subagents running (`internal/agents/snapshots.go`, `internal/git/snapshot.go`), so a snapshot always sits on a turn boundary, the only place a Codex conversation can be forked. 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 when the snapshot started, at or after the turn's `turn.ended`, 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.
The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call`, `ask_human` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. 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.
The trigger is `turn_end`: the snapshot starts after byre's end-of-turn commit and the `turn.ended` event. A turn that ends while the session's subagent count (see "Subagent count") is above zero takes no snapshot and requests no review, since its work is half done; the CLI starts another turn when each background subagent finishes, and the first of those to end with none running takes the snapshot and its review. Snapshots taken by earlier builds also carry `message` (mid-turn, when the agent wrote text after tool calls), `tool_call`, `ask_human` and `timer`. A snapshot whose tree equals the task's latest snapshot's tree is skipped, so a turn that changed no files leaves none. 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.
The `take_snapshot` MCP tool asks for a snapshot now (`RequestSnapshot` and `takeSnapshotTool` in `internal/agents/snapshots.go`): a task's agent calls it with no arguments for its own task, and the lead with a `task_id`. The snapshot has trigger `requested`, is taken even when its tree equals the latest snapshot's (so it retries a failed review of unchanged work), and is reviewed like any other (see "Reviews"); it counts as a review request (`BeginReviewRequest`) from the call until the reviewer starts. It is attributed to the task's latest implementer session, authored as that session's creator, and can be taken mid-turn, uncommitted work included. A requested snapshot that is replaced while waiting passes its trigger and its taken-even-when-unchanged to the one that replaces it. Refused for a closed task, while the task is merging or being discarded, and for a task with no agent session.