mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 01:50:13 +01:00
## Thinking Path > - Paperclip manages agent work and preserves its task ownership. > - Heartbeat admission controls when a queued run can start. > - A failed run can still own workspace saving and environment cleanup. > - Automatic recovery can transfer the task execution lock to its queued retry before that cleanup finishes. > - The old queue check then sees the retry itself and misses the predecessor's remaining work. > - This pull request checks the exact retry predecessor before claiming the queued run. > - Existing cleanup notifications can then resume that same run through normal admission. ## Linked Issues or Issue Description Refs #18898 and #18691. The first repair covers canonical connected-task retries with a native source. The second covers live remote workspace writers. This change covers ordinary automatic continuation when the predecessor failed before native runtime selection. The search found no open repair for that path. **What happened?** A preparation failure saved a terminal legacy run row while its remote workspace save and environment cleanup were still active. Generic continuation created one queued successor and moved the task execution lock to it. The queue checked that current lock but did not check the successor's predecessor. The new run started and failed at the existing unfinished-writer guard. **Expected behavior** Keep that same successor queued until its exact predecessor finishes execution and cleanup. Check company, agent and task identity before using retry provenance as a wait condition. Preserve Stop, reassignment, failed cleanup and independent recovery gates. **Steps to reproduce** 1. Save a failed legacy preparation run with an active environment lease. 2. Queue its ordinary automatic continuation and bind the task execution lock to the successor. 3. Claim the queued run before the predecessor finishes cleanup. 4. The old code starts the successor. The fixed code retains it in the queue. 5. Commit successful cleanup through the existing cleanup owner. The ordinary dispatcher starts that same run once. **Paperclip version or commit** The causal regression fails on master `86814c6550f52e99ede150a883bdc773cbb540e3`. **Deployment mode** Source checkout on macOS arm64 with disposable PostgreSQL, the assembled heartbeat service and the production work coordinator. The adapter boundary is controlled. No hosted provider replay or deployment is claimed. ## What Changed - Check the current task execution owner and the retry's exact predecessor under the existing task claim lock. - Use the saved native task binding, ordinary task binding and historical context fallback in that order. Ignore unrelated retry provenance. - Reuse the existing live-executor and pending-cleanup checks. Reuse committed cleanup notifications and the existing dispatcher. - Add real PostgreSQL claim controls and assembled cleanup-event acceptance. Keep the original source failure, saved files and current task ownership intact. - Document the queued predecessor-cleanup rule. ## Verification - RED: the unchanged queue claimed the successor and wrote a new controller lease while its legacy predecessor retained an active remote environment lease. The regression expected the successor to remain queued. - GREEN: the same regression passes after the change. The complete queue suite passes all 71 tests. - The assembled heartbeat suite passes all six tests. The added cases cover one continuation after committed cleanup, saved-file reuse, final task lock release, board Stop, reassignment and failed cleanup. - Scope controls cover a deleted source after scan, another company, another task, another agent, canonical native task binding and the existing current-owner hold. A failed cleanup and a still-live local executor retain the wait. - Node 24.19.0 and pnpm 9.15.4. Module boundaries and `git diff --check` pass. - Initial fixture attempts failed on database constraints and observer timing. The final fixtures use valid immutable bindings, the board Stop attribution and a completed real dispatcher call after reading the exact committed lease state. Production ownership guards were retained throughout. - Full low-memory repository typecheck passed at `5cddb49322`. The sync to master `04a24f4e06` adds only the independent retained-allocation test fixture from #18984; production code is unchanged. - Full `pnpm build` passes on current head `c7c2bcf08ffd05be5ce714683ae1dcfe6f5b49f1`. - The post-sync run executes all 80 behavior assertions successfully across the queue, assembled cleanup and updated upstream retention suites. Its queue `afterAll` then exceeds the existing 30-second database shutdown hook. That failed suite result is retained. The controlled post-build queue rerun passes all 71 tests with normal teardown. The earlier shutdown cause remains undetermined. Its owned queue-test PostgreSQL process has exited. The separate local default `pnpm test:run` was intentionally stopped after full unfiltered CI passed on the exact head. Four local chat partitions completed before that stop; this is not a local full-suite pass. The verified owned process group and its PostgreSQL fixture have exited. Logs and retained fixture data remain available. The timeout has not been increased. - Full unfiltered CI passes on current head `c7c2bcf08ffd05be5ce714683ae1dcfe6f5b49f1`: all 12 server shards, all six internal chat partitions, all nine serialized server shards, the workspace groups, native Runner lanes, typecheck, build and browser E2E. Both required [ci / verify](https://github.com/paperclipai/paperclip/actions/runs/41866478617/job/126711606613) and [ci / e2e](https://github.com/paperclipai/paperclip/actions/runs/41866478617/job/126711263146) pass. The file selector is skipped, so this is the full CI test matrix. The head has 55 successful checks and three expected skipped checks. The ready-for-review contributor trust and security scan also pass. - Current-head Greptile is 5/5, its check is successful, and no inline review threads remain. The ordinary review check also passes. No merge, deployment or original task retry was performed. ## Risks - A predecessor with unfinished cleanup keeps its successor queued. Its existing cleanup owner remains responsible for settlement. - A task claim reads at most one additional predecessor row. A matching source also uses the existing ownership and cleanup queries. The change adds no timer, schema, credential, provider or model policy. - A committed cleanup signal only requests normal admission. Current ownership and independent execution gates still apply. ## Model Used OpenAI GPT-6 through Codex, with reasoning, code editing, tool use and PostgreSQL verification. The session does not expose a more specific serving model ID or context-window limit. ## 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 pass; the separate local full-suite attempt is explicitly incomplete 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>
279 lines
20 KiB
TypeScript
279 lines
20 KiB
TypeScript
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { agents, agentWakeupRequests, authUsers, companies, companyMemberships, createDb, environmentLeases, environments, heartbeatRuns, issues, nativeRunFinalizations } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { heartbeatService } from "../services/heartbeat.js";
|
|
import { NativeStartupCleanupPendingError, readNativeStartupCleanup } from "../services/native-runtime/native-startup-cleanup.js";
|
|
import { prepareNativeHeartbeatRun } from "../services/native-runtime/prepare-native-run.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { remoteTerminationReceipt } from "../services/remote-execution-termination.js";
|
|
import { createIsolatedTestHome } from "./helpers/isolated-test-home.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { writeEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
|
|
import { registerRunDispatchWork } from "../services/heartbeat/dispatch-work.js";
|
|
|
|
const execute = vi.hoisted(() => vi.fn());
|
|
vi.mock("../adapters/index.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../adapters/index.js")>("../adapters/index.js");
|
|
return { ...actual, getServerAdapter: vi.fn(() => ({ supportsLocalAgentJwt: false, execute })) };
|
|
});
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
const suite = support.supported ? describe : describe.skip;
|
|
|
|
suite("native terminal-run cleanup admission", () => {
|
|
let db: ReturnType<typeof createDb>;
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
beforeAll(async () => {
|
|
temporary = await startEmbeddedPostgresTestDatabase("native-cleanup-admission-");
|
|
db = createDb(temporary.connectionString);
|
|
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 0 });
|
|
await temporary?.cleanup();
|
|
});
|
|
|
|
it("holds the same task until its native executor settles while allowing unrelated tasks", async () => {
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), otherIssueId = randomUUID();
|
|
await db.insert(companies).values({ id: companyId, name: "Cleanup admission", issuePrefix: "CLN", defaultResponsibleUserId: "owner" });
|
|
await db.insert(authUsers).values({ id: "owner", name: "Fixture user", email: "owner@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "owner", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", status: "idle", adapterType: "process",
|
|
adapterConfig: {}, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 20 } } });
|
|
await db.insert(issues).values([issueId, otherIssueId].map(id => ({ id, companyId, title: "Recovery task", status: "todo", assigneeAgentId: agentId, responsibleUserId: "owner" })));
|
|
let release!: () => void, entered!: () => void;
|
|
const cleanupGate = new Promise<void>(resolve => { release = resolve; });
|
|
const terminal = new Promise<void>(resolve => { entered = resolve; });
|
|
let first = true;
|
|
execute.mockImplementation(async (context: { runId: string; context: { issueId: string } }) => {
|
|
if (first) {
|
|
first = false;
|
|
// Native finalization commits success before workspace/lease cleanup
|
|
// finishes. Keep the real heartbeat executor alive across that boundary.
|
|
await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native", finishedAt: new Date() }).where(eq(heartbeatRuns.id, context.runId));
|
|
entered();
|
|
await cleanupGate;
|
|
} else {
|
|
await db.update(issues).set({ status: "done" }).where(eq(issues.id, context.context.issueId));
|
|
}
|
|
return { exitCode: 0, signal: null, timedOut: false, summary: "Completed" };
|
|
});
|
|
const heartbeat = heartbeatService(db);
|
|
const nextId = randomUUID(), otherId = randomUUID();
|
|
try {
|
|
await heartbeat.invoke(agentId, "on_demand", { issueId }, "manual");
|
|
await terminal;
|
|
await db.insert(heartbeatRuns).values([
|
|
{ id: nextId, companyId, agentId, status: "queued", invocationSource: "on_demand", responsibleUserId: "owner", contextSnapshot: { issueId } },
|
|
{ id: otherId, companyId, agentId, status: "queued", invocationSource: "on_demand", responsibleUserId: "owner", contextSnapshot: { issueId: otherIssueId } },
|
|
]);
|
|
await heartbeat.resumeQueuedRuns();
|
|
expect((await heartbeat.getRun(nextId))?.status).toBe("queued");
|
|
expect((await heartbeat.getRun(otherId))?.status).not.toBe("queued");
|
|
} finally {
|
|
release();
|
|
await heartbeat.drainActiveRunExecutions();
|
|
}
|
|
expect((await heartbeat.getRun(nextId))?.status).toBe("succeeded");
|
|
expect(execute).toHaveBeenCalledTimes(3);
|
|
}, 30_000);
|
|
|
|
it.each(["continue", "stop", "reassign", "failed cleanup"] as const)(
|
|
"settles an automatic legacy retry from the committed cleanup event: %s", async mode => {
|
|
const home = createIsolatedTestHome(), previousHome = process.env.PAPERCLIP_HOME;
|
|
process.env.PAPERCLIP_HOME = home.home;
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), sourceId = randomUUID();
|
|
const successorId = randomUUID(), wakeId = randomUUID(), ownerId = randomUUID();
|
|
const cwd = path.join(home.home, "saved-task");
|
|
await mkdir(cwd, { recursive: true });
|
|
await writeFile(path.join(cwd, "saved.txt"), "Preserved work before preparation failed.\n");
|
|
await db.insert(authUsers).values({ id: ownerId, name: "Task owner", email: `${ownerId}@example.test`,
|
|
createdAt: new Date(), updatedAt: new Date() });
|
|
await db.insert(companies).values({ id: companyId, name: "Automatic cleanup retry", issuePrefix: `R${companyId.slice(0, 6)}`, defaultResponsibleUserId: ownerId });
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: ownerId, status: "active", membershipRole: "owner" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Continuation worker", status: "idle", adapterType: "process",
|
|
adapterConfig: { cwd }, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true, maxConcurrentRuns: 1 } } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Continue preserved work", status: "in_progress",
|
|
assigneeAgentId: agentId, responsibleUserId: ownerId });
|
|
await db.insert(heartbeatRuns).values({ id: sourceId, companyId, agentId, issueId, status: "failed", runtimeMode: "legacy",
|
|
startedAt: new Date(Date.now() - 10_000), finishedAt: new Date(), executionStage: "preparing", errorCode: "adapter_failed",
|
|
resultJson: { executionRecovery: { kind: "bootstrap", providerWorkStarted: false } }, contextSnapshot: { issueId } });
|
|
const [lease] = await db.insert(environmentLeases).values({ companyId, issueId, heartbeatRunId: sourceId,
|
|
provider: "daytona", providerLeaseId: randomUUID(), status: "active" }).returning();
|
|
await db.insert(agentWakeupRequests).values({ id: wakeId, companyId, agentId, source: "automation", triggerDetail: "system",
|
|
reason: "issue_continuation_needed", requestedByActorType: "system", status: "queued", runId: successorId,
|
|
payload: { issueId } });
|
|
await db.insert(heartbeatRuns).values({ id: successorId, companyId, agentId, issueId, status: "queued", invocationSource: "automation",
|
|
responsibleUserId: ownerId, retryOfRunId: sourceId, wakeupRequestId: wakeId,
|
|
contextSnapshot: { issueId, retryOfRunId: sourceId, source: "issue.continuation_recovery",
|
|
wakeReason: "issue_continuation_needed", skipIssueComment: true } });
|
|
await db.update(issues).set({ executionRunId: successorId, executionAgentNameKey: "continuation-worker", executionLockedAt: new Date() })
|
|
.where(eq(issues.id, issueId));
|
|
execute.mockReset();
|
|
execute.mockImplementation(async (context: { runId: string }) => {
|
|
expect(context.runId).toBe(successorId);
|
|
expect(await readFile(path.join(cwd, "saved.txt"), "utf8")).toBe("Preserved work before preparation failed.\n");
|
|
await writeFile(path.join(cwd, "continued.txt"), "Continued once after cleanup.\n");
|
|
await db.update(issues).set({ status: "done" }).where(eq(issues.id, issueId));
|
|
return { exitCode: 0, signal: null, timedOut: false, summary: "Continued preserved work after cleanup." };
|
|
});
|
|
const heartbeat = heartbeatService(db), errors: unknown[] = [];
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
|
let completedCleanupDispatches = 0;
|
|
const cleanupStatus = mode === "failed cleanup" ? "failed" : "success";
|
|
try {
|
|
await registerRunDispatchWork(coordinator, { promoteDueScheduledRetries: async () => undefined,
|
|
nextScheduledRetryAt: async () => null, schedulingSuppressed: async () => false,
|
|
resumeQueuedRunsAfterDrain: async () => {
|
|
const [current] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease.id));
|
|
await heartbeat.resumeQueuedRunsAfterDrain({ companyId });
|
|
if (current?.status === "released" && current.releasedAt && current.cleanupStatus === cleanupStatus)
|
|
completedCleanupDispatches++;
|
|
},
|
|
}, () => true).ready;
|
|
expect((await heartbeat.getRun(successorId))?.status).toBe("queued");
|
|
expect(execute).not.toHaveBeenCalled();
|
|
if (mode === "stop") await heartbeat.cancelRun(successorId, "Cancelled by a board operator", {
|
|
resultJson: { cancelledByActorType: "user", cancelledByUserId: ownerId },
|
|
});
|
|
if (mode === "reassign") {
|
|
const [replacement] = await db.insert(agents).values({ companyId, name: "New owner", adapterType: "process" }).returning();
|
|
await db.update(issues).set({ assigneeAgentId: replacement.id }).where(eq(issues.id, issueId));
|
|
}
|
|
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "released", releasedAt: new Date(),
|
|
cleanupStatus }).where(eq(environmentLeases.id, lease.id)),
|
|
{ companyId, runId: sourceId });
|
|
await vi.waitFor(() => expect(completedCleanupDispatches).toBeGreaterThan(0), { timeout: 10_000 });
|
|
if (mode === "continue") {
|
|
await heartbeat.drainActiveRunExecutions();
|
|
expect((await heartbeat.getRun(successorId))?.status).toBe("succeeded");
|
|
expect(execute).toHaveBeenCalledTimes(1);
|
|
expect(await readFile(path.join(cwd, "continued.txt"), "utf8")).toBe("Continued once after cleanup.\n");
|
|
const [done] = await db.select().from(issues).where(eq(issues.id, issueId));
|
|
expect(done).toMatchObject({ status: "done", executionRunId: null, checkoutRunId: null });
|
|
} else {
|
|
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull(), { timeout: 10_000 });
|
|
expect((await heartbeat.getRun(successorId))?.status).toBe(mode === "failed cleanup" ? "queued" : "cancelled");
|
|
expect(execute).not.toHaveBeenCalled();
|
|
}
|
|
expect((await heartbeat.getRun(sourceId))?.status).toBe("failed");
|
|
expect(await readFile(path.join(cwd, "saved.txt"), "utf8")).toBe("Preserved work before preparation failed.\n");
|
|
expect(errors).toEqual([]);
|
|
} finally {
|
|
await coordinator.stop();
|
|
await heartbeat.drainActiveRunExecutions();
|
|
await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId));
|
|
if (previousHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousHome;
|
|
home.cleanup();
|
|
}
|
|
}, 30_000,
|
|
);
|
|
|
|
it("retries a failed startup intent through timer failure and drain before verified cancellation", async () => {
|
|
const home = createIsolatedTestHome(), originalHome = process.env.PAPERCLIP_HOME;
|
|
process.env.PAPERCLIP_HOME = home.home;
|
|
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(), leaseId = randomUUID();
|
|
const contextId = randomUUID(), leaseOwner = randomUUID(), taskId = randomUUID(), deadlineAtMs = Date.now() - 1;
|
|
const originalSetTimeout = globalThis.setTimeout;
|
|
const retries: Array<{ timer: ReturnType<typeof setTimeout>; fire(): void }> = [];
|
|
const timeout = vi.spyOn(globalThis, "setTimeout").mockImplementation(((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
|
|
if (typeof delay === "number" && delay <= 30_000 && new Error().stack?.includes("scheduleNativeSessionResumeDispatch")) {
|
|
const timer = originalSetTimeout(() => {}, 3_600_000);
|
|
retries.push({ timer, fire() { clearTimeout(timer); callback(...args); } });
|
|
return timer;
|
|
}
|
|
return originalSetTimeout(callback, delay, ...args);
|
|
}) as typeof setTimeout);
|
|
let failSuppression = false, runId = "";
|
|
const runtimeEnv = new Proxy<Record<string, string | undefined>>({}, { get(_target, key) {
|
|
if (key === "PAPERCLIP_DATABASE_RESTORE_IN_PROGRESS" && failSuppression) {
|
|
failSuppression = false;
|
|
throw new Error("Scheduling dependency unavailable");
|
|
}
|
|
return undefined;
|
|
} });
|
|
const heartbeat = heartbeatService(db, { runtimeEnv });
|
|
try {
|
|
await db.insert(companies).values({ id: companyId, name: "Intent retry", issuePrefix: "CIR", defaultResponsibleUserId: "owner" });
|
|
await db.insert(authUsers).values({ id: "owner", name: "Fixture user", email: "owner@example.test", createdAt: new Date(), updatedAt: new Date() }).onConflictDoNothing();
|
|
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "owner", status: "active", membershipRole: "member" });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", role: "engineer", status: "idle", adapterType: "process",
|
|
adapterConfig: {}, permissions: {}, runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } } });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Recover startup", status: "todo", assigneeAgentId: agentId, responsibleUserId: "owner" });
|
|
const [environment] = await db.insert(environments).values({ name: "Controlled provider", driver: "plugin", config: {} }).returning();
|
|
await db.execute(sql`create table test_cleanup_intent_fault (enabled boolean not null)`);
|
|
await db.execute(sql`insert into test_cleanup_intent_fault values (true)`);
|
|
await db.execute(sql`create sequence test_cleanup_intent_attempts`);
|
|
await db.execute(sql.raw(`create function test_reject_cleanup_intent() returns trigger language plpgsql as $$ begin
|
|
if new.company_id = '${companyId}'::uuid and new.error_code = 'native_startup_cleanup_pending'
|
|
and exists (select 1 from test_cleanup_intent_fault where enabled) then
|
|
perform nextval('test_cleanup_intent_attempts'); raise exception 'Cleanup intent write unavailable';
|
|
end if; return new; end $$`));
|
|
await db.execute(sql`create trigger test_cleanup_intent_fault before update on heartbeat_runs for each row execute function test_reject_cleanup_intent()`);
|
|
execute.mockReset();
|
|
execute.mockImplementation(async (context: { runId: string }) => {
|
|
runId = context.runId;
|
|
const [run] = await db.update(heartbeatRuns).set({ runtimeMode: "native", runtimeModeResolvedAt: null }).where(eq(heartbeatRuns.id, runId)).returning();
|
|
await prepareNativeHeartbeatRun({ db, run, issue: { id: issueId, title: "Recover startup", description: null }, environmentLeaseId: leaseId });
|
|
await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex" } }).where(eq(agents.id, agentId));
|
|
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
|
|
await db.insert(nativeRunFinalizations).values({ runId, companyId, issueId, phase: "observed", attempt: 1,
|
|
controllerGeneration: 1, leaseOwner, leaseExpiresAt: new Date(Date.now() - 1_000) });
|
|
await db.insert(environmentLeases).values({ id: leaseId, companyId, environmentId: environment.id, heartbeatRunId: runId,
|
|
issueId, status: "active", provider: "plugin", providerLeaseId: taskId, leasePolicy: "reuse_by_environment", metadata: { runnerTaskLease: true } });
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: sql`coalesce(${heartbeatRuns.runnerProfileJson}, '{}'::jsonb) || ${JSON.stringify({
|
|
nativeStartupContext: { schema: "paperclip.native-startup-context.v1", companyId, agentId, runId,
|
|
contextId, deadlineAtMs, leaseId, leaseOwner, controllerGeneration: 1, attempt: 1 },
|
|
})}::jsonb` }).where(eq(heartbeatRuns.id, runId));
|
|
throw new NativeStartupCleanupPendingError(contextId, deadlineAtMs, leaseOwner, false, leaseId);
|
|
});
|
|
await heartbeat.invoke(agentId, "on_demand", { issueId }, "manual");
|
|
await heartbeat.drainActiveRunExecutions();
|
|
const owner = () => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)).then(rows => rows[0]!);
|
|
const lease = () => db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId)).then(rows => rows[0]!);
|
|
expect((await db.execute<{ last_value: string }>(sql`select last_value from test_cleanup_intent_attempts`))[0].last_value).toBe("2");
|
|
expect(await heartbeat.getRun(runId)).toMatchObject({ status: "running", errorCode: null });
|
|
expect((await owner()).leaseOwner).toBe(leaseOwner);
|
|
expect(await lease()).toMatchObject({ status: "active", cleanupStatus: null });
|
|
expect(readNativeStartupCleanup((await heartbeat.getRun(runId))!)).toBeNull();
|
|
const drainedRetries = retries.length;
|
|
expect(drainedRetries).toBeGreaterThan(0);
|
|
await heartbeat.resumeQueuedRunsAfterDrain();
|
|
expect(retries.length).toBeGreaterThan(drainedRetries);
|
|
const resumedRetries = retries.length;
|
|
failSuppression = true;
|
|
retries.at(-1)!.fire();
|
|
await vi.waitFor(() => expect(retries.length).toBeGreaterThan(resumedRetries));
|
|
expect((await owner()).leaseOwner).toBe(leaseOwner);
|
|
expect((await heartbeat.getRun(runId))?.status).toBe("running");
|
|
await db.execute(sql`update test_cleanup_intent_fault set enabled = false`);
|
|
retries.at(-1)!.fire();
|
|
await vi.waitFor(async () => expect(readNativeStartupCleanup((await heartbeat.getRun(runId))!)?.state).toBe("pending"));
|
|
await vi.waitFor(async () => expect((await heartbeat.listEvents(runId)).some(event => event.eventType === "native.recovery.transition")).toBe(true));
|
|
const current = await lease();
|
|
await environmentService(db).releaseLease(leaseId, "released", { cleanupStatus: "success",
|
|
remoteExecutionTermination: remoteTerminationReceipt(current, { providerLeaseId: taskId, state: "stopped" }) });
|
|
await heartbeat.cancelRun(runId, "Startup cleanup retry completed");
|
|
expect(await heartbeat.getRun(runId)).toMatchObject({ status: "cancelled" });
|
|
expect(readNativeStartupCleanup((await heartbeat.getRun(runId))!)?.state).toBe("complete");
|
|
expect(await owner()).toMatchObject({ leaseOwner: null, controlDeadlineAt: null });
|
|
expect(execute).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await heartbeat.drainActiveRunExecutions();
|
|
timeout.mockRestore();
|
|
for (const retry of retries) clearTimeout(retry.timer);
|
|
await db.execute(sql`drop trigger if exists test_cleanup_intent_fault on heartbeat_runs`);
|
|
await db.execute(sql`drop function if exists test_reject_cleanup_intent()`);
|
|
await db.execute(sql`drop table if exists test_cleanup_intent_fault`);
|
|
await db.execute(sql`drop sequence if exists test_cleanup_intent_attempts`);
|
|
if (originalHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = originalHome;
|
|
home.cleanup();
|
|
}
|
|
}, 30_000);
|
|
|
|
});
|