From 279919c6cdf3c72a8c1fcb19552c47f49910e32e Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Wed, 21 Oct 2026 08:46:51 -0700 Subject: [PATCH] Ask each last workflow stage to merge as soon as it finishes --- internal/agents/audit.go | 6 +++--- internal/agents/lead.go | 8 +------- internal/agents/ready.go | 21 ++++++++++++--------- internal/agents/workflows.go | 4 ++-- internal/store/store.go | 6 +----- internal/store/workflow.go | 2 +- 6 files changed, 20 insertions(+), 27 deletions(-) diff --git a/internal/agents/audit.go b/internal/agents/audit.go index 2ce731d..00510cd 100644 --- a/internal/agents/audit.go +++ b/internal/agents/audit.go @@ -115,7 +115,7 @@ func (m *Manager) auditError(ctx context.Context, task api.Task, msg string) { func auditBrief(branch, commit string) string { return fmt.Sprintf("Audit of %s at %s. This isn't a review of a change: check the whole codebase as it stands against the project's standards and expectations, and fix what falls short. "+ "byre runs this task in the background: the human doesn't see it until you ask a question, call ready_for_merge or hit an error. "+ - "Finish your stage with finish_stage as usual, even when you found nothing. Once the workflow has finished, byre asks whether the task is ready to merge; "+ + "Finish your stage with finish_stage as usual, even when you found nothing. Once your stage has finished and its review has come back, byre asks whether the task is ready to merge;"+ "if nothing needed changing, answer with suggest_discard, and byre discards the task without bothering the human.", branch, commit) } @@ -129,12 +129,12 @@ func (m *Manager) dismissAudit(taskID string) { if !task.Background || task.Status != api.TaskStatusActive { return } - finished, err := m.store.WorkflowFinished(ctx, *task.WorkflowRun) + output, _, err := m.store.StageResult(ctx, taskID) if err != nil { slog.Error("dismiss audit", "task", taskID, "err", err) return } - if !finished { + if output == "" { return } _, err = m.Discard(ctx, task, "byre") diff --git a/internal/agents/lead.go b/internal/agents/lead.go index 7a8cf7b..43696a7 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -33,7 +33,7 @@ const leadGuidance = `You are the human's lead agent in byre, shown in the left - Dig into context with list_tasks, read_transcript, read_inbox and read_status (vendor rate limits and agent health), and with your own Read, Grep and Bash on a task's worktree path (from list_tasks). Don't edit files in task worktrees; ask the task's agent with send_message. - You can create tasks, start TODO items, message, interrupt and compact task agents, and merge or discard tasks. Merge pushes to the default branch and Discard destroys work: do either only when the human asks for it. - Projects: when the human asks you to add a project, create it with create_project from its name and the path of its git repository on this machine. -- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and the workflow finishes once every stage has; byre then discards the run's other stage tasks, leaving the task of each last stage (one per branch when it ends in a parallel) to merge. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". +- Workflows: you manage the server's workflows with list_workflows, read_workflow, save_workflow and delete_workflow, and run one by giving its name to create_task or start_todo. A workflow is a tree of steps run in order, each a stage, an order or a parallel; every stage runs as its own task on the stage's model once the step before it has finished, so stages fan out (a parallel after a stage) and fan in (a stage after a parallel); a stage's agent calls finish_stage to hand its output to the stages after it, signals tell you when a stage starts and when the workflow finishes, and the workflow finishes once every stage has; byre then discards the run's other stage tasks, leaving the task of each last stage (one per branch when it ends in a parallel) to merge. Each last stage's agent is asked whether its task is ready to merge as soon as its own stage has finished and been reviewed, without waiting for the other last stages. Whenever you show or propose a workflow, draw it as a Mermaid flowchart of its stage names only, with no models, for example a fenced mermaid block holding "flowchart LR", "Build --> Review", "Build --> Test", "Review --> Fix" and "Test --> Fix". - Whenever you name a task in a message, link it as a Markdown link to #/task/, for example [Fix login redirect](#/task/tskDa7e4K7305e). byre draws it in that task's color, and clicking it shows the task in the right pane. To open a specific tab, append it: #/task//. When you point the human at a question to answer or a finished turn, link to #/task//transcript. - Keep messages short; the human reads them in half the screen. Put long detail in collapsed sections: a line
Title, the Markdown content, then a line
. - You are long-running. Your context window is large. byre sends you a signal once your context passes a quarter of its window; then call compact_self at a logical point (a piece of work wrapped up, a topic closed), and not before; when the turn ends, byre replaces you with a fresh session that starts from your handoff. @@ -366,12 +366,6 @@ func (m *Manager) Observe(events []api.Event) { if to, _ := ev.Payload["to"].(string); ev.Kind == "task.state" && readyState(api.TaskState(to)) && ev.TaskId != nil { go m.promptReady(*ev.TaskId, ev.Payload["from"]) } - if ev.Kind == "task.workflow_finished" && ev.TaskId != nil { - kept, _ := ev.Payload["kept"].([]string) - for _, id := range kept { - go m.promptReady(id, nil) - } - } switch { case (ev.Kind == "task.merged" || ev.Kind == "task.merged_to_main" || ev.Kind == "task.discarded") && ev.ProjectId != nil: go m.audit(*ev.ProjectId) diff --git a/internal/agents/ready.go b/internal/agents/ready.go index ebdabd2..d07c782 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -171,19 +171,22 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview } stagePrompt := "" if task.WorkflowStage != nil { + frozen, err := m.store.TaskWorkflow(ctx, taskID) + if err != nil { + return err + } + output, _, err := m.store.StageResult(ctx, taskID) + if err != nil { + return err + } + if output != "" && len(workflow.Next(frozen.Definition, *task.WorkflowStage)) > 0 { + return nil + } finished, err := m.store.WorkflowFinished(ctx, *task.WorkflowRun) if err != nil { return err } - if !finished { - output, _, err := m.store.StageResult(ctx, taskID) - if err != nil || output != "" { - return err - } - frozen, err := m.store.TaskWorkflow(ctx, taskID) - if err != nil { - return err - } + if output == "" && !finished { stagePrompt = finishStagePrompt(frozen, *task.WorkflowStage) } } diff --git a/internal/agents/workflows.go b/internal/agents/workflows.go index 342786e..b9329d4 100644 --- a/internal/agents/workflows.go +++ b/internal/agents/workflows.go @@ -689,9 +689,9 @@ func quoteList(names []string) string { func lastStageText(def api.WorkflowDefinition) string { ends := workflow.Ends(def) if len(ends) == 1 { - return "This is the workflow's last stage: the workflow finishes when this turn ends, and byre discards its other stage tasks." + return "This is the workflow's last stage: the workflow finishes when this turn ends, byre discards its other stage tasks, and once the review of your latest work has come back, byre asks whether this task is ready to merge." } - return fmt.Sprintf("This is one of the workflow's last stages (%s): the workflow finishes once all of them have, and byre then discards its other stage tasks, keeping each last stage's task.", quoteList(ends)) + return fmt.Sprintf("This is one of the workflow's last stages (%s): once the review of your latest work has come back, byre asks whether this task is ready to merge, without waiting for the others. The workflow finishes once all of them have, and byre then discards its other stage tasks, keeping each last stage's task.", quoteList(ends)) } func stageGuidance(frozen store.FrozenWorkflow, stage string) string { diff --git a/internal/store/store.go b/internal/store/store.go index 62b9398..da80e71 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -816,11 +816,7 @@ func (s *Store) ReadyPromptDue(ctx context.Context, id string) (bool, error) { WHERE t.id = ? AND e.task_id = t.id AND e.seq > t.ready_prompt_seq AND ((e.kind = 'snapshot.created' AND json_extract(e.payload, '$.trigger') NOT IN (?, ?, ?)) OR (e.kind = 'user.message' AND e.actor != ? AND e.actor NOT LIKE '% (lead)' AND json_extract(e.payload, '$.bounce') IS NOT 1)) - ) OR EXISTS ( - SELECT 1 FROM events e, tasks t, tasks r - WHERE t.id = ? AND t.workflow_run != '' AND r.workflow_run = t.workflow_run AND e.task_id = r.id - AND e.kind = 'task.workflow_finished' AND e.seq > t.ready_prompt_seq - )`, id, string(api.SnapshotTriggerAnswer), string(api.SnapshotTriggerFork), string(api.SnapshotTriggerAskHuman), agentActor, id).Scan(&due) + )`, id, string(api.SnapshotTriggerAnswer), string(api.SnapshotTriggerFork), string(api.SnapshotTriggerAskHuman), agentActor).Scan(&due) return due, err } diff --git a/internal/store/workflow.go b/internal/store/workflow.go index dc6e846..6a71701 100644 --- a/internal/store/workflow.go +++ b/internal/store/workflow.go @@ -204,7 +204,7 @@ func (s *Store) RunEvents(ctx context.Context, run string) ([]api.Event, error) func (s *Store) RecordStage(ctx context.Context, taskID, output, commit string) error { return s.write(ctx, func(tx *sql.Tx, emit emitFunc) error { - _, err := tx.ExecContext(ctx, `UPDATE tasks SET stage_output = ?, stage_commit = ? WHERE id = ?`, output, commit, taskID) + _, err := tx.ExecContext(ctx, `UPDATE tasks SET stage_output = ?, stage_commit = ?, ready_prompt_seq = 0 WHERE id = ?`, output, commit, taskID) if err != nil { return err }