2580 lines
67 KiB
Go
2580 lines
67 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"ai-workflow-skill/internal/protocol"
|
|
)
|
|
|
|
var ErrRunNotFound = errors.New("run not found")
|
|
var ErrTaskNotFound = errors.New("task not found")
|
|
|
|
type OrchStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
type Run struct {
|
|
RunID string `json:"run_id"`
|
|
Goal string `json:"goal"`
|
|
Summary string `json:"summary"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type Task struct {
|
|
RunID string `json:"run_id"`
|
|
TaskID string `json:"task_id"`
|
|
Title string `json:"title"`
|
|
Summary string `json:"summary"`
|
|
Status string `json:"status"`
|
|
DefaultTo string `json:"default_to,omitempty"`
|
|
Priority string `json:"priority"`
|
|
AcceptanceJSON json.RawMessage `json:"acceptance_json"`
|
|
LatestAttemptNo int `json:"latest_attempt_no,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type TaskDependency struct {
|
|
RunID string `json:"run_id"`
|
|
TaskID string `json:"task_id"`
|
|
DependsOnTaskID string `json:"depends_on_task_id"`
|
|
}
|
|
|
|
type TaskAttempt struct {
|
|
RunID string `json:"run_id"`
|
|
TaskID string `json:"task_id"`
|
|
AttemptNo int `json:"attempt_no"`
|
|
AssignedTo string `json:"assigned_to"`
|
|
ThreadID string `json:"thread_id"`
|
|
BaseRef string `json:"base_ref,omitempty"`
|
|
BaseCommit string `json:"base_commit,omitempty"`
|
|
BranchName string `json:"branch_name,omitempty"`
|
|
WorktreePath string `json:"worktree_path,omitempty"`
|
|
WorkspaceStatus string `json:"workspace_status,omitempty"`
|
|
ResultCommit string `json:"result_commit,omitempty"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type RunOverview struct {
|
|
Run Run `json:"run"`
|
|
TaskCounts map[string]int `json:"task_counts"`
|
|
Tasks []Task `json:"tasks,omitempty"`
|
|
}
|
|
|
|
type CreateRunInput struct {
|
|
RunID string
|
|
Goal string
|
|
Summary string
|
|
}
|
|
|
|
type AddTaskInput struct {
|
|
RunID string
|
|
TaskID string
|
|
Title string
|
|
Summary string
|
|
DefaultTo string
|
|
AcceptanceJSON string
|
|
Priority string
|
|
}
|
|
|
|
type AddDependencyInput struct {
|
|
RunID string
|
|
TaskID string
|
|
DependsOnTaskID string
|
|
}
|
|
|
|
type ListReadyInput struct {
|
|
RunID string
|
|
Limit int
|
|
}
|
|
|
|
type DispatchInput struct {
|
|
RunID string
|
|
TaskID string
|
|
ToAgent string
|
|
Body string
|
|
BaseRef string
|
|
PrepareWorkspace DispatchWorkspacePreparer
|
|
}
|
|
|
|
type DispatchResult struct {
|
|
Task Task `json:"task"`
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
Thread Thread `json:"thread"`
|
|
Message Message `json:"message"`
|
|
}
|
|
|
|
type ReconcileResult struct {
|
|
Run Run `json:"run"`
|
|
TaskCounts map[string]int `json:"task_counts"`
|
|
UpdatedTasks []Task `json:"updated_tasks"`
|
|
}
|
|
|
|
type RunEvent struct {
|
|
EventID int64 `json:"event_id"`
|
|
Type string `json:"type"`
|
|
RunID string `json:"run_id"`
|
|
TaskID string `json:"task_id"`
|
|
ThreadID string `json:"thread_id,omitempty"`
|
|
Summary string `json:"summary"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type WaitInput struct {
|
|
RunID string
|
|
EventTypes []string
|
|
AfterEventID int64
|
|
Timeout time.Duration
|
|
}
|
|
|
|
type WaitResult struct {
|
|
Woke bool `json:"woke"`
|
|
NextEventID int64 `json:"next_event_id"`
|
|
Events []RunEvent `json:"events,omitempty"`
|
|
}
|
|
|
|
type DispatchWorkspace struct {
|
|
BaseRef string `json:"base_ref,omitempty"`
|
|
BaseCommit string `json:"base_commit,omitempty"`
|
|
BranchName string `json:"branch_name,omitempty"`
|
|
WorktreePath string `json:"worktree_path,omitempty"`
|
|
WorkspaceStatus string `json:"workspace_status,omitempty"`
|
|
}
|
|
|
|
type DispatchWorkspacePreparer func(task Task, attemptNo int) (DispatchWorkspace, func(), error)
|
|
|
|
type BlockedTask struct {
|
|
Task Task `json:"task"`
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
Question Message `json:"question"`
|
|
}
|
|
|
|
type AnswerInput struct {
|
|
RunID string
|
|
TaskID string
|
|
Body string
|
|
PayloadJSON string
|
|
}
|
|
|
|
type AnswerResult struct {
|
|
Task Task `json:"task"`
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
Thread Thread `json:"thread"`
|
|
Message Message `json:"message"`
|
|
}
|
|
|
|
type RetryInput struct {
|
|
RunID string
|
|
TaskID string
|
|
ToAgent string
|
|
Body string
|
|
PrepareWorkspace DispatchWorkspacePreparer
|
|
}
|
|
|
|
type RetryResult struct {
|
|
Task Task `json:"task"`
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
Thread Thread `json:"thread"`
|
|
Message Message `json:"message"`
|
|
PreviousAttempt TaskAttempt `json:"previous_attempt"`
|
|
}
|
|
|
|
type ReassignInput struct {
|
|
RunID string
|
|
TaskID string
|
|
ToAgent string
|
|
Reason string
|
|
PrepareWorkspace DispatchWorkspacePreparer
|
|
}
|
|
|
|
type ReassignResult struct {
|
|
Task Task `json:"task"`
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
Thread Thread `json:"thread"`
|
|
Message Message `json:"message"`
|
|
PreviousAttempt TaskAttempt `json:"previous_attempt"`
|
|
}
|
|
|
|
type CancelControlInput struct {
|
|
RunID string
|
|
TaskID string
|
|
Reason string
|
|
}
|
|
|
|
type CancelResult struct {
|
|
Run Run `json:"run"`
|
|
CancelledTasks []Task `json:"cancelled_tasks"`
|
|
}
|
|
|
|
type CleanupInput struct {
|
|
RunID string
|
|
TaskID string
|
|
AttemptNo int
|
|
AllCompleted bool
|
|
Force bool
|
|
}
|
|
|
|
type CleanupCandidate struct {
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
}
|
|
|
|
type CleanupRecord struct {
|
|
Attempt TaskAttempt `json:"attempt"`
|
|
}
|
|
|
|
func NewOrchStore(db *sql.DB) *OrchStore {
|
|
return &OrchStore{db: db}
|
|
}
|
|
|
|
func (s *OrchStore) CreateRun(ctx context.Context, input CreateRunInput) (Run, error) {
|
|
runID := strings.TrimSpace(input.RunID)
|
|
goal := strings.TrimSpace(input.Goal)
|
|
if runID == "" {
|
|
return Run{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if goal == "" {
|
|
return Run{}, fmt.Errorf("%w: goal is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
run := Run{
|
|
RunID: runID,
|
|
Goal: goal,
|
|
Summary: strings.TrimSpace(input.Summary),
|
|
Status: "active",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Run{}, fmt.Errorf("begin create run transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO runs (run_id, goal, summary, status, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
run.RunID,
|
|
run.Goal,
|
|
run.Summary,
|
|
run.Status,
|
|
formatTime(run.CreatedAt),
|
|
formatTime(run.UpdatedAt),
|
|
)
|
|
if err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return Run{}, fmt.Errorf("%w: run %s already exists", ErrInvalidState, run.RunID)
|
|
}
|
|
return Run{}, fmt.Errorf("insert run: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: run.RunID,
|
|
TaskID: "",
|
|
Source: "orch",
|
|
EventType: "run_initialized",
|
|
Summary: defaultString(run.Summary, run.Goal),
|
|
PayloadJSON: marshalJSON(map[string]any{"goal": run.Goal, "summary": run.Summary}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return Run{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return Run{}, fmt.Errorf("commit create run transaction: %w", err)
|
|
}
|
|
|
|
return run, nil
|
|
}
|
|
|
|
func (s *OrchStore) GetRun(ctx context.Context, runID string) (Run, error) {
|
|
return selectRun(ctx, s.db, runID)
|
|
}
|
|
|
|
func (s *OrchStore) AddTask(ctx context.Context, input AddTaskInput) (Task, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return Task{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return Task{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.Title) == "" {
|
|
return Task{}, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
|
}
|
|
|
|
priority, err := normalizePriority(input.Priority)
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
acceptanceJSON, err := validateAndNormalizeJSONDefault("acceptance-json", input.AcceptanceJSON, "[]")
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("begin add task transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO tasks (
|
|
run_id, task_id, title, summary, status, default_to, priority,
|
|
acceptance_json, latest_attempt_no, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
input.RunID,
|
|
input.TaskID,
|
|
input.Title,
|
|
input.Summary,
|
|
"planned",
|
|
nullIfEmpty(input.DefaultTo),
|
|
priority,
|
|
acceptanceJSON,
|
|
formatTime(now),
|
|
formatTime(now),
|
|
)
|
|
if err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return Task{}, fmt.Errorf("%w: task %s already exists in run %s", ErrInvalidState, input.TaskID, input.RunID)
|
|
}
|
|
return Task{}, fmt.Errorf("insert task: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: input.RunID,
|
|
TaskID: input.TaskID,
|
|
Source: "orch",
|
|
EventType: "task_added",
|
|
Summary: input.Title,
|
|
PayloadJSON: marshalJSON(map[string]any{"title": input.Title, "priority": priority}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil {
|
|
return Task{}, err
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, input.RunID, now); err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return Task{}, fmt.Errorf("commit add task transaction: %w", err)
|
|
}
|
|
|
|
return task, nil
|
|
}
|
|
|
|
func (s *OrchStore) AddDependency(ctx context.Context, input AddDependencyInput) (TaskDependency, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return TaskDependency{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return TaskDependency{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.DependsOnTaskID) == "" {
|
|
return TaskDependency{}, fmt.Errorf("%w: depends-on task id is required", ErrInvalidInput)
|
|
}
|
|
if input.TaskID == input.DependsOnTaskID {
|
|
return TaskDependency{}, fmt.Errorf("%w: task cannot depend on itself", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return TaskDependency{}, fmt.Errorf("begin add dependency transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
if _, err := selectTask(ctx, tx, input.RunID, input.TaskID); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
if _, err := selectTask(ctx, tx, input.RunID, input.DependsOnTaskID); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO task_dependencies (run_id, task_id, depends_on_task_id)
|
|
VALUES (?, ?, ?)`,
|
|
input.RunID,
|
|
input.TaskID,
|
|
input.DependsOnTaskID,
|
|
)
|
|
if err != nil {
|
|
if isUniqueConstraintError(err) {
|
|
return TaskDependency{}, fmt.Errorf("%w: dependency %s -> %s already exists", ErrInvalidState, input.TaskID, input.DependsOnTaskID)
|
|
}
|
|
return TaskDependency{}, fmt.Errorf("insert dependency: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: input.RunID,
|
|
TaskID: input.TaskID,
|
|
Source: "orch",
|
|
EventType: "task_dependency_added",
|
|
Summary: fmt.Sprintf("%s depends on %s", input.TaskID, input.DependsOnTaskID),
|
|
PayloadJSON: marshalJSON(map[string]any{"depends_on_task_id": input.DependsOnTaskID}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
|
|
if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, input.RunID, now); err != nil {
|
|
return TaskDependency{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return TaskDependency{}, fmt.Errorf("commit add dependency transaction: %w", err)
|
|
}
|
|
|
|
return TaskDependency{
|
|
RunID: input.RunID,
|
|
TaskID: input.TaskID,
|
|
DependsOnTaskID: input.DependsOnTaskID,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) ListReadyTasks(ctx context.Context, input ListReadyInput) ([]Task, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
limit := input.Limit
|
|
if limit <= 0 {
|
|
limit = 20
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin list ready transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := refreshReadyStates(ctx, tx, input.RunID, nowUTC()); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, input.RunID, nowUTC()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := tx.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
run_id, task_id, title, summary, status, default_to, priority,
|
|
acceptance_json, latest_attempt_no, created_at, updated_at
|
|
FROM tasks
|
|
WHERE run_id = ? AND status = 'ready'
|
|
ORDER BY CASE priority
|
|
WHEN 'high' THEN 0
|
|
WHEN 'normal' THEN 1
|
|
ELSE 2
|
|
END, created_at ASC
|
|
LIMIT ?`,
|
|
input.RunID,
|
|
limit,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query ready tasks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tasks []Task
|
|
for rows.Next() {
|
|
task, err := scanTask(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate ready tasks: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit list ready transaction: %w", err)
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
func (s *OrchStore) DispatchTask(ctx context.Context, input DispatchInput) (DispatchResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return DispatchResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return DispatchResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return DispatchResult{}, fmt.Errorf("begin dispatch transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return DispatchResult{}, err
|
|
}
|
|
if err := refreshReadyStates(ctx, tx, input.RunID, now); err != nil {
|
|
return DispatchResult{}, err
|
|
}
|
|
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return DispatchResult{}, err
|
|
}
|
|
if task.Status != "ready" {
|
|
return DispatchResult{}, fmt.Errorf("%w: task %s is not ready for dispatch", ErrInvalidState, task.TaskID)
|
|
}
|
|
|
|
result, finalizeWorkspace, err := s.dispatchTaskTx(ctx, tx, task, strings.TrimSpace(input.ToAgent), input.Body, strings.TrimSpace(input.BaseRef), input.PrepareWorkspace, now)
|
|
if err != nil {
|
|
return DispatchResult{}, err
|
|
}
|
|
workspaceCommitted := false
|
|
defer func() {
|
|
finalizeWorkspace(workspaceCommitted)
|
|
}()
|
|
|
|
if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil {
|
|
return DispatchResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return DispatchResult{}, fmt.Errorf("commit dispatch transaction: %w", err)
|
|
}
|
|
workspaceCommitted = true
|
|
return result, nil
|
|
}
|
|
|
|
func (s *OrchStore) GetTaskWithLatestAttempt(ctx context.Context, runID, taskID string) (Task, *TaskAttempt, error) {
|
|
task, err := selectTask(ctx, s.db, runID, taskID)
|
|
if err != nil {
|
|
return Task{}, nil, err
|
|
}
|
|
if task.LatestAttemptNo == 0 {
|
|
return task, nil, nil
|
|
}
|
|
|
|
attempt, err := selectAttempt(ctx, s.db, runID, taskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return Task{}, nil, err
|
|
}
|
|
return task, &attempt, nil
|
|
}
|
|
|
|
func (s *OrchStore) RetryTask(ctx context.Context, input RetryInput) (RetryResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return RetryResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return RetryResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return RetryResult{}, fmt.Errorf("begin retry transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
if task.Status != "failed" {
|
|
return RetryResult{}, fmt.Errorf("%w: task %s is not failed", ErrInvalidState, task.TaskID)
|
|
}
|
|
if task.LatestAttemptNo == 0 {
|
|
return RetryResult{}, fmt.Errorf("%w: task %s has no attempt to retry", ErrInvalidState, task.TaskID)
|
|
}
|
|
|
|
previousAttempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
|
|
result, finalizeWorkspace, err := s.dispatchTaskTx(
|
|
ctx,
|
|
tx,
|
|
task,
|
|
strings.TrimSpace(input.ToAgent),
|
|
input.Body,
|
|
defaultString(previousAttempt.BaseRef, previousAttempt.BaseCommit),
|
|
input.PrepareWorkspace,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
workspaceCommitted := false
|
|
defer func() {
|
|
finalizeWorkspace(workspaceCommitted)
|
|
}()
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET workspace_status = CASE
|
|
WHEN workspace_status = 'cleaned' THEN workspace_status
|
|
ELSE ?
|
|
END,
|
|
updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
"abandoned",
|
|
formatTime(now),
|
|
previousAttempt.RunID,
|
|
previousAttempt.TaskID,
|
|
previousAttempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return RetryResult{}, fmt.Errorf("mark previous retry attempt abandoned: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
ThreadID: result.Thread.ThreadID,
|
|
Source: "orch",
|
|
EventType: "task_retried",
|
|
MessageID: result.Message.MessageID,
|
|
Summary: result.Message.Summary,
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"previous_attempt_no": previousAttempt.AttemptNo,
|
|
"previous_thread_id": previousAttempt.ThreadID,
|
|
"attempt_no": result.Attempt.AttemptNo,
|
|
"thread_id": result.Attempt.ThreadID,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
|
|
if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil {
|
|
return RetryResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return RetryResult{}, fmt.Errorf("commit retry transaction: %w", err)
|
|
}
|
|
workspaceCommitted = true
|
|
|
|
return RetryResult{
|
|
Task: result.Task,
|
|
Attempt: result.Attempt,
|
|
Thread: result.Thread,
|
|
Message: result.Message,
|
|
PreviousAttempt: previousAttempt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) ReassignTask(ctx context.Context, input ReassignInput) (ReassignResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return ReassignResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return ReassignResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.ToAgent) == "" {
|
|
return ReassignResult{}, fmt.Errorf("%w: destination agent is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ReassignResult{}, fmt.Errorf("begin reassign transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, input.RunID); err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
if task.Status != "blocked" && task.Status != "failed" {
|
|
return ReassignResult{}, fmt.Errorf("%w: task %s is not blocked or failed", ErrInvalidState, task.TaskID)
|
|
}
|
|
if task.LatestAttemptNo == 0 {
|
|
return ReassignResult{}, fmt.Errorf("%w: task %s has no attempt to reassign", ErrInvalidState, task.TaskID)
|
|
}
|
|
|
|
previousAttempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
|
|
if task.Status == "blocked" && previousAttempt.ThreadID != "" {
|
|
thread, err := selectThread(ctx, tx, previousAttempt.ThreadID)
|
|
if err != nil && !errors.Is(err, ErrThreadNotFound) {
|
|
return ReassignResult{}, err
|
|
}
|
|
if err == nil && !isTerminalStatus(thread.Status) {
|
|
if err := cancelThreadTx(ctx, tx, thread, defaultString(input.Reason, "task reassigned"), now); err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
}
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET status = ?, workspace_status = CASE
|
|
WHEN workspace_status = 'cleaned' THEN workspace_status
|
|
ELSE ?
|
|
END,
|
|
updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
"cancelled",
|
|
"abandoned",
|
|
formatTime(now),
|
|
previousAttempt.RunID,
|
|
previousAttempt.TaskID,
|
|
previousAttempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return ReassignResult{}, fmt.Errorf("mark previous blocked attempt abandoned: %w", err)
|
|
}
|
|
} else {
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET workspace_status = CASE
|
|
WHEN workspace_status = 'cleaned' THEN workspace_status
|
|
ELSE ?
|
|
END,
|
|
updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
"abandoned",
|
|
formatTime(now),
|
|
previousAttempt.RunID,
|
|
previousAttempt.TaskID,
|
|
previousAttempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return ReassignResult{}, fmt.Errorf("mark previous attempt abandoned: %w", err)
|
|
}
|
|
}
|
|
|
|
result, finalizeWorkspace, err := s.dispatchTaskTx(
|
|
ctx,
|
|
tx,
|
|
task,
|
|
strings.TrimSpace(input.ToAgent),
|
|
input.Reason,
|
|
defaultString(previousAttempt.BaseRef, previousAttempt.BaseCommit),
|
|
input.PrepareWorkspace,
|
|
now,
|
|
)
|
|
if err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
workspaceCommitted := false
|
|
defer func() {
|
|
finalizeWorkspace(workspaceCommitted)
|
|
}()
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
ThreadID: result.Thread.ThreadID,
|
|
Source: "orch",
|
|
EventType: "task_reassigned",
|
|
MessageID: result.Message.MessageID,
|
|
Summary: defaultString(input.Reason, result.Message.Summary),
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"previous_attempt_no": previousAttempt.AttemptNo,
|
|
"previous_thread_id": previousAttempt.ThreadID,
|
|
"from_agent": previousAttempt.AssignedTo,
|
|
"to_agent": result.Attempt.AssignedTo,
|
|
"attempt_no": result.Attempt.AttemptNo,
|
|
"thread_id": result.Attempt.ThreadID,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
|
|
if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil {
|
|
return ReassignResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return ReassignResult{}, fmt.Errorf("commit reassign transaction: %w", err)
|
|
}
|
|
workspaceCommitted = true
|
|
|
|
return ReassignResult{
|
|
Task: result.Task,
|
|
Attempt: result.Attempt,
|
|
Thread: result.Thread,
|
|
Message: result.Message,
|
|
PreviousAttempt: previousAttempt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) Cancel(ctx context.Context, input CancelControlInput) (CancelResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return CancelResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return CancelResult{}, fmt.Errorf("begin cancel transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
run, err := selectRun(ctx, tx, input.RunID)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
|
|
var tasks []Task
|
|
if strings.TrimSpace(input.TaskID) != "" {
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
tasks = append(tasks, task)
|
|
} else {
|
|
tasks, err = listTasksForRun(ctx, tx, input.RunID)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
}
|
|
|
|
cancelledTasks := make([]Task, 0, len(tasks))
|
|
for _, task := range tasks {
|
|
if task.Status == "cancelled" {
|
|
if strings.TrimSpace(input.TaskID) != "" {
|
|
return CancelResult{}, fmt.Errorf("%w: task %s is already cancelled", ErrInvalidState, task.TaskID)
|
|
}
|
|
continue
|
|
}
|
|
|
|
cancelledTask, err := cancelTaskTx(ctx, tx, task, defaultString(input.Reason, "task cancelled"), now)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
cancelledTasks = append(cancelledTasks, cancelledTask)
|
|
}
|
|
|
|
if len(cancelledTasks) == 0 && len(tasks) == 0 {
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE runs SET status = ?, updated_at = ? WHERE run_id = ?`,
|
|
"cancelled",
|
|
formatTime(now),
|
|
run.RunID,
|
|
)
|
|
if err != nil {
|
|
return CancelResult{}, fmt.Errorf("cancel empty run: %w", err)
|
|
}
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: run.RunID,
|
|
Source: "orch",
|
|
EventType: "run_cancelled",
|
|
Summary: defaultString(input.Reason, "run cancelled"),
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"task_id": input.TaskID,
|
|
"reason": input.Reason,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
|
|
if err := updateRunAggregateStatus(ctx, tx, run.RunID, now); err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
|
|
run, err = selectRun(ctx, tx, run.RunID)
|
|
if err != nil {
|
|
return CancelResult{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return CancelResult{}, fmt.Errorf("commit cancel transaction: %w", err)
|
|
}
|
|
|
|
return CancelResult{
|
|
Run: run,
|
|
CancelledTasks: cancelledTasks,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) ListCleanupCandidates(ctx context.Context, input CleanupInput) ([]CleanupCandidate, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if input.AttemptNo > 0 && strings.TrimSpace(input.TaskID) == "" {
|
|
return nil, fmt.Errorf("%w: task id is required when attempt is specified", ErrInvalidInput)
|
|
}
|
|
if !input.AllCompleted && strings.TrimSpace(input.TaskID) == "" && input.AttemptNo == 0 {
|
|
return nil, fmt.Errorf("%w: specify --task, --attempt, or --all-completed", ErrInvalidInput)
|
|
}
|
|
|
|
if _, err := s.GetRun(ctx, input.RunID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
conditions := []string{"run_id = ?", "worktree_path <> ''", "workspace_status <> 'cleaned'"}
|
|
args := []any{input.RunID}
|
|
if strings.TrimSpace(input.TaskID) != "" {
|
|
conditions = append(conditions, "task_id = ?")
|
|
args = append(args, strings.TrimSpace(input.TaskID))
|
|
}
|
|
if input.AttemptNo > 0 {
|
|
conditions = append(conditions, "attempt_no = ?")
|
|
args = append(args, input.AttemptNo)
|
|
}
|
|
if !input.Force {
|
|
conditions = append(conditions, "workspace_status IN (?, ?)")
|
|
args = append(args, "completed", "abandoned")
|
|
}
|
|
|
|
query := `SELECT
|
|
run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit,
|
|
branch_name, worktree_path, workspace_status, result_commit, status,
|
|
created_at, updated_at
|
|
FROM task_attempts
|
|
WHERE ` + strings.Join(conditions, " AND ") + `
|
|
ORDER BY run_id, task_id, attempt_no ASC`
|
|
|
|
rows, err := s.db.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query cleanup candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var candidates []CleanupCandidate
|
|
for rows.Next() {
|
|
attempt, err := scanAttempt(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
candidates = append(candidates, CleanupCandidate{Attempt: attempt})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate cleanup candidates: %w", err)
|
|
}
|
|
|
|
if len(candidates) == 0 {
|
|
return nil, protocol.NoMatchingWork("no cleanup candidates matched the requested filters")
|
|
}
|
|
return candidates, nil
|
|
}
|
|
|
|
func (s *OrchStore) MarkAttemptsCleaned(ctx context.Context, records []CleanupRecord) ([]TaskAttempt, error) {
|
|
if len(records) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin cleanup commit transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
cleaned := make([]TaskAttempt, 0, len(records))
|
|
for _, record := range records {
|
|
attempt := record.Attempt
|
|
_, err := tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET workspace_status = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
"cleaned",
|
|
formatTime(now),
|
|
attempt.RunID,
|
|
attempt.TaskID,
|
|
attempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("mark attempt cleaned: %w", err)
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: attempt.RunID,
|
|
TaskID: attempt.TaskID,
|
|
ThreadID: attempt.ThreadID,
|
|
Source: "orch",
|
|
EventType: "workspace_cleaned",
|
|
Summary: fmt.Sprintf("cleaned workspace for %s/%s attempt %d", attempt.RunID, attempt.TaskID, attempt.AttemptNo),
|
|
PayloadJSON: marshalJSON(map[string]any{
|
|
"attempt_no": attempt.AttemptNo,
|
|
"worktree_path": attempt.WorktreePath,
|
|
}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
attempt.WorkspaceStatus = "cleaned"
|
|
attempt.UpdatedAt = now
|
|
cleaned = append(cleaned, attempt)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit cleanup transaction: %w", err)
|
|
}
|
|
|
|
return cleaned, nil
|
|
}
|
|
|
|
func (s *OrchStore) dispatchTaskTx(
|
|
ctx context.Context,
|
|
tx *sql.Tx,
|
|
task Task,
|
|
toAgent string,
|
|
body string,
|
|
baseRef string,
|
|
prepareWorkspace DispatchWorkspacePreparer,
|
|
now time.Time,
|
|
) (DispatchResult, func(bool), error) {
|
|
assignedTo := defaultString(strings.TrimSpace(toAgent), task.DefaultTo)
|
|
if assignedTo == "" {
|
|
return DispatchResult{}, nil, fmt.Errorf("%w: dispatch target agent is required", ErrInvalidInput)
|
|
}
|
|
|
|
attemptNo := task.LatestAttemptNo + 1
|
|
workspace := DispatchWorkspace{
|
|
BaseRef: strings.TrimSpace(baseRef),
|
|
}
|
|
finalizeWorkspace := func(success bool) {}
|
|
if prepareWorkspace != nil {
|
|
cleanupWorkspace := func() {}
|
|
var err error
|
|
workspace, cleanupWorkspace, err = prepareWorkspace(task, attemptNo)
|
|
if err != nil {
|
|
return DispatchResult{}, nil, err
|
|
}
|
|
if cleanupWorkspace == nil {
|
|
cleanupWorkspace = func() {}
|
|
}
|
|
finalizeWorkspace = func(success bool) {
|
|
if !success {
|
|
cleanupWorkspace()
|
|
}
|
|
}
|
|
}
|
|
|
|
threadID := newID("thr")
|
|
messageID := newID("msg")
|
|
payloadJSON := buildDispatchPayload(task, attemptNo, workspace)
|
|
thread := Thread{
|
|
ThreadID: threadID,
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
Subject: task.Title,
|
|
CreatedBy: "orch",
|
|
AssignedTo: assignedTo,
|
|
Status: "pending",
|
|
Priority: task.Priority,
|
|
LatestMessageID: messageID,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
_, err := tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO threads (
|
|
thread_id, run_id, task_id, subject, created_by, assigned_to, status,
|
|
priority, latest_message_id, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
thread.ThreadID,
|
|
thread.RunID,
|
|
thread.TaskID,
|
|
thread.Subject,
|
|
thread.CreatedBy,
|
|
thread.AssignedTo,
|
|
thread.Status,
|
|
thread.Priority,
|
|
thread.LatestMessageID,
|
|
formatTime(thread.CreatedAt),
|
|
formatTime(thread.UpdatedAt),
|
|
)
|
|
if err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, fmt.Errorf("insert dispatch thread: %w", err)
|
|
}
|
|
|
|
message := Message{
|
|
MessageID: messageID,
|
|
ThreadID: threadID,
|
|
FromAgent: "orch",
|
|
ToAgent: assignedTo,
|
|
Kind: "task",
|
|
Summary: defaultString(task.Summary, task.Title),
|
|
Body: body,
|
|
PayloadJSON: json.RawMessage(payloadJSON),
|
|
CreatedAt: now,
|
|
}
|
|
if err := insertMessage(ctx, tx, message); err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, err
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: thread.RunID,
|
|
TaskID: thread.TaskID,
|
|
ThreadID: thread.ThreadID,
|
|
Source: "inbox",
|
|
EventType: "thread_created",
|
|
MessageID: message.MessageID,
|
|
Summary: message.Summary,
|
|
PayloadJSON: payloadJSON,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, err
|
|
}
|
|
|
|
attempt := TaskAttempt{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
AttemptNo: attemptNo,
|
|
AssignedTo: assignedTo,
|
|
ThreadID: threadID,
|
|
BaseRef: workspace.BaseRef,
|
|
BaseCommit: workspace.BaseCommit,
|
|
BranchName: workspace.BranchName,
|
|
WorktreePath: workspace.WorktreePath,
|
|
WorkspaceStatus: workspace.WorkspaceStatus,
|
|
Status: "dispatched",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`INSERT INTO task_attempts (
|
|
run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit,
|
|
branch_name, worktree_path, workspace_status, result_commit, status,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
attempt.RunID,
|
|
attempt.TaskID,
|
|
attempt.AttemptNo,
|
|
attempt.AssignedTo,
|
|
attempt.ThreadID,
|
|
nullIfEmpty(attempt.BaseRef),
|
|
nullIfEmpty(attempt.BaseCommit),
|
|
nullIfEmpty(attempt.BranchName),
|
|
nullIfEmpty(attempt.WorktreePath),
|
|
nullIfEmpty(attempt.WorkspaceStatus),
|
|
nil,
|
|
attempt.Status,
|
|
formatTime(attempt.CreatedAt),
|
|
formatTime(attempt.UpdatedAt),
|
|
)
|
|
if err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, fmt.Errorf("insert task attempt: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE tasks
|
|
SET status = ?, latest_attempt_no = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
"dispatched",
|
|
attempt.AttemptNo,
|
|
formatTime(now),
|
|
task.RunID,
|
|
task.TaskID,
|
|
)
|
|
if err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, fmt.Errorf("update task dispatch status: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
ThreadID: thread.ThreadID,
|
|
Source: "orch",
|
|
EventType: "task_dispatched",
|
|
MessageID: message.MessageID,
|
|
Summary: message.Summary,
|
|
PayloadJSON: payloadJSON,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return DispatchResult{}, finalizeWorkspace, err
|
|
}
|
|
|
|
task.Status = "dispatched"
|
|
task.LatestAttemptNo = attempt.AttemptNo
|
|
task.UpdatedAt = now
|
|
|
|
return DispatchResult{
|
|
Task: task,
|
|
Attempt: attempt,
|
|
Thread: thread,
|
|
Message: message,
|
|
}, finalizeWorkspace, nil
|
|
}
|
|
|
|
func cancelTaskTx(ctx context.Context, tx *sql.Tx, task Task, reason string, now time.Time) (Task, error) {
|
|
if task.LatestAttemptNo > 0 {
|
|
attempt, err := selectAttempt(ctx, tx, task.RunID, task.TaskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return Task{}, err
|
|
}
|
|
if attempt.ThreadID != "" {
|
|
thread, err := selectThread(ctx, tx, attempt.ThreadID)
|
|
if err != nil && !errors.Is(err, ErrThreadNotFound) {
|
|
return Task{}, err
|
|
}
|
|
if err == nil && !isTerminalStatus(thread.Status) {
|
|
if err := cancelThreadTx(ctx, tx, thread, reason, now); err != nil {
|
|
return Task{}, err
|
|
}
|
|
}
|
|
}
|
|
|
|
attemptStatus := attempt.Status
|
|
if attemptStatus != "done" && attemptStatus != "failed" && attemptStatus != "cancelled" {
|
|
attemptStatus = "cancelled"
|
|
}
|
|
workspaceStatus := attempt.WorkspaceStatus
|
|
if workspaceStatus != "cleaned" {
|
|
workspaceStatus = "abandoned"
|
|
}
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET status = ?, workspace_status = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
attemptStatus,
|
|
nullIfEmpty(workspaceStatus),
|
|
formatTime(now),
|
|
attempt.RunID,
|
|
attempt.TaskID,
|
|
attempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("update cancelled attempt: %w", err)
|
|
}
|
|
}
|
|
|
|
_, err := tx.ExecContext(
|
|
ctx,
|
|
`UPDATE tasks
|
|
SET status = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
"cancelled",
|
|
formatTime(now),
|
|
task.RunID,
|
|
task.TaskID,
|
|
)
|
|
if err != nil {
|
|
return Task{}, fmt.Errorf("update cancelled task: %w", err)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
Source: "orch",
|
|
EventType: "task_cancelled",
|
|
Summary: defaultString(reason, "task cancelled"),
|
|
PayloadJSON: marshalJSON(map[string]any{"reason": reason}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return Task{}, err
|
|
}
|
|
|
|
task.Status = "cancelled"
|
|
task.UpdatedAt = now
|
|
return task, nil
|
|
}
|
|
|
|
func cancelThreadTx(ctx context.Context, tx *sql.Tx, thread Thread, reason string, now time.Time) error {
|
|
messageID := newID("msg")
|
|
summary := defaultString(reason, "thread cancelled")
|
|
message := Message{
|
|
MessageID: messageID,
|
|
ThreadID: thread.ThreadID,
|
|
FromAgent: "orch",
|
|
ToAgent: thread.AssignedTo,
|
|
Kind: "control",
|
|
Summary: summary,
|
|
Body: reason,
|
|
PayloadJSON: json.RawMessage(`{}`),
|
|
CreatedAt: now,
|
|
}
|
|
|
|
if err := insertMessage(ctx, tx, message); err != nil {
|
|
return err
|
|
}
|
|
if err := updateThreadState(ctx, tx, thread.ThreadID, "cancelled", thread.AssignedTo, message.MessageID, now); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
`UPDATE leases
|
|
SET released_at = ?
|
|
WHERE thread_id = ?
|
|
AND released_at IS NULL`,
|
|
formatTime(now),
|
|
thread.ThreadID,
|
|
); err != nil {
|
|
return fmt.Errorf("release lease on orch cancel: %w", err)
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: thread.RunID,
|
|
TaskID: thread.TaskID,
|
|
ThreadID: thread.ThreadID,
|
|
Source: "inbox",
|
|
EventType: "thread_cancelled",
|
|
MessageID: message.MessageID,
|
|
Summary: message.Summary,
|
|
PayloadJSON: string(message.PayloadJSON),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *OrchStore) ReconcileRun(ctx context.Context, runID string) (ReconcileResult, error) {
|
|
if strings.TrimSpace(runID) == "" {
|
|
return ReconcileResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("begin reconcile transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, runID); err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
|
|
rows, err := tx.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
t.task_id,
|
|
t.status,
|
|
a.attempt_no,
|
|
a.status,
|
|
a.thread_id,
|
|
th.status
|
|
FROM tasks t
|
|
JOIN task_attempts a
|
|
ON a.run_id = t.run_id
|
|
AND a.task_id = t.task_id
|
|
AND a.attempt_no = t.latest_attempt_no
|
|
JOIN threads th ON th.thread_id = a.thread_id
|
|
WHERE t.run_id = ?
|
|
AND t.latest_attempt_no IS NOT NULL`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("query reconcile candidates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var updatedIDs []string
|
|
for rows.Next() {
|
|
var (
|
|
taskID string
|
|
taskStatus string
|
|
attemptNo int
|
|
attemptStatus string
|
|
threadID string
|
|
threadStatus string
|
|
)
|
|
if err := rows.Scan(&taskID, &taskStatus, &attemptNo, &attemptStatus, &threadID, &threadStatus); err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("scan reconcile candidate: %w", err)
|
|
}
|
|
|
|
nextStatus := reconcileTaskStatus(threadStatus)
|
|
if nextStatus == "" {
|
|
continue
|
|
}
|
|
if nextStatus == taskStatus && nextStatus == attemptStatus {
|
|
continue
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE tasks
|
|
SET status = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
nextStatus,
|
|
formatTime(now),
|
|
runID,
|
|
taskID,
|
|
)
|
|
if err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("update reconciled task status: %w", err)
|
|
}
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET status = ?, workspace_status = COALESCE(?, workspace_status), updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
nextStatus,
|
|
nullIfEmpty(reconcileWorkspaceStatus(threadStatus)),
|
|
formatTime(now),
|
|
runID,
|
|
taskID,
|
|
attemptNo,
|
|
)
|
|
if err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("update reconciled attempt status: %w", err)
|
|
}
|
|
|
|
summary := fmt.Sprintf("%s -> %s", taskID, nextStatus)
|
|
payloadJSON := marshalJSON(map[string]any{
|
|
"thread_id": threadID,
|
|
"thread_status": threadStatus,
|
|
"previous_status": taskStatus,
|
|
"previous_attempt": attemptStatus,
|
|
})
|
|
if nextStatus == "blocked" {
|
|
question, err := selectLatestQuestionMessage(ctx, tx, threadID)
|
|
if err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
summary = question.Summary
|
|
payloadJSON = string(question.PayloadJSON)
|
|
}
|
|
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
TaskID: taskID,
|
|
ThreadID: threadID,
|
|
Source: "orch",
|
|
EventType: "task_" + nextStatus,
|
|
Summary: summary,
|
|
PayloadJSON: payloadJSON,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
|
|
updatedIDs = append(updatedIDs, taskID)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("iterate reconcile candidates: %w", err)
|
|
}
|
|
|
|
if err := refreshReadyStates(ctx, tx, runID, now); err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
|
|
run, err := selectRun(ctx, tx, runID)
|
|
if err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
taskCounts, err := collectTaskCounts(ctx, tx, runID)
|
|
if err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
|
|
updatedTasks := make([]Task, 0, len(updatedIDs))
|
|
for _, taskID := range updatedIDs {
|
|
task, err := selectTask(ctx, tx, runID, taskID)
|
|
if err != nil {
|
|
return ReconcileResult{}, err
|
|
}
|
|
updatedTasks = append(updatedTasks, task)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return ReconcileResult{}, fmt.Errorf("commit reconcile transaction: %w", err)
|
|
}
|
|
|
|
return ReconcileResult{
|
|
Run: run,
|
|
TaskCounts: taskCounts,
|
|
UpdatedTasks: updatedTasks,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) ListBlockedTasks(ctx context.Context, runID string) ([]BlockedTask, error) {
|
|
if strings.TrimSpace(runID) == "" {
|
|
return nil, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("begin list blocked transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, runID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows, err := tx.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
t.run_id, t.task_id, t.title, t.summary, t.status, t.default_to, t.priority,
|
|
t.acceptance_json, t.latest_attempt_no, t.created_at, t.updated_at,
|
|
a.run_id, a.task_id, a.attempt_no, a.assigned_to, a.thread_id, a.base_ref,
|
|
a.base_commit, a.branch_name, a.worktree_path, a.workspace_status,
|
|
a.result_commit, a.status, a.created_at, a.updated_at
|
|
FROM tasks t
|
|
JOIN task_attempts a
|
|
ON a.run_id = t.run_id
|
|
AND a.task_id = t.task_id
|
|
AND a.attempt_no = t.latest_attempt_no
|
|
WHERE t.run_id = ?
|
|
AND t.status = 'blocked'
|
|
ORDER BY t.updated_at ASC`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query blocked tasks: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var blocked []BlockedTask
|
|
for rows.Next() {
|
|
task, attempt, err := scanTaskAndAttempt(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
question, err := selectLatestQuestionMessage(ctx, tx, attempt.ThreadID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocked = append(blocked, BlockedTask{
|
|
Task: task,
|
|
Attempt: attempt,
|
|
Question: question,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate blocked tasks: %w", err)
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("commit list blocked transaction: %w", err)
|
|
}
|
|
|
|
return blocked, nil
|
|
}
|
|
|
|
func (s *OrchStore) AnswerTask(ctx context.Context, input AnswerInput) (AnswerResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return AnswerResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
if strings.TrimSpace(input.TaskID) == "" {
|
|
return AnswerResult{}, fmt.Errorf("%w: task id is required", ErrInvalidInput)
|
|
}
|
|
|
|
payloadJSON, err := validateAndNormalizeJSON("payload-json", input.PayloadJSON)
|
|
if err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if strings.TrimSpace(input.Body) == "" && payloadJSON == "{}" {
|
|
return AnswerResult{}, fmt.Errorf("%w: body or payload-json is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return AnswerResult{}, fmt.Errorf("begin answer transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
task, err := selectTask(ctx, tx, input.RunID, input.TaskID)
|
|
if err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if task.Status != "blocked" {
|
|
return AnswerResult{}, fmt.Errorf("%w: task %s is not blocked", ErrInvalidState, task.TaskID)
|
|
}
|
|
if task.LatestAttemptNo == 0 {
|
|
return AnswerResult{}, fmt.Errorf("%w: task %s has no active attempt", ErrInvalidState, task.TaskID)
|
|
}
|
|
|
|
attempt, err := selectAttempt(ctx, tx, input.RunID, input.TaskID, task.LatestAttemptNo)
|
|
if err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
thread, err := selectThread(ctx, tx, attempt.ThreadID)
|
|
if err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if isTerminalStatus(thread.Status) {
|
|
return AnswerResult{}, fmt.Errorf("%w: thread %s is already terminal", ErrInvalidState, thread.ThreadID)
|
|
}
|
|
|
|
message := Message{
|
|
MessageID: newID("msg"),
|
|
ThreadID: thread.ThreadID,
|
|
FromAgent: "orch",
|
|
ToAgent: attempt.AssignedTo,
|
|
Kind: "answer",
|
|
Summary: summarizeAnswer(input.Body),
|
|
Body: input.Body,
|
|
PayloadJSON: json.RawMessage(payloadJSON),
|
|
CreatedAt: now,
|
|
}
|
|
if err := insertMessage(ctx, tx, message); err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if err := updateThreadState(ctx, tx, thread.ThreadID, thread.Status, thread.AssignedTo, message.MessageID, now); err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: thread.RunID,
|
|
TaskID: thread.TaskID,
|
|
ThreadID: thread.ThreadID,
|
|
Source: "inbox",
|
|
EventType: "thread_reply",
|
|
MessageID: message.MessageID,
|
|
Summary: message.Summary,
|
|
PayloadJSON: payloadJSON,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: task.RunID,
|
|
TaskID: task.TaskID,
|
|
ThreadID: thread.ThreadID,
|
|
Source: "orch",
|
|
EventType: "task_answered",
|
|
MessageID: message.MessageID,
|
|
Summary: message.Summary,
|
|
PayloadJSON: payloadJSON,
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE tasks
|
|
SET updated_at = ?
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
formatTime(now),
|
|
task.RunID,
|
|
task.TaskID,
|
|
)
|
|
if err != nil {
|
|
return AnswerResult{}, fmt.Errorf("touch answered task: %w", err)
|
|
}
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE task_attempts
|
|
SET updated_at = ?
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
formatTime(now),
|
|
attempt.RunID,
|
|
attempt.TaskID,
|
|
attempt.AttemptNo,
|
|
)
|
|
if err != nil {
|
|
return AnswerResult{}, fmt.Errorf("touch answered attempt: %w", err)
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, task.RunID, now); err != nil {
|
|
return AnswerResult{}, err
|
|
}
|
|
|
|
task.UpdatedAt = now
|
|
attempt.UpdatedAt = now
|
|
thread.LatestMessageID = message.MessageID
|
|
thread.UpdatedAt = now
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return AnswerResult{}, fmt.Errorf("commit answer transaction: %w", err)
|
|
}
|
|
|
|
return AnswerResult{
|
|
Task: task,
|
|
Attempt: attempt,
|
|
Thread: thread,
|
|
Message: message,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) GetRunOverview(ctx context.Context, runID string) (RunOverview, error) {
|
|
if strings.TrimSpace(runID) == "" {
|
|
return RunOverview{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
now := nowUTC()
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return RunOverview{}, fmt.Errorf("begin run overview transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := selectRun(ctx, tx, runID); err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
if err := refreshReadyStates(ctx, tx, runID, now); err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
if err := updateRunAggregateStatus(ctx, tx, runID, now); err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
|
|
run, err := selectRun(ctx, tx, runID)
|
|
if err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
taskCounts, err := collectTaskCounts(ctx, tx, runID)
|
|
if err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
tasks, err := listTasksForRun(ctx, tx, runID)
|
|
if err != nil {
|
|
return RunOverview{}, err
|
|
}
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
return RunOverview{}, fmt.Errorf("commit run overview transaction: %w", err)
|
|
}
|
|
|
|
return RunOverview{
|
|
Run: run,
|
|
TaskCounts: taskCounts,
|
|
Tasks: tasks,
|
|
}, nil
|
|
}
|
|
|
|
func (s *OrchStore) WaitForEvents(ctx context.Context, input WaitInput) (WaitResult, error) {
|
|
if strings.TrimSpace(input.RunID) == "" {
|
|
return WaitResult{}, fmt.Errorf("%w: run id is required", ErrInvalidInput)
|
|
}
|
|
|
|
eventTypes := normalizeWaitEventTypes(input.EventTypes)
|
|
if _, err := s.GetRun(ctx, input.RunID); err != nil {
|
|
return WaitResult{}, err
|
|
}
|
|
|
|
cursor := input.AfterEventID
|
|
waitCtx := ctx
|
|
cancel := func() {}
|
|
if input.Timeout > 0 {
|
|
waitCtx, cancel = context.WithTimeout(ctx, input.Timeout)
|
|
}
|
|
defer cancel()
|
|
|
|
for {
|
|
events, nextEventID, found, err := s.findRunEventsAfter(waitCtx, input.RunID, cursor, eventTypes)
|
|
if err != nil {
|
|
if isDeadlineExceeded(waitCtx) {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
return WaitResult{}, err
|
|
}
|
|
if found {
|
|
return WaitResult{
|
|
Woke: true,
|
|
NextEventID: nextEventID,
|
|
Events: events,
|
|
}, nil
|
|
}
|
|
|
|
if _, err := s.ReconcileRun(waitCtx, input.RunID); err != nil {
|
|
if isSQLiteBusyError(err) {
|
|
ok, waitErr := waitForNextPoll(waitCtx, 25*time.Millisecond)
|
|
if waitErr != nil {
|
|
if errors.Is(waitErr, context.DeadlineExceeded) {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
return WaitResult{}, waitErr
|
|
}
|
|
if !ok {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
continue
|
|
}
|
|
if isDeadlineExceeded(waitCtx) {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
return WaitResult{}, err
|
|
}
|
|
|
|
events, nextEventID, found, err = s.findRunEventsAfter(waitCtx, input.RunID, cursor, eventTypes)
|
|
if err != nil {
|
|
if isDeadlineExceeded(waitCtx) {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
return WaitResult{}, err
|
|
}
|
|
if found {
|
|
return WaitResult{
|
|
Woke: true,
|
|
NextEventID: nextEventID,
|
|
Events: events,
|
|
}, nil
|
|
}
|
|
|
|
ok, err := waitForNextPoll(waitCtx, 200*time.Millisecond)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
return WaitResult{}, err
|
|
}
|
|
if !ok {
|
|
return WaitResult{Woke: false, NextEventID: cursor}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func listTasksForRun(ctx context.Context, db queryRowsContexter, runID string) ([]Task, error) {
|
|
rows, err := db.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
run_id, task_id, title, summary, status, default_to, priority,
|
|
acceptance_json, latest_attempt_no, created_at, updated_at
|
|
FROM tasks
|
|
WHERE run_id = ?
|
|
ORDER BY created_at ASC`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query tasks for run: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var tasks []Task
|
|
for rows.Next() {
|
|
task, err := scanTask(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tasks = append(tasks, task)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate tasks for run: %w", err)
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
func (s *OrchStore) findRunEventsAfter(ctx context.Context, runID string, afterEventID int64, eventTypes []string) ([]RunEvent, int64, bool, error) {
|
|
args := []any{runID, afterEventID}
|
|
query := `SELECT
|
|
event_id, event_type, run_id, task_id, thread_id, summary, payload_json, created_at
|
|
FROM events
|
|
WHERE run_id = ?
|
|
AND event_id > ?`
|
|
if len(eventTypes) > 0 {
|
|
query += " AND event_type IN (" + placeholders(len(eventTypes)) + ")"
|
|
for _, eventType := range eventTypes {
|
|
args = append(args, eventType)
|
|
}
|
|
}
|
|
query += " ORDER BY event_id ASC LIMIT 1"
|
|
|
|
row := s.db.QueryRowContext(ctx, query, args...)
|
|
|
|
var (
|
|
event RunEvent
|
|
threadID sql.NullString
|
|
payload string
|
|
createdAt string
|
|
)
|
|
err := row.Scan(
|
|
&event.EventID,
|
|
&event.Type,
|
|
&event.RunID,
|
|
&event.TaskID,
|
|
&threadID,
|
|
&event.Summary,
|
|
&payload,
|
|
&createdAt,
|
|
)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, 0, false, nil
|
|
}
|
|
if err != nil {
|
|
return nil, 0, false, fmt.Errorf("query run events after %d: %w", afterEventID, err)
|
|
}
|
|
|
|
if threadID.Valid {
|
|
event.ThreadID = threadID.String
|
|
}
|
|
event.Payload = json.RawMessage(payload)
|
|
event.CreatedAt = parseTime(createdAt)
|
|
|
|
return []RunEvent{event}, event.EventID, true, nil
|
|
}
|
|
|
|
type queryRowsContexter interface {
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
}
|
|
|
|
func scanRun(scanner threadScanner) (Run, error) {
|
|
var (
|
|
run Run
|
|
createdAt, updated string
|
|
)
|
|
|
|
if err := scanner.Scan(
|
|
&run.RunID,
|
|
&run.Goal,
|
|
&run.Summary,
|
|
&run.Status,
|
|
&createdAt,
|
|
&updated,
|
|
); err != nil {
|
|
return Run{}, fmt.Errorf("scan run: %w", err)
|
|
}
|
|
|
|
run.CreatedAt = parseTime(createdAt)
|
|
run.UpdatedAt = parseTime(updated)
|
|
return run, nil
|
|
}
|
|
|
|
func scanTask(scanner threadScanner) (Task, error) {
|
|
var (
|
|
task Task
|
|
defaultTo sql.NullString
|
|
latestAttempt sql.NullInt64
|
|
acceptanceJSON string
|
|
createdAt, updatedAt string
|
|
)
|
|
|
|
if err := scanner.Scan(
|
|
&task.RunID,
|
|
&task.TaskID,
|
|
&task.Title,
|
|
&task.Summary,
|
|
&task.Status,
|
|
&defaultTo,
|
|
&task.Priority,
|
|
&acceptanceJSON,
|
|
&latestAttempt,
|
|
&createdAt,
|
|
&updatedAt,
|
|
); err != nil {
|
|
return Task{}, fmt.Errorf("scan task: %w", err)
|
|
}
|
|
|
|
task.DefaultTo = defaultTo.String
|
|
task.AcceptanceJSON = json.RawMessage(acceptanceJSON)
|
|
if latestAttempt.Valid {
|
|
task.LatestAttemptNo = int(latestAttempt.Int64)
|
|
}
|
|
task.CreatedAt = parseTime(createdAt)
|
|
task.UpdatedAt = parseTime(updatedAt)
|
|
return task, nil
|
|
}
|
|
|
|
func scanAttempt(scanner threadScanner) (TaskAttempt, error) {
|
|
var (
|
|
attempt TaskAttempt
|
|
baseRef sql.NullString
|
|
baseCommit sql.NullString
|
|
branchName sql.NullString
|
|
worktreePath sql.NullString
|
|
workspaceStatus sql.NullString
|
|
resultCommit sql.NullString
|
|
createdAt, updated string
|
|
)
|
|
|
|
if err := scanner.Scan(
|
|
&attempt.RunID,
|
|
&attempt.TaskID,
|
|
&attempt.AttemptNo,
|
|
&attempt.AssignedTo,
|
|
&attempt.ThreadID,
|
|
&baseRef,
|
|
&baseCommit,
|
|
&branchName,
|
|
&worktreePath,
|
|
&workspaceStatus,
|
|
&resultCommit,
|
|
&attempt.Status,
|
|
&createdAt,
|
|
&updated,
|
|
); err != nil {
|
|
return TaskAttempt{}, fmt.Errorf("scan attempt: %w", err)
|
|
}
|
|
|
|
attempt.BaseRef = baseRef.String
|
|
attempt.BaseCommit = baseCommit.String
|
|
attempt.BranchName = branchName.String
|
|
attempt.WorktreePath = worktreePath.String
|
|
attempt.WorkspaceStatus = workspaceStatus.String
|
|
attempt.ResultCommit = resultCommit.String
|
|
attempt.CreatedAt = parseTime(createdAt)
|
|
attempt.UpdatedAt = parseTime(updated)
|
|
return attempt, nil
|
|
}
|
|
|
|
func scanTaskAndAttempt(scanner threadScanner) (Task, TaskAttempt, error) {
|
|
var (
|
|
task Task
|
|
taskDefaultTo sql.NullString
|
|
taskLatestAttempt sql.NullInt64
|
|
taskAcceptanceJSON string
|
|
taskCreatedAt string
|
|
taskUpdatedAt string
|
|
attempt TaskAttempt
|
|
attemptBaseRef sql.NullString
|
|
attemptBaseCommit sql.NullString
|
|
attemptBranchName sql.NullString
|
|
attemptWorktreePath sql.NullString
|
|
attemptWorkspaceState sql.NullString
|
|
attemptResultCommit sql.NullString
|
|
attemptCreatedAt string
|
|
attemptUpdatedAt string
|
|
)
|
|
|
|
if err := scanner.Scan(
|
|
&task.RunID,
|
|
&task.TaskID,
|
|
&task.Title,
|
|
&task.Summary,
|
|
&task.Status,
|
|
&taskDefaultTo,
|
|
&task.Priority,
|
|
&taskAcceptanceJSON,
|
|
&taskLatestAttempt,
|
|
&taskCreatedAt,
|
|
&taskUpdatedAt,
|
|
&attempt.RunID,
|
|
&attempt.TaskID,
|
|
&attempt.AttemptNo,
|
|
&attempt.AssignedTo,
|
|
&attempt.ThreadID,
|
|
&attemptBaseRef,
|
|
&attemptBaseCommit,
|
|
&attemptBranchName,
|
|
&attemptWorktreePath,
|
|
&attemptWorkspaceState,
|
|
&attemptResultCommit,
|
|
&attempt.Status,
|
|
&attemptCreatedAt,
|
|
&attemptUpdatedAt,
|
|
); err != nil {
|
|
return Task{}, TaskAttempt{}, fmt.Errorf("scan task and attempt: %w", err)
|
|
}
|
|
|
|
task.DefaultTo = taskDefaultTo.String
|
|
task.AcceptanceJSON = json.RawMessage(taskAcceptanceJSON)
|
|
if taskLatestAttempt.Valid {
|
|
task.LatestAttemptNo = int(taskLatestAttempt.Int64)
|
|
}
|
|
task.CreatedAt = parseTime(taskCreatedAt)
|
|
task.UpdatedAt = parseTime(taskUpdatedAt)
|
|
|
|
attempt.BaseRef = attemptBaseRef.String
|
|
attempt.BaseCommit = attemptBaseCommit.String
|
|
attempt.BranchName = attemptBranchName.String
|
|
attempt.WorktreePath = attemptWorktreePath.String
|
|
attempt.WorkspaceStatus = attemptWorkspaceState.String
|
|
attempt.ResultCommit = attemptResultCommit.String
|
|
attempt.CreatedAt = parseTime(attemptCreatedAt)
|
|
attempt.UpdatedAt = parseTime(attemptUpdatedAt)
|
|
|
|
return task, attempt, nil
|
|
}
|
|
|
|
func selectRun(ctx context.Context, db queryRower, runID string) (Run, error) {
|
|
row := db.QueryRowContext(
|
|
ctx,
|
|
`SELECT run_id, goal, summary, status, created_at, updated_at
|
|
FROM runs
|
|
WHERE run_id = ?`,
|
|
runID,
|
|
)
|
|
run, err := scanRun(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Run{}, fmt.Errorf("%w: %s", ErrRunNotFound, runID)
|
|
}
|
|
return run, err
|
|
}
|
|
|
|
func selectTask(ctx context.Context, db queryRower, runID, taskID string) (Task, error) {
|
|
row := db.QueryRowContext(
|
|
ctx,
|
|
`SELECT
|
|
run_id, task_id, title, summary, status, default_to, priority,
|
|
acceptance_json, latest_attempt_no, created_at, updated_at
|
|
FROM tasks
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
runID,
|
|
taskID,
|
|
)
|
|
task, err := scanTask(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Task{}, fmt.Errorf("%w: %s/%s", ErrTaskNotFound, runID, taskID)
|
|
}
|
|
return task, err
|
|
}
|
|
|
|
func selectAttempt(ctx context.Context, db queryRower, runID, taskID string, attemptNo int) (TaskAttempt, error) {
|
|
row := db.QueryRowContext(
|
|
ctx,
|
|
`SELECT
|
|
run_id, task_id, attempt_no, assigned_to, thread_id, base_ref, base_commit,
|
|
branch_name, worktree_path, workspace_status, result_commit, status,
|
|
created_at, updated_at
|
|
FROM task_attempts
|
|
WHERE run_id = ? AND task_id = ? AND attempt_no = ?`,
|
|
runID,
|
|
taskID,
|
|
attemptNo,
|
|
)
|
|
attempt, err := scanAttempt(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return TaskAttempt{}, fmt.Errorf("%w: attempt %s/%s/%d not found", ErrInvalidState, runID, taskID, attemptNo)
|
|
}
|
|
return attempt, err
|
|
}
|
|
|
|
func selectLatestQuestionMessage(ctx context.Context, db queryRowsAndRower, threadID string) (Message, error) {
|
|
row := db.QueryRowContext(
|
|
ctx,
|
|
`SELECT
|
|
message_id, thread_id, from_agent, to_agent, kind, summary, body,
|
|
payload_json, created_at
|
|
FROM messages
|
|
WHERE thread_id = ? AND kind = 'question'
|
|
ORDER BY created_at DESC
|
|
LIMIT 1`,
|
|
threadID,
|
|
)
|
|
message, err := scanMessage(row)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return Message{}, fmt.Errorf("%w: blocked thread %s has no question message", ErrInvalidState, threadID)
|
|
}
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
artifactsByMessageID, err := loadArtifactsForMessageIDsFromQueryer(ctx, db, []string{message.MessageID})
|
|
if err != nil {
|
|
return Message{}, err
|
|
}
|
|
message.Artifacts = artifactsByMessageID[message.MessageID]
|
|
return message, nil
|
|
}
|
|
|
|
type queryRowsAndRower interface {
|
|
queryRower
|
|
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
|
}
|
|
|
|
func loadArtifactsForMessageIDsFromQueryer(ctx context.Context, db queryRowsContexter, messageIDs []string) (map[string][]Artifact, error) {
|
|
result := make(map[string][]Artifact)
|
|
if len(messageIDs) == 0 {
|
|
return result, nil
|
|
}
|
|
|
|
args := make([]any, 0, len(messageIDs))
|
|
for _, messageID := range messageIDs {
|
|
args = append(args, messageID)
|
|
}
|
|
|
|
rows, err := db.QueryContext(
|
|
ctx,
|
|
`SELECT
|
|
artifact_id, message_id, path, kind, metadata_json, created_at
|
|
FROM artifacts
|
|
WHERE message_id IN (`+placeholders(len(messageIDs))+`)
|
|
ORDER BY created_at ASC`,
|
|
args...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query artifacts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
artifact, err := scanArtifact(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result[artifact.MessageID] = append(result[artifact.MessageID], artifact)
|
|
}
|
|
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate artifacts: %w", err)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func refreshReadyStates(ctx context.Context, tx *sql.Tx, runID string, now time.Time) error {
|
|
rows, err := tx.QueryContext(
|
|
ctx,
|
|
`SELECT task_id, status, title
|
|
FROM tasks
|
|
WHERE run_id = ?
|
|
AND status IN ('planned', 'ready')`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("query tasks for readiness refresh: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
type readinessRow struct {
|
|
taskID string
|
|
status string
|
|
title string
|
|
}
|
|
|
|
var tasks []readinessRow
|
|
for rows.Next() {
|
|
var row readinessRow
|
|
if err := rows.Scan(&row.taskID, &row.status, &row.title); err != nil {
|
|
return fmt.Errorf("scan readiness refresh row: %w", err)
|
|
}
|
|
tasks = append(tasks, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate readiness refresh rows: %w", err)
|
|
}
|
|
|
|
for _, task := range tasks {
|
|
ready, err := dependenciesSatisfied(ctx, tx, runID, task.taskID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
desired := "planned"
|
|
if ready {
|
|
desired = "ready"
|
|
}
|
|
if desired == task.status {
|
|
continue
|
|
}
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE tasks
|
|
SET status = ?, updated_at = ?
|
|
WHERE run_id = ? AND task_id = ?`,
|
|
desired,
|
|
formatTime(now),
|
|
runID,
|
|
task.taskID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update task readiness: %w", err)
|
|
}
|
|
|
|
if desired == "ready" {
|
|
if err := insertEvent(ctx, tx, eventInput{
|
|
RunID: runID,
|
|
TaskID: task.taskID,
|
|
Source: "orch",
|
|
EventType: "task_ready",
|
|
Summary: defaultString(task.title, task.taskID),
|
|
PayloadJSON: marshalJSON(map[string]any{"task_id": task.taskID}),
|
|
CreatedAt: now,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func dependenciesSatisfied(ctx context.Context, tx *sql.Tx, runID, taskID string) (bool, error) {
|
|
var pendingCount int
|
|
err := tx.QueryRowContext(
|
|
ctx,
|
|
`SELECT COUNT(*)
|
|
FROM task_dependencies d
|
|
JOIN tasks dep
|
|
ON dep.run_id = d.run_id
|
|
AND dep.task_id = d.depends_on_task_id
|
|
WHERE d.run_id = ?
|
|
AND d.task_id = ?
|
|
AND dep.status <> 'done'`,
|
|
runID,
|
|
taskID,
|
|
).Scan(&pendingCount)
|
|
if err != nil {
|
|
return false, fmt.Errorf("query dependency readiness: %w", err)
|
|
}
|
|
return pendingCount == 0, nil
|
|
}
|
|
|
|
func updateRunAggregateStatus(ctx context.Context, tx *sql.Tx, runID string, now time.Time) error {
|
|
counts, err := collectTaskCounts(ctx, tx, runID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nextStatus := deriveRunStatus(counts)
|
|
|
|
_, err = tx.ExecContext(
|
|
ctx,
|
|
`UPDATE runs
|
|
SET status = ?, updated_at = ?
|
|
WHERE run_id = ?`,
|
|
nextStatus,
|
|
formatTime(now),
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update run aggregate status: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func collectTaskCounts(ctx context.Context, db queryRowsContexter, runID string) (map[string]int, error) {
|
|
rows, err := db.QueryContext(
|
|
ctx,
|
|
`SELECT status, COUNT(*)
|
|
FROM tasks
|
|
WHERE run_id = ?
|
|
GROUP BY status`,
|
|
runID,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query task counts: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
counts := make(map[string]int)
|
|
for rows.Next() {
|
|
var (
|
|
status string
|
|
count int
|
|
)
|
|
if err := rows.Scan(&status, &count); err != nil {
|
|
return nil, fmt.Errorf("scan task count: %w", err)
|
|
}
|
|
counts[status] = count
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("iterate task counts: %w", err)
|
|
}
|
|
|
|
return counts, nil
|
|
}
|
|
|
|
func deriveRunStatus(counts map[string]int) string {
|
|
total := 0
|
|
for _, count := range counts {
|
|
total += count
|
|
}
|
|
if total == 0 {
|
|
return "active"
|
|
}
|
|
if counts["blocked"] > 0 {
|
|
return "blocked"
|
|
}
|
|
if counts["failed"] > 0 {
|
|
return "failed"
|
|
}
|
|
if counts["running"] > 0 || counts["dispatched"] > 0 {
|
|
return "running"
|
|
}
|
|
if counts["ready"] > 0 {
|
|
return "ready"
|
|
}
|
|
if counts["planned"] > 0 {
|
|
return "planned"
|
|
}
|
|
if counts["done"] > 0 {
|
|
return "done"
|
|
}
|
|
if counts["cancelled"] == total {
|
|
return "cancelled"
|
|
}
|
|
return "active"
|
|
}
|
|
|
|
func reconcileTaskStatus(threadStatus string) string {
|
|
switch threadStatus {
|
|
case "pending":
|
|
return "dispatched"
|
|
case "claimed", "in_progress":
|
|
return "running"
|
|
case "blocked":
|
|
return "blocked"
|
|
case "done":
|
|
return "done"
|
|
case "failed":
|
|
return "failed"
|
|
case "cancelled":
|
|
return "cancelled"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizePriority(priority string) (string, error) {
|
|
priority = defaultString(strings.TrimSpace(priority), "normal")
|
|
switch priority {
|
|
case "low", "normal", "high":
|
|
return priority, nil
|
|
default:
|
|
return "", fmt.Errorf("%w: priority must be one of low, normal, high", ErrInvalidInput)
|
|
}
|
|
}
|
|
|
|
func normalizeWaitEventTypes(eventTypes []string) []string {
|
|
if len(eventTypes) == 0 {
|
|
return []string{"task_ready", "task_blocked", "task_done", "task_failed"}
|
|
}
|
|
|
|
normalized := make([]string, 0, len(eventTypes))
|
|
seen := make(map[string]struct{}, len(eventTypes))
|
|
for _, eventType := range eventTypes {
|
|
eventType = strings.TrimSpace(eventType)
|
|
if eventType == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[eventType]; ok {
|
|
continue
|
|
}
|
|
seen[eventType] = struct{}{}
|
|
normalized = append(normalized, eventType)
|
|
}
|
|
if len(normalized) == 0 {
|
|
return []string{"task_ready", "task_blocked", "task_done", "task_failed"}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
func validateAndNormalizeJSONDefault(fieldName, value, defaultValue string) (string, error) {
|
|
normalized := strings.TrimSpace(value)
|
|
if normalized == "" {
|
|
normalized = defaultValue
|
|
}
|
|
if !json.Valid([]byte(normalized)) {
|
|
return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName)
|
|
}
|
|
|
|
var compact bytes.Buffer
|
|
if err := json.Compact(&compact, []byte(normalized)); err != nil {
|
|
return "", fmt.Errorf("%w: %s must be valid JSON", ErrInvalidInput, fieldName)
|
|
}
|
|
return compact.String(), nil
|
|
}
|
|
|
|
func buildDispatchPayload(task Task, attemptNo int, workspace DispatchWorkspace) string {
|
|
payload := map[string]any{
|
|
"run_id": task.RunID,
|
|
"task_id": task.TaskID,
|
|
"attempt_no": attemptNo,
|
|
"title": task.Title,
|
|
"summary": task.Summary,
|
|
"priority": task.Priority,
|
|
}
|
|
|
|
if len(task.AcceptanceJSON) > 0 {
|
|
var acceptance any
|
|
if err := json.Unmarshal(task.AcceptanceJSON, &acceptance); err == nil {
|
|
payload["acceptance"] = acceptance
|
|
}
|
|
}
|
|
if strings.TrimSpace(workspace.BaseRef) != "" {
|
|
payload["base_ref"] = strings.TrimSpace(workspace.BaseRef)
|
|
}
|
|
if strings.TrimSpace(workspace.BaseCommit) != "" {
|
|
payload["base_commit"] = strings.TrimSpace(workspace.BaseCommit)
|
|
}
|
|
if strings.TrimSpace(workspace.BranchName) != "" {
|
|
payload["branch_name"] = strings.TrimSpace(workspace.BranchName)
|
|
}
|
|
if strings.TrimSpace(workspace.WorktreePath) != "" {
|
|
payload["worktree_path"] = strings.TrimSpace(workspace.WorktreePath)
|
|
}
|
|
if strings.TrimSpace(workspace.WorkspaceStatus) != "" {
|
|
payload["workspace_status"] = strings.TrimSpace(workspace.WorkspaceStatus)
|
|
}
|
|
|
|
return marshalJSON(payload)
|
|
}
|
|
|
|
func marshalJSON(v any) string {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return "{}"
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
func nullIfEmpty(value string) any {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
return value
|
|
}
|
|
|
|
func summarizeAnswer(body string) string {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return "task answer"
|
|
}
|
|
line := body
|
|
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
|
|
line = line[:idx]
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
return "task answer"
|
|
}
|
|
return line
|
|
}
|
|
|
|
func reconcileWorkspaceStatus(threadStatus string) string {
|
|
switch threadStatus {
|
|
case "pending":
|
|
return "created"
|
|
case "claimed", "in_progress", "blocked":
|
|
return "active"
|
|
case "done", "failed":
|
|
return "completed"
|
|
case "cancelled":
|
|
return "abandoned"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isUniqueConstraintError(err error) bool {
|
|
return strings.Contains(strings.ToLower(err.Error()), "unique constraint failed")
|
|
}
|