feat(server): trigger agent when issue moves out of backlog (#1006)

* feat(server): trigger agent when issue moves out of backlog

When a member moves an agent-assigned issue from "backlog" to an active
status (e.g. "todo", "in_progress"), enqueue an agent task so the agent
starts working. This lets backlog act as a parking lot where issues can
be assigned to agents without immediately triggering execution.

Applies to both single and batch issue updates.

* fix(server): treat backlog as parking lot — no trigger on create/assign

Address review feedback: creating or assigning an agent to a backlog
issue no longer triggers immediate execution. Only moving out of backlog
to an active status triggers the agent, producing exactly one task.

- shouldEnqueueAgentTask now gates on backlog status
- backlog→active trigger uses isAgentAssigneeReady directly
- Added TestBacklogNoTriggerOnCreate test
- Updated TestBacklogToTodoTriggersAgent to assert exactly 1 task
  across the full create→move path (no manual cleanup)

* feat(ui): show toast hint when assigning agent to backlog issue

Users may not know that backlog issues won't trigger agent execution
until moved to an active status. Show an actionable toast with a
"Move to Todo" button when:

- Assigning an agent to a backlog issue in the detail page
- Creating a backlog issue with an agent assignee

* feat(ui): add "Don't show again" option to backlog agent toast

Users who understand the backlog parking lot behavior can dismiss the
hint permanently. Uses localStorage to persist the preference.

* feat(ui): replace backlog agent toast with AlertDialog

Use a modal dialog instead of a toast notification so users must
explicitly acknowledge the hint. The dialog offers three options:
- "Move to Todo" — changes status and triggers the agent
- "Keep in Backlog" — dismisses without action
- "Don't show again" — persists dismissal in localStorage

* fix(ui): improve backlog agent dialog

* fix(ui): close create dialog behind hint, use checkbox for don't-show-again

1. Create Issue dialog now closes when the backlog agent hint appears,
   so only the hint dialog is visible (not stacked behind).
2. "Don't show again" is now a checkbox instead of a separate button.
   When checked, clicking either "Keep in Backlog" or "Move to Todo"
   persists the preference.

* fix(ui): smooth backlog agent hint dialog

* fix(test): add useUpdateIssue mock to create-issue test

The test mock for @multica/core/issues/mutations was missing the
useUpdateIssue export that create-issue.tsx now imports, causing
CI failure.
This commit is contained in:
Jiayuan Zhang
2026-04-15 19:07:48 +08:00
committed by GitHub
parent 5a44c255fe
commit d930bcaa18
6 changed files with 481 additions and 152 deletions

View File

@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { Archive, ArrowRight, Bot, CheckCircle2 } from "lucide-react";
import {
AlertDialog,
AlertDialogContent,
} from "@multica/ui/components/ui/alert-dialog";
import { Button } from "@multica/ui/components/ui/button";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
interface BacklogAgentHintDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onDismissPermanently: () => void;
onMoveToTodo: () => void;
}
export function BacklogAgentHintDialog({
open,
onOpenChange,
onDismissPermanently,
onMoveToTodo,
}: BacklogAgentHintDialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="w-[calc(100vw-2rem)] !max-w-[480px] gap-0 overflow-hidden rounded-lg p-0">
<BacklogAgentHintContent
onKeepInBacklog={() => onOpenChange(false)}
onDismissPermanently={onDismissPermanently}
onMoveToTodo={onMoveToTodo}
/>
</AlertDialogContent>
</AlertDialog>
);
}
interface BacklogAgentHintContentProps {
onKeepInBacklog: () => void;
onDismissPermanently: () => void;
onMoveToTodo: () => void;
}
export function BacklogAgentHintContent({
onKeepInBacklog,
onDismissPermanently,
onMoveToTodo,
}: BacklogAgentHintContentProps) {
const [dontShowAgain, setDontShowAgain] = useState(false);
const handleKeepInBacklog = () => {
if (dontShowAgain) onDismissPermanently();
onKeepInBacklog();
};
const handleMoveToTodo = () => {
if (dontShowAgain) onDismissPermanently();
onMoveToTodo();
};
return (
<>
<div className="px-5 pb-4 pt-5">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-muted-foreground">
<Bot className="size-4" />
</div>
<div className="min-w-0">
<h2 className="text-base font-semibold">
Agent is paused in Backlog
</h2>
<p className="mt-1 text-sm leading-5 text-muted-foreground">
This issue is parked, so the assigned agent will wait. Move it to
Todo when you want the agent to start.
</p>
</div>
</div>
<div className="mt-4 grid gap-2 rounded-lg border bg-muted/35 p-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Archive className="size-4 shrink-0" />
<span className="font-medium text-foreground">Backlog</span>
<span className="text-muted-foreground">keeps the agent paused</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<ArrowRight className="size-4 shrink-0" />
<span className="font-medium text-foreground">Todo</span>
<span className="text-muted-foreground">starts the agent</span>
<CheckCircle2 className="ml-auto size-4 shrink-0 text-primary" />
</div>
</div>
</div>
<div className="border-t bg-muted/25 px-5 py-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex min-w-0 cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox
checked={dontShowAgain}
onCheckedChange={(next) => setDontShowAgain(next === true)}
/>
<span className="truncate">Don&apos;t show this again</span>
</label>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
className="w-full sm:w-auto"
onClick={handleKeepInBacklog}
>
Keep in Backlog
</Button>
<Button
type="button"
className="w-full sm:w-auto"
onClick={handleMoveToTodo}
>
Move to Todo
</Button>
</div>
</div>
</div>
</>
);
}

