Files
multica/server/internal/handler/agent_builder_test.go
Bohan Jiang 168620fc15 MUL-5163 fix(agents): rebind the Agent Builder carrier when the runtime is switched (#5780)
Switching the runtime mid-conversation in Build with AI only updated local React state, so the picker could show runtime B while every subsequent message still executed on the runtime frozen at session create time.

- Add PATCH /api/agent-builder/sessions/{id}/runtime to rebind the hidden builder carrier (runtime_id/runtime_mode, model cleared since model ids are per-runtime). Creator-only, builder carriers only, target must be in-workspace, usable by the member, and online; a reply in flight returns 409.
- Serialise rebind against send: both take LockChatSessionForRuntimeBind on the chat_session row and SendDirectChatMessage re-reads the agent inside that transaction, so a send blocked behind a rebind cannot resume and stamp its task with the runtime the switch moved away from.
- Leave chat_session.runtime_id stale on purpose so the daemon starts a fresh provider session on the new runtime while Multica-side history and the draft survive.
- Frontend updates the draft only after the server reports the bound runtime, blocks sending during a rebind, disables the Mine/All filter alongside the trigger, and explains why the picker is locked during a pending reply.

Closes #5773
2026-07-23 10:56:53 +08:00

804 lines
30 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func TestAgentBuilderInstructionsConstrainModelsToRuntimeCatalog(t *testing.T) {
for _, requirement := range []string{
"AVAILABLE RUNTIME MODELS",
"Never use a model label as the id",
"never invent a model id",
} {
if !strings.Contains(agentBuilderInstructions, requirement) {
t.Fatalf("agent builder instructions missing model constraint %q", requirement)
}
}
}
func TestCreateAgentBuilderSessionCreatesIsolatedHiddenBuilder(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM agent
WHERE workspace_id = $1 AND kind = 'system' AND system_key LIKE 'agent_builder:%'
`, testWorkspaceID)
})
create := func(model string) CreateAgentBuilderSessionResponse {
w := httptest.NewRecorder()
testHandler.CreateAgentBuilderSession(w, newRequest(http.MethodPost, "/api/agent-builder/sessions", map[string]any{
"runtime_id": testRuntimeID,
"model": model,
}))
if w.Code != http.StatusCreated {
t.Fatalf("CreateAgentBuilderSession: expected 201, got %d: %s", w.Code, w.Body.String())
}
var response CreateAgentBuilderSessionResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.SessionID == "" || response.BuilderAgentID == "" {
t.Fatalf("missing builder identifiers: %+v", response)
}
return response
}
first := create("builder-model-a")
second := create("builder-model-b")
if first.BuilderAgentID == second.BuilderAgentID {
t.Fatalf("builder sessions unexpectedly shared an agent: %s", first.BuilderAgentID)
}
if first.SessionID == second.SessionID {
t.Fatalf("each creation flow must receive a fresh chat session")
}
var kind, systemKey, firstModel string
if err := testPool.QueryRow(context.Background(), `
SELECT kind, system_key, model FROM agent WHERE id = $1
`, first.BuilderAgentID).Scan(&kind, &systemKey, &firstModel); err != nil {
t.Fatalf("load builder agent: %v", err)
}
if kind != "system" || !strings.HasPrefix(systemKey, "agent_builder:") {
t.Fatalf("unexpected builder identity kind=%q system_key=%q", kind, systemKey)
}
if firstModel != "builder-model-a" {
t.Fatalf("first builder model was mutated: got %q", firstModel)
}
w := httptest.NewRecorder()
testHandler.ListAgents(w, newRequest(http.MethodGet, "/api/agents", nil))
if w.Code != http.StatusOK {
t.Fatalf("ListAgents: %d: %s", w.Code, w.Body.String())
}
var listed []AgentResponse
if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode agent list: %v", err)
}
for _, agent := range listed {
if agent.ID == first.BuilderAgentID {
t.Fatalf("system builder leaked into the user-facing agent list")
}
}
// Knowing the ID must not expose system infrastructure through the public
// Agent detail/update/archive loaders.
w = httptest.NewRecorder()
req := withURLParams(newRequest(http.MethodGet, "/api/agents/"+first.BuilderAgentID, nil), "id", first.BuilderAgentID)
testHandler.GetAgent(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("GetAgent(system): expected 404, got %d: %s", w.Code, w.Body.String())
}
// Deleting the private Builder chat also removes its session-scoped hidden
// Agent, so completed/cancelled flows do not accumulate infrastructure rows.
w = httptest.NewRecorder()
req = withURLParams(newRequest(http.MethodDelete, "/api/chat/sessions/"+first.SessionID, nil), "sessionId", first.SessionID)
req = withChatTestWorkspaceCtx(t, req)
testHandler.DeleteChatSession(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DeleteChatSession(builder): expected 204, got %d: %s", w.Code, w.Body.String())
}
var remaining int
if err := testPool.QueryRow(context.Background(), `SELECT count(*) FROM agent WHERE id = $1`, first.BuilderAgentID).Scan(&remaining); err != nil {
t.Fatalf("count deleted builder: %v", err)
}
if remaining != 0 {
t.Fatalf("builder agent survived chat deletion")
}
}
func TestCreateAgentAttachesSkillsInCreateTransaction(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
var skillID string
if err := testPool.QueryRow(ctx, `
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
VALUES ($1, 'Atomic Create Skill', '', '# Atomic', '{}'::jsonb, $2)
RETURNING id
`, testWorkspaceID, testUserID).Scan(&skillID); err != nil {
t.Fatalf("create skill fixture: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent WHERE workspace_id = $1 AND name = 'Atomic Skill Agent'`, testWorkspaceID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM skill WHERE id = $1`, skillID)
})
w := httptest.NewRecorder()
testHandler.CreateAgent(w, newRequest(http.MethodPost, "/api/agents", map[string]any{
"name": "Atomic Skill Agent",
"runtime_id": testRuntimeID,
"skill_ids": []string{skillID},
}))
if w.Code != http.StatusCreated {
t.Fatalf("CreateAgent: expected 201, got %d: %s", w.Code, w.Body.String())
}
var response AgentResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(response.Skills) != 1 || response.Skills[0].ID != skillID {
t.Fatalf("create response did not include attached skill: %+v", response.Skills)
}
var introSessions int
if err := testPool.QueryRow(ctx, `
SELECT count(*) FROM chat_session WHERE agent_id = $1 AND is_agent_intro = true
`, response.ID).Scan(&introSessions); err != nil {
t.Fatalf("count welcome chat sessions: %v", err)
}
if introSessions != 1 {
t.Fatalf("welcome chat sessions = %d, want 1", introSessions)
}
}
// newBuilderSession starts a builder conversation on testRuntimeID and registers
// cleanup for the carrier agents the flow creates.
func newBuilderSession(t *testing.T) CreateAgentBuilderSessionResponse {
t.Helper()
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM agent
WHERE workspace_id = $1 AND kind = 'system' AND system_key LIKE 'agent_builder:%'
`, testWorkspaceID)
})
w := httptest.NewRecorder()
testHandler.CreateAgentBuilderSession(w, newRequest(http.MethodPost, "/api/agent-builder/sessions", map[string]any{
"runtime_id": testRuntimeID,
"model": "model-pinned-to-runtime-a",
}))
if w.Code != http.StatusCreated {
t.Fatalf("CreateAgentBuilderSession: expected 201, got %d: %s", w.Code, w.Body.String())
}
var session CreateAgentBuilderSessionResponse
if err := json.Unmarshal(w.Body.Bytes(), &session); err != nil {
t.Fatalf("decode create response: %v", err)
}
return session
}
// newTestRuntime inserts an extra runtime in the fixture workspace so switch
// tests have somewhere to move to.
func newTestRuntime(t *testing.T, name, status string) string {
t.Helper()
var runtimeID string
if err := testPool.QueryRow(context.Background(), `
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', $3, $4, 'switch test runtime', '{}'::jsonb, $5, now())
RETURNING id
`, testWorkspaceID, name, strings.ToLower(strings.ReplaceAll(name, " ", "_")), status, testUserID).Scan(&runtimeID); err != nil {
t.Fatalf("create runtime %q: %v", name, err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
})
return runtimeID
}
func switchBuilderRuntime(t *testing.T, sessionID, runtimeID string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
req := withURLParams(
newRequest(http.MethodPatch, "/api/agent-builder/sessions/"+sessionID+"/runtime", map[string]any{
"runtime_id": runtimeID,
}),
"sessionId", sessionID,
)
testHandler.SwitchAgentBuilderRuntime(w, req)
return w
}
func TestSwitchAgentBuilderRuntimeRebindsCarrier(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
session := newBuilderSession(t)
target := newTestRuntime(t, "Builder Switch Target", "online")
w := switchBuilderRuntime(t, session.SessionID, target)
if w.Code != http.StatusOK {
t.Fatalf("SwitchAgentBuilderRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
var response SwitchAgentBuilderRuntimeResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("decode switch response: %v", err)
}
if response.RuntimeID != target {
t.Fatalf("response runtime = %q, want %q", response.RuntimeID, target)
}
// The carrier is what stamps a chat task's runtime, so this row — not the
// client's local selection — is the fix.
var boundRuntimeID, boundRuntimeMode string
var boundModel pgtype.Text
if err := testPool.QueryRow(ctx, `
SELECT runtime_id::text, runtime_mode, model FROM agent WHERE id = $1
`, session.BuilderAgentID).Scan(&boundRuntimeID, &boundRuntimeMode, &boundModel); err != nil {
t.Fatalf("load builder carrier: %v", err)
}
if boundRuntimeID != target {
t.Fatalf("carrier runtime = %q, want %q", boundRuntimeID, target)
}
if boundRuntimeMode != "cloud" {
t.Fatalf("carrier runtime_mode = %q, want cloud", boundRuntimeMode)
}
if boundModel.Valid {
t.Fatalf("carrier model should be cleared on rebind, got %q", boundModel.String)
}
// Left deliberately stale: the daemon only resumes a stored provider session
// when this pointer matches the claiming task's runtime, so keeping the old
// value is what makes the new runtime start a fresh session.
var sessionRuntimeID string
if err := testPool.QueryRow(ctx, `
SELECT runtime_id::text FROM chat_session WHERE id = $1
`, session.SessionID).Scan(&sessionRuntimeID); err != nil {
t.Fatalf("load chat session: %v", err)
}
if sessionRuntimeID != testRuntimeID {
t.Fatalf("chat_session.runtime_id = %q, want the original %q", sessionRuntimeID, testRuntimeID)
}
}
func TestSwitchAgentBuilderRuntimeRejectsOfflineTarget(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
session := newBuilderSession(t)
offline := newTestRuntime(t, "Builder Switch Offline", "offline")
if w := switchBuilderRuntime(t, session.SessionID, offline); w.Code != http.StatusConflict {
t.Fatalf("offline target: expected 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestSwitchAgentBuilderRuntimeRejectsWhileReplyPending(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
session := newBuilderSession(t)
target := newTestRuntime(t, "Builder Switch Pending Target", "online")
if _, err := testPool.Exec(ctx, `
INSERT INTO agent_task_queue (agent_id, chat_session_id, status, priority, context, runtime_id)
VALUES ($1, $2, 'running', 2, '{}'::jsonb, $3)
`, session.BuilderAgentID, session.SessionID, testRuntimeID); err != nil {
t.Fatalf("insert pending task: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE chat_session_id = $1`, session.SessionID)
})
if w := switchBuilderRuntime(t, session.SessionID, target); w.Code != http.StatusConflict {
t.Fatalf("pending reply: expected 409, got %d: %s", w.Code, w.Body.String())
}
}
func TestSwitchAgentBuilderRuntimeRejectsNonBuilderSession(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
target := newTestRuntime(t, "Builder Switch Foreign Target", "online")
// A user-authored agent changes runtime through the agent update path; this
// endpoint must not be a second way in.
var userAgentID, userSessionID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent (
workspace_id, name, description, runtime_mode, runtime_config,
runtime_id, visibility, permission_mode, max_concurrent_tasks, owner_id
)
VALUES ($1, 'Builder Switch User Agent', '', 'cloud', '{}'::jsonb, $2, 'workspace', 'public_to', 1, $3)
RETURNING id
`, testWorkspaceID, testRuntimeID, testUserID).Scan(&userAgentID); err != nil {
t.Fatalf("create user agent: %v", err)
}
if err := testPool.QueryRow(ctx, `
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, runtime_id)
VALUES ($1, $2, $3, 'Not a builder', $4)
RETURNING id
`, testWorkspaceID, userAgentID, testUserID, testRuntimeID).Scan(&userSessionID); err != nil {
t.Fatalf("create user chat session: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM chat_session WHERE id = $1`, userSessionID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, userAgentID)
})
if w := switchBuilderRuntime(t, userSessionID, target); w.Code != http.StatusNotFound {
t.Fatalf("user agent session: expected 404, got %d: %s", w.Code, w.Body.String())
}
var stillBound string
if err := testPool.QueryRow(ctx, `SELECT runtime_id::text FROM agent WHERE id = $1`, userAgentID).Scan(&stillBound); err != nil {
t.Fatalf("reload user agent: %v", err)
}
if stillBound != testRuntimeID {
t.Fatalf("user agent runtime changed to %q; the builder endpoint must never touch it", stillBound)
}
}
// The regression this whole change exists for: a send that loaded the agent
// before a rebind committed must still enqueue on the runtime the session is
// bound to NOW. SendDirectChatMessage is handed the stale agent on purpose here
// — that is exactly what its caller does when the two requests interleave.
func TestSendDirectChatMessageUsesCurrentlyBoundRuntime(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
created := newBuilderSession(t)
target := newTestRuntime(t, "Builder Send Rebind Target", "online")
sessionUUID := parseUUID(created.SessionID)
session, err := testHandler.Queries.GetChatSession(ctx, sessionUUID)
if err != nil {
t.Fatalf("load chat session: %v", err)
}
staleAgent, err := testHandler.Queries.GetAgent(ctx, parseUUID(created.BuilderAgentID))
if err != nil {
t.Fatalf("load builder carrier: %v", err)
}
if uuidToString(staleAgent.RuntimeID) != testRuntimeID {
t.Fatalf("carrier should start on %q, got %q", testRuntimeID, uuidToString(staleAgent.RuntimeID))
}
if w := switchBuilderRuntime(t, created.SessionID, target); w.Code != http.StatusOK {
t.Fatalf("SwitchAgentBuilderRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE chat_session_id = $1`, created.SessionID)
})
sent, err := testHandler.TaskService.SendDirectChatMessage(
ctx, session, staleAgent, parseUUID(testUserID), "hello after the switch", nil, "member", parseUUID(testUserID),
)
if err != nil {
t.Fatalf("SendDirectChatMessage: %v", err)
}
if got := uuidToString(sent.Task.RuntimeID); got != target {
t.Fatalf("task runtime = %q, want the rebound runtime %q — a stale in-flight send must not resurrect the old runtime", got, target)
}
}
// holderBackendPID returns the server-side PID of the backend serving tx, so a
// waiter can later be attributed to this specific lock holder.
func holderBackendPID(t *testing.T, ctx context.Context, tx pgx.Tx) int {
t.Helper()
var pid int
if err := tx.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&pid); err != nil {
t.Fatalf("read holder backend pid: %v", err)
}
return pid
}
// waitForWaiterBlockedBy blocks until some backend is waiting on a lock held by
// holderPID, which is how the interleaving tests below observe "the other side
// is parked on our chat_session row" without guessing at timings.
//
// pg_blocking_pids is what makes this specific. `go test ./...` runs package
// test binaries in parallel against one DATABASE_URL, so a probe for "any
// Lock-waiting backend" could match an unrelated package and let the holder
// commit early — after which the path under test would start clean, read the
// committed state, and pass even with its lock removed. Attributing the waiter
// to this transaction's PID removes that false-green path.
//
// Returns false only after the deadline with no attributable waiter, which is
// the signal that the path under test never took the lock. A probe error is
// fatal rather than swallowed: a permissions or connectivity failure must not
// be reported as "that path is not locking".
func waitForWaiterBlockedBy(t *testing.T, holderPID int, timeout time.Duration) bool {
t.Helper()
deadline := time.Now().Add(timeout)
for {
var waiting int
if err := testPool.QueryRow(context.Background(), `
SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'active'
AND wait_event_type = 'Lock'
AND $1::int = ANY(pg_blocking_pids(pid))
`, holderPID).Scan(&waiting); err != nil {
t.Fatalf("probe pg_stat_activity for waiters blocked by pid %d: %v", holderPID, err)
}
if waiting > 0 {
return true
}
if time.Now().After(deadline) {
return false
}
time.Sleep(20 * time.Millisecond)
}
}
// A rebind that has not committed yet must hold off a concurrent send, and the
// send must then observe the NEW runtime. This is the half of the protocol the
// lock owns: with the lock removed the send reads straight through the
// uncommitted rebind under READ COMMITTED, sees the pre-switch runtime, and
// enqueues the reply on the runtime the user just switched away from.
func TestSendDirectChatMessageWaitsForUncommittedRebind(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
created := newBuilderSession(t)
target := newTestRuntime(t, "Builder Interleave Send Target", "online")
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE chat_session_id = $1`, created.SessionID)
})
session, err := testHandler.Queries.GetChatSession(ctx, parseUUID(created.SessionID))
if err != nil {
t.Fatalf("load chat session: %v", err)
}
// The agent as the send handler would have loaded it: still on runtime A.
staleAgent, err := testHandler.Queries.GetAgent(ctx, parseUUID(created.BuilderAgentID))
if err != nil {
t.Fatalf("load builder carrier: %v", err)
}
// Hold an uncommitted rebind, exactly as SwitchAgentBuilderRuntime does.
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin rebind tx: %v", err)
}
defer tx.Rollback(context.Background())
holderPID := holderBackendPID(t, ctx, tx)
qtx := testHandler.Queries.WithTx(tx)
if _, err := qtx.LockChatSessionForRuntimeBind(ctx, session.ID); err != nil {
t.Fatalf("lock chat session: %v", err)
}
targetRuntime, err := testHandler.Queries.GetAgentRuntimeForWorkspace(ctx, db.GetAgentRuntimeForWorkspaceParams{
ID: parseUUID(target),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("load target runtime: %v", err)
}
if _, err := qtx.RebindAgentBuilderRuntime(ctx, db.RebindAgentBuilderRuntimeParams{
ID: staleAgent.ID,
RuntimeID: targetRuntime.ID,
RuntimeMode: targetRuntime.RuntimeMode,
}); err != nil {
t.Fatalf("rebind carrier: %v", err)
}
type sendResult struct {
task db.AgentTaskQueue
err error
}
results := make(chan sendResult, 1)
go func() {
sent, err := testHandler.TaskService.SendDirectChatMessage(
context.Background(), session, staleAgent, parseUUID(testUserID),
"sent while the rebind was still open", nil, "member", parseUUID(testUserID),
)
if err != nil {
results <- sendResult{err: err}
return
}
results <- sendResult{task: sent.Task}
}()
if !waitForWaiterBlockedBy(t, holderPID, 10*time.Second) {
select {
case got := <-results:
t.Fatalf("send completed (err=%v, runtime=%q) while an uncommitted rebind held the chat_session lock; the send path is not taking the lock",
got.err, uuidToString(got.task.RuntimeID))
default:
t.Fatalf("send never blocked on the chat_session lock held by pid %d; the send path is not taking the lock", holderPID)
}
}
select {
case got := <-results:
t.Fatalf("send returned (err=%v) before the rebind committed", got.err)
default:
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit rebind: %v", err)
}
select {
case got := <-results:
if got.err != nil {
t.Fatalf("SendDirectChatMessage after commit: %v", got.err)
}
if runtimeID := uuidToString(got.task.RuntimeID); runtimeID != target {
t.Fatalf("task runtime = %q, want the rebound runtime %q", runtimeID, target)
}
case <-time.After(10 * time.Second):
t.Fatal("send did not complete after the rebind committed")
}
}
// The mirror image: a send that has not committed yet must hold off a rebind,
// and the rebind must then see the now-visible pending task and refuse. Without
// the lock on the switch side, GetPendingChatTask cannot see the uncommitted
// task, so the switch reports success while a reply is already in flight on the
// old runtime — the same "UI says B, execution is A" split, one turn later.
func TestSwitchAgentBuilderRuntimeWaitsForUncommittedSend(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
created := newBuilderSession(t)
target := newTestRuntime(t, "Builder Interleave Switch Target", "online")
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE chat_session_id = $1`, created.SessionID)
})
// Hold an uncommitted send: the chat_session lock plus its task row, in the
// order SendDirectChatMessage takes them.
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin send tx: %v", err)
}
defer tx.Rollback(context.Background())
holderPID := holderBackendPID(t, ctx, tx)
qtx := testHandler.Queries.WithTx(tx)
if _, err := qtx.LockChatSessionForRuntimeBind(ctx, parseUUID(created.SessionID)); err != nil {
t.Fatalf("lock chat session: %v", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO agent_task_queue (agent_id, chat_session_id, status, priority, context, runtime_id)
VALUES ($1, $2, 'queued', 2, '{}'::jsonb, $3)
`, created.BuilderAgentID, created.SessionID, testRuntimeID); err != nil {
t.Fatalf("insert in-flight task: %v", err)
}
codes := make(chan int, 1)
go func() {
w := httptest.NewRecorder()
req := withURLParams(
newRequest(http.MethodPatch, "/api/agent-builder/sessions/"+created.SessionID+"/runtime", map[string]any{
"runtime_id": target,
}),
"sessionId", created.SessionID,
)
testHandler.SwitchAgentBuilderRuntime(w, req)
codes <- w.Code
}()
if !waitForWaiterBlockedBy(t, holderPID, 10*time.Second) {
select {
case code := <-codes:
t.Fatalf("switch returned %d while an uncommitted send held the chat_session lock; the switch path is not taking the lock", code)
default:
t.Fatalf("switch never blocked on the chat_session lock held by pid %d; the switch path is not taking the lock", holderPID)
}
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit send: %v", err)
}
select {
case code := <-codes:
if code != http.StatusConflict {
t.Fatalf("switch returned %d after the send committed, want 409 — a reply is in flight", code)
}
case <-time.After(10 * time.Second):
t.Fatal("switch did not complete after the send committed")
}
// And the carrier must be untouched: a refused switch cannot half-apply.
var boundRuntimeID string
if err := testPool.QueryRow(ctx, `SELECT runtime_id::text FROM agent WHERE id = $1`, created.BuilderAgentID).Scan(&boundRuntimeID); err != nil {
t.Fatalf("reload builder carrier: %v", err)
}
if boundRuntimeID != testRuntimeID {
t.Fatalf("carrier runtime = %q after a refused switch, want the original %q", boundRuntimeID, testRuntimeID)
}
}
func TestSwitchAgentBuilderRuntimeEnforcesRuntimeAndSessionOwnership(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
// A plain member, so canUseRuntimeForAgent's owner/admin bypass does not
// apply — the fixture user is the workspace owner and may legitimately use
// anyone's private runtime.
var plainMemberID string
if err := testPool.QueryRow(ctx, `
INSERT INTO "user" (name, email) VALUES ('Builder Switch Plain Member', 'builder-switch-plain@multica.ai')
RETURNING id
`).Scan(&plainMemberID); err != nil {
t.Fatalf("create plain member user: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, plainMemberID)
})
if _, err := testPool.Exec(ctx, `
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'member')
`, testWorkspaceID, plainMemberID); err != nil {
t.Fatalf("add plain member: %v", err)
}
// The fixture runtime is private to the workspace owner, so start this
// member's session on a public one.
var publicRuntimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, visibility, last_seen_at
)
VALUES ($1, NULL, 'Builder Switch Public', 'cloud', 'builder_switch_public', 'online', 'public', '{}'::jsonb, $2, 'public', now())
RETURNING id
`, testWorkspaceID, testUserID).Scan(&publicRuntimeID); err != nil {
t.Fatalf("create public runtime: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, publicRuntimeID)
})
// That member's own builder session, so the creator gate passes and the
// runtime gate is what we are actually testing.
createW := httptest.NewRecorder()
testHandler.CreateAgentBuilderSession(createW, newRequestAs(plainMemberID, http.MethodPost, "/api/agent-builder/sessions", map[string]any{
"runtime_id": publicRuntimeID,
}))
if createW.Code != http.StatusCreated {
t.Fatalf("CreateAgentBuilderSession as plain member: expected 201, got %d: %s", createW.Code, createW.Body.String())
}
var created CreateAgentBuilderSessionResponse
if err := json.Unmarshal(createW.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create response: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `
DELETE FROM agent
WHERE workspace_id = $1 AND kind = 'system' AND system_key LIKE 'agent_builder:%'
`, testWorkspaceID)
})
// The workspace owner's private runtime is not a legal target for them.
var privateRuntimeID string
if err := testPool.QueryRow(ctx, `
INSERT INTO agent_runtime (
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, visibility, last_seen_at
)
VALUES ($1, NULL, 'Builder Switch Private', 'cloud', 'builder_switch_private', 'online', 'private', '{}'::jsonb, $2, 'private', now())
RETURNING id
`, testWorkspaceID, testUserID).Scan(&privateRuntimeID); err != nil {
t.Fatalf("create private runtime: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, privateRuntimeID)
})
forbiddenW := httptest.NewRecorder()
testHandler.SwitchAgentBuilderRuntime(forbiddenW, withURLParams(
newRequestAs(plainMemberID, http.MethodPatch, "/api/agent-builder/sessions/"+created.SessionID+"/runtime", map[string]any{
"runtime_id": privateRuntimeID,
}),
"sessionId", created.SessionID,
))
if forbiddenW.Code != http.StatusForbidden {
t.Fatalf("someone else's private runtime: expected 403, got %d: %s", forbiddenW.Code, forbiddenW.Body.String())
}
// And a session the caller does not own is not theirs to rebind, whatever
// their workspace role — this one is issued by the workspace owner.
target := newTestRuntime(t, "Builder Switch Ownership Target", "online")
if w := switchBuilderRuntime(t, created.SessionID, target); w.Code != http.StatusForbidden {
t.Fatalf("someone else's session: expected 403, got %d: %s", w.Code, w.Body.String())
}
var boundRuntimeID string
if err := testPool.QueryRow(ctx, `SELECT runtime_id::text FROM agent WHERE id = $1`, created.BuilderAgentID).Scan(&boundRuntimeID); err != nil {
t.Fatalf("reload builder carrier: %v", err)
}
if boundRuntimeID != publicRuntimeID {
t.Fatalf("carrier runtime = %q after refused switches, want the original %q", boundRuntimeID, publicRuntimeID)
}
}
// The probe above is only as good as its attribution: a database-wide "is
// anything waiting on a lock?" check would match another package's test binary
// on the shared DATABASE_URL and let the interleaving tests commit their holder
// early, turning them green even with the lock under test removed. This pins the
// property directly — a waiter blocked by a DIFFERENT backend must not count.
func TestWaitForWaiterBlockedByIgnoresUnrelatedWaiters(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
mine := newBuilderSession(t)
theirs := newBuilderSession(t)
// Our holder: locks our own session and blocks nobody.
holderTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin holder tx: %v", err)
}
defer holderTx.Rollback(context.Background())
holderPID := holderBackendPID(t, ctx, holderTx)
if _, err := holderTx.Exec(ctx, `SELECT id FROM chat_session WHERE id = $1 FOR UPDATE`, mine.SessionID); err != nil {
t.Fatalf("hold our own session lock: %v", err)
}
// An unrelated holder on a different row, plus a backend parked behind it —
// the shape of another package's test running against the same database.
otherTx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin unrelated holder tx: %v", err)
}
defer otherTx.Rollback(context.Background())
otherPID := holderBackendPID(t, ctx, otherTx)
if _, err := otherTx.Exec(ctx, `SELECT id FROM chat_session WHERE id = $1 FOR UPDATE`, theirs.SessionID); err != nil {
t.Fatalf("hold unrelated session lock: %v", err)
}
blocked := make(chan struct{})
go func() {
defer close(blocked)
waiterTx, err := testPool.Begin(context.Background())
if err != nil {
return
}
defer waiterTx.Rollback(context.Background())
_, _ = waiterTx.Exec(context.Background(), `SELECT id FROM chat_session WHERE id = $1 FOR UPDATE`, theirs.SessionID)
}()
// The unrelated waiter is genuinely parked, so a database-wide probe would
// fire here.
if !waitForWaiterBlockedBy(t, otherPID, 10*time.Second) {
t.Fatal("the unrelated waiter never blocked; this test cannot prove anything")
}
// Attributed to our holder, it must not.
if waitForWaiterBlockedBy(t, holderPID, 500*time.Millisecond) {
t.Fatal("probe matched a waiter blocked by another backend; the interleaving tests could commit their holder early and pass with the lock removed")
}
if err := otherTx.Rollback(ctx); err != nil {
t.Fatalf("release unrelated lock: %v", err)
}
select {
case <-blocked:
case <-time.After(10 * time.Second):
t.Fatal("unrelated waiter did not finish after its blocker released")
}
}