From c621bbb904d3809929cbf397eb3f84dbc2921338 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 16 Jun 2026 22:53:41 +0200 Subject: [PATCH] feat(agentloop): background task registry (issue #195, v0 in-process) What ships: - cmd/sin-code/internal/agentloop/background.go (new): - BackgroundTask struct + TaskRegistry with Add/Get/List/ Finish/Cancel/SetCancel. - ErrTaskNotFound sentinel. - id format: bg-001, bg-002, ... (3-digit zero-pad). - 9 race-clean tests. Acceptance criteria (from #195): - [x] Process-local task registry. - [x] Each task owns a goroutine, a context.CancelFunc, a Result. - [x] list / get / cancel are safe for concurrent use. - [x] 50-goroutine stress test passes under -race. Hard mandates honored: - M2: no new deps. - M7: 9/9 tests pass under go test -race -count=1. What does NOT ship (deferred): - CLI surface (\) is a v0.1 follow-up; the registry is ready. - Cross-invocation persistence (the registry is in-process only). Refs: OpenSIN-Code/SIN-Code#195 --- cmd/sin-code/internal/agentloop/background.go | 164 ++++++++++++++++++ .../internal/agentloop/background_test.go | 144 +++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 cmd/sin-code/internal/agentloop/background.go create mode 100644 cmd/sin-code/internal/agentloop/background_test.go diff --git a/cmd/sin-code/internal/agentloop/background.go b/cmd/sin-code/internal/agentloop/background.go new file mode 100644 index 00000000..91448cab --- /dev/null +++ b/cmd/sin-code/internal/agentloop/background.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +// Purpose: Background tasks — fire-and-keep-working agent runs +// (issue #195). Each task owns a goroutine, a context.CancelFunc, +// and a Result. A process-local registry tracks them by short id. +// +// v0 scope: in-process tasks only (no daemon, no IPC). v1 will +// persist the registry to SQLite and surface tasks across +// `sin-code` invocations. +package agentloop + +import ( + "context" + "errors" + "sync" + "time" +) + +// BackgroundTask is one in-process long-running agent run. +type BackgroundTask struct { + ID string `json:"id"` + Goal string `json:"goal"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Status string `json:"status"` // "running" | "verified" | "failed" | "cancelled" + Result *Result `json:"result,omitempty"` + Err string `json:"error,omitempty"` +} + +// ErrTaskNotFound is returned by Get/Cancel for unknown task ids. +var ErrTaskNotFound = errors.New("background task not found") + +// TaskRegistry is a process-local in-memory map of BackgroundTasks. +// All methods are safe for concurrent use. The zero value is +// not ready — use NewTaskRegistry. +type TaskRegistry struct { + mu sync.RWMutex + tasks map[string]*BackgroundTask + cancels map[string]context.CancelFunc + seq int +} + +// NewTaskRegistry returns an empty registry. The process should +// have at most one registry; it is the singleton entry point +// for the `sin-code background` subcommand. +func NewTaskRegistry() *TaskRegistry { + return &TaskRegistry{ + tasks: map[string]*BackgroundTask{}, + cancels: map[string]context.CancelFunc{}, + } +} + +// Add registers a new task with a pre-allocated id. The caller +// is expected to start the goroutine immediately after Add returns. +// Returns the task (with id). +func (r *TaskRegistry) Add(goal string) *BackgroundTask { + r.mu.Lock() + defer r.mu.Unlock() + r.seq++ + id := taskIDFromSeq(r.seq) + t := &BackgroundTask{ + ID: id, + Goal: goal, + StartedAt: time.Now().UTC(), + Status: "running", + } + r.tasks[id] = t + return t +} + +// SetCancel records the cancel function for id. Called by the +// goroutine immediately after Add. +func (r *TaskRegistry) SetCancel(id string, cancel context.CancelFunc) { + r.mu.Lock() + defer r.mu.Unlock() + r.cancels[id] = cancel +} + +// Finish marks the task as done. status is "verified", "failed", +// or "cancelled". result is the agent run's Result; err is the +// error string if status is "failed" or "cancelled". +func (r *TaskRegistry) Finish(id, status string, result *Result, err error) { + r.mu.Lock() + defer r.mu.Unlock() + t, ok := r.tasks[id] + if !ok { + return + } + t.Status = status + t.FinishedAt = time.Now().UTC() + t.Result = result + if err != nil { + t.Err = err.Error() + } + delete(r.cancels, id) +} + +// Get returns a copy of the task with the given id. Returns +// ErrTaskNotFound if no such task is registered. +func (r *TaskRegistry) Get(id string) (*BackgroundTask, error) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tasks[id] + if !ok { + return nil, ErrTaskNotFound + } + cp := *t + return &cp, nil +} + +// List returns a copy of every registered task, sorted by +// StartedAt descending (newest first). +func (r *TaskRegistry) List() []*BackgroundTask { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*BackgroundTask, 0, len(r.tasks)) + for _, t := range r.tasks { + cp := *t + out = append(out, &cp) + } + sortByStartedDesc(out) + return out +} + +// Cancel cancels the goroutine for id. The task is marked +// "cancelled" by the goroutine when it observes the context +// cancellation. Returns ErrTaskNotFound if no such task. +func (r *TaskRegistry) Cancel(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + cancel, ok := r.cancels[id] + if !ok { + return ErrTaskNotFound + } + cancel() + delete(r.cancels, id) + return nil +} + +// taskIDFromSeq formats seq as "bg-001", "bg-002", ... +func taskIDFromSeq(seq int) string { + return "bg-" + itoa3(seq) +} + +// itoa3 is a tiny zero-padded integer formatter for ids. +func itoa3(n int) string { + if n < 0 { + n = -n + } + var buf [3]byte + for i := 2; i >= 0; i-- { + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[:]) +} + +// sortByStartedDesc sorts tasks newest-first. +func sortByStartedDesc(tasks []*BackgroundTask) { + for i := 1; i < len(tasks); i++ { + for j := i; j > 0 && tasks[j].StartedAt.After(tasks[j-1].StartedAt); j-- { + tasks[j], tasks[j-1] = tasks[j-1], tasks[j] + } + } +} diff --git a/cmd/sin-code/internal/agentloop/background_test.go b/cmd/sin-code/internal/agentloop/background_test.go new file mode 100644 index 00000000..95a5b0b5 --- /dev/null +++ b/cmd/sin-code/internal/agentloop/background_test.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +// Purpose: tests for issue #195 — background task registry. +package agentloop + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestTaskRegistry_AddGet(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("first") + if t1.ID == "" { + t.Fatal("expected non-empty id") + } + if t1.Status != "running" { + t.Errorf("expected status=running, got %q", t1.Status) + } + got, err := r.Get(t1.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != t1.ID { + t.Errorf("id mismatch: %q != %q", got.ID, t1.ID) + } +} + +func TestTaskRegistry_GetNotFound(t *testing.T) { + r := NewTaskRegistry() + _, err := r.Get("bg-999") + if !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + +func TestTaskRegistry_Finish(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("x") + res := &Result{Summary: "ok", Verified: true, Turns: 1} + r.Finish(t1.ID, "verified", res, nil) + got, _ := r.Get(t1.ID) + if got.Status != "verified" { + t.Errorf("expected status=verified, got %q", got.Status) + } + if got.Result == nil || got.Result.Summary != "ok" { + t.Errorf("expected result.summary=ok, got %+v", got.Result) + } + if got.FinishedAt.IsZero() { + t.Error("expected FinishedAt to be set after Finish") + } +} + +func TestTaskRegistry_FinishWithError(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("x") + r.Finish(t1.ID, "failed", nil, errors.New("boom")) + got, _ := r.Get(t1.ID) + if got.Status != "failed" { + t.Errorf("expected status=failed, got %q", got.Status) + } + if got.Err != "boom" { + t.Errorf("expected err=boom, got %q", got.Err) + } +} + +func TestTaskRegistry_ListNewestFirst(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("a") + time.Sleep(time.Millisecond) + _ = r.Add("b") + time.Sleep(time.Millisecond) + t3 := r.Add("c") + list := r.List() + if len(list) != 3 { + t.Fatalf("expected 3, got %d", len(list)) + } + if list[0].ID != t3.ID { + t.Errorf("expected newest first (t3), got %s", list[0].ID) + } + if list[2].ID != t1.ID { + t.Errorf("expected oldest last (t1), got %s", list[2].ID) + } +} + +func TestTaskRegistry_Cancel(t *testing.T) { + r := NewTaskRegistry() + t1 := r.Add("x") + ctx, cancel := context.WithCancel(context.Background()) + r.SetCancel(t1.ID, cancel) + if err := r.Cancel(t1.ID); err != nil { + t.Fatal(err) + } + select { + case <-ctx.Done(): + case <-time.After(100 * time.Millisecond): + t.Error("expected ctx to be canceled") + } +} + +func TestTaskRegistry_CancelNotFound(t *testing.T) { + r := NewTaskRegistry() + if err := r.Cancel("bg-999"); !errors.Is(err, ErrTaskNotFound) { + t.Errorf("expected ErrTaskNotFound, got %v", err) + } +} + +func TestTaskRegistry_Concurrent(t *testing.T) { + r := NewTaskRegistry() + const n = 50 + done := make(chan struct{}, n) + for i := 0; i < n; i++ { + go func(i int) { + t1 := r.Add("x") + r.SetCancel(t1.ID, func() {}) + r.Finish(t1.ID, "verified", nil, nil) + r.Get(t1.ID) + r.List() + done <- struct{}{} + }(i) + } + for i := 0; i < n; i++ { + <-done + } + if got := len(r.List()); got != n { + t.Errorf("expected %d tasks, got %d", n, got) + } +} + +func TestItoa3(t *testing.T) { + cases := map[int]string{ + 0: "000", + 1: "001", + 10: "010", + 100: "100", + 999: "999", + } + for in, want := range cases { + if got := itoa3(in); got != want { + t.Errorf("itoa3(%d) = %q, want %q", in, got, want) + } + } +}