Files
Algis DumbrisandClaude Opus 4.6 8ddccd2452 feat(reactor): route non-K8s reactive runs through harness registry
Phase 7: the reactor now branches on agent backend kind.

Reactor changes (internal/reactor/reactor.go):

  * Adds `registry *harness.Registry` field + `SetHarnessRegistry`.
  * `agentBackendKind()` picks k8s | subprocess | webhook | none from
    the agent's `HarnessName`, `K8sImage`, `LocalCommand`, and
    `HarnessConfigJSON` fields. Explicit `HarnessName` wins.
  * `evaluateTrigger` applies the same preconditions (depth, daily
    budget, cooldown, already-running, pending_work coalescing) to
    every backend — a subprocess agent mentioned in a channel now
    goes through the exact same rate limits a K8s agent does.
  * K8s agents keep the existing `createJob` fast-return path with
    the async poller for restart safety. Non-K8s agents use a new
    `dispatchHarness` that inserts the reactive_runs row, spawns a
    detached goroutine, blocks on `Registry.Execute`, and writes the
    terminal status / error_log / metrics / failure DM on return.
  * Import `harness`, `messaging`, `google/uuid` for building the
    ExecRequest.

main.go wiring:

  * Build one `harness.Registry` with all three real backends:
    `k8sjob.New(k8sRunner, …)`, `subprocess.New(Config{BaseDir:
    dataDir/harness/subprocess}, …)`, `webhook.New(Config{}, …)`.
  * Attach a `runs.Store` as the registry Observer so every dispatch
    writes a harness_runs row — no per-caller code required.
  * Hand the registry to the reactor via `SetHarnessRegistry`.
  * Log the registered backend names at startup.

Tests (internal/reactor/reactor_test.go):

  * New `insertSubprocessAgent`, `newHarnessReactor`, `waitForRun`,
    and `fakeNotifier` helpers.
  * Seven new tests that register a stub harness under "subprocess"
    and verify: success from @mention, failure recorded + DM sent,
    depth-exceeded skipped, budget-exhausted skipped, cooldown
    skipped, already-running queued, no-backend fails cleanly. Each
    checks the harness stub is NOT called when a precondition skips.
  * Existing `TestReactorNoK8sImage` keeps working — the old
    k8s-specific error message is replaced with the backend-agnostic
    "no backend configured" phrasing.
  * `setupTestDB` now pins `SetMaxOpenConns(1)`: modernc.org/sqlite
    in-memory DBs give each pool connection a fresh empty database,
    which races the new dispatchHarness goroutine and main-test
    goroutine. Pinning is the standard workaround.

The overall behaviour: `@local-agent` in a channel message now starts
the configured subprocess/webhook under the same depth/budget/cooldown
rate limits as a K8s agent, tracked in reactive_runs and harness_runs,
instrumented with an OTel span, with trace context propagated into the
child via env vars. Failure DMs go to the human owner, as with K8s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:38:10 +03:00

725 lines
22 KiB
Go

