Attach a refs/notes/byre note to each squash merge and push it

This commit is contained in:
Ian Gulliver
2026-10-15 19:44:53 -07:00
parent c5e3a680d8
commit 6741177f53
8 changed files with 275 additions and 11 deletions
-2
View File
@@ -8,8 +8,6 @@
# M4 — Merging and history # M4 — Merging and history
- Git notes on squash merges
Attach a note under `refs/notes/byre` to each squash commit, holding the plan, the handoff bundle, review summaries, eval results and resolved inbox decisions, with full transcripts referenced by task ID. Notes don't push or fetch by default, so the merge pushes the notes ref explicitly and project setup configures fetching it.
- Repeated merge cycles - Repeated merge cycles
Let a task merge more than once. After the squash commit lands, reset the task branch to the new default-branch head, since squash commits leave the agent's commits as non-ancestors and later cycles would accumulate spurious conflicts (the per-turn history stays reachable through snapshot refs). Then tell the agent explicitly that its branch was squashed and reset, so it doesn't try to repair history, and resume it; files on disk don't change. Test this per vendor. Let a task merge more than once. After the squash commit lands, reset the task branch to the new default-branch head, since squash commits leave the agent's commits as non-ancestors and later cycles would accumulate spurious conflicts (the per-turn history stays reachable through snapshot refs). Then tell the agent explicitly that its branch was squashed and reset, so it doesn't try to repair history, and resume it; files on disk don't change. Test this per vendor.
- Archive search - Archive search
+5 -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, the worktree file browser (see "Files"), terminals (see "Terminals"). - `internal/server`: HTTP handlers, identity, SSE, the worktree file browser (see "Files"), terminals (see "Terminals").
- `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges and pushes. - `internal/git`: worktrees, the task changes view (see "Changes"), snapshots (see "Snapshots"), unmerged-change summaries, squash merges, merge notes and pushes.
- `internal/agents`: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, and records their activity as events. - `internal/agents`: the agent session manager. It owns the running agent processes, task agents, lead agents (see "Lead agent") and reviewers (see "Reviews") alike, 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/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`). - `internal/codex`: the `codex app-server` JSON-RPC client, with types generated from the CLI's schema by `go generate ./internal/codex` (see `docs/codex-protocol.md`).
@@ -36,9 +36,11 @@ A task's `merge_title` is the title of the squash commit Merge makes. It is the
A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer. A task's participants are the users who put a message bubble with their name in its transcript, derived from its events (`Store.Participants`): the actors of its `user.message` events, joined to `users` so `byre` and `agent` drop out. That covers messages sent to the agent and inbox answers, whose delivery message carries the name of the first responder in it (`Deliver` in `internal/agents/inbox.go`); dismissing an item puts nothing in the transcript and doesn't count. A fork's participants also include the user who forked it, the actor of its `task.created` event (whose payload carries `forked_from`), whose "forked this task" note opens its transcript. They are ordered by their first such event. `GET /api/tasks/{id}/co-authors` returns the participants other than the caller, with their git name and email from `PUT /api/me` (empty for a user who hasn't set one). The Merge confirm fetches it and lists `Name <email>` for each co-author, and separately those with no git identity, who get no trailer.
`POST /api/tasks/{id}/merge` starts the same turn, with the prompt's closing invitation to revise the title left out since the human just confirmed it, and records in `tasks.merging_by` that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name <email>` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout (`git push --porcelain origin <commit>:refs/heads/<default branch>`, run in the checkout). Only once the push is accepted does it run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed. `POST /api/tasks/{id}/merge` starts the same turn, with the prompt's closing invitation to revise the title left out since the human just confirmed it, and records in `tasks.merging_by` that the task is to be merged by the requesting user, who must have a git identity (400 otherwise). Every turn end of the task's session reads and clears it. It survives a byre restart during the turn: startup resumes the session with the restart notice, and the merge happens when that resumed turn ends. When that turn ends normally (not interrupted, not an error), byre squashes the worktree, now containing the default branch, onto it with `git merge-tree --merge-base`, makes one commit titled with the task's `merge_title` with `git commit-tree`, authored and committed by the merging user with no agent attribution, its body one `Co-authored-by: Name <email>` trailer per co-author with a git identity (`squashMessage` in `internal/agents/mergein.go`; the co-authors are computed when the squash runs, relative to the user who clicked Merge), and pushes that commit to the default branch on the `origin` remote of the project's checkout together with its note (below), in one atomic push (`git push --atomic --porcelain origin <commit>:refs/heads/<default branch> <notes commit>:refs/notes/byre`, run in the checkout), so either both land or neither does. Only once the push is accepted does it point the checkout's `refs/notes/byre` at the notes commit, run `git merge --ff-only` in the project's checkout (which must have the default branch checked out) and close the task; the `task.merged` event carries the commit and `pushed` (`origin/<default branch>`), and the transcript shows both. When the merge changes nothing, no commit is made and nothing is pushed.
Before starting the turn, Merge runs `git fetch origin <default branch>` and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing. `POST /api/tasks/{id}/discard` removes everything without merging. Before starting the turn, Merge runs `git fetch origin` (every configured refspec, so the notes too) and `git merge --ff-only origin/<default branch>` in the checkout, so the agent merges in what the remote has; the request waits for the fetch, and a failure (network, or a checkout with commits the remote lacks) is returned as the request's error. If the push is still refused, for example because someone pushed during the turn, or fails for any other reason, byre files the error in the inbox and the task stays active; clicking Merge again fetches and goes round once more. A refused push leaves the checkout's default branch where it was, since the squash commit was never moved onto it. If the squash fails, for example because the default branch moved again during the turn, byre also files an error and the task stays active. The agent calls `stop_merge` (`internal/agents/mergein.go`) with a `reason` when its conflict resolution took substantive changes: it clears `merging_by` and releases the project's merge at once, and records a `task.merge_stopped` event carrying the reason, which the transcript shows as a note. The turn then ends without a squash, the task stays active, its snapshot is reviewed, and the human clicks Merge again when satisfied. Called with no merge pending (a Sync turn, say), it changes nothing and tells the agent so. An interrupted or failed turn, an agent process that exits mid-turn, or a session that can't be resumed after a restart clears `merging_by` and leaves the task active without squashing. `POST /api/tasks/{id}/discard` removes everything without merging.
Each squash commit gets a git note under `refs/notes/byre`, the durable record of the task (`mergeNote` in `internal/agents/notes.go`): a Markdown heading with the merge title; the task's id, title, creator, merging user, TODO item and fork source; a pointer to the full transcripts as the task's events in byre (`GET /api/events?task_id=<id>`), which the note doesn't copy; every handoff its sessions wrote on compaction (the `result` of each `turn.ended` with `handoff: true`); each review's state, error, `change` and `summary`, by snapshot; and each `answered` question with its context and the responses to it. Plans and eval results aren't recorded yet, since byre has neither. `git.NoteCommit` builds the notes commit on top of the checkout's current `refs/notes/byre` in a temporary index, authored as the merging user, without moving the ref, so a refused push leaves the checkout's notes as they were. Notes don't fetch by default, so registering a project (`POST /api/projects`), and every byre startup for each existing project, adds the refspec `refs/notes/byre*:refs/notes/byre*` to the checkout's `remote.origin.fetch` if it isn't there (`git.ConfigureNotesFetch`); it's a pattern because git refuses a fetch whose exact refspec names a ref the remote doesn't have yet, as it doesn't before the first note is pushed. Another clone adds the same refspec with `git config --add remote.origin.fetch 'refs/notes/byre*:refs/notes/byre*'`. `git log --notes=byre` shows the notes, and `git notes --ref=byre show <commit>` one commit's.
One merge runs per project at a time. The Manager keeps the task holding each project's merge in memory (`mergeHolders` in `internal/agents/manager.go`), from just before the fetch until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails (the fetch, or the user has no git identity), byre files the error in the task's inbox and tries the next held merge. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges. One merge runs per project at a time. The Manager keeps the task holding each project's merge in memory (`mergeHolders` in `internal/agents/manager.go`), from just before the fetch until the merge closes the task, fails, or is abandoned as above, or the task is discarded; on startup it is rebuilt from the active tasks with `merging_by` set. Merge on another task of that project while one is held doesn't fetch or start a turn: it records the request in `tasks.merge_queued_by` and `merge_queued_at` (`merge_queued_at` on the Task), emits `task.merge_queued` (a transcript note), and returns the task with 202. A second Merge on a held or merging task is a 409. Whenever a project's merge is released, any task turn in it ends, and on startup, byre starts the oldest held merge in the project whose task session is not `running`: it clears the request (emitting `task.merge_unqueued`, not drawn) and goes through the same fetch and turn as a direct Merge by the user who asked, so it merges in what the earlier merge pushed. If that start fails (the fetch, or the user has no git identity), byre files the error in the task's inbox and tries the next held merge. The Merge button reads **Merge held**, disabled, while the task has `merge_queued_at`; discarding the task drops the request. `sqlite3 byre.db "SELECT id, merge_queued_by, merge_queued_at FROM tasks WHERE merge_queued_by != ''"` lists held merges.
+16 -3
View File
@@ -269,23 +269,36 @@ func (m *Manager) squashAndClose(ctx context.Context, taskID, login string) erro
return err return err
} }
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), []string{ env := []string{
"GIT_AUTHOR_NAME=" + user.GitName, "GIT_AUTHOR_NAME=" + user.GitName,
"GIT_AUTHOR_EMAIL=" + user.GitEmail, "GIT_AUTHOR_EMAIL=" + user.GitEmail,
"GIT_COMMITTER_NAME=" + user.GitName, "GIT_COMMITTER_NAME=" + user.GitName,
"GIT_COMMITTER_EMAIL=" + user.GitEmail, "GIT_COMMITTER_EMAIL=" + user.GitEmail,
}) }
commit, err := git.SquashCommit(project.RepoPath, task.WorktreePath, project.DefaultBranch, squashMessage(task.MergeTitle, coAuthors), env)
if err != nil { if err != nil {
return err return err
} }
pushed := "" pushed := ""
if commit != "" { if commit != "" {
err = git.Push(project.RepoPath, commit, project.DefaultBranch) note, err := m.mergeNote(ctx, task, login)
if err != nil {
return err
}
notes, err := git.NoteCommit(project.RepoPath, commit, note, env)
if err != nil {
return err
}
err = git.Push(project.RepoPath, commit, project.DefaultBranch, notes)
if err != nil { if err != nil {
return err return err
} }
pushed = git.Remote + "/" + project.DefaultBranch pushed = git.Remote + "/" + project.DefaultBranch
err = git.SetNotes(project.RepoPath, notes)
if err != nil {
return fmt.Errorf("pushed %s to %s, but updating %s in the project checkout failed: %w", commit, pushed, git.NotesRef, err)
}
err = git.FastForward(project.RepoPath, commit) err = git.FastForward(project.RepoPath, commit)
if err != nil { if err != nil {
return fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err) return fmt.Errorf("pushed %s to %s, but fast-forwarding the project checkout failed: %w", commit, pushed, err)
+107
View File
@@ -0,0 +1,107 @@
package agents
import (
"context"
"fmt"
"strings"
"time"
"g.fc.run/misc/byre/internal/api"
"g.fc.run/misc/byre/internal/store"
)
func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (string, error) {
handoffs, err := m.store.TaskHandoffs(ctx, task.Id)
if err != nil {
return "", err
}
reviews, err := m.store.ListReviews(ctx, task.Id)
if err != nil {
return "", err
}
answered, err := m.store.ListInbox(ctx, store.InboxFilter{TaskID: task.Id, State: api.Answered})
if err != nil {
return "", err
}
b := &strings.Builder{}
fmt.Fprintf(b, "# %s\n\n", task.MergeTitle)
fmt.Fprintf(b, "byre task %s, %q, created by %s, merged by %s.\n", task.Id, task.Title, task.CreatedBy, login)
if task.TodoItem != nil {
fmt.Fprintf(b, "Started from the TODO item %q.\n", *task.TodoItem)
}
if task.ForkedFrom != nil && task.ForkedAt != nil {
fmt.Fprintf(b, "Forked from task %s at snapshot %d.\n", *task.ForkedFrom, *task.ForkedAt)
}
fmt.Fprintf(b, "Full transcripts: the events of task %s in byre (GET /api/events?task_id=%s).\n", task.Id, task.Id)
if len(handoffs) > 0 {
b.WriteString("\n## Handoffs\n")
for _, h := range handoffs {
fmt.Fprintf(b, "\n### Session %s, %s\n\n%s\n", h.SessionID, h.At.Format(time.RFC3339), strings.TrimSpace(h.Text))
}
}
if len(reviews) > 0 {
b.WriteString("\n## Reviews\n")
for _, r := range reviews {
fmt.Fprintf(b, "\n### Snapshot %d, %s %s: %s\n", r.SnapshotN, r.Vendor, r.Model, r.State)
if r.Error != "" {
fmt.Fprintf(b, "\n%s\n", r.Error)
}
if r.Result == nil {
continue
}
for _, key := range []string{"change", "summary"} {
text, _ := (*r.Result)[key].(string)
if strings.TrimSpace(text) != "" {
fmt.Fprintf(b, "\n%s\n", strings.TrimSpace(text))
}
}
}
}
decisions := []api.InboxItem{}
for _, item := range answered {
if item.Kind == api.Question {
decisions = append(decisions, item)
}
}
if len(decisions) > 0 {
b.WriteString("\n## Decisions\n")
for _, item := range decisions {
fmt.Fprintf(b, "\n### %s\n", item.Payload["question"])
background, _ := item.Payload["context"].(string)
if strings.TrimSpace(background) != "" {
fmt.Fprintf(b, "\n%s\n", strings.TrimSpace(background))
}
b.WriteString("\n")
for _, r := range item.Responses {
b.WriteString(decisionLine(item, r))
}
}
}
return b.String(), nil
}
func decisionLine(item api.InboxItem, r api.InboxResponse) string {
text := ""
if r.Text != nil {
text = strings.TrimSpace(*r.Text)
}
switch r.Type {
case api.Option:
line := fmt.Sprintf("%s chose: %s\n", r.User, strings.Join(*r.Options, "; "))
if item.Payload["multiple"] == true {
line = fmt.Sprintf("%s checked: %s\n", r.User, strings.Join(*r.Options, "; "))
}
if text != "" {
line += fmt.Sprintf("%s added: %s\n", r.User, text)
}
return line
case api.Other:
return fmt.Sprintf("%s answered: %s\n", r.User, text)
}
return ""
}
+20 -3
View File
@@ -3,6 +3,7 @@ package git
import ( import (
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -217,8 +218,8 @@ const Remote = "origin"
var ErrRemoteMoved = errors.New("the remote's branch has moved") var ErrRemoteMoved = errors.New("the remote's branch has moved")
func Push(repo, commit, branch string) error { func Push(repo, commit, branch, notes string) error {
out, stderr, code, err := runCode(repo, nil, "push", "--porcelain", Remote, commit+":refs/heads/"+branch) out, stderr, code, err := runCode(repo, nil, "push", "--atomic", "--porcelain", Remote, commit+":refs/heads/"+branch, notes+":"+NotesRef)
if err != nil { if err != nil {
return err return err
} }
@@ -239,7 +240,7 @@ func FastForward(repo, rev string) error {
} }
func FetchFastForward(repo, branch string) error { func FetchFastForward(repo, branch string) error {
_, err := run(repo, "fetch", Remote, branch) _, err := run(repo, "fetch", Remote)
if err != nil { if err != nil {
return err return err
} }
@@ -303,10 +304,26 @@ func runEnv(dir string, env []string, args ...string) (string, error) {
return out, nil return out, nil
} }
func runInput(dir string, env []string, input string, args ...string) (string, error) {
out, stderr, code, err := runCodeInput(dir, env, strings.NewReader(input), args...)
if err != nil {
return "", err
}
if code != 0 {
return "", fmt.Errorf("git %s: exit status %d: %s", strings.Join(args, " "), code, stderr)
}
return out, nil
}
func runCode(dir string, env []string, args ...string) (string, string, int, error) { func runCode(dir string, env []string, args ...string) (string, string, int, error) {
return runCodeInput(dir, env, nil, args...)
}
func runCodeInput(dir string, env []string, stdin io.Reader, args ...string) (string, string, int, error) {
cmd := exec.Command("git", args...) cmd := exec.Command("git", args...)
cmd.Dir = dir cmd.Dir = dir
cmd.Env = append(os.Environ(), env...) cmd.Env = append(os.Environ(), env...)
cmd.Stdin = stdin
stderr := &strings.Builder{} stderr := &strings.Builder{}
cmd.Stderr = stderr cmd.Stderr = stderr
out, err := cmd.Output() out, err := cmd.Output()
+75
View File
@@ -0,0 +1,75 @@
package git
import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
)
const NotesRef = "refs/notes/byre"
const notesFetch = NotesRef + "*:" + NotesRef + "*"
func ConfigureNotesFetch(repo string) error {
key := "remote." + Remote + ".fetch"
out, stderr, code, err := runCode(repo, nil, "config", "--get-all", key)
if err != nil {
return err
}
if code != 0 && code != 1 {
return fmt.Errorf("git config --get-all %s: exit status %d: %s", key, code, stderr)
}
if slices.Contains(strings.Split(out, "\n"), notesFetch) {
return nil
}
_, err = run(repo, "config", "--add", key, notesFetch)
return err
}
func NoteCommit(repo, commit, note string, env []string) (string, error) {
blob, err := runInput(repo, nil, note, "hash-object", "-w", "--stdin")
if err != nil {
return "", err
}
parent, stderr, code, err := runCode(repo, nil, "rev-parse", "--verify", "--quiet", NotesRef)
if err != nil {
return "", err
}
if code != 0 && code != 1 {
return "", fmt.Errorf("git rev-parse %s: exit status %d: %s", NotesRef, code, stderr)
}
dir, err := os.MkdirTemp("", "byre-notes-*")
if err != nil {
return "", err
}
defer os.RemoveAll(dir)
index := []string{"GIT_INDEX_FILE=" + filepath.Join(dir, "index")}
commitArgs := []string{"commit-tree", "-m", "Notes added by byre"}
if code == 0 {
_, err = runEnv(repo, index, "read-tree", parent)
if err != nil {
return "", err
}
commitArgs = append(commitArgs, "-p", parent)
}
_, err = runEnv(repo, index, "update-index", "--add", "--cacheinfo", "100644,"+blob+","+commit)
if err != nil {
return "", err
}
tree, err := runEnv(repo, index, "write-tree")
if err != nil {
return "", err
}
return runEnv(repo, env, append(commitArgs, tree)...)
}
func SetNotes(repo, notes string) error {
_, err := run(repo, "update-ref", NotesRef, notes)
return err
}
+18
View File
@@ -55,6 +55,18 @@ func New(dataDir string, portFirst int, spa fs.FS) (*Server, error) {
return nil, err return nil, err
} }
projects, err := s.store.ListProjects(context.Background())
if err != nil {
s.store.Close()
return nil, err
}
for _, p := range projects {
err = git.ConfigureNotesFetch(p.RepoPath)
if err != nil {
slog.Error("configure notes fetch", "project", p.Id, "repo", p.RepoPath, "err", err)
}
}
s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "codex")) s.agents = agents.New(s.store, filepath.Join(dataDir, "worktrees"), filepath.Join(dataDir, "leads"), filepath.Join(dataDir, "codex"))
err = s.agents.Recover(context.Background()) err = s.agents.Recover(context.Background())
if err != nil { if err != nil {
@@ -198,6 +210,12 @@ func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
return return
} }
err = git.ConfigureNotesFetch(info.Root)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
p := api.Project{ p := api.Project{
Id: store.NewID(), Id: store.NewID(),
Name: req.Name, Name: req.Name,
+34
View File
@@ -866,6 +866,40 @@ func (s *Store) TaskEventsTail(ctx context.Context, taskID string, limit int) ([
return scanEvents(rows) return scanEvents(rows)
} }
type Handoff struct {
SessionID string
At time.Time
Text string
}
func (s *Store) TaskHandoffs(ctx context.Context, taskID string) ([]Handoff, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT agent_session_id, created_at, coalesce(json_extract(payload, '$.result'), '') FROM events
WHERE task_id = ? AND kind = 'turn.ended' AND json_extract(payload, '$.handoff') = 1
ORDER BY seq
`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
handoffs := []Handoff{}
for rows.Next() {
h := Handoff{}
at := ""
err = rows.Scan(&h.SessionID, &at, &h.Text)
if err != nil {
return nil, err
}
h.At, err = parseTime(at)
if err != nil {
return nil, err
}
handoffs = append(handoffs, h)
}
return handoffs, rows.Err()
}
func (s *Store) LeadEvents(ctx context.Context, owner string, after int64, limit int) ([]api.Event, error) { func (s *Store) LeadEvents(ctx context.Context, owner string, after int64, limit int) ([]api.Event, error) {
rows, err := s.db.QueryContext(ctx, ` rows, err := s.db.QueryContext(ctx, `
SELECT `+eventColumns+` SELECT `+eventColumns+`