View File

@@ -64,6 +64,7 @@ import { ProjectPicker } from "../../projects/components/project-picker";
import { CommentCard } from "./comment-card";
import { CommentInput } from "./comment-input";
import { AgentLiveCard, TaskRunHistory } from "./agent-live-card";
import { BacklogAgentHintDialog } from "./backlog-agent-hint-dialog";
import { useQuery } from "@tanstack/react-query";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceStore } from "@multica/core/workspace";
@@ -344,6 +345,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
const [deleting, setDeleting] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [backlogHintOpen, setBacklogHintOpen] = useState(false);
const [propertiesOpen, setPropertiesOpen] = useState(true);
const [detailsOpen, setDetailsOpen] = useState(true);
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -444,6 +446,16 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{ id, ...updates },
{ onError: () => toast.error("Failed to update issue") },
);
// Hint: assigning an agent to a backlog issue won't trigger execution
// until the issue is moved to an active status.
if (
updates.assignee_type === "agent" &&
updates.assignee_id &&
issue.status === "backlog" &&
localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true"
) {
setBacklogHintOpen(true);
}
},
[issue, id, updateIssueMutation],
);
@@ -862,6 +874,21 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
</AlertDialogContent>
</AlertDialog>
<BacklogAgentHintDialog
open={backlogHintOpen}
onOpenChange={setBacklogHintOpen}
onDismissPermanently={() => {
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
}}
onMoveToTodo={() => {
updateIssueMutation.mutate(
{ id, status: "todo" },
{ onError: () => toast.error("Failed to update status") },
);
setBacklogHintOpen(false);
}}
/>
{/* Set parent issue picker */}
<IssuePickerDialog
open={parentPickerOpen}

View File

