mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(daemon): enforce runtime ownership on daemon routes (MUL-8154) /api/daemon/* accepts any workspace member's PAT/JWT, and authorization only checked workspace membership. Any member could claim another member's tasks (receiving the agent's custom_env, MCP config and an owner-scoped task token), write task results/messages, and take a runtime over by re-registering its daemon_id. - Runtime-level check on every runtime/task daemon route, HTTP heartbeat, deregister, batch claim (and so the WS claim RPC) and the WS handshake: user credentials must own the runtime (NULL owner fails closed); daemon tokens must match a non-empty runtime daemon_id. - Register: pre-check that every existing row on the daemon_id belongs to the caller, plus a required_owner_id fence in both upserts (incl. the failed-profile path) so a concurrent registration cannot win the row. Daemon-token registration keeps the existing owner. - Legacy merge: only for user-credential registrations, and only when the caller owns both rows, verified on the FOR UPDATE-locked rows. - Tasks without a runtime are not writable through daemon routes; status stays readable. - GetTaskStatus / WS lease queries carry runtime owner/daemon so the hot paths keep one round trip. Co-authored-by: multica-agent <[email protected]> * test(daemon): authenticate fixtures as the runtime owner (MUL-8154) Most handler tests drove daemon routes with a daemon token whose daemon_id did not match the fixture runtime, relying on the workspace-wide token scope this fix removes. They now authenticate the way real daemons do, with the runtime owner's user credential (newOwnerDaemonRequest). Tests about daemon token scoping keep mdt_ and get fixtures with a matching daemon_id; the ownerless-runtime tests also assert that a user credential is refused. Co-authored-by: multica-agent <[email protected]> * fix(daemon): serialize registration per daemon and keep Qoder WS claims working (MUL-8154) Review of #9423: - P1: the owner pre-check and the per-provider/profile upserts hit different conflict keys, so two members registering different providers of the same daemon could both pass the pre-check and both insert, leaving one daemon with two owners (and a daemon token for either runtime reaching the other's). Registration now runs the pre-check and every runtime write in one transaction under a pg_advisory_xact_lock on (workspace_id, daemon_id), on a single connection so queued registrations cannot starve the holder. Legacy merges, analytics and the register event run after commit; failed-profile records use savepoints so they stay best-effort. - P2: the Qoder revocation check read the Authorization header, which the WS RPC bridge's in-process request does not carry, so valid Qoder WS claims silently returned nothing. DaemonAuth now records the mdt_ token hash in the request context, the WS identity keeps it, the RPC bridge re-attaches it, and the check validates that hash on every call (revocation still applies to an open connection). - Regression tests for cross-provider and provider+profile concurrent registration, and for valid/revoked Qoder credentials over WS RPC. Co-authored-by: multica-agent <[email protected]> * fix(daemon): bind daemon tokens to the runtime owner they were issued for (MUL-8154) Second review of #9423: a daemon_id can be re-registered by another member after its runtimes are deleted, and a daemon token was bound only to (workspace, daemon_id), so the previous owner's Remote MCP token reached the new owner's runtime (heartbeat, claim, owner-scoped task token). - daemon_token.owner_id (migration 588). Remote MCP tokens are bound to the locked runtime owner inside FinalizeTaskClaim; live tokens are backfilled only where their daemon has exactly one owner. - DaemonAuth carries the owner binding (DB and cache; cache key bumped to v2 so pre-upgrade entries are never read), the WS identity keeps it and the RPC bridge replays it. - Daemon-token authorization now also requires token owner == runtime owner. Qoder-managed tokens stay unbound (governed by cloud_agent_connection), and user credentials can no longer register the reserved qoder-managed-* ids. - Tests: the reuse lifecycle over HTTP, a new WS handshake and an already open WS connection; the reserved daemon_id; the concurrency barrier now hooks the registration transaction (TxStarter) so the interleaving is real. Co-authored-by: multica-agent <[email protected]> * fix(daemon): owner-bind daemon-token registration; drop unsafe token backfill (MUL-8154) Third review of #9423: - Migration 588 no longer backfills daemon_token.owner_id from the daemon's current owner: that owner may be a later re-registrant, which re-bound the previous owner's live token to them. Existing non-Qoder tokens stay NULL and are refused (24h Remote MCP credentials, re-issued on the next claim). - owner_id is a plain UUID column, no foreign key / cascade (AGENTS.md). - Register via a non-Qoder daemon token now uses the token's owner for the in-transaction pre-check and the SQL owner fence, and refuses unbound tokens. Qoder keeps its connection-based check; daemon tokens still never trigger legacy merges. - Tests: an old owner's bound token and an unbound legacy token can neither register over nor heartbeat the replacement owner's runtime. Co-authored-by: multica-agent <[email protected]> * fix(daemon): grant the Qoder registration exemption only with a live connection (MUL-8154) Fourth review of #9423: register skipped the token owner binding for any qoder-managed-* daemon token, but checked the Qoder connection only when the body declared a qoder_cloud runtime. A pre-upgrade Remote MCP token under that prefix (no owner, no connection) could register type=claude and rewrite another member's runtime. Register now verifies the token against a live cloud_agent_connection before anything else whenever the token is in the Qoder namespace, regardless of the declared runtimes, and restricts that namespace to the bridge's own shape: a built-in qoder_cloud runtime, no custom profiles, no failed_profiles. Test covers built-in, custom-profile, failed-profiles-only and qoder_cloud bodies with an unconnected token, plus the connected token's allowed and refused shapes. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
396 lines
16 KiB
Go
396 lines
16 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/multica-ai/multica/server/internal/testutil"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
func claimWorkerReplyRun(t *testing.T, runtimeID string) *AgentTaskResponse {
|
|
t.Helper()
|
|
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
req.Header.Set("X-Client-Capabilities", protocol.DaemonCapabilityCoalescedCommentsV1)
|
|
var response struct {
|
|
Task *AgentTaskResponse `json:"task"`
|
|
}
|
|
testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusOK).JSON(&response)
|
|
return response.Task
|
|
}
|
|
|
|
func completeWorkerReplyRun(t *testing.T, taskID string) {
|
|
t.Helper()
|
|
req := newOwnerDaemonRequest(http.MethodPost, "/api/daemon/tasks/"+taskID+"/complete", map[string]any{"output": "Processed the inputs delivered to this run"})
|
|
req = withURLParam(req, "taskId", taskID)
|
|
testutil.Call(t, testHandler.CompleteTask, req).Want(http.StatusOK)
|
|
}
|
|
|
|
// A worker progress comment wakes the leader; its final reply must also reach a
|
|
// leader run even if the first wake was claimed before the reply arrived.
|
|
func TestWorkerReplyDelivery(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
for _, state := range []string{"queued", "dispatched", "running"} {
|
|
for _, explicit := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("%s/explicit=%t", state, explicit), func(t *testing.T) {
|
|
ctx := context.Background()
|
|
leaderRuntimeID := dbfx.Runtime(t, "Worker handoff leader runtime")
|
|
leaderID := dbfx.Agent(t, "Worker handoff leader", leaderRuntimeID, testutil.Cols{"max_concurrent_tasks": 3})
|
|
workerRuntimeID := dbfx.Runtime(t, "Worker handoff worker runtime")
|
|
workerID := dbfx.Agent(t, "Worker handoff worker", workerRuntimeID)
|
|
squadID := dbfx.Squad(t, "Worker handoff squad", leaderID)
|
|
dbfx.SquadMember(t, squadID, "agent", workerID)
|
|
issueID := dbfx.Issue(t, "Worker results must reach the coordinator", testutil.Cols{
|
|
"status": "in_progress", "assignee_type": "squad", "assignee_id": squadID,
|
|
})
|
|
sourceID := dbfx.Task(t, leaderID, testutil.Cols{
|
|
"runtime_id": leaderRuntimeID, "issue_id": issueID, "status": "completed",
|
|
"is_leader_task": true, "squad_id": squadID,
|
|
"originator_user_id": testUserID, "accountable_user_id": testUserID,
|
|
})
|
|
rootID := dbfx.Comment(t, issueID, fmt.Sprintf("[@Worker](mention://agent/%s) verify the implementation", workerID), testutil.Cols{
|
|
"author_type": "agent", "author_id": leaderID, "source_task_id": sourceID,
|
|
})
|
|
workerTaskID := dbfx.Task(t, workerID, testutil.Cols{
|
|
"runtime_id": workerRuntimeID, "issue_id": issueID, "status": "running",
|
|
"trigger_comment_id": rootID, "squad_id": squadID, "delegated_from_task_id": sourceID,
|
|
"originator_user_id": testUserID, "accountable_user_id": testUserID,
|
|
})
|
|
post := func(content string) CommentResponse {
|
|
req := withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", map[string]any{
|
|
"content": content, "parent_id": rootID,
|
|
}), "id", issueID)
|
|
req.Header.Set("X-Agent-ID", workerID)
|
|
req.Header.Set("X-Task-ID", workerTaskID)
|
|
var response CommentResponse
|
|
testutil.Call(t, testHandler.CreateComment, req).Want(http.StatusCreated).JSON(&response)
|
|
if response.AuthorType != "agent" || response.SourceTaskID == nil || *response.SourceTaskID != workerTaskID {
|
|
t.Fatal("worker reply lost its authenticated source task")
|
|
}
|
|
return response
|
|
}
|
|
progress := post("Verification started; final results will follow in this thread")
|
|
var leaderTaskID string
|
|
dbfx.QueryRow(t, `SELECT id FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`, issueID, leaderID).Scan(&leaderTaskID)
|
|
var first *AgentTaskResponse
|
|
if state != "queued" {
|
|
first = claimWorkerReplyRun(t, leaderRuntimeID)
|
|
if first == nil || first.ID != leaderTaskID || !slices.Contains(first.DeliveredCommentIDs, progress.ID) {
|
|
t.Fatal("first leader run did not receive the worker's progress")
|
|
}
|
|
}
|
|
if state == "running" {
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(leaderTaskID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
content := "Verification complete: found a correctness issue; please coordinate the repair"
|
|
if explicit {
|
|
content = fmt.Sprintf("[@Squad](mention://squad/%s) %s", squadID, content)
|
|
}
|
|
details := post("Evidence: the final reply can arrive after the leader claims its inputs")
|
|
result := post(content)
|
|
stored, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(leaderTaskID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state == "dispatched" && !explicit {
|
|
for _, id := range []string{details.ID, result.ID} {
|
|
if !slices.Contains(stored.CoalescedCommentIds, parseUUID(id)) || slices.Contains(stored.DeliveredCommentIds, parseUUID(id)) {
|
|
t.Fatal("accepted worker reply must be planned without changing the earlier delivery receipt")
|
|
}
|
|
}
|
|
if stored.TriggerCommentID != parseUUID(progress.ID) {
|
|
t.Fatal("registering a worker reply must not replace an already claimed trigger")
|
|
}
|
|
}
|
|
if state == "queued" {
|
|
first = claimWorkerReplyRun(t, leaderRuntimeID)
|
|
if first == nil || first.ID != leaderTaskID || !slices.Contains(first.DeliveredCommentIDs, result.ID) || !slices.Contains(first.DeliveredCommentIDs, details.ID) || first.TriggerCommentContent != content {
|
|
t.Fatal("queued leader must coalesce and receive the worker result")
|
|
}
|
|
} else if slices.Contains(first.DeliveredCommentIDs, result.ID) {
|
|
t.Fatal("an earlier claim cannot have delivered a later comment")
|
|
}
|
|
if state != "running" {
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(leaderTaskID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if other := claimWorkerReplyRun(t, leaderRuntimeID); other != nil {
|
|
t.Fatal("same issue/leader must remain serialized")
|
|
}
|
|
completeWorkerReplyRun(t, leaderTaskID)
|
|
next := claimWorkerReplyRun(t, leaderRuntimeID)
|
|
if state == "queued" {
|
|
if next != nil {
|
|
t.Fatal("already delivered result must not create another leader run")
|
|
}
|
|
return
|
|
}
|
|
if next == nil {
|
|
t.Fatal("worker result persisted but was neither delivered nor followed by another leader run")
|
|
}
|
|
if !next.IsLeaderTask || !slices.Contains(next.DeliveredCommentIDs, result.ID) || !slices.Contains(next.DeliveredCommentIDs, details.ID) || next.TriggerCommentContent != content {
|
|
t.Fatal("follow-up must deliver the result in the squad leader role")
|
|
}
|
|
successor, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(next.ID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if successor.SquadID != parseUUID(squadID) || successor.OriginatorUserID != parseUUID(testUserID) || successor.AccountableUserID != parseUUID(testUserID) || successor.DelegatedFromTaskID != parseUUID(workerTaskID) {
|
|
t.Fatal("worker follow-up lost squad or human delegation provenance")
|
|
}
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(next.ID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
completeWorkerReplyRun(t, next.ID)
|
|
if another := claimWorkerReplyRun(t, leaderRuntimeID); another != nil {
|
|
t.Fatal("worker result must not generate an endless leader follow-up loop")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unplanned replies must be in the completing run's thread and timestamp window:
|
|
// otherwise a passing negative case could merely be the SQL scope excluding it.
|
|
func TestWorkerReplyReconcileBoundaries(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
for _, mode := range []string{"accepted", "unplanned", "suppressed", "leader_reply", "note", "archived_leader", "reassigned", "registration_failure", "completed_before_registration"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
ctx := context.Background()
|
|
runtimeID := dbfx.Runtime(t, "Worker replay boundary leader")
|
|
leaderID := dbfx.Agent(t, "Worker replay boundary leader", runtimeID)
|
|
workerRuntimeID := dbfx.Runtime(t, "Worker replay boundary worker")
|
|
workerID := dbfx.Agent(t, "Worker replay boundary worker", workerRuntimeID)
|
|
squadID := dbfx.Squad(t, "Worker replay boundary squad", leaderID)
|
|
dbfx.SquadMember(t, squadID, "agent", workerID)
|
|
issueID := dbfx.Issue(t, "Worker replay boundary", testutil.Cols{
|
|
"status": "in_progress", "assignee_type": "squad", "assignee_id": squadID,
|
|
})
|
|
rootID := dbfx.Comment(t, issueID, "Coordinate this work", testutil.Cols{"created_at": testutil.Raw("now() - interval '6 minutes'")})
|
|
taskID := dbfx.Task(t, leaderID, testutil.Cols{
|
|
"runtime_id": runtimeID, "issue_id": issueID, "status": "queued",
|
|
"trigger_comment_id": rootID, "is_leader_task": true, "squad_id": squadID,
|
|
"originator_user_id": testUserID, "accountable_user_id": testUserID,
|
|
"created_at": testutil.Raw("now() - interval '5 minutes'"),
|
|
})
|
|
workerTaskID := dbfx.Task(t, workerID, testutil.Cols{
|
|
"runtime_id": workerRuntimeID, "issue_id": issueID, "status": "running",
|
|
"trigger_comment_id": rootID, "squad_id": squadID,
|
|
"originator_user_id": testUserID, "accountable_user_id": testUserID,
|
|
})
|
|
first := claimWorkerReplyRun(t, runtimeID)
|
|
if first == nil || first.ID != taskID {
|
|
t.Fatal("leader run was not claimed")
|
|
}
|
|
var replyID string
|
|
if mode == "unplanned" {
|
|
replyID = dbfx.Comment(t, issueID, "An ordinary reply with no accepted dispatch", testutil.Cols{
|
|
"parent_id": rootID, "author_type": "agent", "author_id": workerID, "source_task_id": workerTaskID,
|
|
})
|
|
} else {
|
|
body := map[string]any{"content": "The worker has results", "parent_id": rootID}
|
|
if mode == "note" {
|
|
body["content"] = "/note an informational update"
|
|
}
|
|
if mode == "suppressed" {
|
|
body["suppress_agent_ids"] = []string{leaderID}
|
|
}
|
|
req := withURLParam(newRequest(http.MethodPost, "/api/issues/"+issueID+"/comments", body), "id", issueID)
|
|
req.Header.Set("X-Agent-ID", workerID)
|
|
req.Header.Set("X-Task-ID", workerTaskID)
|
|
if mode == "leader_reply" {
|
|
req.Header.Set("X-Agent-ID", leaderID)
|
|
req.Header.Set("X-Task-ID", taskID)
|
|
}
|
|
var response CommentResponse
|
|
h := *testHandler
|
|
registration := &workerReplyRegistrationDB{DBTX: testPool}
|
|
if mode == "registration_failure" {
|
|
registration.err = errors.New("injected worker reply registration failure")
|
|
}
|
|
if mode == "completed_before_registration" {
|
|
registration.before = func() {
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(taskID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
completeWorkerReplyRun(t, taskID)
|
|
}
|
|
}
|
|
if mode == "registration_failure" || mode == "completed_before_registration" {
|
|
h.Queries = db.New(registration)
|
|
}
|
|
testutil.Call(t, h.CreateComment, req).Want(http.StatusCreated).JSON(&response)
|
|
if (mode == "registration_failure" || mode == "completed_before_registration") && registration.calls != 1 {
|
|
t.Fatalf("registration probe called %d times", registration.calls)
|
|
}
|
|
replyID = response.ID
|
|
}
|
|
task, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(taskID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
comments, err := testHandler.Queries.ListReconcilableCommentsForIssueSince(ctx, db.ListReconcilableCommentsForIssueSinceParams{
|
|
IssueID: task.IssueID, CommentThreadID: task.CommentThreadID, Since: task.CreatedAt,
|
|
PlannedCommentIds: task.CoalescedCommentIds,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.ContainsFunc(comments, func(c db.Comment) bool { return c.ID == parseUUID(replyID) }) {
|
|
t.Fatal("reply must reach the replay filter, not be excluded by the SQL thread/time window")
|
|
}
|
|
wantPlanned := mode == "accepted" || mode == "archived_leader" || mode == "reassigned"
|
|
if slices.Contains(task.CoalescedCommentIds, parseUUID(replyID)) != wantPlanned {
|
|
t.Fatalf("unexpected creation-time obligation for %s", mode)
|
|
}
|
|
if mode == "archived_leader" {
|
|
dbfx.Exec(t, `UPDATE agent SET archived_at = now() WHERE id = $1`, leaderID)
|
|
}
|
|
if mode == "reassigned" {
|
|
dbfx.Exec(t, `UPDATE issue SET assignee_type = 'agent', assignee_id = $2 WHERE id = $1`, issueID, workerID)
|
|
}
|
|
if mode != "completed_before_registration" {
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(taskID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
completeWorkerReplyRun(t, taskID)
|
|
}
|
|
queued := dbfx.Count(t, `SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND status = 'queued'`, issueID)
|
|
if mode != "accepted" && mode != "completed_before_registration" {
|
|
if queued != 0 {
|
|
t.Fatalf("%s must not create a completion-driven run, got %d", mode, queued)
|
|
}
|
|
return
|
|
}
|
|
if queued != 1 {
|
|
t.Fatalf("accepted reply must create exactly one successor, got %d", queued)
|
|
}
|
|
next := claimWorkerReplyRun(t, runtimeID)
|
|
if next == nil || !slices.Contains(next.DeliveredCommentIDs, replyID) {
|
|
t.Fatal("accepted reply was not delivered")
|
|
}
|
|
if _, err := testHandler.TaskService.StartTask(ctx, parseUUID(next.ID)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
completeWorkerReplyRun(t, next.ID)
|
|
if extra := claimWorkerReplyRun(t, runtimeID); extra != nil {
|
|
t.Fatal("delivered worker reply replayed again")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// If completion commits while registration waits for the task row lock, the
|
|
// caller must see a miss and enqueue fresh, not attach an obligation to a run
|
|
// whose completion snapshot can no longer include it.
|
|
func TestWorkerReplyRegistrationLosesCompletionRace(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
runtimeID := dbfx.Runtime(t, "Worker registration race")
|
|
agentID := dbfx.Agent(t, "Worker registration race", runtimeID)
|
|
issueID := dbfx.Issue(t, "Worker registration race")
|
|
rootID := dbfx.Comment(t, issueID, "Initial input")
|
|
replyID := dbfx.Comment(t, issueID, "Worker result", testutil.Cols{"parent_id": rootID})
|
|
taskID := dbfx.Task(t, agentID, testutil.Cols{
|
|
"runtime_id": runtimeID, "issue_id": issueID, "status": "running", "trigger_comment_id": rootID,
|
|
})
|
|
tx, err := testPool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() {
|
|
if err := tx.Rollback(context.Background()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
|
|
t.Error(err)
|
|
}
|
|
}()
|
|
if _, err := tx.Exec(ctx, `UPDATE agent_task_queue SET status = 'completed', completed_at = now() WHERE id = $1`, taskID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
conn, err := testPool.Acquire(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer conn.Release()
|
|
pid := conn.Conn().PgConn().PID()
|
|
done := make(chan struct{})
|
|
var registrationErr error
|
|
go func() {
|
|
_, registrationErr = db.New(conn).RegisterPlannedCommentForActiveTask(ctx, db.RegisterPlannedCommentForActiveTaskParams{
|
|
IssueID: parseUUID(issueID), AgentID: parseUUID(agentID), CommentID: parseUUID(replyID),
|
|
})
|
|
close(done)
|
|
}()
|
|
// Stop the registration query before returning its connection to the pool,
|
|
// including assertion failures while it is waiting for the task row lock.
|
|
defer func() { cancel(); <-done }()
|
|
for {
|
|
var blocked bool
|
|
if err := testPool.QueryRow(ctx, `SELECT cardinality(pg_blocking_pids($1)) > 0`, pid).Scan(&blocked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if blocked {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
t.Fatal("registration did not block on the completing row")
|
|
case <-time.After(5 * time.Millisecond):
|
|
}
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
if !errors.Is(registrationErr, pgx.ErrNoRows) {
|
|
t.Fatalf("registration after completion = %v, want no rows", registrationErr)
|
|
}
|
|
task, err := testHandler.Queries.GetAgentTask(ctx, parseUUID(taskID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if slices.Contains(task.CoalescedCommentIds, parseUUID(replyID)) {
|
|
t.Fatal("completed run acquired an undeliverable obligation")
|
|
}
|
|
}
|
|
|
|
// Intercept only the new obligation write; all routing, comment persistence,
|
|
// completion, and successor enqueue still use their real database paths.
|
|
type workerReplyRegistrationDB struct {
|
|
db.DBTX
|
|
before func()
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (d *workerReplyRegistrationDB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row {
|
|
if strings.Contains(query, "-- name: RegisterPlannedCommentForActiveTask :one") {
|
|
d.calls++
|
|
if d.before != nil {
|
|
d.before()
|
|
}
|
|
if d.err != nil {
|
|
return errRow{err: d.err}
|
|
}
|
|
}
|
|
return d.DBTX.QueryRow(ctx, query, args...)
|
|
}
|