mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:01:23 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Contributors wait for PR CI before merge, and a flaky test forces a
re-run of the whole CI run
> - Four server tests failed on 2026-11-06 on commits that passed on a
later attempt
> - Two of them failed because of real slowness inside a fixed time
limit on contended 4-vCPU runners
> - This pull request removes that slowness from the two tests. It does
not raise their time limits
> - Of the other two tests, one is already fixed on master, and one is
not reproduced (see below)
> - The benefit is fewer whole-run re-runs
## Linked Issues or Issue Description
**What happened?**
These tests failed in PR CI and passed on a re-run of the same commit:
- `server/src/adapters/cloud-external-adapters.test.ts` › "Cloud image
adapter boundary › cold boots built-ins…" failed with `Test timed out in
15000ms` (3 times).
- `server/src/__tests__/retained-native-session-cleanup-work.test.ts` ›
"drains a backlog across discovery pages…" failed with `Test timed out
in 15000ms` (2 times).
**Expected behavior**
Both tests pass on a loaded CI runner.
**Steps to reproduce**
1. Run the test file in a 4-CPU container (`docker run --cpus=4
--memory=16g`) with 4 copies at the same time.
2. Before this change, the adapter case fails in 4 of 4 runs at 15,008
to 15,041 ms.
## What Changed
- `cloud-external-adapters.test.ts`: add a `beforeAll` warm-up with a
90-second limit.
- The warm-up imports `registry.js`, `plugin-loader.js` and
`adapter-plugin-store.js` with an empty temporary home and the managed
signal, so it reads and runs no persisted adapter package.
- Before this change, the first case paid the one-time transform of the
whole built-in adapter graph inside its own 15-second budget. In CI the
first case timed out at 15 seconds, and the next case took 1.1 seconds.
- Each case still gets fresh module state from the existing
`vi.resetModules()`. No assertions change.
- `retained-native-session-cleanup-work.test.ts`:
- `flush()` takes an optional poll `interval`, which defaults to 50 ms,
so other tests do not change.
- The backlog test uses 5 ms. The worker admits one cleanup pass per
1-second fake-time step, so 103 entries need 103 polls in a row.
- The next wake is scheduled before a pass releases its idle-work hold,
so a faster poll cannot see a false quiet state. No assertions change.
## Verification
| Test | Before | After |
| --- | --- | --- |
| Adapter cold boot, container, 4 copies | failed 4/4 (15,008–15,041 ms)
| passed 4/4 (537–756 ms per case; the warm-up takes about 23 s) |
| Adapter cold boot, host | failed 2/2 | passed 3/3 (0.6–1.6 s) |
| Backlog drain, container, 4 copies with load | 6.3–6.6 s | 2.0–2.1 s |
| Backlog drain, host, unloaded | 5.1 s | 1.1–1.3 s (5 runs, 29/29 pass)
|
- Final host run of both files with `--maxWorkers=2`: 33/33 pass.
- The backlog test did not reach the 15-second timeout in local runs.
The evidence for that fix is the measured poll-bound cost.
**Not changed in this PR:**
- `connection-management.integration.test.ts` (expected 3 to be 2):
- Cause: a GitHub webhook recovery worker from an earlier test fetched
through the suite-wide fetch spy 60 seconds later.
- Master already fixes this with commit 0b48bc7ac5 (#19541). The failed
jobs ran on bases without that commit.
- `idle-completed-history.test.ts` (runtime service never ready):
- Not reproduced in a 4-CPU container, even with heavy load. CPU load
alone does not explain a child process that stays silent for 10 seconds.
- A next step is to record spawn-to-listen timing in CI.
## Risks
- The adapter warm-up adds about 1 to 25 seconds to the file's
`beforeAll`, depending on load, but the test body becomes faster by the
same amount.
- If the adapter graph import becomes much slower, the 90-second warm-up
limit shows that as a clear hook timeout.
- Typecheck did not run locally. CI checks it. The edits are a
`beforeAll` import and a numeric default parameter.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use and
code execution. A Claude sub-agent found the causes and made the fix,
and the parent session reviewed it.
## 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
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
640 lines
31 KiB
TypeScript
640 lines
31 KiB
TypeScript
import { heartbeatService } from "../services/heartbeat.js";
|
|
import * as nativeExecutor from "../services/native-runtime/native-session-executor.js";
|
|
import { finalizeNativeRun } from "../services/native-runtime/native-run-finalizer.js";
|
|
import { notifyNativeFinalizationWork } from "../services/native-finalization-notifications.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import os from "node:os";
|
|
import { PROCESS_IDENTITY_RECORDED, releaseSettledNativeRunProcess } from "../services/native-local-process-stop.js";
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { agents, companies, completionContracts, createDb, environmentLeases, heartbeatRunEvents, heartbeatRuns, issues, nativeRunFinalizations,
|
|
nativeRunResults, statusDecisions, workAssessments, type Db } from "@paperclipai/db";
|
|
import { settleAbsentSandbox, settleStopOnlyCleanup } from "../services/sandbox-stop-and-retain.js";
|
|
import { settleNativeWorkspaceExportResume } from "../services/native-runtime/native-workspace-export-resume.js";
|
|
import type { SandboxAbsenceProof } from "../services/sandbox-absence-proof.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
import { notifyRetainedNativeSessionCleanup, releaseRetainedNativeSessionCleanup } from "../services/retained-native-session-cleanup-notifications.js";
|
|
import { retainedNativeSessionCleanupWork } from "../services/native-runtime/retained-native-session-cleanup-work.js";
|
|
import { reconcileRetainedNativeSessionCleanups, runRetainedNativeSessionCleanup, type NativeCleanupOutcome } from "../services/native-runtime/retained-native-session-cleanup.js";
|
|
import { reconcileRetainedNativeSessionCleanup } from "../services/native-runtime/native-session-executor.js";
|
|
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>(done => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("retained native cleanup scheduled lifecycle", () => {
|
|
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | undefined;
|
|
let db: ReturnType<typeof createDb>;
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
|
let worker: ReturnType<ReturnType<typeof retainedNativeSessionCleanupWork>["register"]> | undefined;
|
|
const companyId = randomUUID(), agentId = randomUUID();
|
|
const closeError = "provider_transport_failed: runner did not durably suspend before checkpoint";
|
|
let admitted = true;
|
|
const errors: unknown[] = [];
|
|
async function settle(input: { runId: string; companyId: string }): Promise<NativeCleanupOutcome> {
|
|
await db.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null,
|
|
recoveryHistory: [{ kind: "native_cleanup_maintenance", phase: "settled" }] })
|
|
.where(eq(nativeRunFinalizations.runId, input.runId));
|
|
return { runId: input.runId, status: "settled" };
|
|
}
|
|
const cleanup = vi.fn(settle);
|
|
beforeAll(async () => {
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_RETAINED_CLEANUP_TEST_DATABASE_URL");
|
|
if (!external) temporary = await startEmbeddedPostgresTestDatabase("paperclip-retained-cleanup-");
|
|
db = createDb(external ?? temporary!.connectionString);
|
|
await db.insert(companies).values({ id: companyId, name: "Retained cleanup lifecycle", issuePrefix: `RC${randomUUID().slice(0, 8)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Cleanup agent", adapterType: "codex_local" });
|
|
}, 30_000);
|
|
beforeEach(() => {
|
|
admitted = true; errors.length = 0; cleanup.mockReset().mockImplementation(settle);
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
|
});
|
|
afterEach(async () => {
|
|
worker?.stop(); await coordinator?.stop(); worker = undefined; coordinator = undefined;
|
|
vi.useRealTimers(); vi.restoreAllMocks();
|
|
await db.delete(nativeRunFinalizations).where(eq(nativeRunFinalizations.companyId, companyId));
|
|
expect(errors).toEqual([]);
|
|
});
|
|
afterAll(async () => { await db?.$client.end({ timeout: 1 }); await temporary?.cleanup(); });
|
|
async function start() {
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
|
onError: error => errors.push(error) });
|
|
worker = retainedNativeSessionCleanupWork(db, cleanup).register(coordinator, () => admitted);
|
|
await worker.ready;
|
|
}
|
|
// `interval` is the real-time gap between polls. Each poll advances fake time
|
|
// by one second, which admits at most one cleanup pass (batch size 1, one
|
|
// second minimum pass interval), so a long backlog drains one entry per poll.
|
|
async function flush(interval = 50) {
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
expect(coordinator!.nextWakeAt()).toBeNull();
|
|
}, { timeout: 15_000, interval });
|
|
}
|
|
async function quiet(ms = 120_000) {
|
|
const previous = db.$client.options.debug;
|
|
const queries: string[] = [];
|
|
db.$client.options.debug = (_id, query) => { queries.push(query); };
|
|
try { await vi.advanceTimersByTimeAsync(ms); await delay(10); expect(queries).toEqual([]); }
|
|
finally { db.$client.options.debug = previous; }
|
|
}
|
|
async function commitCandidate() {
|
|
const runId = randomUUID();
|
|
return db.transaction(async tx => {
|
|
await notifyNativeFinalizationWork(tx, { companyId, runId });
|
|
return candidate({ runId }, tx as unknown as Db);
|
|
});
|
|
}
|
|
async function candidate(
|
|
options: {
|
|
runId?: string;
|
|
owner?: { companyId: string; agentId: string };
|
|
run?: Partial<typeof heartbeatRuns.$inferInsert>;
|
|
coordinator?: Partial<typeof nativeRunFinalizations.$inferInsert>;
|
|
schemaStatus?: string;
|
|
issueId?: string;
|
|
revision?: number;
|
|
} = {},
|
|
writer: Db = db,
|
|
) {
|
|
const owner = options.owner ?? { companyId, agentId };
|
|
const runId = options.runId ?? randomUUID();
|
|
const issueId = options.issueId ?? randomUUID();
|
|
const contractId = randomUUID();
|
|
if (!options.issueId) await writer
|
|
.insert(issues)
|
|
.values({
|
|
id: issueId,
|
|
companyId: owner.companyId,
|
|
title: "Retained session",
|
|
assigneeAgentId: owner.agentId,
|
|
});
|
|
await writer.insert(completionContracts).values({
|
|
id: contractId,
|
|
companyId: owner.companyId,
|
|
issueId,
|
|
revision: options.revision ?? 1,
|
|
schemaVersion: "paperclip.completion-contract.v1",
|
|
policyVersion: "phase6-v3",
|
|
risk: "standard",
|
|
completionAuthority: "server_arbiter",
|
|
incompleteCriteriaPolicy: "preserve_non_terminal",
|
|
contractJson: {},
|
|
canonicalSha256: runId,
|
|
createdByActorType: "system",
|
|
createdByActorId: "test",
|
|
});
|
|
await writer.insert(heartbeatRuns).values({
|
|
id: runId,
|
|
companyId: owner.companyId,
|
|
agentId: owner.agentId,
|
|
runtimeMode: "native",
|
|
status: "succeeded",
|
|
nativeIssueId: issueId,
|
|
nativeSessionId: runId,
|
|
nativePhase: "terminal_failure",
|
|
completionContractId: contractId,
|
|
completionContractSha256: runId,
|
|
finishedAt: new Date(),
|
|
errorCode: "adapter_failed",
|
|
error: closeError,
|
|
...options.run,
|
|
});
|
|
const [result] = await writer
|
|
.insert(nativeRunResults)
|
|
.values({
|
|
companyId: owner.companyId,
|
|
issueId,
|
|
runId,
|
|
completionContractId: contractId,
|
|
serverFingerprint: runId,
|
|
canonicalSha256: runId,
|
|
schemaStatus: options.schemaStatus ?? "accepted",
|
|
resultJson: {},
|
|
})
|
|
.returning();
|
|
const [assessment] = await writer
|
|
.insert(workAssessments)
|
|
.values({
|
|
companyId: owner.companyId,
|
|
issueId,
|
|
runId,
|
|
contractId,
|
|
resultId: result!.id,
|
|
triggerKind: "native_result",
|
|
triggerActorCompanyId: owner.companyId,
|
|
priorIssueStatus: "in_progress",
|
|
priorStatusVersion: 0,
|
|
policyVersion: "phase6-v3",
|
|
assessmentJson: {},
|
|
inputDigest: runId,
|
|
})
|
|
.returning();
|
|
const [decision] = await writer
|
|
.insert(statusDecisions)
|
|
.values({
|
|
companyId: owner.companyId,
|
|
issueId,
|
|
runId,
|
|
assessmentId: assessment!.id,
|
|
decisionVersion: options.revision ?? 1,
|
|
policyVersion: "phase6-v3",
|
|
fromStatus: "in_progress",
|
|
toStatus: "in_review",
|
|
reasonCode: "prior_status_terminal_preserved",
|
|
decisionJson: {},
|
|
decisionDigest: runId,
|
|
})
|
|
.returning();
|
|
await writer.insert(nativeRunFinalizations).values({
|
|
companyId: owner.companyId,
|
|
issueId,
|
|
runId,
|
|
phase: "committed",
|
|
resultId: result!.id,
|
|
assessmentId: assessment!.id,
|
|
decisionId: decision!.id,
|
|
...options.coordinator,
|
|
});
|
|
return runId;
|
|
}
|
|
|
|
|
|
it("becomes SQL-quiet with no work, then processes the first committed candidate", async () => {
|
|
await start(); await flush(); await quiet();
|
|
const runId = await commitCandidate();
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
await quiet();
|
|
});
|
|
|
|
it("leaves retained unprovable history quiet through the real physical cleanup boundary", async () => {
|
|
await candidate();
|
|
cleanup.mockImplementation(input => reconcileRetainedNativeSessionCleanup(db, input));
|
|
await start(); await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
await quiet();
|
|
});
|
|
|
|
it("keeps dormant retained cleanup quiet on unscoped finalization hints and resumes from local release", async () => {
|
|
const runId = await candidate();
|
|
cleanup.mockResolvedValue({ runId, status: "deferred", waitFor: "session_release" });
|
|
await start();
|
|
await flush();
|
|
await quiet();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(await retainedNativeSessionCleanupWork(db, cleanup).load({ companyId, runId })).toHaveLength(1);
|
|
const debug = db.$client.options.debug;
|
|
const queries: string[] = [];
|
|
db.$client.options.debug = (_connection, query) => {
|
|
if (query.startsWith("select") && query.includes("native_run_finalizations")) queries.push(query);
|
|
};
|
|
try {
|
|
for (let i = 0; i < 3; i++) {
|
|
await db.transaction(tx => notifyNativeFinalizationWork(tx, { companyId }));
|
|
await flush();
|
|
}
|
|
expect(queries).toEqual([]);
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
} finally { db.$client.options.debug = debug; }
|
|
cleanup.mockImplementation(settle);
|
|
releaseRetainedNativeSessionCleanup(db, { companyId, runId });
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await quiet();
|
|
});
|
|
|
|
it.each(["session_release", "environment_release", "maintenance_settlement"] as const)(
|
|
"waits quietly for %s and then reactivates only the affected run", async waitFor => {
|
|
const runId = await candidate();
|
|
cleanup.mockResolvedValueOnce({ runId, status: "deferred", waitFor });
|
|
await start(); await flush(); await quiet();
|
|
releaseRetainedNativeSessionCleanup(db, { companyId, runId });
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await quiet();
|
|
},
|
|
);
|
|
|
|
it.each(["single", "run"] as const)("reactivates after the real %s lease release commits, but not rollback", async method => {
|
|
const runId = await candidate();
|
|
const [lease] = await db.insert(environmentLeases).values({ companyId, heartbeatRunId: runId,
|
|
status: "active", provider: "local" }).returning();
|
|
cleanup.mockImplementation(async input => {
|
|
const [current] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, lease!.id));
|
|
return current!.status === "active"
|
|
? { runId: input.runId, status: "deferred", waitFor: "environment_release" }
|
|
: settle(input);
|
|
});
|
|
const release = async (writer: Db) => {
|
|
const service = environmentService(writer);
|
|
if (method === "single") await service.releaseLease(lease!.id);
|
|
else await service.releaseLeasesForRun(runId);
|
|
};
|
|
await start(); await flush(); await quiet();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
await expect(db.transaction(async tx => {
|
|
await release(tx as unknown as Db);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
throw new Error("rollback lease release");
|
|
})).rejects.toThrow("rollback lease release");
|
|
await flush(); await quiet();
|
|
const afterRollback = cleanup.mock.calls.length;
|
|
await db.transaction(async tx => {
|
|
await release(tx as unknown as Db);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(afterRollback);
|
|
});
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(afterRollback + 1);
|
|
expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0]!.recoveryHistory)
|
|
.toEqual([{ kind: "native_cleanup_maintenance", phase: "settled" }]);
|
|
await quiet();
|
|
});
|
|
|
|
it("keeps pending cleanup quiet through metadata updates and stale release attempts", async () => {
|
|
const runId = await candidate(), attemptId = randomUUID();
|
|
const [lease] = await db.insert(environmentLeases).values({ companyId, heartbeatRunId: runId,
|
|
status: "pending_cleanup", provider: "local", metadata: { pendingCleanupAttemptId: attemptId } }).returning();
|
|
cleanup.mockResolvedValueOnce({ runId, status: "deferred", waitFor: "environment_release" });
|
|
await start(); await flush();
|
|
const service = environmentService(db);
|
|
await service.updateLeaseMetadata(lease!.id, { pendingCleanupAttemptId: attemptId, pendingCleanupInFlight: true });
|
|
await service.releaseLease(lease!.id, "pending_cleanup", { expectedPendingCleanupAttemptId: attemptId, cleanupStatus: "failed" });
|
|
expect(await service.releaseLease(lease!.id, "released", { expectedPendingCleanupAttemptId: randomUUID(), cleanupStatus: "success" })).toBeNull();
|
|
await flush(); await quiet();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(await service.releaseLease(lease!.id, "released", { expectedPendingCleanupAttemptId: attemptId, cleanupStatus: "success" })).not.toBeNull();
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await quiet();
|
|
});
|
|
|
|
it.each(["stop-only", "export-resume", "absent"] as const)("reactivates after real %s settlement with outer transaction ownership", async method => {
|
|
const ownerToken = randomUUID();
|
|
const runId = await candidate({ run: { runnerProfileJson: { nativeWorkspaceFinalizationOwner: { token: ownerToken } } } });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
const leaseId = randomUUID(), providerLeaseId = randomUUID(), attemptId = randomUUID(), pluginId = randomUUID();
|
|
const binding = { companyId, runId, leaseId, provider: "daytona" as const, providerLeaseId };
|
|
const marker = { ...binding, requestId: attemptId, pluginId,
|
|
schema: method === "export-resume" ? "paperclip.workspace-export-resume.v2" : "paperclip.sandbox-stop-and-retain.v1",
|
|
resultId: randomUUID() };
|
|
const [lease] = await db.insert(environmentLeases).values({ id: leaseId, companyId, heartbeatRunId: runId,
|
|
issueId: run!.nativeIssueId, provider: "daytona", providerLeaseId, status: "pending_cleanup", cleanupStatus: "failed",
|
|
metadata: { pluginId, pendingCleanupAttemptId: attemptId,
|
|
[method === "export-resume" ? "nativeWorkspaceExportResume" : "sandboxStopAndRetain"]: marker } }).returning();
|
|
const proof: SandboxAbsenceProof = { ...binding, issueId: run!.nativeIssueId!, ownerToken, v: 1,
|
|
iss: "paperclip-cloud", aud: "paperclip-sandbox-absence/v1", sub: "fixture", absent: true,
|
|
iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 };
|
|
cleanup.mockImplementation(async input => {
|
|
const [current] = await db.select().from(environmentLeases).where(eq(environmentLeases.id, leaseId));
|
|
return current!.status === "pending_cleanup"
|
|
? { runId: input.runId, status: "deferred", waitFor: "environment_release" }
|
|
: settle(input);
|
|
});
|
|
const release = async (writer: Db) => {
|
|
const options = { attemptId, receipt: { providerLeaseId, state: "stopped" } };
|
|
if (method === "absent") expect(await settleAbsentSandbox(writer, lease!, { proof, receipt: "verified-upstream" })).toBe(true);
|
|
else if (method === "export-resume") expect(await settleNativeWorkspaceExportResume(writer, lease!, options)).not.toBeNull();
|
|
else expect(await settleStopOnlyCleanup(writer, lease!, options)).not.toBeNull();
|
|
};
|
|
await start(); await flush(); await quiet();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
await expect(db.transaction(async tx => {
|
|
await release(tx as unknown as Db);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
throw new Error("rollback settlement");
|
|
})).rejects.toThrow("rollback settlement");
|
|
await flush(); await quiet();
|
|
const afterRollback = cleanup.mock.calls.length;
|
|
await db.transaction(async tx => {
|
|
await release(tx as unknown as Db);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(afterRollback);
|
|
});
|
|
await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(afterRollback + 1);
|
|
await quiet();
|
|
});
|
|
|
|
it("reactivates immediately after verified process release commits without waiting for its probe", async () => {
|
|
const pid = 2_000_000_000, startedAt = new Date();
|
|
const runId = await candidate({ run: { status: "failed", processPid: pid, processGroupId: pid,
|
|
processStartedAt: startedAt, runnerProfileJson: { sessionCheckpoint: {} } } });
|
|
await db.insert(heartbeatRunEvents).values({ companyId, runId, agentId, seq: 0,
|
|
eventType: PROCESS_IDENTITY_RECORDED, payload: { targetKind: "local", hostname: os.hostname(),
|
|
processPid: pid, processGroupId: pid, processStartedAt: startedAt.toISOString() } });
|
|
await db.update(heartbeatRuns).set({ nextEventSeq: 1 }).where(eq(heartbeatRuns.id, runId));
|
|
const originalKill = process.kill.bind(process);
|
|
vi.spyOn(process, "kill").mockImplementation((id, signal) => {
|
|
if ((id === pid || id === -pid) && signal === 0) throw Object.assign(new Error("gone"), { code: "ESRCH" });
|
|
return originalKill(id, signal);
|
|
});
|
|
cleanup.mockImplementation(async input => {
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.runId));
|
|
return run!.processPid !== null
|
|
? { runId: input.runId, status: "deferred", waitFor: "process_exit" }
|
|
: settle(input);
|
|
});
|
|
await start();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
await expect(db.transaction(async tx => {
|
|
expect(await releaseSettledNativeRunProcess(tx as unknown as Db, runId)).toBe("released");
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
throw new Error("rollback process release");
|
|
})).rejects.toThrow("rollback process release");
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
});
|
|
expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)))[0]!.processPid).toBe(pid);
|
|
const afterRollback = cleanup.mock.calls.length;
|
|
await db.transaction(async tx => {
|
|
expect(await releaseSettledNativeRunProcess(tx as unknown as Db, runId)).toBe("released");
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).toHaveBeenCalledTimes(afterRollback);
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.waitFor(() => expect(cleanup).toHaveBeenCalledTimes(afterRollback + 1));
|
|
await flush(); await quiet();
|
|
});
|
|
|
|
it("reactivates when the real finalizer projects an already committed result to a terminal run", async () => {
|
|
const runId = await candidate({ run: { status: "running", finishedAt: null } });
|
|
await db.update(nativeRunResults).set({ resultJson: { terminal: { runTerminalState: "succeeded" } } })
|
|
.where(eq(nativeRunResults.runId, runId));
|
|
await start(); await flush(); await quiet();
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
const finalize = (writer: Db) => finalizeNativeRun({ db: writer, runId, workspaceFinalizeStatus: "succeeded",
|
|
projectRunStatus: true, skipNativeResponsePresentation: true, skipNativeReviewResponse: true });
|
|
await expect(db.transaction(async tx => {
|
|
await finalize(tx as unknown as Db);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
throw new Error("rollback final projection");
|
|
})).rejects.toThrow("rollback final projection");
|
|
await flush(); await quiet();
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
await finalize(db); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId));
|
|
expect(run!.status).toBe("succeeded");
|
|
expect(run!.error).toBeNull();
|
|
expect(run!.resultJson?.recoveredExecutionFailure).toMatchObject({ errorCode: "adapter_failed", error: closeError });
|
|
await quiet();
|
|
});
|
|
|
|
it("reconstructs a lease deadline across restart and does no SQL before it", async () => {
|
|
const leaseExpiresAt = new Date(Date.now() + 60_000);
|
|
const runId = await candidate({ coordinator: { leaseOwner: "other-owner", leaseExpiresAt } });
|
|
await start();
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
expect(coordinator!.nextWakeAt()).toBe(leaseExpiresAt.getTime());
|
|
await quiet(10_000);
|
|
worker!.stop(); await coordinator!.stop();
|
|
await start();
|
|
expect(coordinator!.nextWakeAt()).toBe(leaseExpiresAt.getTime());
|
|
await quiet(10_000);
|
|
await vi.advanceTimersByTimeAsync(40_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
await quiet();
|
|
});
|
|
|
|
it("retains only the concrete process-exit probe and disarms it after settlement", async () => {
|
|
const runId = await candidate();
|
|
cleanup.mockResolvedValueOnce({ runId, status: "deferred", waitFor: "process_exit" });
|
|
await start();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
await quiet(20_000);
|
|
await vi.advanceTimersByTimeAsync(10_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await quiet();
|
|
});
|
|
|
|
it("does not run before outer commit and clears rolled-back intent", async () => {
|
|
await start(); await flush();
|
|
const entered = deferred(), release = deferred();
|
|
const transaction = db.transaction(async outer => {
|
|
await outer.transaction(async tx => {
|
|
await notifyRetainedNativeSessionCleanup(tx, { companyId });
|
|
await candidate({}, tx as unknown as Db);
|
|
});
|
|
entered.resolve(); await release.promise;
|
|
throw new Error("rollback fixture");
|
|
});
|
|
const rolledBack = expect(transaction).rejects.toThrow("rollback fixture");
|
|
await entered.promise;
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
release.resolve(); await rolledBack; await flush(); await quiet();
|
|
const runId = await commitCandidate(); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
await quiet();
|
|
});
|
|
|
|
it("retains the first write during drain without passive SQL until resume", async () => {
|
|
await start(); await flush(); admitted = false;
|
|
const runId = await commitCandidate();
|
|
await vi.advanceTimersByTimeAsync(1000); await delay(10);
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
await quiet();
|
|
admitted = true; await vi.advanceTimersByTimeAsync(30_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
await quiet();
|
|
});
|
|
|
|
it("retains an operational failure across drain and retries after resume", async () => {
|
|
await candidate(); cleanup.mockRejectedValueOnce(new Error("connection lost"));
|
|
await start(); expect(errors).toHaveLength(1); errors.length = 0;
|
|
admitted = false; await quiet();
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
admitted = true; await vi.advanceTimersByTimeAsync(30_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2);
|
|
await quiet();
|
|
});
|
|
|
|
it("keeps a committed wake arriving during cleanup and never overlaps worker dispatch", async () => {
|
|
await candidate();
|
|
const entered = deferred(), release = deferred();
|
|
let active = 0, maximum = 0;
|
|
cleanup.mockImplementation(async input => {
|
|
maximum = Math.max(maximum, ++active);
|
|
try { entered.resolve(); await release.promise; return await settle(input); }
|
|
finally { active--; }
|
|
});
|
|
const starting = start(); await entered.promise;
|
|
await commitCandidate(); release.resolve(); await starting; await flush();
|
|
expect(cleanup).toHaveBeenCalledTimes(2); expect(maximum).toBe(1);
|
|
await quiet();
|
|
});
|
|
|
|
it("joins the original manual startup operation instead of performing cleanup twice", async () => {
|
|
await candidate();
|
|
const entered = deferred(), release = deferred();
|
|
cleanup.mockImplementation(async input => { entered.resolve(); await release.promise; return settle(input); });
|
|
const manual = reconcileRetainedNativeSessionCleanups(db, { cleanup });
|
|
await entered.promise;
|
|
const work = retainedNativeSessionCleanupWork(db, cleanup);
|
|
const [entry] = await work.load();
|
|
const joined = work.check(entry);
|
|
try { expect(cleanup).toHaveBeenCalledTimes(1); }
|
|
finally { release.resolve(); }
|
|
await expect(joined).resolves.toBeNull(); await manual;
|
|
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("serializes different cleanup inspections sharing a database lane", async () => {
|
|
const first = await candidate(), second = await candidate();
|
|
const entered = deferred(), release = deferred();
|
|
cleanup.mockImplementationOnce(async input => { entered.resolve(); await release.promise; return settle(input); });
|
|
const active = runRetainedNativeSessionCleanup(db, { companyId, runId: first }, cleanup);
|
|
await entered.promise;
|
|
const queued = runRetainedNativeSessionCleanup(db, { companyId, runId: second }, cleanup);
|
|
try { await delay(10); expect(cleanup).toHaveBeenCalledTimes(1); }
|
|
finally { release.resolve(); }
|
|
await Promise.all([active, queued]);
|
|
expect(cleanup.mock.calls.map(([input]) => input.runId)).toEqual([first, second]);
|
|
});
|
|
|
|
it("joins scheduled physical cleanup through the assembled heartbeat shutdown owner", async () => {
|
|
const runId = await candidate();
|
|
const entered = deferred(), release = deferred();
|
|
const physical = vi.spyOn(nativeExecutor, "reconcileRetainedNativeSessionCleanup").mockImplementation(async (_db, input) => {
|
|
entered.resolve(); await release.promise; return settle(input);
|
|
});
|
|
const heartbeat = heartbeatService(db);
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => errors.push(error) });
|
|
worker = heartbeat.registerRetainedNativeSessionCleanupWork(coordinator, () => true);
|
|
await entered.promise;
|
|
let drained = false;
|
|
const draining = heartbeat.drainActiveRunExecutions().then(() => { drained = true; });
|
|
try {
|
|
await delay(10);
|
|
expect(drained).toBe(false);
|
|
expect(physical).toHaveBeenCalledExactlyOnceWith(db, { companyId, runId });
|
|
} finally { release.resolve(); }
|
|
await worker.ready; await draining; await flush();
|
|
expect(drained).toBe(true); await quiet();
|
|
});
|
|
|
|
it("waits on the original in-flight cleanup when shutdown begins", async () => {
|
|
await candidate();
|
|
const entered = deferred(), release = deferred();
|
|
cleanup.mockImplementation(async input => { entered.resolve(); await release.promise; return settle(input); });
|
|
const starting = start(); await entered.promise;
|
|
let stopped = false;
|
|
worker!.stop(); const stopping = coordinator!.stop().then(() => { stopped = true; });
|
|
try { await delay(10); expect(stopped).toBe(false); }
|
|
finally { release.resolve(); }
|
|
await Promise.all([starting, stopping]);
|
|
expect(stopped).toBe(true); expect(cleanup).toHaveBeenCalledTimes(1);
|
|
releaseRetainedNativeSessionCleanup(db, { companyId });
|
|
await quiet();
|
|
});
|
|
|
|
it("replaces an old lease alarm when a committed change releases it early", async () => {
|
|
const runId = await candidate({ coordinator: { leaseOwner: "old-owner", leaseExpiresAt: new Date(Date.now() + 60_000) } });
|
|
await start(); expect(cleanup).not.toHaveBeenCalled();
|
|
await db.transaction(async tx => {
|
|
await notifyNativeFinalizationWork(tx, { companyId, runId });
|
|
await tx.update(nativeRunFinalizations).set({ leaseOwner: null, leaseExpiresAt: null }).where(eq(nativeRunFinalizations.runId, runId));
|
|
});
|
|
await flush(); expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId });
|
|
await quiet();
|
|
});
|
|
|
|
it("cancels a saved alarm when a committed owner marks the candidate operator-required", async () => {
|
|
const runId = await candidate({ coordinator: { leaseOwner: "old-owner", leaseExpiresAt: new Date(Date.now() + 60_000) } });
|
|
await start();
|
|
await db.transaction(async tx => {
|
|
await notifyNativeFinalizationWork(tx, { companyId, runId });
|
|
await tx.update(nativeRunFinalizations).set({ recoveryHistory: [{ kind: "native_cleanup_source_archive", phase: "operator_required" }] })
|
|
.where(eq(nativeRunFinalizations.runId, runId));
|
|
});
|
|
await flush(); expect(cleanup).not.toHaveBeenCalled(); await quiet();
|
|
});
|
|
|
|
it("retains startup discovery during drain and reconstructs only after admission resumes", async () => {
|
|
const runId = await candidate(); admitted = false;
|
|
await start(); await quiet(); expect(cleanup).not.toHaveBeenCalled();
|
|
admitted = true; await vi.advanceTimersByTimeAsync(30_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId }); await quiet();
|
|
});
|
|
|
|
it("drains a backlog across discovery pages and becomes quiet with the completed history retained", async () => {
|
|
const runIds: string[] = [];
|
|
for (let index = 0; index < 103; index++) runIds.push(await candidate());
|
|
// 103 serial passes at the default 50ms poll spend ~4.7s of a ~5s local run
|
|
// waiting between polls, and contended CI runners pushed it past 15s. A 5ms
|
|
// poll drains the same passes in under a second; the wake schedule is set
|
|
// before a pass releases its idle-work hold, so polling faster cannot
|
|
// observe a false quiet state.
|
|
await start(); await flush(5);
|
|
expect(cleanup.mock.calls.map(([input]) => input.runId).sort()).toEqual(runIds.sort());
|
|
expect(await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.companyId, companyId))).toHaveLength(103);
|
|
await quiet();
|
|
});
|
|
|
|
it("retries failed startup discovery without treating an unavailable database as empty", async () => {
|
|
const runId = await candidate();
|
|
vi.spyOn(db, "select").mockImplementationOnce(() => { throw new Error("discovery connection lost"); });
|
|
await start(); expect(errors).toHaveLength(1); errors.length = 0;
|
|
expect(cleanup).not.toHaveBeenCalled();
|
|
await quiet(20_000); await vi.advanceTimersByTimeAsync(10_000); await flush();
|
|
expect(cleanup).toHaveBeenCalledExactlyOnceWith({ companyId, runId }); await quiet();
|
|
});
|
|
});
|