mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 17:33:51 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The control plane must retain work when an execution or workspace export fails. > - Retained-workspace holds can leave saved input waiting with no usable Retry action. > - Users cannot verify process ownership or repair provider allocations. > - This pull request gives system recovery a durable budget and gives each deliberate Retry a durable intent. > - Safe recovery can preserve files, settle an accepted result, or prepare an independent workspace. > - One task state keeps infrastructure diagnostics out of the ordinary conversation while preserving real permission and approval gates. ## Linked Issues or Issue Description **What happened?** A task with a retained workspace can show recovery, failed-run and saved-message warnings at the same time. Historical runs without native workspace descriptors cannot use the native restore path. Old recovery metadata can prevent a new attempt even when an independent workspace would be safe. **Expected behavior** Paperclip owns bounded infrastructure retries and file preservation. A user can request another attempt with ordinary Retry. Thinking appears immediately. The service preserves completed effects and current user controls. It does not claim that starting fresh recovered the old files. **Steps to reproduce** 1. Retain a failed run and its workspace after export or process-stop failure. 2. Save a follow-up while the recovery hold is active. 3. Open the task and try to continue without infrastructure credentials or operator certification. Refs #16906, #16835, #16883. This extends their recovery and receipt contracts. Related work: #16962 and #16963. ## What Changed - Add task-level Retry with company-scoped authorization, all-status idempotency and durable admission before physical work. Keep exact-run Retry compatible. - Add typed recovery phases, finite persisted backoff, historical adoption and durable local failure diagnostics. - Preserve native workspaces or verified historical snapshots. Admit an independent fresh workspace only after execution containment. Keep old-source preservation and cleanup obligations separate. - Carry completed publication and API receipts forward. Fence uncertain mutation retries across replacement runs. Preserve connected-chat identity and authorization. - Re-enter normal dispatch for saved input. Reuse accepted results through the existing finalizer. - Use one task interruption projection across the thread, composer and task list. Show optimistic Thinking, ordinary Retry and genuine user gates. - Add PostgreSQL, filesystem, API, browser-fixture and controlled provider coverage. Document the behavior and update the feature/access maps. ## Verification - All GitHub Actions CI jobs pass on `6d8c6d27d50586d8548b31d03b47f6d73a0ee11d`: typecheck, all server/chat/workspace and serialized suites, Runner checks, eight browser shards, canary and build. The required merge gate passes. - Greptile reviewed the final commit at 5/5, with no actionable findings or unresolved review threads. Its two earlier P1 findings are fixed. - Full recursive typecheck and build pass locally on the final commit. All 16 task Retry cases pass against isolated real PostgreSQL. The unrelated-success case and both stale Retry cases fail on the previous code and pass after the fix. - The corrected gateway fixture passes its uncertain-mutation and independent-read case. Four real HTTP API cases pass for definite 403/422 rejection, completed-effect reuse, and uncertain-effect fences across deliberate and automatic retries. These focused gateway/API checks used a temporary database lifecycle harness on an existing healthy local PostgreSQL process. The standard repository helper is unchanged. - Earlier qualification covers 291 task UI cases, saved-file copyback and ancestry, current Agent Chat ownership races, nine chat Retry API browser cases, and six assembled browser recovery fixtures. A real API/PostgreSQL/native-runner journey showed Thinking in 25 ms despite a delayed response, admitted one successor in a separate workspace, retained original files, and survived reload. - The controlled Daytona test interrupts export, joins the controller, restarts it in a separate process, and verifies one agent start, one external sentinel operation, retained file contents and no premature cleanup. It does not establish abrupt-kill or live LLM/GitHub acceptance. - The full local `pnpm test:run` was attempted and was not green: an OAuth row-lock timing assertion failed, and a later suite exhausted macOS PostgreSQL semaphores. The ten OAuth timing cases passed on rerun against a separate database. Later embedded database initialization failures remain recorded; the final CI matrix passes. - Security scanning passed on production-code commit `63953edf069b111c6becc3144dffa6deb52f68b5`. Its only successor changes one test-fixture identity line. The final scan is neutral/inconclusive because its pinned merge-base checkout failed or timed out. Both rerun API requests returned 404, so no fresh successful scan is claimed. Socket and Snyk checks pass on the final head. - The optional Paperclip Review remains queued behind an earlier run in workspace finalization. Its prior completed assessment was incomplete because a comparison tool was blocked by the platform filter. It is not counted as a completed code review. GitHub does not require this check for merge. - Hosted acceptance remains pending deployment. Local and CI results do not establish recovery of a hosted task or its retained files. ## Risks - Recovery changes span admission, physical ownership and dispatch. Incorrect evidence could permit overlapping work. Existing physical locks and authoritative stop requirements remain in place. - The migration adds concurrent indexes. It requires the existing top-level migration runner and retries incomplete index creation safely. - A fresh attempt can complete while old-source preservation remains unresolved. The service retains that obligation and does not report those files as recovered. - Unknown tool mutations remain blocked until their authoritative receipt owner resolves them. Generic upstream tools have no universal result-query protocol. - Recovery and diagnostics stay inside the stack. No Cloud incident endpoint, notification worker or new outbound reporting channel is included. ## Model Used OpenAI Codex, GPT-6 family. The runtime does not expose a more specific model version or context-window size. The model used reasoning, shell execution, source editing and browser tools. No model subagents were used for this implementation. ## 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; the full local run limitation is documented 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>
189 lines
13 KiB
JavaScript
189 lines
13 KiB
JavaScript
// Opt-in provider qualification, separate from the cheap default suite:
|
|
// PAPERCLIP_RECOVERY_LIVE=1 DAYTONA_API_KEY=... PAPERCLIP_DAYTONA_SDK_PATH=...
|
|
// PAPERCLIP_RECOVERY_IMAGE=ghcr.io/...@sha256:... vitest run --config vitest.config.ts scripts/workspace-recovery-provider.test.mjs
|
|
import { randomUUID, createHash } from "node:crypto";
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { eq } from "drizzle-orm";
|
|
import { expect, it, vi } from "vitest";
|
|
import { agents, companies, createDb, environmentLeases, environments, heartbeatRuns, issueRecoveryActions,
|
|
issues, workspaceOperations } from "@paperclipai/db";
|
|
import { NOOP_PLUGIN_TRACER } from "@paperclipai/plugin-sdk";
|
|
import { startEmbeddedPostgresTestDatabase } from "../src/__tests__/helpers/embedded-postgres.js";
|
|
import { readSystemRecovery } from "../src/services/system-recovery-state.js";
|
|
import { recoverRetainedWorkspace } from "../src/services/workspace-recovery.js";
|
|
import { attachPreservedWorkspaceSnapshots } from "../src/services/workspace-recovery-snapshot.js";
|
|
import { performSyncOut } from "../../packages/plugins/sandbox-providers/daytona/src/file-sync.js";
|
|
|
|
const boundary = vi.hoisted(() => ({ target: null }));
|
|
vi.mock("../src/services/environment-execution-target.js", () => ({
|
|
resolveEnvironmentExecutionTarget: async () => boundary.target,
|
|
}));
|
|
vi.mock("../../packages/plugins/sandbox-providers/daytona/src/plugin.js", () => ({
|
|
getPluginTracer: () => NOOP_PLUGIN_TRACER,
|
|
}));
|
|
|
|
const live = process.env.PAPERCLIP_RECOVERY_LIVE === "1";
|
|
const worker = process.env.PAPERCLIP_RECOVERY_WORKER;
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const quote = value => `'${value.replaceAll("'", "'\\''")}'`;
|
|
async function client() {
|
|
const modulePath = process.env.PAPERCLIP_DAYTONA_SDK_PATH;
|
|
if (!modulePath || !process.env.DAYTONA_API_KEY) throw new Error("Explicit Daytona qualification settings are required");
|
|
const { Daytona } = await import(/* @vite-ignore */ pathToFileURL(modulePath).href);
|
|
return new Daytona({ apiKey: process.env.DAYTONA_API_KEY, requestTimeoutMs: 120_000 });
|
|
}
|
|
|
|
it.skipIf(!live || !worker)("provider controller", async () => {
|
|
const f = JSON.parse(await readFile(process.env.PAPERCLIP_RECOVERY_FIXTURE, "utf8"));
|
|
process.env.PAPERCLIP_STORAGE_LOCAL_DIR = path.join(f.directory, "storage");
|
|
const db = createDb(f.connectionString);
|
|
try {
|
|
const sandbox = await (await client()).get(f.providerLeaseId);
|
|
const download = sandbox.fs.downloadFiles.bind(sandbox.fs);
|
|
sandbox.fs.downloadFiles = async (...args) => {
|
|
const result = await download(...args);
|
|
if (worker === "interrupt") {
|
|
// Interrupt after the real provider transfer and before host extraction.
|
|
// The controller joins its work and stops the export-only sandbox before
|
|
// exiting; an unjoined hard crash is separately fenced by ownership tests.
|
|
await writeFile(path.join(f.directory, "interrupted"), "archive received; extraction interrupted");
|
|
throw new Error("qualification_export_interrupted");
|
|
}
|
|
return result;
|
|
};
|
|
boundary.target = { kind: "remote", transport: "sandbox", providerKey: "daytona", leaseId: f.leaseId,
|
|
remoteCwd: f.remoteCwd, timeoutMs: 120_000,
|
|
runner: {
|
|
syncIn: async () => { throw new Error("Recovery must not upload into the retained source"); },
|
|
execute: async input => {
|
|
const startedAt = new Date().toISOString();
|
|
const result = await sandbox.process.executeCommand([input.command, ...(input.args ?? [])].map(quote).join(" "),
|
|
input.cwd, input.env, 120);
|
|
return { exitCode: result.exitCode, stdout: result.result, stderr: "", timedOut: false,
|
|
signal: null, pid: null, startedAt };
|
|
},
|
|
syncOut: operations => performSyncOut({ sandbox, operations, remoteDir: f.remoteCwd, timeoutSeconds: 120 }),
|
|
} };
|
|
const environmentRuntime = {
|
|
resumeRunLease: async () => {
|
|
await sandbox.start(120); await sandbox.refreshData();
|
|
if (sandbox.state !== "started") throw new Error("Provider did not confirm start");
|
|
return { providerLeaseId: sandbox.id, metadata: { remoteCwd: f.remoteCwd } };
|
|
},
|
|
retryPendingSandboxTeardown: async () => {
|
|
await sandbox.stop(120); await sandbox.refreshData();
|
|
if (sandbox.state !== "stopped") throw new Error("Provider did not confirm stop");
|
|
return { providerLeaseId: sandbox.id, state: "stopped" };
|
|
},
|
|
};
|
|
await recoverRetainedWorkspace({ db, ...f, environmentRuntime });
|
|
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
|
|
const state = readSystemRecovery(action.evidence);
|
|
expect(state?.state).toBe(worker === "interrupt" ? "scheduled" : "complete");
|
|
await writeFile(path.join(f.directory, `${worker}.json`), JSON.stringify(state));
|
|
} finally { await db.$client.end(); }
|
|
}, 300_000);
|
|
|
|
it.skipIf(!live || Boolean(worker))("preserves provider files across an interrupted export and controller restart without restarting the old agent", async () => {
|
|
const image = process.env.PAPERCLIP_RECOVERY_IMAGE;
|
|
if (!image?.includes("@sha256:")) throw new Error("An immutable qualification image is required");
|
|
const directory = await mkdtemp(path.join(os.tmpdir(), "paperclip-provider-recovery-"));
|
|
const priorHome = process.env.PAPERCLIP_HOME;
|
|
process.env.PAPERCLIP_HOME = path.join(directory, "home");
|
|
const priorStorage = process.env.PAPERCLIP_STORAGE_LOCAL_DIR;
|
|
process.env.PAPERCLIP_STORAGE_LOCAL_DIR = path.join(directory, "storage");
|
|
const database = await startEmbeddedPostgresTestDatabase("provider-recovery-");
|
|
const db = createDb(database.connectionString);
|
|
const daytona = await client();
|
|
let sandbox;
|
|
const evidence = { image, interrupted: false, restored: false, oldAgentStarts: null, externalOperations: null,
|
|
prematureCleanup: false, controllerProcesses: [], snapshotCount: 0 };
|
|
try {
|
|
sandbox = await daytona.create({ image, autoStopInterval: 10, autoArchiveInterval: 60,
|
|
resources: { cpu: 2, memory: 4, disk: 10 }, labels: { purpose: "paperclip-recovery-qualification" } }, { timeout: 300 });
|
|
const remoteCwd = "/tmp/paperclip-recovery-fixture";
|
|
await sandbox.process.executeCommand(`mkdir -p ${remoteCwd}`, undefined, undefined, 30);
|
|
await sandbox.process.createSession("old-agent");
|
|
await sandbox.process.executeSessionCommand("old-agent", {
|
|
command: `sh -c ${quote(`echo start >> ${remoteCwd}/agent-starts; echo published >> ${remoteCwd}/external-operations; printf 'saved work\\n' > ${remoteCwd}/saved.txt; while :; do sleep 1; done`)}`,
|
|
runAsync: true,
|
|
});
|
|
await expect.poll(async () => (await sandbox.process.executeCommand(`cat ${remoteCwd}/saved.txt`, undefined, undefined, 30)).result)
|
|
.toContain("saved work");
|
|
const companyId = randomUUID(), issueId = randomUUID(), runId = randomUUID(), actionId = randomUUID(), agentId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Disposable provider qualification", issuePrefix: "LIVE" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Fixture", adapterType: "paperclip_runner" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Disposable preservation fixture", status: "blocked", assigneeAgentId: agentId });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, issueId, agentId, status: "failed", runtimeMode: "legacy",
|
|
contextSnapshot: { issueId }, finishedAt: new Date() });
|
|
const [environment] = await db.insert(environments).values({ name: runId, driver: "sandbox" }).returning();
|
|
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: runId,
|
|
environmentId: environment.id, provider: "daytona", providerLeaseId: sandbox.id,
|
|
leasePolicy: "retain_on_failure", status: "released", releasedAt: new Date(),
|
|
metadata: { remoteCwd, driver: "sandbox", pluginId: randomUUID(), sandboxProviderPlugin: true } }).returning();
|
|
const recovery = { schema: "paperclip.workspace-restore-recovery.v1", leaseIds: [lease.id] };
|
|
await db.update(heartbeatRuns).set({ resultJson: { workspaceRestoreRecovery: recovery, workspaceRestoreFailure: "restore_failed" } })
|
|
.where(eq(heartbeatRuns.id, runId));
|
|
await db.insert(issueRecoveryActions).values({ id: actionId, companyId, sourceIssueId: issueId,
|
|
kind: "active_run_watchdog", ownerType: "system", fingerprint: runId, nextAction: "",
|
|
cause: "legacy_execution_requires_reconciliation", evidence: { runId, workspaceRestoreRecovery: recovery } });
|
|
const fixture = path.join(directory, "fixture.json");
|
|
await writeFile(fixture, JSON.stringify({ connectionString: database.connectionString, companyId, issueId, runId, actionId,
|
|
leaseId: lease.id, providerLeaseId: sandbox.id, remoteCwd, directory }), { mode: 0o600 });
|
|
async function runController(phase) {
|
|
const child = spawn(process.execPath, [path.join(root, "node_modules/vitest/vitest.mjs"), "run", "--config", "vitest.config.ts",
|
|
"scripts/workspace-recovery-provider.test.mjs", "-t", "provider controller"], {
|
|
cwd: path.join(root, "server"), env: { ...process.env, PAPERCLIP_RECOVERY_WORKER: phase, PAPERCLIP_RECOVERY_FIXTURE: fixture },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
evidence.controllerProcesses.push(child.pid);
|
|
// Keep provider/auth diagnostics off the terminal. Only the bounded,
|
|
// scrubbed verdict is published by the parent.
|
|
let output = "";
|
|
child.stdout.on("data", data => { output += data; });
|
|
child.stderr.on("data", data => { output += data; });
|
|
const code = await new Promise(resolve => child.on("exit", resolve));
|
|
if (code !== 0) {
|
|
await writeFile(path.join(directory, `${phase}.log`), output, { mode: 0o600 });
|
|
throw new Error(`Provider controller ${phase} failed; protected log: ${directory}/${phase}.log`);
|
|
}
|
|
}
|
|
await runController("interrupt");
|
|
evidence.interrupted = Boolean(await readFile(path.join(directory, "interrupted"), "utf8"));
|
|
const failed = JSON.parse(await readFile(path.join(directory, "interrupt.json"), "utf8"));
|
|
expect(failed.attempts).toBe(1);
|
|
// Honor the production persisted backoff; the probe never replenishes it.
|
|
if (failed.nextAttemptAt > Date.now()) await new Promise(resolve => setTimeout(resolve, failed.nextAttemptAt - Date.now() + 10));
|
|
await runController("resume");
|
|
const finished = JSON.parse(await readFile(path.join(directory, "resume.json"), "utf8"));
|
|
expect(finished.attempts).toBe(2);
|
|
const fresh = path.join(directory, "fresh"); await mkdir(fresh);
|
|
const attached = await attachPreservedWorkspaceSnapshots({ db, companyId, issueId, sourceRunIds: [runId], cwd: fresh });
|
|
const files = path.join(attached[0].path, "files");
|
|
expect(await readFile(path.join(files, "saved.txt"), "utf8")).toBe("saved work\n");
|
|
evidence.oldAgentStarts = (await readFile(path.join(files, "agent-starts"), "utf8")).trim().split("\n").length;
|
|
evidence.externalOperations = (await readFile(path.join(files, "external-operations"), "utf8")).trim().split("\n").length;
|
|
expect(evidence.oldAgentStarts).toBe(1); expect(evidence.externalOperations).toBe(1);
|
|
const operations = await db.select().from(workspaceOperations).where(eq(workspaceOperations.heartbeatRunId, runId));
|
|
evidence.snapshotCount = operations.filter(op => op.phase === "workspace_recovery_snapshot" && op.status === "succeeded").length;
|
|
expect(evidence.snapshotCount).toBe(1);
|
|
await sandbox.refreshData(); expect(sandbox.state).toBe("stopped");
|
|
const [retained] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id));
|
|
expect(retained.providerLeaseId).toBe(sandbox.id); expect(retained.metadata.workspaceRecoveryCleanup).toBeUndefined();
|
|
evidence.restored = true;
|
|
evidence.savedFileSha256 = createHash("sha256").update(await readFile(path.join(files, "saved.txt"))).digest("hex");
|
|
} finally {
|
|
// This test owns the disposable source. Production recovery never destroys it.
|
|
if (sandbox) await sandbox.delete(120);
|
|
if (process.env.PAPERCLIP_RECOVERY_EVIDENCE) await writeFile(process.env.PAPERCLIP_RECOVERY_EVIDENCE, JSON.stringify(evidence, null, 2));
|
|
await db.$client.end(); await database.cleanup();
|
|
if (priorHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = priorHome;
|
|
if (priorStorage === undefined) delete process.env.PAPERCLIP_STORAGE_LOCAL_DIR; else process.env.PAPERCLIP_STORAGE_LOCAL_DIR = priorStorage;
|
|
if (evidence.restored) await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}, 900_000);
|