mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-05 09:30:05 +02:00
* feat(chat): add agent-generated quick actions
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): preserve mid-response quick-action fences
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): drop quick actions on empty reply to keep no_response fallback
An actions-only completion — a quick-actions footer with no visible text —
wrote an empty-content assistant message (message_kind=message). Older
Desktop/mobile clients ignore the quick_actions field and render that as an
empty bubble, breaking the MUL-4351 contract that an empty turn always gives
old clients a visible no_response fallback.
Drop the quick actions when the visible body is empty so an actions-only turn
falls through to the visible no_response outcome, and revert the completion
switch to gate the message row on visible text only. Update the completion
test to pin the corrected behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): generate quick actions via daemon suggestion pass
Replace the in-band runtime-brief instruction with a dedicated post-completion
provider turn: after a direct chat reply finishes, the daemon resumes the same
session with a JSON-only suggest prompt and forwards the raw output on the
complete callback. The server parses it leniently and reuses the existing
sanitize/redact/store/broadcast pipeline; the stripped in-band footer stays as
a fallback for older daemons and pre-upgrade sessions. The footer strip now
covers every chat completion, fixing the intro-turn protocol leak. Adds a
Settings → Chat toggle (client-persisted, default on) that hides the chips.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): deliver quick actions async with skeleton placeholders
Decouple suggestion generation from the turn: the daemon reports completion
immediately (chat:done carries quick_actions_pending as a per-turn capability
signal) and runs the suggestion pass in the background, delivering results
through a new supplement endpoint + chat:quick_actions broadcast. A new turn
on the same session cancels the stale pass. Clients render pill skeletons
under the finished reply until the supplement resolves them (entrance
animation on arrival, 30s safety timeout); older daemons never raise the flag
so no skeleton dangles. Suggest usage re-reports merged totals because
task_usage upserts replace per (task, provider, model). Prompt now asks for
exactly 3 actions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(chat): make the quick-actions toggle stop generation, not hide pills
The Settings → Chat toggle previously only hid rendered pills while the
daemon kept burning a suggestion call every turn. It now travels with each
send (quick_actions_enabled, absent = enabled for older clients), is stamped
on the chat task (migration 213), forwarded on the claim, and gates the
daemon's suggestion pass at the source — no call, no pending flag, no
skeleton. Existing suggestions stay visible; settings copy now says
'generate' instead of 'show'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(migrations): renumber quick-action migrations onto current main
Merging current origin/main brought the vcs migrations to their canonical
216-221 prefixes, which collided with the quick-action migrations that were
sitting at 219/220 (backend CI red in
TestMigrationNumericPrefixesStayUniqueAfterLegacySet). Renumber them to the
next unused prefixes:
- 219_chat_message_quick_actions -> 222_chat_message_quick_actions
- 220_agent_task_quick_actions_disabled -> 223_agent_task_quick_actions_disabled
Contents are unchanged; sqlc regeneration produces no drift since the added
columns are independent of the vcs tables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(mobile): render async chat quick actions via chat:quick_actions
The daemon generates quick actions in a background pass after the turn
finishes, delivering them on a separate chat:quick_actions event. Mobile
only handled chat:done (which invalidates + refetches an actions-less
message list) and keeps the messages query at staleTime: Infinity, so an
active mobile session never rendered async-generated quick actions until a
manual pull-to-refresh or refocus.
Add applyChatQuickActionsToCache — mirroring web's patcher — which patches
the supplement onto the targeted assistant message in the flat messages
cache, and subscribe to chat:quick_actions in use-chat-session-realtime.
Patch-only (no invalidate), matching web and mobile's cellular
patch-over-invalidate rule; an empty supplement is a terminal no-op. Covered
by chat-ws-updaters.test.ts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): cancel in-flight messages refetch before quick-actions patch
The chat:done invalidate can leave a messages refetch in flight that read the
assistant row before the daemon persisted the quick actions. If that refetch
resolves after the chat:quick_actions setQueryData patch, it overwrites the
freshly-patched actions with an actions-less row. Both message caches are
staleTime: Infinity, so the overwrite never self-heals and the actions vanish
permanently (MUL-5149, Howard review).
applyChatQuickActionsToCache now awaits cancelQueries for the affected caches
(web: flat messages + messagesPage, mobile: flat messages) before patching, so
a stale in-flight refetch is cancelled and cannot land after the patch. Cancel
must precede setQueryData because cancelQueries reverts to the pre-fetch state.
WS handlers call it via `void` (fire-and-forget).
Adds an active-query race regression test on both web and mobile that holds a
refetch open across the supplement and asserts the patched actions survive;
verified to fail without the cancel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(chat): quick-actions refresh/regenerate + review hardening (MUL-5149)
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): address quick-actions re-review (MUL-5149)
- Ack alignment: refresh request carries the target message_id; server
atomically confirms it is still the session's latest turn (409 stale
otherwise), so the client marker always matches the resolving
chat:quick_actions — no response reconciliation. Adds a regression test.
- Converge the pending marker on every terminal path: HandleFailedTasks
(sweeper/orphan) now resolves it, and the daemon reports a failed supplement
so FailTask resolves it instead of leaving a completed-but-unresolved task.
- Timeout fallback now clears the real query state (useQuickActionsPendingTimeout)
instead of a component-local flag that only masked the UI; drop the skeleton's
and pill row's local timers.
- frontend-test type-scale: text-xs -> text-caption. Strip EOF blank line.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): close quick-actions refresh races and failure feedback (MUL-5149)
Third-round review of the refresh button surfaced three issues; all three
are addressed here.
§1/§2 Session-busy race + concurrent-refresh double-spend: a newer reply
that is queued/running but whose assistant row hasn't landed leaves the old
turn as latest-persisted, so the stale check passes and the regen resumes
the newer provider state — attaching suggestions to the wrong turn. And two
concurrent refreshes each enqueue a quota-spending pass. Add
HasActiveChatTaskForSession and refuse a refresh (ErrChatQuickActionsBusy →
409) whenever the session has any task in flight, checked under the same
session lock as the enqueue so no sibling insert slips past.
§3a Timeout re-arm on surface switch: the pending marker now carries an
absolute expires_at deadline instead of a per-mount timer, so switching
between the floating window and the chat tab resumes the same deadline
rather than restarting a fresh 30s window each remount.
§3b Generation failure masked as success: runChatSuggestPass now returns ok
so an explicit refresh distinguishes a failed pass (didn't start / didn't
complete / timed out) from a completed-but-empty one. On failure the regen
task reports failure, resolveFailedRegenerateQuickActions broadcasts a
FAILED chat:quick_actions, and the client resolves the spinner AND toasts
"couldn't refresh" instead of silently stopping on unchanged pills.
Co-authored-by: multica-agent <github@multica.ai>
* fix(chat): count deferred tasks in refresh busy check; solid refresh icon tone (MUL-5149)
Two re-review blockers on 2dc9404d.
§1 (deferred window): HasActiveChatTaskForSession only treated
queued/dispatched/running/waiting_local_directory as in-flight, so a chat
auto-retry armed with a backoff fire_at — inserted 'deferred' by
CreateRetryTask, as provider_network's ~5s final attempt is — slipped past
the busy check. In that window the failed turn has no assistant row yet, so
the old turn is still latest-persisted and refreshable; the regen would then
resume a session the retry is about to advance and pin the new turn's
suggestions onto the old one. Add 'deferred' so the set matches the
canonical in-flight status list the rest of the queries already use
(agent.sql has-active-task checks). New regression test covers a deferred
active turn.
CI (text-contrast gate): the refresh icon button used
text-muted-foreground/70 (transparency standing in for a text tone), which
the frontend-test contrast gate rejects. Switch to the solid
text-faint-foreground token — the tone the gate recommends for icons/glyphs,
already used repo-wide and clearing WCAG 1.4.11.
Co-authored-by: multica-agent <github@multica.ai>
* test(chat): assert quick-actions pending marker carries expires_at (MUL-5149)
The chat:done supplement-flow test still expected the 2-field marker from
before the absolute-deadline change; applyChatDoneToCache now stamps
expires_at, so the deep-equal failed on frontend-test. Assert the deadline is
present (expect.any(Number)) rather than a wall-clock-dependent value — its
timing semantics are covered by the pending-timeout hook.
Co-authored-by: multica-agent <github@multica.ai>
* fix(db): renumber regenerate-quick-actions migration 237 -> 240 (MUL-5149)
main merged Issue Quick Actions (MUL-5465) taking migrations 237/238/239
(quick_action, quick_action_workspace_index, comment_quick_action). This
branch independently took 237 for agent_task_queue.regenerate_quick_actions_for.
The two 237s do not textually conflict (different filenames) so the PR reads
mergeable, but the merged tree would carry two migration 237s. Renumber this
one to 240 so it applies after main's chain. The migration is a standalone
`ALTER TABLE agent_task_queue ADD COLUMN IF NOT EXISTS` — order-independent,
touches a column none of main's migrations reference.
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com>
Co-authored-by: NevilleQingNY <nevilleqing@gmail.com>
804 lines
30 KiB
Go
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), false,
|
|
)
|
|
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), false,
|
|
)
|
|
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")
|
|
}
|
|
}
|