Files
multica/server/internal/handler/runtime_unbind_delete_test.go
Multica Eve b06af2ae17 feat(runtime): unbind agents on runtime delete instead of destroying them (#6220)
* feat(runtime): unbind agents on runtime delete instead of destroying them

Deleting a runtime archived its agents and then hard-deleted the rows, so the
agents and every conversation with them disappeared — while the confirmation
dialog said "archive", which a user reasonably reads as recoverable. Retiring a
laptop is an ordinary action; losing the agents configured on it is not an
ordinary consequence.

An agent is now a persistent business object and a runtime is replaceable
execution capacity: deleting a runtime unbinds its agents. `runtime_id IS NULL`
means unbound — orthogonal to archived — and the agent keeps its instructions,
skills, chats, labels, channel installations, autopilots and task history.
service.AgentReadiness already refused an agent with no runtime, so the
scheduling safety gate needed no change.

Two columns become nullable, not one. Without `agent_task_queue.runtime_id`,
deleting the runtime still cascades the task history away (and task_message /
task_usage / task_token with it), so the agents would survive with no record of
anything they did — the same class of loss. A NOT VALID CHECK keeps NULL confined
to history: an active task must always have a runtime, so claim / dispatch /
delivery-CAS paths can never observe one without. It is written against
completed_at rather than a status list so a future non-terminal status fails
closed instead of slipping through.

Two prerequisites this depends on:

- 'deferred' (migration 128) was missing from CancelAgentTasksByRuntimeOrAgent.
  It went unnoticed because the delete used to cascade those rows away; with the
  new CHECK it would abort the delete and make the runtime undeletable.
- The channel-installation / label / chat-pin / invocation-target / draft-restore
  cleanups were scoped to "archived agents on this runtime". Archived user agents
  now survive, so that scope is narrowed to kind='system' — otherwise the fix
  would produce a subtler loss: agent alive, configuration wiped.

Also removes the squad guard that refused (409) when an active squad's leader was
an archived agent on the runtime, plus the archived-squad delete that existed
only to get past squad.leader_id's RESTRICT FK. The leader is no longer deleted,
so nothing needs to be given up to retire a machine. Autopilots are no longer
paused either: their assignee survives, and a rebind restores them without the
owner having to remember to re-enable.

Reason codes: an unbound agent reports agent_runtime_required, not
runtime_offline. The copy for runtime_offline tells users to reconnect a machine;
an unbound agent has no machine to reconnect, and the fix is to bind a runtime.
Chat's bare 409 string gains the same code so the composer can offer that action.

API: agents gain runtime_bound. runtime_id stays a string (empty when unbound) so
installed clients keep parsing and no gated two-release rollout is needed. The
confirmed-delete endpoint is /unbind-agents-and-delete; /archive-agents-and-delete
still routes to it, and the compared expected_active_agent_ids set is unchanged —
widening it would 409 every older client forever.

Co-authored-by: multica-agent <github@multica.ai>

* fix: make runtime unbinding recoverable

Co-authored-by: multica-agent <github@multica.ai>

* fix: address runtime unbind review nits

Co-authored-by: multica-agent <github@multica.ai>

* fix: resolve runtime unbind review blockers

Co-authored-by: multica-agent <github@multica.ai>

* fix(migrations): renumber runtime unbind after main merge

Co-authored-by: multica-agent <github@multica.ai>

* test(daemon): avoid late-request lease flake

Co-authored-by: multica-agent <github@multica.ai>

* test(autopilots): bind validation fixture runtime

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 12:39:27 +08:00

579 lines
20 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// parseExpectedActiveAgentIDs is the cascade endpoint's input validator.
// Empty list is a valid plan ("no active agents" — cascade just deletes the
// runtime); malformed UUIDs must surface as 400 so a bug in the front-end
// can't silently dilute the plan check.
func TestParseExpectedActiveAgentIDs(t *testing.T) {
t.Run("empty list returns empty set, ok", func(t *testing.T) {
got, ok := parseExpectedActiveAgentIDs(nil)
if !ok {
t.Fatalf("expected ok for nil input")
}
if len(got) != 0 {
t.Fatalf("expected empty set, got %d entries", len(got))
}
})
t.Run("valid uuids are accepted and deduplicated by set semantics", func(t *testing.T) {
ids := []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
"11111111-1111-1111-1111-111111111111", // dup is intentional
}
got, ok := parseExpectedActiveAgentIDs(ids)
if !ok {
t.Fatalf("expected ok for valid uuid list")
}
if len(got) != 2 {
t.Fatalf("expected dedup set of 2, got %d", len(got))
}
for _, want := range []string{
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
} {
if _, ok := got[want]; !ok {
t.Fatalf("expected %s in set", want)
}
}
})
t.Run("any malformed entry fails the whole list", func(t *testing.T) {
ids := []string{
"11111111-1111-1111-1111-111111111111",
"not-a-uuid",
}
_, ok := parseExpectedActiveAgentIDs(ids)
if ok {
t.Fatal("expected !ok for list containing malformed uuid")
}
})
}
// activeAgentSetMatches drives the runtime_delete_plan_changed branch: it
// must report mismatch for any divergence — extra agent, missing agent, or
// substituted agent — and accept order-insensitive set equality.
func TestActiveAgentSetMatches(t *testing.T) {
mkAgent := func(id string) db.Agent {
u, err := uuidFromString(id)
if err != nil {
t.Fatalf("uuidFromString: %v", err)
}
return db.Agent{ID: u}
}
a1 := mkAgent("11111111-1111-1111-1111-111111111111")
a2 := mkAgent("22222222-2222-2222-2222-222222222222")
a3 := mkAgent("33333333-3333-3333-3333-333333333333")
t.Run("equal sets match regardless of order", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if !activeAgentSetMatches([]db.Agent{a2, a1}, expected) {
t.Fatal("expected match for set-equal inputs")
}
})
t.Run("missing agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if activeAgentSetMatches([]db.Agent{a1}, expected) {
t.Fatal("expected mismatch when an agent disappeared")
}
})
t.Run("extra agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
}
if activeAgentSetMatches([]db.Agent{a1, a2}, expected) {
t.Fatal("expected mismatch when a new agent appeared")
}
})
t.Run("substituted agent is a mismatch", func(t *testing.T) {
expected := map[string]struct{}{
"11111111-1111-1111-1111-111111111111": {},
"22222222-2222-2222-2222-222222222222": {},
}
if activeAgentSetMatches([]db.Agent{a1, a3}, expected) {
t.Fatal("expected mismatch when one agent was swapped for another")
}
})
t.Run("both empty matches", func(t *testing.T) {
if !activeAgentSetMatches(nil, map[string]struct{}{}) {
t.Fatal("expected empty/empty to match")
}
})
}
func uuidFromString(s string) (pgtype.UUID, error) {
var u pgtype.UUID
if err := u.Scan(s); err != nil {
return pgtype.UUID{}, err
}
return u, nil
}
// TestDeleteAgentRuntime_StructuredConflict covers the new 409 shape: the
// strict DELETE refuses with `runtime_has_active_agents` and the body carries
// the live active-agent list so the front-end can pivot to the cascade dialog
// without a second round-trip.
func TestDeleteAgentRuntime_StructuredConflict(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade 409 Runtime")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade 409 Agent")
_ = agentID
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/runtimes/"+runtimeID, nil)
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.DeleteAgentRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Error string `json:"error"`
Code string `json:"code"`
ActiveAgents []AgentResponse `json:"active_agents"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_has_active_agents" {
t.Fatalf("expected code runtime_has_active_agents, got %q", body.Code)
}
if len(body.ActiveAgents) != 1 || body.ActiveAgents[0].ID != agentID {
t.Fatalf("expected one active agent %s, got %+v", agentID, body.ActiveAgents)
}
}
func TestRuntimeDeleteLockBlocksConcurrentAgentBinding(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Runtime Delete Lock")
deleteTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin delete transaction: %v", err)
}
defer deleteTx.Rollback(ctx)
if _, err := testHandler.Queries.WithTx(deleteTx).LockAgentRuntime(
ctx,
parseUUID(runtimeID),
); err != nil {
t.Fatalf("lock runtime for delete: %v", err)
}
bindTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin bind transaction: %v", err)
}
defer bindTx.Rollback(ctx)
if _, err := bindTx.Exec(ctx, `SET LOCAL lock_timeout = '50ms'`); err != nil {
t.Fatalf("set lock timeout: %v", err)
}
if _, err := bindTx.Exec(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, 'concurrent bind probe', '', 'cloud', '{}'::jsonb,
$2, 'workspace', 1, $3)
`, testWorkspaceID, runtimeID, testUserID); err == nil {
t.Fatal("agent bind unexpectedly bypassed the runtime delete lock")
}
}
func TestAutopilotAssignmentLockBlocksRuntimeTeardown(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Autopilot Assignment Lock")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Autopilot Assignment Agent")
assignmentTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin assignment transaction: %v", err)
}
defer assignmentTx.Rollback(ctx)
if _, err := testHandler.Queries.WithTx(assignmentTx).LockAgentForAutopilotAssignment(
ctx,
db.LockAgentForAutopilotAssignmentParams{
ID: parseUUID(agentID),
WorkspaceID: parseUUID(testWorkspaceID),
},
); err != nil {
t.Fatalf("lock Agent for Autopilot assignment: %v", err)
}
deleteTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin delete transaction: %v", err)
}
defer deleteTx.Rollback(ctx)
if _, err := deleteTx.Exec(ctx, `SET LOCAL lock_timeout = '50ms'`); err != nil {
t.Fatalf("set lock timeout: %v", err)
}
if _, err := testHandler.Queries.WithTx(deleteTx).ListUserAgentsByRuntimeForUpdate(
ctx,
parseUUID(runtimeID),
); err == nil {
t.Fatal("runtime teardown unexpectedly bypassed the Autopilot assignment lock")
}
}
func TestDeleteAgentRuntime_CustomProfileInstanceRefusesDirectDelete(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID, _ := createProfileBackedRuntime(t, ctx, "Custom Instance Delete Guard")
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/runtimes/"+runtimeID, nil)
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.DeleteAgentRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_profile_instance_delete_unsupported" {
t.Fatalf("expected runtime_profile_instance_delete_unsupported, got %q", body.Code)
}
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 1 {
t.Fatalf("expected custom runtime instance to survive refusal, count=%d", rtRows)
}
}
func TestDeleteAgentRuntime_OrphanedProfileAllowsDirectDelete(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID, profileID := createProfileBackedRuntime(t, ctx, "Orphaned Custom Instance Delete")
if _, err := testPool.Exec(ctx, `DELETE FROM runtime_profile WHERE id = $1`, profileID); err != nil {
t.Fatalf("delete profile row: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("DELETE", "/api/runtimes/"+runtimeID, nil)
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.DeleteAgentRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 0 {
t.Fatalf("expected orphaned custom runtime instance to be deleted, count=%d", rtRows)
}
}
// TestUnbindAgentsAndDeleteRuntime_HappyPath exercises the confirmed endpoint
// end-to-end: with the correct expected_active_agent_ids snapshot, it must
// unbind the active agent, delete the runtime row, and respond 200 with the
// counts.
func TestUnbindAgentsAndDeleteRuntime_HappyPath(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade Happy Runtime")
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Happy Agent")
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/unbind-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{agentID}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UnbindAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// Runtime row must be gone.
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 0 {
t.Fatalf("expected runtime row to be deleted, found %d", rtRows)
}
// The agent must SURVIVE, unbound and un-archived. This is the whole point
// of MUL-5559: the old flow archived it and then hard-deleted the row, taking
// its chat sessions with it, while the dialog promised an archive.
var (
agentRows int
hasRuntime bool
archived bool
)
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM agent WHERE id = $1`, agentID).Scan(&agentRows); err != nil {
t.Fatalf("count agent rows: %v", err)
}
if agentRows != 1 {
t.Fatalf("expected the agent to survive its runtime, found %d rows", agentRows)
}
if err := testPool.QueryRow(ctx,
`SELECT runtime_id IS NOT NULL, archived_at IS NOT NULL FROM agent WHERE id = $1`,
agentID).Scan(&hasRuntime, &archived); err != nil {
t.Fatalf("read agent binding: %v", err)
}
if hasRuntime {
t.Fatalf("expected the surviving agent to be unbound (runtime_id IS NULL)")
}
if archived {
t.Fatalf("unbinding must not archive the agent: unbound and archived are orthogonal")
}
var body struct {
AgentsUnbound int `json:"agents_unbound"`
AgentsArchived int `json:"agents_archived"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.AgentsUnbound != 1 {
t.Fatalf("agents_unbound = %d, want 1", body.AgentsUnbound)
}
// Deprecated mirror kept for installed clients built against the
// archive-and-delete contract.
if body.AgentsArchived != 1 {
t.Fatalf("agents_archived mirror = %d, want 1", body.AgentsArchived)
}
}
func TestUnbindAgentsAndDeleteRuntime_CustomProfileInstanceRefusesDirectDelete(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID, _ := createProfileBackedRuntime(t, ctx, "Custom Instance Cascade Guard")
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/unbind-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UnbindAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Code string `json:"code"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_profile_instance_delete_unsupported" {
t.Fatalf("expected runtime_profile_instance_delete_unsupported, got %q", body.Code)
}
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 1 {
t.Fatalf("expected custom runtime instance to survive refusal, count=%d", rtRows)
}
}
func TestUnbindAgentsAndDeleteRuntime_OrphanedProfileAllowsCascade(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID, profileID := createProfileBackedRuntime(t, ctx, "Orphaned Custom Instance Cascade")
if _, err := testPool.Exec(ctx, `DELETE FROM runtime_profile WHERE id = $1`, profileID); err != nil {
t.Fatalf("delete profile row: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/unbind-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UnbindAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 0 {
t.Fatalf("expected orphaned custom runtime instance to be deleted, count=%d", rtRows)
}
}
// TestUnbindAgentsAndDeleteRuntime_PlanChanged proves the dialog-confirm
// race guard: if the user's snapshot of active agents drifts from the live
// set (somebody added or archived an agent while the dialog was open), the
// cascade endpoint must refuse with 409 + runtime_delete_plan_changed and
// surface the new live snapshot so the dialog can re-prompt.
func TestUnbindAgentsAndDeleteRuntime_PlanChanged(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
runtimeID := createCascadeFixtureRuntime(t, ctx, "Cascade Drift Runtime")
agent1 := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Drift Agent A")
agent2 := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Drift Agent B")
// User confirmed only agent1 — but the live set is {agent1, agent2}.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/runtimes/"+runtimeID+"/unbind-agents-and-delete",
map[string]any{"expected_active_agent_ids": []string{agent1}})
req = withURLParam(req, "runtimeId", runtimeID)
testHandler.UnbindAgentsAndDeleteRuntime(w, req)
if w.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
}
var body struct {
Code string `json:"code"`
ActiveAgents []AgentResponse `json:"active_agents"`
}
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Code != "runtime_delete_plan_changed" {
t.Fatalf("expected code runtime_delete_plan_changed, got %q", body.Code)
}
if len(body.ActiveAgents) != 2 {
t.Fatalf("expected 2 active agents in fresh snapshot, got %d", len(body.ActiveAgents))
}
// Runtime must still exist — the plan-changed branch is non-destructive.
var rtRows int
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
t.Fatalf("count runtime rows: %v", err)
}
if rtRows != 1 {
t.Fatalf("expected runtime to survive plan-changed refusal, count=%d", rtRows)
}
_ = agent2
}
// createCascadeFixtureRuntime creates a fresh runtime owned by testUserID
// inside testWorkspaceID and registers cleanup. Each cascade test uses its
// own runtime so the destructive paths don't trample the shared fixture.
func createCascadeFixtureRuntime(t *testing.T, ctx context.Context, name string) string {
t.Helper()
var runtimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, last_seen_at
)
VALUES ($1, NULL, $2, 'cloud', 'cascade-test', 'online', $3, '{}'::jsonb, $4, now())
RETURNING id
`, testWorkspaceID, name, name+" device", testUserID).Scan(&runtimeID); err != nil {
t.Fatalf("insert cascade fixture runtime: %v", err)
}
t.Cleanup(func() {
// Best-effort cleanup. The cascade endpoint deletes the runtime;
// these statements only matter when the test failed before the
// cascade ran.
testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, runtimeID)
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
})
return runtimeID
}
func createProfileBackedRuntime(t *testing.T, ctx context.Context, name string) (string, string) {
t.Helper()
var profileID string
if err := testPool.QueryRow(ctx, `
INSERT INTO runtime_profile (
workspace_id, display_name, protocol_family, command_name,
fixed_args, visibility, created_by, enabled
)
VALUES ($1, $2, 'codex', 'custom-codex', '[]'::jsonb, 'workspace', $3, true)
RETURNING id
`, testWorkspaceID, name+" Profile", testUserID).Scan(&profileID); err != nil {
t.Fatalf("insert runtime profile: %v", err)
}
var runtimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status,
device_info, metadata, owner_id, profile_id, last_seen_at
)
VALUES ($1, $2, $3, 'local', 'codex', 'online', $4, '{}'::jsonb, $5, $6, now())
RETURNING id
`, testWorkspaceID, "daemon-"+profileID, name, name+" device", testUserID, profileID).Scan(&runtimeID); err != nil {
t.Fatalf("insert profile-backed runtime: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, runtimeID)
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, profileID)
})
return runtimeID, profileID
}
func createCascadeFixtureAgent(t *testing.T, ctx context.Context, runtimeID, name string) string {
t.Helper()
var agentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, max_concurrent_tasks, owner_id
)
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4)
RETURNING id
`, testWorkspaceID, name, runtimeID, testUserID).Scan(&agentID); err != nil {
t.Fatalf("insert cascade fixture agent: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, agentID)
})
return agentID
}