mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task Retry must preserve a person's request and enforce the current chat authority. > - Chat retry preparation takes nonblocking source locks before it can authorize the saved source. > - Sustained lock contention could discard the Retry request before it was saved. > - This pull request saves one unadmitted request in the existing chat retry queue. > - The worker checks the source and current access before normal admission. The task shows a scheduled retry while it waits. ## Linked Issues or Issue Description **What happened?** Task Retry returned HTTP 500 when another transaction held the saved chat principal's identity lock or the endpoint authorization row. The source preparation error rolled back the canonical Retry intent. The same gap existed when Retry had to restore a certified input batch that had never reached a provider. With the bounded synchronous retries in #18895, sustained contention returns 409 without a saved intent. **Expected behavior** HTTP 202 retains one Retry request. Acceptance does not grant source authority or mean that a provider started. The existing worker prepares the request if the source locks become available before its saved deadline. It must reject changed input, revoked access, Stop, or superseding work before input adoption, publication, or provider work. **Steps to reproduce** 1. Prepare a failed task with its original admitted GitHub chat source. 2. Hold that principal's `chat-identity` advisory lock or the endpoint row on a second PostgreSQL connection. 3. Send the normal task Retry HTTP request. Check the response and persisted intent. 4. Release the lock. Process the existing chat retry queue and check that one authorized successor is queued. **Paperclip version or commit** Reproduced on master `8771606fd87f69ed4057779e3dce3a9bca8088e6`. **Deployment mode** Source checkout, real Express Retry route and embedded PostgreSQL. External chat transport is a test fixture. The live hosted fix has not been deployed or verified. Related: #18895 handles brief admission contention with bounded synchronous attempts and returns 409 if it remains busy. This change retains a durable request through sustained identity contention. It uses the existing background retry owner and adds real HTTP, restored-input, Stop, publication, restart, and task-presentation coverage. ## What Changed - Preserve a strict pending-source action only when the identity try-lock reports contention or the endpoint authorization query reports lock-unavailable through an explicit Retry callback. Bind it to the canonical task Retry, company, task, agent, endpoint, conversation, and principal. Other database and authorization errors keep their existing behavior. - Revalidate the canonical intent, current board permission, saved source, and latest execution before the existing worker can prepare that action. Ordinary authorization rejects an unprepared action. - Keep certified input restoration inside source preparation. Recheck the saved deadline before the prepared payload write. Expiry, a nested savepoint, or outer rollback cannot commit partial restoration. - Give source authorization contention one saved 60-second deadline and a separate counter. Keep its waiting phase until current source authorization succeeds or the action terminates. It does not spend the eight delivery attempts. Duplicate clicks, worker restart, and background sweeps do not renew either limit. Reuse the existing work notifications and outbox deadlines. - Persist completion of the authorization wait under the current claim before delivery. A restart revalidates current authority. Generic delivery failure preserves the stored phase even when a commit acknowledgment is lost; it does not grant a wake or renew the saved deadline, counters, or claim. - Project pending preparation and prepared but unsettled actions as the existing scheduled-retry state with the actual outbox deadline. Keep the exact task, intent, agent, and source binding. Active execution keeps its normal working state. No UI or API enum change is needed. - Add regressions and document the admission boundary. ## Verification - RED: the original real HTTP route returned 500 under a held PostgreSQL identity lock. The restored-input case also returned 500 while its 60 sibling source and budget cases passed. The current endpoint-row case returned 409 without a saved intent under the bounded synchronous fallback. - RED: the worker failed a valid Retry during an actual nine-second PostgreSQL lock. Removing only the latest-run guard makes the new queued-run negative fail. Source preparation could also commit restored input after its deadline, and an already prepared action could process after a late lock release. The prepared but unsettled task view lost its scheduled state. - GREEN: all 91 selected real PostgreSQL integration cases passed. Coverage includes the 62-case saved-input matrix; coalescing; restart; one real heartbeat successor; source, actor, Stop, newer-work and publication refusals; a real nine-second lock; fixed-deadline exhaustion; the separate eight delivery attempts; certified restoration rollback on expiry; prepared-action early and late release; a real conversation-row wait that crosses the deadline during current authorization; endpoint-row contention; unknown source; an already-revoked actor; unchanged unrelated ingress errors; durable-work idle settlement; actual outbox presentation deadlines; and recovery after authorization commits, with Stop and a lost commit acknowledgment. - GREEN: all 75 cases in `task-retry.test.ts`, `task-interruption-presentation.test.ts`, `chat-failed-run-retry-boundary.test.ts`, and `chat-action-work.test.ts` passed without a name filter. - RED → GREEN: stopping after source authorization committed left a stale waiting flag, so a fresh worker failed a previously authorized Retry after the deadline. The fix saves that phase in the same transaction. A real PostgreSQL commit followed by an injected lost acknowledgment also reproduced generic settlement restoring the stale flag. The corrected settlement preserves the stored phase. No wake is sent on the ambiguous response. Recovery queues once after current checks; Stop still prevents a successor. The deadline, contention count, original input, failed run, and publications stay unchanged. - Module boundary checks pass. Full workspace typecheck and the full build passed at `d8a4a1a739aa6825714b04691d4166a00991808b`. At this exact head, both required checks (`ci / verify` and `ci / e2e`) pass. The inventory has 53 successful, three skipped, and one neutral check, plus the automatic Paperclip Review in progress. The legacy Snyk status passes. Greptile is 5/5 on this head, with no new comments or unresolved threads. A current Paperclip assessment has not been published yet. - The full local `pnpm test:run` did not pass: its second chat shard hit a 15-second timeout in the HTTP/restart test after 348 and 293 passing cases. One bounded diagnostic of the same 642-case shard pair passed, with that case completing in 606 ms. The matching Linux CI pair passed too. The original timeout remains unexplained and nonreproduced. No timeout changed. The earlier stopped run was incomplete, and both conflicting documentation additions are retained. - CI chat 3/3 exposed a separate fixture assumption: restoration can commit before a durable wake receipt exists. One known pre-receipt delivery failure reproduces the same missing-run error. The corrected fixture drives the existing worker until the exact canonical intent and wake agree. It proves one real wake, one successor, exact source/actor binding, and one restoration. The exact failed shard pair now passes locally: 635 cases. Workspace typecheck also passes at test-only head `94d691c9df61b05019b96900d5800a8f1c484e9a`. Production source is unchanged from the previous verified build. That head completed all registered checks: 53 successful, two neutral, three skipped, plus the successful legacy status. Greptile scored 5/5. The subsequent Paperclip 4/5 persistence finding is addressed by the source repair above; its new exact-head review is pending. - Compatibility: 111 selected cases passed with #18895 head `9b5d877161ffcbf16c0fb6920e8155a3620c85b0` in a disposable worktree. The branch is now synchronized with master `4a83ba4050`, which includes that change. - Preserve the old CI failure: at head `37954951fb66f3b50d0f2a3bec78d6993c479e87`, server shard 6/12 initially failed the empty organization-audience replacement versus member-removal assertion in `tool-access-service.test.ts`. The exact race family passed on unchanged base and head; all 501 file tests and two 30-case controls passed on base. The one Linux replay passed 119 files and 2,833 cases, with three skipped. No guard or assertion changed. The initial failure remains unexplained and nonreproduced; this PR does not claim to fix it. - The prior external Superagent scan could not start because its Security Graph returned HTTP 503. Its neutral check was not a completed security assessment. - These tests exercise the real Express route, PostgreSQL transactions, and heartbeat queue. External chat transport is a fixture. They do not run a live model or prove recovery of an original hosted task. ## Risks - A still-pending source authorization wait fails after its fixed 60-second contention deadline. A completed wait is saved, but new contention keeps the original deadline. A healthy lock can exceed that limit. Other delivery errors retain the eight-attempt budget. Current authority checks remain required before admission. Inspection does not reset either limit. - The new pending phase must never be treated as an authorized chat source. Normal authorization rejects it until the worker prepares and binds the exact source. - The task projection reads the existing linked outbox deadline. It adds a scoped join and does not create a timer or change a business deadline. - No schema migration, provider identity change, or new delivery queue is included. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, tool use, and test execution. The host did not expose an exact deployment snapshot ID or context-window size. ## 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 - [x] Required Paperclip CI gates are green (`ci / verify` and `ci / e2e`; automatic review progress is recorded above) - [x] 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>
177 lines
12 KiB
TypeScript
177 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { projectTaskInterruption } from "./task-interruption-presentation.js";
|
|
|
|
const issue = { id: "task", companyId: "11111111-1111-4111-8111-111111111111", status: "blocked", assigneeAgentId: "agent", projectId: "22222222-2222-4222-8222-222222222222" };
|
|
const run = { id: "run", agentId: "agent", status: "failed", scheduledRetryAt: null,
|
|
errorCode: "project_access_denied", error: null, livenessState: null,
|
|
resultJson: { projectAccessDenied: { projectId: "22222222-2222-4222-8222-222222222222" } } };
|
|
const agent = { status: "idle", pauseReason: null };
|
|
|
|
describe("unadmitted chat Retry presentation", () => {
|
|
const now = Date.parse("2026-01-01T12:00:00Z");
|
|
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
|
const wait = { id: "retry", requestedAt: new Date(now), sourceRunId: run.id,
|
|
preparationStatus: "issued", preparationRetryAt: now + 250 };
|
|
const input = { run: failed, agent, wait, paused: false, approval: false };
|
|
|
|
it.each(["issued", "processing"])("uses the persisted %s outbox deadline without implying provider work", status => {
|
|
for (const offset of [0, 20_000]) expect(projectTaskInterruption(issue, {
|
|
...input, wait: { ...wait, preparationStatus: status },
|
|
}, now + offset)).toMatchObject({ state: "retry_scheduled", canRetry: false,
|
|
nextRetryAt: new Date(wait.preparationRetryAt).toISOString() });
|
|
});
|
|
|
|
it.each(["failed", "cancelled"])("keeps terminal %s preparation out of the working state", status => {
|
|
expect(projectTaskInterruption(issue, { ...input, wait: { ...wait, preparationStatus: status,
|
|
preparationRetryAt: null } }, now)).toMatchObject({ state: "failed", nextRetryAt: null });
|
|
});
|
|
|
|
it("keeps current gates and admitted execution authoritative", () => {
|
|
expect(projectTaskInterruption(issue, { ...input, paused: true }, now))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
|
|
expect(projectTaskInterruption(issue, { ...input, approval: true }, now))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "approval" });
|
|
for (const status of ["queued", "running"]) expect(projectTaskInterruption(issue, {
|
|
...input, run: { ...failed, id: "successor", status },
|
|
}, now)).toMatchObject({ state: "working", nextRetryAt: null });
|
|
expect(projectTaskInterruption(issue, { ...input, wait: { ...wait, sourceRunId: "old-run" } }, now))
|
|
.toMatchObject({ state: "working", nextRetryAt: null });
|
|
});
|
|
});
|
|
|
|
it("explains a persisted Retry wait without exposing the saved payload", () => {
|
|
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
|
const wait = { id: "retry", requestedAt: new Date(), sourceRunId: failed.id,
|
|
executionWaitReason: "workspace_repair_required" };
|
|
const presentation = projectTaskInterruption(issue, { run: failed, agent, wait, paused: false, approval: false });
|
|
expect(presentation).toMatchObject({ state: "failed", canRetry: true,
|
|
retryWait: { reason: "workspace_repair_required", message: "Retry is waiting for Paperclip to recover the saved files. Your message is saved." } });
|
|
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, sourceRunId: "another-run" }, paused: false, approval: false }))
|
|
.not.toHaveProperty("retryWait");
|
|
expect(projectTaskInterruption(issue, { run: failed, agent, wait: { ...wait, executionWaitReason: "secret-payload" }, paused: false, approval: false }))
|
|
.toMatchObject({ retryWait: { reason: "execution_recovery", message: "Retry is waiting for execution checks. Your message is saved." } });
|
|
expect(projectTaskInterruption(issue, { run: failed, agent, wait, paused: true, approval: false }))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
|
|
});
|
|
|
|
it.each(["cleanup_quarantined", "cancelled_by_operator"])("does not offer Retry for terminal Retry waits: %s", reason => {
|
|
const failed = { ...run, errorCode: "native_provider_terminal_failed", resultJson: null };
|
|
expect(projectTaskInterruption(issue, { run: failed, agent, paused: false, approval: false,
|
|
wait: { id: "retry", requestedAt: new Date(), sourceRunId: failed.id, executionWaitReason: reason } }))
|
|
.toMatchObject({ state: "failed", canRetry: false, retryWait: { reason } });
|
|
});
|
|
|
|
it("preserves the actual workspace preparation error without changing retry authority", () => {
|
|
const failedRun = { ...run, errorCode: "workspace_validation_failed",
|
|
error: "The selected project has no repository source for a remote workspace.", resultJson: null };
|
|
expect(projectTaskInterruption(issue, { run: failedRun, agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "failed", canRetry: true, workspaceFailure: { message: failedRun.error } });
|
|
for (const status of ["done", "cancelled"]) expect(projectTaskInterruption({ ...issue, status },
|
|
{ run: failedRun, agent, paused: false, approval: false })).not.toHaveProperty("workspaceFailure");
|
|
expect(projectTaskInterruption(issue, { run: { ...failedRun, errorCode: "unrelated_failure" }, agent, paused: false, approval: false }))
|
|
.not.toHaveProperty("workspaceFailure");
|
|
});
|
|
|
|
function operatorStoppedRun() {
|
|
return { ...run, status: "cancelled", runtimeMode: "native" as const, companyId: issue.companyId,
|
|
nativeIssueId: issue.id, errorCode: "cancelled", resultJson: {
|
|
cancelledByActorType: "user", cancelledByUserId: "board",
|
|
executionCancellation: { state: "requested" },
|
|
nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: run.id,
|
|
companyId: issue.companyId, issueId: issue.id, scope: "run",
|
|
reasonCode: "cancellation_run_only", dispatched: true, dispatchState: "acknowledged",
|
|
intentAuditId: "intent", acknowledgementAuditId: "ack" },
|
|
} };
|
|
}
|
|
|
|
describe("intentional native Stop presentation", () => {
|
|
const workingIssue = { ...issue, status: "in_progress" };
|
|
it("does not offer a failed-attempt Retry for an audited operator Stop that keeps its task in progress", () => {
|
|
expect(projectTaskInterruption(workingIssue, { run: operatorStoppedRun(), agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "resolved", canRetry: false, userAction: null });
|
|
expect(workingIssue.status).toBe("in_progress");
|
|
});
|
|
|
|
it.each([{ runId: "foreign" }, { companyId: "foreign" }, { issueId: "foreign" },
|
|
{ dispatchState: "pending" }, { dispatched: false }, { acknowledgementAuditId: undefined },
|
|
{ intentAuditId: 123 }, { acknowledgementAuditId: {} },
|
|
{ scope: "subtree" }])("retains failure presentation without the matching operator receipt %j", change => {
|
|
const stopped = operatorStoppedRun();
|
|
expect(projectTaskInterruption(workingIssue, { run: { ...stopped, resultJson: { ...stopped.resultJson,
|
|
nativeCancellation: { ...stopped.resultJson.nativeCancellation, ...change } } }, agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "failed", canRetry: true });
|
|
});
|
|
|
|
it("keeps pause, approval, non-user cancellation and new input authoritative", () => {
|
|
const stopped = operatorStoppedRun();
|
|
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: true, approval: false }))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "resume" });
|
|
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: false, approval: true }))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "approval" });
|
|
expect(projectTaskInterruption(workingIssue, { run: { ...stopped, resultJson: {
|
|
...stopped.resultJson, cancelledByActorType: "system" } }, agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "failed", canRetry: true });
|
|
expect(projectTaskInterruption(workingIssue, { run: stopped, agent, paused: false, approval: false,
|
|
wait: { id: "new-input", requestedAt: new Date() } }))
|
|
.toMatchObject({ state: "working", canRetry: false });
|
|
});
|
|
});
|
|
|
|
it("preserves a provider explanation on a failed task without changing retry authority", () => {
|
|
const providerFailure = { schema: "paperclip.provider-failure.v1", provider: "anthropic", phase: "startup",
|
|
category: "rate_limit", message: "API error: 429 This request would exceed your account's rate limit.",
|
|
httpStatus: 429, retryability: "unknown" };
|
|
const failedRun = { ...run, errorCode: "native_session_interrupted", resultJson: { providerFailure } };
|
|
const presentation = projectTaskInterruption(issue, { run: failedRun, agent, paused: false, approval: false });
|
|
expect(presentation).toMatchObject({ state: "failed", canRetry: true, providerFailure });
|
|
expect(projectTaskInterruption({ ...issue, status: "done" }, { run: failedRun, agent, paused: false, approval: false }))
|
|
.not.toHaveProperty("providerFailure");
|
|
});
|
|
|
|
describe("task project-access interruption", () => {
|
|
it("explains a project denial without offering a futile retry", () => {
|
|
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: false })).toMatchObject({
|
|
state: "waiting_for_user", responsibility: "user", userAction: "project_access", canRetry: false,
|
|
projectAccess: { agentId: "agent", projectId: "22222222-2222-4222-8222-222222222222", ready: false },
|
|
});
|
|
});
|
|
|
|
it("projects the historical setup denial without requiring another failed run", () => {
|
|
expect(projectTaskInterruption(issue, { run: { ...run, errorCode: "setup_failed",
|
|
error: "Project is outside this low-trust boundary.", resultJson: null }, agent, paused: false, approval: false }))
|
|
.toMatchObject({ userAction: "project_access", canRetry: false });
|
|
});
|
|
|
|
it("offers an explicit retry after the project is added, without changing the failed run", () => {
|
|
expect(projectTaskInterruption(issue, { run, agent, projectAccessAllowed: true, paused: false, approval: false }))
|
|
.toMatchObject({ state: "failed", canRetry: true, projectAccess: { ready: true } });
|
|
expect(run.status).toBe("failed");
|
|
});
|
|
|
|
it("shows an accepted retry as working after access is repaired", () => {
|
|
const input = { run, agent, paused: false, approval: false, wait: { id: "request", requestedAt: new Date() } };
|
|
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true }))
|
|
.toMatchObject({ state: "working", canRetry: false });
|
|
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: true })).not.toHaveProperty("projectAccess");
|
|
expect(projectTaskInterruption(issue, { ...input, projectAccessAllowed: false }))
|
|
.toMatchObject({ state: "waiting_for_user", userAction: "project_access", canRetry: false });
|
|
});
|
|
|
|
it("does not reinterpret unrelated failures or an old assignee's denial", () => {
|
|
for (const otherRun of [{ ...run, errorCode: "setup_failed", error: "Disk full", resultJson: null },
|
|
{ ...run, agentId: "previous-agent" }]) {
|
|
expect(projectTaskInterruption(issue, { run: otherRun, agent, paused: false, approval: false }))
|
|
.not.toHaveProperty("projectAccess");
|
|
}
|
|
});
|
|
|
|
it("keeps approval, pause, running and completed states authoritative", () => {
|
|
expect(projectTaskInterruption(issue, { run, agent, paused: false, approval: true })).toMatchObject({ userAction: "approval" });
|
|
expect(projectTaskInterruption(issue, { run, agent, paused: true, approval: false })).toMatchObject({ userAction: "resume" });
|
|
expect(projectTaskInterruption(issue, { run: { ...run, status: "running" }, agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "working" });
|
|
expect(projectTaskInterruption({ ...issue, status: "done" }, { run, agent, paused: false, approval: false }))
|
|
.toMatchObject({ state: "resolved" });
|
|
});
|
|
});
|