Files
PaperClipAI/tests/e2e/connection-reviews.spec.ts
T
b06ea111a2 fix(server): schedule skill cache retention from changes and saved deadlines (#17923)
<!-- entire-trail-link-start -->
https://entire.com/gh/paperclipai/-/trails/83/changes/paperclip/83
<!-- entire-trail-link-end -->

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The server caches saved skill versions on disk so runs can restore
their exact instructions.
> - The old retention owner scans company rows every hour, even after
all caches are protected or removed.
> - Cache publication, version changes, and pin release already have
local or transaction-owned boundaries.
> - Grace windows and terminal controller leases already have saved
timestamps.
> - This pull request uses those changes and deadlines to drive the
existing bounded prune service.
> - Idle retention stops SQL and directory reads while pending cleanup
remains recoverable.

## Linked Issues or Issue Description

Stack slice 64. Parent: #17876 (`codex/sleep-63-agent-timer-deadlines`).
Related: #15625 establishes transaction-owned work signals. #17907
independently keeps an active cache-lock transaction alive; this PR
preserves that lock and does not duplicate that repair.

**What happened?**

At parent `31c12f1d68ff4a51e06fc2be2abf2c2aee9f9d3a`, skill-version
retention has an hourly company scan, a startup timeout, and separate
continuation timers. Empty or permanently protected caches still cause
recurring SQL.

**Expected behavior**

Reconstruct once, inspect changed companies, and schedule saved
access/staging grace or terminal controller deadlines. Stop recurring
work when no timed or uncertain obligation remains. Keep
current/newest/agent/run protection, restorable-byte validation, cache
locking, final pin revalidation, atomic rename, and trash recovery.

**Steps to reproduce**

Start an enabled server with no cached versions, or only current/pinned
versions. After the initial scan, advance an hour. The old interval
selects companies again. The new worker has no deadline and performs no
retention SQL or directory scan in that state.

## What Changed

- Signal the missing-local-source reconciliation deletion in its
transaction. Limit fresh scans of a successfully observed company to one
per thirty seconds. Keep one-second page continuations and actual saved
deadlines independent. Empty or uncertain passes do not delay first
publication.
- Restart a company scan if drain interrupts inspection or post-rename
trash cleanup. Restore bounded candidate/byte logging with dry-run mode.
Three controlled cases failed before repair and the complete 34-case
retention suite passes afterward.
- Register retention through the delivery coordinator and shared
scheduler. Remove the hourly, startup, and continuation timers from
`index.ts`. Track startup work without delaying readiness. Stop delivery
work before closing retained iterators.
- Keep one active company cursor and bounded 50-entry passes. Preserve
observed deadlines across pages and bound company discovery to 51 rows
per page. Retain concrete company failures and incomplete pins for
retry.
- Report saved grace and terminal controller deadlines from the existing
cache owner. PostgreSQL remains the controller-lease authority.
Current/newest/agent pins do not arm unrelated lease checks.
- Wake on cache publication, rejected materialization, and access
markers. Signal version creation/deletion and agent configuration/hire
changes in their owning transactions. Extend existing instruction,
finalization/environment, and retained-session fanouts with company
hints.
- Route reused provider-task prior-owner retirement through
`writeEnvironmentCleanup`, so that supported pin-release write also
settles a notification.
- Add an optional scope callback to `registerDeadlineWork`. Existing
consumers omit it and keep their behavior. The feature owns persistence
predicates; startup only registers it. Add no table or migration.

Full producer audit, shared-contract explanation, lifecycle evidence,
and limitations:
[`doc/plans/2026-10-27-skill-version-retention-sleep-audit.md`](doc/plans/2026-10-27-skill-version-retention-sleep-audit.md).

## Verification

- Reconciled all existing sleep changes with actual master
`7e27bc5007c8f9cb8cdce06c24f9813e27f7120f` through normal merges. All 24
retention-owned and 95 unrelated upstream paths are preserved; package,
UI, E2E and map trees equal master.
- Combined-source qualification: **1,009 TypeScript cases across 42
complete files**, plus **28 Rust cases** in two targets. One intentional
Linux-inotify-only adapter case is skipped on this macOS host. Coverage
includes actual retention/cache/producer/runtime behavior, startup,
shared deadline consumers, changed OpenCode/observability and native
execution boundaries.
- After the subsequent task-index/task-read upstream merges, **85 cases
across six complete files** passed on final source
`d9277b1858a2230bdb726974c90a0c9bc5c5ebe6`: real retention/cache/work,
task reads/query behavior and concurrent migration repair. Counts
overlap earlier coverage. Fresh PostgreSQL 17 schemas apply all 413
journal entries through index 414.
- Database/adapter/Runner builds, production server and Runner
typechecking, module boundaries, target migration order and generated
TypeScript/Rust release-profile consistency pass. Selected source hashes
and temporary fixture imports restore exactly.
- Reproduced and repaired Greptile's own-company due-cleanup P2 with
identical old-worker-failing/new-worker-passing assertions. The
30-second ordinary scan floor remains; already-due cleanup receives its
owed scan and disarms afterward. Earlier 799-case and fresh-blank-plugin
acceptance remain in the audit.
- **Inherited validation exception:** the separate Copilot
source-identity check fails after upstream OpenCode reserved-environment
changes; all 72 identity inputs equal current master. No provider
release attestation is regenerated or new live qualification claimed
here. Required exact-head CI/security and Greptile 5/5 still must pass.
- Historical results, repair details and limitations remain in [the
retention
audit](doc/plans/2026-10-27-skill-version-retention-sleep-audit.md). No
full local repository-suite, live provider, production-load,
external-alarm, measured suspend/wake or fleet-savings claim.

- CI on the prior published head found four connection-review assertions
racing the concealed conversation after reload. Its fresh
Greptile/Ponytail reviews were both 5/5, but the failing browser gate
was not waived or retried unchanged. The fixture now foregrounds the
task tab and waits for visible history before retaining all existing
saved-result assertions. The full file passes four journeys locally; the
controlled-restart case is intentionally skipped by the default
configuration. Original local journeys also passed unchanged, so no
deterministic local red reproduction is claimed. See the audit for CI
failure artifacts and scope. Runtime and prior sleep-test sources remain
unchanged.

## Risks

- Supported live writers are the existing service/API owners in one
process. Independent SQL/FS writers need an ingress contract. Startup
reconstruction covers restarts.
- Notifications cause a bounded inspection; they do not authorize
deletion. Durable pin predicates and the cache lock remain
authoritative.
- A database clock behind application time can require another
one-second check of the concrete expired controller lease. Unknown pins,
lock contention, and failed inspection retain retries until resolved.
- The in-process scheduler cannot wake stopped compute. This PR
preserves Cloud sleep-safety gates and external-alarm coverage. It does
not prove deployed suspension or fleet savings. Cache reclamation can
wait until a supported wake when compute is stopped.
- Native-harness backup retention and the other remaining duties are
separate stack slices.

## Model Used

OpenAI Codex (GPT-6-based coding agent), using repository inspection,
normal Git merges, composition proofs, a controlled red/green regression
and complete PostgreSQL/filesystem integration suites. No subagents were
used in this reconciliation.

## 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
them 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 (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 10:14:12 -05:00

395 lines
16 KiB
TypeScript

import { expect, test, type APIRequestContext } from "@playwright/test";
import { readFile, writeFile } from "node:fs/promises";
import { startReviewProvider } from "../fixtures/connection-review-provider";
type Json = Record<string, unknown>;
type Seed = { companyId: string; prefix: string };
type Agent = { id: string; name: string };
async function json<T = Json>(
response: Awaited<ReturnType<APIRequestContext["get"]>>,
): Promise<T> {
expect(
response.ok(),
`${response.url()} failed ${response.status()}: ${await response.text()}`,
).toBe(true);
return (await response.json()) as T;
}
async function newCompany(request: APIRequestContext): Promise<Seed> {
const company = await json<{ id: string; issuePrefix: string }>(
await request.post("/api/companies", {
data: { name: `Connection review E2E ${Date.now()}` },
}),
);
return { companyId: company.id, prefix: company.issuePrefix };
}
async function createAgent(
request: APIRequestContext,
companyId: string,
name: string,
): Promise<Agent> {
return await json<Agent>(
await request.post(`/api/companies/${companyId}/agents`, {
data: {
name,
role: "qa",
title: "Connection intent fixture agent",
capabilities: "Exercises deterministic connection intent wiring.",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["--input-type=module", "-e", "process.exit(0)"],
},
},
}),
);
}
function reviewAgentScript(connectionId: string, query: string) {
return `
const base = process.env.PAPERCLIP_API_URL + "/api";
const headers = { authorization: "Bearer " + process.env.PAPERCLIP_API_KEY, "content-type": "application/json", "x-paperclip-run-id": process.env.PAPERCLIP_RUN_ID };
const api = async (path, method = "GET", body) => {
const response = await fetch(base + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
if (!response.ok) throw new Error(await response.text());
return await response.json();
};
const describePages = (raw) => {
let value = raw;
if (typeof value === "string") { try { value = JSON.parse(value); } catch {} }
const content = typeof value === "string" ? value : typeof value?.content === "string" ? value.content : value?.data?.content?.filter(item => item.type === "text").map(item => item.text).join(" ") ?? "No pages returned.";
return "I found these recent pages: " + content.replace(/^Pages: /, "") + ".";
};
const issueId = process.env.PAPERCLIP_TASK_ID ?? (await api("/heartbeat-runs/" + process.env.PAPERCLIP_RUN_ID)).contextSnapshot.issueId;
const interactions = await api("/issues/" + issueId + "/interactions");
const review = interactions.find(i => i.payload?.toolAction);
if (review?.status === "pending") { console.log("Still waiting for the existing review; no retry."); process.exit(0); }
if (review && review.status !== "pending") {
const result = review.result?.toolAction;
if (review.status === "accepted" && (!result || ["approved", "executing"].includes(result.status))) {
console.log("Review accepted; waiting for the stored execution result. No retry.");
process.exit(0);
}
const message = review.status === "rejected" ? "Review declined. No pages were read." : result?.status === "failed" ? "Read failed: " + result.errorMessage : ["expired", "cancelled"].includes(review.status) ? "The review expired or was cancelled. No pages were read." : describePages(result?.resultSummary);
await api("/issues/" + issueId + "/comments", "POST", { body: message });
await api("/issues/" + issueId, "PATCH", { status: "done" });
console.log(message);
process.exit(0);
}
const session = await api("/tool-gateway/sessions", "POST", { runId: process.env.PAPERCLIP_RUN_ID, ttlMs: 60000 });
const gatewayHeaders = { "x-paperclip-tool-gateway-token": session.token, "content-type": "application/json" };
const tools = await (await fetch(base + "/tool-gateway/tools", { headers: gatewayHeaders })).json();
const tool = tools.find(t => t.connectionId === ${JSON.stringify(connectionId)} && t.upstreamToolName === "notion:list_pages");
if (!tool) throw new Error("Missing fixture connection");
const call = await fetch(base + "/tool-gateway/tools/call", { method: "POST", headers: gatewayHeaders, body: JSON.stringify({ tool: tool.name, parameters: { query: ${JSON.stringify(query)} } }) });
const result = await call.json();
if (!call.ok) {
if (result.reasonCode !== "approval_required" && result.code !== "approval_required" && !JSON.stringify(result).includes("approval_required")) throw new Error(JSON.stringify(result));
await api("/issues/" + issueId, "PATCH", { status: "in_review" });
console.log("Waiting for human review. No retry.");
process.exit(0);
}
await api("/issues/" + issueId + "/comments", "POST", { body: describePages(result.result) });
await api("/issues/" + issueId, "PATCH", { status: "done" });
`;
}
for (const journey of [
"approve",
"decline",
"always",
"failure",
"restart",
] as const) {
test(`connection review: ${journey}, synchronized task history and actual continuation`, async ({
page,
context,
request,
}, testInfo) => {
test.setTimeout(180_000);
test.skip(
journey === "restart" && !process.env.PAPERCLIP_REVIEW_RESTART_FILE,
"Use connection-reviews.config.ts for controlled server restart",
);
let suppressReviewEvents = journey === "decline";
if (journey === "decline") {
// Reproduce a review arriving between the initial fetch and subscription:
// return an empty first snapshot and drop its live creation notification.
const initialSnapshots = new Set<string>();
await page.route("**/api/issues/*/interactions", async (route) => {
const url = route.request().url();
if (route.request().method() === "GET" && !initialSnapshots.has(url)) {
initialSnapshots.add(url);
await route.fulfill({ json: [] });
} else await route.continue();
});
await page.routeWebSocket("**/api/companies/*/events/ws", (socket) => {
const server = socket.connectToServer();
server.onMessage((message) => {
if (suppressReviewEvents && String(message).includes("issue.thread_interaction_")) return;
socket.send(message);
});
});
}
const provider = await startReviewProvider();
try {
const seed = await newCompany(request);
const agent = await createAgent(request, seed.companyId, "Page reader");
await page.goto(`/${seed.prefix}/apps`);
const connector = page
.getByRole("list", { name: "Connector list" })
.getByRole("listitem")
.filter({ hasText: "Connect your own tool" });
await connector
.getByRole("button", { name: "Connect", exact: true })
.click();
await connector
.getByRole("button", { name: "Connect your own MCP server" })
.click();
await page
.getByPlaceholder("https://example.com/actions")
.fill(provider.url);
await page.getByRole("button", { name: "Continue", exact: true }).click();
await page.getByRole("button", { name: /Check link/i }).click();
await expect(
page.getByRole("heading", { name: /is ready/i }),
).toBeVisible({ timeout: 30_000 });
const {
connections: [connection],
} = await json<{ connections: Array<{ id: string }> }>(
await request.get(`/api/companies/${seed.companyId}/tools/connections`),
);
await json(
await request.put(`/api/tool-connections/${connection.id}/installs`, {
data: { installs: [{ targetType: "agent", targetId: agent.id }] },
}),
);
await page.goto(`/${seed.prefix}/apps/${connection.id}/permissions`);
await page
.getByRole("radio", { name: "List fixture pages: Ask first" })
.click();
const configureAgent = async (query: string) =>
json(
await request.patch(`/api/agents/${agent.id}`, {
data: {
adapterConfig: {
command: process.execPath,
args: [
"--input-type=module",
"-e",
reviewAgentScript(connection.id, query),
],
},
replaceAdapterConfig: true,
},
}),
);
await configureAgent(journey === "failure" ? "fail" : "recent");
const issue = await json<{ id: string; identifier: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: {
title: "Read recent pages",
status: "in_progress",
assigneeAgentId: agent.id,
},
}),
);
await page.goto(`/${seed.prefix}/issues/${issue.identifier}`);
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).toBeVisible({ timeout: 45_000 });
suppressReviewEvents = false;
const originatingReviews = await json<Array<{ id: string; sourceRunId: string | null }>>(await request.get(`/api/issues/${issue.id}/interactions`));
const originatingReview = originatingReviews[0];
const calls = () =>
provider.captures.filter((c) => c.method === "tools/call").length;
expect(calls()).toBe(0);
await page.screenshot({
path: testInfo.outputPath("pending-review.png"),
});
await page
.getByRole("button", { name: "Dismiss Approve tool action" })
.click();
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).not.toBeVisible();
await page.reload();
await expect(page.getByRole("button", { name: "Review request", exact: true })).toBeVisible({ timeout: 30_000 });
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).not.toBeVisible();
await json(
await request.post(`/api/issues/${issue.id}/comments`, {
data: { body: "Keeping this review pending while I check." },
}),
);
await page
.getByRole("button", { name: "Review request", exact: true })
.click();
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).toBeVisible();
const queue = await context.newPage();
await queue.goto(`/${seed.prefix}/apps/review`);
await expect(
queue.getByRole("button", { name: "Decline", exact: true }),
).toBeVisible({ timeout: 20_000 });
if (journey === "restart") {
const control = process.env.PAPERCLIP_REVIEW_RESTART_FILE!;
const token = String(Date.now());
await writeFile(control, `restart:${token}`);
await expect
.poll(() => readFile(control, "utf8"), { timeout: 60_000 })
.toBe(`started:${token}`);
await expect
.poll(
async () => {
try {
return (await request.get("/api/health")).ok();
} catch {
return false;
}
},
{ timeout: 60_000 },
)
.toBe(true);
await page.reload();
await queue.reload();
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).not.toBeVisible();
await page.getByRole("button", { name: "Review request", exact: true }).click();
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).toBeVisible();
expect(calls()).toBe(0);
}
if (journey === "decline") {
// Resolve from Connections and observe the still-open task tab update.
await queue
.getByRole("button", { name: "Decline", exact: true })
.click();
} else {
if (journey === "always") {
await page.getByRole("button", { name: "Approval options", exact: true }).click();
await page.getByRole("menuitem", { name: "Always allow", exact: true }).click();
} else {
await page.getByRole("button", { name: "Approve & run", exact: true }).click();
}
}
await expect
.poll(
async () =>
(
await json<Array<{ body: string }>>(
await request.get(`/api/issues/${issue.id}/comments`),
)
)
.map((c) => c.body)
.join("\n"),
{ timeout: 60_000 },
)
.toContain(
journey === "decline"
? "Review declined"
: journey === "failure"
? "Read failed"
: "Roadmap",
);
const replies = await json<Array<{ body: string; authorAgentId: string | null; createdByRunId: string | null }>>(await request.get(`/api/issues/${issue.id}/comments`));
const reply = replies.find(comment => comment.authorAgentId === agent.id)!;
expect(reply.createdByRunId).toBeTruthy();
expect(originatingReview.sourceRunId).toBeTruthy();
expect(reply.createdByRunId).not.toBe(originatingReview.sourceRunId);
expect(reply.body).not.toContain('"content":');
if (!["decline", "failure"].includes(journey)) {
await expect(page.getByRole("button", { name: "Show result details" })).toBeVisible();
await expect(page.locator("pre")).not.toBeVisible();
await page.getByRole("button", { name: "Show result details" }).click();
await expect(page.locator("pre")).toContainText('"content":');
await page.getByRole("button", { name: "Hide result details" }).click();
}
expect(calls()).toBe(journey === "decline" ? 0 : 1);
await expect(
page.getByRole("button", { name: "Approve & run", exact: true }),
).not.toBeVisible();
await expect(
queue.getByRole("button", { name: "Decline", exact: true }),
).not.toBeVisible({ timeout: 20_000 });
await page.bringToFront();
await page.reload();
await expect(page.getByTestId("task-chat-history-content")).toBeVisible({
timeout: 30_000,
});
await expect(
page
.getByText(
journey === "decline"
? "Declined"
: journey === "failure"
? "Execution failed"
: "Succeeded",
{ exact: false },
)
.first(),
).toBeVisible();
await page.screenshot({
path: testInfo.outputPath("resolved-review.png"),
});
if (journey === "always") {
await configureAgent("different search options");
const later = await json<{ id: string }>(
await request.post(`/api/companies/${seed.companyId}/issues`, {
data: {
title: "Read pages with different arguments",
status: "in_progress",
assigneeAgentId: agent.id,
},
}),
);
await expect
.poll(
async () =>
(
await json<Array<{ body: string }>>(
await request.get(`/api/issues/${later.id}/comments`),
)
)
.map((c) => c.body)
.join("\n"),
{ timeout: 45_000 },
)
.toContain("Roadmap");
expect(calls()).toBe(2);
expect(
await json<unknown[]>(
await request.get(`/api/issues/${later.id}/interactions`),
),
).toHaveLength(0);
}
await testInfo.attach("journey", {
body: JSON.stringify(
{
source: "local MCP fixture; no live Notion",
journey,
...seed,
issue,
agent,
providerCalls: calls(),
},
null,
2,
),
contentType: "application/json",
});
await queue.close();
} finally {
await provider.close();
}
});
}