package reactor
import (
"context"
"database/sql"
"encoding/json"
"strings"
"testing"
"time"
"fmt"
"log/slog"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/dispatcher"
"github.com/synapbus/synapbus/internal/harness"
"github.com/synapbus/synapbus/internal/harness/stub"
k8spkg "github.com/synapbus/synapbus/internal/k8s"
_ "modernc.org/sqlite"
)
// setupTestDB creates an in-memory SQLite database with schema for testing.
func setupTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
// modernc.org/sqlite gives every pool connection its own fresh
// :memory: database, which breaks tests that spawn goroutines
// (harness runs) alongside the main test goroutine. Pin to one
// connection so every query sees the same schema and rows.
db.SetMaxOpenConns(1)
// Create minimal schema
schema := `
CREATE TABLE agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'ai',
capabilities TEXT NOT NULL DEFAULT '{}',
owner_id INTEGER NOT NULL DEFAULT 1,
api_key_hash TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
trigger_mode TEXT NOT NULL DEFAULT 'passive',
cooldown_seconds INTEGER NOT NULL DEFAULT 600,
daily_trigger_budget INTEGER NOT NULL DEFAULT 8,
max_trigger_depth INTEGER NOT NULL DEFAULT 5,
k8s_image TEXT,
k8s_env_json TEXT,
k8s_resource_preset TEXT NOT NULL DEFAULT 'default',
pending_work INTEGER NOT NULL DEFAULT 0,
harness_name TEXT,
local_command TEXT,
harness_config_json TEXT
);
CREATE TABLE reactive_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_name TEXT NOT NULL,
trigger_message_id INTEGER,
trigger_event TEXT NOT NULL,
trigger_depth INTEGER NOT NULL DEFAULT 0,
trigger_from TEXT,
status TEXT NOT NULL DEFAULT 'queued',
k8s_job_name TEXT,
k8s_namespace TEXT,
started_at DATETIME,
completed_at DATETIME,
duration_ms INTEGER,
error_log TEXT,
token_cost_json TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`
if _, err := db.Exec(schema); err != nil {
t.Fatalf("create schema: %v", err)
}
return db
}
func insertTestAgent(t *testing.T, db *sql.DB, name, triggerMode, image string, cooldown, budget, maxDepth int) {
t.Helper()
_, err := db.Exec(
`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_resource_preset)
VALUES (?, ?, 'ai', 1, ?, ?, ?, ?, ?, 'default')`,
name, name, triggerMode, cooldown, budget, maxDepth, image,
)
if err != nil {
t.Fatalf("insert agent: %v", err)
}
}
// insertSubprocessAgent creates a reactive agent backed by a local
// command instead of a K8s image.
func insertSubprocessAgent(t *testing.T, db *sql.DB, name, localCommand string, cooldown, budget, maxDepth int) {
t.Helper()
_, err := db.Exec(
`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_resource_preset, local_command)
VALUES (?, ?, 'ai', 1, 'reactive', ?, ?, ?, 'default', ?)`,
name, name, cooldown, budget, maxDepth, localCommand,
)
if err != nil {
t.Fatalf("insert subprocess agent: %v", err)
}
}
func TestReactorPassiveAgentSkipped(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "passive-agent", "passive", "image:latest", 600, 8, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := k8spkg.NewNoopRunner()
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "passive-agent",
Body: "hello",
}
err := reactor.Dispatch(context.Background(), event)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// No runs should be created for passive agents
runs, total, err := store.ListRuns(context.Background(), "passive-agent", "", 10, 0)
if err != nil {
t.Fatalf("list runs: %v", err)
}
if total != 0 || len(runs) != 0 {
t.Errorf("expected 0 runs for passive agent, got %d", total)
}
}
func TestReactorNoK8sImage(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "no-image-agent", "reactive", "", 600, 8, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := k8spkg.NewNoopRunner()
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "no-image-agent",
Body: "hello",
}
_ = reactor.Dispatch(context.Background(), event)
runs, _, _ := store.ListRuns(context.Background(), "no-image-agent", StatusFailed, 10, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 failed run for agent with no image, got %d", len(runs))
}
if !strings.Contains(runs[0].ErrorLog, "no backend configured") {
t.Errorf("expected 'no backend configured' error, got: %s", runs[0].ErrorLog)
}
}
func TestReactorDepthExceeded(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "deep-agent", "reactive", "image:latest", 600, 8, 3)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "other-agent",
ToAgent: "deep-agent",
Body: "hello from depth 3",
Depth: 3, // equals max depth
}
_ = reactor.Dispatch(context.Background(), event)
runs, _, _ := store.ListRuns(context.Background(), "deep-agent", StatusDepthExceeded, 10, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 depth_exceeded run, got %d", len(runs))
}
}
func TestReactorBudgetExhausted(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "budget-agent", "reactive", "image:latest", 0, 2, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
// Record 2 existing runs today
for i := 0; i < 2; i++ {
_, _ = store.InsertRun(context.Background(), &ReactiveRun{
AgentName: "budget-agent",
TriggerEvent: "message.received",
Status: StatusSucceeded,
})
}
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 10,
FromAgent: "algis",
ToAgent: "budget-agent",
Body: "one more",
}
_ = reactor.Dispatch(context.Background(), event)
runs, _, _ := store.ListRuns(context.Background(), "budget-agent", StatusBudgetExhausted, 10, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 budget_exhausted run, got %d", len(runs))
}
}
func TestReactorCooldownSkipped(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "cool-agent", "reactive", "image:latest", 600, 8, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
// Record a recent run
now := time.Now().UTC()
_, _ = store.InsertRun(context.Background(), &ReactiveRun{
AgentName: "cool-agent",
TriggerEvent: "message.received",
Status: StatusSucceeded,
})
// Hack: the above uses CURRENT_TIMESTAMP which is "now", so cooldown should be active
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 10,
FromAgent: "algis",
ToAgent: "cool-agent",
Body: "too soon",
}
_ = reactor.Dispatch(context.Background(), event)
_ = now // avoid unused
runs, _, _ := store.ListRuns(context.Background(), "cool-agent", StatusCooldownSkipped, 10, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 cooldown_skipped run, got %d", len(runs))
}
// Check pending_work was set
agent, _ := agentStore.GetAgentByName(context.Background(), "cool-agent")
if !agent.PendingWork {
t.Error("expected pending_work to be set after cooldown skip")
}
}
func TestReactorSequentialExecution(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "busy-agent", "reactive", "image:latest", 0, 8, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
// First trigger — should succeed
event1 := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "busy-agent",
Body: "first",
}
_ = reactor.Dispatch(context.Background(), event1)
// Second trigger — agent is running, should queue
event2 := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 2,
FromAgent: "algis",
ToAgent: "busy-agent",
Body: "second",
}
_ = reactor.Dispatch(context.Background(), event2)
// Check: one running, one queued
running, _, _ := store.ListRuns(context.Background(), "busy-agent", StatusRunning, 10, 0)
queued, _, _ := store.ListRuns(context.Background(), "busy-agent", StatusQueued, 10, 0)
if len(running) != 1 {
t.Errorf("expected 1 running, got %d", len(running))
}
if len(queued) != 1 {
t.Errorf("expected 1 queued, got %d", len(queued))
}
// Check pending_work is set
agent, _ := agentStore.GetAgentByName(context.Background(), "busy-agent")
if !agent.PendingWork {
t.Error("expected pending_work to be set")
}
}
func TestReactorSelfMentionIgnored(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertTestAgent(t, db, "self-agent", "reactive", "image:latest", 0, 8, 5)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
// Agent mentions itself
event := dispatcher.MessageEvent{
EventType: "message.mentioned",
MessageID: 1,
FromAgent: "self-agent",
Body: "hey @self-agent",
MentionedAgents: []string{"self-agent"},
}
_ = reactor.Dispatch(context.Background(), event)
runs, total, _ := store.ListRuns(context.Background(), "self-agent", "", 10, 0)
if total != 0 || len(runs) != 0 {
t.Errorf("expected 0 runs for self-mention, got %d", total)
}
}
func TestReactorSuccessfulTrigger(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
envJSON, _ := json.Marshal(map[string]string{
"AGENT_GIT_REPO": "Dumbris/test-agent",
})
_, _ = db.Exec(
`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_env_json, k8s_resource_preset)
VALUES (?, ?, 'ai', 1, 'reactive', 0, 8, 5, 'image:latest', ?, 'default')`,
"test-agent", "Test Agent", string(envJSON),
)
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
runner := &fakeRunner{available: true}
logger := slog.Default()
reactor := New(store, agentStore, runner, logger)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 42,
FromAgent: "algis",
ToAgent: "test-agent",
Body: "research this topic",
}
err := reactor.Dispatch(context.Background(), event)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify job was created
if runner.lastJobName == "" {
t.Fatal("expected K8s Job to be created")
}
// Verify run record
runs, _, _ := store.ListRuns(context.Background(), "test-agent", StatusRunning, 10, 0)
if len(runs) != 1 {
t.Fatalf("expected 1 running run, got %d", len(runs))
}
run := runs[0]
if run.TriggerFrom != "algis" {
t.Errorf("expected trigger_from=algis, got %s", run.TriggerFrom)
}
if run.TriggerEvent != "message.received" {
t.Errorf("expected trigger_event=message.received, got %s", run.TriggerEvent)
}
// Verify env vars passed to job
if runner.lastEnv["SYNAPBUS_TRIGGER_DEPTH"] != "0" {
t.Errorf("expected SYNAPBUS_TRIGGER_DEPTH=0, got %s", runner.lastEnv["SYNAPBUS_TRIGGER_DEPTH"])
}
if runner.lastEnv["AGENT_GIT_REPO"] != "Dumbris/test-agent" {
t.Errorf("expected AGENT_GIT_REPO from k8s_env_json, got %s", runner.lastEnv["AGENT_GIT_REPO"])
}
}
// --- subprocess/harness path tests ---------------------------------------
// newHarnessReactor wires a reactor with a registry that dispatches to
// the provided stub harness under the "subprocess" name. The NoopRunner
// ensures the K8s path is skipped for agents without a k8s_image.
func newHarnessReactor(t *testing.T, db *sql.DB, stubHarness *stub.Harness) *Reactor {
t.Helper()
store := NewStore(db)
agentStore := agents.NewSQLiteAgentStore(db)
reactor := New(store, agentStore, k8spkg.NewNoopRunner(), slog.Default())
reg := harness.NewRegistry()
reg.Register(stubHarness)
reactor.SetHarnessRegistry(reg)
return reactor
}
// waitForRun polls the store until a run in the given status exists or
// the deadline expires. Subprocess dispatches run in a goroutine so
// tests need to wait a bit for the terminal status.
func waitForRun(t *testing.T, store *Store, agentName, status string) *ReactiveRun {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
runs, _, err := store.ListRuns(context.Background(), agentName, status, 5, 0)
if err == nil && len(runs) > 0 {
return runs[0]
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for run with status=%s for agent=%s", status, agentName)
return nil
}
func TestReactorSubprocess_SuccessFromMention(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "local-agent", `["sh","-c","true"]`, 0, 8, 5)
s := stub.New()
s.NameStr = "subprocess"
s.Result = &harness.ExecResult{ExitCode: 0, Logs: "ok"}
reactor := newHarnessReactor(t, db, s)
event := dispatcher.MessageEvent{
EventType: "message.mentioned",
MessageID: 42,
FromAgent: "algis",
Body: "@local-agent please help",
MentionedAgents: []string{"local-agent"},
}
if err := reactor.Dispatch(context.Background(), event); err != nil {
t.Fatalf("Dispatch err = %v", err)
}
run := waitForRun(t, reactor.store, "local-agent", StatusSucceeded)
if run.TriggerEvent != "message.mentioned" {
t.Errorf("trigger_event = %q", run.TriggerEvent)
}
if run.TriggerFrom != "algis" {
t.Errorf("trigger_from = %q", run.TriggerFrom)
}
if len(s.Calls) != 1 {
t.Fatalf("stub got %d calls, want 1", len(s.Calls))
}
call := s.Calls[0]
if call.Agent == nil || call.Agent.Name != "local-agent" {
t.Errorf("stub call agent = %+v", call.Agent)
}
if call.Env["SYNAPBUS_TRIGGER_DEPTH"] != "0" {
t.Errorf("trigger depth env = %q", call.Env["SYNAPBUS_TRIGGER_DEPTH"])
}
if call.Env["SYNAPBUS_FROM_AGENT"] != "algis" {
t.Errorf("from_agent env = %q", call.Env["SYNAPBUS_FROM_AGENT"])
}
if call.Message == nil || call.Message.ID != 42 {
t.Errorf("message = %+v", call.Message)
}
}
func TestReactorSubprocess_FailureRecordedAndDMSent(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "crash-agent", `["sh","-c","false"]`, 0, 8, 5)
// Insert a human owner so notifyFailure can resolve the recipient.
_, _ = db.Exec(`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode, k8s_resource_preset)
VALUES ('algis', 'algis', 'human', 1, 'passive', 'default')`)
s := stub.New()
s.NameStr = "subprocess"
s.Result = &harness.ExecResult{ExitCode: 7, Logs: "bad"}
reactor := newHarnessReactor(t, db, s)
notifier := &fakeNotifier{}
reactor.SetFailureNotifier(notifier)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "crash-agent",
Body: "work",
}
_ = reactor.Dispatch(context.Background(), event)
run := waitForRun(t, reactor.store, "crash-agent", StatusFailed)
if run.ErrorLog == "" {
t.Error("expected error log on failed run")
}
if notifier.calls != 1 {
t.Errorf("notifier calls = %d, want 1", notifier.calls)
}
}
func TestReactorSubprocess_DepthExceededSkipped(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "deep-local", `["sh","-c","true"]`, 0, 8, 3)
s := stub.New()
s.NameStr = "subprocess"
reactor := newHarnessReactor(t, db, s)
event := dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "deep-local",
Depth: 3, // at limit
}
_ = reactor.Dispatch(context.Background(), event)
runs, _, _ := reactor.store.ListRuns(context.Background(), "deep-local", StatusDepthExceeded, 5, 0)
if len(runs) != 1 {
t.Fatalf("expected depth_exceeded run, got %d", len(runs))
}
if len(s.Calls) != 0 {
t.Errorf("harness should not be called when depth exceeded (calls=%d)", len(s.Calls))
}
}
func TestReactorSubprocess_BudgetExhaustedSkipped(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "budget-local", `["sh","-c","true"]`, 0, 1, 5)
// Seed one completed run today to exhaust the daily budget (=1).
now := time.Now().UTC().Format(time.RFC3339)
_, err := db.Exec(`INSERT INTO reactive_runs (agent_name, trigger_event, trigger_depth, trigger_from, status, created_at)
VALUES ('budget-local', 'message.received', 0, 'algis', 'succeeded', ?)`, now)
if err != nil {
t.Fatalf("seed run: %v", err)
}
s := stub.New()
s.NameStr = "subprocess"
reactor := newHarnessReactor(t, db, s)
_ = reactor.Dispatch(context.Background(), dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "budget-local",
})
runs, _, _ := reactor.store.ListRuns(context.Background(), "budget-local", StatusBudgetExhausted, 5, 0)
if len(runs) != 1 {
t.Fatalf("expected budget_exhausted run, got %d", len(runs))
}
if len(s.Calls) != 0 {
t.Errorf("harness called despite budget exhaustion")
}
}
func TestReactorSubprocess_CooldownSkipped(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "cool-local", `["sh","-c","true"]`, 3600, 8, 5)
// Seed a recent completed run so cooldown applies.
now := time.Now().UTC().Format(time.RFC3339)
_, _ = db.Exec(`INSERT INTO reactive_runs (agent_name, trigger_event, trigger_depth, trigger_from, status, created_at)
VALUES ('cool-local', 'message.received', 0, 'algis', 'succeeded', ?)`, now)
s := stub.New()
s.NameStr = "subprocess"
reactor := newHarnessReactor(t, db, s)
_ = reactor.Dispatch(context.Background(), dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "cool-local",
})
runs, _, _ := reactor.store.ListRuns(context.Background(), "cool-local", StatusCooldownSkipped, 5, 0)
if len(runs) != 1 {
t.Fatalf("expected cooldown_skipped run, got %d", len(runs))
}
if len(s.Calls) != 0 {
t.Errorf("harness called despite cooldown")
}
}
func TestReactorSubprocess_AlreadyRunningQueued(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
insertSubprocessAgent(t, db, "busy-local", `["sh","-c","true"]`, 0, 8, 5)
// Seed a running run so the already-running check fires.
_, _ = db.Exec(`INSERT INTO reactive_runs (agent_name, trigger_event, trigger_depth, trigger_from, status, started_at, created_at)
VALUES ('busy-local', 'message.received', 0, 'algis', 'running', datetime('now'), datetime('now'))`)
s := stub.New()
s.NameStr = "subprocess"
reactor := newHarnessReactor(t, db, s)
_ = reactor.Dispatch(context.Background(), dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "busy-local",
})
runs, _, _ := reactor.store.ListRuns(context.Background(), "busy-local", StatusQueued, 5, 0)
if len(runs) != 1 {
t.Fatalf("expected queued run, got %d", len(runs))
}
if len(s.Calls) != 0 {
t.Errorf("harness called despite already-running")
}
}
func TestReactorSubprocess_NoBackendFailsCleanly(t *testing.T) {
db := setupTestDB(t)
defer db.Close()
// Reactive agent with no k8s_image, no local_command, no harness config.
_, _ = db.Exec(`INSERT INTO agents (name, display_name, type, owner_id, trigger_mode,
cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_resource_preset)
VALUES ('orphan', 'orphan', 'ai', 1, 'reactive', 0, 8, 5, 'default')`)
reactor := newHarnessReactor(t, db, stub.New())
_ = reactor.Dispatch(context.Background(), dispatcher.MessageEvent{
EventType: "message.received",
MessageID: 1,
FromAgent: "algis",
ToAgent: "orphan",
})
runs, _, _ := reactor.store.ListRuns(context.Background(), "orphan", StatusFailed, 5, 0)
if len(runs) != 1 {
t.Fatalf("expected failed run for orphan agent, got %d", len(runs))
}
if !strings.Contains(runs[0].ErrorLog, "no backend configured") {
t.Errorf("error_log = %q", runs[0].ErrorLog)
}
}
// fakeNotifier captures NotifyFailure calls.
type fakeNotifier struct {
calls int
last string
}
func (f *fakeNotifier) NotifyFailure(_ context.Context, _, _, _, _ string, _ int64, errorSummary string) error {
f.calls++
f.last = errorSummary
return nil
}
// fakeRunner is a test double for k8spkg.JobRunner.
type fakeRunner struct {
available bool
lastJobName string
lastEnv map[string]string
callCount int
}
func (f *fakeRunner) IsAvailable() bool { return f.available }
func (f *fakeRunner) GetNamespace() string { return "test-ns" }
func (f *fakeRunner) GetJobLogs(_ context.Context, _, _ string) (string, error) {
return "test logs", nil
}
func (f *fakeRunner) CreateJob(_ context.Context, handler *k8spkg.K8sHandler, msg *k8spkg.JobMessage) (string, error) {
f.callCount++
f.lastJobName = fmt.Sprintf("synapbus-%s-%d", handler.AgentName, msg.MessageID)
f.lastEnv = make(map[string]string)
for k, v := range handler.Env {
f.lastEnv[k] = v
}
return f.lastJobName, nil
}