Files
PaperClipAI/server/src/services/pre-dispatch-review-wait.ts
T
Devin FoleyandPaperclip 5847f047c7 fix(connections): retire requests for already available tools (#15856)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connection cards let users provide the access an agent needs.
> - Users can complete setup in Apps while an earlier card stays
pending.
> - A stale card can then cancel recovery despite valid existing access.
> - This change verifies existing access and expires the obsolete
request.
> - It records a system outcome and sends one durable continuation.
> - Approval, identity, permission, and workspace recovery gates remain
in place.

## Linked Issues or Issue Description

**What happened?**

An ordinary tool connection request stayed pending after the addressed
user connected the service in Apps. A later conversation retry was
cancelled with `issue_waiting_for_response`.

**Expected behavior**

A request expires when the same user and requesting agent already have
usable access. The agent gets a durable tool refresh. The system records
no new human approval.

**Steps to reproduce**

1. Have an agent request a tool connection on an active task.
2. Connect the service through Apps with the addressed user's existing
grant, agent install, and allowed tools.
3. Let a conversation run stop and retry. The pending card still blocks
the retry on the base branch.

Related: #12345 introduced the connection request flow. This fix uses
its existing access checks and delivery queue.

## What Changed

- Verify company, task ownership, user identity, membership, health,
grants, installs, and task tool policy before expiring an ordinary
request.
- Preserve AI account, channel, additional permission, and upstream
provider requests on their existing paths.
- Save the system outcome, audit event, and continuation in one
transaction. Notify an idle delivery worker when a request commits.
- Recheck access and other questions or approvals before delivery,
retry, final dispatch, and native projection. Refresh system
observations for an authorized replacement account.
- Rearm delivery when access changes before provider work starts. Verify
the host's admission receipt against execution evidence before allowing
retry without a repair incident.
- Require the current delivery claim when marking an attempt complete.
An old completion cannot overwrite a rearmed continuation.
- Add embedded-Postgres regressions for permissions, policy,
concurrency, restart, replacement accounts, both dispatch gates, and the
delivery race. Keep genuine workspace repair holds blocking.

## Verification

- Latest-head connection, wake-context, dispatch, and legacy recovery
suites: 124 tests passed. The connection suite includes 44 real-Postgres
cases.
- Server typecheck passed. Full `pnpm -r typecheck` and `pnpm build`
passed on the latest head.
- `pnpm test:run` was run locally. Its general-server group passed
18,583 tests and failed 20. That long run started before the review
fixes, then loaded new test cases against modules transformed before
those fixes. The affected latest-head suites pass in a fresh process.
- One full-run failure also reproduces on unchanged base commit
de9ab8e7e0: native-session-resume's damaged-prior-epoch replacement
case. It is outside this change. Full-run groups after general-server
did not run because that command stopped on failure.
- Apex reviewed commit 96b96297f4 at 5/5. All review threads are
resolved. All latest-head CI gates passed. The branch has no merge
conflicts.

## Risks

A false readiness result could retire a request too early. The fix
reuses the runtime access selection rules, makes no new grants, and
rechecks access at delivery. Ordinary expired requests do not authorize
continuation. A failed workspace restore still requires repair through
the existing gate. No schema migration is needed.

## Model Used

OpenAI GPT-6 through Codex, with repository inspection, code editing,
shell execution, and test tools. The session identifies the model family
as GPT-6. It does not expose a more specific runtime model 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 (focused suites; full local
limitations are listed above)
- [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] All Paperclip CI gates are green
- [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>
2026-10-11 09:30:45 -07:00

62 lines
4.2 KiB
TypeScript

import { and, eq, inArray, isNotNull, or } from "drizzle-orm";
import { environmentLeases, heartbeatRunEvents, heartbeatRuns, nativeRunFinalizations, type Db } from "@paperclipai/db";
import { PROCESS_IDENTITY_RECORDED, PROCESS_START_REQUESTED } from "./native-local-process-stop.js";
import { hasRemoteTerminationReceipt } from "./remote-execution-termination.js";
type Run = typeof heartbeatRuns.$inferSelect;
type DispatchFields = "startedAt" | "runtimeModeResolvedAt" | "processPid" | "processGroupId" |
"processStartedAt" | "nativeIssueId" | "nativeSessionId" | "sessionIdAfter" |
"controllerBootId" | "controllerLeaseExpiresAt" | "executionStage" | "contextSnapshot";
/** The queued-run gate owns this receipt before execution authority is claimed.
* A deliberate review or connection response wait has no provider actions to reconcile. The error code
* alone is not proof; missing or conflicting dispatch evidence retains the hold. */
export function isPreDispatchReviewWait(
run: Pick<Run, "runtimeMode" | "status" | "errorCode" | "resultJson"> & Partial<Pick<Run, DispatchFields>>,
): boolean {
const finalResponseWait = run.errorCode === "issue_waiting_for_response" &&
(run.resultJson?.preDispatchResponseWait as { version?: number; providerWorkStarted?: boolean } | undefined)?.version === 1 &&
(run.resultJson?.preDispatchResponseWait as { providerWorkStarted?: boolean } | undefined)?.providerWorkStarted === false;
return run.runtimeMode === "legacy" && run.status === "cancelled" &&
(run.errorCode === "issue_continuation_waiting_on_review" ||
(run.errorCode === "issue_waiting_for_response" &&
run.contextSnapshot?.connectionIntentResolution === "existing_connection" &&
run.contextSnapshot?.source === "connection_intent.resolved" &&
run.contextSnapshot?.interactionKind === "connection_intent" &&
run.contextSnapshot?.interactionStatus === "expired" &&
run.contextSnapshot?.mutation === "interaction" &&
typeof run.contextSnapshot?.interactionId === "string")) &&
run.resultJson?.stopReason === run.errorCode &&
run.resultJson?.timeoutSource === "stale_queued_run_gate" &&
run.resultJson?.workspaceRestoreFailure !== "restore_unsafe_archive" &&
(finalResponseWait || (run.startedAt === null && run.runtimeModeResolvedAt === null)) &&
run.processPid === null && run.processGroupId === null && run.processStartedAt === null &&
run.nativeIssueId === null && run.nativeSessionId === null && run.sessionIdAfter === null &&
(finalResponseWait || (run.controllerBootId === null && run.controllerLeaseExpiresAt === null && run.executionStage === null));
}
/** The row receipt is a candidate, not permission to discard conflicting
* retained execution or cleanup evidence. Historical false holds must be
* checked against the same evidence that gates explicit user admission. */
export async function isPreDispatchReviewWaitVerified(db: Db, run: Run): Promise<boolean> {
if (!isPreDispatchReviewWait(run)) return false;
const [coordinator] = await db.select({ id: nativeRunFinalizations.runId }).from(nativeRunFinalizations).where(and(
eq(nativeRunFinalizations.companyId, run.companyId), eq(nativeRunFinalizations.runId, run.id),
)).limit(1);
if (coordinator) return false;
const leases = await db.select().from(environmentLeases).where(and(
eq(environmentLeases.companyId, run.companyId), eq(environmentLeases.heartbeatRunId, run.id),
));
if (leases.some(lease => lease.provider === "local"
? !lease.releasedAt || lease.status === "pending_cleanup" || lease.cleanupStatus === "failed"
: !hasRemoteTerminationReceipt(lease))) return false;
const [execution] = await db.select({ id: heartbeatRunEvents.id }).from(heartbeatRunEvents).where(and(
eq(heartbeatRunEvents.companyId, run.companyId), eq(heartbeatRunEvents.runId, run.id),
or(isNotNull(heartbeatRunEvents.sourceEventId),
inArray(heartbeatRunEvents.eventType, ["adapter.invoke", PROCESS_START_REQUESTED, PROCESS_IDENTITY_RECORDED,
"harness.ready", "session.started", "session.resumed", "session.updated", "turn.started",
"provider.event", "provider.rpc_result", "tool.execution.started"])),
)).limit(1);
return !execution;
}