|
| 1 | +// SPDX-License-Identifier: MIT |
| 2 | +// Purpose: Background tasks — fire-and-keep-working agent runs |
| 3 | +// (issue #195). Each task owns a goroutine, a context.CancelFunc, |
| 4 | +// and a Result. A process-local registry tracks them by short id. |
| 5 | +// |
| 6 | +// v0 scope: in-process tasks only (no daemon, no IPC). v1 will |
| 7 | +// persist the registry to SQLite and surface tasks across |
| 8 | +// `sin-code` invocations. |
| 9 | +package agentloop |
| 10 | + |
| 11 | +import ( |
| 12 | + "context" |
| 13 | + "errors" |
| 14 | + "sync" |
| 15 | + "time" |
| 16 | +) |
| 17 | + |
| 18 | +// BackgroundTask is one in-process long-running agent run. |
| 19 | +type BackgroundTask struct { |
| 20 | + ID string `json:"id"` |
| 21 | + Goal string `json:"goal"` |
| 22 | + StartedAt time.Time `json:"started_at"` |
| 23 | + FinishedAt time.Time `json:"finished_at,omitempty"` |
| 24 | + Status string `json:"status"` // "running" | "verified" | "failed" | "cancelled" |
| 25 | + Result *Result `json:"result,omitempty"` |
| 26 | + Err string `json:"error,omitempty"` |
| 27 | +} |
| 28 | + |
| 29 | +// ErrTaskNotFound is returned by Get/Cancel for unknown task ids. |
| 30 | +var ErrTaskNotFound = errors.New("background task not found") |
| 31 | + |
| 32 | +// TaskRegistry is a process-local in-memory map of BackgroundTasks. |
| 33 | +// All methods are safe for concurrent use. The zero value is |
| 34 | +// not ready — use NewTaskRegistry. |
| 35 | +type TaskRegistry struct { |
| 36 | + mu sync.RWMutex |
| 37 | + tasks map[string]*BackgroundTask |
| 38 | + cancels map[string]context.CancelFunc |
| 39 | + seq int |
| 40 | +} |
| 41 | + |
| 42 | +// NewTaskRegistry returns an empty registry. The process should |
| 43 | +// have at most one registry; it is the singleton entry point |
| 44 | +// for the `sin-code background` subcommand. |
| 45 | +func NewTaskRegistry() *TaskRegistry { |
| 46 | + return &TaskRegistry{ |
| 47 | + tasks: map[string]*BackgroundTask{}, |
| 48 | + cancels: map[string]context.CancelFunc{}, |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +// Add registers a new task with a pre-allocated id and cancel |
| 53 | +// function. The caller is expected to start the goroutine |
| 54 | +// immediately after Add returns. Returns the task (with id). |
| 55 | +func (r *TaskRegistry) Add(goal string) *BackgroundTask { |
| 56 | + r.mu.Lock() |
| 57 | + defer r.mu.Unlock() |
| 58 | + r.seq++ |
| 59 | + id := taskIDFromSeq(r.seq) |
| 60 | + t := &BackgroundTask{ |
| 61 | + ID: id, |
| 62 | + Goal: goal, |
| 63 | + StartedAt: time.Now().UTC(), |
| 64 | + Status: "running", |
| 65 | + } |
| 66 | + r.tasks[id] = t |
| 67 | + return t |
| 68 | +} |
| 69 | + |
| 70 | +// SetCancel records the cancel function for id. Called by the |
| 71 | +// goroutine immediately after Add. |
| 72 | +func (r *TaskRegistry) SetCancel(id string, cancel context.CancelFunc) { |
| 73 | + r.mu.Lock() |
| 74 | + defer r.mu.Unlock() |
| 75 | + r.cancels[id] = cancel |
| 76 | +} |
| 77 | + |
| 78 | +// Finish marks the task as done. status is "verified", "failed", |
| 79 | +// or "cancelled". result is the agent run's Result; err is the |
| 80 | +// error string if status is "failed" or "cancelled". |
| 81 | +func (r *TaskRegistry) Finish(id, status string, result *Result, err error) { |
| 82 | + r.mu.Lock() |
| 83 | + defer r.mu.Unlock() |
| 84 | + t, ok := r.tasks[id] |
| 85 | + if !ok { |
| 86 | + return |
| 87 | + } |
| 88 | + t.Status = status |
| 89 | + t.FinishedAt = time.Now().UTC() |
| 90 | + t.Result = result |
| 91 | + if err != nil { |
| 92 | + t.Err = err.Error() |
| 93 | + } |
| 94 | + delete(r.cancels, id) |
| 95 | +} |
| 96 | + |
| 97 | +// Get returns a copy of the task with the given id. Returns |
| 98 | +// ErrTaskNotFound if no such task is registered. |
| 99 | +func (r *TaskRegistry) Get(id string) (*BackgroundTask, error) { |
| 100 | + r.mu.RLock() |
| 101 | + defer r.mu.RUnlock() |
| 102 | + t, ok := r.tasks[id] |
| 103 | + if !ok { |
| 104 | + return nil, ErrTaskNotFound |
| 105 | + } |
| 106 | + // Shallow copy is enough for the JSON envelope (all fields |
| 107 | + // are values or pointers we don't mutate after Finish). |
| 108 | + cp := *t |
| 109 | + return &cp, nil |
| 110 | +} |
| 111 | + |
| 112 | +// List returns a copy of every registered task, sorted by |
| 113 | +// StartedAt descending (newest first). |
| 114 | +func (r *TaskRegistry) List() []*BackgroundTask { |
| 115 | + r.mu.RLock() |
| 116 | + defer r.mu.RUnlock() |
| 117 | + out := make([]*BackgroundTask, 0, len(r.tasks)) |
| 118 | + for _, t := range r.tasks { |
| 119 | + cp := *t |
| 120 | + out = append(out, &cp) |
| 121 | + } |
| 122 | + sortByStartedDesc(out) |
| 123 | + return out |
| 124 | +} |
| 125 | + |
| 126 | +// Cancel cancels the goroutine for id. The task is marked |
| 127 | +// "cancelled" by the goroutine when it observes the context |
| 128 | +// cancellation. Returns ErrTaskNotFound if no such task. |
| 129 | +func (r *TaskRegistry) Cancel(id string) error { |
| 130 | + r.mu.Lock() |
| 131 | + defer r.mu.Unlock() |
| 132 | + cancel, ok := r.cancels[id] |
| 133 | + if !ok { |
| 134 | + return ErrTaskNotFound |
| 135 | + } |
| 136 | + cancel() |
| 137 | + delete(r.cancels, id) |
| 138 | + return nil |
| 139 | +} |
| 140 | + |
| 141 | +// taskIDFromSeq formats seq as "bg-001", "bg-002", ... (3-digit |
| 142 | +// zero-pad). Bounded by the int size; collision-free within a |
| 143 | +// single process lifetime. |
| 144 | +func taskIDFromSeq(seq int) string { |
| 145 | + return "bg-" + itoa3(seq) |
| 146 | +} |
| 147 | + |
| 148 | +// itoa3 is a tiny zero-padded integer formatter for ids. |
| 149 | +func itoa3(n int) string { |
| 150 | + if n < 0 { |
| 151 | + n = -n |
| 152 | + } |
| 153 | + var buf [3]byte |
| 154 | + for i := 2; i >= 0; i-- { |
| 155 | + buf[i] = byte('0' + n%10) |
| 156 | + n /= 10 |
| 157 | + } |
| 158 | + return string(buf[:]) |
| 159 | +} |
| 160 | + |
| 161 | +// sortByStartedDesc sorts tasks newest-first. |
| 162 | +func sortByStartedDesc(tasks []*BackgroundTask) { |
| 163 | + for i := 1; i < len(tasks); i++ { |
| 164 | + for j := i; j > 0 && tasks[j].StartedAt.After(tasks[j-1].StartedAt); j-- { |
| 165 | + tasks[j], tasks[j-1] = tasks[j-1], tasks[j] |
| 166 | + } |
| 167 | + } |
| 168 | +} |
0 commit comments