mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users can stop a run and send another message on the same task.
> - Remote runners need evidence from their sandbox provider that
execution stopped.
> - Local process checks cannot prove that a remote process exited.
> - This pull request records provider stop receipts and uses them for
conversation admission.
> - New user messages can proceed after confirmed cleanup without
repeating interrupted actions.
## Linked Issues or Issue Description
Refs #13237 and #13239. Related: #13163 covers app-restart recovery;
this change covers an explicit stop followed by a new user message.
**What happened?**
A stopped remote Claude ACP task kept its execution hold after Daytona
cleanup succeeded. Native runners also rejected remote process
identities and retained stale session cleanup gates. A message sent
during cleanup could stay deferred after the sandbox stopped.
**Expected behavior**
After the provider confirms that the old execution stopped, a new user
message starts a fresh turn. Pending user messages must not need another
message to trigger admission. Prior action outcomes remain recorded.
**Steps to reproduce**
1. Start a long-running task in Daytona with a legacy Claude ACP or
native ACP runner.
2. Cancel the run while its tool is active.
3. Send a new message immediately, or after cleanup completes.
4. Observe the execution hold despite the old sandbox having stopped.
**Paperclip version or commit**
Reproduced on master at 7b829efdf6. The
branch is rebased on current master.
## What Changed
- Add optional provider stop receipts to sandbox release and destroy
hooks. Old plugins remain compatible.
- Bind receipts to the company, run, lease, and provider resource.
Failed cleanup cannot supply stop authority.
- Acknowledge legacy remote cancellation after confirmed termination.
- Admit native user continuations using remote receipts instead of host
process checks.
- Retire only the settled cleanup owner matching the stopped company,
run, and provider resource. Isolate cleanup gates between remote
sandboxes, including two sandboxes owned by one run.
- Reconsider user messages deferred during remote cleanup through normal
admission, including successful later cleanup retries.
- Preserve receipts through cleanup retries and inline cleanup after
failed startup.
- Permit provider destruction after a terminal remote checkpoint
failure. Busy ownership still blocks destruction.
- Add regression tests and update execution semantics.
- Give the real preview fixture ten seconds for cold startup. Reuse
release-mode Rust artifacts for the filtered parity checks, avoiding a
duplicate debug test build that exhausted CI disk twice; test filters
and assertions are unchanged.
## Verification
- Recursive typecheck and build passed.
- Targeted server, Daytona plugin, and native runtime tests passed. They
cover missing or mismatched receipts, failed cleanup, local process
protection, exact cleanup ownership, and a message sent during cleanup.
- Live Daytona tests passed for legacy Claude ACP, native per-turn,
native warm, and a newly created native runner using disposable
sandboxes. Each original run was cancelled; its explicit follow-up
completed with no execution hold. The disposable case confirmed a new
sandbox after deletion.
- Native tests used the provider's Opus 5 selector, `opus[1m]`, and the
Linux runner bundle from the sandbox image. Existing checkpoint/sync
finalization warnings remained visible before the native follow-ups
reached committed success. This change does not repair those separate
warnings or guarantee recovery of uncheckpointed files.
- A direct live provider test also passed with the final delete-wait
change: the destroy hook returned its receipt only after Daytona
reported the sandbox destroyed. The final Daytona plugin suite passed
all 153 tests; its build passed.
- After rebasing on master, 284 targeted server/plugin tests and 313
native executor tests passed. The native session runtime suite passed
all 128 tests.
- The review regression passed all 145 tests across the continuation,
environment runtime, and pending-cleanup sweep suites. Recursive
typecheck and build passed again after that fix.
- The security review's exact-resource finding is fixed. Cleanup
completion requires the same provider-resource scope used during session
creation. All 128 native runtime tests, 61 server continuation/cleanup
tests, recursive typecheck, and build passed after this fix. The
two-sandboxes-in-one-run regression proves one receipt cannot retire the
other quarantine.
- Greptile reviewed the final commit at 5/5, the security scan passed,
and no review threads remain unresolved. The final native executor suite
also passed all 313 tests.
- The full local suite reached 10,616 passing tests before stopping on
four failures. All four now pass in focused reruns: the final-code
continuation/teardown suites, the attachment suite, and the native
session test after building its required fake-provider binary. The
original full run overlapped source edits and did not reach the
remaining groups; it is not counted as a full-suite pass.
- The preview exposure suite passed all 25 applicable tests (three
Linux-only tests skipped locally) after the startup allowance change.
Both release-mode Rust parity commands passed locally. All final-head PR
checks passed, including full server/workspace/browser test groups, full
native runner verification, build, typecheck, canary dry-run, and the
aggregate gates.
## Risks
- A provider must return a receipt only after confirmed termination.
Incorrect provider claims could permit overlapping execution.
- Older providers without receipts retain the existing hold. Missing
evidence, failed cleanup, active ownership, pauses, approvals, and
budgets still block admission.
- This change preserves unknown action outcomes and old checkpoints. It
does not authorize replay or alter historical runs.
- No database migration or telemetry contract change is required.
## Model Used
OpenAI GPT-6 through Codex, with repository inspection, code execution,
and browser tools. The exact backend revision and context-window size
are not exposed in this session.
## 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 references)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run targeted 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] 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>
53 lines
2.6 KiB
TypeScript
53 lines
2.6 KiB
TypeScript
import { and, eq } from "drizzle-orm";
|
|
import { environmentLeases, type Db } from "@paperclipai/db";
|
|
|
|
type LeaseIdentity = {
|
|
id: string; companyId: string; heartbeatRunId: string | null;
|
|
provider: string | null; providerLeaseId: string | null;
|
|
};
|
|
|
|
/** Bind a provider receipt to the exact host-owned lease and run. Old plugins
|
|
* return void; that remains supported but grants no continuation authority. */
|
|
export function remoteTerminationReceipt(lease: LeaseIdentity, value: unknown) {
|
|
const receipt = value as { providerLeaseId?: unknown; state?: unknown } | null;
|
|
if (!lease.heartbeatRunId || !lease.provider || lease.provider === "local" ||
|
|
!lease.providerLeaseId || receipt?.providerLeaseId !== lease.providerLeaseId ||
|
|
!["stopped", "destroyed"].includes(String(receipt?.state))) return undefined;
|
|
return {
|
|
schema: "paperclip.remote-termination.v1", companyId: lease.companyId,
|
|
runId: lease.heartbeatRunId, leaseId: lease.id, provider: lease.provider,
|
|
providerLeaseId: lease.providerLeaseId, state: receipt!.state,
|
|
confirmedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
export function hasRemoteTerminationReceipt(lease: LeaseIdentity & {
|
|
releasedAt: unknown; cleanupStatus: string | null; status: string;
|
|
metadata: Record<string, unknown> | null;
|
|
}): boolean {
|
|
const receipt = lease.metadata?.remoteExecutionTermination as Record<string, unknown> | undefined;
|
|
return Boolean(lease.releasedAt && lease.cleanupStatus === "success" &&
|
|
["released", "expired", "failed"].includes(lease.status) && receipt &&
|
|
receipt.schema === "paperclip.remote-termination.v1" &&
|
|
receipt.companyId === lease.companyId && receipt.runId === lease.heartbeatRunId &&
|
|
receipt.leaseId === lease.id && receipt.provider === lease.provider &&
|
|
remoteTerminationReceipt(lease, receipt));
|
|
}
|
|
|
|
export function remoteLeaseCleanupScope(lease: Pick<LeaseIdentity, "provider" | "providerLeaseId">) {
|
|
return lease.provider && lease.provider !== "local" && lease.providerLeaseId
|
|
? JSON.stringify([lease.provider, lease.providerLeaseId]) : undefined;
|
|
}
|
|
|
|
export async function stoppedRemoteCleanupScopes(db: Db, companyId: string, runId: string) {
|
|
const leases = await db.select().from(environmentLeases).where(and(
|
|
eq(environmentLeases.companyId, companyId), eq(environmentLeases.heartbeatRunId, runId),
|
|
));
|
|
if (leases.length === 0 || !leases.every(hasRemoteTerminationReceipt)) return null;
|
|
return [...new Set(leases.map(lease => remoteLeaseCleanupScope(lease)!))];
|
|
}
|
|
|
|
export async function remoteExecutionHasStopped(db: Db, companyId: string, runId: string) {
|
|
return await stoppedRemoteCleanupScopes(db, companyId, runId) !== null;
|
|
}
|