diff --git a/packages/core/api/client.ts b/packages/core/api/client.ts index 5f8aa547531..890a71935d4 100644 --- a/packages/core/api/client.ts +++ b/packages/core/api/client.ts @@ -105,6 +105,7 @@ import type { ChatDraftRestoresResponse, ChatPendingTask, PrioritizeQueuedChatTaskResponse, + MoveTaskResponse, PendingChatTasksResponse, HasPendingChatTasksResponse, SendChatMessageResponse, @@ -276,6 +277,7 @@ import { ChatSessionListSchema, ChatSessionSchema, PrioritizeQueuedChatTaskResponseSchema, + MoveTaskResponseSchema, SendChatMessageResponseSchema, StartMikaOnboardingResponseSchema, ChildIssuesResponseSchema, @@ -304,6 +306,7 @@ import { EMPTY_CHAT_SESSION, EMPTY_CHAT_SESSION_LIST, EMPTY_PRIORITIZE_QUEUED_CHAT_TASK_RESPONSE, + EMPTY_MOVE_TASK_RESPONSE, EMPTY_CLOUD_RUNTIME_NODE, EMPTY_CLOUD_RUNTIME_NODE_LIST, EMPTY_AGENT_BUILDER_SESSION, @@ -4133,6 +4136,22 @@ export class ApiClient { }); } + /** + * Move one queued task a step up or down in its agent's claim order. + * moved=false means the task was already at the edge of the queue (or + * could not gain a position within the 0–4 priority scale); the queue is + * then unchanged. + */ + async moveQueuedTask(taskId: string, direction: "up" | "down"): Promise { + const raw = await this.fetch(`/api/tasks/${taskId}/move`, { + method: "POST", + body: JSON.stringify({ direction }), + }); + return parseWithFallback(raw, MoveTaskResponseSchema, EMPTY_MOVE_TASK_RESPONSE, { + endpoint: "POST /api/tasks/{taskId}/move", + }); + } + async listAttachments(issueId: string): Promise { return this.fetch(`/api/issues/${issueId}/attachments`); } diff --git a/packages/core/api/schemas.ts b/packages/core/api/schemas.ts index 40cf1283a0a..9ef63a14256 100644 --- a/packages/core/api/schemas.ts +++ b/packages/core/api/schemas.ts @@ -23,6 +23,7 @@ import type { ChatPendingTask, ChatSession, PrioritizeQueuedChatTaskResponse, + MoveTaskResponse, SendChatMessageResponse, StartMikaOnboardingResponse, Comment, @@ -2326,6 +2327,18 @@ export const PrioritizeQueuedChatTaskResponseSchema: export const EMPTY_PRIORITIZE_QUEUED_CHAT_TASK_RESPONSE: PrioritizeQueuedChatTaskResponse = { task_id: "" }; +export const MoveTaskResponseSchema: z.ZodType = z.object({ + task_id: z.string(), + priority: z.number(), + moved: z.boolean(), +}).loose(); + +export const EMPTY_MOVE_TASK_RESPONSE: MoveTaskResponse = { + task_id: "", + priority: 0, + moved: false, +}; + export const EMPTY_CHAT_DRAFT_RESTORES: ChatDraftRestoresResponse = { restores: [], }; diff --git a/packages/core/types/agent.ts b/packages/core/types/agent.ts index 0565b051adc..f4b488505d1 100644 --- a/packages/core/types/agent.ts +++ b/packages/core/types/agent.ts @@ -1390,3 +1390,12 @@ export interface RuntimeLocalSkillImportResult { skill?: Skill; conflict?: RuntimeLocalSkillImportConflict; } + +export interface MoveTaskResponse { + task_id: string; + priority: number; + /** False when the task was already at the edge of its agent's queue, or + * could not gain a position within the 0–4 priority scale; priority is + * then unchanged. */ + moved: boolean; +} diff --git a/packages/core/types/index.ts b/packages/core/types/index.ts index adadfe9ce53..2d2bdd964ea 100644 --- a/packages/core/types/index.ts +++ b/packages/core/types/index.ts @@ -111,6 +111,7 @@ export type { RuntimeLocalSkillImportResult, IssueUsageSummary, MikaBootstrapResponse, + MoveTaskResponse, } from "./agent"; export { RUNTIME_PROFILE_PROTOCOL_FAMILIES, RUNTIME_PROFILE_RUNTIME_TYPES } from "./agent"; export type { Workspace, WorkspaceRepo, WorkspaceMcpServer, Member, MemberRole, User, MemberWithUser, Invitation, ShareLink, ShareLinkInfo } from "./workspace"; diff --git a/packages/views/agents/components/tabs/activity-tab.tsx b/packages/views/agents/components/tabs/activity-tab.tsx index dfb50327e4a..ade151cc07a 100644 --- a/packages/views/agents/components/tabs/activity-tab.tsx +++ b/packages/views/agents/components/tabs/activity-tab.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from "react"; import { forwardRef, memo, useCallback, useMemo, useState } from "react"; import { ArrowUpRight, + ChevronDown, + ChevronUp, CircleHelp, Hash, MessageSquare, @@ -145,11 +147,20 @@ export function AgentWorkSections({ t.status === "dispatched" || t.status === "waiting_local_directory"), ) - .sort( - (a, b) => - (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99) || - new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - ); + .sort((a, b) => { + const rank = (statusRank[a.status] ?? 99) - (statusRank[b.status] ?? 99); + if (rank !== 0) return rank; + // Queued rows follow the server's claim order (priority DESC, + // created_at ASC) so the reorder buttons move a row toward or away + // from what the daemon will claim next. + if (a.status === "queued" && b.status === "queued") { + return ( + b.priority - a.priority || + new Date(a.created_at).getTime() - new Date(b.created_at).getTime() + ); + } + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + }); }, [snapshot, agent.id]); // Most recent terminal tasks. Includes cancelled — users searching @@ -627,6 +638,7 @@ function LiveTaskRow({ const timeAgo = useTimeAgo(); const paths = useWorkspacePaths(); const [cancelling, setCancelling] = useState(false); + const [moving, setMoving] = useState(false); const { cfg, hasIssue, issue, title, SourceIcon, sourceLabel, sourceFallback, statusLabel } = useTaskRowModel(task); const Icon = cfg.icon; @@ -638,6 +650,33 @@ function LiveTaskRow({ task.status === "queued" || task.status === "dispatched" || task.status === "running"; + // Only queued rows are reorderable — dispatched/running work is already + // claimed and its position is no longer the queue's to change. + const showMove = task.status === "queued"; + + const handleMove = async (direction: "up" | "down") => { + if (moving) return; + setMoving(true); + try { + const result = await api.moveQueuedTask(task.id, direction); + if (!result.moved) { + toast.info( + t(($) => + direction === "up" + ? $.tab_body.activity.move_front_toast + : $.tab_body.activity.move_back_toast, + ), + ); + } + // No manual invalidate needed — the task:queued WS event flows + // through useRealtimeSync's `task:` prefix path which already + // invalidates the snapshot. + } catch (e) { + toast.error(e instanceof Error ? e.message : t(($) => $.tab_body.activity.move_failed_toast)); + } finally { + setMoving(false); + } + }; const handleCancel = async () => { if (cancelling) return; @@ -687,6 +726,42 @@ function LiveTaskRow({ {activeTaskTimeText(task, t, timeAgo)}
+ {showMove && ( + <> + + void handleMove("up")} + disabled={moving} + aria-label={t(($) => $.tab_body.activity.move_up_aria)} + /> + } + className={`flex items-center justify-center rounded-xs p-1 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50 ${TOUCH_ACTION_CLASS}`} + > + + {t(($) => $.tab_body.activity.move_up_tooltip)} + + + void handleMove("down")} + disabled={moving} + aria-label={t(($) => $.tab_body.activity.move_down_aria)} + /> + } + className={`flex items-center justify-center rounded-xs p-1 text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50 ${TOUCH_ACTION_CLASS}`} + > + + {t(($) => $.tab_body.activity.move_down_tooltip)} + + + )} {showTranscript && ( t.id + AND (-p.priority, p.created_at, p.id) > (-t.priority, t.created_at, t.id) + ORDER BY (-p.priority, p.created_at, p.id) ASC + LIMIT 1 +) +UPDATE agent_task_queue atq +SET priority = CASE + WHEN neighbor.priority IS NULL THEN atq.priority + WHEN neighbor.priority < atq.priority THEN neighbor.priority + ELSE GREATEST(atq.priority - 1, 0) + END +FROM target +LEFT JOIN neighbor ON TRUE +WHERE atq.id = target.id +RETURNING atq.id, atq.priority +` + +type MoveQueuedTaskDownRow struct { + ID pgtype.UUID `json:"id"` + Priority int32 `json:"priority"` +} + +// Move one queued task one step toward the back of its agent's claim order, +// mirroring MoveQueuedTaskUp: take the neighbor's level when it is lower, +// otherwise drop the task's own level by one (clamped at 0). No neighbor +// means the task is already last — unchanged, moved=false. +func (q *Queries) MoveQueuedTaskDown(ctx context.Context, id pgtype.UUID) (MoveQueuedTaskDownRow, error) { + row := q.db.QueryRow(ctx, moveQueuedTaskDown, id) + var i MoveQueuedTaskDownRow + err := row.Scan(&i.ID, &i.Priority) + return i, err +} + +const moveQueuedTaskUp = `-- name: MoveQueuedTaskUp :one +WITH target AS ( + SELECT atq.id, atq.agent_id, atq.priority, atq.created_at + FROM agent_task_queue atq + WHERE atq.id = $1 AND atq.status = 'queued' +), +neighbor AS ( + SELECT p.priority + FROM agent_task_queue p + JOIN target t ON p.agent_id = t.agent_id + WHERE p.status = 'queued' + AND p.id <> t.id + AND (-p.priority, p.created_at, p.id) < (-t.priority, t.created_at, t.id) + ORDER BY (-p.priority, p.created_at, p.id) DESC + LIMIT 1 +) +UPDATE agent_task_queue atq +SET priority = CASE + WHEN neighbor.priority IS NULL THEN atq.priority + WHEN neighbor.priority > atq.priority THEN neighbor.priority + ELSE LEAST(atq.priority + 1, 4) + END +FROM target +LEFT JOIN neighbor ON TRUE +WHERE atq.id = target.id +RETURNING atq.id, atq.priority +` + +type MoveQueuedTaskUpRow struct { + ID pgtype.UUID `json:"id"` + Priority int32 `json:"priority"` +} + +// Move one queued task one step toward the front of its agent's claim order — +// priority DESC, created_at ASC, id ASC, the same order ClaimAgentTask uses. +// The caller holds the agent's claim lock (GetAgentForClaimUpdate), which +// serializes this against claims and other moves, so a plain +// read-compute-update is race-free. +// +// If a queued peer sorts ahead of the task, the task takes that neighbor's +// level when it is higher, or bumps its own level by one (clamped at 4) to +// clear a same-level peer. The 0–4 priority scale is coarse, so a bump can +// clear several lower levels at once. If nothing sorts ahead, the task is +// already first: it comes back unchanged and the caller reports moved=false. +func (q *Queries) MoveQueuedTaskUp(ctx context.Context, id pgtype.UUID) (MoveQueuedTaskUpRow, error) { + row := q.db.QueryRow(ctx, moveQueuedTaskUp, id) + var i MoveQueuedTaskUpRow + err := row.Scan(&i.ID, &i.Priority) + return i, err +} + const nextDeferredTaskFireAtForRuntimes = `-- name: NextDeferredTaskFireAtForRuntimes :one SELECT MIN(fire_at)::timestamptz FROM agent_task_queue t diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index decbb1e6cc7..a5000de3978 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -820,6 +820,75 @@ WHERE id = ( ) RETURNING *; +-- name: MoveQueuedTaskUp :one +-- Move one queued task one step toward the front of its agent's claim order — +-- priority DESC, created_at ASC, id ASC, the same order ClaimAgentTask uses. +-- The caller holds the agent's claim lock (GetAgentForClaimUpdate), which +-- serializes this against claims and other moves, so a plain +-- read-compute-update is race-free. +-- +-- If a queued peer sorts ahead of the task, the task takes that neighbor's +-- level when it is higher, or bumps its own level by one (clamped at 4) to +-- clear a same-level peer. The 0–4 priority scale is coarse, so a bump can +-- clear several lower levels at once. If nothing sorts ahead, the task is +-- already first: it comes back unchanged and the caller reports moved=false. +WITH target AS ( + SELECT atq.id, atq.agent_id, atq.priority, atq.created_at + FROM agent_task_queue atq + WHERE atq.id = $1 AND atq.status = 'queued' +), +neighbor AS ( + SELECT p.priority + FROM agent_task_queue p + JOIN target t ON p.agent_id = t.agent_id + WHERE p.status = 'queued' + AND p.id <> t.id + AND (-p.priority, p.created_at, p.id) < (-t.priority, t.created_at, t.id) + ORDER BY (-p.priority, p.created_at, p.id) DESC + LIMIT 1 +) +UPDATE agent_task_queue atq +SET priority = CASE + WHEN neighbor.priority IS NULL THEN atq.priority + WHEN neighbor.priority > atq.priority THEN neighbor.priority + ELSE LEAST(atq.priority + 1, 4) + END +FROM target +LEFT JOIN neighbor ON TRUE +WHERE atq.id = target.id +RETURNING atq.id, atq.priority; + +-- name: MoveQueuedTaskDown :one +-- Move one queued task one step toward the back of its agent's claim order, +-- mirroring MoveQueuedTaskUp: take the neighbor's level when it is lower, +-- otherwise drop the task's own level by one (clamped at 0). No neighbor +-- means the task is already last — unchanged, moved=false. +WITH target AS ( + SELECT atq.id, atq.agent_id, atq.priority, atq.created_at + FROM agent_task_queue atq + WHERE atq.id = $1 AND atq.status = 'queued' +), +neighbor AS ( + SELECT p.priority + FROM agent_task_queue p + JOIN target t ON p.agent_id = t.agent_id + WHERE p.status = 'queued' + AND p.id <> t.id + AND (-p.priority, p.created_at, p.id) > (-t.priority, t.created_at, t.id) + ORDER BY (-p.priority, p.created_at, p.id) ASC + LIMIT 1 +) +UPDATE agent_task_queue atq +SET priority = CASE + WHEN neighbor.priority IS NULL THEN atq.priority + WHEN neighbor.priority < atq.priority THEN neighbor.priority + ELSE GREATEST(atq.priority - 1, 0) + END +FROM target +LEFT JOIN neighbor ON TRUE +WHERE atq.id = target.id +RETURNING atq.id, atq.priority; + -- name: SetTaskDeliveredCommentIDs :one -- Replace (rather than append to) the delivery receipt for this claim. A stale -- dispatched task may be reclaimed by a daemon with different capabilities,