Add task summaries, closed-task search and a summary turn before discard
This commit is contained in:
@@ -2,10 +2,12 @@ package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const handoffPrompt = `Write a handoff for a fresh agent that will continue this task with none of your context. ` +
|
||||
@@ -59,20 +61,37 @@ func (m *Manager) handoff(ctx context.Context, sessionID, handoff string) error
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.retire(ctx, sess, "byre")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if sess.Owner != "" {
|
||||
err = m.retire(ctx, sess, "byre")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.createLead(ctx, creator, leadHandoffPreamble+handoff, sess.Model, "byre", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
discard, err := m.store.GetDiscard(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discard.State != store.DiscardNone {
|
||||
m.startDiscard(sess.TaskId)
|
||||
return nil
|
||||
}
|
||||
err = transition(ctx, m.store, sess.Id, api.AgentSessionStateIdle, api.AgentSessionStateCompleted, "byre")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.stopLive(sess.Id)
|
||||
|
||||
task, err := m.store.GetTask(ctx, sess.TaskId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.create(ctx, task, creator, handoffPreamble+handoff, sess.Model, "byre", nil)
|
||||
if errors.Is(err, ErrConflict) {
|
||||
m.startDiscard(task.Id)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -357,6 +357,15 @@ func (m *Manager) deliverMidTurn(ctx context.Context, l *live) string {
|
||||
m.deliverMu.Lock()
|
||||
defer m.deliverMu.Unlock()
|
||||
|
||||
discard, err := m.store.GetDiscard(ctx, l.taskID)
|
||||
if err != nil {
|
||||
slog.Error("read discard state", "task", l.taskID, "err", err)
|
||||
return ""
|
||||
}
|
||||
if discard.State != store.DiscardNone {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := []string{}
|
||||
msgs := m.takeQueuedMidTurn(ctx, l)
|
||||
if len(msgs) > 0 {
|
||||
|
||||
@@ -64,6 +64,18 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
return m.transcriptText(ctx, args.TaskID, args.AfterSeq, args.Limit)
|
||||
}),
|
||||
|
||||
tool("search_closed_tasks",
|
||||
searchClosedDescription+" Give project_id for one project's tasks; without it, every project's. read_transcript reads a closed task's transcript.",
|
||||
`{"type": "object", "properties": {"project_id": {"type": "string"},`+searchClosedProperties+`}}`,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
args := searchClosedArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return m.searchClosedText(ctx, args.ProjectID, args.Query, args.Limit)
|
||||
}),
|
||||
|
||||
tool("read_inbox",
|
||||
"Read open inbox items as JSON, with each question's full context, choices and responses. Give task_id for one task's items.",
|
||||
`{"type": "object", "properties": {"task_id": {"type": "string"}}}`,
|
||||
@@ -308,18 +320,19 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
}),
|
||||
|
||||
tool("discard_task",
|
||||
"Discard a task: stop its agent and delete its worktree and branch without merging. This destroys its unmerged work. Only when the human asks.",
|
||||
"Discard a task: its agent gets one turn to write the task summary, then byre stops it and deletes its worktree and branch without merging. This destroys its unmerged work. Only when the human asks. "+
|
||||
"If the agent is mid-turn, the summary turn starts when that turn ends; no other turn runs in between.",
|
||||
taskOnly,
|
||||
func(ctx context.Context, user api.User, raw json.RawMessage) (string, error) {
|
||||
task, project, err := m.taskArg(ctx, raw)
|
||||
task, _, err := m.taskArg(ctx, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = m.Discard(ctx, task, project, user.Login)
|
||||
_, err = m.Discard(ctx, task, user.Login)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Discarded %s.", task.Id), nil
|
||||
return fmt.Sprintf("Discarding %s: its agent gets a turn to write the task summary, and byre discards the task when that turn ends.", task.Id), nil
|
||||
}),
|
||||
|
||||
tool("show",
|
||||
|
||||
+99
-25
@@ -47,6 +47,7 @@ type Manager struct {
|
||||
paneReported map[string]time.Time
|
||||
paneMoved map[string]time.Time
|
||||
startedTodo map[string]time.Time
|
||||
finishing map[string]bool
|
||||
|
||||
deliverMu sync.Mutex
|
||||
leadMu sync.Mutex
|
||||
@@ -111,6 +112,7 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string) *Manager {
|
||||
paneReported: map[string]time.Time{},
|
||||
paneMoved: map[string]time.Time{},
|
||||
startedTodo: map[string]time.Time{},
|
||||
finishing: map[string]bool{},
|
||||
signals: make(chan api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
mergeHolders: map[string]string{},
|
||||
@@ -142,6 +144,14 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
|
||||
return api.AgentSession{}, fmt.Errorf("%w: task %s is %s", ErrConflict, task.Id, task.Status)
|
||||
}
|
||||
|
||||
sess, err := m.newSession(ctx, task, creator, model)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
return m.turn(ctx, sess, task, actor, prompt, images)
|
||||
}
|
||||
|
||||
func (m *Manager) newSession(ctx context.Context, task api.Task, creator api.User, model string) (api.AgentSession, error) {
|
||||
existing, err := m.store.ListSessions(ctx, task.Id)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
@@ -167,11 +177,7 @@ func (m *Manager) create(ctx context.Context, task api.Task, creator api.User, p
|
||||
}
|
||||
|
||||
err = m.store.CreateSession(ctx, sess, task.ProjectId)
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
|
||||
return m.turn(ctx, sess, task, actor, prompt, images)
|
||||
return sess, err
|
||||
}
|
||||
|
||||
func (m *Manager) Send(ctx context.Context, sessionID, actor, text, model string, images ...string) (api.AgentSession, error) {
|
||||
@@ -253,8 +259,13 @@ func (m *Manager) StopTask(ctx context.Context, taskID, actor string) error {
|
||||
}
|
||||
|
||||
func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor string) error {
|
||||
m.stopLive(sess.Id)
|
||||
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) stopLive(sessionID string) {
|
||||
m.mu.Lock()
|
||||
l := m.live[sess.Id]
|
||||
l := m.live[sessionID]
|
||||
if l != nil {
|
||||
l.stopping = true
|
||||
}
|
||||
@@ -263,8 +274,6 @@ func (m *Manager) retire(ctx context.Context, sess api.AgentSession, actor strin
|
||||
if l != nil {
|
||||
l.sess.kill()
|
||||
}
|
||||
|
||||
return m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateCompleted, actor, nil)
|
||||
}
|
||||
|
||||
func (m *Manager) Recover(ctx context.Context) error {
|
||||
@@ -300,17 +309,32 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
if derr != nil {
|
||||
return derr
|
||||
}
|
||||
serr := m.store.SetSessionState(ctx, sess.Id, api.AgentSessionStateIdle, "byre", map[string]any{"error": err.Error()})
|
||||
next, serr := m.store.EndTurn(ctx, sess.Id, "byre", map[string]any{"error": err.Error()})
|
||||
if serr != nil {
|
||||
return serr
|
||||
}
|
||||
if sess.Role == api.Implementer {
|
||||
switch {
|
||||
case next == store.DiscardClosing:
|
||||
go m.finishDiscard(task.Id)
|
||||
case next == store.DiscardHeld:
|
||||
go m.startDiscard(task.Id)
|
||||
case sess.Role == api.Implementer:
|
||||
m.abandonMerge(task.Id, task.ProjectId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
err = m.message(ctx, l, "byre", restartNotice, nil)
|
||||
notice := restartNotice
|
||||
if sess.Role == api.Implementer {
|
||||
discard, err := m.store.GetDiscard(ctx, task.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if discard.State == store.DiscardRunning && discard.Session == sess.Id {
|
||||
notice = discardRestartPrompt
|
||||
}
|
||||
}
|
||||
err = m.message(ctx, l, "byre", notice, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,6 +355,11 @@ func (m *Manager) Recover(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.recoverDiscards(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go m.deliverAllLeads()
|
||||
go m.deliverAllQueued(context.Background())
|
||||
go m.startHeldMerges(context.Background())
|
||||
@@ -411,7 +440,10 @@ func (m *Manager) turn(ctx context.Context, sess api.AgentSession, task api.Task
|
||||
if err != nil {
|
||||
return api.AgentSession{}, err
|
||||
}
|
||||
return m.launch(ctx, sess, task, actor, text, images)
|
||||
}
|
||||
|
||||
func (m *Manager) launch(ctx context.Context, sess api.AgentSession, task api.Task, actor, text string, images []string) (api.AgentSession, error) {
|
||||
l, err := m.ensureLive(ctx, sess, task)
|
||||
if err == nil {
|
||||
err = m.syncModel(ctx, l, sess.Model)
|
||||
@@ -536,7 +568,7 @@ func (m *Manager) ensureLive(ctx context.Context, sess api.AgentSession, task ap
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.exposePortTool(l)}
|
||||
tools := []mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.createTaskTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l)}
|
||||
if sess.Role == api.Implementer && task.WorkflowStage != nil {
|
||||
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
||||
if err != nil {
|
||||
@@ -622,7 +654,7 @@ func (m *Manager) pump(l *live) {
|
||||
slog.Error("drop stage output", "session", l.id, "err", err)
|
||||
}
|
||||
}
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, map[string]any{"error": "agent process exited mid-turn"})
|
||||
next, err := m.store.EndTurn(ctx, l.id, agentActor, map[string]any{"error": "agent process exited mid-turn"})
|
||||
if err != nil {
|
||||
slog.Error("record session exit", "session", l.id, "err", err)
|
||||
}
|
||||
@@ -630,6 +662,14 @@ func (m *Manager) pump(l *live) {
|
||||
m.finishReviewer(l, "the reviewer's process exited mid-turn")
|
||||
return
|
||||
}
|
||||
switch next {
|
||||
case store.DiscardClosing:
|
||||
m.finishDiscard(l.taskID)
|
||||
return
|
||||
case store.DiscardHeld:
|
||||
m.startDiscard(l.taskID)
|
||||
return
|
||||
}
|
||||
if l.owner == "" {
|
||||
m.abandonMerge(l.taskID, l.projectID)
|
||||
m.fileError(l, "The agent process exited mid-turn. Send a message to resume it.")
|
||||
@@ -804,6 +844,10 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
discard, err := m.store.GetDiscard(ctx, l.taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mergingBy := ""
|
||||
if finished && !handoff {
|
||||
mergingBy, err = m.store.MergingBy(ctx, l.taskID)
|
||||
@@ -811,7 +855,9 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0, mergingBy == "")
|
||||
if discard.State == store.DiscardNone {
|
||||
m.snapshotSoon(l, api.SnapshotTriggerTurnEnd, 0, mergingBy == "")
|
||||
}
|
||||
|
||||
if res.window > 0 {
|
||||
err = m.updateContext(ctx, l, l.contextUsed, res.window)
|
||||
@@ -830,22 +876,37 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
}
|
||||
|
||||
stageFinished := false
|
||||
switch {
|
||||
case l.stage == "":
|
||||
case finished && !handoff && mergingBy == "":
|
||||
stageFinished, err = m.store.FinishStage(ctx, l.id)
|
||||
default:
|
||||
err = m.store.DropStageOutput(ctx, l.id)
|
||||
if l.stage != "" {
|
||||
if finished && !handoff && mergingBy == "" {
|
||||
stageFinished, err = m.store.FinishStage(ctx, l.id)
|
||||
}
|
||||
if err == nil && !stageFinished {
|
||||
err = m.store.DropStageOutput(ctx, l.id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
next, err := m.store.EndTurn(ctx, l.id, agentActor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = m.store.SetSessionState(ctx, l.id, api.AgentSessionStateIdle, agentActor, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
switch next {
|
||||
case store.DiscardClosing:
|
||||
m.logUndelivered(ctx, l.id, "turn ended in discard")
|
||||
go m.finishDiscard(l.taskID)
|
||||
return nil
|
||||
case store.DiscardHeld:
|
||||
go m.nextMerge(l.projectID)
|
||||
if stageFinished {
|
||||
go m.advanceStage(l)
|
||||
return nil
|
||||
}
|
||||
go m.startDiscard(l.taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
if handoff {
|
||||
m.logUndelivered(ctx, l.id, "turn ended in handoff")
|
||||
go m.continueFromHandoff(l.id, res.result)
|
||||
@@ -869,11 +930,24 @@ func (m *Manager) turnEnded(ctx context.Context, l *live, res endResult) error {
|
||||
go m.advanceStage(l)
|
||||
return nil
|
||||
}
|
||||
m.afterTurn(ctx, l)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) afterTurn(ctx context.Context, l *live) {
|
||||
discard, err := m.store.GetDiscard(ctx, l.taskID)
|
||||
if err != nil {
|
||||
slog.Error("read discard state", "task", l.taskID, "err", err)
|
||||
return
|
||||
}
|
||||
if discard.State == store.DiscardHeld {
|
||||
m.startDiscard(l.taskID)
|
||||
return
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func commitMessage(res endResult) string {
|
||||
|
||||
@@ -81,7 +81,7 @@ func (m *Manager) Merge(ctx context.Context, task api.Task, project api.Project,
|
||||
default:
|
||||
err := m.store.QueueMerge(ctx, task.Id, user.Login)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return api.Task{}, fmt.Errorf("%w: task %s is closed or already waiting to merge", ErrConflict, task.Id)
|
||||
return api.Task{}, fmt.Errorf("%w: task %s is closed, being discarded or already waiting to merge", ErrConflict, task.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
@@ -97,12 +97,15 @@ func (m *Manager) startMerge(ctx context.Context, task api.Task, project api.Pro
|
||||
}
|
||||
|
||||
err = m.store.SetMergingBy(ctx, task.Id, user.Login)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return fmt.Errorf("%w: task %s is being discarded", ErrConflict, task.Id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.mergeHolders[project.Id] = task.Id
|
||||
|
||||
prompt := mergeInPrompt(project.DefaultBranch) + fmt.Sprintf(" When this turn ends, byre squashes this branch onto %s and pushes it; if you stop, call stop_merge first so it doesn't.", project.DefaultBranch)
|
||||
prompt := mergeInPrompt(project.DefaultBranch) + fmt.Sprintf(" When this turn ends, byre squashes this branch onto %s and pushes it; if you stop, call stop_merge first so it doesn't. ", project.DefaultBranch) + summaryRequest
|
||||
_, err = m.sendMergeIn(ctx, task, user, prompt)
|
||||
if err != nil {
|
||||
m.takeMerge(task.Id)
|
||||
@@ -343,19 +346,6 @@ func squashMessage(title string, coAuthors []api.Participant) string {
|
||||
return title + "\n\n" + strings.Join(trailers, "\n")
|
||||
}
|
||||
|
||||
func (m *Manager) Discard(ctx context.Context, task api.Task, project api.Project, actor string) (api.Task, error) {
|
||||
err := m.RemoveTask(ctx, task, project, actor)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
t, err := m.store.DiscardTask(ctx, task.Id, actor)
|
||||
if err != nil {
|
||||
return api.Task{}, err
|
||||
}
|
||||
go m.releaseMerge(project.Id, task.Id)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveTask(ctx context.Context, task api.Task, project api.Project, actor string) error {
|
||||
err := m.StopTask(ctx, task.Id, actor)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,10 @@ func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (s
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
summary, err := m.store.TaskSummary(ctx, task.Id)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
b := &strings.Builder{}
|
||||
fmt.Fprintf(b, "# %s\n\n", task.MergeTitle)
|
||||
@@ -35,6 +39,10 @@ func (m *Manager) mergeNote(ctx context.Context, task api.Task, login string) (s
|
||||
}
|
||||
fmt.Fprintf(b, "Full transcripts: the events of task %s in byre (GET /api/events?task_id=%s).\n", task.Id, task.Id)
|
||||
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
fmt.Fprintf(b, "\n## Summary\n\n%s\n", strings.TrimSpace(summary))
|
||||
}
|
||||
|
||||
if len(handoffs) > 0 {
|
||||
b.WriteString("\n## Handoffs\n")
|
||||
for _, h := range handoffs {
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/mcp"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
const summaryRequest = "Call set_task_summary with a summary of the whole task, not just this turn, for whoever searches closed tasks later: what it set out to do, what was built or tried, the key decisions and why, and what was deliberately left out."
|
||||
|
||||
const discardPrompt = "This task is being discarded: when this turn ends, byre stops this session and deletes the worktree and branch without merging. Don't change any files. " +
|
||||
summaryRequest + " Say why it is being dropped, as far as you know. Then end your turn."
|
||||
|
||||
const discardRestartPrompt = "byre restarted while this turn was running. " + discardPrompt
|
||||
|
||||
type setTaskSummaryArgs struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
func (m *Manager) setTaskSummaryTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "set_task_summary",
|
||||
Description: "Set the summary of this whole task that byre keeps after it is merged or discarded, where agents find it with search_closed_tasks. " +
|
||||
"Cover what the task set out to do, what was built or tried, the key decisions and why, and what was deliberately left out. It replaces any earlier summary.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string", "description": "Markdown, a few short paragraphs or a bulleted list."}
|
||||
},
|
||||
"required": ["summary"]
|
||||
}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := setTaskSummaryArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed set_task_summary call: %w", err)
|
||||
}
|
||||
summary := strings.TrimSpace(args.Summary)
|
||||
if summary == "" {
|
||||
return "", fmt.Errorf("%w: summary must not be empty", ErrInvalid)
|
||||
}
|
||||
err = m.store.SetTaskSummary(ctx, l.taskID, summary, agentActor)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return "", fmt.Errorf("%w: this task is closed", ErrConflict)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "Task summary saved.", nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const searchClosedDescription = "Search tasks that were merged or discarded, newest closed first. A task matches when query appears, ignoring case, in its title, merge commit title, TODO item, summary or merge commit; without query every closed task matches. " +
|
||||
"Each result has the task's id, outcome, merge commit, close time, titles, TODO item and the summary its agent wrote when it was merged or discarded (tasks closed before byre kept summaries have none). " +
|
||||
"Use it to check whether something was already done or tried, and why."
|
||||
|
||||
const searchClosedProperties = `
|
||||
"query": {"type": "string"},
|
||||
"limit": {"type": "integer", "description": "At most this many tasks, default 20, at most 100."}`
|
||||
|
||||
type searchClosedArgs struct {
|
||||
ProjectID string `json:"project_id"`
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
func (m *Manager) searchClosedTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "search_closed_tasks",
|
||||
Description: searchClosedDescription + " Searches this task's project.",
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {` + searchClosedProperties + `}}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := searchClosedArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed search_closed_tasks call: %w", err)
|
||||
}
|
||||
return m.searchClosedText(ctx, l.projectID, args.Query, args.Limit)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) searchClosedText(ctx context.Context, projectID, query string, limit int) (string, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
limit = min(limit, 100)
|
||||
|
||||
found, err := m.store.SearchClosedTasks(ctx, projectID, strings.TrimSpace(query), limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return "No closed tasks match.", nil
|
||||
}
|
||||
|
||||
b := &strings.Builder{}
|
||||
for _, c := range found {
|
||||
t := c.Task
|
||||
outcome := string(t.Status)
|
||||
if t.MergeCommit != nil {
|
||||
outcome += " as " + *t.MergeCommit
|
||||
}
|
||||
closed := ""
|
||||
if t.ClosedAt != nil {
|
||||
closed = t.ClosedAt.Format(time.RFC3339)
|
||||
}
|
||||
fmt.Fprintf(b, "## %s %q\n\nproject %s, %s %s, created by %s\n", t.Id, t.Title, c.Project, outcome, closed, t.CreatedBy)
|
||||
if t.MergeTitle != t.Title {
|
||||
fmt.Fprintf(b, "merge commit title: %s\n", t.MergeTitle)
|
||||
}
|
||||
if t.TodoItem != nil {
|
||||
fmt.Fprintf(b, "TODO item: %s\n", *t.TodoItem)
|
||||
}
|
||||
if t.ForkedFrom != nil {
|
||||
fmt.Fprintf(b, "forked from %s\n", *t.ForkedFrom)
|
||||
}
|
||||
summary := strings.TrimSpace(c.Summary)
|
||||
if summary == "" {
|
||||
summary = "(no summary)"
|
||||
}
|
||||
fmt.Fprintf(b, "\n%s\n\n", summary)
|
||||
}
|
||||
return strings.TrimSpace(b.String()), nil
|
||||
}
|
||||
|
||||
func (m *Manager) Discard(ctx context.Context, task api.Task, actor string) (api.Task, error) {
|
||||
state, err := m.store.RequestDiscard(ctx, task.Id, actor)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrNotFound):
|
||||
return api.Task{}, fmt.Errorf("%w: task %s is closed", ErrConflict, task.Id)
|
||||
case errors.Is(err, store.ErrDiscardPending), errors.Is(err, store.ErrMerging):
|
||||
return api.Task{}, fmt.Errorf("%w: task %s: %w", ErrConflict, task.Id, err)
|
||||
case err != nil:
|
||||
return api.Task{}, err
|
||||
}
|
||||
if state == store.DiscardClosing {
|
||||
go m.finishDiscard(task.Id)
|
||||
} else {
|
||||
go m.startDiscard(task.Id)
|
||||
}
|
||||
return m.store.GetTask(ctx, task.Id)
|
||||
}
|
||||
|
||||
func (m *Manager) startDiscard(taskID string) {
|
||||
ctx := context.Background()
|
||||
started, err := m.launchDiscard(ctx, taskID)
|
||||
if err == nil {
|
||||
if started {
|
||||
slog.Info("discard turn started", "task", taskID)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Error("start discard turn", "task", taskID, "err", err)
|
||||
err = m.store.CloseDiscard(ctx, taskID)
|
||||
if err != nil {
|
||||
slog.Error("close discard", "task", taskID, "err", err)
|
||||
return
|
||||
}
|
||||
m.finishDiscard(taskID)
|
||||
}
|
||||
|
||||
func (m *Manager) launchDiscard(ctx context.Context, taskID string) (bool, error) {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sessions, err := m.store.ListSessions(ctx, taskID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sess := api.AgentSession{}
|
||||
for _, s := range sessions {
|
||||
if s.State != api.AgentSessionStateCompleted {
|
||||
sess = s
|
||||
}
|
||||
}
|
||||
if sess.Id == "" {
|
||||
creator, err := m.store.GetUser(ctx, task.CreatedBy)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
sess, err = m.newSession(ctx, task, creator, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.store.StartDiscardTurn(ctx, taskID, sess.Id, "byre")
|
||||
if errors.Is(err, store.ErrStateChanged) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = m.launch(ctx, sess, task, "byre", discardPrompt, nil)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (m *Manager) finishDiscard(taskID string) {
|
||||
m.mu.Lock()
|
||||
if m.finishing[taskID] {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
}
|
||||
m.finishing[taskID] = true
|
||||
m.mu.Unlock()
|
||||
defer func() {
|
||||
m.mu.Lock()
|
||||
delete(m.finishing, taskID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
err := m.discardNow(ctx, taskID)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
slog.Error("finish discard", "task", taskID, "err", err)
|
||||
sessions, serr := m.store.ListSessions(ctx, taskID)
|
||||
if serr != nil {
|
||||
slog.Error("list sessions for discard error", "task", taskID, "err", serr)
|
||||
return
|
||||
}
|
||||
task, terr := m.store.GetTask(ctx, taskID)
|
||||
if terr != nil {
|
||||
slog.Error("load task for discard error", "task", taskID, "err", terr)
|
||||
return
|
||||
}
|
||||
m.fileTaskError(ctx, task, sessions, "Discarding the task failed: "+err.Error()+". Discard again to retry; byre also retries when it next starts.")
|
||||
}
|
||||
|
||||
func (m *Manager) discardNow(ctx context.Context, taskID string) error {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return nil
|
||||
}
|
||||
discard, err := m.store.GetDiscard(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, task.ProjectId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.RemoveTask(ctx, task, project, discard.By)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = m.store.DiscardTask(ctx, task.Id, discard.By)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go m.releaseMerge(project.Id, task.Id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) recoverDiscards(ctx context.Context) error {
|
||||
tasks, err := m.store.PendingDiscards(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
discard, err := m.store.GetDiscard(ctx, t.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch discard.State {
|
||||
case store.DiscardHeld:
|
||||
go m.startDiscard(t.Id)
|
||||
case store.DiscardClosing:
|
||||
go m.finishDiscard(t.Id)
|
||||
case store.DiscardRunning:
|
||||
sess, err := m.store.GetSession(ctx, discard.Session)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sess.State == api.AgentSessionStateRunning {
|
||||
continue
|
||||
}
|
||||
err = m.store.CloseDiscard(ctx, t.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
go m.finishDiscard(t.Id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package agents
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
@@ -149,10 +150,7 @@ func (m *Manager) advanceStage(l *live) {
|
||||
slog.Error("advance workflow stage", "session", l.id, "err", err)
|
||||
m.fileError(l, fmt.Sprintf("The workflow didn't move on from this stage: %v. byre tries again when it next starts.", err))
|
||||
}
|
||||
err = m.Deliver(ctx, l.id)
|
||||
if err != nil {
|
||||
slog.Error("deliver inbox responses", "session", l.id, "err", err)
|
||||
}
|
||||
m.afterTurn(ctx, l)
|
||||
}
|
||||
|
||||
func (m *Manager) recoverStages(ctx context.Context) (map[string]bool, error) {
|
||||
@@ -184,7 +182,11 @@ func (m *Manager) nextStage(ctx context.Context, sessionID string) ([]string, er
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.Status != api.TaskStatusActive {
|
||||
discard, err := m.store.GetDiscard(ctx, task.Id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task.Status != api.TaskStatusActive || discard.State == store.DiscardClosing {
|
||||
return nil, m.store.ClearStage(ctx, sess.Id)
|
||||
}
|
||||
frozen, err := m.store.TaskWorkflow(ctx, task.Id)
|
||||
@@ -259,7 +261,7 @@ func (m *Manager) advanceTo(ctx context.Context, from api.Task, frozen store.Fro
|
||||
|
||||
existing, found := latestStage(tasks, stage)
|
||||
if found {
|
||||
if existing.Status != api.TaskStatusActive {
|
||||
if existing.Status != api.TaskStatusActive || existing.Discarding {
|
||||
return "", nil
|
||||
}
|
||||
return m.resumeStage(ctx, existing, project, creator, frozen, parents)
|
||||
@@ -334,15 +336,15 @@ func (m *Manager) discardStages(ctx context.Context, final api.Task) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
project, err := m.store.GetProject(ctx, final.ProjectId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.Id == final.Id || t.Status != api.TaskStatusActive {
|
||||
continue
|
||||
}
|
||||
_, err = m.Discard(ctx, t, project, "byre")
|
||||
_, err = m.Discard(ctx, t, "byre")
|
||||
if errors.Is(err, ErrConflict) {
|
||||
slog.Info("stage task not discarded", "task", t.Id, "err", err)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("discard stage task %s: %w", t.Id, err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user