mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-11 23:36:51 +02:00
## Thinking Path > - Paperclip is the control plane operators use to coordinate AI-agent companies and review work needing attention. > - The Inbox is the operator-facing surface that aggregates tasks requiring attention across server state and shared client polling. > - Archiving a task optimistically removed it, but ordinary background activity and stale polling responses could make it reappear seconds later. > - The server therefore needs to distinguish genuine user-attention events from routine agent/system activity. > - The client also needs a bounded local archive guard across every Inbox query path while the server mutation and in-flight polls settle. > - This pull request fixes both resurrection paths and adds race-focused regression coverage. > - The benefit is stable archive behavior without hiding a genuine archive failure after reconciliation or reload. ## Linked Issues or Issue Description ### Pre-submission checklist - [x] Searched existing open and closed issues and pull requests; no duplicate implementation was found. - [x] Reproduced on `master` before this branch. - [x] Confirmed this is a Paperclip core bug, not adapter or provider behavior. ### What happened? Archiving an Inbox task hid it optimistically, then background refresh activity could insert it back into the list seconds later. ### Expected behavior A successfully archived task remains hidden during normal polling. A genuine failed archive may become visible again after reconciliation or reload. ### Steps to reproduce 1. Open Inbox with a visible task. 2. Archive the task. 3. Wait for shared polling or routine agent activity to refresh task data. 4. Observe the archived task reappear without a hard page refresh. ### Paperclip version or commit `master` before this branch. ### Deployment mode Built from source using the local development application. ### Installation method Built from source (`pnpm`). ### Agent adapter(s) involved Not adapter-specific; this is a core Inbox bug. ### Database mode Not database-mode-specific. ### Access context Board (human operator). ### Additional context The failure had independent server and client causes: routine activity could resurface archived rows server-side, while stale shared-poll responses could bypass optimistic client removal. ## What Changed - Restrict server-side Inbox resurfacing to explicit user-attention events rather than any issue activity write. - Add a bounded client-side archive guard with confirmation, failure restoration, and cache reconciliation behavior. - Apply the guard to Inbox rendering, badge counts, optimistic cache updates, and shared-poll result application. - Classify the generic compact Inbox query so stale shared-poll data cannot bypass the guard. - Add server visibility-matrix tests and UI race-condition regression tests. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/hooks/useSharedPolling.test.ts src/lib/inboxArchiveCache.test.ts src/pages/Inbox.test.tsx` — 25 passed. - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/issues-service.test.ts` — 107 passed. - Branch rebased cleanly onto current `origin/master` before push. ## Risks - Low-to-moderate behavioral risk: resurfacing is intentionally narrower, so the server tests cover human comments, mentions, interactions, and status transitions that must still regain attention. - The client guard is bounded and cleared on mutation failure, limiting the risk of hiding a task whose archive did not persist. - No schema, migration, public API, workflow, dependency-lock, or visual-token changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude via Claude Code (`claude_local`; prior implementation/review run, exact underlying model ID and context window were not retained in the handoff metadata), with repository tool use and test execution. - OpenAI `gpt-5.5` via Codex CLI for final review repair and PR preparation, with reasoning, repository editing, GitHub tooling, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked/described the result above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip task identifier - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation where needed; no documentation change is required for this bug fix - [x] I have considered and documented risks above - [x] All Paperclip-authored commits include the required co-author trailer --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
114 lines
4.0 KiB
TypeScript
114 lines
4.0 KiB
TypeScript
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
beginLocalInboxArchive,
|
|
boundLocalInboxArchive,
|
|
clearLocalInboxArchive,
|
|
confirmLocalInboxArchive,
|
|
filterLocalInboxArchivedIssues,
|
|
getIssuePresenceInActiveInboxCaches,
|
|
getLocalInboxArchiveIssueIds,
|
|
removeIssueFromInboxCaches,
|
|
restoreIssueToInboxCaches,
|
|
snapshotInboxIssueCaches,
|
|
} from "./inboxArchiveCache";
|
|
import { queryKeys } from "./queryKeys";
|
|
|
|
function issue(id: string): Issue {
|
|
return { id } as Issue;
|
|
}
|
|
|
|
describe("inboxArchiveCache", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
for (const issueId of getLocalInboxArchiveIssueIds("company-1")) {
|
|
clearLocalInboxArchive("company-1", issueId);
|
|
}
|
|
});
|
|
|
|
it("restores only the failed archive during overlapping optimistic removals", () => {
|
|
const companyId = "company-1";
|
|
const queryClient = new QueryClient();
|
|
const queryKey = [...queryKeys.issues.listMineByMe(companyId), "with-routine-executions"] as const;
|
|
|
|
queryClient.setQueryData<Issue[]>(queryKey, [
|
|
issue("issue-a"),
|
|
issue("issue-b"),
|
|
issue("issue-c"),
|
|
]);
|
|
|
|
const archiveASnapshot = snapshotInboxIssueCaches(queryClient, companyId);
|
|
removeIssueFromInboxCaches(queryClient, companyId, "issue-a");
|
|
|
|
const archiveBSnapshot = snapshotInboxIssueCaches(queryClient, companyId);
|
|
removeIssueFromInboxCaches(queryClient, companyId, "issue-b");
|
|
|
|
restoreIssueToInboxCaches(queryClient, archiveASnapshot, "issue-a");
|
|
|
|
expect(queryClient.getQueryData<Issue[]>(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([
|
|
"issue-a",
|
|
"issue-c",
|
|
]);
|
|
|
|
restoreIssueToInboxCaches(queryClient, archiveBSnapshot, "issue-b");
|
|
|
|
expect(queryClient.getQueryData<Issue[]>(queryKey)?.map((cachedIssue) => cachedIssue.id)).toEqual([
|
|
"issue-a",
|
|
"issue-b",
|
|
"issue-c",
|
|
]);
|
|
});
|
|
|
|
it("filters locally archived issues until confirmed grace expires", () => {
|
|
vi.useFakeTimers();
|
|
const issues = [issue("issue-a"), issue("issue-b")];
|
|
|
|
beginLocalInboxArchive("company-1", "issue-a");
|
|
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual([issue("issue-b")]);
|
|
|
|
confirmLocalInboxArchive("company-1", "issue-a");
|
|
vi.advanceTimersByTime(4_999);
|
|
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual([issue("issue-b")]);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(filterLocalInboxArchivedIssues("company-1", issues)).toEqual(issues);
|
|
});
|
|
|
|
it("does not expire an in-flight archive before post-settle bounding starts", () => {
|
|
vi.useFakeTimers();
|
|
beginLocalInboxArchive("company-1", "issue-a");
|
|
|
|
vi.advanceTimersByTime(30_000);
|
|
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(true);
|
|
|
|
boundLocalInboxArchive("company-1", "issue-a");
|
|
vi.advanceTimersByTime(29_999);
|
|
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(true);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(getLocalInboxArchiveIssueIds("company-1").has("issue-a")).toBe(false);
|
|
});
|
|
|
|
it("distinguishes present, absent, and unavailable active inbox data", () => {
|
|
const companyId = "company-1";
|
|
const queryClient = new QueryClient();
|
|
const queryKey = [...queryKeys.issues.listMineByMe(companyId), "with-routine-executions"] as const;
|
|
|
|
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("unknown");
|
|
|
|
queryClient.setQueryData<Issue[]>(queryKey, [issue("issue-a")]);
|
|
const observer = new QueryObserver<Issue[]>(queryClient, {
|
|
queryKey,
|
|
queryFn: async () => [],
|
|
});
|
|
const unsubscribe = observer.subscribe(() => undefined);
|
|
|
|
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("present");
|
|
queryClient.setQueryData<Issue[]>(queryKey, [issue("issue-b")]);
|
|
expect(getIssuePresenceInActiveInboxCaches(queryClient, companyId, "issue-a")).toBe("absent");
|
|
|
|
unsubscribe();
|
|
});
|
|
});
|