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

This commit is contained in:
Ian Gulliver
2026-10-15 11:36:51 -07:00
parent 3fe0374ac5
commit 0000db1f86
25 changed files with 893 additions and 421 deletions
+102
View File
@@ -0,0 +1,102 @@
package agents
import (
"context"
"fmt"
"time"
"g.fc.run/misc/byre/internal/api"
)
func (m *Manager) Fork(ctx context.Context, src api.Task, project api.Project, n int, creator api.User) (api.Task, error) {
if creator.GitName == "" || creator.GitEmail == "" {
return api.Task{}, ErrNoGitIdentity
}
if src.Status != api.TaskStatusActive {
return api.Task{}, fmt.Errorf("%w: task %s is %s", ErrConflict, src.Id, src.Status)
}
snap, err := m.store.GetSnapshot(ctx, src.Id, n)
if err != nil {
return api.Task{}, err
}
srcSess, err := m.store.GetSession(ctx, snap.AgentSessionId)
if err != nil {
return api.Task{}, err
}
events, err := m.store.TaskEvents(ctx, src.Id, 0, -1)
if err != nil {
return api.Task{}, err
}
at := forkPoint(events, snap.AgentSessionId, snap.EventSeq)
if at == "" {
return api.Task{}, fmt.Errorf("%w: snapshot %d of task %s has no recorded agent message at or before it to fork the conversation from", ErrConflict, n, src.Id)
}
t, err := m.createTask(ctx, project, api.Task{
Title: fmt.Sprintf("%s (fork of #%d)", src.Title, n),
MergeTitle: src.MergeTitle,
CreatedBy: creator.Login,
TodoItem: src.TodoItem,
ForkedFrom: &src.Id,
ForkedAt: &n,
}, snap.CommitSha, creator.Login)
if err != nil {
return api.Task{}, err
}
id := newUUID()
err = m.store.CreateSession(ctx, api.AgentSession{
Id: id,
TaskId: t.Id,
Vendor: srcSess.Vendor,
Model: srcSess.Model,
VendorSessionId: id,
State: api.AgentSessionStateIdle,
Role: api.Implementer,
CreatedBy: creator.Login,
CreatedAt: time.Now().UTC(),
ForkedFrom: &srcSess.VendorSessionId,
ForkedAt: &at,
}, t.ProjectId)
if err != nil {
return api.Task{}, fmt.Errorf("task %s created, but its agent session was not: %w", t.Id, err)
}
return t, nil
}
func forkPoint(events []api.Event, sessionID string, seq int64) string {
at := ""
open := map[string]bool{}
for _, ev := range events {
if ev.AgentSessionId == nil || *ev.AgentSessionId != sessionID {
continue
}
uuid, _ := ev.Payload["vendor_uuid"].(string)
if uuid == "" {
continue
}
id := ""
switch ev.Kind {
case "agent.tool_call":
id, _ = ev.Payload["id"].(string)
case "agent.tool_result":
id, _ = ev.Payload["tool_use_id"].(string)
}
after := ev.Seq > seq
if after && (len(open) == 0 || id == "") {
break
}
switch ev.Kind {
case "agent.tool_call":
open[id] = true
case "agent.tool_result":
delete(open, id)
}
at = uuid
if after && len(open) == 0 {
break
}
}
return at
}
+49
View File
@@ -0,0 +1,49 @@
package agents
import (
"testing"
"g.fc.run/misc/byre/internal/api"
)
func TestForkPoint(t *testing.T) {
s1, s2 := "s1", "s2"
ev := func(seq int64, sess *string, kind string, payload map[string]any) api.Event {
return api.Event{Seq: seq, AgentSessionId: sess, Kind: kind, Payload: payload}
}
events := []api.Event{
ev(1, &s1, "user.message", map[string]any{"text": "go"}),
ev(2, &s1, "agent.message", map[string]any{"text": "on it", "vendor_uuid": "u-msg"}),
ev(3, &s1, "agent.tool_call", map[string]any{"id": "a", "vendor_uuid": "u-call-a"}),
ev(4, &s1, "agent.tool_call", map[string]any{"id": "b", "vendor_uuid": "u-call-b"}),
ev(5, &s1, "agent.tool_call", map[string]any{"id": "x", "parent_tool_use_id": "b"}),
ev(6, nil, "snapshot.created", map[string]any{"n": 1}),
ev(7, &s1, "agent.tool_result", map[string]any{"tool_use_id": "a", "vendor_uuid": "u-res-a"}),
ev(8, &s2, "agent.tool_result", map[string]any{"tool_use_id": "b", "vendor_uuid": "u-other"}),
ev(9, &s1, "agent.tool_result", map[string]any{"tool_use_id": "b", "vendor_uuid": "u-res-b"}),
ev(10, &s1, "agent.message", map[string]any{"text": "done", "vendor_uuid": "u-done"}),
ev(11, &s1, "turn.ended", map[string]any{}),
ev(12, &s1, "user.message", map[string]any{"text": "more"}),
ev(13, &s1, "agent.tool_call", map[string]any{"id": "c", "vendor_uuid": "u-call-c"}),
ev(14, &s1, "agent.tool_result", map[string]any{"tool_use_id": "c", "vendor_uuid": "u-res-c"}),
}
for _, c := range []struct {
seq int64
want string
}{
{1, ""},
{2, "u-msg"},
{3, "u-res-b"},
{5, "u-res-b"},
{9, "u-res-b"},
{11, "u-done"},
{12, "u-done"},
{13, "u-res-c"},
} {
got := forkPoint(events, s1, c.seq)
if got != c.want {
t.Errorf("forkPoint at seq %d = %q, want %q", c.seq, got, c.want)
}
}
}
+19 -5
View File
@@ -59,12 +59,26 @@ func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
}
}
func taskGuidance(task api.Task) string {
if task.TodoItem == nil {
return guidance
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
g := guidance
if task.TodoItem != nil {
g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
*task.TodoItem, todo.File, todo.File)
}
return guidance + fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
*task.TodoItem, todo.File, todo.File)
if task.ForkedFrom == nil {
return g, nil
}
src, err := m.store.GetTask(ctx, *task.ForkedFrom)
if err != nil {
return "", err
}
return g + forkGuidance(task, src), nil
}
func forkGuidance(task, src api.Task) string {
return fmt.Sprintf("\n- This conversation was forked from byre task %q at its snapshot %d, into a task of its own: branch %s, worktree %s, which held that snapshot's files when the fork was made. "+
"Everything before the fork happened in %s, which belongs to the other task: from now on read and change files only in %s, never in the other worktree, and use paths under it.",
src.Title, *task.ForkedAt, task.Branch, task.WorktreePath, src.WorktreePath, task.WorktreePath)
}
func remindGuidance(text string) claude.HookFunc {
-2
View File
@@ -134,8 +134,6 @@ func (m *Manager) ask(ctx context.Context, l *live, raw json.RawMessage) (string
l.askedBlock = l.askedBlock || *args.Blocking
m.mu.Unlock()
m.snapshotSoon(l, api.SnapshotTriggerAskHuman)
cancel := fmt.Sprintf("If the human answers it some other way, such as in a chat message, withdraw it with cancel_question and question_id %q.", id)
if *args.Blocking {
return fmt.Sprintf("Question filed in the human's inbox. question_id: %s. It is blocking: end your turn now. The answer will arrive as your next message. %s", id, cancel), nil
+3
View File
@@ -363,6 +363,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
if err != nil {
return "", err
}
if task.ForkedFrom != nil {
return fmt.Sprintf("%s was forked by %s from %s at snapshot %d.", m.taskLabel(ctx, task.Id), ev.Actor, m.taskLabel(ctx, *task.ForkedFrom), *task.ForkedAt), nil
}
if task.TodoItem == nil {
return fmt.Sprintf("%s was created by %s.", m.taskLabel(ctx, task.Id), ev.Actor), nil
}
+3
View File
@@ -531,6 +531,9 @@ func (m *Manager) listTasksText(ctx context.Context) (string, error) {
if t.TodoItem != nil {
fmt.Fprintf(b, "; from TODO item %q", *t.TodoItem)
}
if t.ForkedFrom != nil {
fmt.Fprintf(b, "; forked from %s at snapshot %d", *t.ForkedFrom, *t.ForkedAt)
}
b.WriteString("\n")
}
if !listed {
+50 -15
View File
@@ -86,6 +86,8 @@ type live struct {
wantCompact bool
rateLimited map[string]any
toolsSinceText bool
contextModel string
contextUsed int
contextWindow int
@@ -608,6 +610,7 @@ func (m *Manager) message(ctx context.Context, l *live, actor, text string, imag
l.wantCompact = false
l.compacting = compacting
l.rateLimited = nil
l.toolsSinceText = false
m.mu.Unlock()
return l.sess.Send(text, imgs...)
@@ -643,7 +646,10 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
owner: sess.Owner,
}
g := taskGuidance(task)
g, err := m.taskGuidance(ctx, task)
if err != nil {
return nil, err
}
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l)}
hooks := map[string][]claude.HookMatcher{
"UserPromptSubmit": {{Func: remindGuidance(g)}},
@@ -675,9 +681,15 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
if err != nil {
return nil, err
}
if started {
switch {
case started:
opts.Resume = sess.VendorSessionId
} else {
case sess.ForkedFrom != nil:
opts.SessionID = sess.VendorSessionId
opts.Resume = *sess.ForkedFrom
opts.ResumeAt = *sess.ForkedAt
opts.ForkSession = true
default:
opts.SessionID = sess.VendorSessionId
}
@@ -691,9 +703,6 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
m.live[sess.Id] = l
go m.pump(l)
if l.owner == "" {
go m.snapshotTimer(l)
}
return l, nil
}
@@ -764,11 +773,29 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
uuid, err := ev.UUID()
if err != nil {
return err
}
for _, b := range blocks {
err = m.handleAssistantBlock(ctx, l, b, parent)
err = m.handleAssistantBlock(ctx, l, b, parent, uuid)
if err != nil {
return err
}
if parent != "" || l.owner != "" {
continue
}
switch {
case b.Type == "tool_use":
l.toolsSinceText = true
case b.Type == "text" && l.toolsSinceText:
l.toolsSinceText = false
seq, err := m.store.LastTaskSeq(ctx, l.taskID)
if err != nil {
return err
}
m.snapshotSoon(l, api.SnapshotTriggerMessage, seq)
}
}
if parent != "" {
return nil
@@ -792,6 +819,10 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if err != nil {
return err
}
uuid, err := ev.UUID()
if err != nil {
return err
}
for _, b := range blocks {
if b.Type != "tool_result" {
continue
@@ -804,7 +835,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
"tool_use_id": b.ToolUseID,
"is_error": b.IsError,
"content": content,
}, parent))
}, parent, uuid))
if err != nil {
return err
}
@@ -838,7 +869,7 @@ func (m *Manager) handle(ctx context.Context, l *live, ev claude.Event) error {
if sha != "" {
payload["commit"] = sha
}
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0)
switch {
case handoff:
payload["handoff"] = true
@@ -957,16 +988,16 @@ func (m *Manager) updateContext(ctx context.Context, l *live, used, window int)
return m.emit(ctx, l, agentActor, "session.context", map[string]any{"model": l.contextModel, "used": used, "window": window})
}
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block, parent string) error {
func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Block, parent, uuid string) error {
switch b.Type {
case "text":
return m.emit(ctx, l, agentActor, "agent.message", withParent(map[string]any{"text": b.Text}, parent))
return m.emit(ctx, l, agentActor, "agent.message", withParent(map[string]any{"text": b.Text}, parent, uuid))
case "thinking":
if b.Thinking == "" {
return nil
}
return m.emit(ctx, l, agentActor, "agent.thinking", withParent(map[string]any{"text": b.Thinking}, parent))
return m.emit(ctx, l, agentActor, "agent.thinking", withParent(map[string]any{"text": b.Thinking}, parent, uuid))
case "tool_use":
input, err := decode(b.Input)
@@ -977,15 +1008,19 @@ func (m *Manager) handleAssistantBlock(ctx context.Context, l *live, b claude.Bl
"id": b.ID,
"name": b.Name,
"input": input,
}, parent))
}, parent, uuid))
}
return m.emit(ctx, l, agentActor, "agent.vendor", withParent(store.ToPayload(b), parent))
return m.emit(ctx, l, agentActor, "agent.vendor", withParent(store.ToPayload(b), parent, uuid))
}
func withParent(payload map[string]any, parent string) map[string]any {
func withParent(payload map[string]any, parent, uuid string) map[string]any {
if parent != "" {
payload["parent_tool_use_id"] = parent
return payload
}
if uuid != "" {
payload["vendor_uuid"] = uuid
}
return payload
}
+73 -2
View File
@@ -203,8 +203,8 @@ func TestTurnAndFollowUp(t *testing.T) {
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)
if snaps[0].Trigger != api.SnapshotTriggerMessage {
t.Fatalf("first snapshot trigger %s, want message: the final message followed the tool calls", snaps[0].Trigger)
}
window := 0
@@ -1015,6 +1015,77 @@ func TestStartTodo(t *testing.T) {
}
}
func TestForkSnapshot(t *testing.T) {
t.Parallel()
e := newEnv(t)
m := e.manager()
src, err := m.Create(e.ctx, e.task, e.user, "Use the Write tool to create a.txt containing exactly APPLE-1. Once that call has returned, write the sentence \"a.txt is written.\" as text. Only after that, use the Write tool to create b.txt containing exactly BANANA-2. Then reply with the single word ok.", testModel)
if err != nil {
t.Fatalf("create session: %v", err)
}
e.waitIdle()
snaps, err := e.store.ListSnapshots(e.ctx, e.task.Id)
if err != nil {
t.Fatalf("list snapshots: %v", err)
}
n := 0
for _, snap := range snaps {
files := gitOut(t, e.repo, "ls-tree", "--name-only", snap.CommitSha)
if strings.Contains(files, "a.txt") && !strings.Contains(files, "b.txt") {
n = snap.N
}
}
if n == 0 {
t.Fatalf("no snapshot holds a.txt without b.txt: %+v", snaps)
}
fork, err := m.Fork(e.ctx, e.task, e.project, n, e.user)
if err != nil {
t.Fatalf("fork: %v", err)
}
if fork.ForkedFrom == nil || *fork.ForkedFrom != e.task.Id || fork.ForkedAt == nil || *fork.ForkedAt != n {
t.Fatalf("fork task %+v", fork)
}
if _, err := os.Stat(filepath.Join(fork.WorktreePath, "a.txt")); err != nil {
t.Fatalf("a.txt missing from the fork's worktree: %v", err)
}
if _, err := os.Stat(filepath.Join(fork.WorktreePath, "b.txt")); err == nil {
t.Fatalf("b.txt, written after the snapshot, is in the fork's worktree")
}
sessions, err := e.store.ListSessions(e.ctx, fork.Id)
if err != nil || len(sessions) != 1 {
t.Fatalf("fork sessions %+v %v", sessions, err)
}
sess := sessions[0]
if sess.State != api.AgentSessionStateIdle || sess.ForkedFrom == nil || *sess.ForkedFrom != src.VendorSessionId || sess.VendorSessionId == src.VendorSessionId {
t.Fatalf("fork session %+v", sess)
}
_, err = m.Send(e.ctx, sess.Id, login, "Without using any tools, list the contents of every file you have created in this conversation, one per line, and nothing else.", "")
if err != nil {
t.Fatalf("send to fork: %v", err)
}
reply := ""
for _, ev := range e.waitFor("fork turn end", func(ev api.Event) bool {
return ev.Kind == "turn.ended" && ev.TaskId != nil && *ev.TaskId == fork.Id
}) {
if ev.Kind == "agent.message" && ev.TaskId != nil && *ev.TaskId == fork.Id {
reply += ev.Payload["text"].(string)
}
}
if !strings.Contains(reply, "APPLE-1") || strings.Contains(reply, "BANANA-2") {
t.Fatalf("fork's conversation isn't cut at the snapshot; it said %q", reply)
}
_, err = m.Fork(e.ctx, e.task, e.project, n+100, e.user)
if !errors.Is(err, store.ErrNotFound) {
t.Fatalf("fork of a missing snapshot: %v", err)
}
}
func writeTestFile(t *testing.T, path, content string) {
t.Helper()
err := os.WriteFile(path, []byte(content), 0o644)
+22 -37
View File
@@ -15,12 +15,15 @@ import (
"g.fc.run/misc/byre/internal/store"
)
const snapshotInterval = 30 * time.Second
type snapRequest struct {
trigger api.SnapshotTrigger
seq int64
}
type snapState struct {
mu sync.Mutex
running bool
pending api.SnapshotTrigger
pending *snapRequest
closed bool
}
@@ -33,7 +36,7 @@ func (m *Manager) snapStateLocked(taskID string) *snapState {
return st
}
func (m *Manager) snapshotSoon(l *live, trigger api.SnapshotTrigger) {
func (m *Manager) snapshotSoon(l *live, trigger api.SnapshotTrigger, seq int64) {
m.snapMu.Lock()
defer m.snapMu.Unlock()
@@ -41,32 +44,33 @@ func (m *Manager) snapshotSoon(l *live, trigger api.SnapshotTrigger) {
if st.closed {
return
}
req := &snapRequest{trigger: trigger, seq: seq}
if st.running {
st.pending = trigger
st.pending = req
return
}
st.running = true
go m.runSnapshots(l, st, trigger)
go m.runSnapshots(l, st, req)
}
func (m *Manager) runSnapshots(l *live, st *snapState, trigger api.SnapshotTrigger) {
func (m *Manager) runSnapshots(l *live, st *snapState, req *snapRequest) {
for {
st.mu.Lock()
m.snapMu.Lock()
closed := st.closed
m.snapMu.Unlock()
if !closed {
err := m.takeSnapshot(context.Background(), l, trigger)
err := m.takeSnapshot(context.Background(), l, req.trigger, req.seq)
if err != nil {
slog.Error("take snapshot", "task", l.taskID, "trigger", trigger, "err", err)
slog.Error("take snapshot", "task", l.taskID, "trigger", req.trigger, "err", err)
}
}
st.mu.Unlock()
m.snapMu.Lock()
trigger = st.pending
st.pending = ""
if trigger == "" || st.closed {
req = st.pending
st.pending = nil
if req == nil || st.closed {
st.running = false
m.snapMu.Unlock()
return
@@ -75,10 +79,13 @@ func (m *Manager) runSnapshots(l *live, st *snapState, trigger api.SnapshotTrigg
}
}
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
func (m *Manager) takeSnapshot(ctx context.Context, l *live, trigger api.SnapshotTrigger, seq int64) error {
if seq == 0 {
var err error
seq, err = m.store.LastTaskSeq(ctx, l.taskID)
if err != nil {
return err
}
}
tree, err := git.Snapshot(l.dir)
@@ -131,7 +138,6 @@ func (m *Manager) dropSnapshots(repo, taskID string) error {
func (m *Manager) afterTool(l *live) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
text := m.deliverMidTurn(ctx, l)
if text == "" {
return map[string]any{}, nil
@@ -144,24 +150,3 @@ func (m *Manager) afterTool(l *live) claude.HookFunc {
}, 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)
}
}
}
}
+5 -5
View File
@@ -92,12 +92,12 @@ func TestSnapshots(t *testing.T) {
write("tracked.txt", "two\n")
write("untracked.txt", "new\n")
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerToolCall)
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 0)
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" {
if len(snaps) != 1 || snaps[0].N != 1 || snaps[0].Trigger != api.SnapshotTriggerMessage || snaps[0].AgentSessionId != "s1" {
t.Fatalf("after the first change: %+v", snaps)
}
first := snaps[0]
@@ -128,7 +128,7 @@ func TestSnapshots(t *testing.T) {
t.Fatalf("snapshot staged files in the real index: %s", staged)
}
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerTimer)
err = m.takeSnapshot(ctx, l, api.SnapshotTriggerMessage, 1)
if err != nil {
t.Fatalf("unchanged snapshot: %v", err)
}
@@ -137,7 +137,7 @@ func TestSnapshots(t *testing.T) {
}
write("untracked.txt", "newer\n")
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd)
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0)
deadline := time.After(30 * time.Second)
for created := false; !created; {
select {
@@ -170,7 +170,7 @@ func TestSnapshots(t *testing.T) {
t.Fatalf("snapshot rows after discard: %+v", snaps)
}
m.snapshotSoon(l, api.SnapshotTriggerToolCall)
m.snapshotSoon(l, api.SnapshotTriggerMessage, 0)
if refs := gitIn(repo, "for-each-ref", "refs/byre/"); refs != "" {
t.Fatalf("snapshot taken after discard:\n%s", refs)
}
+11 -15
View File
@@ -17,7 +17,7 @@ import (
)
func (m *Manager) CreateTask(ctx context.Context, project api.Project, title, createdBy, base, actor string) (api.Task, error) {
return m.createTask(ctx, project, title, createdBy, base, actor, nil)
return m.createTask(ctx, project, api.Task{Title: title, CreatedBy: createdBy}, base, actor)
}
func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator api.User, title, model, actor string) (api.Task, error) {
@@ -47,7 +47,7 @@ func (m *Manager) StartTodo(ctx context.Context, project api.Project, creator ap
}
}
task, err := m.createTask(ctx, project, item.Title, creator.Login, project.DefaultBranch, actor, &item.Title)
task, err := m.createTask(ctx, project, api.Task{Title: item.Title, CreatedBy: creator.Login, TodoItem: &item.Title}, project.DefaultBranch, actor)
if err != nil {
return api.Task{}, err
}
@@ -64,20 +64,16 @@ func todoPrompt(item api.TodoItem) string {
todo.File, todo.Markdown(item))
}
func (m *Manager) createTask(ctx context.Context, project api.Project, title, createdBy, base, actor string, todoItem *string) (api.Task, error) {
id := store.NewID()
t := api.Task{
Id: id,
ProjectId: project.Id,
Title: title,
MergeTitle: title,
Branch: "byre/" + id,
WorktreePath: filepath.Join(m.worktreeRoot, project.Id, id),
Status: api.TaskStatusActive,
CreatedBy: createdBy,
CreatedAt: time.Now().UTC(),
TodoItem: todoItem,
func (m *Manager) createTask(ctx context.Context, project api.Project, t api.Task, base, actor string) (api.Task, error) {
t.Id = store.NewID()
t.ProjectId = project.Id
if t.MergeTitle == "" {
t.MergeTitle = t.Title
}
t.Branch = "byre/" + t.Id
t.WorktreePath = filepath.Join(m.worktreeRoot, project.Id, t.Id)
t.Status = api.TaskStatusActive
t.CreatedAt = time.Now().UTC()
var err error
t.BaseRef, err = git.AddWorktree(project.RepoPath, t.WorktreePath, t.Branch, base)