@@ -49,6 +49,7 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
vi.mock("@multica/core/issues/mutations", () => ({
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
useUpdateIssue: () => ({ mutate: vi.fn() }),
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({

View File

@@ -15,10 +15,11 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { Button } from "@multica/ui/components/ui/button";
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
import { ProjectPicker } from "../projects/components/project-picker";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateIssue } from "@multica/core/issues/mutations";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
@@ -74,6 +75,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
(data?.project_id as string) || undefined,
);
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
// File upload — collect attachment IDs so we can link them after issue creation.
const [attachmentIds, setAttachmentIds] = useState<string[]>([]);
@@ -97,6 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
const updateDueDate = (v: string | null) => { setDueDate(v); setDraft({ dueDate: v }); };
const createIssueMutation = useCreateIssue();
const updateIssueMutation = useUpdateIssue();
const handleSubmit = async () => {
if (!title.trim() || submitting) return;
setSubmitting(true);
@@ -114,31 +117,42 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
project_id: projectId,
});
clearDraft();
onClose();
toast.custom((t) => (
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
<Check className="size-3" />
const shouldShowBacklogHint =
status === "backlog" && assigneeType === "agent" && assigneeId &&
localStorage.getItem("multica:backlog-agent-hint-dismissed") !== "true";
if (shouldShowBacklogHint) {
setBacklogHintIssueId(issue.id);
} else {
onClose();
}
if (!shouldShowBacklogHint) {
toast.custom((t) => (
<div className="bg-popover text-popover-foreground border rounded-lg shadow-lg p-4 w-[360px]">
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center size-5 rounded-full bg-emerald-500/15 text-emerald-500">
<Check className="size-3" />
</div>
<span className="text-sm font-medium">Issue created</span>
</div>
<span className="text-sm font-medium">Issue created</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
<span className="truncate">{issue.identifier} {issue.title}</span>
</div>
<button
type="button"
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
onClick={() => {
router.push(`/issues/${issue.id}`);
toast.dismiss(t);
}}
>
View issue
</button>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground ml-7">
<StatusIcon status={issue.status} className="size-3.5 shrink-0" />
<span className="truncate">{issue.identifier} {issue.title}</span>
</div>
<button
type="button"
className="ml-7 mt-2 text-sm text-primary hover:underline cursor-pointer"
onClick={() => {
router.push(`/issues/${issue.id}`);
toast.dismiss(t);
}}
>
View issue
</button>
</div>
), { duration: 5000 });
), { duration: 5000 });
}
} catch {
toast.error("Failed to create issue");
} finally {
@@ -147,145 +161,179 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?
};
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<Dialog
open
onOpenChange={(v) => {
if (!v) {
setBacklogHintIssueId(null);
onClose();
}
}}
>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
"!transition-all !duration-300 !ease-out",
isExpanded
backlogHintIssueId
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
: "!transition-all !duration-300 !ease-out",
!backlogHintIssueId && isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: "!max-w-2xl !w-full !h-96 !-translate-y-1/2",
: !backlogHintIssueId
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
: "",
)}
>
<DialogTitle className="sr-only">New Issue</DialogTitle>
{backlogHintIssueId ? (
<BacklogAgentHintContent
onKeepInBacklog={() => {
setBacklogHintIssueId(null);
onClose();
}}
onDismissPermanently={() => {
localStorage.setItem("multica:backlog-agent-hint-dismissed", "true");
}}
onMoveToTodo={() => {
updateIssueMutation.mutate(
{ id: backlogHintIssueId, status: "todo" },
{ onError: () => toast.error("Failed to update status") },
);
setBacklogHintIssueId(null);
onClose();
}}
/>
) : (
<>
<DialogTitle className="sr-only">New Issue</DialogTitle>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
{typeof data?.parent_issue_identifier === "string" && (
<>
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
{/* Header */}
<div className="flex items-center justify-between px-5 pt-3 pb-2 shrink-0">
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
</>
)}
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
{typeof data?.parent_issue_identifier === "string" && (
<>
<span className="text-muted-foreground">{data.parent_issue_identifier}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
</>
)}
<span className="font-medium">{data?.parent_issue_id ? "New sub-issue" : "New issue"}</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={() => setIsExpanded(!isExpanded)}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
{isExpanded ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</button>
}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
autoFocus
defaultValue={draft.title}
placeholder="Issue title"
className="text-lg font-semibold"
onChange={(v) => updateTitle(v)}
onSubmit={handleSubmit}
/>
<TooltipContent side="bottom">{isExpanded ? "Collapse" : "Expand"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
</div>
{/* Description — takes remaining space */}
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
onUploadFile={handleUpload}
debounceMs={500}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
</div>
{descDragOver && <FileDropOverlay />}
</div>
{/* Title */}
<div className="px-5 pb-2 shrink-0">
<TitleEditor
autoFocus
defaultValue={draft.title}
placeholder="Issue title"
className="text-lg font-semibold"
onChange={(v) => updateTitle(v)}
onSubmit={handleSubmit}
/>
</div>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<StatusPicker
status={status}
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Description — takes remaining space */}
<div {...descDropZoneProps} className="relative flex-1 min-h-0 overflow-y-auto px-5">
<ContentEditor
ref={descEditorRef}
defaultValue={draft.description}
placeholder="Add description..."
onUpdate={(md) => setDraft({ description: md })}
onUploadFile={handleUpload}
debounceMs={500}
/>
{descDragOver && <FileDropOverlay />}
</div>
{/* Priority */}
<PriorityPicker
priority={priority}
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Property toolbar */}
<div className="flex items-center gap-1.5 px-4 py-2 shrink-0 flex-wrap">
{/* Status */}
<StatusPicker
status={status}
onUpdate={(u) => { if (u.status) updateStatus(u.status); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Assignee */}
<AssigneePicker
assigneeType={assigneeType ?? null}
assigneeId={assigneeId ?? null}
onUpdate={(u) => updateAssignee(
u.assignee_type ?? undefined,
u.assignee_id ?? undefined,
)}
triggerRender={<PillButton />}
align="start"
/>
{/* Priority */}
<PriorityPicker
priority={priority}
onUpdate={(u) => { if (u.priority) updatePriority(u.priority); }}
triggerRender={<PillButton />}
align="start"
/>
{/* Due date */}
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
triggerRender={<PillButton />}
align="start"
/>
{/* Assignee */}
<AssigneePicker
assigneeType={assigneeType ?? null}
assigneeId={assigneeId ?? null}
onUpdate={(u) => updateAssignee(
u.assignee_type ?? undefined,
u.assignee_id ?? undefined,
)}
triggerRender={<PillButton />}
align="start"
/>
{/* Project */}
<ProjectPicker
projectId={projectId ?? null}
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
triggerRender={<PillButton />}
align="start"
/>
</div>
{/* Due date */}
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => updateDueDate(u.due_date ?? null)}
triggerRender={<PillButton />}
align="start"
/>
{/* Project */}
<ProjectPicker
projectId={projectId ?? null}
onUpdate={(u) => setProjectId(u.project_id ?? undefined)}
triggerRender={<PillButton />}
align="start"
/>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<FileUploadButton
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
/>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);

