mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-17 15:19:00 +02:00
* feat(chat): Chat V2 — first-class IM-style Chat tab (MUL-4171) Replace the floating chat FAB/window with a first-class Chat tab under Inbox, laid out as an IM-style two-pane surface (thread list + conversation). Highlights: - New Chat page (packages/views/chat/chat-page.tsx) with URL-addressable session selection; web + desktop routing wired up. Removes the old chat-fab / chat-window / resize-handles / context-items paths. - IM thread list: agent avatar + last-message preview + IM timestamp, red unread *count* badge (read-cursor model), presence-gated typing vs waiting. Rename lives only in the conversation header ⋯ menu (not the list hover). - Per-session conversation header (rename / view agent / delete), agent-aware empty state (avatar + name + description + starter prompts), and a deterministic clean-title derivation from the first message. - Server: read-cursor unread model (migration 145) and per-user pinned agents (migration 146, dedicated chat_pinned_agent table + handler/queries). New-agent welcome chat auto-enqueues a real agent run (LLM intro, no static template). - Design: fade the global --border token; borderless list headers on Chat/Inbox, kept (faded) on the conversation header. Verified: pnpm typecheck (all packages), go build ./..., go vet, gofmt. Co-authored-by: multica-agent <github@multica.ai> * feat(chat): make new-agent welcome read as an agent-initiated intro (MUL-4230) The "meet your new agent" chat used to insert a fake user message ("👋 Hi! Please introduce yourself …") and have the agent reply to it, so the thread looked like the creator prompting the agent. Drop the persisted user message. Flag the auto-created session is_agent_intro (migration 147) and drive the intro run server-side: the daemon builds a proactive self-introduction prompt for such sessions (buildChatPrompt) instead of a "reply to their message" prompt. The intro stays LLM-generated; the thread now opens with the agent's own message, as if it reached out first. - migration 147: chat_session.is_agent_intro - CreateChatSession carries the flag; sendAgentWelcomeChat no longer persists/publishes a user message - daemon: ChatIntro threaded from session flag → intro prompt Co-authored-by: multica-agent <github@multica.ai> * feat(chat): Settings toggle for the floating chat window (MUL-4235) (#5080) * feat(chat): Settings toggle for the floating chat window (MUL-4235) Re-introduce the floating chat overlay on top of Chat V2 as an optional, Settings-gated surface instead of deleting it outright. - Settings → Preferences → Chat: a switch (floatingChatEnabled, persisted client preference, default ON) to show/hide the floating window. - FloatingChat wrapper owns the two gates: the preference, and the /chat route (hidden on the tab so the same activeSessionId isn't shown twice). - ChatFab + a compact ChatWindow that reuse the shared useChatController and conversation components, so activeSessionId stays in lockstep with the tab. - Restore use-chat-context-items so the overlay's @ surfaces the current issue/project (the 'current context' affordance) — the tab stays manual. - i18n (en/zh-Hans/ja/ko), store unit tests. typecheck: core/views/web/desktop green. tests: chat store 9, settings 82, chat 39 pass. Co-authored-by: multica-agent <github@multica.ai> * feat(chat): dedicated Chat settings tab, floating window opt-in (MUL-4235) Address review: give Chat its own Settings tab instead of a section inside Preferences, and default the floating window OFF (opt-in). - New Settings → Chat tab (chat-tab.tsx) under My Account; moves the floating-window toggle out of the Preferences tab. - floatingChatEnabled now defaults OFF — only an explicit enable from the Chat tab mounts the FAB/overlay. - i18n: page.tabs.chat + a top-level chat block (en/zh-Hans/ja/ko); revert the Preferences chat section and its test mock; store tests updated for the opt-in default. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * refactor(chat): drop starter prompts from chat empty state (MUL-4237) (#5081) The three starter prompts (List my open tasks by priority / Summarize what I did today / Plan what to work on next) read as filler more than help, so remove them along with the now-unused returning_subtitle ("Try asking"). The empty state keeps its agent-aware header — avatar + "Chat with {name}" + optional description — and the composer stays the entry point. Locale keys dropped across en/zh-Hans/ja/ko (parity preserved). Based on the Chat V2 branch (parent MUL-4171, #5076), not main. Co-authored-by: Lambda <lambda@multica.ai> * feat(chat): pin a chat to the top of the Chat list (MUL-4240) (#5082) Builds on Chat V2 (#5076). Adds a per-conversation pin so a user can keep important chats at the top of the IM-style thread list, above the activity-sorted rest. Backend: - migration 148: chat_session.pinned_at (nullable) + partial index; the timestamp doubles as the pinned-group sort key and the boolean flag. - list queries order pinned-first, then by most-recent activity. - SetChatSessionPinned query + PATCH /api/chat/sessions/{id}/pin handler; pinning never bumps updated_at, so an unpinned chat won't jump the list. - ChatSessionResponse.pinned + chat:session_updated carries the new state. Frontend: - ChatSession.pinned; setChatSessionPinned API + useSetChatSessionPinned with optimistic re-sort; shared sortChatSessions comparator. - thread list: pin indicator on pinned rows + pin/unpin hover action; list sorted pinned-first so it stays ordered after cache patches. - realtime patch re-sorts on pin change; en/ja/ko/zh-Hans strings. Tests: SetChatSessionPinned handler test, sortChatSessions unit tests. * feat(chat): round send button, move file upload into a + menu (MUL-4250) (#5088) Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(inbox): match chat list selected-item style (inset padding + rounded) (#5093) Wrap the inbox list in p-1 and give each row rounded-md/px-3 so the selected bg-accent reads as an inset rounded card — same treatment the chat thread list already uses — instead of a full-bleed, sharp-cornered highlight. Content stays 16px-inset (p-1 + px-3 == old px-4). MUL-4253 Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): rename + menu upload item to "Image or files" (MUL-4250) (#5092) Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): stop welcome intro session repeating the same introduction (MUL-4259) The is_agent_intro flag on chat_session is persistent, so every follow-up turn on a welcome session re-selected the self-introduction prompt in buildChatPrompt and the agent kept replying with the same intro instead of answering the user. Gate resp.ChatIntro at claim time on the session still having zero human (role='user') messages via a new ChatSessionHasUserMessage query: the first, message-less server-driven turn introduces the agent; once the creator replies, later turns fall back to the normal reply prompt. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review findings + unbreak CI (MUL-4171) - task:failed now refreshes the sessions list (invalidateSessionLists), so the thread-list preview / unread / sort stays correct after an agent failure — FailTask persists a failure chat_message but only broadcasts task:failed, mirroring the chat:done success path. - Self-heal stale chat deep links: once the sessions list has loaded and a ?session= id isn't in it (deleted / no access / never existed) with nothing in flight, clear the selection instead of rendering an editable empty chat that would POST into a nonexistent session. Freshly-created sessions are exempt (they carry optimistic messages + a pending task). - CI: add the new parameterless `chat` route to link-handler's WORKSPACE_ROUTE_SEGMENTS and to paths/consistency.test.ts (route set + expectedSegments) — keeps the two in sync, fixes the failing @multica/core test. - Fix a MUL-4235/MUL-4237 merge collision that broke @multica/views typecheck: chat-window.tsx still passed the removed `onPickPrompt` prop to EmptyState. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): self-heal dangling session in shared controller, not just ChatPage (MUL-4171) Re-review follow-up: the stale-session self-heal only lived in ChatPage, so the floating ChatWindow still entered from a persisted activeSessionId and would render an editable empty chat (then POST into a nonexistent session) when the selected session was deleted / lost access off the /chat route. - Move the self-heal into the shared useChatController so every surface (tab and floating window) drops a dangling activeSessionId once the sessions list has loaded and doesn't contain it. - Harden ensureSession: trust the current id only when it's in the loaded list or is a just-created session still awaiting the refetch; a dangling id falls through to create a fresh session instead of POSTing into a 404. - Exempt just-created sessions via an OPTIMISTIC-write signal (hasOptimisticInFlight: pending task or optimistic- message), not hasMessages — a session deleted elsewhere with real cached history stays eligible for self-heal. Add a unit test for the discriminator. Co-authored-by: multica-agent <github@multica.ai> * test(views): fix app-sidebar useWorkspacePaths mock for the new chat nav (MUL-4171) The AppSidebar personal nav gained a `chat` item, so it calls `useWorkspacePaths().chat()` at render. The app-sidebar.test.tsx mock hadn't been updated, so `p.chat` was undefined and every render threw `TypeError: p[item.key] is not a function`, failing @multica/views#test in CI. - Add `chat: () => "/acme/chat"` to the mocked useWorkspacePaths. - Route the chat-sessions query key through a mutable `chatSessions` fixture. - Add coverage for the Chat nav: renders the link, badges the summed unread_count, and hides the badge when all sessions are read — so this drift is caught next time. Co-authored-by: multica-agent <github@multica.ai> * fix(chat): use the original floating window, not the rewritten one (MUL-4235) (#5102) Follow-up to the merged #5080, which shipped a hand-written, simplified ChatWindow and lost the original's animations / drag-resize / expand-minimize. The floating window is just a quick entry point — it should be the original UI, not a rewrite. - Restore chat-window.tsx, chat-fab.tsx, chat-resize-handles.tsx and use-chat-resize.ts verbatim from main (0-diff): motion animations, drag resize, expand/minimize and the session dropdown are back. - Restore the empty_state.returning_subtitle + starter_prompts i18n keys the original window renders (V2 had dropped them); drop the now-unused window.open_full_tooltip key the rewrite added. - Settings gating is unchanged: FloatingChat still wraps the original FAB + window, gated by floatingChatEnabled (default off) and hidden on /chat. typecheck: core/views/web/desktop green. tests: chat + settings views 126 pass. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(chat): archive chats from the list, delete only from Archived (MUL-4263) (#5098) Restore an archive flow as the reversible sibling of delete: - Chat list hover now offers Archive (not Delete); pin/stop unchanged. - A footer entry ('Archived · N') opens an Archived view listing archived chats; hard delete lives only there (hover -> unarchive + delete, with the existing inline confirm). - Conversation header ⋯ menu mirrors this: active chats archive, archived chats unarchive/delete. Backend: PATCH /api/chat/sessions/{id}/archive flips status active<->archived (SetChatSessionArchived), broadcasts status on chat:session_updated so other tabs re-sort into the right list. SendChatMessage already refuses archived sessions, so archived chats stay read-only until unarchived. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) (#5100) * feat(chat): handle archived agents in Chat V2 list & conversation (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> * refactor(chat): drop chat-list archive marker, keep conversation read-only (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> * feat(chat): apply archived-agent read-only to the floating chat window (MUL-4265) Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address floating-window + archived-agent review blockers (MUL-4171) Re-review follow-up on the restored floating ChatWindow + archive flow: 1. Floating stale-session self-heal. The restored ChatWindow doesn't use the shared controller, so its ensureSession trusted any non-empty activeSessionId and there was no dangling-session cleanup — a deleted / no-access persisted session could send into a nonexistent session. Ported the same guard used for the tab: a self-heal effect that clears a dangling activeSessionId once the sessions list has loaded, and ensureSession only trusts an id that's in the list or has an in-flight optimistic write (hasOptimisticInFlight, reused from use-chat-controller). handleSend seeds the optimistic message + pending task before setActiveSession, so a freshly-created session is never mis-cleared. 2. Floating dropdown bypassed archive-first safety. Its active rows offered a hard-delete, letting the floating window destroy active chats and skip the "archive first, delete only from Archived" model. Active rows now ARCHIVE (reversible, one-click) like ChatThreadList; the floating window offers no hard-delete — unarchive/delete live only in the full Chat page's Archived view (reachable via expand). Removed the now-dead delete-confirm machinery. 3. Orphan user message on archived-agent send. SendChatMessage created the chat_message before EnqueueChatTask, which rejects an archived / runtime-less agent — a stale client would land a user message then get a 500, orphaning it. Added a preflight that checks the session agent's archived / runtime state and returns 409 before any mutation, plus a handler test asserting the send is rejected with no message persisted. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
4536 lines
175 KiB
Go
4536 lines
175 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/multica-ai/multica/server/internal/auth"
|
|
"github.com/multica-ai/multica/server/internal/daemonws"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
"github.com/multica-ai/multica/server/internal/service"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
func TestLogClaimEndpointSlowIncludesPayloadFields(t *testing.T) {
|
|
var logs bytes.Buffer
|
|
prev := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
|
|
logClaimEndpointSlow("runtime-1", "claimed", time.Now().Add(-600*time.Millisecond), 10, 20, 30, 4096, 2, 8, 3072)
|
|
|
|
got := logs.String()
|
|
for _, want := range []string{
|
|
"msg=\"claim_endpoint slow\"",
|
|
"runtime_id=runtime-1",
|
|
"payload_bytes=4096",
|
|
"agent_skill_count=2",
|
|
"builtin_skill_count=8",
|
|
"skill_payload_bytes=3072",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("slow claim log missing %q in %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// slowProbeLocalSkillListStore wraps a LocalSkillListStore but blocks inside
|
|
// HasPending until the provided context is cancelled. PopPending delegates
|
|
// to the underlying store. Used to verify that a stalled probe cannot wedge
|
|
// the heartbeat — the bound context must cut it short — while the ack-safe
|
|
// PopPending path is never reached because HasPending returns an error, not
|
|
// true.
|
|
type slowProbeLocalSkillListStore struct{ LocalSkillListStore }
|
|
|
|
func (s slowProbeLocalSkillListStore) HasPending(ctx context.Context, _ string) (bool, error) {
|
|
<-ctx.Done()
|
|
return false, ctx.Err()
|
|
}
|
|
|
|
type slowProbeLocalSkillImportStore struct{ LocalSkillImportStore }
|
|
|
|
func (s slowProbeLocalSkillImportStore) HasPending(ctx context.Context, _ string) (bool, error) {
|
|
<-ctx.Done()
|
|
return false, ctx.Err()
|
|
}
|
|
|
|
// popRecordingLocalSkillListStore counts PopPending calls so a test can assert
|
|
// that the handler never reaches the ack-unsafe side-effecting claim path
|
|
// when HasPending reports an empty queue.
|
|
type popRecordingLocalSkillListStore struct {
|
|
LocalSkillListStore
|
|
popCalls int
|
|
}
|
|
|
|
func (s *popRecordingLocalSkillListStore) PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillListRequest, error) {
|
|
s.popCalls++
|
|
return s.LocalSkillListStore.PopPending(ctx, runtimeID)
|
|
}
|
|
|
|
type popRecordingLocalSkillImportStore struct {
|
|
LocalSkillImportStore
|
|
popCalls int
|
|
}
|
|
|
|
func (s *popRecordingLocalSkillImportStore) PopPending(ctx context.Context, runtimeID string) (*RuntimeLocalSkillImportRequest, error) {
|
|
s.popCalls++
|
|
return s.LocalSkillImportStore.PopPending(ctx, runtimeID)
|
|
}
|
|
|
|
func setHandlerTestWorkspaceRepos(t *testing.T, repos []map[string]string) {
|
|
t.Helper()
|
|
data, err := json.Marshal(repos)
|
|
if err != nil {
|
|
t.Fatalf("marshal repos: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, data, testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte("[]"), testWorkspaceID); err != nil {
|
|
t.Fatalf("reset workspace repos: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// newDaemonTokenRequest creates an HTTP request with daemon token context set
|
|
// (simulating DaemonAuth middleware for mdt_ tokens).
|
|
func newDaemonTokenRequest(method, path string, body any, workspaceID, daemonID string) *http.Request {
|
|
var buf bytes.Buffer
|
|
if body != nil {
|
|
json.NewEncoder(&buf).Encode(body)
|
|
}
|
|
req := httptest.NewRequest(method, path, &buf)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
// No X-User-ID — daemon tokens don't set it.
|
|
ctx := middleware.WithDaemonContext(req.Context(), workspaceID, daemonID)
|
|
return req.WithContext(ctx)
|
|
}
|
|
|
|
func createClaimReclaimRuntime(t *testing.T, ctx context.Context, name string) string {
|
|
t.Helper()
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider,
|
|
status, device_info, metadata, last_seen_at, visibility, owner_id
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', 'handler_test_runtime', 'online', 'claim reclaim fixture', '{}'::jsonb, now(), 'private', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, name, testUserID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) })
|
|
|
|
return runtimeID
|
|
}
|
|
|
|
func createClaimReclaimAgentAndIssue(t *testing.T, ctx context.Context, runtimeID, name string) (string, string) {
|
|
t.Helper()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks, owner_id
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'private', 1, $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, name, runtimeID, testUserID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: create agent: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent WHERE id = $1`, agentID) })
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES (
|
|
$1, $2, 'in_progress', 'none', $3, 'member',
|
|
(SELECT COALESCE(MAX(number), 82649) + 1 FROM issue WHERE workspace_id = $1),
|
|
0
|
|
)
|
|
RETURNING id
|
|
`, testWorkspaceID, name+" issue", testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
return agentID, issueID
|
|
}
|
|
|
|
func createDispatchedClaimFixtureTask(t *testing.T, ctx context.Context, agentID, runtimeID, issueID, dispatchedAge string, started bool) string {
|
|
t.Helper()
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority, dispatched_at, started_at
|
|
)
|
|
VALUES ($1, $2, $3, 'dispatched', 0, now() - ($4::interval), CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, dispatchedAge, started).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create dispatched task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
return taskID
|
|
}
|
|
|
|
func setTaskPrepareLeaseForTest(t *testing.T, ctx context.Context, taskID, expiresIn string) {
|
|
t.Helper()
|
|
if _, err := testPool.Exec(ctx, `
|
|
UPDATE agent_task_queue
|
|
SET prepare_lease_expires_at = now() + ($2::interval)
|
|
WHERE id = $1
|
|
`, taskID, expiresIn); err != nil {
|
|
t.Fatalf("setup: set prepare lease: %v", err)
|
|
}
|
|
}
|
|
|
|
func claimTaskByRuntimeForTest(t *testing.T, runtimeID string) (*struct {
|
|
ID string `json:"id"`
|
|
}, string) {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
|
testWorkspaceID, "claim-reclaim-review")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
return resp.Task, w.Body.String()
|
|
}
|
|
|
|
// claimChatIntroForTest claims the next task for a runtime and returns the
|
|
// claimed task id plus its chat_intro flag (false when the field is absent, as
|
|
// it is omitempty).
|
|
func claimChatIntroForTest(t *testing.T, runtimeID string) (string, bool, string) {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
|
testWorkspaceID, "chat-intro-review")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
ChatIntro bool `json:"chat_intro"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatalf("expected a claimable task, got none: %s", w.Body.String())
|
|
}
|
|
return resp.Task.ID, resp.Task.ChatIntro, w.Body.String()
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_ChatIntroGateClearsAfterUserReplies pins the MUL-4259
|
|
// fix: an is_agent_intro session drives the self-introduction prompt only on
|
|
// its first, message-less turn. Once the creator has replied, later turns on
|
|
// the same (still is_agent_intro) session must claim with chat_intro=false so
|
|
// the agent answers instead of repeating its introduction.
|
|
func TestClaimTaskByRuntime_ChatIntroGateClearsAfterUserReplies(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Chat intro gate runtime")
|
|
agentID, _ := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Chat intro gate agent")
|
|
// Allow more than one in-flight task so the second claim isn't blocked by
|
|
// the first (which stays 'dispatched') on the concurrency limit.
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET max_concurrent_tasks = 5 WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("raise max_concurrent_tasks: %v", err)
|
|
}
|
|
|
|
var sessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status, runtime_id, is_agent_intro)
|
|
VALUES ($1, $2, $3, '👋 intro', 'active', $4, true)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID, runtimeID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("insert intro chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID) })
|
|
|
|
seedQueuedChatTask := func() string {
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, chat_session_id)
|
|
VALUES ($1, $2, 'queued', 0, $3)
|
|
RETURNING id
|
|
`, agentID, runtimeID, sessionID).Scan(&taskID); err != nil {
|
|
t.Fatalf("seed queued chat task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
return taskID
|
|
}
|
|
|
|
// First turn: no user message yet → the server-driven intro run.
|
|
introTaskID := seedQueuedChatTask()
|
|
claimedID, chatIntro, body := claimChatIntroForTest(t, runtimeID)
|
|
if claimedID != introTaskID {
|
|
t.Fatalf("claimed task id = %s, want intro task %s: %s", claimedID, introTaskID, body)
|
|
}
|
|
if !chatIntro {
|
|
t.Fatalf("expected chat_intro=true for the message-less intro turn: %s", body)
|
|
}
|
|
|
|
// The intro run finishes. Chat tasks serialize on chat_session_id, so the
|
|
// next turn only becomes claimable once this one leaves an in-flight state.
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent_task_queue SET status = 'completed' WHERE id = $1`, introTaskID); err != nil {
|
|
t.Fatalf("complete intro task: %v", err)
|
|
}
|
|
|
|
// The creator replies: persist a user message on the same session.
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content)
|
|
VALUES ($1, 'user', 'thanks! can you help me with X?')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("insert user reply: %v", err)
|
|
}
|
|
|
|
// Second turn on the same is_agent_intro session must NOT re-introduce.
|
|
followupTaskID := seedQueuedChatTask()
|
|
claimedID, chatIntro, body = claimChatIntroForTest(t, runtimeID)
|
|
if claimedID != followupTaskID {
|
|
t.Fatalf("claimed task id = %s, want follow-up task %s: %s", claimedID, followupTaskID, body)
|
|
}
|
|
if chatIntro {
|
|
t.Fatalf("expected chat_intro=false once the creator has replied: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_ReclaimsStaleDispatchedTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Stale dispatch reclaim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Stale dispatch reclaim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, runtimeID)
|
|
if task == nil {
|
|
t.Fatalf("expected stale dispatched task %s to be reclaimed, got nil response: %s", taskID, body)
|
|
}
|
|
if task.ID != taskID {
|
|
t.Fatalf("reclaimed task id = %s, want %s", task.ID, taskID)
|
|
}
|
|
|
|
var refreshed bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT dispatched_at > now() - interval '15 seconds'
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&refreshed); err != nil {
|
|
t.Fatalf("load refreshed dispatched_at: %v", err)
|
|
}
|
|
if !refreshed {
|
|
t.Fatal("expected reclaimed task to refresh dispatched_at")
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_DoesNotReclaimFreshDispatchedTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Fresh dispatch reclaim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Fresh dispatch reclaim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "75 seconds", false)
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, runtimeID)
|
|
if task != nil {
|
|
t.Fatalf("expected fresh dispatched task %s not to be reclaimed, got %s in %s", taskID, task.ID, body)
|
|
}
|
|
|
|
var stillFresh bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT dispatched_at < now() - interval '70 seconds'
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&stillFresh); err != nil {
|
|
t.Fatalf("load fresh dispatched task: %v", err)
|
|
}
|
|
if !stillFresh {
|
|
t.Fatal("expected fresh dispatched task to keep its original dispatched_at")
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_DoesNotReclaimActivePrepareLease(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Active prepare lease runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Active prepare lease agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "240 seconds", false)
|
|
setTaskPrepareLeaseForTest(t, ctx, taskID, "30 seconds")
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, runtimeID)
|
|
if task != nil {
|
|
t.Fatalf("expected actively leased dispatched task %s not to be reclaimed, got %s in %s", taskID, task.ID, body)
|
|
}
|
|
|
|
var leaseActive bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT prepare_lease_expires_at > now()
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&leaseActive); err != nil {
|
|
t.Fatalf("load active prepare lease: %v", err)
|
|
}
|
|
if !leaseActive {
|
|
t.Fatal("expected prepare lease to remain active")
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_ReclaimsExpiredPrepareLease(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Expired prepare lease runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Expired prepare lease agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "240 seconds", false)
|
|
setTaskPrepareLeaseForTest(t, ctx, taskID, "-5 seconds")
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, runtimeID)
|
|
if task == nil {
|
|
t.Fatalf("expected expired leased task %s to be reclaimed, got nil response: %s", taskID, body)
|
|
}
|
|
if task.ID != taskID {
|
|
t.Fatalf("reclaimed task id = %s, want %s", task.ID, taskID)
|
|
}
|
|
|
|
var leaseRefreshed bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT prepare_lease_expires_at > now()
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&leaseRefreshed); err != nil {
|
|
t.Fatalf("load refreshed prepare lease: %v", err)
|
|
}
|
|
if !leaseRefreshed {
|
|
t.Fatal("expected reclaimed task to refresh prepare lease")
|
|
}
|
|
}
|
|
|
|
func TestExtendTaskPrepareLease(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Extend prepare lease runtime")
|
|
otherRuntimeID := createClaimReclaimRuntime(t, ctx, "Other prepare lease runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Extend prepare lease agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+otherRuntimeID+"/tasks/"+taskID+"/prepare-lease", nil,
|
|
testWorkspaceID, "extend-prepare-lease")
|
|
req = withURLParams(req, "runtimeId", otherRuntimeID, "taskId", taskID)
|
|
testHandler.ExtendTaskPrepareLease(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("ExtendTaskPrepareLease wrong runtime: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/"+taskID+"/prepare-lease", nil,
|
|
testWorkspaceID, "extend-prepare-lease")
|
|
req = withURLParams(req, "runtimeId", runtimeID, "taskId", taskID)
|
|
testHandler.ExtendTaskPrepareLease(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ExtendTaskPrepareLease: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var leaseActive bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT prepare_lease_expires_at > now()
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&leaseActive); err != nil {
|
|
t.Fatalf("load extended prepare lease: %v", err)
|
|
}
|
|
if !leaseActive {
|
|
t.Fatal("expected prepare lease to be extended")
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_DoesNotReclaimAlreadyStartedTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Started dispatch reclaim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Started dispatch reclaim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", true)
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, runtimeID)
|
|
if task != nil {
|
|
t.Fatalf("expected started dispatched task %s not to be reclaimed, got %s in %s", taskID, task.ID, body)
|
|
}
|
|
|
|
var startedAtValid bool
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT started_at IS NOT NULL
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&startedAtValid); err != nil {
|
|
t.Fatalf("load started dispatched task: %v", err)
|
|
}
|
|
if !startedAtValid {
|
|
t.Fatal("expected started dispatched task to keep started_at")
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_DoesNotReclaimDifferentRuntimeTask(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
claimingRuntimeID := createClaimReclaimRuntime(t, ctx, "Claiming dispatch reclaim runtime")
|
|
owningRuntimeID := createClaimReclaimRuntime(t, ctx, "Owning dispatch reclaim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, owningRuntimeID, "Different runtime reclaim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, owningRuntimeID, issueID, "120 seconds", false)
|
|
|
|
task, body := claimTaskByRuntimeForTest(t, claimingRuntimeID)
|
|
if task != nil {
|
|
t.Fatalf("expected other-runtime task %s not to be reclaimed, got %s in %s", taskID, task.ID, body)
|
|
}
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT runtime_id::text
|
|
FROM agent_task_queue
|
|
WHERE id = $1
|
|
`, taskID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("load other-runtime dispatched task: %v", err)
|
|
}
|
|
if runtimeID != owningRuntimeID {
|
|
t.Fatalf("task runtime_id = %s, want %s", runtimeID, owningRuntimeID)
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_SkillBundleRefsAndResolve(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Skill refs runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Skill refs agent")
|
|
|
|
var skillID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO skill (workspace_id, name, description, content, config, created_by)
|
|
VALUES ($1, 'deploy-skill-ref-test', 'Deploy safely', 'main skill content', '{}'::jsonb, $2)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&skillID); err != nil {
|
|
t.Fatalf("setup: create skill: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_skill WHERE skill_id = $1`, skillID)
|
|
testPool.Exec(ctx, `DELETE FROM skill_file WHERE skill_id = $1`, skillID)
|
|
testPool.Exec(ctx, `DELETE FROM skill WHERE id = $1`, skillID)
|
|
})
|
|
if _, err := testPool.Exec(ctx, `INSERT INTO skill_file (skill_id, path, content) VALUES ($1, 'rules.md', 'rules content')`, skillID); err != nil {
|
|
t.Fatalf("setup: create skill file: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `INSERT INTO agent_skill (agent_id, skill_id) VALUES ($1, $2)`, agentID, skillID); err != nil {
|
|
t.Fatalf("setup: bind skill: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil, testWorkspaceID, "skill-refs-daemon")
|
|
req.Header.Set("X-Client-Capabilities", protocol.DaemonCapabilitySkillBundlesV1)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var claimResp struct {
|
|
Task *AgentTaskResponse `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &claimResp); err != nil {
|
|
t.Fatalf("decode claim: %v", err)
|
|
}
|
|
if claimResp.Task == nil || claimResp.Task.Agent == nil {
|
|
t.Fatalf("missing task agent in response: %s", w.Body.String())
|
|
}
|
|
if len(claimResp.Task.Agent.Skills) != 0 {
|
|
t.Fatalf("slim claim returned full skills: %+v", claimResp.Task.Agent.Skills)
|
|
}
|
|
var ref service.AgentSkillRefData
|
|
for _, candidate := range claimResp.Task.Agent.SkillRefs {
|
|
if candidate.ID == skillID {
|
|
ref = candidate
|
|
break
|
|
}
|
|
}
|
|
if ref.ID == "" || ref.Hash == "" || ref.FileCount != 1 || len(ref.Files) != 1 || ref.Files[0].SHA256 == "" {
|
|
t.Fatalf("workspace skill ref missing manifest: %+v", ref)
|
|
}
|
|
|
|
staleHashBody := resolveSkillBundlesRequest{Skills: []resolveSkillBundleRef{{ID: ref.ID, Source: ref.Source, Hash: "sha256:stale"}}}
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/"+taskID+"/skill-bundles/resolve", staleHashBody, testWorkspaceID, "skill-refs-daemon")
|
|
req = withURLParams(req, "runtimeId", runtimeID, "taskId", taskID)
|
|
testHandler.ResolveTaskSkillBundles(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ResolveTaskSkillBundles with stale hash: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var staleHashResp struct {
|
|
Bundles []service.AgentSkillData `json:"bundles"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &staleHashResp); err != nil {
|
|
t.Fatalf("decode stale hash resolve: %v", err)
|
|
}
|
|
if len(staleHashResp.Bundles) != 1 || staleHashResp.Bundles[0].Hash != ref.Hash {
|
|
t.Fatalf("stale hash resolve did not return current bundle: %+v", staleHashResp.Bundles)
|
|
}
|
|
|
|
invalidRefBody := resolveSkillBundlesRequest{Skills: []resolveSkillBundleRef{{ID: ref.ID, Source: ref.Source}}}
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/"+taskID+"/skill-bundles/resolve", invalidRefBody, testWorkspaceID, "skill-refs-daemon")
|
|
req = withURLParams(req, "runtimeId", runtimeID, "taskId", taskID)
|
|
testHandler.ResolveTaskSkillBundles(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("ResolveTaskSkillBundles with invalid ref: expected 400, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
resolveBody := resolveSkillBundlesRequest{Skills: []resolveSkillBundleRef{{ID: ref.ID, Source: ref.Source, Hash: ref.Hash}}}
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/"+taskID+"/skill-bundles/resolve", resolveBody, testWorkspaceID, "skill-refs-daemon")
|
|
req = withURLParams(req, "runtimeId", runtimeID, "taskId", taskID)
|
|
testHandler.ResolveTaskSkillBundles(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ResolveTaskSkillBundles: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resolveResp struct {
|
|
Bundles []service.AgentSkillData `json:"bundles"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resolveResp); err != nil {
|
|
t.Fatalf("decode resolve: %v", err)
|
|
}
|
|
if len(resolveResp.Bundles) != 1 || resolveResp.Bundles[0].Content != "main skill content" || len(resolveResp.Bundles[0].Files) != 1 || resolveResp.Bundles[0].Files[0].Content != "rules content" {
|
|
t.Fatalf("unexpected resolved bundles: %+v", resolveResp.Bundles)
|
|
}
|
|
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent_task_queue SET status = 'running', started_at = now() WHERE id = $1`, taskID); err != nil {
|
|
t.Fatalf("setup: mark task running: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/"+taskID+"/skill-bundles/resolve", resolveBody, testWorkspaceID, "skill-refs-daemon")
|
|
req = withURLParams(req, "runtimeId", runtimeID, "taskId", taskID)
|
|
testHandler.ResolveTaskSkillBundles(w, req)
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("ResolveTaskSkillBundles for running task: expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_PopulatesWorkspaceContext verifies the claim
|
|
// response carries workspace.context so the daemon can inject the
|
|
// workspace-level system prompt into every agent brief. Regression coverage
|
|
// for MUL-2542: before this fix the field was never plumbed through, so
|
|
// even workspaces that had set a context got an empty brief.
|
|
func TestClaimTaskByRuntime_PopulatesWorkspaceContext(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const wsContext = "All comments must be in English. Prefer concise PR descriptions."
|
|
var prior string
|
|
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
|
|
t.Fatalf("read workspace.context: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, wsContext, testWorkspaceID); err != nil {
|
|
t.Fatalf("set workspace.context: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if prior == "" {
|
|
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
|
|
} else {
|
|
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
|
|
}
|
|
})
|
|
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context claim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context claim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
|
testWorkspaceID, "workspace-context-claim")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
WorkspaceContext string `json:"workspace_context"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatalf("expected dispatched task %s to be claimed, got nil response: %s", taskID, w.Body.String())
|
|
}
|
|
if resp.Task.ID != taskID {
|
|
t.Fatalf("claimed task id = %s, want %s", resp.Task.ID, taskID)
|
|
}
|
|
if resp.Task.WorkspaceContext != wsContext {
|
|
t.Errorf("workspace_context = %q, want %q", resp.Task.WorkspaceContext, wsContext)
|
|
}
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset verifies the field
|
|
// is omitted (empty string after JSON decode) when the workspace owner has
|
|
// not set a context. Important because the daemon's brief skips the heading
|
|
// only on empty input — a stray "context: null" coming back as the string
|
|
// "null" would render as a bogus paragraph.
|
|
func TestClaimTaskByRuntime_WorkspaceContextEmptyWhenUnset(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var prior string
|
|
if err := testPool.QueryRow(ctx, `SELECT COALESCE(context, '') FROM workspace WHERE id = $1`, testWorkspaceID).Scan(&prior); err != nil {
|
|
t.Fatalf("read workspace.context: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID); err != nil {
|
|
t.Fatalf("clear workspace.context: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if prior == "" {
|
|
testPool.Exec(ctx, `UPDATE workspace SET context = NULL WHERE id = $1`, testWorkspaceID)
|
|
} else {
|
|
testPool.Exec(ctx, `UPDATE workspace SET context = $1 WHERE id = $2`, prior, testWorkspaceID)
|
|
}
|
|
})
|
|
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Workspace context empty claim runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Workspace context empty claim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
|
testWorkspaceID, "workspace-context-empty-claim")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
WorkspaceContext string `json:"workspace_context"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatalf("expected dispatched task %s to be claimed, got nil: %s", taskID, w.Body.String())
|
|
}
|
|
if resp.Task.WorkspaceContext != "" {
|
|
t.Errorf("workspace_context = %q, want empty string when workspace.context is NULL", resp.Task.WorkspaceContext)
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_MissingRuntimeOwnerCancelsAndRejects(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider,
|
|
status, device_info, metadata, last_seen_at, visibility
|
|
)
|
|
VALUES ($1, NULL, 'Missing owner claim runtime', 'cloud', 'handler_test_runtime', 'online', 'claim missing owner fixture', '{}'::jsonb, now(), 'private')
|
|
RETURNING id
|
|
`, testWorkspaceID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) })
|
|
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Missing owner claim agent")
|
|
taskID := createDispatchedClaimFixtureTask(t, ctx, agentID, runtimeID, issueID, "120 seconds", false)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil,
|
|
testWorkspaceID, "missing-owner-claim")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 500, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "runtime owner required to mint task token") {
|
|
t.Fatalf("ClaimTaskByRuntime body = %q, want runtime owner error", w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status); err != nil {
|
|
t.Fatalf("read task status: %v", err)
|
|
}
|
|
if status != "cancelled" {
|
|
t.Fatalf("task status = %q, want cancelled", status)
|
|
}
|
|
}
|
|
|
|
func TestDaemonRegister_WithDaemonToken(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-mdt",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
}, testWorkspaceID, "test-daemon-mdt")
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister with daemon token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
runtimes, ok := resp["runtimes"].([]any)
|
|
if !ok || len(runtimes) == 0 {
|
|
t.Fatalf("DaemonRegister: expected runtimes in response, got %v", resp)
|
|
}
|
|
if _, ok := resp["repos_version"].(string); !ok {
|
|
t.Fatalf("DaemonRegister: expected repos_version in response, got %v", resp)
|
|
}
|
|
|
|
// Clean up: deregister the runtime.
|
|
rt := runtimes[0].(map[string]any)
|
|
runtimeID := rt["id"].(string)
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
}
|
|
|
|
func TestDaemonRegister_RecordsRuntimeProfileRegistrationFailure(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
profileID := insertRuntimeProfileFixture(t, ctx, "Missing Custom Codex", "codex", "missing-codex")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-profile-failure",
|
|
"device_name": "test-device",
|
|
"failed_profiles": []map[string]any{
|
|
{
|
|
"profile_id": profileID,
|
|
"command_name": "missing-codex",
|
|
"reason": "command not found on PATH: missing-codex",
|
|
},
|
|
},
|
|
}, testWorkspaceID, "test-daemon-profile-failure")
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE profile_id = $1`, profileID)
|
|
})
|
|
|
|
var status string
|
|
var metadata []byte
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT status, metadata FROM agent_runtime
|
|
WHERE workspace_id = $1 AND daemon_id = $2 AND profile_id = $3
|
|
`, testWorkspaceID, "test-daemon-profile-failure", profileID).Scan(&status, &metadata); err != nil {
|
|
t.Fatalf("read failed profile runtime row: %v", err)
|
|
}
|
|
if status != "offline" {
|
|
t.Fatalf("status = %q, want offline", status)
|
|
}
|
|
var meta map[string]any
|
|
if err := json.Unmarshal(metadata, &meta); err != nil {
|
|
t.Fatalf("unmarshal metadata: %v", err)
|
|
}
|
|
if meta["runtime_profile_registration_error"] != true {
|
|
t.Fatalf("registration error metadata missing: %#v", meta)
|
|
}
|
|
if meta["runtime_profile_failure_reason"] != "command not found on PATH: missing-codex" {
|
|
t.Fatalf("failure reason metadata = %#v", meta["runtime_profile_failure_reason"])
|
|
}
|
|
}
|
|
|
|
func TestDaemonRegister_WithDaemonToken_WorkspaceMismatch(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
// Daemon token is for a different workspace than the request body.
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-mdt",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
}, "00000000-0000-0000-0000-000000000000", "test-daemon-mdt")
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonRegister with mismatched workspace: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDaemonHeartbeat_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// First, register a runtime using PAT (existing flow).
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "test-daemon-heartbeat",
|
|
"device_name": "test-device",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime-hb", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("Setup: DaemonRegister failed: %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var regResp map[string]any
|
|
json.NewDecoder(w.Body).Decode(®Resp)
|
|
runtimes := regResp["runtimes"].([]any)
|
|
runtimeID := runtimes[0].(map[string]any)["id"].(string)
|
|
defer testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
|
|
// Try heartbeat with a daemon token from a DIFFERENT workspace — should fail.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
}, "00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
|
|
testHandler.DaemonHeartbeat(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("DaemonHeartbeat with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestHandleDaemonWSHeartbeat_RuntimeGoneReturnsAckNotError pins the fix for
|
|
// issue #2391: when GetAgentRuntime returns pgx.ErrNoRows (runtime row was
|
|
// deleted server-side), the WS handler must return a successful ack with
|
|
// RuntimeGone=true rather than an error. Returning an error makes the WS hub
|
|
// log every beat at Warn — the flood the issue is about.
|
|
func TestHandleDaemonWSHeartbeat_RuntimeGoneReturnsAckNotError(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// A well-formed UUID that does NOT exist in agent_runtime. The handler
|
|
// must turn the resulting pgx.ErrNoRows into a RuntimeGone ack.
|
|
missingRuntime := uuid.New().String()
|
|
ack, err := testHandler.HandleDaemonWSHeartbeat(context.Background(),
|
|
daemonws.ClientIdentity{WorkspaceID: testWorkspaceID},
|
|
missingRuntime, false)
|
|
if err != nil {
|
|
t.Fatalf("HandleDaemonWSHeartbeat: unexpected error %v", err)
|
|
}
|
|
if ack == nil {
|
|
t.Fatal("HandleDaemonWSHeartbeat: nil ack for missing runtime")
|
|
}
|
|
if !ack.RuntimeGone {
|
|
t.Fatalf("ack.RuntimeGone = false, want true")
|
|
}
|
|
if ack.Status != protocol.HeartbeatStatusRuntimeGone {
|
|
t.Fatalf("ack.Status = %q, want %q", ack.Status, protocol.HeartbeatStatusRuntimeGone)
|
|
}
|
|
if ack.RuntimeID != missingRuntime {
|
|
t.Fatalf("ack.RuntimeID = %q, want %q", ack.RuntimeID, missingRuntime)
|
|
}
|
|
}
|
|
|
|
func TestHandleDaemonWSHeartbeat_AllowsAnyAuthorizedWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
slug := "handler-ws-heartbeat-" + uuid.New().String()
|
|
var workspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "WS Heartbeat Scope", slug, "Temporary workspace for WS heartbeat tests", "HWS").Scan(&workspaceID); err != nil {
|
|
t.Fatalf("setup: create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM workspace WHERE id = $1`, workspaceID)
|
|
})
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider,
|
|
status, device_info, metadata, owner_id, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, $5, now())
|
|
RETURNING id
|
|
`, workspaceID, "WS Heartbeat Runtime", "handler_test_runtime", "WS heartbeat runtime", testUserID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
})
|
|
|
|
ack, err := testHandler.HandleDaemonWSHeartbeat(ctx,
|
|
daemonws.ClientIdentity{WorkspaceIDs: []string{testWorkspaceID, workspaceID}},
|
|
runtimeID, false)
|
|
if err != nil {
|
|
t.Fatalf("HandleDaemonWSHeartbeat: unexpected error %v", err)
|
|
}
|
|
if ack == nil || ack.RuntimeID != runtimeID {
|
|
t.Fatalf("ack = %+v, want runtime_id %q", ack, runtimeID)
|
|
}
|
|
}
|
|
|
|
// TestDaemonHeartbeat_HTTPRuntimeGoneReturns404 pins the HTTP-path mirror:
|
|
// pgx.ErrNoRows on the runtime lookup is the only DB error mapped to 404.
|
|
// Anything else (transient pool issue, schema mismatch, ...) must surface
|
|
// as 500 so the daemon does not mistake a hiccup for a deletion.
|
|
func TestDaemonHeartbeat_HTTPRuntimeGoneReturns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
missingRuntime := uuid.New().String()
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": missingRuntime,
|
|
}, testWorkspaceID, "test-daemon")
|
|
testHandler.DaemonHeartbeat(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404 for missing runtime, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(strings.ToLower(w.Body.String()), "runtime not found") {
|
|
t.Fatalf("expected 'runtime not found' body, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestDaemonHeartbeat_SlowProbeDoesNotWedge pins the invariant that a stalled
|
|
// HasPending probe cannot wedge the heartbeat endpoint past the per-probe
|
|
// timeout. The probe is the only bounded call; PopPending is ack-safe-
|
|
// critical and is intentionally left unbounded. Without the probe bound the
|
|
// heartbeat would hang on a slow shared store.
|
|
func TestDaemonHeartbeat_SlowProbeDoesNotWedge(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
|
|
origList := testHandler.LocalSkillListStore
|
|
origImport := testHandler.LocalSkillImportStore
|
|
testHandler.LocalSkillListStore = slowProbeLocalSkillListStore{origList}
|
|
testHandler.LocalSkillImportStore = slowProbeLocalSkillImportStore{origImport}
|
|
t.Cleanup(func() {
|
|
testHandler.LocalSkillListStore = origList
|
|
testHandler.LocalSkillImportStore = origImport
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
}, testWorkspaceID, "runtime-local-skills-daemon")
|
|
|
|
start := time.Now()
|
|
testHandler.DaemonHeartbeat(w, req)
|
|
elapsed := time.Since(start)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonHeartbeat with slow probes: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
// Two bounded probes at 1s each + a small fixed slack.
|
|
if elapsed > 3*time.Second {
|
|
t.Fatalf("DaemonHeartbeat took %s; expected fast return despite slow probes", elapsed)
|
|
}
|
|
}
|
|
|
|
// TestDaemonHeartbeat_EmptyQueueSkipsPopPending pins the ack-safety property:
|
|
// when HasPending reports no work, the heartbeat must NOT invoke PopPending,
|
|
// because PopPending's Redis implementation has non-atomic side effects that
|
|
// a client-side cancel cannot cleanly un-run (see GH #1637 review).
|
|
func TestDaemonHeartbeat_EmptyQueueSkipsPopPending(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
runtimeID := createRuntimeLocalSkillTestRuntime(t, testUserID)
|
|
|
|
origList := testHandler.LocalSkillListStore
|
|
origImport := testHandler.LocalSkillImportStore
|
|
listSpy := &popRecordingLocalSkillListStore{LocalSkillListStore: origList}
|
|
importSpy := &popRecordingLocalSkillImportStore{LocalSkillImportStore: origImport}
|
|
testHandler.LocalSkillListStore = listSpy
|
|
testHandler.LocalSkillImportStore = importSpy
|
|
t.Cleanup(func() {
|
|
testHandler.LocalSkillListStore = origList
|
|
testHandler.LocalSkillImportStore = origImport
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest(http.MethodPost, "/api/daemon/heartbeat", map[string]any{
|
|
"runtime_id": runtimeID,
|
|
}, testWorkspaceID, "runtime-local-skills-daemon")
|
|
|
|
testHandler.DaemonHeartbeat(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonHeartbeat: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if listSpy.popCalls != 0 {
|
|
t.Fatalf("expected 0 PopPending calls on empty list queue, got %d", listSpy.popCalls)
|
|
}
|
|
if importSpy.popCalls != 0 {
|
|
t.Fatalf("expected 0 PopPending calls on empty import queue, got %d", importSpy.popCalls)
|
|
}
|
|
}
|
|
|
|
func TestGetTaskStatus_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Create a task in the test workspace.
|
|
var issueID, taskID string
|
|
err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'daemon-auth-test-issue', 'todo', 'medium', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
|
|
// Get an agent and runtime from the test workspace.
|
|
var agentID, runtimeID string
|
|
err = testPool.QueryRow(context.Background(), `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID)
|
|
if err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
err = testPool.QueryRow(context.Background(), `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
|
|
// Try GetTaskStatus with a daemon token from a DIFFERENT workspace — should fail.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/status", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetTaskStatus with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same request with the CORRECT workspace should succeed.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/status", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = req.WithContext(context.WithValue(
|
|
middleware.WithDaemonContext(req.Context(), testWorkspaceID, "legit-daemon"),
|
|
chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetTaskStatus with correct workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetTaskStatus_TransientDBError_Returns500 verifies that a transient DB
|
|
// error from GetAgentTask is reported as 500 rather than 404. The daemon
|
|
// uses 404+"task not found" as a hard cancel signal; a transient lookup
|
|
// failure must therefore not be smuggled into that body, otherwise a single
|
|
// DB hiccup would kill an in-flight agent.
|
|
func TestGetTaskStatus_TransientDBError_Returns500(t *testing.T) {
|
|
h := &Handler{}
|
|
h.Queries = db.New(&mockDB{getUserErr: errors.New("connection reset by peer")})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/tasks/00000000-0000-0000-0000-000000000001/status", nil,
|
|
"00000000-0000-0000-0000-000000000000", "test-daemon")
|
|
req = withURLParam(req, "taskId", "00000000-0000-0000-0000-000000000001")
|
|
|
|
h.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("transient DB error: expected 500, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if strings.Contains(w.Body.String(), "task not found") {
|
|
t.Fatalf("transient DB error must not surface as %q (daemon treats that as a deletion): %s", "task not found", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetTaskStatus_ErrNoRows_Returns404 verifies that an actually-missing
|
|
// task row still returns the 404+"task not found" body the daemon relies on
|
|
// to interrupt the running agent.
|
|
func TestGetTaskStatus_ErrNoRows_Returns404(t *testing.T) {
|
|
h := &Handler{}
|
|
h.Queries = db.New(&mockDB{getUserErr: pgx.ErrNoRows})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/tasks/00000000-0000-0000-0000-000000000001/status", nil,
|
|
"00000000-0000-0000-0000-000000000000", "test-daemon")
|
|
req = withURLParam(req, "taskId", "00000000-0000-0000-0000-000000000001")
|
|
|
|
h.GetTaskStatus(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("ErrNoRows: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "task not found") {
|
|
t.Fatalf("ErrNoRows: expected body to contain %q, got %s", "task not found", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetIssueGCCheck_WithDaemonToken_CrossWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
// Create an issue in the test workspace. The daemon GC endpoint returns
|
|
// only status + updated_at, so a "done" issue exercises the typical path.
|
|
var issueID string
|
|
err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'gc-check-auth-test-issue', 'done', 'medium', $2, 'member')
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID)
|
|
if err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
defer testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
|
|
|
|
// Cross-workspace daemon token must be rejected with 404 — same status
|
|
// code as "issue not found" so there is no UUID enumeration oracle.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/issues/"+issueID+"/gc-check", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
req = withURLParam(req, "issueId", issueID)
|
|
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetIssueGCCheck with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace daemon token succeeds and returns status + updated_at.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/issues/"+issueID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "issueId", issueID)
|
|
|
|
testHandler.GetIssueGCCheck(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetIssueGCCheck with correct workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Status != "done" {
|
|
t.Fatalf("expected status %q, got %q", "done", resp.Status)
|
|
}
|
|
if resp.UpdatedAt == "" {
|
|
t.Fatal("expected updated_at to be set")
|
|
}
|
|
}
|
|
|
|
// withURLParams merges the given chi URL parameters into the request context.
|
|
// Unlike calling withURLParam twice (which replaces the whole chi.RouteContext
|
|
// and loses earlier params), this preserves previously-added params.
|
|
func withURLParams(req *http.Request, kv ...string) *http.Request {
|
|
rctx := chi.NewRouteContext()
|
|
if existing, ok := req.Context().Value(chi.RouteCtxKey).(*chi.Context); ok && existing != nil {
|
|
for i, key := range existing.URLParams.Keys {
|
|
rctx.URLParams.Add(key, existing.URLParams.Values[i])
|
|
}
|
|
}
|
|
for i := 0; i+1 < len(kv); i += 2 {
|
|
rctx.URLParams.Add(kv[i], kv[i+1])
|
|
}
|
|
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
}
|
|
|
|
func TestListTaskMessagesByUser_InvalidTaskIDReturnsBadRequest(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/api/tasks/optimistic-optimistic-1778739487737/messages", nil)
|
|
req = withURLParams(req, "taskId", "optimistic-optimistic-1778739487737")
|
|
|
|
(&Handler{}).ListTaskMessagesByUser(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected 400 for invalid task id, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "task_id") {
|
|
t.Fatalf("expected task_id validation error, got %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// setupForeignWorkspaceFixture creates an isolated workspace (not reachable
|
|
// from testUserID) with its own agent, runtime, issue, and queued task.
|
|
// Returns (issueID, taskID). All rows are cleaned up when the test ends.
|
|
func setupForeignWorkspaceFixture(t *testing.T) (string, string) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
var foreignWorkspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Foreign Workspace", "foreign-idor-tests", "Cross-tenant IDOR test workspace", "FOR").Scan(&foreignWorkspaceID); err != nil {
|
|
t.Fatalf("setup: create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID)
|
|
})
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
|
RETURNING id
|
|
`, foreignWorkspaceID, "Foreign Runtime", "foreign_runtime", "Foreign runtime").Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create foreign runtime: %v", err)
|
|
}
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, description, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks
|
|
)
|
|
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1)
|
|
RETURNING id
|
|
`, foreignWorkspaceID, "Foreign Agent", runtimeID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: create foreign agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type)
|
|
VALUES ($1, 'foreign-workspace-issue', 'todo', 'medium', $2, 'agent')
|
|
RETURNING id
|
|
`, foreignWorkspaceID, agentID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create foreign issue: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create foreign task: %v", err)
|
|
}
|
|
|
|
return issueID, taskID
|
|
}
|
|
|
|
// TestGetActiveTaskForIssue_CrossWorkspace_Returns404 verifies that a member of
|
|
// workspace A cannot discover tasks for an issue in workspace B by passing
|
|
// B's issue UUID in the URL while keeping A in X-Workspace-ID.
|
|
func TestGetActiveTaskForIssue_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/active-task", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.GetActiveTaskForIssue(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetActiveTaskForIssue with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_CrossWorkspace_Returns404 verifies that a member of workspace
|
|
// A cannot cancel a task that lives in workspace B. Critically, the task must
|
|
// remain in its original status — no side effect before the access check.
|
|
func TestCancelTask_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, foreignTaskID := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+foreignIssueID+"/tasks/"+foreignTaskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", foreignIssueID, "taskId", foreignTaskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("CancelTask with cross-workspace issueId/taskId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The foreign task must not have been cancelled.
|
|
var status string
|
|
if err := testPool.QueryRow(context.Background(),
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, foreignTaskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read foreign task status: %v", err)
|
|
}
|
|
if status != "queued" {
|
|
t.Fatalf("foreign task status was mutated: expected 'queued', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_TaskBelongsToDifferentIssue_Returns404 verifies that a task
|
|
// UUID belonging to a *different* issue in the *same* accessible workspace
|
|
// cannot be cancelled by routing it through another issue's URL. This guards
|
|
// against the weaker fix that only validates the issue→workspace binding.
|
|
func TestCancelTask_TaskBelongsToDifferentIssue_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
// Issue X — the task's real parent.
|
|
var issueXID, taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-crossissue-x', 'todo', 'medium', $2, 'member', 91001, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueXID); err != nil {
|
|
t.Fatalf("setup: create issue X: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueXID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueXID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
// Issue Y — a sibling in the same workspace, used only as the URL cover.
|
|
var issueYID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-crossissue-y', 'todo', 'medium', $2, 'member', 91002, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueYID); err != nil {
|
|
t.Fatalf("setup: create issue Y: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueYID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueYID+"/tasks/"+taskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", issueYID, "taskId", taskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("CancelTask with mismatched issueId/taskId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, taskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read task status: %v", err)
|
|
}
|
|
if status != "queued" {
|
|
t.Fatalf("task status was mutated: expected 'queued', got %q", status)
|
|
}
|
|
}
|
|
|
|
// TestCancelTask_SameIssue_Succeeds is the happy-path companion to the two
|
|
// negative tests above — same workspace, correct issue→task pairing → 200.
|
|
func TestCancelTask_SameIssue_Succeeds(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var issueID, taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'cancel-happy-path', 'todo', 'medium', $2, 'member', 91003, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'queued', $3)
|
|
RETURNING id
|
|
`, agentID, issueID, runtimeID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/"+issueID+"/tasks/"+taskID+"/cancel", nil)
|
|
req = withURLParams(req, "id", issueID, "taskId", taskID)
|
|
|
|
testHandler.CancelTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CancelTask with matching issueId/taskId: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestListTasksByIssue_CrossWorkspace_Returns404 verifies that task history
|
|
// is not readable across workspaces via a bare issue UUID.
|
|
func TestListTasksByIssue_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/task-runs", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.ListTasksByIssue(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("ListTasksByIssue with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetIssueUsage_CrossWorkspace_Returns404 verifies that per-issue token
|
|
// usage is not readable across workspaces via a bare issue UUID.
|
|
func TestGetIssueUsage_CrossWorkspace_Returns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
foreignIssueID, _ := setupForeignWorkspaceFixture(t)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("GET", "/api/issues/"+foreignIssueID+"/usage", nil)
|
|
req = withURLParam(req, "id", foreignIssueID)
|
|
|
|
testHandler.GetIssueUsage(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetIssueUsage with cross-workspace issueId: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_WithDaemonToken(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "git@example.com:team/api.git", "description": "API"},
|
|
{"url": " git@example.com:team/web.git ", "description": " Web "},
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Repos []map[string]string `json:"repos"`
|
|
ReposVersion string `json:"repos_version"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
|
|
if resp.WorkspaceID != testWorkspaceID {
|
|
t.Fatalf("expected workspace_id %s, got %s", testWorkspaceID, resp.WorkspaceID)
|
|
}
|
|
if len(resp.Repos) != 2 {
|
|
t.Fatalf("expected 2 repos, got %d", len(resp.Repos))
|
|
}
|
|
if resp.Repos[1]["url"] != "git@example.com:team/web.git" {
|
|
t.Fatalf("expected trimmed repo URL, got %q", resp.Repos[1]["url"])
|
|
}
|
|
if resp.ReposVersion == "" {
|
|
t.Fatal("expected repos_version to be set")
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_WithDaemonToken_WorkspaceMismatch(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, "00000000-0000-0000-0000-000000000000", "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("GetDaemonWorkspaceRepos with mismatched workspace: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestGetDaemonWorkspaceRepos_VersionIgnoresOrderAndDescription(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "git@example.com:team/api.git", "description": "API"},
|
|
{"url": "git@example.com:team/web.git", "description": "Web"},
|
|
})
|
|
|
|
getReposVersion := func() string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil, testWorkspaceID, "test-daemon-mdt")
|
|
req = withURLParam(req, "workspaceId", testWorkspaceID)
|
|
testHandler.GetDaemonWorkspaceRepos(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GetDaemonWorkspaceRepos: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
ReposVersion string `json:"repos_version"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
return resp.ReposVersion
|
|
}
|
|
|
|
version1 := getReposVersion()
|
|
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/web.git","description":"frontend"},{"url":"git@example.com:team/api.git","description":"backend"}]`), testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
version2 := getReposVersion()
|
|
if version1 != version2 {
|
|
t.Fatalf("expected repos_version to ignore order/description changes, got %s vs %s", version1, version2)
|
|
}
|
|
|
|
if _, err := testPool.Exec(context.Background(), `UPDATE workspace SET repos = $1 WHERE id = $2`, []byte(`[{"url":"git@example.com:team/api.git","description":"backend"},{"url":"git@example.com:team/mobile.git","description":"mobile"}]`), testWorkspaceID); err != nil {
|
|
t.Fatalf("update workspace repos: %v", err)
|
|
}
|
|
version3 := getReposVersion()
|
|
if strings.EqualFold(version2, version3) {
|
|
t.Fatalf("expected repos_version to change when URL set changes, got %s", version3)
|
|
}
|
|
}
|
|
|
|
// TestDaemonRegister_MergesLegacyDaemonIDRuntime simulates the migration path
|
|
// for an existing user whose runtime was previously keyed on a hostname-derived
|
|
// daemon_id (e.g. "MacBook-Pro.local"). After the daemon switches to a stable
|
|
// UUID, the registration payload lists the old id under `legacy_daemon_ids`.
|
|
// The server must:
|
|
//
|
|
// - reassign every agent pointing at the old runtime row to the new row,
|
|
// - reassign every task (agent_task_queue.runtime_id) onto the new row,
|
|
// - delete the stale old row so there's exactly one runtime per machine,
|
|
// - record the legacy daemon_id on the new row for traceability.
|
|
//
|
|
// This is the acceptance path from MUL-975: hostname drift must no longer
|
|
// orphan agents on stale runtime rows.
|
|
func TestDaemonRegister_MergesLegacyDaemonIDRuntime(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const legacyDaemonID = "TestMachine.local"
|
|
const newDaemonID = "0192a7a0-9ab3-7c3f-9f1c-4a6fe8c4e801"
|
|
|
|
// Seed a legacy runtime row keyed on the hostname-derived id.
|
|
var legacyRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at)
|
|
VALUES ($1, $2, 'legacy-runtime', 'local', 'claude', 'offline', 'TestMachine.local', '{}'::jsonb, $3, now() - interval '1 hour')
|
|
RETURNING id
|
|
`, testWorkspaceID, legacyDaemonID, testUserID).Scan(&legacyRuntimeID); err != nil {
|
|
t.Fatalf("seed legacy runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, legacyRuntimeID)
|
|
})
|
|
|
|
// An agent bound to the legacy runtime.
|
|
var legacyAgentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (workspace_id, name, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks)
|
|
VALUES ($1, 'legacy-agent', 'local', '{}'::jsonb, $2, 'workspace', 1)
|
|
RETURNING id
|
|
`, testWorkspaceID, legacyRuntimeID).Scan(&legacyAgentID); err != nil {
|
|
t.Fatalf("seed legacy agent: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, legacyAgentID)
|
|
})
|
|
|
|
// An issue + task also bound to the legacy runtime (tasks have ON DELETE
|
|
// CASCADE, so without reassignment deleting the legacy row would silently
|
|
// drop historical tasks).
|
|
var legacyIssueID, legacyTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'legacy-task-owner', 'todo', 'medium', $2, 'member', 97501, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&legacyIssueID); err != nil {
|
|
t.Fatalf("seed legacy issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, legacyIssueID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, issue_id, status, runtime_id)
|
|
VALUES ($1, $2, 'completed', $3)
|
|
RETURNING id
|
|
`, legacyAgentID, legacyIssueID, legacyRuntimeID).Scan(&legacyTaskID); err != nil {
|
|
t.Fatalf("seed legacy task: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, legacyTaskID)
|
|
})
|
|
|
|
// Register under the new stable UUID, declaring the prior hostname-derived
|
|
// id as legacy. The handler should merge the legacy row into the new one.
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": newDaemonID,
|
|
"legacy_daemon_ids": []string{legacyDaemonID},
|
|
"device_name": "TestMachine",
|
|
"runtimes": []map[string]any{
|
|
{"name": "test-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
runtimes := resp["runtimes"].([]any)
|
|
newRuntimeID := runtimes[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, newRuntimeID)
|
|
})
|
|
|
|
if newRuntimeID == legacyRuntimeID {
|
|
t.Fatalf("expected a new runtime row, got the legacy id back")
|
|
}
|
|
|
|
// Agent should now point at the new runtime.
|
|
var agentRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, legacyAgentID).Scan(&agentRuntimeID); err != nil {
|
|
t.Fatalf("read agent runtime_id: %v", err)
|
|
}
|
|
if agentRuntimeID != newRuntimeID {
|
|
t.Fatalf("agent not reassigned: got runtime_id=%s, want %s", agentRuntimeID, newRuntimeID)
|
|
}
|
|
|
|
// Task should be reassigned (not dropped).
|
|
var taskRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent_task_queue WHERE id = $1`, legacyTaskID).Scan(&taskRuntimeID); err != nil {
|
|
t.Fatalf("read task runtime_id: %v", err)
|
|
}
|
|
if taskRuntimeID != newRuntimeID {
|
|
t.Fatalf("task not reassigned: got runtime_id=%s, want %s", taskRuntimeID, newRuntimeID)
|
|
}
|
|
|
|
// Legacy runtime row must be gone — no more "online + offline" duplicates
|
|
// for the same machine.
|
|
var legacyCount int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, legacyRuntimeID).Scan(&legacyCount); err != nil {
|
|
t.Fatalf("count legacy runtime: %v", err)
|
|
}
|
|
if legacyCount != 0 {
|
|
t.Fatalf("expected legacy runtime row to be deleted, still present")
|
|
}
|
|
|
|
// New row should record which legacy id it subsumed, for debug/audit.
|
|
var legacyTrace *string
|
|
if err := testPool.QueryRow(ctx, `SELECT legacy_daemon_id FROM agent_runtime WHERE id = $1`, newRuntimeID).Scan(&legacyTrace); err != nil {
|
|
t.Fatalf("read legacy_daemon_id: %v", err)
|
|
}
|
|
if legacyTrace == nil || *legacyTrace != legacyDaemonID {
|
|
t.Fatalf("expected legacy_daemon_id=%q, got %v", legacyDaemonID, legacyTrace)
|
|
}
|
|
}
|
|
|
|
// TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal covers the
|
|
// direction missed by the initial implementation: the stored runtime row is
|
|
// `host` (no `.local`) but the daemon's current `os.Hostname()` now returns
|
|
// `host.local`. The daemon must emit the bare variant as a legacy candidate
|
|
// and the server must match it.
|
|
func TestDaemonRegister_MergesLegacyDaemonIDRuntime_ReverseDotLocal(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const legacyDaemonID = "ReverseDotLocalHost" // stored without .local
|
|
const emittedLegacyID = "ReverseDotLocalHost.local" // daemon now reports with .local
|
|
const newDaemonID = "0192a7b0-0011-7ee9-9c21-30a5bcf86aa2"
|
|
|
|
var legacyRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at)
|
|
VALUES ($1, $2, 'legacy-runtime-reverse', 'local', 'claude', 'offline', '', '{}'::jsonb, $3, now())
|
|
RETURNING id
|
|
`, testWorkspaceID, legacyDaemonID, testUserID).Scan(&legacyRuntimeID); err != nil {
|
|
t.Fatalf("seed legacy runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, legacyRuntimeID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": newDaemonID,
|
|
"legacy_daemon_ids": []string{"ReverseDotLocalHost", emittedLegacyID},
|
|
"device_name": "ReverseDotLocalHost",
|
|
"runtimes": []map[string]any{
|
|
{"name": "reverse-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
newRuntimeID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, newRuntimeID)
|
|
})
|
|
|
|
var legacyCount int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, legacyRuntimeID).Scan(&legacyCount); err != nil {
|
|
t.Fatalf("count legacy runtime: %v", err)
|
|
}
|
|
if legacyCount != 0 {
|
|
t.Fatalf("expected legacy row to be merged and deleted, still present")
|
|
}
|
|
}
|
|
|
|
// TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift verifies that
|
|
// case-only drift in os.Hostname() output (e.g. `Jiayuans-MacBook-Pro.local`
|
|
// vs `jiayuans-macbook-pro.local`) still merges the legacy row. The daemon
|
|
// emits the id in its current casing; the server-side lookup uses LOWER() on
|
|
// both sides so stored and emitted casings can differ without orphaning.
|
|
func TestDaemonRegister_MergesLegacyDaemonIDRuntime_CaseDrift(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const storedDaemonID = "Jiayuans-MacBook-Pro.local" // DB has original mixed case
|
|
const emittedLegacyID = "jiayuans-macbook-pro.local" // Daemon now reports lowercased
|
|
const newDaemonID = "0192a7b0-0022-7ee9-9c21-30a5bcf86aa3"
|
|
|
|
var legacyRuntimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at)
|
|
VALUES ($1, $2, 'legacy-runtime-case', 'local', 'claude', 'offline', '', '{}'::jsonb, $3, now())
|
|
RETURNING id
|
|
`, testWorkspaceID, storedDaemonID, testUserID).Scan(&legacyRuntimeID); err != nil {
|
|
t.Fatalf("seed legacy runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, legacyRuntimeID)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": newDaemonID,
|
|
"legacy_daemon_ids": []string{emittedLegacyID},
|
|
"device_name": "jiayuans-macbook-pro",
|
|
"runtimes": []map[string]any{
|
|
{"name": "case-drift-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
newRuntimeID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, newRuntimeID)
|
|
})
|
|
|
|
var legacyCount int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, legacyRuntimeID).Scan(&legacyCount); err != nil {
|
|
t.Fatalf("count legacy runtime: %v", err)
|
|
}
|
|
if legacyCount != 0 {
|
|
t.Fatalf("expected case-drift legacy row to be merged and deleted, still present")
|
|
}
|
|
|
|
var legacyTrace *string
|
|
if err := testPool.QueryRow(ctx, `SELECT legacy_daemon_id FROM agent_runtime WHERE id = $1`, newRuntimeID).Scan(&legacyTrace); err != nil {
|
|
t.Fatalf("read legacy_daemon_id: %v", err)
|
|
}
|
|
if legacyTrace == nil || *legacyTrace != emittedLegacyID {
|
|
t.Fatalf("expected legacy_daemon_id trace = %q, got %v", emittedLegacyID, legacyTrace)
|
|
}
|
|
}
|
|
|
|
// TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes covers the case
|
|
// where the DB already holds *two* legacy runtime rows that differ only in
|
|
// casing (e.g. `Jiayuans-MacBook-Pro.local` AND `jiayuans-macbook-pro.local`
|
|
// coexist under the same workspace+provider because earlier hostname drift
|
|
// already minted a duplicate). A single-row lookup would merge only one of
|
|
// them and leave the other orphaned; the lookup must return every row whose
|
|
// daemon_id case-insensitively matches and the handler must consolidate them
|
|
// all. This is the acceptance-standard path: after registration there must
|
|
// not be two runtime rows for the same machine.
|
|
func TestDaemonRegister_MergesAllCaseDuplicateLegacyRuntimes(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
const storedUpperID = "DupHost.local"
|
|
const storedLowerID = "duphost.local"
|
|
const newDaemonID = "0192a7b0-0033-7ee9-9c21-30a5bcf86aa4"
|
|
|
|
var legacyUpperID, legacyLowerID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at)
|
|
VALUES ($1, $2, 'legacy-upper', 'local', 'claude', 'offline', '', '{}'::jsonb, $3, now() - interval '2 hours')
|
|
RETURNING id
|
|
`, testWorkspaceID, storedUpperID, testUserID).Scan(&legacyUpperID); err != nil {
|
|
t.Fatalf("seed upper-case legacy runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, legacyUpperID) })
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, owner_id, last_seen_at)
|
|
VALUES ($1, $2, 'legacy-lower', 'local', 'claude', 'offline', '', '{}'::jsonb, $3, now() - interval '1 hour')
|
|
RETURNING id
|
|
`, testWorkspaceID, storedLowerID, testUserID).Scan(&legacyLowerID); err != nil {
|
|
t.Fatalf("seed lower-case legacy runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, legacyLowerID) })
|
|
|
|
// Bind one agent to each legacy row to verify both sides get reassigned.
|
|
var upperAgentID, lowerAgentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (workspace_id, name, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks)
|
|
VALUES ($1, 'dup-agent-upper', 'local', '{}'::jsonb, $2, 'workspace', 1)
|
|
RETURNING id
|
|
`, testWorkspaceID, legacyUpperID).Scan(&upperAgentID); err != nil {
|
|
t.Fatalf("seed upper agent: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, upperAgentID) })
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (workspace_id, name, runtime_mode, runtime_config, runtime_id, visibility, max_concurrent_tasks)
|
|
VALUES ($1, 'dup-agent-lower', 'local', '{}'::jsonb, $2, 'workspace', 1)
|
|
RETURNING id
|
|
`, testWorkspaceID, legacyLowerID).Scan(&lowerAgentID); err != nil {
|
|
t.Fatalf("seed lower agent: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent WHERE id = $1`, lowerAgentID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": newDaemonID,
|
|
"legacy_daemon_ids": []string{storedLowerID}, // a single candidate must resolve both stored casings
|
|
"device_name": "DupHost",
|
|
"runtimes": []map[string]any{
|
|
{"name": "dup-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
newRuntimeID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, newRuntimeID)
|
|
})
|
|
|
|
// Both case-duplicate legacy rows must be gone — not just one.
|
|
var stillPresent int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM agent_runtime WHERE id = ANY($1)
|
|
`, []string{legacyUpperID, legacyLowerID}).Scan(&stillPresent); err != nil {
|
|
t.Fatalf("count legacy runtimes: %v", err)
|
|
}
|
|
if stillPresent != 0 {
|
|
t.Fatalf("expected both case-duplicate legacy rows merged and deleted, %d still present", stillPresent)
|
|
}
|
|
|
|
// Both agents must point at the new runtime.
|
|
for _, agentID := range []string{upperAgentID, lowerAgentID} {
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `SELECT runtime_id FROM agent WHERE id = $1`, agentID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("read agent runtime_id: %v", err)
|
|
}
|
|
if runtimeID != newRuntimeID {
|
|
t.Fatalf("agent %s not reassigned: runtime_id=%s, want %s", agentID, runtimeID, newRuntimeID)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDaemonRegister_LegacyIDNoMatchIsNoop guards the common case where the
|
|
// daemon sends legacy candidates but no matching row exists (e.g. first
|
|
// registration on a fresh machine). Registration must still succeed, the new
|
|
// row must not have a spurious legacy_daemon_id recorded, and no unrelated
|
|
// rows may be touched.
|
|
func TestDaemonRegister_LegacyIDNoMatchIsNoop(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": "0192a7a1-5e3c-7be9-9a7d-6e0f1cb3deab",
|
|
"legacy_daemon_ids": []string{"NeverSeenHost", "NeverSeenHost.local"},
|
|
"device_name": "NeverSeenHost",
|
|
"runtimes": []map[string]any{
|
|
{"name": "fresh-runtime", "type": "claude", "version": "1.0.0", "status": "online"},
|
|
},
|
|
})
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp map[string]any
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
runtimeID := resp["runtimes"].([]any)[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
})
|
|
|
|
var legacy *string
|
|
if err := testPool.QueryRow(ctx, `SELECT legacy_daemon_id FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&legacy); err != nil {
|
|
t.Fatalf("read legacy_daemon_id: %v", err)
|
|
}
|
|
if legacy != nil {
|
|
t.Fatalf("expected legacy_daemon_id to stay NULL when no merge occurred, got %q", *legacy)
|
|
}
|
|
}
|
|
|
|
// Regression test for #1224: tasks linked only via AutopilotRunID (run_only
|
|
// autopilots) must resolve to the autopilot's workspace. Before the fix,
|
|
// resolveTaskWorkspaceID fell through and every StartTask call returned 404.
|
|
func TestStartTask_AutopilotRunOnlyTask_ResolvesWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (
|
|
workspace_id, title, assignee_id, execution_mode,
|
|
created_by_type, created_by_id
|
|
)
|
|
VALUES ($1, 'run_only fixture', $2, 'run_only', 'member', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("setup: create autopilot: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
|
|
var runID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot_run (autopilot_id, source, status)
|
|
VALUES ($1, 'manual', 'running')
|
|
RETURNING id
|
|
`, autopilotID).Scan(&runID); err != nil {
|
|
t.Fatalf("setup: create autopilot_run: %v", err)
|
|
}
|
|
|
|
// issue_id is explicitly NULL — the condition that used to trigger 404.
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority, autopilot_run_id
|
|
)
|
|
VALUES ($1, $2, NULL, 'dispatched', 0, $3)
|
|
RETURNING id
|
|
`, agentID, runtimeID, runID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create autopilot task: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
|
|
// Cross-workspace daemon token must still 404.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/start", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.StartTask(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("StartTask with cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace daemon token must succeed — this is the bug in #1224.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/start", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.StartTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("StartTask for run_only autopilot task: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var status string
|
|
if err := testPool.QueryRow(ctx, `SELECT status FROM agent_task_queue WHERE id = $1`, taskID).Scan(&status); err != nil {
|
|
t.Fatalf("post-check: read task status: %v", err)
|
|
}
|
|
if status != "running" {
|
|
t.Fatalf("expected task status 'running' after StartTask, got %q", status)
|
|
}
|
|
}
|
|
|
|
// ClaimTaskByRuntime must surface the issue's project github_repo resources
|
|
// as resp.Repos and hide the workspace-bound repos. Without this the agent
|
|
// would see two repo lists in the meta-skill and have no signal about which
|
|
// belongs to the current issue.
|
|
func TestClaimTask_ProjectGithubReposOverrideWorkspaceRepos(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Workspace repos: two of them, neither matches the project repo URL.
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "https://github.com/example/workspace-repo-a", "description": "ws a"},
|
|
{"url": "https://github.com/example/workspace-repo-b", "description": "ws b"},
|
|
})
|
|
|
|
// Project + project_resource(github_repo) with a URL that is NOT in the
|
|
// workspace's repos list.
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id
|
|
`, testWorkspaceID, "Claim project repo override").Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) })
|
|
|
|
const projectRepoURL = "https://github.com/example/project-only-repo"
|
|
const projectRepoRef = "release/v2"
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO project_resource (
|
|
project_id, workspace_id, resource_type, resource_ref, position
|
|
) VALUES ($1, $2, 'github_repo', $3::jsonb, 0)
|
|
`, projectID, testWorkspaceID, `{"url":"`+projectRepoURL+`","ref":"`+projectRepoRef+`"}`); err != nil {
|
|
t.Fatalf("create project_resource: %v", err)
|
|
}
|
|
|
|
// Agent + runtime + queued task in this project.
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, project_id, title, status, priority, creator_id, creator_type, number, position
|
|
) VALUES ($1, $2, 'project repo override', 'todo', 'medium', $3, 'member', 88001, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, projectID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority
|
|
) VALUES ($1, $2, $3, 'queued', 0)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-project-repos")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
Repos []RepoData `json:"repos"`
|
|
ProjectID string `json:"project_id"`
|
|
ProjectResources []ProjectResourceData `json:"project_resources"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatal("expected task in response")
|
|
}
|
|
if resp.Task.ProjectID != projectID {
|
|
t.Errorf("project_id = %q, want %q", resp.Task.ProjectID, projectID)
|
|
}
|
|
if len(resp.Task.Repos) != 1 || resp.Task.Repos[0].URL != projectRepoURL {
|
|
t.Fatalf("expected resp.Repos to contain only the project repo URL, got %+v", resp.Task.Repos)
|
|
}
|
|
if resp.Task.Repos[0].Ref != projectRepoRef {
|
|
t.Fatalf("project repo ref = %q, want %q", resp.Task.Repos[0].Ref, projectRepoRef)
|
|
}
|
|
for _, r := range resp.Task.Repos {
|
|
if strings.HasSuffix(r.URL, "workspace-repo-a") || strings.HasSuffix(r.URL, "workspace-repo-b") {
|
|
t.Errorf("workspace repo %q leaked into resp.Repos despite project override", r.URL)
|
|
}
|
|
}
|
|
if len(resp.Task.ProjectResources) != 1 {
|
|
t.Errorf("expected 1 project_resources entry, got %d", len(resp.Task.ProjectResources))
|
|
}
|
|
}
|
|
|
|
// When an issue belongs to a project that has a description, the claim handler
|
|
// must surface that description as project_description so the daemon can inject
|
|
// it into the brief. This is the handler-side boundary the execenv tests can't
|
|
// cover: if this assignment regresses, the description silently stops reaching
|
|
// the agent while the execenv rendering tests still pass.
|
|
func TestClaimTask_ProjectDescriptionInjected(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
const projectDescription = "Always write copy in British English. Ship behind a feature flag."
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title, description) VALUES ($1, $2, $3) RETURNING id
|
|
`, testWorkspaceID, "Claim project description", projectDescription).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) })
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, project_id, title, status, priority, creator_id, creator_type, number, position
|
|
) VALUES ($1, $2, 'project description', 'todo', 'medium', $3, 'member', 88002, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, projectID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority
|
|
) VALUES ($1, $2, $3, 'queued', 0)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-project-desc")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
ProjectID string `json:"project_id"`
|
|
ProjectDescription string `json:"project_description"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatal("expected task in response")
|
|
}
|
|
if resp.Task.ProjectID != projectID {
|
|
t.Errorf("project_id = %q, want %q", resp.Task.ProjectID, projectID)
|
|
}
|
|
if resp.Task.ProjectDescription != projectDescription {
|
|
t.Errorf("project_description = %q, want %q", resp.Task.ProjectDescription, projectDescription)
|
|
}
|
|
}
|
|
|
|
// The quick-create path resolves its project from the task context JSONB
|
|
// (not an issue row), so its project_description wiring is a separate branch
|
|
// in the claim handler and needs its own boundary assertion.
|
|
func TestClaimTask_QuickCreateInjectsProjectDescription(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
const projectDescription = "Use the design system tokens; never hardcode colors."
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title, description) VALUES ($1, $2, $3) RETURNING id
|
|
`, testWorkspaceID, "Quick-create project description", projectDescription).Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) })
|
|
|
|
quickContext, _ := json.Marshal(map[string]any{
|
|
"type": "quick_create",
|
|
"prompt": "create a follow-up issue",
|
|
"requester_id": testUserID,
|
|
"workspace_id": testWorkspaceID,
|
|
"project_id": projectID,
|
|
})
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, context)
|
|
VALUES ($1, $2, 'queued', 2, $3)
|
|
`, agentID, runtimeID, quickContext); err != nil {
|
|
t.Fatalf("setup: create quick-create task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.ProjectID != projectID {
|
|
t.Errorf("quick-create project_id = %q, want %q", task.ProjectID, projectID)
|
|
}
|
|
if task.ProjectDescription != projectDescription {
|
|
t.Errorf("quick-create project_description = %q, want %q", task.ProjectDescription, projectDescription)
|
|
}
|
|
}
|
|
|
|
// When the issue's project has no github_repo resources, the claim handler
|
|
// must fall back to workspace repos (the pre-override behavior).
|
|
func TestClaimTask_ProjectWithoutRepos_FallsBackToWorkspaceRepos(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
setHandlerTestWorkspaceRepos(t, []map[string]string{
|
|
{"url": "https://github.com/example/workspace-fallback", "description": "ws"},
|
|
})
|
|
|
|
var projectID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO project (workspace_id, title) VALUES ($1, $2) RETURNING id
|
|
`, testWorkspaceID, "Claim project without repos").Scan(&projectID); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID) })
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (
|
|
workspace_id, project_id, title, status, priority, creator_id, creator_type, number, position
|
|
) VALUES ($1, $2, 'no project repos', 'todo', 'medium', $3, 'member', 88002, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, projectID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority
|
|
) VALUES ($1, $2, $3, 'queued', 0)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("create task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil, testWorkspaceID, "test-claim-fallback")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
Repos []RepoData `json:"repos"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatal("expected task in response")
|
|
}
|
|
if len(resp.Task.Repos) != 1 || !strings.HasSuffix(resp.Task.Repos[0].URL, "workspace-fallback") {
|
|
t.Fatalf("expected workspace fallback repo, got %+v", resp.Task.Repos)
|
|
}
|
|
}
|
|
|
|
// Regression test for #1276: ClaimTaskByRuntime must populate workspace_id in
|
|
// the response for run_only autopilot tasks. Before the fix, resp.WorkspaceID
|
|
// stayed empty because ClaimTaskByRuntime only handled IssueID and
|
|
// ChatSessionID branches, causing the daemon's execenv to fail with
|
|
// "workspace ID is required".
|
|
func TestClaimTask_AutopilotRunOnly_PopulatesWorkspaceID(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (
|
|
workspace_id, title, assignee_id, execution_mode,
|
|
created_by_type, created_by_id
|
|
)
|
|
VALUES ($1, 'claim workspace fixture', $2, 'run_only', 'member', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("setup: create autopilot: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
|
|
var runID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot_run (autopilot_id, source, status)
|
|
VALUES ($1, 'manual', 'running')
|
|
RETURNING id
|
|
`, autopilotID).Scan(&runID); err != nil {
|
|
t.Fatalf("setup: create autopilot_run: %v", err)
|
|
}
|
|
|
|
// Create a queued task with only AutopilotRunID (no IssueID, no ChatSessionID).
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, status, priority, autopilot_run_id
|
|
)
|
|
VALUES ($1, $2, NULL, 'queued', 0, $3)
|
|
RETURNING id
|
|
`, agentID, runtimeID, runID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create autopilot task: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
|
|
testWorkspaceID, "test-daemon-claim")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("runtimeId", runtimeID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *struct {
|
|
WorkspaceID string `json:"workspace_id"`
|
|
ThreadName string `json:"thread_name"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatal("expected a task in response, got nil")
|
|
}
|
|
if resp.Task.WorkspaceID == "" {
|
|
t.Fatal("ClaimTaskByRuntime for run_only autopilot: workspace_id is empty in response")
|
|
}
|
|
if resp.Task.WorkspaceID != testWorkspaceID {
|
|
t.Fatalf("expected workspace_id %q, got %q", testWorkspaceID, resp.Task.WorkspaceID)
|
|
}
|
|
if resp.Task.ThreadName != "claim workspace fixture" {
|
|
t.Fatalf("autopilot task thread_name = %q, want autopilot title", resp.Task.ThreadName)
|
|
}
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects verifies
|
|
// the defense-in-depth check in ClaimTaskByRuntime: if a task is somehow
|
|
// dispatched to a runtime whose workspace doesn't match the task's
|
|
// resolved workspace (upstream routing / data-integrity bug), the handler
|
|
// must 500 AND cancel the dispatched task so it doesn't sit in
|
|
// 'dispatched' until the 5-minute sweeper — which would also leave the
|
|
// agent stuck reporting 'working' in the UI.
|
|
func TestClaimTaskByRuntime_TaskWorkspaceMismatch_CancelsAndRejects(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
// Local agent/runtime (belongs to testWorkspace).
|
|
var localAgentID, localRuntimeID string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT id, runtime_id FROM agent WHERE workspace_id = $1 LIMIT 1`,
|
|
testWorkspaceID,
|
|
).Scan(&localAgentID, &localRuntimeID); err != nil {
|
|
t.Fatalf("setup: get local agent: %v", err)
|
|
}
|
|
|
|
// Foreign workspace with its own issue — what the misrouted task will
|
|
// resolve to.
|
|
var foreignWorkspaceID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id
|
|
`, "Mismatch Foreign", "mismatch-foreign-claim", "", "MFC").Scan(&foreignWorkspaceID); err != nil {
|
|
t.Fatalf("setup: create foreign workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, foreignWorkspaceID) })
|
|
|
|
var foreignIssueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'mismatch-foreign-issue', 'todo', 'medium', $2, 'member', 77001, 0)
|
|
RETURNING id
|
|
`, foreignWorkspaceID, testUserID).Scan(&foreignIssueID); err != nil {
|
|
t.Fatalf("setup: create foreign issue: %v", err)
|
|
}
|
|
|
|
// Construct the inconsistent task: runtime_id belongs to testWorkspace,
|
|
// but issue_id is in foreignWorkspace. This is the data shape a routing
|
|
// bug would produce.
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority)
|
|
VALUES ($1, $2, $3, 'queued', 2)
|
|
RETURNING id
|
|
`, localAgentID, localRuntimeID, foreignIssueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create mismatched task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+localRuntimeID+"/claim", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("runtimeId", localRuntimeID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("ClaimTaskByRuntime (mismatch): expected 500, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Task must NOT remain dispatched — it has to be cancelled so the agent
|
|
// is released immediately rather than stuck until the sweeper fires.
|
|
var status string
|
|
if err := testPool.QueryRow(ctx,
|
|
`SELECT status FROM agent_task_queue WHERE id = $1`, taskID,
|
|
).Scan(&status); err != nil {
|
|
t.Fatalf("read task status: %v", err)
|
|
}
|
|
if status != "cancelled" {
|
|
t.Fatalf("ClaimTaskByRuntime (mismatch): expected task status=cancelled, got %q", status)
|
|
}
|
|
}
|
|
|
|
// Regression test for MUL-1198: comment-triggered tasks that finish without
|
|
// the agent posting any comment must still deliver a synthesized result
|
|
// comment, threaded under the trigger. Before the fix, CompleteTask exempted
|
|
// comment-triggered tasks from the auto-synthesis path, so a Claude Code /
|
|
// Codex / etc. agent that ended its run with only terminal text (no
|
|
// `multica issue comment add` call) left the user staring at a "Completed"
|
|
// badge with no reply.
|
|
func TestCompleteTask_CommentTriggered_SynthesizesCommentWhenAgentSilent(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
setWorkspaceIssuePrefixForTest(t, "MUL")
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'mul-3310 agent output fixture', 'in_progress', 'none', $2, 'member', 3310, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var triggerCommentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'please take a look', 'comment')
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID).Scan(&triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create trigger comment: %v", err)
|
|
}
|
|
|
|
// Comment-triggered, already running (as CompleteAgentTask requires).
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, trigger_comment_id,
|
|
status, priority, started_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, 'running', 0, now())
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create comment-triggered task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
agentFinalOutput := fmt.Sprintf(
|
|
"sure, see MUL-3310, issue/MUL-3310, feature/MUL-3310, and [MUL-3310](mention://issue/%s)",
|
|
issueID,
|
|
)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",
|
|
map[string]any{"output": agentFinalOutput},
|
|
testWorkspaceID, "legit-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.CompleteTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Exactly one agent comment on the issue, threaded under the trigger,
|
|
// carrying the agent's final output.
|
|
rows, err := testPool.Query(ctx, `
|
|
SELECT content, parent_id FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'agent' AND author_id = $2
|
|
ORDER BY created_at ASC
|
|
`, issueID, agentID)
|
|
if err != nil {
|
|
t.Fatalf("query synthesized comments: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var (
|
|
content string
|
|
parentID *string
|
|
seen int
|
|
)
|
|
for rows.Next() {
|
|
if err := rows.Scan(&content, &parentID); err != nil {
|
|
t.Fatalf("scan comment: %v", err)
|
|
}
|
|
seen++
|
|
}
|
|
if seen != 1 {
|
|
t.Fatalf("expected exactly 1 synthesized agent comment, got %d", seen)
|
|
}
|
|
if content != agentFinalOutput {
|
|
t.Fatalf("synthesized comment content = %q, want %q", content, agentFinalOutput)
|
|
}
|
|
if parentID == nil || *parentID != triggerCommentID {
|
|
got := "<nil>"
|
|
if parentID != nil {
|
|
got = *parentID
|
|
}
|
|
t.Fatalf("synthesized comment parent_id = %s, want trigger comment %s", got, triggerCommentID)
|
|
}
|
|
}
|
|
|
|
// Companion to the above: when the agent DID post its own comment during the
|
|
// run, CompleteTask must not synthesize a duplicate. Guards against the
|
|
// common case where the fix is over-eager and creates two comments per task.
|
|
func TestCompleteTask_CommentTriggered_SkipsSynthesisWhenAgentAlreadyCommented(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'mul-1198 dedup fixture', 'in_progress', 'none', $2, 'member', 81199, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var triggerCommentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'please take a look', 'comment')
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID).Scan(&triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create trigger comment: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, trigger_comment_id,
|
|
status, priority, started_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, 'running', 0, now())
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create comment-triggered task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
// Agent posts its own reply during the run — exactly the compliant path.
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
|
|
VALUES ($1, $2, 'agent', $3, 'done, see PR', 'comment', $4)
|
|
`, issueID, testWorkspaceID, agentID, triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create agent reply: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",
|
|
map[string]any{"output": "final terminal text that must NOT become a comment"},
|
|
testWorkspaceID, "legit-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.CompleteTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'agent' AND author_id = $2
|
|
`, issueID, agentID).Scan(&count); err != nil {
|
|
t.Fatalf("count agent comments: %v", err)
|
|
}
|
|
if count != 1 {
|
|
t.Fatalf("expected 1 agent comment (the agent's own reply), got %d — synthesis duplicated", count)
|
|
}
|
|
}
|
|
|
|
func TestCompleteTask_CommentTriggered_SuppressesTrivialDoneOutput(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'trivial-done-suppression fixture', 'in_progress', 'none', $2, 'member', 81200, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var triggerCommentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'please follow up', 'comment')
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID).Scan(&triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create trigger comment: %v", err)
|
|
}
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, trigger_comment_id,
|
|
status, priority, started_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, 'running', 0, now())
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, triggerCommentID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create comment-triggered task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",
|
|
map[string]any{"output": "Done."},
|
|
testWorkspaceID, "legit-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.CompleteTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var count int
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT count(*) FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'agent' AND author_id = $2
|
|
`, issueID, agentID).Scan(&count); err != nil {
|
|
t.Fatalf("count agent comments: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("expected no synthesized agent comment for trivial Done output, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestCompleteTask_AssignmentTriggered_DoesNotSuppressTrivialDoneOutput(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var issueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'assignment-trivial-done fixture', 'in_progress', 'none', $2, 'member', 81201, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&issueID); err != nil {
|
|
t.Fatalf("setup: create issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, issueID) })
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, started_at
|
|
)
|
|
VALUES ($1, $2, $3, 'running', 0, now())
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create assignment-triggered task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/tasks/"+taskID+"/complete",
|
|
map[string]any{"output": "Done."},
|
|
testWorkspaceID, "legit-daemon")
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("taskId", taskID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.CompleteTask(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("CompleteTask: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var content string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT content FROM comment
|
|
WHERE issue_id = $1 AND author_type = 'agent' AND author_id = $2
|
|
ORDER BY created_at DESC LIMIT 1
|
|
`, issueID, agentID).Scan(&content); err != nil {
|
|
t.Fatalf("query synthesized comment: %v", err)
|
|
}
|
|
if content != "Done." {
|
|
t.Fatalf("synthesized comment content = %q, want Done.", content)
|
|
}
|
|
}
|
|
|
|
type claimRuntimeGuardTask struct {
|
|
PriorSessionID string `json:"prior_session_id"`
|
|
PriorWorkDir string `json:"prior_work_dir"`
|
|
ChatMessage string `json:"chat_message"`
|
|
ThreadName string `json:"thread_name"`
|
|
QuickCreateAttachmentIDs []string `json:"quick_create_attachment_ids"`
|
|
ProjectID string `json:"project_id"`
|
|
ProjectDescription string `json:"project_description"`
|
|
}
|
|
|
|
func claimTaskForRuntimeGuard(t *testing.T, runtimeID, daemonID string) *claimRuntimeGuardTask {
|
|
t.Helper()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/claim", nil,
|
|
testWorkspaceID, daemonID)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("runtimeId", runtimeID)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var resp struct {
|
|
Task *claimRuntimeGuardTask `json:"task"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatal("expected a task in response, got nil")
|
|
}
|
|
return resp.Task
|
|
}
|
|
|
|
func createRuntimeGuardAgent(t *testing.T, ctx context.Context) (agentID, runtimeID, daemonID string) {
|
|
t.Helper()
|
|
|
|
daemonID = "runtime-guard-" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", "-"))
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/register", map[string]any{
|
|
"workspace_id": testWorkspaceID,
|
|
"daemon_id": daemonID,
|
|
"device_name": "runtime-guard-test",
|
|
"runtimes": []map[string]any{
|
|
{"name": "runtime-guard-current", "type": "opencode", "version": "test", "status": "online"},
|
|
},
|
|
}, testWorkspaceID, daemonID)
|
|
|
|
testHandler.DaemonRegister(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("setup: DaemonRegister: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp map[string]any
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("setup: decode DaemonRegister response: %v", err)
|
|
}
|
|
runtimes, ok := resp["runtimes"].([]any)
|
|
if !ok || len(runtimes) == 0 {
|
|
t.Fatalf("setup: expected registered runtime, got %v", resp)
|
|
}
|
|
runtimeID = runtimes[0].(map[string]any)["id"].(string)
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) })
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent_runtime SET owner_id = $1 WHERE id = $2`, testUserID, runtimeID); err != nil {
|
|
t.Fatalf("setup: set runtime owner: %v", err)
|
|
}
|
|
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent (
|
|
workspace_id, name, runtime_mode, runtime_config,
|
|
runtime_id, visibility, max_concurrent_tasks
|
|
)
|
|
VALUES ($1, $2, 'local', '{}'::jsonb, $3, 'workspace', 3)
|
|
RETURNING id
|
|
`, testWorkspaceID, "Runtime Guard Agent "+t.Name(), runtimeID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: create runtime guard agent: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent WHERE id = $1`, agentID) })
|
|
|
|
return agentID, runtimeID, daemonID
|
|
}
|
|
|
|
func createRuntimeGuardRuntime(t *testing.T, ctx context.Context, provider string) string {
|
|
t.Helper()
|
|
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
|
device_info, metadata, owner_id, last_seen_at
|
|
)
|
|
VALUES ($1, 'runtime-guard-' || gen_random_uuid()::text, 'Runtime Guard Fixture',
|
|
'local', $2, 'offline', '{}'::jsonb, '{}'::jsonb, $3, now())
|
|
RETURNING id
|
|
`, testWorkspaceID, provider, testUserID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("setup: create runtime: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_runtime WHERE id = $1`, runtimeID) })
|
|
return runtimeID
|
|
}
|
|
|
|
func TestChatSessionRuntimeBackfillRequiresMatchingSessionID(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
tx, err := testPool.Begin(ctx)
|
|
if err != nil {
|
|
t.Fatalf("begin tx: %v", err)
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
CREATE TEMP TABLE chat_session (
|
|
id uuid PRIMARY KEY,
|
|
session_id text,
|
|
runtime_id uuid
|
|
) ON COMMIT DROP;
|
|
`); err != nil {
|
|
t.Fatalf("setup temp chat_session table: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
CREATE TEMP TABLE agent_task_queue (
|
|
chat_session_id uuid,
|
|
runtime_id uuid,
|
|
session_id text,
|
|
status text,
|
|
completed_at timestamptz,
|
|
started_at timestamptz,
|
|
dispatched_at timestamptz,
|
|
created_at timestamptz
|
|
) ON COMMIT DROP;
|
|
`); err != nil {
|
|
t.Fatalf("setup temp agent_task_queue table: %v", err)
|
|
}
|
|
|
|
const (
|
|
poisonedChatID = "00000000-0000-0000-0000-000000000101"
|
|
matchedChatID = "00000000-0000-0000-0000-000000000102"
|
|
oldRuntimeID = "00000000-0000-0000-0000-000000000201"
|
|
newRuntimeID = "00000000-0000-0000-0000-000000000202"
|
|
)
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO chat_session (id, session_id, runtime_id)
|
|
VALUES
|
|
($1, 'old-runtime-session', NULL),
|
|
($2, 'matched-runtime-session', NULL);
|
|
`, poisonedChatID, matchedChatID); err != nil {
|
|
t.Fatalf("seed temp chat sessions: %v", err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
chat_session_id, runtime_id, session_id, status,
|
|
completed_at, started_at, dispatched_at, created_at
|
|
)
|
|
VALUES
|
|
($1, $3, 'old-runtime-session', 'completed',
|
|
now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours', now() - interval '2 hours'),
|
|
($1, $4, 'new-runtime-session', 'completed',
|
|
now() - interval '1 hour', now() - interval '1 hour', now() - interval '1 hour', now() - interval '1 hour'),
|
|
($2, $4, 'matched-runtime-session', 'completed',
|
|
now() - interval '30 minutes', now() - interval '30 minutes', now() - interval '30 minutes', now() - interval '30 minutes');
|
|
`, poisonedChatID, matchedChatID, oldRuntimeID, newRuntimeID); err != nil {
|
|
t.Fatalf("seed temp task sessions: %v", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `
|
|
UPDATE chat_session cs
|
|
SET runtime_id = latest.runtime_id
|
|
FROM (
|
|
SELECT DISTINCT ON (chat_session_id)
|
|
chat_session_id,
|
|
runtime_id,
|
|
session_id
|
|
FROM agent_task_queue
|
|
WHERE chat_session_id IS NOT NULL
|
|
AND session_id IS NOT NULL
|
|
AND status IN ('completed', 'failed')
|
|
ORDER BY chat_session_id, COALESCE(completed_at, started_at, dispatched_at, created_at) DESC
|
|
) latest
|
|
WHERE latest.chat_session_id = cs.id
|
|
AND latest.session_id = cs.session_id
|
|
`); err != nil {
|
|
t.Fatalf("run runtime backfill: %v", err)
|
|
}
|
|
|
|
var poisonedRuntimeID *string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT runtime_id::text FROM chat_session WHERE id = $1
|
|
`, poisonedChatID).Scan(&poisonedRuntimeID); err != nil {
|
|
t.Fatalf("query poisoned chat runtime: %v", err)
|
|
}
|
|
if poisonedRuntimeID != nil {
|
|
t.Fatalf("expected stale session mismatch to remain NULL, got %s", *poisonedRuntimeID)
|
|
}
|
|
|
|
var matchedRuntimeID string
|
|
if err := tx.QueryRow(ctx, `
|
|
SELECT runtime_id::text FROM chat_session WHERE id = $1
|
|
`, matchedChatID).Scan(&matchedRuntimeID); err != nil {
|
|
t.Fatalf("query matched chat runtime: %v", err)
|
|
}
|
|
if matchedRuntimeID != newRuntimeID {
|
|
t.Fatalf("expected matched session to backfill runtime %s, got %s", newRuntimeID, matchedRuntimeID)
|
|
}
|
|
}
|
|
|
|
func TestClaimTask_IssuePriorSessionRuntimeGuard(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
oldRuntimeID := createRuntimeGuardRuntime(t, ctx, "kimi")
|
|
|
|
var skipIssueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'runtime-session-skip fixture', 'in_progress', 'none', $2, 'member', 81203, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&skipIssueID); err != nil {
|
|
t.Fatalf("setup: create skip issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, skipIssueID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'old-runtime-session', '/tmp/old-runtime-workdir')
|
|
`, agentID, oldRuntimeID, skipIssueID); err != nil {
|
|
t.Fatalf("setup: create old-runtime prior task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
`, agentID, runtimeID, skipIssueID); err != nil {
|
|
t.Fatalf("setup: create current-runtime task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "" {
|
|
t.Fatalf("runtime mismatch: expected empty PriorSessionID, got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/old-runtime-workdir" {
|
|
t.Fatalf("runtime mismatch: expected PriorWorkDir='/tmp/old-runtime-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
if task.ThreadName != "runtime-session-skip fixture" {
|
|
t.Fatalf("issue task thread_name = %q, want issue title", task.ThreadName)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
UPDATE agent_task_queue
|
|
SET status = 'completed', completed_at = now()
|
|
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
|
`, skipIssueID); err != nil {
|
|
t.Fatalf("setup: complete claimed skip task: %v", err)
|
|
}
|
|
|
|
var resumeIssueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'runtime-session-resume fixture', 'in_progress', 'none', $2, 'member', 81204, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&resumeIssueID); err != nil {
|
|
t.Fatalf("setup: create resume issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, resumeIssueID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'same-runtime-session', '/tmp/same-runtime-workdir')
|
|
`, agentID, runtimeID, resumeIssueID); err != nil {
|
|
t.Fatalf("setup: create same-runtime prior task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
`, agentID, runtimeID, resumeIssueID); err != nil {
|
|
t.Fatalf("setup: create same-runtime task: %v", err)
|
|
}
|
|
|
|
task = claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "same-runtime-session" {
|
|
t.Fatalf("runtime match: expected PriorSessionID='same-runtime-session', got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/same-runtime-workdir" {
|
|
t.Fatalf("runtime match: expected PriorWorkDir='/tmp/same-runtime-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
|
|
var commentIssueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'comment-triggered-session-skip fixture', 'in_progress', 'none', $2, 'member', 81205, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&commentIssueID); err != nil {
|
|
t.Fatalf("setup: create comment-triggered issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, commentIssueID) })
|
|
|
|
var triggerCommentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'please follow up', 'comment')
|
|
RETURNING id
|
|
`, commentIssueID, testWorkspaceID, testUserID).Scan(&triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create trigger comment: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'comment-prior-session', '/tmp/comment-prior-workdir')
|
|
`, agentID, runtimeID, commentIssueID); err != nil {
|
|
t.Fatalf("setup: create comment-trigger prior task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id, trigger_comment_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, $4, 'queued', 0)
|
|
`, agentID, runtimeID, commentIssueID, triggerCommentID); err != nil {
|
|
t.Fatalf("setup: create comment-triggered task: %v", err)
|
|
}
|
|
|
|
task = claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
// Comment-triggered tasks now resume the prior session by default (same
|
|
// runtime), so the agent keeps the issue's conversation context across turns.
|
|
if task.PriorSessionID != "comment-prior-session" {
|
|
t.Fatalf("comment trigger: expected PriorSessionID='comment-prior-session' (resume default-on), got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/comment-prior-workdir" {
|
|
t.Fatalf("comment trigger: expected PriorWorkDir='/tmp/comment-prior-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
UPDATE agent_task_queue
|
|
SET status = 'completed', completed_at = now()
|
|
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
|
|
`, commentIssueID); err != nil {
|
|
t.Fatalf("setup: complete claimed comment-trigger task: %v", err)
|
|
}
|
|
|
|
var freshIssueID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO issue (workspace_id, title, status, priority, creator_id, creator_type, number, position)
|
|
VALUES ($1, 'force-fresh-session fixture', 'in_progress', 'none', $2, 'member', 81206, 0)
|
|
RETURNING id
|
|
`, testWorkspaceID, testUserID).Scan(&freshIssueID); err != nil {
|
|
t.Fatalf("setup: create force-fresh issue: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, freshIssueID) })
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'force-fresh-prior-session', '/tmp/force-fresh-prior-workdir')
|
|
`, agentID, runtimeID, freshIssueID); err != nil {
|
|
t.Fatalf("setup: create force-fresh prior task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, issue_id,
|
|
status, priority, force_fresh_session
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0, TRUE)
|
|
`, agentID, runtimeID, freshIssueID); err != nil {
|
|
t.Fatalf("setup: create force-fresh task: %v", err)
|
|
}
|
|
|
|
task = claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "" {
|
|
t.Fatalf("force fresh: expected empty PriorSessionID, got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "" {
|
|
t.Fatalf("force fresh: expected empty PriorWorkDir, got %q", task.PriorWorkDir)
|
|
}
|
|
}
|
|
|
|
func TestClaimTask_ChatPriorSessionRuntimeGuard(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
oldRuntimeID := createRuntimeGuardRuntime(t, ctx, "kimi")
|
|
|
|
var skipSessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (
|
|
workspace_id, agent_id, creator_id, title,
|
|
session_id, work_dir, runtime_id
|
|
)
|
|
VALUES ($1, $2, $3, 'runtime guard skip chat', 'old-chat-session', '/tmp/old-chat-workdir', $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID, oldRuntimeID).Scan(&skipSessionID); err != nil {
|
|
t.Fatalf("setup: create skip chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, skipSessionID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'old-chat-session', '/tmp/old-chat-workdir')
|
|
`, agentID, oldRuntimeID, skipSessionID); err != nil {
|
|
t.Fatalf("setup: create old-runtime chat task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
`, agentID, runtimeID, skipSessionID); err != nil {
|
|
t.Fatalf("setup: create current-runtime chat task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "" {
|
|
t.Fatalf("chat runtime mismatch: expected empty PriorSessionID, got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/old-chat-workdir" {
|
|
t.Fatalf("chat runtime mismatch: expected PriorWorkDir='/tmp/old-chat-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
UPDATE agent_task_queue
|
|
SET status = 'completed', completed_at = now()
|
|
WHERE chat_session_id = $1 AND status IN ('dispatched', 'running')
|
|
`, skipSessionID); err != nil {
|
|
t.Fatalf("setup: complete claimed skip chat task: %v", err)
|
|
}
|
|
|
|
var resumeSessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (
|
|
workspace_id, agent_id, creator_id, title,
|
|
session_id, work_dir, runtime_id
|
|
)
|
|
VALUES ($1, $2, $3, 'runtime guard resume chat', 'same-chat-session', '/tmp/same-chat-workdir', $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID, runtimeID).Scan(&resumeSessionID); err != nil {
|
|
t.Fatalf("setup: create resume chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, resumeSessionID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
`, agentID, runtimeID, resumeSessionID); err != nil {
|
|
t.Fatalf("setup: create same-runtime chat task: %v", err)
|
|
}
|
|
|
|
task = claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "same-chat-session" {
|
|
t.Fatalf("chat runtime match: expected PriorSessionID='same-chat-session', got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/same-chat-workdir" {
|
|
t.Fatalf("chat runtime match: expected PriorWorkDir='/tmp/same-chat-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
}
|
|
|
|
// TestClaimTask_ChatDeliversAllUnansweredUserMessages pins the fix for the
|
|
// regression the MUL-2968 debounce exposed: when several user messages are
|
|
// debounced into a single run, the agent must receive ALL of them, not just
|
|
// the most recent. Before the fix the daemon prompt was the single latest
|
|
// user message, so "看上海天气" then "还有青岛" answered only Qingdao.
|
|
func TestClaimTask_ChatDeliversAllUnansweredUserMessages(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
var sessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
|
|
VALUES ($1, $2, $3, 'debounce delivery chat')
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("setup: create chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID) })
|
|
|
|
// Two user messages debounced into one run (explicit created_at so the
|
|
// ASC ordering is deterministic).
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content, created_at) VALUES
|
|
($1, 'user', '看上海天气', now()),
|
|
($1, 'user', '还有青岛', now() + interval '1 second')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("setup: insert user messages: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, chat_session_id, status, priority)
|
|
VALUES ($1, $2, $3, 'queued', 2)
|
|
`, agentID, runtimeID, sessionID); err != nil {
|
|
t.Fatalf("setup: create chat task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.ChatMessage != "看上海天气\n\n还有青岛" {
|
|
t.Fatalf("chat prompt must include every unanswered user message in order; got %q", task.ChatMessage)
|
|
}
|
|
if task.ThreadName != "debounce delivery chat" {
|
|
t.Fatalf("chat task thread_name = %q, want chat session title", task.ThreadName)
|
|
}
|
|
|
|
// Complete the run and record the agent's assistant reply, then send a
|
|
// fresh user message — only the new one should be delivered next.
|
|
if _, err := testPool.Exec(ctx, `
|
|
UPDATE agent_task_queue SET status = 'completed', completed_at = now()
|
|
WHERE chat_session_id = $1 AND status IN ('dispatched', 'running')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("setup: complete first chat task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'assistant', '上海与青岛天气如下…', now() + interval '2 second')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("setup: insert assistant reply: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content, created_at)
|
|
VALUES ($1, 'user', '深圳呢', now() + interval '3 second')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("setup: insert follow-up user message: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, chat_session_id, status, priority)
|
|
VALUES ($1, $2, $3, 'queued', 2)
|
|
`, agentID, runtimeID, sessionID); err != nil {
|
|
t.Fatalf("setup: create follow-up chat task: %v", err)
|
|
}
|
|
|
|
task = claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.ChatMessage != "深圳呢" {
|
|
t.Fatalf("after a reply, only the new user message must be delivered; got %q", task.ChatMessage)
|
|
}
|
|
}
|
|
|
|
// TestClaimTask_ChatPopulatesInitiator verifies MUL-2645 for chat tasks: the
|
|
// claim response surfaces the STORED task initiator (initiator_user_id captured
|
|
// at enqueue), NOT chat_session.creator_id. This is the MUL-2645 review fix: for
|
|
// Lark group chats the session creator is the installer, not the sender, so the
|
|
// claim must read the stored sender. The test pins this by making the creator a
|
|
// DIFFERENT user (the "installer") from the stored initiator (the sender) and
|
|
// asserting the claim resolves the sender.
|
|
func TestClaimTask_ChatPopulatesInitiator(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
// A separate user stands in for the Lark group session creator (installer).
|
|
var installerID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO "user" (name, email) VALUES ('Installer User', 'installer-test@multica.ai')
|
|
RETURNING id
|
|
`).Scan(&installerID); err != nil {
|
|
t.Fatalf("setup: create installer user: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM "user" WHERE id = $1`, installerID) })
|
|
|
|
var sessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title)
|
|
VALUES ($1, $2, $3, 'initiator chat')
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, installerID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("setup: create chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO chat_message (chat_session_id, role, content) VALUES ($1, 'user', 'hi there')
|
|
`, sessionID); err != nil {
|
|
t.Fatalf("setup: insert user message: %v", err)
|
|
}
|
|
// initiator_user_id = the real sender (testUserID), distinct from creator.
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, chat_session_id, status, priority, initiator_user_id)
|
|
VALUES ($1, $2, $3, 'queued', 2, $4)
|
|
`, agentID, runtimeID, sessionID, testUserID); err != nil {
|
|
t.Fatalf("setup: create chat task: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil, testWorkspaceID, daemonID)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Task *struct {
|
|
InitiatorType string `json:"initiator_type"`
|
|
InitiatorID string `json:"initiator_id"`
|
|
InitiatorName string `json:"initiator_name"`
|
|
InitiatorEmail string `json:"initiator_email"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatalf("expected a claimed task, got %s", w.Body.String())
|
|
}
|
|
if resp.Task.InitiatorType != "member" || resp.Task.InitiatorID != testUserID ||
|
|
resp.Task.InitiatorName != handlerTestName || resp.Task.InitiatorEmail != handlerTestEmail {
|
|
t.Errorf("chat initiator = {type:%q id:%q name:%q email:%q}, want {member %q %q %q}",
|
|
resp.Task.InitiatorType, resp.Task.InitiatorID, resp.Task.InitiatorName, resp.Task.InitiatorEmail,
|
|
testUserID, handlerTestName, handlerTestEmail)
|
|
}
|
|
}
|
|
|
|
func TestClaimTask_QuickCreatePopulatesThreadName(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
quickPrompt := "create a follow-up issue for Codex session titles"
|
|
attachmentID := "019ec09d-6222-722b-bdfa-427b105d80be"
|
|
quickContext, _ := json.Marshal(map[string]any{
|
|
"type": "quick_create",
|
|
"prompt": quickPrompt,
|
|
"requester_id": testUserID,
|
|
"workspace_id": testWorkspaceID,
|
|
"attachment_ids": []string{attachmentID},
|
|
})
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, status, priority, context)
|
|
VALUES ($1, $2, 'queued', 2, $3)
|
|
`, agentID, runtimeID, quickContext); err != nil {
|
|
t.Fatalf("setup: create quick-create task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.ThreadName != quickPrompt {
|
|
t.Fatalf("quick-create task thread_name = %q, want prompt", task.ThreadName)
|
|
}
|
|
if len(task.QuickCreateAttachmentIDs) != 1 || task.QuickCreateAttachmentIDs[0] != attachmentID {
|
|
t.Fatalf("quick-create attachment ids = %#v, want [%q]", task.QuickCreateAttachmentIDs, attachmentID)
|
|
}
|
|
}
|
|
|
|
func TestClaimTask_ChatForceFreshSessionSkipsPriorSession(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
var chatSessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (
|
|
workspace_id, agent_id, creator_id, title,
|
|
session_id, work_dir, runtime_id
|
|
)
|
|
VALUES ($1, $2, $3, 'force fresh chat', 'chat-pointer-session', '/tmp/chat-pointer-workdir', $4)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID, runtimeID).Scan(&chatSessionID); err != nil {
|
|
t.Fatalf("setup: create chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, chatSessionID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'task-row-session', '/tmp/task-row-workdir')
|
|
`, agentID, runtimeID, chatSessionID); err != nil {
|
|
t.Fatalf("setup: create prior chat task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority, force_fresh_session
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0, TRUE)
|
|
`, agentID, runtimeID, chatSessionID); err != nil {
|
|
t.Fatalf("setup: create force-fresh chat task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "" {
|
|
t.Fatalf("force fresh chat: expected empty PriorSessionID, got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "" {
|
|
t.Fatalf("force fresh chat: expected empty PriorWorkDir, got %q", task.PriorWorkDir)
|
|
}
|
|
}
|
|
|
|
// Locks the legacy-row fallback: chat_session.runtime_id IS NULL (e.g. a row
|
|
// the migration left untouched because no prior task matched the cs pointer)
|
|
// but a completed task on the claiming runtime exists. ClaimTaskByRuntime
|
|
// must recover the session from the task row, not start a fresh conversation.
|
|
func TestClaimTask_ChatLegacyNullRuntimeFallsBackToTaskRow(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
agentID, runtimeID, daemonID := createRuntimeGuardAgent(t, ctx)
|
|
|
|
var legacySessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (
|
|
workspace_id, agent_id, creator_id, title,
|
|
session_id, work_dir, runtime_id
|
|
)
|
|
VALUES ($1, $2, $3, 'runtime guard legacy chat', NULL, NULL, NULL)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&legacySessionID); err != nil {
|
|
t.Fatalf("setup: create legacy chat session: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, legacySessionID) })
|
|
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority, started_at, completed_at,
|
|
session_id, work_dir
|
|
)
|
|
VALUES ($1, $2, $3, 'completed', 0, now(), now(), 'legacy-fallback-session', '/tmp/legacy-fallback-workdir')
|
|
`, agentID, runtimeID, legacySessionID); err != nil {
|
|
t.Fatalf("setup: create matching-runtime prior task: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, chat_session_id,
|
|
status, priority
|
|
)
|
|
VALUES ($1, $2, $3, 'queued', 0)
|
|
`, agentID, runtimeID, legacySessionID); err != nil {
|
|
t.Fatalf("setup: create current chat task: %v", err)
|
|
}
|
|
|
|
task := claimTaskForRuntimeGuard(t, runtimeID, daemonID)
|
|
if task.PriorSessionID != "legacy-fallback-session" {
|
|
t.Fatalf("legacy fallback: expected PriorSessionID='legacy-fallback-session', got %q", task.PriorSessionID)
|
|
}
|
|
if task.PriorWorkDir != "/tmp/legacy-fallback-workdir" {
|
|
t.Fatalf("legacy fallback: expected PriorWorkDir='/tmp/legacy-fallback-workdir', got %q", task.PriorWorkDir)
|
|
}
|
|
}
|
|
|
|
// TestGetChatSessionGCCheck verifies the chat session gc-check endpoint
|
|
// matches the same anti-enumeration shape as GetIssueGCCheck: cross-workspace
|
|
// daemon tokens get 404, same-workspace tokens get the live status.
|
|
func TestGetChatSessionGCCheck(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var sessionID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
|
VALUES ($1, $2, $3, 'gc-check fixture', 'active')
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&sessionID); err != nil {
|
|
t.Fatalf("setup: create chat session: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID)
|
|
|
|
// Cross-workspace daemon token must 404 with no oracle.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/chat-sessions/"+sessionID+"/gc-check", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
testHandler.GetChatSessionGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace daemon token sees the live row.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/chat-sessions/"+sessionID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
testHandler.GetChatSessionGCCheck(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("same-workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Status != "active" {
|
|
t.Fatalf("expected status %q, got %q", "active", resp.Status)
|
|
}
|
|
if resp.UpdatedAt == "" {
|
|
t.Fatal("expected updated_at to be set")
|
|
}
|
|
|
|
// Hard-deleted session: 404 — exactly what the daemon needs to reclaim
|
|
// the workdir on the next GC pass after a user runs DeleteChatSession.
|
|
if _, err := testPool.Exec(ctx, `DELETE FROM chat_session WHERE id = $1`, sessionID); err != nil {
|
|
t.Fatalf("delete chat session: %v", err)
|
|
}
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/chat-sessions/"+sessionID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "sessionId", sessionID)
|
|
testHandler.GetChatSessionGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("hard-deleted session: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestGetAutopilotRunGCCheck verifies the autopilot-run gc-check endpoint:
|
|
// 200 with status+completed_at on success, 404 on cross-workspace probe.
|
|
func TestGetAutopilotRunGCCheck(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID string
|
|
if err := testPool.QueryRow(ctx, `SELECT id FROM agent WHERE workspace_id = $1 LIMIT 1`, testWorkspaceID).Scan(&agentID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
var autopilotID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot (
|
|
workspace_id, title, assignee_id, execution_mode,
|
|
created_by_type, created_by_id
|
|
)
|
|
VALUES ($1, 'gc-check autopilot', $2, 'run_only', 'member', $3)
|
|
RETURNING id
|
|
`, testWorkspaceID, agentID, testUserID).Scan(&autopilotID); err != nil {
|
|
t.Fatalf("setup: create autopilot: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM autopilot WHERE id = $1`, autopilotID)
|
|
|
|
var runID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO autopilot_run (autopilot_id, source, status, completed_at)
|
|
VALUES ($1, 'manual', 'completed', NOW() - INTERVAL '6 days')
|
|
RETURNING id
|
|
`, autopilotID).Scan(&runID); err != nil {
|
|
t.Fatalf("setup: create autopilot_run: %v", err)
|
|
}
|
|
|
|
// Cross-workspace probe.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/autopilot-runs/"+runID+"/gc-check", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
req = withURLParam(req, "runId", runID)
|
|
testHandler.GetAutopilotRunGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace probe.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/autopilot-runs/"+runID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "runId", runID)
|
|
testHandler.GetAutopilotRunGCCheck(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("same-workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
CompletedAt string `json:"completed_at"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Status != "completed" {
|
|
t.Fatalf("expected status %q, got %q", "completed", resp.Status)
|
|
}
|
|
if resp.CompletedAt == "" {
|
|
t.Fatal("expected completed_at to be set for terminal run")
|
|
}
|
|
}
|
|
|
|
// TestGetTaskGCCheck verifies the task gc-check endpoint that quick-create
|
|
// workdirs key on. Same anti-enumeration shape via requireDaemonTaskAccess.
|
|
func TestGetTaskGCCheck(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
|
|
var agentID, runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
SELECT a.id, a.runtime_id FROM agent a WHERE a.workspace_id = $1 LIMIT 1
|
|
`, testWorkspaceID).Scan(&agentID, &runtimeID); err != nil {
|
|
t.Fatalf("setup: get agent: %v", err)
|
|
}
|
|
|
|
// Quick-create-shaped task: no issue_id, no chat_session_id, no run id.
|
|
// context.type is set so ResolveTaskWorkspaceID can recover workspace.
|
|
quickContext, _ := json.Marshal(map[string]any{
|
|
"type": "quick_create",
|
|
"prompt": "fixture",
|
|
"requester_id": testUserID,
|
|
"workspace_id": testWorkspaceID,
|
|
})
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (
|
|
agent_id, runtime_id, status, priority, context, completed_at
|
|
)
|
|
VALUES ($1, $2, 'completed', 0, $3, NOW())
|
|
RETURNING id
|
|
`, agentID, runtimeID, quickContext).Scan(&taskID); err != nil {
|
|
t.Fatalf("setup: create quick-create task: %v", err)
|
|
}
|
|
defer testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID)
|
|
|
|
// Cross-workspace probe.
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/gc-check", nil,
|
|
"00000000-0000-0000-0000-000000000000", "attacker-daemon")
|
|
req = withURLParam(req, "taskId", taskID)
|
|
testHandler.GetTaskGCCheck(w, req)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("cross-workspace token: expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Same-workspace probe — terminal task returns its status.
|
|
w = httptest.NewRecorder()
|
|
req = newDaemonTokenRequest("GET", "/api/daemon/tasks/"+taskID+"/gc-check", nil,
|
|
testWorkspaceID, "legit-daemon")
|
|
req = withURLParam(req, "taskId", taskID)
|
|
testHandler.GetTaskGCCheck(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("same-workspace token: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Status string `json:"status"`
|
|
CompletedAt string `json:"completed_at"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if resp.Status != "completed" {
|
|
t.Fatalf("expected status %q, got %q", "completed", resp.Status)
|
|
}
|
|
if resp.CompletedAt == "" {
|
|
t.Fatal("expected completed_at to be set for completed task")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Membership Cache Integration Tests
|
|
//
|
|
// These tests don't just exercise the cache primitive (that's covered in
|
|
// auth/membership_cache_test.go). They prove two things that the unit tests
|
|
// can't:
|
|
//
|
|
// 1. requireDaemonWorkspaceAccess actually short-circuits the DB on a cache
|
|
// hit (the "ghost user" trick below).
|
|
// 2. Each handler that mutates membership actually calls
|
|
// h.MembershipCache.Invalidate(...) — so a future refactor that drops
|
|
// one of those calls will fail CI instead of silently leaking a stale
|
|
// authorization grant for up to MembershipCacheTTL.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// installFreshMembershipCache swaps in a Redis-backed MembershipCache against
|
|
// a freshly-flushed Redis DB for the test, restoring the original on cleanup.
|
|
func installFreshMembershipCache(t *testing.T) {
|
|
t.Helper()
|
|
rdb := newRedisTestClient(t)
|
|
origCache := testHandler.MembershipCache
|
|
testHandler.MembershipCache = auth.NewMembershipCache(rdb)
|
|
t.Cleanup(func() { testHandler.MembershipCache = origCache })
|
|
}
|
|
|
|
// createEphemeralUser inserts a throwaway user with a unique email and
|
|
// deletes it on test cleanup. Returns the user id as a string.
|
|
func createEphemeralUser(t *testing.T, label string) string {
|
|
t.Helper()
|
|
email := fmt.Sprintf("membership-cache-%s-%s@multica.ai", label, uuid.NewString())
|
|
var userID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO "user" (name, email) VALUES ($1, $2) RETURNING id
|
|
`, "Membership Cache Test "+label, email).Scan(&userID); err != nil {
|
|
t.Fatalf("create ephemeral user: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM "user" WHERE id = $1`, userID)
|
|
})
|
|
return userID
|
|
}
|
|
|
|
// createEphemeralMember creates a throwaway user AND a member row in the
|
|
// given workspace. Returns (userID, memberID). Both rows are cleaned up on
|
|
// test exit.
|
|
func createEphemeralMember(t *testing.T, workspaceID, label, role string) (string, string) {
|
|
t.Helper()
|
|
userID := createEphemeralUser(t, label)
|
|
var memberID string
|
|
if err := testPool.QueryRow(context.Background(), `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, $3) RETURNING id
|
|
`, workspaceID, userID, role).Scan(&memberID); err != nil {
|
|
t.Fatalf("create ephemeral member: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM member WHERE id = $1`, memberID)
|
|
})
|
|
return userID, memberID
|
|
}
|
|
|
|
// TestRequireDaemonWorkspaceAccess_CacheHit proves the cache lookup actually
|
|
// short-circuits the DB query. The trick: the request actor is a "ghost"
|
|
// user with NO member row in the workspace. With an empty cache the access
|
|
// check must fail; after priming the cache it must succeed. If a future
|
|
// change ever bypasses the cache and falls through to the DB, the priming
|
|
// step stops mattering and the second assertion catches it.
|
|
func TestRequireDaemonWorkspaceAccess_CacheHit(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
ctx := context.Background()
|
|
|
|
ghostUserID := createEphemeralUser(t, "ghost")
|
|
|
|
// Baseline: with an empty cache the ghost has no path to access.
|
|
req := newRequestAsUser(ghostUserID, "GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil)
|
|
w := httptest.NewRecorder()
|
|
if testHandler.requireDaemonWorkspaceAccess(w, req, testWorkspaceID) {
|
|
t.Fatal("setup: ghost user must not be allowed without cache priming")
|
|
}
|
|
|
|
// Priming the cache is the only thing that changes — the access check
|
|
// must now succeed via the cache short-circuit.
|
|
testHandler.MembershipCache.Set(ctx, ghostUserID, testWorkspaceID)
|
|
|
|
req = newRequestAsUser(ghostUserID, "GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil)
|
|
w = httptest.NewRecorder()
|
|
if !testHandler.requireDaemonWorkspaceAccess(w, req, testWorkspaceID) {
|
|
t.Fatalf("expected access via cache hit, got denied (status %d)", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestRequireDaemonWorkspaceAccess_CacheMissBackfills(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
|
|
ctx := context.Background()
|
|
|
|
// Cache is empty — verify miss.
|
|
if testHandler.MembershipCache.Get(ctx, testUserID, testWorkspaceID) {
|
|
t.Fatal("expected cache miss before request")
|
|
}
|
|
|
|
// Make a request that triggers DB lookup.
|
|
req := newRequest("GET", "/api/daemon/workspaces/"+testWorkspaceID+"/repos", nil)
|
|
w := httptest.NewRecorder()
|
|
if !testHandler.requireDaemonWorkspaceAccess(w, req, testWorkspaceID) {
|
|
t.Fatalf("expected access granted via DB lookup, got denied (status %d)", w.Code)
|
|
}
|
|
|
|
// Cache should now be backfilled.
|
|
if !testHandler.MembershipCache.Get(ctx, testUserID, testWorkspaceID) {
|
|
t.Fatal("expected cache to be backfilled after DB hit")
|
|
}
|
|
}
|
|
|
|
// TestMembershipCache_InvalidatedOnDeleteMember drives a real DeleteMember
|
|
// HTTP handler call and asserts the cache entry for the removed member is
|
|
// gone afterwards. Guards against future refactors that move or drop the
|
|
// h.MembershipCache.Invalidate(...) line in workspace.go.
|
|
func TestMembershipCache_InvalidatedOnDeleteMember(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
ctx := context.Background()
|
|
|
|
targetUserID, targetMemberID := createEphemeralMember(t, testWorkspaceID, "delete", "admin")
|
|
testHandler.MembershipCache.Set(ctx, targetUserID, testWorkspaceID)
|
|
if !testHandler.MembershipCache.Get(ctx, targetUserID, testWorkspaceID) {
|
|
t.Fatal("setup: expected cache hit after Set")
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/members/"+targetMemberID, nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "memberId", targetMemberID)
|
|
testHandler.DeleteMember(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteMember: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if testHandler.MembershipCache.Get(ctx, targetUserID, testWorkspaceID) {
|
|
t.Fatal("DeleteMember handler did not invalidate membership cache for removed user")
|
|
}
|
|
}
|
|
|
|
// TestMembershipCache_InvalidatedOnUpdateMember drives a real UpdateMember
|
|
// (role change) call and asserts the cache entry is invalidated. The cache
|
|
// stores only the existence of membership, but UpdateMember still flushes
|
|
// the entry so any downstream caller that did add role-aware caching later
|
|
// would not silently see a stale role.
|
|
func TestMembershipCache_InvalidatedOnUpdateMember(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
ctx := context.Background()
|
|
|
|
targetUserID, targetMemberID := createEphemeralMember(t, testWorkspaceID, "update", "admin")
|
|
testHandler.MembershipCache.Set(ctx, targetUserID, testWorkspaceID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("PUT", "/api/workspaces/"+testWorkspaceID+"/members/"+targetMemberID,
|
|
map[string]any{"role": "member"})
|
|
req = withURLParams(req, "id", testWorkspaceID, "memberId", targetMemberID)
|
|
testHandler.UpdateMember(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("UpdateMember: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if testHandler.MembershipCache.Get(ctx, targetUserID, testWorkspaceID) {
|
|
t.Fatal("UpdateMember handler did not invalidate membership cache for updated user")
|
|
}
|
|
}
|
|
|
|
// TestMembershipCache_InvalidatedOnLeaveWorkspace exercises the self-removal
|
|
// path (LeaveWorkspace, not DeleteMember). Both handlers route through
|
|
// revokeAndRemoveMember, but the Invalidate call lives in the handler — a
|
|
// refactor that consolidates them could drop one invalidation without the
|
|
// other test catching it.
|
|
func TestMembershipCache_InvalidatedOnLeaveWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
ctx := context.Background()
|
|
|
|
targetUserID, _ := createEphemeralMember(t, testWorkspaceID, "leave", "admin")
|
|
testHandler.MembershipCache.Set(ctx, targetUserID, testWorkspaceID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequestAsUser(targetUserID, "DELETE", "/api/workspaces/"+testWorkspaceID+"/leave", nil)
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
testHandler.LeaveWorkspace(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("LeaveWorkspace: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if testHandler.MembershipCache.Get(ctx, targetUserID, testWorkspaceID) {
|
|
t.Fatal("LeaveWorkspace handler did not invalidate membership cache for leaver")
|
|
}
|
|
}
|
|
|
|
// TestMembershipCache_InvalidatedOnDeleteWorkspace exercises the bulk
|
|
// invalidation path: when the workspace is deleted, every member's cache
|
|
// entry must be flushed. We create an isolated workspace with two members
|
|
// (owner + extra) so the shared testWorkspace stays intact.
|
|
func TestMembershipCache_InvalidatedOnDeleteWorkspace(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
installFreshMembershipCache(t)
|
|
ctx := context.Background()
|
|
|
|
const slug = "membership-cache-delete-ws"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM workspace WHERE slug = $1`, slug)
|
|
var wsID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO workspace (name, slug, description, issue_prefix)
|
|
VALUES ($1, $2, $3, $4) RETURNING id
|
|
`, "Membership Cache Delete WS", slug, "DeleteWorkspace cache invalidation test", "MCD").Scan(&wsID); err != nil {
|
|
t.Fatalf("create workspace: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM workspace WHERE id = $1`, wsID)
|
|
})
|
|
|
|
// testUser must be an owner of the isolated workspace to call
|
|
// DeleteWorkspace.
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO member (workspace_id, user_id, role) VALUES ($1, $2, 'owner')
|
|
`, wsID, testUserID); err != nil {
|
|
t.Fatalf("add owner: %v", err)
|
|
}
|
|
|
|
extraUserID, _ := createEphemeralMember(t, wsID, "ws-delete-extra", "admin")
|
|
|
|
testHandler.MembershipCache.Set(ctx, testUserID, wsID)
|
|
testHandler.MembershipCache.Set(ctx, extraUserID, wsID)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+wsID, nil)
|
|
req = withURLParam(req, "id", wsID)
|
|
testHandler.DeleteWorkspace(w, req)
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("DeleteWorkspace: expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
if testHandler.MembershipCache.Get(ctx, testUserID, wsID) {
|
|
t.Fatal("DeleteWorkspace handler did not invalidate owner cache entry")
|
|
}
|
|
if testHandler.MembershipCache.Get(ctx, extraUserID, wsID) {
|
|
t.Fatal("DeleteWorkspace handler did not invalidate extra-member cache entry")
|
|
}
|
|
}
|
|
|
|
// createCommentTriggeredClaimTask seeds a queued comment-triggered task whose
|
|
// trigger comment is rooted under parentID (nil → trigger is itself a root).
|
|
// Returns the task id and the trigger comment id.
|
|
func createCommentTriggeredClaimTask(t *testing.T, ctx context.Context, agentID, runtimeID, issueID string, parentID *string) (string, string) {
|
|
t.Helper()
|
|
var commentID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
|
|
VALUES ($1, $2, 'member', $3, 'trigger comment', 'comment', $4)
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID, parentID).Scan(&commentID); err != nil {
|
|
t.Fatalf("insert trigger comment: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, commentID) })
|
|
|
|
var taskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, trigger_comment_id)
|
|
VALUES ($1, $2, $3, 'queued', 0, $4)
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, commentID).Scan(&taskID); err != nil {
|
|
t.Fatalf("insert comment-triggered task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, taskID) })
|
|
return taskID, commentID
|
|
}
|
|
|
|
type claimCommentTaskResp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
PriorSessionID string `json:"prior_session_id"`
|
|
TriggerCommentID string `json:"trigger_comment_id"`
|
|
NewCommentCount int `json:"new_comment_count"`
|
|
NewCommentsSince string `json:"new_comments_since"`
|
|
} `json:"task"`
|
|
}
|
|
|
|
func claimCommentTask(t *testing.T, runtimeID, daemonID string) claimCommentTaskResp {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil, testWorkspaceID, daemonID)
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp claimCommentTaskResp
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil {
|
|
t.Fatalf("expected a claimed task, got nil: %s", w.Body.String())
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_CommentTaskPopulatesNewCommentCount
|
|
// verifies the claim response carries new_comment_count + new_comments_since for
|
|
// a comment task when the agent ran before: the count is ISSUE-WIDE (covers
|
|
// every thread, not just the triggering one), excludes the injected trigger
|
|
// comment, excludes the agent's own comments, and the since anchor is the prior
|
|
// run's started_at.
|
|
func TestClaimTaskByRuntime_CommentTaskPopulatesNewCommentCount(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Comment newcount runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Comment newcount agent")
|
|
|
|
// A prior run establishes the "since" anchor (its started_at, in the past).
|
|
var priorTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, started_at, completed_at)
|
|
VALUES ($1, $2, $3, 'completed', 0, now() - interval '1 hour', now() - interval '50 minutes')
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&priorTaskID); err != nil {
|
|
t.Fatalf("insert prior task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, priorTaskID) })
|
|
|
|
var threadRootID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'same-thread context', 'comment')
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID).Scan(&threadRootID); err != nil {
|
|
t.Fatalf("insert trigger thread root: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, threadRootID) })
|
|
|
|
var unrelatedRootID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type)
|
|
VALUES ($1, $2, 'member', $3, 'unrelated thread context', 'comment')
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, testUserID).Scan(&unrelatedRootID); err != nil {
|
|
t.Fatalf("insert unrelated thread root: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, unrelatedRootID) })
|
|
|
|
var agentOwnID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
|
|
VALUES ($1, $2, 'agent', $3, 'agent self reply', 'comment', $4)
|
|
RETURNING id
|
|
`, issueID, testWorkspaceID, agentID, threadRootID).Scan(&agentOwnID); err != nil {
|
|
t.Fatalf("insert agent self reply: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM comment WHERE id = $1`, agentOwnID) })
|
|
|
|
// The trigger comment (member-authored, created now) lands after the anchor
|
|
// but is injected into the prompt, so it should not be counted.
|
|
_, triggerID := createCommentTriggeredClaimTask(t, ctx, agentID, runtimeID, issueID, &threadRootID)
|
|
|
|
resp := claimCommentTask(t, runtimeID, "comment-newcount-claim")
|
|
if resp.Task.TriggerCommentID != triggerID {
|
|
t.Fatalf("trigger_comment_id = %s, want %s", resp.Task.TriggerCommentID, triggerID)
|
|
}
|
|
if resp.Task.NewCommentsSince == "" {
|
|
t.Errorf("new_comments_since must be set when a prior run exists, got empty")
|
|
}
|
|
// Issue-wide: the same-thread context comment AND the unrelated-thread root
|
|
// both count; only the agent's own reply and the injected trigger are excluded.
|
|
if resp.Task.NewCommentCount != 2 {
|
|
t.Errorf("new_comment_count = %d, want 2 (issue-wide: same-thread + unrelated thread)", resp.Task.NewCommentCount)
|
|
}
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_CommentTaskPopulatesInitiator verifies MUL-2645: the
|
|
// claim response surfaces the triggering comment's member author as the task
|
|
// initiator (type + id + name + email), so a workspace-visible agent learns who
|
|
// actually asked rather than seeing the runtime owner. createCommentTriggeredClaimTask
|
|
// authors the trigger comment as the fixture member (testUserID).
|
|
func TestClaimTaskByRuntime_CommentTaskPopulatesInitiator(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Comment initiator runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Comment initiator agent")
|
|
taskID, _ := createCommentTriggeredClaimTask(t, ctx, agentID, runtimeID, issueID, nil)
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newDaemonTokenRequest("POST", "/api/daemon/runtimes/"+runtimeID+"/tasks/claim", nil, testWorkspaceID, "comment-initiator-claim")
|
|
req = withURLParam(req, "runtimeId", runtimeID)
|
|
testHandler.ClaimTaskByRuntime(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("ClaimTaskByRuntime: expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Task *struct {
|
|
ID string `json:"id"`
|
|
InitiatorType string `json:"initiator_type"`
|
|
InitiatorID string `json:"initiator_id"`
|
|
InitiatorName string `json:"initiator_name"`
|
|
InitiatorEmail string `json:"initiator_email"`
|
|
} `json:"task"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode claim response: %v", err)
|
|
}
|
|
if resp.Task == nil || resp.Task.ID != taskID {
|
|
t.Fatalf("expected claimed task %s, got %s", taskID, w.Body.String())
|
|
}
|
|
if resp.Task.InitiatorType != "member" {
|
|
t.Errorf("initiator_type = %q, want %q", resp.Task.InitiatorType, "member")
|
|
}
|
|
if resp.Task.InitiatorID != testUserID {
|
|
t.Errorf("initiator_id = %q, want %q", resp.Task.InitiatorID, testUserID)
|
|
}
|
|
if resp.Task.InitiatorName != handlerTestName {
|
|
t.Errorf("initiator_name = %q, want %q", resp.Task.InitiatorName, handlerTestName)
|
|
}
|
|
if resp.Task.InitiatorEmail != handlerTestEmail {
|
|
t.Errorf("initiator_email = %q, want %q", resp.Task.InitiatorEmail, handlerTestEmail)
|
|
}
|
|
}
|
|
|
|
func TestClaimTaskByRuntime_CommentTaskOmitsDeltaWhenOnlyTriggerIsNew(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Comment trigger-only runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Comment trigger-only agent")
|
|
|
|
var priorTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, started_at, completed_at)
|
|
VALUES ($1, $2, $3, 'completed', 0, now() - interval '1 hour', now() - interval '50 minutes')
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID).Scan(&priorTaskID); err != nil {
|
|
t.Fatalf("insert prior task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, priorTaskID) })
|
|
|
|
_, triggerID := createCommentTriggeredClaimTask(t, ctx, agentID, runtimeID, issueID, nil)
|
|
|
|
resp := claimCommentTask(t, runtimeID, "comment-trigger-only-claim")
|
|
if resp.Task.TriggerCommentID != triggerID {
|
|
t.Fatalf("trigger_comment_id = %s, want %s", resp.Task.TriggerCommentID, triggerID)
|
|
}
|
|
if resp.Task.NewCommentCount != 0 {
|
|
t.Errorf("new_comment_count = %d, want 0 when only the injected trigger is new", resp.Task.NewCommentCount)
|
|
}
|
|
if resp.Task.NewCommentsSince != "" {
|
|
t.Errorf("new_comments_since = %q, want empty when only the injected trigger is new", resp.Task.NewCommentsSince)
|
|
}
|
|
}
|
|
|
|
// TestClaimTaskByRuntime_CommentResumeDefaultOn verifies comment-triggered tasks
|
|
// resume the prior session by default (no env flag), as long as the prior
|
|
// session ran on the same runtime.
|
|
func TestClaimTaskByRuntime_CommentResumeDefaultOn(t *testing.T) {
|
|
if testHandler == nil || testPool == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
runtimeID := createClaimReclaimRuntime(t, ctx, "Comment resume runtime")
|
|
agentID, issueID := createClaimReclaimAgentAndIssue(t, ctx, runtimeID, "Comment resume agent")
|
|
|
|
// A prior completed task on the same (agent, issue, runtime) with a session.
|
|
const priorSession = "sess-prior-123"
|
|
var priorTaskID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_task_queue (agent_id, runtime_id, issue_id, status, priority, session_id, completed_at)
|
|
VALUES ($1, $2, $3, 'completed', 0, $4, now())
|
|
RETURNING id
|
|
`, agentID, runtimeID, issueID, priorSession).Scan(&priorTaskID); err != nil {
|
|
t.Fatalf("insert prior completed task: %v", err)
|
|
}
|
|
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE id = $1`, priorTaskID) })
|
|
|
|
createCommentTriggeredClaimTask(t, ctx, agentID, runtimeID, issueID, nil)
|
|
|
|
resp := claimCommentTask(t, runtimeID, "comment-resume-default")
|
|
if resp.Task.PriorSessionID != priorSession {
|
|
t.Errorf("prior_session_id = %q, want %q (comment resume is default-on)", resp.Task.PriorSessionID, priorSession)
|
|
}
|
|
}
|