Skip to content

Commit 8b4e352

Browse files
author
SIN CI
committed
fix(main): deduplicate AddCommand calls and remove duplicate subcommands
1 parent 9cd22c3 commit 8b4e352

3 files changed

Lines changed: 320 additions & 0 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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+
}
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: MIT
2+
// Purpose: tests for issue #195 — background task registry.
3+
package agentloop
4+
5+
import (
6+
"context"
7+
"errors"
8+
"testing"
9+
"time"
10+
)
11+
12+
func TestTaskRegistry_AddGet(t *testing.T) {
13+
r := NewTaskRegistry()
14+
t1 := r.Add("first")
15+
if t1.ID == "" {
16+
t.Fatal("expected non-empty id")
17+
}
18+
if t1.Status != "running" {
19+
t.Errorf("expected status=running, got %q", t1.Status)
20+
}
21+
got, err := r.Get(t1.ID)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
if got.ID != t1.ID {
26+
t.Errorf("id mismatch: %q != %q", got.ID, t1.ID)
27+
}
28+
}
29+
30+
func TestTaskRegistry_GetNotFound(t *testing.T) {
31+
r := NewTaskRegistry()
32+
_, err := r.Get("bg-999")
33+
if !errors.Is(err, ErrTaskNotFound) {
34+
t.Errorf("expected ErrTaskNotFound, got %v", err)
35+
}
36+
}
37+
38+
func TestTaskRegistry_Finish(t *testing.T) {
39+
r := NewTaskRegistry()
40+
t1 := r.Add("x")
41+
res := &Result{Summary: "ok", Verified: true, Turns: 1}
42+
r.Finish(t1.ID, "verified", res, nil)
43+
got, _ := r.Get(t1.ID)
44+
if got.Status != "verified" {
45+
t.Errorf("expected status=verified, got %q", got.Status)
46+
}
47+
if got.Result == nil || got.Result.Summary != "ok" {
48+
t.Errorf("expected result.summary=ok, got %+v", got.Result)
49+
}
50+
if got.FinishedAt.IsZero() {
51+
t.Error("expected FinishedAt to be set after Finish")
52+
}
53+
}
54+
55+
func TestTaskRegistry_FinishWithError(t *testing.T) {
56+
r := NewTaskRegistry()
57+
t1 := r.Add("x")
58+
r.Finish(t1.ID, "failed", nil, errors.New("boom"))
59+
got, _ := r.Get(t1.ID)
60+
if got.Status != "failed" {
61+
t.Errorf("expected status=failed, got %q", got.Status)
62+
}
63+
if got.Err != "boom" {
64+
t.Errorf("expected err=boom, got %q", got.Err)
65+
}
66+
}
67+
68+
func TestTaskRegistry_ListNewestFirst(t *testing.T) {
69+
r := NewTaskRegistry()
70+
t1 := r.Add("a")
71+
time.Sleep(time.Millisecond) // ensure distinct StartedAt
72+
_ = r.Add("b")
73+
time.Sleep(time.Millisecond)
74+
t3 := r.Add("c")
75+
list := r.List()
76+
if len(list) != 3 {
77+
t.Fatalf("expected 3, got %d", len(list))
78+
}
79+
if list[0].ID != t3.ID {
80+
t.Errorf("expected newest first (t3), got %s", list[0].ID)
81+
}
82+
if list[2].ID != t1.ID {
83+
t.Errorf("expected oldest last (t1), got %s", list[2].ID)
84+
}
85+
}
86+
87+
func TestTaskRegistry_Cancel(t *testing.T) {
88+
r := NewTaskRegistry()
89+
t1 := r.Add("x")
90+
ctx, cancel := context.WithCancel(context.Background())
91+
r.SetCancel(t1.ID, cancel)
92+
if err := r.Cancel(t1.ID); err != nil {
93+
t.Fatal(err)
94+
}
95+
// ctx should be done.
96+
select {
97+
case <-ctx.Done():
98+
// good
99+
case <-time.After(100 * time.Millisecond):
100+
t.Error("expected ctx to be canceled")
101+
}
102+
}
103+
104+
func TestTaskRegistry_CancelNotFound(t *testing.T) {
105+
r := NewTaskRegistry()
106+
if err := r.Cancel("bg-999"); !errors.Is(err, ErrTaskNotFound) {
107+
t.Errorf("expected ErrTaskNotFound, got %v", err)
108+
}
109+
}
110+
111+
func TestTaskRegistry_Concurrent(t *testing.T) {
112+
// Stress test: many goroutines add/get/finish at once.
113+
r := NewTaskRegistry()
114+
const n = 50
115+
done := make(chan struct{}, n)
116+
for i := 0; i < n; i++ {
117+
go func(i int) {
118+
t1 := r.Add("x")
119+
r.SetCancel(t1.ID, func() {})
120+
r.Finish(t1.ID, "verified", nil, nil)
121+
r.Get(t1.ID)
122+
r.List()
123+
done <- struct{}{}
124+
}(i)
125+
}
126+
for i := 0; i < n; i++ {
127+
<-done
128+
}
129+
if got := len(r.List()); got != n {
130+
t.Errorf("expected %d tasks, got %d", n, got)
131+
}
132+
}
133+
134+
func TestItoa3(t *testing.T) {
135+
cases := map[int]string{
136+
0: "000",
137+
1: "001",
138+
10: "010",
139+
100: "100",
140+
999: "999",
141+
}
142+
for in, want := range cases {
143+
if got := itoa3(in); got != want {
144+
t.Errorf("itoa3(%d) = %q, want %q", in, got, want)
145+
}
146+
}
147+
}

