Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/lib/components/notifications/notification-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IconUserPlus,
IconMessage,
IconPlayerPlay,
IconAlertTriangle,
} from "@tabler/icons-react";
import { Avatar, AvatarFallback } from "@conductor/ui";
import type { BadgeProps } from "@conductor/ui";
Expand Down Expand Up @@ -71,6 +72,13 @@ export const typeConfig: Record<
iconBg: "bg-success/10",
iconColor: "text-success",
},
rate_limit: {
icon: IconAlertTriangle,
label: "Rate Limited",
badgeVariant: "warning",
iconBg: "bg-warning/10",
iconColor: "text-warning",
},
system: {
icon: IconInfoCircle,
label: "System",
Expand Down
40 changes: 33 additions & 7 deletions apps/web/lib/components/quick-tasks/QuickTaskCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
IconGitPullRequest,
IconDotsVertical,
IconAlertCircle,
IconAlertTriangle,
IconClock,
} from "@tabler/icons-react";
import dayjs from "@conductor/shared/dates";
import { useQuery } from "convex/react";
Expand All @@ -36,6 +38,7 @@ interface QuickTaskCardProps {
createdAt: number;
createdBy?: Id<"users">;
branchName?: string;
scheduledRetryAt?: number;
onClick?: () => void;
isSelecting?: boolean;
isSelected?: boolean;
Expand All @@ -50,6 +53,7 @@ export function QuickTaskCard({
createdAt,
createdBy,
branchName,
scheduledRetryAt,
onClick,
isSelecting,
isSelected,
Expand All @@ -58,12 +62,18 @@ export function QuickTaskCard({
const { fullName } = useRepo();
const runs = useQuery(api.agentRuns.listByTask, { taskId: id });
const latestPrUrl = runs?.find((r) => r.prUrl)?.prUrl;
const hasError = runs?.[0]?.status === "error";
const latestRun = runs?.[0];
const hasError = latestRun?.status === "error";
const isRateLimited = hasError && latestRun?.errorType === "rate_limit";

return (
<Card
className={`relative overflow-hidden shadow-2xs transition-all duration-200 ${
hasError ? "border-destructive/60" : ""
hasError
? isRateLimited
? "border-warning/60"
: "border-destructive/60"
: ""
} ${isSelected ? "ring-2 ring-primary shadow-xs" : ""} ${
!isSelecting && onClick
? "cursor-pointer hover:shadow-xs hover:border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/35"
Expand All @@ -82,7 +92,11 @@ export function QuickTaskCard({
>
<div
className={`absolute left-0 top-0 bottom-0 w-[3px] ${
hasError ? "bg-destructive" : statusConfig[status].bar
hasError
? isRateLimited
? "bg-warning"
: "bg-destructive"
: statusConfig[status].bar
}`}
/>
<CardContent className="p-2 pl-3 md:p-2 md:pl-3 space-y-1">
Expand Down Expand Up @@ -153,10 +167,22 @@ export function QuickTaskCard({
<div className="flex items-center gap-2 justify-between">
<div className="flex items-center gap-1.5 min-w-0">
{createdBy && <UserInitials userId={createdBy} />}
{hasError && (
<span className="flex items-center gap-1 text-xs text-destructive shrink-0">
<IconAlertCircle size={12} />
Failed
{hasError &&
(isRateLimited ? (
<span className="flex items-center gap-1 text-xs text-warning shrink-0">
<IconAlertTriangle size={12} />
Rate Limited
</span>
) : (
<span className="flex items-center gap-1 text-xs text-destructive shrink-0">
<IconAlertCircle size={12} />
Failed
</span>
))}
{scheduledRetryAt && (
<span className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
<IconClock size={12} />
Retrying {dayjs(scheduledRetryAt).fromNow()}
</span>
)}
</div>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/components/quick-tasks/QuickTasksKanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ export function QuickTasksKanbanBoard({
status={task.status}
createdAt={task.createdAt}
createdBy={task.createdBy}
scheduledRetryAt={task.scheduledRetryAt}
isSelecting={isSelecting}
isSelected={selectedIds.has(task._id)}
onToggleSelect={() => onToggleSelect(task._id)}
Expand All @@ -143,6 +144,7 @@ export function QuickTasksKanbanBoard({
status={task.status}
createdAt={task.createdAt}
createdBy={task.createdBy}
scheduledRetryAt={task.scheduledRetryAt}
isSelecting={isSelecting}
isSelected={selectedIds.has(task._id)}
/>
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/components/quick-tasks/QuickTasksListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export function QuickTasksListView({
status={task.status}
createdAt={task.createdAt}
createdBy={task.createdBy}
scheduledRetryAt={task.scheduledRetryAt}
onClick={() => {
if (isSelecting) {
onToggleSelect(task._id);
Expand Down
98 changes: 96 additions & 2 deletions apps/web/lib/components/tasks/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
IconFolder,
IconTags,
IconGitBranch,
IconClock,
} from "@tabler/icons-react";
import { FormEvent, useEffect, useRef, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
Expand Down Expand Up @@ -107,6 +108,7 @@ export function TaskDetailModal({
const updateTask = useMutation(api.agentTasks.update);
const updateStatus = useMutation(api.agentTasks.updateStatus);
const deleteTask = useMutation(api.agentTasks.deleteCascade);
const scheduleRetry = useMutation(api.agentTasks.scheduleRetry);
const comments = useQuery(api.taskComments.listByTask, { taskId });
const createComment = useMutation(api.taskComments.create);
const removeComment = useMutation(api.taskComments.remove);
Expand All @@ -123,6 +125,8 @@ export function TaskDetailModal({
const [commentText, setCommentText] = useState("");
const [requestChangesPanel, setRequestChangesPanel] = useState(false);
const [tagsInput, setTagsInput] = useState("");
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
const [isScheduling, setIsScheduling] = useState(false);
const commentsEndRef = useRef<HTMLDivElement>(null);

useEffect(() => {
Expand Down Expand Up @@ -167,6 +171,10 @@ export function TaskDetailModal({
};

const latestPrUrl = runs?.find((r) => r.prUrl)?.prUrl;
const latestRun = runs?.[0];
const isRateLimited =
latestRun?.status === "error" && latestRun?.errorType === "rate_limit";
const limitResetAt = isRateLimited ? latestRun?.limitResetAt : undefined;
const status = task?.status;
const showProofSection = status !== "todo" && status !== "in_progress";
const projectOptions = projects ?? [];
Expand Down Expand Up @@ -337,6 +345,36 @@ export function TaskDetailModal({
</div>
)}

{isRateLimited && (
<div className="p-3 bg-warning/10 border border-warning/30 rounded-lg">
<div className="flex items-center gap-2 text-warning font-medium text-sm mb-1">
<IconAlertTriangle size={16} />
Usage limit reached
</div>
<p className="text-sm text-muted-foreground">
{limitResetAt
? `Resets ${dayjs(limitResetAt).fromNow()} (${dayjs(limitResetAt).format("h:mm A")})`
: "Reset time unknown"}
</p>
{task?.scheduledRetryAt ? (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1">
<IconClock size={14} />
Retry scheduled {dayjs(task.scheduledRetryAt).fromNow()}
</p>
) : (
<Button
size="sm"
variant="outline"
className="mt-2"
onClick={() => setShowScheduleDialog(true)}
>
<IconClock size={14} />
Schedule Retry
</Button>
)}
</div>
)}

{runs && runs.length > 0 && (
<div className="pt-4">
<h4 className="text-sm font-medium text-foreground mb-3 flex items-center gap-2">
Expand All @@ -357,13 +395,18 @@ export function TaskDetailModal({
run.status === "success"
? "success"
: run.status === "error"
? "destructive"
? run.errorType === "rate_limit"
? "warning"
: "destructive"
: run.status === "running"
? "warning"
: "outline"
}
>
{run.status}
{run.status === "error" &&
run.errorType === "rate_limit"
? "rate limited"
: run.status}
</Badge>
<span className="text-xs text-muted-foreground">
{run.startedAt
Expand Down Expand Up @@ -1069,6 +1112,57 @@ export function TaskDetailModal({
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={showScheduleDialog} onOpenChange={setShowScheduleDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconClock size={18} />
Schedule Retry
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
All accounts have reached their usage limits.
{limitResetAt
? ` The earliest reset is ${dayjs(limitResetAt).fromNow()} (${dayjs(limitResetAt).format("h:mm A")}).`
: ""}
</p>
<p className="text-sm text-muted-foreground">
Schedule this task to automatically retry when limits reset.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setShowScheduleDialog(false)}
>
Cancel
</Button>
<Button
disabled={isScheduling || !limitResetAt}
onClick={async () => {
if (!limitResetAt) return;
setIsScheduling(true);
try {
await scheduleRetry({
taskId,
retryAt: limitResetAt,
});
setShowScheduleDialog(false);
} finally {
setIsScheduling(false);
}
}}
>
{isScheduling && (
<IconLoader2 size={16} className="animate-spin" />
)}
Schedule Retry
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
8 changes: 7 additions & 1 deletion packages/backend/convex/agentRuns.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import type { Id } from "./_generated/dataModel";
import { runStatusValidator, logLevelValidator } from "./validators";
import {
runStatusValidator,
logLevelValidator,
errorTypeValidator,
} from "./validators";
import { createNotification } from "./notifications";

const logEntryValidator = v.object({
Expand All @@ -21,6 +25,8 @@ const agentRunValidator = v.object({
resultSummary: v.optional(v.string()),
prUrl: v.optional(v.string()),
error: v.optional(v.string()),
errorType: v.optional(errorTypeValidator),
limitResetAt: v.optional(v.number()),
});

export const get = query({
Expand Down
52 changes: 51 additions & 1 deletion packages/backend/convex/agentTasks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { mutation, query } from "./_generated/server";
import { mutation, query, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";
import { internal } from "./_generated/api";
import { getCurrentUserId } from "./auth";
import { taskStatusValidator, claudeModelValidator } from "./validators";
import { createNotification } from "./notifications";
Expand Down Expand Up @@ -41,6 +42,7 @@ const agentTaskValidator = v.object({
assignedTo: v.optional(v.id("users")),
model: v.optional(claudeModelValidator),
activeWorkflowId: v.optional(v.string()),
scheduledRetryAt: v.optional(v.number()),
});

export const listByBoard = query({
Expand Down Expand Up @@ -831,3 +833,51 @@ export const deleteCascade = mutation({
return null;
},
});

export const scheduleRetry = mutation({
args: {
taskId: v.id("agentTasks"),
retryAt: v.number(),
},
returns: v.null(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
const task = await ctx.db.get(args.taskId);
if (!task) {
throw new Error("Task not found");
}
const board = await ctx.db.get(task.boardId);
if (!board || board.ownerId !== identity.subject) {
throw new Error("Task not found");
}

await ctx.db.patch(args.taskId, {
scheduledRetryAt: args.retryAt,
updatedAt: Date.now(),
});

ctx.scheduler.runAt(args.retryAt, internal.agentTasks.clearScheduledRetry, {
taskId: args.taskId,
});

return null;
},
});

export const clearScheduledRetry = internalMutation({
args: { taskId: v.id("agentTasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
if (task && task.scheduledRetryAt) {
await ctx.db.patch(args.taskId, {
scheduledRetryAt: undefined,
updatedAt: Date.now(),
});
}
return null;
},
});
3 changes: 3 additions & 0 deletions packages/backend/convex/designWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ export const handleCompletion = mutation({
result: v.union(v.string(), v.null()),
error: v.union(v.string(), v.null()),
activityLog: v.union(v.string(), v.null()),
errorType: v.optional(v.union(v.literal("rate_limit"), v.null())),
limitResetAt: v.optional(v.union(v.string(), v.null())),
accountKey: v.optional(v.union(v.string(), v.null())),
},
returns: v.null(),
handler: async (ctx, args) => {
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/convex/docInterviewWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,9 @@ export const handleCompletion = mutation({
result: v.union(v.string(), v.null()),
error: v.union(v.string(), v.null()),
activityLog: v.union(v.string(), v.null()),
errorType: v.optional(v.union(v.literal("rate_limit"), v.null())),
limitResetAt: v.optional(v.union(v.string(), v.null())),
accountKey: v.optional(v.union(v.string(), v.null())),
},
returns: v.null(),
handler: async (ctx, args) => {
Expand Down
Loading