Files
multica/server/internal/handler/claim_project_context_test.go
f39debf8d1 MUL-7344: skip the issue read and the comment scan when the server already answered them (#8377)
* feat(daemon): read the comment delta with one --since, not a roots scan (MUL-7344)

A resumed follow-up turn whose claim carried a server-computed issue-wide
comment delta was still told to run the workflow's two-step scan: a
`--roots-only --summary` pass to discover which threads moved, then a
`--thread ... --since ...` expansion per thread. That rediscovers, in two or
more calls, an answer the server already has.

`--since <anchor>` without `--thread` IS that answer.
ListCommentsSinceForIssue returns every comment on the issue created after the
anchor, in every thread and at every nesting level, with no folding, on the
same `created_at > since` predicate CountNewCommentsSince counted with. It is a
strict superset of what the roots scan could surface: roots-only
`last_activity_at` is MAX(created_at) over a thread, so any comment able to
move a thread's activity is already in the `--since` result.

So BuildNewCommentsHint now hands over exactly one wide read and says it
answers step 2's scan — the same rule BuildResumedCommentsHint already applies
to a server-computed EMPTY delta. The optional second command stays a THREAD
read on `--tail 30` for the reply itself; it is never the wide read, and it
never combines `--thread` with `--since`, which drops the thread root.

Brief step 2 and the multica-platform skill's bounded-reads rule both gain the
matching static sentence, so an agent is not left choosing between the hint and
two surfaces that contradict it.

Cold start, dropped resume and unknown-delta paths are untouched, byte for
byte: only an affirmative server report may stand in for the scan.

Co-authored-by: multica-agent <[email protected]>

* feat(claim): compare the issue against the last run so a resumed turn can skip the read (MUL-7344)

A follow-up turn that resumes its provider session already holds the issue it
read minutes ago, and was still told, unconditionally, to run `multica issue
get` before doing anything. On an issue that did not move, that read returns
bytes the session already has.

The claim now answers the question instead. Each claim records the comparable
state of the issue it built its payload from — status, assignee, priority, and
sha256 of title and description — in a new `agent_task_queue.issue_snapshot`
JSONB column, and compares against the snapshot the SAME agent's previous run
on the SAME issue recorded. The response carries `issue_state_delta_known`,
`issue_changed_fields`, and the current `issue_status` / assignee.

Why a per-claim snapshot and not a `content_revision` on `issue`: a revision
column has to be bumped by every writer that touches one of those five fields,
and the first one missed reports "unchanged" forever with no symptom. A
snapshot taken at claim time has exactly one writer. It also rides the anchor
the comment delta already uses (agent_id, issue_id, started_at DESC), so both
deltas on a claim mean "since your last run" in the same sense.

The daemon renders one of three things, gated on a REAL resume:

  - unchanged  → reported as workflow step 1's answer; the read survives only
                 as "if resumed memory is not enough".
  - changed    → the changed field NAMES plus the read, so the agent knows what
                 to look for instead of diffing blind.
  - otherwise  → the pre-existing instruction, byte for byte.

`issue_state_delta_known` carries the same contract as
`new_comments_delta_known` (MUL-6984), and for the same reason: a cold start,
a missing prior snapshot, a read error, a foreign shape version and an old
server all produce an empty changed-field list, identical to a genuine
"nothing changed". Only the flag separates them, and only the affirmative one
may stand in for the read.

The compared set is named in full in the prompt rather than summarised as "the
issue": labels, parent, due date, stage, project and metadata are NOT compared,
and an agent told "unchanged" has to be able to see that.

The snapshot write is NOT gated on the task being comment-backed — an
assignment run that recorded nothing would leave the next run with no baseline.
It shares the receipt's CAS, so a stale handler cannot write over a newer
reclaim or after execution starts, and a snapshot that fails to encode is
simply not recorded: the next run reports "not compared" and reads the issue.

Co-authored-by: multica-agent <[email protected]>

* fix(claim): renumber the migration past main and fold in review nits (MUL-7344)

CI caught a real collision: main landed 471_comment_deleted_at through
474 while this branch was open, so 471_agent_task_issue_snapshot shared a
numeric prefix and TestMigrationNumericPrefixesAreUnique failed. Renumbered to
475 after merging main; no other migration references it.

Review nits from the PR review, all non-blocking, all cheap to take now:

1. Brief step 2's closing sentence told the agent that a resumed run's scan
   surfaces moved threads via `last_activity_at`. That is now true only on the
   path where the delta is UNKNOWN — when the server hands over a `--since`
   delta there is no scan to read it from. Rewritten to attach the
   `last_activity_at` guidance to the scan itself, wherever the scan happens.
   Done in this PR rather than deferred because this PR already moves brief
   bytes, so it costs no extra prompt-cache invalidation.

2. The two per-claim anchor reads are now one. GetLastTaskStartedAtForIssueAndAgent
   and GetLastTaskIssueSnapshotForIssueAndAgent selected the same row with the
   same WHERE and ORDER, differing only in projection; they are replaced by
   GetLastRunAnchorForIssueAndAgent returning started_at and issue_snapshot
   together. A comment-triggered claim is back to ONE anchor read, as before
   this branch.

   The saved round trip is the smaller half. The real gain is that "since your
   last run" is now a single resolved fact on a claim instead of two
   independently-resolved ones that could disagree. The shared read also shares
   a failure mode, and both consumers already degrade the same safe way: comment
   scan required, issue comparison unknown.

3. The hint's `%d new comment(s)` excludes the injected trigger and the agent's
   own comments; the `--since` read it hands over has no such filter, so the row
   count is legitimately higher. Narrowing the read to match would mean a second
   count-shaped query for no gain, so the text now states the mismatch and its
   direction outright instead of leaving the agent to reconcile two numbers.

New test pins the merge: one claim, one prior run, both deltas resolved from
that single anchor row.

Co-authored-by: multica-agent <[email protected]>

* fix(migrations): renumber the issue-snapshot migration to 478 (MUL-7344)

main landed 475_issue_status_key_not_reserved through 477 while the previous
renumber was in flight, so 475_agent_task_issue_snapshot collided in turn.
Merged main again and moved to 478. TestMigrationNumericPrefixesAreUnique
passes, and the full ledger applies cleanly from scratch through 478.

Co-authored-by: multica-agent <[email protected]>

* fix(claim): measure both deltas from the run whose session is resumed (MUL-7344)

Review found a real correctness bug, not a performance one: the claim could tell
a resumed agent "the issue is unchanged" while the session it handed back
predated the change.

Both deltas were anchored on the most recently STARTED run for (agent, issue).
The session the daemon resumes is chosen by a different rule, and the two are
not the same row:

  - GetLastTaskSession skips poisoned and retired sessions, so a newer run that
    died on an unprocessable conversation is passed over for an OLDER healthy
    one.
  - A manual rerun resumes the exact source task the operator clicked.

So: an old session remembers description A, the user edits it to B, a newer run
records B and then dies unresumable. The next follow-up resumes the OLD session
but compares B against B, reports "unchanged", and the agent keeps working from
a context that still says A. PriorSessionResumeUnavailable does not catch this —
it flags a missing Codex rollout, which is only one of the ways the resumed
session can be older than the newest run.

The fix binds both deltas to the run actually being resumed. GetLastTaskSession
now returns that row's started_at and issue_snapshot alongside its session, the
rerun path takes them from its source task, and the delta computation moves
after resume resolution so it can only ever read the adopted row. Where no
session is adopted — no prior session, a session on another runtime, a
forced-fresh retry, a rerun whose source failure poisoned the conversation — no
delta is reported and the daemon performs the reads it always has.

That makes the standalone anchor query unnecessary: the anchor is now a
projection on a row the claim already fetched, so a claim reads no extra row at
all (GetLastRunAnchorForIssueAndAgent is removed).

The precondition is now visible in the fixtures: a prior run has to be
resumable for a delta to exist, so the comment-delta tests seed a session_id.
The regression test covers both paths and was verified to fail against the
previous anchor — both sub-cases report an empty changed-field list, which is
exactly the wrong "unchanged".

Also renumbers the migration 478 → 491; main landed 478_issue_status_category_expand
through 490 while the branch was open.

Co-authored-by: multica-agent <[email protected]>

* fix(claim): a never-started anchor row may date neither delta (MUL-7344)

Final review found the last corner of the resumed-run bug. Binding the anchor to
the resumed SESSION was necessary but not sufficient: GetLastTaskSession returns
that session's latest TERMINAL row, and that row is not always a run that ran.

A retry child can inherit the session, be claimed — which writes its snapshot —
and then fail before the agent ever starts: runtime offline during prepare,
cancelled while dispatched. Its snapshot describes the issue as of ITS claim,
while the session's memory stops at the last run that actually executed. If the
description was edited in between, the current issue matches that snapshot and
the claim reports "unchanged" to an agent whose context predates the edit.

The started_at gate now covers both deltas rather than only the comment one.
The comment delta was already immune because it read StartedAt directly; the
issue delta read only the snapshot, so it had no such guard. Hoisting the check
makes the row's requirement explicit — a row that never ran dates nothing — and
the inner duplicate is dropped so no reader has to wonder what each guards.

Regression test reproduces the exact fixture and was verified to fail without
the gate: known=true with an empty changed-field list, i.e. the false
"unchanged".

Also from review:

- Adopt the no-adopted-session table (missing session, session on another
  runtime, force-fresh retry, poisoned rerun source). Each seeds a usable
  snapshot AND an unread comment, so a regression has something to report and
  the assertion cannot pass on an empty fixture.
- The resumed-run regression test asserted a comment anchor that never
  executed: both sub-cases created only the trigger comment, which the count
  excludes, so NewCommentsSince was empty and the branch was dead. A comment
  between the two runs now makes it run and pins known/count/since.
- Rewrite the migration header, which still described the old
  "(agent_id, issue_id, started_at DESC)" anchor and an index rationale for a
  column that is never a lookup key.

Co-authored-by: multica-agent <[email protected]>

* refactor(claim): compare title, description and status only (MUL-7344)

The snapshot compared five fields. The comparison answers exactly one question
— must the agent run `issue get` again? — so a field earns a place only if
changing it alters what the agent does AND its current value is not already in
the per-turn message.

Assignee fails the second test: the claim ships the current assignee outright,
and "is this mine now" is answered by that value, not by whether it moved.
Priority fails the first: it is reachable only by reading the issue, but it does
not change what the agent does. Both are dropped.

Status stays even though it is also shipped as a current value, so comparing it
is strictly redundant for "what is it now". It earns its place as a signal
rather than a value: "changed: status" is the clearest evidence that somebody
intervened between the runs — a push back from in_review to todo means the
delivery was rejected — and it costs nothing to carry.

Narrowing the set means a narrower claim, so the prompt now says "compared
title, description and status". An issue whose only change is assignee or
priority is reported as unchanged, which is correct and now pinned by a test
that also asserts the current assignee still ships — without that second
assertion the fixture's UPDATE could silently not apply and the test would pass
on nothing.

Snapshot version goes to 2, because the rule is that the version tracks the
compared SET. No deployment has written a v1 row, but the gate is what makes
narrowing safe in general: a v1 row read by v2 code is "not compared", which
degrades to the unconditional read.

Test fixtures now build snapshots at issueSnapshotVersion rather than a literal
1, so a future bump cannot quietly turn them into not-compared tests while they
keep passing. The foreign-version case stays explicit at +1.

Also renumbers the migration 491 -> 495; main landed 491 through 494 while the
branch was open. Fifth renumber.

Co-authored-by: multica-agent <[email protected]>

* refactor(claim): keep the snapshot at v1 (MUL-7344)

The previous commit bumped issueSnapshotVersion to 2 when it narrowed the
compared set. That was the version rule applied past its purpose.

The rule — "bump when the compared SET changes" — exists to stop a stored
snapshot from being compared against a differently-shaped one. It protects
DEPLOYED rows. This column is introduced by this same unmerged PR: the
migration exists only on this branch, main has neither the column nor the
writer, and no environment has ever stored a row with the wider set. There is
nothing for the bump to protect, and shipping v2 as the first version the
column has ever held would leave a reader hunting for a v1 that never existed.

Back to 1. The `v` field and its unknown-version gate stay exactly as they
were — those are what make a real narrowing safe later. The comment now says
which version this is and why the review-time narrowing did not earn a bump,
so the rule still has a stated precedent without a burned number.

No behaviour change: the compared set is still title, description and status,
and fixtures build at issueSnapshotVersion rather than a literal, so they
followed the constant back down.

Co-authored-by: multica-agent <[email protected]>

* fix(claim): only a completed run may date a delta (MUL-7344)

Final review found a window one step later than the never-started case.

A snapshot is written at CLAIM time. The daemon then writes started_at
(TaskService.StartTask) and only afterwards launches the provider. So a run can
carry both a snapshot and a started_at while its prompt never reached the
session: the daemon exits during prepare, the runtime drops, orphan recovery
marks the row failed. Anchoring there compares the current issue against a
snapshot describing an edit that session never saw — reporting "unchanged"
across a real change, and dating the comment delta late enough to hide every
comment older than that row's start. Reproduced on the real
CreateRetryTask -> claim -> StartTask -> RecoverOrphanedTasksForRuntime ->
follow-up claim path.

Nothing on the row separates "failed after the provider ran" from "failed
before it ran". session_id is inherited by CreateRetryTask at insert, so it does
not mean the session was fed; started_at precedes the launch, so it does not
mean the turn began. The only terminal status that PROVES delivery is
'completed' — a run cannot finish a turn it never started.

So failed and cancelled sources now date neither delta. Their SESSION is still
resumed exactly as before, and GetLastTaskSession still accepts them on purpose;
those runs simply perform the context reads they always did. That is the cheap
side of the trade: a follow-up after a failure or a stop is already recovering
from something, while the case the optimization exists for — an ordinary
follow-up after a healthy run — is untouched.

Both conditions now live in newResumedRunAnchor rather than at the use site, so
"which run may date a delta" is answered once, and GetLastTaskSession carries
the status needed to answer it.

Regression test is the reviewer's repro, verified to fail without the rule:
known=true with an empty changed list AND a computed zero comment count, i.e.
both a false "unchanged" and a false "no new comments".

Also:
- Renumber the migration 495 -> 499; main landed 495 through 498. Sixth
  renumber.
- The migration's content_revision rationale named the old five-field set while
  arguing about hypothetical issue-row writers; it now refers to "any compared
  field", which is what the argument actually depends on.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 16:11:16 +08:00

528 lines
20 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// The claim path resolves project context from a SOFT reference: issue.project_id,
// chat_session.project_id and the quick-create context JSONB are all plain
// columns with no foreign key behind them (see the repo's no-FK rule). These
// tests pin the tenant boundary that resolveClaimProjectContext is responsible
// for, using the only states that can actually produce a cross-tenant read:
// a dangling reference to a project in another workspace, and a project_resource
// row whose own workspace_id disagrees with its project's.
//
// Both were reachable before MUL-6547: the issue and quick-create branches read
// the project with GetProject (no workspace predicate) and every branch listed
// resources by project_id alone.
// foreignWorkspaceWithProject builds a complete project in a DIFFERENT workspace
// and returns (workspaceID, projectID). The caller points a same-workspace task
// at projectID to simulate the corrupt reference.
func foreignWorkspaceWithProject(t *testing.T, slug, prefix string) (string, string) {
t.Helper()
foreignWorkspaceID := dbfx.Insert(t, "workspace", testutil.Cols{
"name": "Foreign " + slug,
"slug": slug,
"description": "",
"issue_prefix": prefix,
})
foreignProjectID := dbfx.Project(t, foreignProjectTitle, testutil.Cols{
"workspace_id": foreignWorkspaceID,
"description": foreignProjectDescription,
})
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": foreignProjectID,
"workspace_id": foreignWorkspaceID,
"resource_type": "github_repo",
"resource_ref": `{"url":"` + foreignRepoURL + `"}`,
"position": 0,
})
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": foreignProjectID,
"workspace_id": foreignWorkspaceID,
"resource_type": "local_directory",
"resource_ref": `{"daemon_id":"foreign-daemon","local_path":"` + foreignLocalPath + `","execution_mode":"in_place"}`,
"position": 1,
})
return foreignWorkspaceID, foreignProjectID
}
const (
foreignProjectTitle = "Foreign project must not leak"
foreignProjectDescription = "Foreign project instructions must not leak"
foreignRepoURL = "https://github.com/example/foreign-tenant-repo"
foreignLocalPath = "/srv/foreign-tenant-project"
localFallbackRepoURL = "https://github.com/example/local-workspace-fallback"
)
// assertNoForeignContext fails when any part of the foreign tenant's project
// reached the claim response, on any field the daemon forwards to the agent.
func assertNoForeignContext(t *testing.T, body string, extra ...string) {
t.Helper()
for _, leaked := range append([]string{
foreignProjectTitle,
foreignProjectDescription,
foreignRepoURL,
foreignLocalPath,
}, extra...) {
if leaked == "" {
continue
}
if strings.Contains(body, leaked) {
t.Errorf("claim response leaked foreign tenant context %q: %s", leaked, body)
}
}
}
type claimProjectFields struct {
WorkspaceID string `json:"workspace_id"`
Repos []RepoData `json:"repos"`
ProjectID string `json:"project_id"`
ProjectTitle string `json:"project_title"`
ProjectDescription string `json:"project_description"`
ProjectResources []ProjectResourceData `json:"project_resources"`
}
// An issue whose project_id points at another workspace's project must degrade
// to workspace context. Before MUL-6547 the issue branch used GetProject, which
// has no workspace predicate, so the foreign project's title, description,
// repository URL and local path were all serialized into the claim.
func TestClaimTask_IssueProjectInForeignWorkspace_DegradesToWorkspaceRepos(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
setHandlerTestWorkspaceRepos(t, []map[string]string{
{"url": localFallbackRepoURL, "description": "local"},
})
_, foreignProjectID := foreignWorkspaceWithProject(t, "foreign-issue-project-ws", "FIP")
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
testWorkspaceID,
).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "issue pointing at a foreign project", testutil.Cols{
"project_id": foreignProjectID,
"priority": "medium",
"number": 88101,
})
dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID,
"issue_id": issueID,
})
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
testWorkspaceID, "test-claim-foreign-issue-project")
req = withURLParam(req, "runtimeId", runtimeID)
w := testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusOK)
var resp struct {
Task *claimProjectFields `json:"task"`
}
w.JSON(&resp)
if resp.Task == nil {
t.Fatal("expected task in response")
}
assertNoForeignContext(t, w.Text(), foreignProjectID)
if resp.Task.ProjectID != "" {
t.Errorf("project_id = %q, want empty for an out-of-workspace project reference", resp.Task.ProjectID)
}
if len(resp.Task.ProjectResources) != 0 {
t.Errorf("project_resources = %+v, want none", resp.Task.ProjectResources)
}
if len(resp.Task.Repos) != 1 || resp.Task.Repos[0].URL != localFallbackRepoURL {
t.Fatalf("repos = %+v, want only the local workspace fallback", resp.Task.Repos)
}
}
// The quick-create branch resolves its project from the task context JSONB
// rather than an issue row, and used the same unscoped GetProject. It needs its
// own assertion: a requester who edits the stored project_id must not be able to
// pull another tenant's resources into the claim.
func TestClaimTask_QuickCreateProjectInForeignWorkspace_DegradesToWorkspaceRepos(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
setHandlerTestWorkspaceRepos(t, []map[string]string{
{"url": localFallbackRepoURL, "description": "local"},
})
_, foreignProjectID := foreignWorkspaceWithProject(t, "foreign-qc-project-ws", "FQP")
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
quickContext, _ := json.Marshal(map[string]any{
"type": "quick_create",
"prompt": "create a follow-up issue",
"requester_id": testUserID,
"workspace_id": testWorkspaceID,
"project_id": foreignProjectID,
})
dbfx.Exec(t, `
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, context)
VALUES ($1, $2, 'queued', 2, $3)
`, agentID, runtimeID, quickContext)
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
testWorkspaceID, daemonID)
req = withURLParam(req, "runtimeId", runtimeID)
w := testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusOK)
var resp struct {
Task *claimProjectFields `json:"task"`
}
w.JSON(&resp)
if resp.Task == nil {
t.Fatal("expected task in response")
}
assertNoForeignContext(t, w.Text(), foreignProjectID)
if resp.Task.ProjectID != "" {
t.Errorf("quick-create project_id = %q, want empty for an out-of-workspace project", resp.Task.ProjectID)
}
if len(resp.Task.ProjectResources) != 0 {
t.Errorf("quick-create project_resources = %+v, want none", resp.Task.ProjectResources)
}
if len(resp.Task.Repos) != 1 || resp.Task.Repos[0].URL != localFallbackRepoURL {
t.Fatalf("quick-create repos = %+v, want only the local workspace fallback", resp.Task.Repos)
}
}
// A project_resource row carries its own workspace_id, so a resource can
// disagree with the project it hangs off. Listing by project_id alone — which
// every claim branch did before MUL-6547 — serialized that row anyway. The
// in-workspace resource on the same project must still come through, proving the
// new predicate filters rather than just emptying the list.
func TestClaimTask_ProjectResourceFromForeignWorkspace_IsFilteredOut(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
foreignWorkspaceID, _ := foreignWorkspaceWithProject(t, "foreign-resource-ws", "FRW")
const localRepoURL = "https://github.com/example/local-project-repo"
projectID := dbfx.Project(t, "Project with a mismatched resource row")
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": projectID,
"workspace_id": testWorkspaceID,
"resource_type": "github_repo",
"resource_ref": `{"url":"` + localRepoURL + `"}`,
"position": 0,
})
// Same project, foreign workspace_id: only reachable through corrupt data,
// which is exactly the case the predicate exists for.
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": projectID,
"workspace_id": foreignWorkspaceID,
"resource_type": "github_repo",
"resource_ref": `{"url":"` + foreignRepoURL + `"}`,
"position": 1,
})
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": projectID,
"workspace_id": foreignWorkspaceID,
"resource_type": "local_directory",
"resource_ref": `{"daemon_id":"foreign-daemon","local_path":"` + foreignLocalPath + `","execution_mode":"in_place"}`,
"position": 2,
})
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
testWorkspaceID,
).Scan(&agentID, &runtimeID)
issueID := dbfx.Issue(t, "issue with a mismatched project resource", testutil.Cols{
"project_id": projectID,
"priority": "medium",
"number": 88102,
})
dbfx.Task(t, agentID, testutil.Cols{
"runtime_id": runtimeID,
"issue_id": issueID,
})
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
testWorkspaceID, "test-claim-foreign-resource")
req = withURLParam(req, "runtimeId", runtimeID)
w := testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusOK)
var resp struct {
Task *claimProjectFields `json:"task"`
}
w.JSON(&resp)
if resp.Task == nil {
t.Fatal("expected task in response")
}
assertNoForeignContext(t, w.Text())
if resp.Task.ProjectID != projectID {
t.Errorf("project_id = %q, want %q", resp.Task.ProjectID, projectID)
}
if len(resp.Task.ProjectResources) != 1 {
t.Fatalf("project_resources count = %d, want only the in-workspace row: %+v",
len(resp.Task.ProjectResources), resp.Task.ProjectResources)
}
if len(resp.Task.Repos) != 1 || resp.Task.Repos[0].URL != localRepoURL {
t.Fatalf("repos = %+v, want only the in-workspace project repo", resp.Task.Repos)
}
}
// A run_only autopilot owns its task's workspace. taskToResponse seeds
// resp.WorkspaceID with the RUNTIME's workspace, and the branch used to widen it
// only `if resp.WorkspaceID == ""` — a condition that never held, so the
// isolation check compared the runtime workspace against itself and a
// cross-workspace autopilot_run_id was dispatched instead of rejected.
func TestClaimTask_AutopilotRunOnlyForeignWorkspace_CancelsTask(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
var localAgentID, localRuntimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
testWorkspaceID,
).Scan(&localAgentID, &localRuntimeID)
foreignWorkspaceID, foreignProjectID := foreignWorkspaceWithProject(t, "foreign-autopilot-ws", "FAW")
const foreignAutopilotTitle = "Foreign autopilot must not leak"
foreignAutopilotID := dbfx.Insert(t, "autopilot", testutil.Cols{
"workspace_id": foreignWorkspaceID,
"project_id": foreignProjectID,
"title": foreignAutopilotTitle,
"assignee_id": localAgentID,
"execution_mode": "run_only",
"created_by_type": "member",
"created_by_id": testUserID,
})
foreignRunID := dbfx.Insert(t, "autopilot_run", testutil.Cols{
"autopilot_id": foreignAutopilotID,
"source": "manual",
"status": "running",
})
taskID := dbfx.Task(t, localAgentID, testutil.Cols{
"runtime_id": localRuntimeID,
"issue_id": nil,
"autopilot_run_id": foreignRunID,
})
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+localRuntimeID+"/claim", nil,
testWorkspaceID, "test-claim-foreign-autopilot")
req = withURLParam(req, "runtimeId", localRuntimeID)
w := testutil.Call(t, testHandler.ClaimTaskByRuntime, req).Want(http.StatusInternalServerError)
if !strings.Contains(w.Text(), "task workspace isolation check failed") {
t.Fatalf("claim error = %q, want the workspace isolation failure", w.Text())
}
assertNoForeignContext(t, w.Text(), foreignProjectID)
if strings.Contains(w.Text(), foreignAutopilotTitle) {
t.Errorf("claim error leaked the foreign autopilot title: %s", w.Text())
}
var status string
dbfx.QueryRow(t, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status)
if status != "cancelled" {
t.Fatalf("cross-workspace autopilot task status = %q, want cancelled", status)
}
}
// failQueryDBTX delegates every query to the real pool except the ones whose
// SQL contains failOn, which fail with a transient-looking error. db.Queries is
// built on the DBTX interface, so this is the seam that lets a single source
// read fail while the rest of the claim proceeds normally — the shape of a real
// DB blip, which a globally broken pool cannot reproduce (it trips the earlier
// agent-load guard instead).
type failQueryDBTX struct {
db.DBTX
failOn string
err error
}
type errRow struct{ err error }
func (r errRow) Scan(...any) error { return r.err }
func (f failQueryDBTX) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
if strings.Contains(sql, f.failOn) {
return errRow{err: f.err}
}
return f.DBTX.QueryRow(ctx, sql, args...)
}
func (f failQueryDBTX) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
if strings.Contains(sql, f.failOn) {
return nil, f.err
}
return f.DBTX.Query(ctx, sql, args...)
}
// A claim resolves its SOURCE row — issue, chat session, autopilot_run,
// autopilot — before anything else. Those reads used to be written as
// `if x, err := ...; err == nil {`, with no else: a failed read silently
// skipped the whole branch. taskToResponse has already seeded resp.WorkspaceID
// with the RUNTIME's workspace by then, so the skipped claim sailed through the
// builder's backstop isolation check (which compared that seed against itself)
// and dispatched an agent with no issue, no chat input, or no autopilot
// instructions at all.
//
// A transient read failure must instead produce NO claim payload and preserve
// the task for the stale-dispatched reclaim — cancelling would destroy a valid
// task over a momentary DB error.
func TestBuildClaimedTaskResponse_SourceLoadFailure_PreservesTaskAndEmitsNoContext(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
testWorkspaceID,
).Scan(&agentID, &runtimeID)
projectID := dbfx.Project(t, "Source load failure project")
dbfx.Insert(t, "project_resource", testutil.Cols{
"project_id": projectID,
"workspace_id": testWorkspaceID,
"resource_type": "github_repo",
"resource_ref": `{"url":"https://github.com/example/must-not-appear"}`,
"position": 0,
})
issueID := dbfx.Issue(t, "source load failure issue", testutil.Cols{
"project_id": projectID,
"priority": "medium",
"number": 88201,
})
chatSessionID := dbfx.ChatSession(t, agentID, testutil.Cols{"project_id": projectID})
for _, tc := range []struct {
name string
failOn string
cols testutil.Cols
}{
{
name: "issue",
failOn: "FROM issue\nWHERE id = $1",
cols: testutil.Cols{"runtime_id": runtimeID, "issue_id": issueID},
},
{
name: "chat session",
failOn: "FROM chat_session\nWHERE id = $1",
cols: testutil.Cols{"runtime_id": runtimeID, "issue_id": nil, "chat_session_id": chatSessionID},
},
} {
t.Run(tc.name, func(t *testing.T) {
taskID := dbfx.Task(t, agentID, tc.cols)
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'dispatched' WHERE id = $1`, taskID)
task := db.AgentTaskQueue{
ID: parseUUID(taskID),
AgentID: parseUUID(agentID),
RuntimeID: parseUUID(runtimeID),
Status: "dispatched",
}
if issueRef, ok := tc.cols["issue_id"].(string); ok {
task.IssueID = parseUUID(issueRef)
}
if chatRef, ok := tc.cols["chat_session_id"].(string); ok {
task.ChatSessionID = parseUUID(chatRef)
}
brokenHandler := *testHandler
brokenHandler.Queries = db.New(failQueryDBTX{
DBTX: testPool,
failOn: tc.failOn,
err: errors.New("simulated transient read failure"),
})
runtime := db.AgentRuntime{
ID: parseUUID(runtimeID),
WorkspaceID: parseUUID(testWorkspaceID),
}
req := httptest.NewRequest(http.MethodPost, "/api/daemon/runtimes/"+runtimeID+"/claim", nil)
resp, _, _, _, _, failure := brokenHandler.buildClaimedTaskResponse(
req, &task, runtime, runtimeID, testWorkspaceID)
if failure == nil {
t.Fatal("expected the claim to be rejected when its source row cannot be read")
}
if failure.outcome != "error_source_load" {
t.Errorf("outcome = %q, want error_source_load", failure.outcome)
}
if failure.status != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", failure.status)
}
// No context may be assembled from a source the handler could not read.
if resp.ThreadName != "" {
t.Errorf("thread_name = %q, want empty on a failed source read", resp.ThreadName)
}
if resp.ProjectID != "" || len(resp.ProjectResources) != 0 || len(resp.Repos) != 0 {
t.Errorf("project context leaked into a failed claim: project_id=%q resources=%d repos=%d",
resp.ProjectID, len(resp.ProjectResources), len(resp.Repos))
}
// A transient failure preserves the task for the reclaim path.
var status string
dbfx.QueryRow(t, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status)
if status != "dispatched" {
t.Errorf("task status = %q, want it preserved as dispatched for reclaim", status)
}
})
}
}
// The other half of the split: a source row that is GONE cannot be retried into
// existence, so it settles terminally instead of spinning in the reclaim loop.
// Legacy FKs keep this state from arising today (agent_task_queue.issue_id ON
// DELETE CASCADE and friends), so the branch is exercised directly rather than
// through a claim.
func TestRejectClaimSourceLoad_MissingRow_CancelsTask(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
var agentID, runtimeID string
dbfx.QueryRow(t,
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
testWorkspaceID,
).Scan(&agentID, &runtimeID)
taskID := dbfx.Task(t, agentID, testutil.Cols{"runtime_id": runtimeID, "issue_id": nil})
dbfx.Exec(t, `UPDATE agent_task_queue SET status = 'dispatched' WHERE id = $1`, taskID)
task := db.AgentTaskQueue{ID: parseUUID(taskID), AgentID: parseUUID(agentID), Status: "dispatched"}
failure := testHandler.rejectClaimSourceLoad(
context.Background(), &task, pgx.ErrNoRows, "issue", uuidToString(task.IssueID))
if failure == nil {
t.Fatal("expected a failure for a missing source row")
}
if failure.outcome != "error_source_missing" {
t.Errorf("outcome = %q, want error_source_missing", failure.outcome)
}
if failure.message != "task is missing its issue" {
t.Errorf("message = %q, want it to name the missing source", failure.message)
}
var status string
dbfx.QueryRow(t, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status)
if status != "cancelled" {
t.Errorf("task status = %q, want cancelled for an unrecoverable source reference", status)
}
}