cmd/sin-code/testdata/scripts/golden_help.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,19 @@ Usage:
2525
Available Commands:
2626
adw Architectural Debt Watchdogs — detect god modules, circular deps, etc.
2727
assets Manage harvested agents/commands/skills
28+
audit Repo-wide audits (complexity, ...)
2829
auto Ultra-autonomous mode: pursue a program.md objective on your behalf
2930
autodev Bridge to OpenSIN-Code/autodev-cli (Python autoresearch loop, never vendored)
3031
catalog Unified tool catalog (hub + assets, one CLI)
32+
ceo-audit CEO-grade SOTA repository audit (48 gates)
3133
chat Run the SIN-Code agent loop (interactive REPL or headless one-shot)
3234
codegraph Bridge to CodeGraph for multi-language code analysis
3335
compile-spec Compile .sin-code.yml into the four derived JSON artifacts
3436
completion Generate the autocompletion script for the specified shell
3537
compress Lossless compaction for lessons / instincts / summaries / AGENTS.md
3638
config View and manage sin-code configuration
3739
daemon Run the autonomous worker: lease goals, execute, verify, learn
40+
debt Inspect sin-debt markers (issue #177)
3841
discover Discover files with relevance scoring and pattern matching
3942
dox Self-maintaining AGENTS.md hierarchy (agent0ai/dox protocol)
4043
edit Hashline-anchored surgical edits with validation
@@ -45,6 +48,7 @@ Available Commands:
4548
gh Bridge to the GitHub CLI (gh) with a 3-tier verb-allowlist policy
4649
goal Manage the autonomous goal queue
4750
grasp Deep code understanding for a single file
51+
grill Native adversarial design-review interview (issue #141 fusion)
4852
harvest Fetch URLs with caching, structure extraction, and change detection
4953
headroom Manage Headroom context compression integration
5054
help Help about any command
@@ -83,6 +87,7 @@ Available Commands:
8387
skills List and install bundled project-local skills
8488
spec Author, validate & inspect *.spec.md contracts (Spec-Layer)
8589
stack Manage the DOX + Superpowers + Vane methodology stack
90+
subagent Run a subtask in an isolated session, return summary (issue #192)
8691
summary Build a deterministic summary from the session ledger
8792
superpowers Integrate obra/superpowers skills into SIN-Code
8893
swarm Race N agent profiles on the same prompt (first verified wins)

0 commit comments

Comments
 (0)