mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Merge origin/master into master
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
ec2c5537fc
11 files changed
+278
-26
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
@@ -1472,6 +1472,42 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("still re-enqueues stranded assigned todo recovery when an old queued wake exists", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
runStatus: "failed",
|
||||
});
|
||||
await db.insert(agentWakeupRequests).values({
|
||||
companyId,
|
||||
agentId,
|
||||
source: "assignment",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_assigned",
|
||||
payload: { issueId },
|
||||
status: "queued",
|
||||
});
|
||||
const heartbeat = heartbeatService(db);
|
||||
|
||||
const result = await heartbeat.reconcileStrandedAssignedIssues();
|
||||
expect(result.assignmentDispatched).toBe(0);
|
||||
expect(result.dispatchRequeued).toBe(1);
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
expect(result.issueIds).toEqual([issueId]);
|
||||
|
||||
const runs = await db
|
||||
.select()
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs).toHaveLength(2);
|
||||
|
||||
const retryRun = runs.find((row) => row.id !== runId);
|
||||
expect((retryRun?.contextSnapshot as Record<string, unknown>)?.retryReason).toBe("assignment_recovery");
|
||||
if (retryRun) {
|
||||
await waitForRunToSettle(heartbeat, retryRun.id);
|
||||
}
|
||||
});
|
||||
|
||||
it("blocks assigned todo work after the one automatic dispatch recovery was already used", async () => {
|
||||
const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "todo",
|
||||
|
||||
@@ -460,6 +460,89 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
||||
});
|
||||
|
||||
it("touches a skipped active routine issue for the manual runner's inbox", async () => {
|
||||
const { agentId, companyId, issueSvc, routine, svc } = await seedFixture();
|
||||
const userId = randomUUID();
|
||||
const previousRunId = randomUUID();
|
||||
const liveHeartbeatRunId = randomUUID();
|
||||
|
||||
await db
|
||||
.update(routines)
|
||||
.set({ concurrencyPolicy: "skip_if_active" })
|
||||
.where(eq(routines.id, routine.id));
|
||||
|
||||
const previousIssue = await issueSvc.create(companyId, {
|
||||
projectId: routine.projectId,
|
||||
title: routine.title,
|
||||
description: routine.description,
|
||||
status: "in_progress",
|
||||
priority: routine.priority,
|
||||
assigneeAgentId: routine.assigneeAgentId,
|
||||
originKind: "routine_execution",
|
||||
originId: routine.id,
|
||||
originRunId: previousRunId,
|
||||
});
|
||||
|
||||
await db.insert(routineRuns).values({
|
||||
id: previousRunId,
|
||||
companyId,
|
||||
routineId: routine.id,
|
||||
triggerId: null,
|
||||
source: "manual",
|
||||
status: "issue_created",
|
||||
triggeredAt: new Date("2026-03-20T12:00:00.000Z"),
|
||||
linkedIssueId: previousIssue.id,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: liveHeartbeatRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId: previousIssue.id },
|
||||
startedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||
});
|
||||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
checkoutRunId: liveHeartbeatRunId,
|
||||
executionRunId: liveHeartbeatRunId,
|
||||
executionLockedAt: new Date("2026-03-20T12:01:00.000Z"),
|
||||
})
|
||||
.where(eq(issues.id, previousIssue.id));
|
||||
await db.insert(issueInboxArchives).values({
|
||||
companyId,
|
||||
issueId: previousIssue.id,
|
||||
userId,
|
||||
archivedAt: new Date("2026-03-20T12:02:00.000Z"),
|
||||
});
|
||||
|
||||
const run = await svc.runRoutine(routine.id, { source: "manual" }, { userId });
|
||||
|
||||
expect(run.status).toBe("skipped");
|
||||
expect(run.linkedIssueId).toBe(previousIssue.id);
|
||||
await expect(
|
||||
db.select().from(issueInboxArchives).where(eq(issueInboxArchives.issueId, previousIssue.id)),
|
||||
).resolves.toHaveLength(0);
|
||||
await expect(
|
||||
db.select().from(issueReadStates).where(eq(issueReadStates.issueId, previousIssue.id)),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
companyId,
|
||||
issueId: previousIssue.id,
|
||||
userId,
|
||||
}),
|
||||
]);
|
||||
|
||||
const inboxIssues = await issueSvc.list(companyId, {
|
||||
touchedByUserId: userId,
|
||||
inboxArchivedByUserId: userId,
|
||||
includeRoutineExecutions: true,
|
||||
});
|
||||
expect(inboxIssues.map((issue) => issue.id)).toContain(previousIssue.id);
|
||||
});
|
||||
|
||||
it("does not coalesce live routine runs with different resolved variables", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const variableRoutine = await svc.create(
|
||||
|
||||
@@ -345,7 +345,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"]),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
|
||||
),
|
||||
)
|
||||
@@ -356,6 +356,21 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
return Boolean(run || deferredWake);
|
||||
}
|
||||
|
||||
async function hasQueuedIssueWake(companyId: string, issueId: string) {
|
||||
return db
|
||||
.select({ id: agentWakeupRequests.id })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.status, "queued"),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issueId}`,
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => Boolean(rows[0]));
|
||||
}
|
||||
|
||||
async function enqueueStrandedIssueRecovery(input: {
|
||||
issueId: string;
|
||||
agentId: string;
|
||||
@@ -1619,6 +1634,11 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
||||
|
||||
if (issue.status === "todo") {
|
||||
if (!latestRun) {
|
||||
if (await hasQueuedIssueWake(issue.companyId, issue.id)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await isInvocationBudgetBlocked(issue, agentId)) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { orderItemsBySelectedAndRecent } from "../lib/recent-selections";
|
||||
@@ -75,17 +75,17 @@ export const InlineEntitySelector = forwardRef<HTMLButtonElement, InlineEntitySe
|
||||
|
||||
const currentOption = options.find((option) => option.id === value) ?? null;
|
||||
|
||||
function setHighlightedIndexValue(next: number | ((current: number) => number)) {
|
||||
const setHighlightedIndexValue = useCallback((next: number | ((current: number) => number)) => {
|
||||
const resolved = typeof next === "function" ? next(highlightedIndexRef.current) : next;
|
||||
highlightedIndexRef.current = resolved;
|
||||
setHighlightedIndex(resolved);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const selectedIndex = filteredOptions.findIndex((option) => option.id === value);
|
||||
setHighlightedIndexValue(selectedIndex >= 0 ? selectedIndex : 0);
|
||||
}, [filteredOptions, open, value]);
|
||||
}, [filteredOptions, open, setHighlightedIndexValue, value]);
|
||||
|
||||
const commitSelection = (index: number, moveNext: boolean) => {
|
||||
const option = filteredOptions[index] ?? filteredOptions[0];
|
||||
|
||||
@@ -2148,6 +2148,14 @@ function findMessageAnchorIndex(messages: readonly ThreadMessage[], anchorId: st
|
||||
return messages.findIndex((message) => issueChatMessageAnchorId(message) === anchorId);
|
||||
}
|
||||
|
||||
export function findLatestCommentMessageIndex(messages: readonly ThreadMessage[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const anchorId = issueChatMessageAnchorId(messages[index]);
|
||||
if (anchorId && anchorId.startsWith("comment-")) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
type VirtualizedVisibleAnchorSnapshot = {
|
||||
anchorId: string;
|
||||
index: number;
|
||||
@@ -3227,6 +3235,19 @@ export function IssueChatThread({
|
||||
}, [location.hash, messageAnchorIndex, messages, useVirtualizedThread]);
|
||||
|
||||
function handleJumpToLatest() {
|
||||
// Prefer the latest comment row over whichever run/timeline row happens to
|
||||
// sort last in the merged feed — see PAP-2672. Fall back to the trailing
|
||||
// row when no comment row exists (run-only/embedded outputs).
|
||||
const latestCommentIndex = findLatestCommentMessageIndex(messages);
|
||||
if (latestCommentIndex >= 0) {
|
||||
const latestCommentAnchor = issueChatMessageAnchorId(messages[latestCommentIndex]);
|
||||
if (
|
||||
latestCommentAnchor
|
||||
&& scrollToThreadAnchor(latestCommentAnchor, { align: "end", behavior: "smooth" })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (useVirtualizedThread) {
|
||||
virtualizedThreadRef.current?.scrollToLatest({ behavior: "smooth" });
|
||||
return;
|
||||
|
||||
@@ -241,4 +241,76 @@ describe("SidebarAgents", () => {
|
||||
expect(mockAgentsApi.resume).toHaveBeenCalledWith("agent-1", "company-1");
|
||||
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Agent resumed" }));
|
||||
});
|
||||
|
||||
it("only shows updating state for the agent currently being changed", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({ id: "agent-1", name: "Alpha", urlKey: "alpha" }),
|
||||
makeAgent({ id: "agent-2", name: "Beta", urlKey: "beta" }),
|
||||
]);
|
||||
mockAgentsApi.pause.mockImplementation(() => new Promise(() => {}));
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await openAgentMenu();
|
||||
|
||||
const pauseItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Pause agent"));
|
||||
expect(pauseItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
pauseItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await openAgentMenu("Open actions for Beta");
|
||||
|
||||
const betaPauseItem = Array.from(
|
||||
document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'),
|
||||
)
|
||||
.find((element) => element.textContent?.includes("Pause agent"));
|
||||
expect(betaPauseItem).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain("Updating...");
|
||||
});
|
||||
|
||||
it("does not offer sidebar resume for budget-paused agents", async () => {
|
||||
mockAgentsApi.list.mockResolvedValue([
|
||||
makeAgent({
|
||||
status: "paused",
|
||||
pauseReason: "budget",
|
||||
pausedAt: new Date("2026-01-02T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
const currentRoot = createRoot(container);
|
||||
root = currentRoot;
|
||||
|
||||
await act(async () => {
|
||||
currentRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAgents />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await openAgentMenu();
|
||||
|
||||
const budgetPausedItem = Array.from(
|
||||
document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'),
|
||||
)
|
||||
.find((element) => element.textContent?.includes("Budget paused"));
|
||||
expect(budgetPausedItem).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
budgetPausedItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.resume).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,14 @@ function SidebarAgentItem({
|
||||
const editHref = `${agentUrl(agent)}/configuration`;
|
||||
const isActive = activeAgentId === routeRef;
|
||||
const isPaused = agent.status === "paused";
|
||||
const isBudgetPaused = isPaused && agent.pauseReason === "budget";
|
||||
const pauseResumeLabel = isPaused ? "Resume agent" : "Pause agent";
|
||||
const pauseResumeDisabled = disabled || agent.status === "pending_approval" || isBudgetPaused;
|
||||
const pauseResumeDisabledLabel = disabled
|
||||
? "Updating..."
|
||||
: isBudgetPaused
|
||||
? "Budget paused"
|
||||
: pauseResumeLabel;
|
||||
|
||||
return (
|
||||
<div className="group/agent relative flex items-center">
|
||||
@@ -130,11 +137,15 @@ function SidebarAgentItem({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPauseResume(agent, isPaused ? "resume" : "pause")}
|
||||
disabled={disabled || agent.status === "pending_approval"}
|
||||
onClick={() => {
|
||||
if (pauseResumeDisabled) return;
|
||||
onPauseResume(agent, isPaused ? "resume" : "pause");
|
||||
}}
|
||||
disabled={pauseResumeDisabled}
|
||||
title={isBudgetPaused ? "Agent was paused by budget limits" : undefined}
|
||||
>
|
||||
{isPaused ? <PlayCircle className="size-4" /> : <PauseCircle className="size-4" />}
|
||||
<span>{disabled ? "Updating..." : pauseResumeLabel}</span>
|
||||
<span>{pauseResumeDisabledLabel}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -144,6 +155,7 @@ function SidebarAgentItem({
|
||||
|
||||
export function SidebarAgents() {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [pendingAgentIds, setPendingAgentIds] = useState<Set<string>>(() => new Set());
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewAgent } = useDialogActions();
|
||||
@@ -198,6 +210,13 @@ export function SidebarAgents() {
|
||||
action === "pause"
|
||||
? agentsApi.pause(agent.id, selectedCompanyId ?? undefined)
|
||||
: agentsApi.resume(agent.id, selectedCompanyId ?? undefined),
|
||||
onMutate: ({ agent }) => {
|
||||
setPendingAgentIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(agent.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
onSuccess: async (_agent, { agent, action }) => {
|
||||
if (selectedCompanyId) {
|
||||
await Promise.all([
|
||||
@@ -223,6 +242,13 @@ export function SidebarAgents() {
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
onSettled: (_data, _error, { agent }) => {
|
||||
setPendingAgentIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.delete(agent.id);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -263,7 +289,7 @@ export function SidebarAgents() {
|
||||
activeAgentId={activeAgentId}
|
||||
activeTab={activeTab}
|
||||
agent={agent}
|
||||
disabled={pauseResumeAgent.isPending}
|
||||
disabled={pendingAgentIds.has(agent.id)}
|
||||
isMobile={isMobile}
|
||||
onPauseResume={(targetAgent, action) => pauseResumeAgent.mutate({ agent: targetAgent, action })}
|
||||
runCount={runCount}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Company } from "@paperclipai/shared";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import {
|
||||
CompanyProvider,
|
||||
resolveBootstrapCompanySelection,
|
||||
@@ -141,8 +142,8 @@ describe("CompanyProvider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
@@ -170,11 +171,11 @@ describe("CompanyProvider", () => {
|
||||
|
||||
it("replaces a stale stored company id with the first loaded company", async () => {
|
||||
localStorage.setItem("paperclip.selectedCompanyId", "stale-company");
|
||||
let resolveCompanies!: (companies: Company[]) => void;
|
||||
const companiesPromise = new Promise<Company[]>((resolve) => {
|
||||
resolveCompanies = resolve;
|
||||
queryClient.setQueryData(queryKeys.companies.all, {
|
||||
companies: [makeCompany("company-1")],
|
||||
unauthorized: false,
|
||||
});
|
||||
mockCompaniesApi.list.mockReturnValue(companiesPromise);
|
||||
mockCompaniesApi.list.mockImplementation(() => new Promise(() => {}));
|
||||
const seen: Array<string | null> = [];
|
||||
|
||||
await act(async () => {
|
||||
@@ -187,14 +188,7 @@ describe("CompanyProvider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveCompanies([makeCompany("company-1")]);
|
||||
await companiesPromise;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(seen).toEqual([null, "company-1"]);
|
||||
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
|
||||
});
|
||||
expect(seen).toEqual([null, "company-1"]);
|
||||
expect(localStorage.getItem("paperclip.selectedCompanyId")).toBe("company-1");
|
||||
});
|
||||
});
|
||||
+2
-2
@@ -442,7 +442,7 @@
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.45rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
@@ -862,7 +862,7 @@ span.paperclip-project-mention-chip {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin: 0 0.1rem;
|
||||
padding: 0 0.45rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
|
||||
Reference in new issue
Block a user