View File

@@ -975,6 +975,115 @@ func TestResolveActor(t *testing.T) {
}
}
// TestBacklogNoTriggerOnCreate verifies that creating a backlog issue with an
// agent assignee does NOT enqueue a task — backlog is a parking lot.
func TestBacklogNoTriggerOnCreate(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog no-trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1`,
created.ID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 0 {
t.Fatalf("expected no tasks for backlog issue on creation, got %d", taskCount)
}
// Cleanup
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
// TestBacklogToTodoTriggersAgent verifies that moving an agent-assigned issue
// from "backlog" to "todo" enqueues exactly one agent task (none on creation,
// one on status transition).
func TestBacklogToTodoTriggersAgent(t *testing.T) {
ctx := context.Background()
var agentID string
err := testPool.QueryRow(ctx,
`SELECT id FROM agent WHERE workspace_id = $1 AND name = $2`,
testWorkspaceID, "Handler Test Agent",
).Scan(&agentID)
if err != nil {
t.Fatalf("failed to find test agent: %v", err)
}
// Create a backlog issue assigned to the agent — should NOT trigger.
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "Backlog trigger test",
"status": "backlog",
"assignee_type": "agent",
"assignee_id": agentID,
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created IssueResponse
json.NewDecoder(w.Body).Decode(&created)
// Move the issue from backlog to todo — should trigger.
w = httptest.NewRecorder()
req = newRequest("PUT", "/api/issues/"+created.ID, map[string]any{
"status": "todo",
})
req = withURLParam(req, "id", created.ID)
testHandler.UpdateIssue(w, req)
if w.Code != http.StatusOK {
t.Fatalf("UpdateIssue: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify exactly one task was enqueued (from the status transition, not creation).
var taskCount int
err = testPool.QueryRow(ctx,
`SELECT count(*) FROM agent_task_queue WHERE issue_id = $1 AND agent_id = $2 AND status = 'queued'`,
created.ID, agentID,
).Scan(&taskCount)
if err != nil {
t.Fatalf("failed to count tasks: %v", err)
}
if taskCount != 1 {
t.Fatalf("expected exactly 1 task after backlog->todo transition, got %d", taskCount)
}
// Cleanup
testPool.Exec(ctx, `DELETE FROM agent_task_queue WHERE issue_id = $1`, created.ID)
cleanupReq := newRequest("DELETE", "/api/issues/"+created.ID, nil)
cleanupReq = withURLParam(cleanupReq, "id", created.ID)
testHandler.DeleteIssue(httptest.NewRecorder(), cleanupReq)
}
func TestDaemonRegisterMissingWorkspaceReturns404(t *testing.T) {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/daemon/register", bytes.NewBufferString(`{

View File

@@ -917,7 +917,7 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
slog.Info("issue created", append(logger.RequestAttrs(r), "issue_id", uuidToString(issue.ID), "title", issue.Title, "status", issue.Status, "workspace_id", workspaceID)...)
h.publish(protocol.EventIssueCreated, workspaceID, creatorType, actualCreatorID, map[string]any{"issue": resp})
// Only ready issues in todo are enqueued for agents.
// Enqueue agent task when an agent-assigned issue is created.
if issue.AssigneeType.Valid && issue.AssigneeID.Valid {
if h.shouldEnqueueAgentTask(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
@@ -1112,8 +1112,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
"creator_id": uuidToString(prevIssue.CreatorID),
})
// Reconcile task queue when assignee changes (not on status changes —
// agents manage issue status themselves via the CLI).
// Reconcile task queue when assignee changes.
if assigneeChanged {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)
@@ -1122,6 +1121,16 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
}
}
// Trigger the assigned agent when a member moves an issue out of backlog.
// Backlog acts as a parking lot — moving to an active status signals the
// issue is ready for work.
if statusChanged && !assigneeChanged && actorType == "member" &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
if h.isAgentAssigneeReady(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
}
}
// Cancel active tasks when the issue is cancelled by a user.
// This is distinct from agent-managed status transitions — cancellation
// is a user-initiated terminal action that should stop execution.
@@ -1163,12 +1172,15 @@ func (h *Handler) canAssignAgent(ctx context.Context, r *http.Request, agentID,
return false, "cannot assign to private agent"
}
// shouldEnqueueAgentTask returns true when an issue assignment should trigger
// the assigned agent. No status gate — assignment is an explicit human action,
// so it should trigger regardless of issue status (e.g. assigning an agent to
// a done issue to fix a discovered problem).
// All trigger types (on_assign, on_comment, on_mention) are always enabled.
// shouldEnqueueAgentTask returns true when an issue creation or assignment
// should trigger the assigned agent. Backlog issues are skipped — backlog
// acts as a parking lot where issues can be pre-assigned without immediately
// triggering execution. Moving out of backlog is handled separately in
// UpdateIssue.
func (h *Handler) shouldEnqueueAgentTask(ctx context.Context, issue db.Issue) bool {
if issue.Status == "backlog" {
return false
}
return h.isAgentAssigneeReady(ctx, issue)
}
@@ -1420,6 +1432,14 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
}
}
// Trigger agent when moving out of backlog (batch).
if statusChanged && !assigneeChanged && actorType == "member" &&
prevIssue.Status == "backlog" && issue.Status != "done" && issue.Status != "cancelled" {
if h.isAgentAssigneeReady(r.Context(), issue) {
h.TaskService.EnqueueTaskForIssue(r.Context(), issue)
}
}
// Cancel active tasks when the issue is cancelled by a user.
if statusChanged && issue.Status == "cancelled" {
h.TaskService.CancelTasksForIssue(r.Context(), issue.ID)