mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 15:55:46 +01:00
## Thinking Path > - Paperclip starts agent runs through a workspace provider when a project uses remote storage. > - The provider must prepare the workspace before the agent starts. > - One long worker call ties the checkout to its transport. > - Some checkouts take longer than the transport allows. > - This change lets an opted-in provider accept work and report its deadline. > - Core waits for completion through short status calls and stops at the provider deadline. ## Linked Issues or Issue Description **What happened?** A workspace provider can lose its reply when preparation takes longer than a single transport request. This stops run bootstrap before agent execution. **Expected behavior** Core should wait for a provider-confirmed workspace through short requests. The provider's deadline must bound the wait. **Steps to reproduce** Use a workspace provider that takes longer to prepare a checkout than its transport can remain open. Start a run that requires a new checkout. ## What Changed - Add an optional workspace preparation method to the plugin SDK. - Validate pending receipts and keep the original binding checks on completed replies. - Poll only during active preparation. Retry a lost status reply within the advertised deadline. - Preserve the original operation for providers that do not offer asynchronous preparation. - Reject deadline offers that exceed the existing lifecycle lock budget. Honor accepted offers without shortening them. - Checkout verification uses the existing synchronous RPC with a 10-second deadline and a 12-second transport timeout. [Core verification PR #20876](https://github.com/paperclipai/paperclip/pull/20876) ## Verification - All 63 workspace SDK and Core integration tests passed with temporary local PostgreSQL. The tests cover lost status replies, deadline expiry, oversized offers, and synchronous verification with an async-capable worker. - All 58 runner SDK input, evidence, and public type tests passed with the regenerated source pins. The local server typecheck passed. ## Risks - This change activates only when the provider advertises the method. Existing providers keep their current behavior. - Polling uses one-second intervals during active preparation. It stops at completion, failure, or the provider deadline. - No database migration is required. ## Model Used - OpenAI GPT-6 Codex with local code execution and tests. ## 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] 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>
64 lines
3.4 KiB
TypeScript
64 lines
3.4 KiB
TypeScript
import { setTimeout as delay } from "node:timers/promises";
|
|
import { parseWorkspacePrepareResult, parseWorkspaceProviderResult, type PluginWorkspaceParams } from "@paperclipai/plugin-sdk";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import { WorkspaceProviderFailureError, workspaceProviderCallFailure } from "./workspace-provider-failure.js";
|
|
|
|
type ProviderRequest = Omit<PluginWorkspaceParams, "deadline">;
|
|
type WorkspaceWorker = Pick<PluginWorkerManager, "call" | "getWorker">;
|
|
const legacyWorkspaceOperationMs = 300_000;
|
|
const legacyRpcGraceMs = 10_000;
|
|
|
|
export async function invokeWorkspaceProvider(workers: WorkspaceWorker, params: ProviderRequest) {
|
|
const verifyExisting = params.operation.kind === "prepare" && params.operation.verifyExisting;
|
|
if (!verifyExisting && params.operation.kind === "prepare" && workers.getWorker(params.pluginId)?.supportedMethods?.includes("workspacePrepare")) {
|
|
const result = await prepareWorkspaceWithPolling(workers, params);
|
|
if (result) return result;
|
|
}
|
|
const timeout = verifyExisting ? 10_000 : params.operation.kind === "finalize" ? 30_000 : legacyWorkspaceOperationMs;
|
|
const request = { ...params, deadline: new Date(Date.now() + timeout).toISOString() };
|
|
return parseWorkspaceProviderResult(request, await workers.call(
|
|
params.pluginId, "workspaceOperation", request, timeout + (verifyExisting ? 2_000 : legacyRpcGraceMs),
|
|
));
|
|
}
|
|
|
|
async function prepareWorkspaceWithPolling(workers: Pick<PluginWorkerManager, "call">, params: ProviderRequest) {
|
|
const rpcTimeout = 15_000;
|
|
const submit = { ...params, phase: "submit" as const, deadline: new Date(Date.now() + rpcTimeout).toISOString() };
|
|
const receipt = parseWorkspacePrepareResult(submit, await workers.call(params.pluginId, "workspacePrepare", submit, rpcTimeout));
|
|
if (receipt.kind === "unsupported") return null;
|
|
if (receipt.kind === "prepared") return receipt;
|
|
const offeredMs = Date.parse(receipt.deadline) - Date.now();
|
|
if (offeredMs > legacyWorkspaceOperationMs + legacyRpcGraceMs)
|
|
throw new WorkspaceProviderFailureError({
|
|
kind: "provider", reason: "unsupported_operation", retryable: false,
|
|
message: "Workspace provider offered a deadline outside the supported lifecycle budget",
|
|
});
|
|
const expiresAt = performance.now() + offeredMs;
|
|
const remaining = () => {
|
|
const ms = Math.ceil(expiresAt - performance.now());
|
|
if (ms <= 0) throw new WorkspaceProviderFailureError({
|
|
kind: "provider", reason: "deadline_exceeded", retryable: true,
|
|
message: "Workspace storage operation expired; retry the same workspace",
|
|
});
|
|
return ms;
|
|
};
|
|
const poll = { ...params, phase: "poll" as const, deadline: receipt.deadline };
|
|
for (;;) {
|
|
await delay(Math.min(1000, remaining()));
|
|
const timeout = Math.min(rpcTimeout, remaining());
|
|
let response: unknown;
|
|
try {
|
|
response = await workers.call(params.pluginId, "workspacePrepare", poll, timeout);
|
|
} catch (error) {
|
|
const failure = workspaceProviderCallFailure(error);
|
|
if (failure.kind === "transport" || failure.kind === "timeout") continue;
|
|
throw failure;
|
|
}
|
|
remaining();
|
|
const status = parseWorkspacePrepareResult(poll, response);
|
|
if (status.kind === "prepared") return status;
|
|
if (status.kind === "unsupported" || status.deadline !== receipt.deadline)
|
|
throw new Error("Workspace provider changed the preparation deadline");
|
|
}
|
|
}
|