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 > - The server has about 1,100 test and helper files in `server/src/__tests__` > - `server/tsconfig.json` excludes that directory, Vitest does not check types, and no script runs the tsconfig in that directory > - So a test can call a function with the wrong arguments and still pass, and no tool reports it > - This gap hid 2,168 real type errors, and some of them hid tests that did not test what they claim > - This pull request fixes all of these errors and adds the directory to the server layers from paperclipai/paperclip#18737 > - The benefit is that a type error in a server test now fails the typecheck, with no baseline and no backlog ## Linked Issues or Issue Description Refs paperclipai/paperclip#18737, which is merged and added the server layers. Refs paperclipai/paperclip#18738, which is merged and checks the test projects outside `pnpm -r`, so it is not a duplicate. Refs paperclipai/paperclip#18137, which records that this directory has no check. **What happened?** No tool checked the types of 1,074 of the 1,101 files in `server/src/__tests__`. I searched `package.json`, the workflows, and all tsconfig files. 27 files are in the program of another tsconfig, and these 27 have 0 errors. 11 of the 27 are in `helpers/maintenance/tsconfig.json`. No script ran that project when I measured this. paperclipai/paperclip#18738 now runs it. **Expected behavior** The server typecheck checks these files, and a type error fails the check. **Steps to reproduce** 1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json --noEmit`. It reports 6,536 errors and needs 12.3 GiB. 2. Add a type error to any file in `server/src/__tests__`. 3. Run `pnpm --filter @paperclipai/server typecheck`. It passes. **Paperclip version or commit** I measured the error breakdown below on commit `8dfd632452`, with TypeScript 7.0.2. The branch now includes master up to paperclipai/paperclip#18865. **Deployment mode** Not applicable. This issue applies to development and CI. No production source file in the server changes. ### Error breakdown Each row adds one change to the row above. | Step | Total errors | Change | Cause | | --- | --- | --- | --- | | Config as committed | 6,536 | | | | Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request types were missing. | | Set `rootDir` to the repository root | 2,436 | -2,011 | Config. | | Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest accepts `.ts` imports. | | Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has `Promise.withResolvers`. | | Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8 `TS7016` errors and shows new real errors. | | Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing. | | Count only files in `server/src` | 2,058 | -84 | Other packages: 53 Daytona, 29 Runner test, 2 UI. | So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058 are real type errors in 314 files. The 29 Runner test errors are also real, which gives 2,087 errors. Tests that master added after this measurement had 81 more errors, so this pull request fixes 2,168 errors. This table classifies the first 2,058 errors. | Kind of real error | Errors | | --- | --- | | Wrong argument or assignment type | 1,023 | | Stale or partial fixture objects | 525 | | Property that the type does not have | 317 | | Missing narrowing of null or unknown | 101 | | Implicit any | 60 | | Missing name or module | 16 | | Other | 16 | ### What the type errors hid Most errors were stale fixtures and loose mocks. These errors hid real test problems, and this pull request fixes them: - **A test that never ran.** `instrumentation.test.ts` required `sdk-trace-base`, which is not installed. So 3 span tests were skipped. They now run and pass. - **Assertions that could not fail.** - `issue-thread-interactions-service` used a company ID as an issue ID. - `status-card-evaluation-work` compared `[]` with `[]` in a rollback check. - `slack-conversation-lifecycle` read `item.issueId`, which does not exist. - `email-channels` used a permission key that does not exist. - `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is not exported. The value was undefined. - **Postgres processes that did not stop.** 11 test files called `stop` on the test database helper. That method does not exist, and the helper method is `cleanup`. - **Arguments that the service ignored.** - `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft, 1Password, and Railway tests. - 108 calls passed `"owner-user"` to a parameter that expects an options object. - 9 chat wizard fixtures passed `managementContext` as `source`, so they ran `existing_app` and not `manifest`. - 2 tests passed a `{ userId }` actor that the function ignores. - Inserts into `environments` passed a `companyId` column that the table does not have. - **Problems in tests that master added during this work.** - `sandbox-creation-operations` read `database.connectionString` when an external test database was set. In that mode `database` is undefined, so the fixture would stop with a TypeError. It now uses the external URL. - `idle-completed-history` set a Dot operation to `dispatched`. That is a Muse status, and the Dot type does not have it. The fixture now uses `admitted`, which is a real in-flight Dot status. ### Possible product problems, not changed These may be real problems in production code or types. I did not change them, because this pull request does not change production behavior: - `decisions-service` `service.create` does not validate the lenient `cancel_issue_tree` input. - `createConnection` ignores `input.connectionPurpose`. - The `createConnection` route schema default stores `{}` for `transportConfig`. - `prepareManagedAiRuntime` accepted the invalid value `mode: "connection"`. - `createBinding` accepts invalid `projectionClass` strings outside class 3. - `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by spread. - `routineVariableSchema.label` is optional, but `RoutineVariable.label` is required. - `ToolRunContext.projectId` may be too strict. - `withdrawInteraction` reads `issue.status`, which its type omits. - `issueService.list` and `reconcileNativeFinalizations` omit fields that they return at runtime. - `createGitHubExternalObjectProvider` handles `tokenProvider: null`, but its type forbids this value. ## What Changed - Fixed the 2,168 type errors. The diff changes 322 test and helper files. - Corrected `server/src/__tests__/tsconfig.json`. Editors and the typecheck now read the same options and report the same errors. - Added three `test-tree` layers to `server/scripts/typecheck-layers.mjs`. They read the server layers as declarations, and any error fails them. A hash of the path selects the shard, so a new file does not move other files. - Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests reach Daytona plugin source, and the root install does not have `@daytonaio/sdk`. - Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use `.js` extensions, because server tests import this file. - Made `declarationsConfig` and `--declarations-only` skip the `test-tree` layers, because these layers emit nothing. Other projects read only the source layers. Added a test for this. - Updated `doc/DEVELOPING.md`. ## Verification Run these commands: ```sh pnpm --filter @paperclipai/server typecheck pnpm exec vitest run server/scripts/typecheck-layers.test.mjs ``` Results on macOS (arm64), Node 24, 16 cores. Each check ran after the merge of paperclipai/paperclip#18738 unless the row says otherwise: | Check | Commit | Result | | --- | --- | --- | | Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers | `6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB | | `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e` | Exit 0, 0 errors, 42 seconds, peak 3.25 GiB | | The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0 errors | | `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` | `01db5634e4` | 90 passed. These include real compiler runs for a pass, a type error, and an invalid tsconfig. | | `tsconfig.declarations.json` after a full server typecheck | `0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No generated config names a `tsconfig.layers.json` file. | | Owned runtime test in `idle-completed-history` | `e8f2f448ac` | Passed, with the fix that master later replaced | | `idle-incoming-wake`, `plugin-idle-manager`, and the changed `chat-channels` test | `6e45a08573` | 30 passed | | Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed | | The 19 server test files that changed since `eab4dcc1f4`, and `typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The 2 failures are in `chat-channels` and are in the list of 7 below. | The peak is the largest single process, from `/usr/bin/time -l`. Peaks for the server typecheck were between 3.42 GiB and 3.56 GiB across 8 runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2 runs. I did not measure a cgroup. **Runtime check of the fixes.** Types do not prove that a test still works. So I ran all 309 changed server test files, 11,929 tests, before the rebase onto paperclipai/paperclip#18737: | Result | Tests | Detail | | --- | --- | --- | | Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests that passed in a later run, as described below. | | Failed | 7 | The same 7 tests also fail on the commit before the fixes. | | Skipped | 9 | These tests have their own skip conditions: `sentry` 5, and 1 each in `bundled-plugin-registration`, `customer-success-cloud`, `onepassword-connection`, and `workspace-runtime-https-live-exercise`. | The 7 tests that fail with and without this change, on my machine: - 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on a `SKILL.md` file. The path is in the parent checkout, above the git worktree that I used, so I expect that the worktree is the cause. I did not prove this. - `workspace-instance-cleanup` compares `/private/var/...` with `/var/...`. This is a macOS symlink difference. - 2 tests in `git-reproducible-managed-checkouts` get `unknown_base_git_files` and expect `unpushed_managed_checkout`. I did not find the cause. - 1 more test in `chat-channels`, `materializes direct external-chat finals once`, gets `undefined`. I did not find the cause. CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`. Failures during this work: - My fix to `agent-runtime-connector-access` changed an invalid permission key in the setup, but not in the assertion. The runtime check found this. I corrected the assertion, and the file passes. - `heartbeat-plugin-environment` failed one wait in the full run. It passed three times alone, so I think that load caused the failure. It can fail again under load. - `dot-runner` skipped all 54 tests in the full run because `cargo` was not on my `PATH`. It then passed 54 of 54 with the toolchain on the `PATH`. - My first `lib` value removed the default DOM types, and the error count went up by 375. I added the DOM libraries. - My Daytona stub with bare `any` left 48 errors, because generic helpers then inferred `unknown`. I added shapes that return promises. - One fix to `agent-secrets-routes` used `class_3_static_lease`, and the test failed. That class allows only specific paths. I reverted to `unclassified`. - `ci / General tests (server (8/12))` failed on `259737b700` because of the flaky port in `idle-completed-history`. paperclipai/paperclip#18853 fixed this test on master, so this pull request keeps that fix. **Audit of the diff.** I counted these patterns in added and removed lines, to find fixes that only hide an error: | Pattern | Added | Removed | | --- | --- | --- | | `@ts-ignore` | 0 | 0 | | `@ts-expect-error` | 0 | 1 | | `.skip` | 0 | 0 | | `as any` | 9 | 9 | | `: any` | 11 | 2 | | `as never` | 8 | 2 | | `as unknown as` | 35 | 4 | | `Promise<unknown>` | 16 | 5 | | `expect(` | 128 | 113 | No hunk removes more `expect(` calls than it adds. Three removals of test content were intentional, and I checked each one: - `muse-control` removed `signalToken`. Line 1012 already asserts that it is absent. - `merged-pr-confirmation-sweep` removed legacy aliases. Other tests cover them. - `heartbeat-workspace-branch-containment` removed a `finalize` branch that no test row can reach. Some partial fixtures stay unchecked through `as unknown as` and `Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes` and `issue-comment-reopen-routes`. **Accuracy of the check.** One program over the tree and the three shards reported the same errors at the same positions. Memory, as the peak of the largest process on one thread, was 3.93 GiB for one program and 2.43 GiB for the largest shard. I measured these two values before the fixes. Not run or not proven: - I did not run the full `pnpm test:run`, or a root `pnpm build`. I expect CI to run the full suites. - I did not run the 4 GiB container check, or the full `scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so they are not a Linux cgroup measurement. - I did not run the UI typecheck after the merges. - `workspace-runtime-https-live-exercise` is skipped, so its `cleanup` fix did not run. ## Risks - **A large diff in test files.** 328 files change. A pull request that edits the same tests can get merge conflicts. New tests on master can also add type errors before this merges. Any such error fails this check and needs a fix here. - **A higher cost for changes to source types.** A change to a type can add errors in test files that the author did not touch. The author must then fix them. This is the purpose of the check, but it is new work. - **Changed test behavior.** Some tests now run paths that they did not run before, such as the 9 chat wizard fixtures and the 3 span tests. They pass here, but they can be more sensitive to timing or to the environment. - **The Daytona stub can go stale.** The stub is written by hand. If the plugin uses a new SDK member, the typecheck reports an error until someone updates the stub. - **The release path.** `release-verify.yml` and `scripts/release.sh` run `pnpm -r typecheck`, so they now run this check. - **The tsconfig change.** The test transform reads `server/src/__tests__/tsconfig.json`. The options that I changed are for type checks, and the runs above passed. - **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and in most sandbox provider plugins exclude `*.test.ts`. This check does not read most of those test files, so they can still drift. That work is separate from this pull request. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with reasoning, tool use, code execution, and subagents. The runtime does not show the context window size. ## 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 - [ ] I have run tests locally and they pass (7 tests fail locally, with and without this change. See Verification.) - [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 - [ ] All Paperclip CI gates are green - [ ] 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>
1528 lines
80 KiB
TypeScript
1528 lines
80 KiB
TypeScript
import { notifyNativeEnvironmentSettlement } from "../services/native-environment-settlement-notifications.js";
|
|
import { notifyRetainedNativeSessionCleanup, onRetainedNativeSessionCleanupRelease, releaseRetainedNativeSessionCleanup } from "../services/retained-native-session-cleanup-notifications.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { agentService } from "../services/agents.js";
|
|
import { publishNativeHarnessBackup } from "../services/native-runtime/native-harness-backup-publication.js";
|
|
import { registerNativeHarnessBackupExpirationWork } from "../services/native-runtime/native-harness-backup-expiration-work.js";
|
|
import { noteNativeHarnessBackupChange } from "../services/native-harness-backup-expiration-notifications.js";
|
|
import { writeEnvironmentCleanup } from "../services/environment-cleanup-notifications.js";
|
|
import { issueService } from "../services/issues.js";
|
|
import { applyPendingMigrations } from "@paperclipai/db";
|
|
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
|
import { registerNativeRunnerEvidenceRetentionWork } from "../services/native-runtime/native-runner-evidence-retention-work.js";
|
|
import { noteNativeRunnerEvidence } from "../services/native-runtime/native-runner-evidence-signal.js";
|
|
import { idleWorkSnapshot } from "../services/task-admission.js";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promises as fs } from "node:fs";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import {
|
|
agents,
|
|
agentTaskSessions,
|
|
companies,
|
|
createDb,
|
|
environmentLeases,
|
|
environments,
|
|
sandboxCreationOperations,
|
|
heartbeatRuns,
|
|
issues,
|
|
nativeRunFinalizations,
|
|
nativeRunnerCheckpoints,
|
|
} from "@paperclipai/db";
|
|
import { recoveryPostgresSupport as embeddedPostgresSupport, startRecoveryPostgres } from "./helpers/recovery-postgres.js";
|
|
import { nativeHarnessBackupExpirationService } from "../services/native-runtime/native-harness-backup-expiration.ts";
|
|
import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
|
|
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_NATIVE_RETENTION_TEST_DATABASE_URL");
|
|
const describeEmbeddedPostgres = external || embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!external && !embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres native harness backup expiration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
|
|
async function exists(target: string) {
|
|
return fs.lstat(target).then(() => true, () => false);
|
|
}
|
|
|
|
describeEmbeddedPostgres("native harness backup expiration", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let tempDb: Awaited<ReturnType<typeof startRecoveryPostgres>> | null = null;
|
|
let stateBase = "";
|
|
const now = new Date();
|
|
|
|
beforeAll(async () => {
|
|
if (!external) tempDb = await startRecoveryPostgres("paperclip-native-backup-expiration-");
|
|
if (external) await applyPendingMigrations(external);
|
|
db = createDb(external ?? tempDb!.connectionString);
|
|
}, 20_000);
|
|
|
|
let coordinator: ReturnType<typeof createDeliveryWorkCoordinator> | undefined;
|
|
let worker: ReturnType<typeof registerNativeRunnerEvidenceRetentionWork> | undefined;
|
|
let admitted = true;
|
|
let backupWorker: ReturnType<typeof registerNativeHarnessBackupExpirationWork> | undefined;
|
|
const backupErrors: unknown[] = [];
|
|
afterEach(async () => {
|
|
await backupWorker?.stop(); backupWorker = undefined; backupErrors.length = 0;
|
|
worker?.unsubscribe(); await coordinator?.stop(); coordinator = undefined; worker = undefined; admitted = true;
|
|
vi.useRealTimers(); vi.restoreAllMocks();
|
|
await db.delete(agentTaskSessions);
|
|
await db.delete(nativeRunFinalizations);
|
|
await db.delete(environmentLeases);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(issues);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
if (stateBase) await fs.rm(stateBase, { recursive: true, force: true });
|
|
stateBase = "";
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db?.$client.end({ timeout: 1 });
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedSession(input: {
|
|
companyId?: string; agentId?: string;
|
|
issueStatus?: string;
|
|
closedDaysAgo?: number;
|
|
runStatus?: string;
|
|
finalization?: { phase: string; recoveryState?: string | null; leaseOwner?: string | null };
|
|
leaseStatus?: string;
|
|
backupAgeDays?: number;
|
|
cleanupActive?: boolean;
|
|
} = {}) {
|
|
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
|
|
const companyId = input.companyId ?? randomUUID();
|
|
if (!input.companyId) await db.insert(companies).values({ id: companyId, name: "Backups", issuePrefix: `B${companyId.slice(0, 6)}` });
|
|
const [agent] = input.agentId ? [{ id: input.agentId }]
|
|
: await db.insert(agents).values({ companyId, name: "Coder" }).returning();
|
|
const issueStatus = input.issueStatus ?? "done";
|
|
const closedAt = new Date(now.getTime() - (input.closedDaysAgo ?? 10) * DAY_MS);
|
|
const [issue] = await db.insert(issues).values({
|
|
companyId,
|
|
title: "Closed work",
|
|
status: issueStatus,
|
|
completedAt: issueStatus === "done" ? closedAt : null,
|
|
cancelledAt: issueStatus === "cancelled" ? closedAt : null,
|
|
}).returning();
|
|
const sessionId = randomUUID();
|
|
const runStatus = input.runStatus ?? "succeeded";
|
|
const [run] = await db.insert(heartbeatRuns).values({
|
|
companyId,
|
|
agentId: agent!.id,
|
|
status: runStatus,
|
|
issueId: issue!.id,
|
|
nativeIssueId: issue!.id,
|
|
nativeSessionId: sessionId,
|
|
finishedAt: ["queued", "running"].includes(runStatus) ? null : closedAt,
|
|
}).returning();
|
|
await db.insert(agentTaskSessions).values({
|
|
companyId,
|
|
agentId: agent!.id,
|
|
adapterType: "paperclip_runner",
|
|
taskKey: issue!.id,
|
|
sessionParamsJson: { sessionId },
|
|
lastRunId: run!.id,
|
|
});
|
|
if (input.finalization) {
|
|
await db.insert(nativeRunFinalizations).values({
|
|
runId: run!.id,
|
|
companyId,
|
|
issueId: issue!.id,
|
|
phase: input.finalization.phase,
|
|
recoveryState: input.finalization.recoveryState ?? null,
|
|
leaseOwner: input.finalization.leaseOwner ?? null,
|
|
});
|
|
}
|
|
const scope = createHash("sha256").update(randomUUID()).digest("hex");
|
|
const root = path.join(stateBase, scope);
|
|
const current = path.join(root, "failover-backups", "current");
|
|
await fs.mkdir(path.join(root, "control-plane"), { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(root, "control-plane", "control-plane-state.json"),
|
|
JSON.stringify({ identity: { runId: run!.id, normalizedSessionId: sessionId } }),
|
|
);
|
|
await fs.mkdir(path.join(current, "codex-home"), { recursive: true });
|
|
await fs.writeFile(path.join(current, "codex-home", "history.jsonl"), "x".repeat(64 * 1024));
|
|
await fs.writeFile(path.join(current, "manifest.json"), "{}");
|
|
const backupAt = new Date(now.getTime() - (input.backupAgeDays ?? 9) * DAY_MS);
|
|
await fs.utimes(current, backupAt, backupAt);
|
|
if (input.cleanupActive) await fs.writeFile(path.join(root, "cleanup-activation.json"), "{}");
|
|
if (input.leaseStatus) {
|
|
await db.insert(environmentLeases).values({
|
|
companyId,
|
|
status: input.leaseStatus,
|
|
metadata: { nativeHarnessBackup: { sessionScopeSha256: scope } },
|
|
});
|
|
}
|
|
return { companyId, agentId: agent!.id, issueId: issue!.id, runId: run!.id, sessionId, scope, root, current };
|
|
}
|
|
|
|
function service() {
|
|
return nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
closedForDays: 7,
|
|
now,
|
|
isSessionBusy: () => false,
|
|
});
|
|
}
|
|
|
|
async function taskSessionCount(agentId: string) {
|
|
return (await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.agentId, agentId))).length;
|
|
}
|
|
|
|
async function seedCheckpoint(session: Awaited<ReturnType<typeof seedSession>>) {
|
|
await db.insert(nativeRunnerCheckpoints).values(["header", "cursor", "event:1"].map(part => ({
|
|
companyId: session.companyId, issueId: session.issueId, runId: session.runId, part, material: { encrypted: true },
|
|
})));
|
|
}
|
|
|
|
it.each([false, true])("adds no periodic database work after startup (retained history: %s)", async retained => {
|
|
if (retained) {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session); // Saved resume pointer deliberately protects it.
|
|
await fs.rm(stateBase, { recursive: true, force: true });
|
|
} else {
|
|
stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "empty-checkpoint-retention-"));
|
|
}
|
|
const expiration = service();
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: error => { throw error; } });
|
|
const select = vi.spyOn(db, "select");
|
|
try {
|
|
await expiration.registerCheckpointExpiration(coordinator).ready;
|
|
expect(select).toHaveBeenCalled();
|
|
const reads = select.mock.calls.length;
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 60 * 1000);
|
|
// The unchanged hourly filesystem sweep must not enumerate DB checkpoints.
|
|
for (let hour = 0; hour < 3; hour++) await expiration.tick();
|
|
expect(select).toHaveBeenCalledTimes(reads);
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
} finally {
|
|
await coordinator.stop();
|
|
select.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
if (retained) {
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toHaveLength(3);
|
|
await db.delete(agentTaskSessions);
|
|
const nextStartup = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: vi.fn() });
|
|
try {
|
|
await service().registerCheckpointExpiration(nextStartup).ready;
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toEqual([]);
|
|
expect(nextStartup.nextWakeAt()).toBeNull();
|
|
} finally { await nextStartup.stop(); }
|
|
}
|
|
});
|
|
|
|
it("preserves startup expiration across drain without querying while paused", async () => {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
await db.delete(agentTaskSessions);
|
|
let draining = true;
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
const select = vi.spyOn(db, "select");
|
|
const onError = vi.fn();
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => !draining, canReconcile: () => true, onError });
|
|
try {
|
|
await service().registerCheckpointExpiration(coordinator).ready;
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(select).not.toHaveBeenCalled();
|
|
draining = false;
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.waitFor(() => expect(coordinator.nextWakeAt()).toBeNull());
|
|
await coordinator.stop(); // Await the DB transaction before asserting.
|
|
expect(onError).not.toHaveBeenCalled();
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toEqual([]);
|
|
} finally {
|
|
await coordinator.stop();
|
|
select.mockRestore();
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("retries a failed checkpoint from an earlier page before startup cleanup goes quiet", async () => {
|
|
for (let index = 0; index < 2; index++) {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
}
|
|
await db.delete(agentTaskSessions);
|
|
const transaction = vi.spyOn(db, "transaction").mockRejectedValueOnce(new Error("temporary retention failure"));
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: vi.fn() });
|
|
try {
|
|
const expiration = nativeHarnessBackupExpirationService(db, { now, maxEntries: 1 });
|
|
await expiration.registerCheckpointExpiration(coordinator).ready;
|
|
await vi.waitFor(async () => {
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toEqual([]);
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
}, { timeout: 5000 });
|
|
} finally {
|
|
await coordinator.stop();
|
|
transaction.mockRestore();
|
|
}
|
|
});
|
|
|
|
it.each(["query", "transaction"] as const)("bounds persistent startup %s failures and becomes quiet until the next startup", async fault => {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
await db.delete(agentTaskSessions);
|
|
const failure = fault === "query"
|
|
? vi.spyOn(db, "select").mockImplementation(() => { throw new Error("retention candidate query unavailable"); })
|
|
: vi.spyOn(db, "transaction").mockRejectedValue(new Error("retention transaction unavailable"));
|
|
const warning = vi.spyOn(logger, "warn");
|
|
const coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: vi.fn() });
|
|
try {
|
|
await service().registerCheckpointExpiration(coordinator).ready;
|
|
await vi.waitFor(() => {
|
|
// No scheduled deadline can also mean a retry is currently running.
|
|
// Wait for the owner's final deferral before asserting quiet time.
|
|
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ failedPasses: 3 }),
|
|
"startup checkpoint expiration retries exhausted; retained checkpoints deferred to next startup");
|
|
expect(failure).toHaveBeenCalledTimes(3);
|
|
expect(coordinator.nextWakeAt()).toBeNull();
|
|
}, { timeout: 5000 });
|
|
const attempts = failure.mock.calls.length;
|
|
expect(attempts).toBe(3);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
await vi.advanceTimersByTimeAsync(3 * 60 * 60 * 1000);
|
|
expect(failure).toHaveBeenCalledTimes(attempts);
|
|
} finally {
|
|
await coordinator.stop(); failure.mockRestore(); warning.mockRestore(); vi.useRealTimers();
|
|
}
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toHaveLength(3);
|
|
const nextStartup = createDeliveryWorkCoordinator({ owner: db, canRun: () => true, onError: vi.fn() });
|
|
try {
|
|
await service().registerCheckpointExpiration(nextStartup).ready;
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toEqual([]);
|
|
expect(nextStartup.nextWakeAt()).toBeNull();
|
|
} finally { await nextStartup.stop(); }
|
|
});
|
|
|
|
it("expires unreferenced settled checkpoints even with an empty controller disk and fences old writers", async () => {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
await db.delete(agentTaskSessions);
|
|
await fs.rm(stateBase, { recursive: true, force: true });
|
|
expect(await service().tickCheckpoints()).toMatchObject({ expiredCheckpoints: 1, checkpointCandidates: 1, failed: 0 });
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toEqual([]);
|
|
const [authority] = await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, session.runId));
|
|
expect(authority?.controllerGeneration).toBe(1);
|
|
expect(await db.select().from(heartbeatRuns)).toHaveLength(1);
|
|
});
|
|
|
|
it.each(["resume", "lease", "lease_stamp", "recovery", "running", "recent", "open", "execution", "dry_run"])(
|
|
"preserves a checkpoint protected by %s without its controller disk", async protection => {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
await fs.rm(stateBase, { recursive: true, force: true });
|
|
if (protection !== "resume") await db.delete(agentTaskSessions);
|
|
if (protection === "lease") await db.insert(environmentLeases).values({
|
|
companyId: session.companyId, issueId: session.issueId, status: "retained",
|
|
});
|
|
if (protection === "lease_stamp") await db.insert(environmentLeases).values({
|
|
companyId: session.companyId, status: "retained",
|
|
metadata: { nativeHarnessBackup: { normalizedSessionId: session.sessionId } },
|
|
});
|
|
if (protection === "recovery") await db.update(nativeRunFinalizations).set({ recoveryState: "pending" });
|
|
if (protection === "running") await db.update(heartbeatRuns).set({ status: "running" });
|
|
if (protection === "recent") await db.update(issues).set({ completedAt: now });
|
|
if (protection === "open") await db.update(issues).set({ status: "in_progress" });
|
|
if (protection === "execution") await db.update(issues).set({ executionRunId: session.runId });
|
|
expect(await service().tickCheckpoints({ dryRun: protection === "dry_run" })).toMatchObject({ expiredCheckpoints: 0, failed: 0 });
|
|
expect(await db.select().from(nativeRunnerCheckpoints)).toHaveLength(3);
|
|
const [authority] = await db.select().from(nativeRunFinalizations);
|
|
expect(authority?.controllerGeneration).toBe(0);
|
|
// Break the issue/run reference cycle before the shared teardown.
|
|
if (protection === "execution") await db.update(issues).set({ executionRunId: null });
|
|
},
|
|
);
|
|
|
|
it("continues checkpoint expiration past retained sessions within bounded pages", async () => {
|
|
for (let index = 0; index < 3; index++) {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await seedCheckpoint(session);
|
|
}
|
|
await fs.rm(stateBase, { recursive: true, force: true });
|
|
const expiration = service();
|
|
expect(await expiration.tickCheckpoints({ maxEntries: 2 })).toMatchObject({ expiredCheckpoints: 0, truncated: true });
|
|
expect(await expiration.tickCheckpoints({ maxEntries: 2 })).toMatchObject({ expiredCheckpoints: 0, truncated: false });
|
|
await db.delete(agentTaskSessions);
|
|
expect(await expiration.tickCheckpoints({ maxEntries: 2 })).toMatchObject({ expiredCheckpoints: 2, truncated: true });
|
|
expect(await expiration.tickCheckpoints({ maxEntries: 2 })).toMatchObject({ expiredCheckpoints: 1, truncated: false });
|
|
});
|
|
|
|
it("expires the backup of a session whose issue closed before the window and clears its resume pointer", async () => {
|
|
const session = await seedSession();
|
|
const result = await service().tick();
|
|
|
|
expect(result).toMatchObject({ checked: 1, candidates: 1, expired: 1, clearedTaskSessions: 1, failed: 0 });
|
|
expect(result.reclaimedBytes).toBeGreaterThan(0);
|
|
expect(await exists(session.current)).toBe(false);
|
|
expect(await exists(path.join(session.root, "control-plane", "control-plane-state.json"))).toBe(true);
|
|
expect(await fs.readdir(path.join(session.root, "failover-backups"))).toEqual([]);
|
|
expect(await taskSessionCount(session.agentId)).toBe(0);
|
|
});
|
|
|
|
it("expires sessions of cancelled issues", async () => {
|
|
const session = await seedSession({ issueStatus: "cancelled" });
|
|
expect(await service().tick()).toMatchObject({ expired: 1 });
|
|
expect(await exists(session.current)).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
["issue_open", { issueStatus: "in_progress" }],
|
|
["recently_closed", { closedDaysAgo: 2 }],
|
|
["live_run", { runStatus: "running" }],
|
|
["pending_finalization", { finalization: { phase: "settling" } }],
|
|
["pending_finalization", { finalization: { phase: "observed", recoveryState: "resuming_session" } }],
|
|
["pending_finalization", { finalization: { phase: "committed", leaseOwner: "controller" } }],
|
|
["live_lease", { leaseStatus: "released" }],
|
|
["recent_backup", { backupAgeDays: 1 }],
|
|
["cleanup_active", { cleanupActive: true }],
|
|
] as const)("keeps the backup when %s", async (reason, input) => {
|
|
const session = await seedSession(input);
|
|
const result = await service().tick();
|
|
|
|
expect(result).toMatchObject({ checked: 1, candidates: 0, expired: 0, failed: 0 });
|
|
expect(result.skipped[reason]).toBe(1);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
});
|
|
|
|
it("keeps the backup when the session's control-plane state is too large to read", async () => {
|
|
const session = await seedSession();
|
|
const statePath = path.join(session.root, "control-plane", "control-plane-state.json");
|
|
await fs.writeFile(statePath, `${await fs.readFile(statePath, "utf8")}${" ".repeat(5 * 1024 * 1024)}`);
|
|
|
|
const result = await service().tick();
|
|
|
|
expect(result.skipped.identity_unknown).toBe(1);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
});
|
|
|
|
it("keeps the backup of a session that is executing in this process", async () => {
|
|
const session = await seedSession();
|
|
const result = await nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
now,
|
|
isSessionBusy: (scope) => scope === session.scope,
|
|
}).tick();
|
|
|
|
expect(result.skipped.session_active).toBe(1);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
});
|
|
|
|
it("settled finalizations and expired leases do not block expiry", async () => {
|
|
const session = await seedSession({ finalization: { phase: "committed" }, leaseStatus: "expired" });
|
|
expect(await service().tick()).toMatchObject({ expired: 1 });
|
|
expect(await exists(session.current)).toBe(false);
|
|
});
|
|
|
|
it("retains incomplete native snapshot publications even after the issue closes", async () => {
|
|
const session = await seedSession({ finalization: { phase: "committed" } });
|
|
await db.update(heartbeatRuns).set({ runnerProfileJson: { nativeControllerRecovery: {
|
|
schema: "paperclip.native-controller-recovery.v1", checkpoint: null,
|
|
} } }).where(eq(heartbeatRuns.id, session.runId));
|
|
expect(await service().tick()).toMatchObject({ expired: 0, skipped: { native_recovery_incomplete: 1 } });
|
|
expect(await exists(session.current)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
["committed", "resuming_session"],
|
|
["terminal_failure", "blocked"],
|
|
] as const)("stale %s recovery state %s does not block expiry once the issue is closed", async (phase, recoveryState) => {
|
|
const session = await seedSession({ finalization: { phase, recoveryState } });
|
|
expect(await service().tick()).toMatchObject({ expired: 1 });
|
|
expect(await exists(session.current)).toBe(false);
|
|
});
|
|
|
|
it("reports candidates without deleting anything in dry run", async () => {
|
|
const session = await seedSession();
|
|
const result = await nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
now,
|
|
dryRun: true,
|
|
isSessionBusy: () => false,
|
|
}).tick();
|
|
|
|
expect(result).toMatchObject({ candidates: 1, expired: 0, clearedTaskSessions: 0 });
|
|
expect(result.candidateBytes).toBeGreaterThan(0);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
});
|
|
|
|
it("continues across ticks when a batch is bounded", async () => {
|
|
const sessions = [await seedSession(), await seedSession(), await seedSession()];
|
|
const expiration = nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
now,
|
|
maxEntries: 2,
|
|
isSessionBusy: () => false,
|
|
});
|
|
|
|
expect(await expiration.tick()).toMatchObject({ checked: 2, expired: 2, truncated: true });
|
|
expect(await expiration.tick()).toMatchObject({ checked: 1, expired: 1, truncated: false });
|
|
for (const session of sessions) expect(await exists(session.current)).toBe(false);
|
|
});
|
|
|
|
it("uses the configured clock for the closed window", async () => {
|
|
const session = await seedSession({ closedDaysAgo: 8, backupAgeDays: 20 });
|
|
const early = nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
now: new Date(now.getTime() - 2 * DAY_MS),
|
|
isSessionBusy: () => false,
|
|
});
|
|
expect((await early.tick()).skipped.recently_closed).toBe(1);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await service().tick()).toMatchObject({ expired: 1 });
|
|
});
|
|
|
|
it("restores a backup whose expiry stopped before its final check", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const retiring = path.join(session.root, "failover-backups", `.retiring-${randomUUID()}`);
|
|
await fs.rename(session.current, retiring);
|
|
|
|
await service().tick();
|
|
|
|
expect(await exists(retiring)).toBe(false);
|
|
expect(await exists(path.join(session.current, "manifest.json"))).toBe(true);
|
|
});
|
|
|
|
it("drops an unconfirmed retired backup once a newer backup exists", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const retiring = path.join(session.root, "failover-backups", `.retiring-${randomUUID()}`);
|
|
await fs.mkdir(path.join(retiring, "codex-home"), { recursive: true });
|
|
|
|
await service().tick();
|
|
|
|
expect(await exists(retiring)).toBe(false);
|
|
expect(await exists(path.join(session.current, "manifest.json"))).toBe(true);
|
|
});
|
|
|
|
it("finishes removing a backup left over from an interrupted expiry", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const leftover = path.join(session.root, "failover-backups", `.expired-${randomUUID()}`);
|
|
await fs.mkdir(path.join(leftover, "codex-home"), { recursive: true });
|
|
|
|
await service().tick();
|
|
|
|
expect(await exists(leftover)).toBe(false);
|
|
expect(await exists(session.current)).toBe(true);
|
|
});
|
|
async function seedArchive(root: string, input: { stampedDaysAgo?: number; mtimeDaysAgo?: number; name?: string } = {}) {
|
|
const stampedAt = now.getTime() - (input.stampedDaysAgo ?? 10) * DAY_MS;
|
|
const archive = path.join(root, "continuity-breaks", input.name ?? `${stampedAt}-${randomUUID()}`);
|
|
await fs.mkdir(path.join(archive, "codex-home"), { recursive: true });
|
|
await fs.writeFile(path.join(archive, "codex-home", "history.jsonl"), "x".repeat(64 * 1024));
|
|
const mtime = new Date(now.getTime() - (input.mtimeDaysAgo ?? input.stampedDaysAgo ?? 10) * DAY_MS);
|
|
await fs.utimes(archive, mtime, mtime);
|
|
return archive;
|
|
}
|
|
|
|
async function seedQuarantine(
|
|
scope: string,
|
|
input: { stampedDaysAgo?: number; name?: string } = {},
|
|
) {
|
|
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
|
|
const stampedAt = now.getTime() - (input.stampedDaysAgo ?? 10) * DAY_MS;
|
|
const entry = path.join(
|
|
stateBase,
|
|
"quarantine",
|
|
input.name ?? `${scope}.identity_indeterminate.${stampedAt}.4242.${randomUUID()}`,
|
|
);
|
|
await fs.mkdir(path.join(entry, "control-plane"), { recursive: true });
|
|
await fs.writeFile(path.join(entry, "history.jsonl"), "x".repeat(64 * 1024));
|
|
return entry;
|
|
}
|
|
|
|
function evidenceService(overrides: Parameters<typeof nativeHarnessBackupExpirationService>[1] = {}) {
|
|
return nativeHarnessBackupExpirationService(db, {
|
|
stateBase,
|
|
now,
|
|
evidenceRetentionDays: 7,
|
|
isSessionBusy: () => false,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
async function emptyStateBase() {
|
|
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
|
|
return createHash("sha256").update(randomUUID()).digest("hex");
|
|
}
|
|
|
|
async function startEvidence(options: Parameters<typeof nativeHarnessBackupExpirationService>[1] = {}) {
|
|
if (!vi.isFakeTimers()) {
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
|
vi.setSystemTime(now);
|
|
}
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
|
onError: error => { throw error; } });
|
|
worker = registerNativeRunnerEvidenceRetentionWork(coordinator, db, { stateBase, isSessionBusy: () => false, ...options }, () => admitted);
|
|
await worker.ready;
|
|
}
|
|
async function settled(deadline: number | null = null) {
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(idleWorkSnapshot().active).toBe(0); expect(coordinator!.nextWakeAt()).toBe(deadline);
|
|
}, { timeout: 5000 });
|
|
}
|
|
async function quiet(ms = 3 * 60 * 60_000) {
|
|
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction"), directories = vi.spyOn(fs, "readdir");
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled(); expect(directories).not.toHaveBeenCalled();
|
|
reads.mockRestore(); writes.mockRestore(); directories.mockRestore();
|
|
}
|
|
|
|
async function startBackups(options: Parameters<typeof nativeHarnessBackupExpirationService>[1] = {}) {
|
|
if (!stateBase) await emptyStateBase();
|
|
if (!vi.isFakeTimers()) {
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"], shouldAdvanceTime: true });
|
|
vi.setSystemTime(now);
|
|
}
|
|
coordinator = createDeliveryWorkCoordinator({ owner: db, canRun: () => admitted, canReconcile: () => true,
|
|
onError: error => backupErrors.push(error) });
|
|
backupWorker = registerNativeHarnessBackupExpirationWork(coordinator, db,
|
|
{ stateBase, isSessionBusy: () => false, ...options }, () => admitted);
|
|
await backupWorker.ready;
|
|
}
|
|
|
|
it.each([false, true])("backup deadline worker leaves empty and retained terminal history quiet (%s)", async history => {
|
|
await emptyStateBase();
|
|
if (history) {
|
|
const session = await seedSession();
|
|
await fs.rm(path.join(session.root, "failover-backups"), { recursive: true });
|
|
await db.insert(heartbeatRuns).values(Array.from({ length: 500 }, () => ({
|
|
companyId: session.companyId, agentId: session.agentId, status: "succeeded", issueId: session.issueId,
|
|
nativeIssueId: session.issueId, nativeSessionId: randomUUID(), finishedAt: now,
|
|
})));
|
|
for (let index = 0; index < 250; index++) await fs.mkdir(path.join(stateBase,
|
|
createHash("sha256").update(String(index)).digest("hex")));
|
|
}
|
|
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
|
|
await startBackups({ maxEntries: 20 }); await settled();
|
|
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
|
|
reads.mockRestore(); writes.mockRestore(); await quiet();
|
|
expect(backupErrors).toEqual([]);
|
|
});
|
|
|
|
it("ordinary task close arms exact backup retention, deletes physical bytes and resume pointer, then stays quiet", async () => {
|
|
const session = await seedSession({ issueStatus: "todo" });
|
|
await startBackups(); await settled(); await quiet();
|
|
const closed = await issueService(db).update(session.issueId, { status: "done", companyGuard: session.companyId });
|
|
const deadline = new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1;
|
|
await settled(deadline); await quiet(60_000);
|
|
await vi.advanceTimersByTimeAsync(deadline - Date.now() - 1);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
await vi.advanceTimersByTimeAsync(1); await settled();
|
|
expect(await exists(session.current)).toBe(false);
|
|
expect(await taskSessionCount(session.agentId)).toBe(0);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, session.runId))).toHaveLength(1);
|
|
expect(backupErrors).toEqual([]); await quiet();
|
|
});
|
|
|
|
it("ordinary backup publication after quiet schedules its actual file age and leaves a durable cleanup result", async () => {
|
|
const session = await seedSession();
|
|
const backupRoot = path.dirname(session.current);
|
|
const pendingRoot = path.join(backupRoot, "pending-publication");
|
|
await fs.rename(session.current, pendingRoot);
|
|
await startBackups(); await settled(); await quiet();
|
|
const publishedAt = Date.now();
|
|
await fs.utimes(pendingRoot, new Date(publishedAt), new Date(publishedAt));
|
|
await publishNativeHarnessBackup({ db, companyId: session.companyId, scopeDigest: session.scope, backupRoot, pendingRoot });
|
|
const stat = await fs.stat(session.current);
|
|
const due = stat.mtime.getTime() + 7 * DAY_MS + 1;
|
|
await settled(due); await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(session.current)).toBe(false); expect(await taskSessionCount(session.agentId)).toBe(0);
|
|
expect(await fs.readdir(backupRoot)).toEqual([]); await quiet();
|
|
});
|
|
|
|
it("a committed earlier close timestamp replaces the later deadline without early cleanup", async () => {
|
|
const session = await seedSession({ closedDaysAgo: 2 });
|
|
await startBackups(); await settled(now.getTime() + 5 * DAY_MS + 1);
|
|
const earlier = new Date(now.getTime() - 6 * DAY_MS);
|
|
await issueService(db).update(session.issueId, { completedAt: earlier });
|
|
const due = earlier.getTime() + 7 * DAY_MS + 1;
|
|
await settled(due); await quiet(60_000);
|
|
await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(session.current)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("holds nested ordinary close activation until the outer transaction commits", async () => {
|
|
const session = await seedSession({ issueStatus: "todo" });
|
|
await startBackups(); await settled();
|
|
let release!: () => void, written!: () => void;
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const staged = new Promise<void>(resolve => { written = resolve; });
|
|
const transaction = db.transaction(async outer => {
|
|
await outer.transaction(nested => issueService(db).update(session.issueId, { status: "done" }, nested));
|
|
written(); await gate;
|
|
});
|
|
await staged;
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(await exists(session.current)).toBe(true);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1);
|
|
release(); await transaction;
|
|
const [closed] = await db.select().from(issues).where(eq(issues.id, session.issueId));
|
|
const due = new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1;
|
|
await settled(due); await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(session.current)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("preserves failed cleanup retry across drain, then finishes its physical tombstone on resume", async () => {
|
|
const session = await seedSession(); const remove = fs.rm;
|
|
const fault = vi.spyOn(fs, "rm").mockImplementation(async (...args) => {
|
|
if (String(args[0]).includes(".expired-")) throw new Error("controlled drain retry failure");
|
|
return remove(...args);
|
|
});
|
|
await startBackups();
|
|
expect(backupErrors).toHaveLength(1); admitted = false; fault.mockRestore();
|
|
await quiet(60_000);
|
|
expect((await fs.readdir(path.dirname(session.current))).some(name => name.startsWith(".expired-"))).toBe(true);
|
|
admitted = true; await settled();
|
|
expect(await fs.readdir(path.dirname(session.current))).toEqual([]); await quiet();
|
|
});
|
|
|
|
it("reopen cancels a saved deadline and a later ordinary close restores retention", async () => {
|
|
const session = await seedSession({ closedDaysAgo: 2 });
|
|
await startBackups(); await settled(now.getTime() + 5 * DAY_MS + 1);
|
|
await issueService(db).update(session.issueId, { status: "todo" });
|
|
await settled(); await quiet(8 * DAY_MS); expect(await exists(session.current)).toBe(true);
|
|
const closed = await issueService(db).update(session.issueId, { status: "done" });
|
|
const due = new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1;
|
|
await settled(due); await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(session.current)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("reconstructs a retained backup deadline on restart without changing business timestamps", async () => {
|
|
const session = await seedSession({ closedDaysAgo: 2 });
|
|
const [before] = await db.select().from(issues).where(eq(issues.id, session.issueId));
|
|
const due = now.getTime() + 5 * DAY_MS + 1;
|
|
await startBackups(); await settled(due);
|
|
await backupWorker!.stop(); await coordinator!.stop();
|
|
await startBackups(); await settled(due);
|
|
const [after] = await db.select().from(issues).where(eq(issues.id, session.issueId));
|
|
expect(after!.updatedAt).toEqual(before!.updatedAt); expect(after!.completedAt).toEqual(before!.completedAt);
|
|
await quiet();
|
|
});
|
|
|
|
it("reports dry-run backups once and rechecks only after source invalidation", async () => {
|
|
const session = await seedSession(); const report = vi.spyOn(logger, "info");
|
|
await startBackups({ dryRun: true }); await settled();
|
|
expect(report.mock.calls.filter(call => call[1] === "native harness backup expiration")).toHaveLength(1);
|
|
expect(await taskSessionCount(session.agentId)).toBe(1); expect(await exists(session.current)).toBe(true);
|
|
await quiet();
|
|
noteNativeHarnessBackupChange(db, { companyId: session.companyId, scopeDigest: session.scope });
|
|
await settled();
|
|
expect(report.mock.calls.filter(call => call[1] === "native harness backup expiration")).toHaveLength(2);
|
|
await quiet();
|
|
});
|
|
|
|
it("company-only notifications inspect only the indexed company's blocked backup sources", async () => {
|
|
const own = await seedSession({ issueStatus: "todo" });
|
|
const other = await seedSession({ issueStatus: "todo" });
|
|
await startBackups({ maxEntries: 1 }); await settled(); await quiet();
|
|
const sql: Array<{ query: string; parameters: unknown[] }> = [];
|
|
const previous = db.$client.options.debug;
|
|
db.$client.options.debug = (_connection, query, parameters) => { sql.push({ query, parameters }); };
|
|
const directories = vi.spyOn(fs, "readdir");
|
|
try {
|
|
const closed = await issueService(db).update(own.issueId, { status: "done" });
|
|
await settled(new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1);
|
|
const unrelated = [other.companyId, other.agentId, other.runId, other.sessionId, other.issueId];
|
|
expect(sql.filter(query => query.parameters.some(parameter =>
|
|
unrelated.some(id => String(parameter).includes(id))))).toEqual([]);
|
|
expect(directories.mock.calls.some(call => call[0] === stateBase
|
|
|| String(call[0]).startsWith(other.root))).toBe(false);
|
|
expect(await exists(other.current)).toBe(true);
|
|
} finally { db.$client.options.debug = previous; directories.mockRestore(); }
|
|
await quiet();
|
|
});
|
|
|
|
it("coalesces repeated task writes to one retained root in a large company with the same historical agent", async () => {
|
|
const own = await seedSession({ issueStatus: "todo" });
|
|
const unrelated: Awaited<ReturnType<typeof seedSession>>[] = [];
|
|
for (let index = 0; index < 80; index++) unrelated.push(await seedSession({ companyId: own.companyId,
|
|
agentId: own.agentId, issueStatus: index % 2 ? "todo" : "done", closedDaysAgo: 2,
|
|
runStatus: index % 3 ? "succeeded" : "running" }));
|
|
const due = now.getTime() + 5 * DAY_MS + 1;
|
|
await startBackups({ isSessionBusy: digest => unrelated.some((entry, index) => index % 4 === 0 && entry.scope === digest) });
|
|
await settled(due); await quiet(60_000);
|
|
const reads = vi.spyOn(fs, "readFile");
|
|
const queries: Array<{ query: string; parameters: unknown[] }> = [];
|
|
const previous = db.$client.options.debug;
|
|
db.$client.options.debug = (_connection, query, parameters) => { queries.push({ query, parameters }); };
|
|
try {
|
|
await db.transaction(async tx => {
|
|
await issueService(db).update(own.issueId, { status: "done" }, tx);
|
|
for (let index = 0; index < 12; index++) await issueService(db).update(own.issueId,
|
|
{ completedAt: new Date(now.getTime() - DAY_MS) }, tx);
|
|
});
|
|
const writes = queries.length;
|
|
await settled(due);
|
|
expect(reads.mock.calls.map(call => String(call[0]))).toEqual([path.join(own.root, "control-plane", "control-plane-state.json")]);
|
|
expect(queries.length - writes).toBeLessThanOrEqual(20);
|
|
const otherIds = unrelated.flatMap(entry => [entry.issueId, entry.runId, entry.sessionId]);
|
|
expect(queries.filter(query => query.parameters.some(parameter => otherIds.some(id => String(parameter).includes(id))))).toEqual([]);
|
|
} finally { reads.mockRestore(); db.$client.options.debug = previous; }
|
|
await issueService(db).update(own.issueId, { status: "todo" }); await settled(due);
|
|
await quiet(); expect(await exists(own.current)).toBe(true);
|
|
});
|
|
|
|
it.each([false, true])("sandbox attachment reconciles only its retained root using durable run authority (explicit run: %s)", async explicitRun => {
|
|
const own = await seedSession({ runStatus: "running" });
|
|
const unrelated = [];
|
|
for (let index = 0; index < 16; index++) unrelated.push(await seedSession({ companyId: own.companyId,
|
|
agentId: own.agentId, issueStatus: "todo", runStatus: index % 2 ? "running" : "succeeded" }));
|
|
const [environment] = await db.insert(environments).values({ name: randomUUID(), driver: "sandbox" }).returning();
|
|
async function attach(runId: string) {
|
|
const operationId = randomUUID();
|
|
await db.insert(sandboxCreationOperations).values({ id: operationId, companyId: own.companyId,
|
|
environmentId: environment!.id, originalEnvironmentId: environment!.id, runId,
|
|
provider: "controlled", driverKey: "controlled", config: {}, metadata: {}, nextAttemptAt: new Date() });
|
|
return environmentService(db).acquireLease({ creationOperationId: operationId, companyId: own.companyId,
|
|
environmentId: environment!.id, ...(explicitRun ? { heartbeatRunId: runId } : {}) });
|
|
}
|
|
await startBackups(); await settled();
|
|
const reads = vi.spyOn(fs, "readFile");
|
|
try {
|
|
const lease = await attach(own.runId); await settled();
|
|
expect(reads.mock.calls.map(call => String(call[0])))
|
|
.toEqual([path.join(own.root, "control-plane", "control-plane-state.json")]);
|
|
expect((await db.select().from(sandboxCreationOperations).where(eq(sandboxCreationOperations.leaseId, lease.id)))[0])
|
|
.toMatchObject({ state: "attached", runId: own.runId });
|
|
reads.mockClear();
|
|
const [run] = await db.insert(heartbeatRuns).values({ companyId: own.companyId, agentId: own.agentId, status: "running" }).returning();
|
|
await attach(run!.id); await settled();
|
|
expect(reads).not.toHaveBeenCalled();
|
|
expect(await exists(own.current)).toBe(true);
|
|
for (const other of unrelated) expect(await exists(other.current)).toBe(true);
|
|
await quiet();
|
|
} finally {
|
|
reads.mockRestore();
|
|
await db.delete(sandboxCreationOperations).where(eq(sandboxCreationOperations.companyId, own.companyId));
|
|
await db.delete(environmentLeases).where(eq(environmentLeases.environmentId, environment!.id));
|
|
await db.delete(environments).where(eq(environments.id, environment!.id));
|
|
}
|
|
});
|
|
|
|
it("indexes protected session relations so a new run, historical task close and exact process release reactivate only its backup", async () => {
|
|
const own = await seedSession({ closedDaysAgo: 2 });
|
|
const other = await seedSession({ companyId: own.companyId, agentId: own.agentId, issueStatus: "todo" });
|
|
const [linked] = await db.insert(issues).values({ companyId: own.companyId, title: "New linked task", status: "todo" }).returning();
|
|
const due = now.getTime() + 5 * DAY_MS + 1;
|
|
await startBackups(); await settled(due); await quiet(60_000);
|
|
const reads = vi.spyOn(fs, "readFile");
|
|
const runId = randomUUID();
|
|
try {
|
|
await db.transaction(async tx => {
|
|
await notifyNativeEnvironmentSettlement(tx, { companyId: own.companyId, runId, issueId: linked!.id });
|
|
await tx.insert(heartbeatRuns).values({ id: runId, companyId: own.companyId, agentId: own.agentId,
|
|
nativeSessionId: own.sessionId, nativeIssueId: linked!.id, issueId: other.issueId, status: "running" });
|
|
});
|
|
await settled();
|
|
expect(reads.mock.calls.every(call => String(call[0]).startsWith(own.root))).toBe(true);
|
|
expect(reads).toHaveBeenCalled(); reads.mockClear();
|
|
await issueService(db).update(linked!.id, { status: "done" }); await settled();
|
|
expect(reads.mock.calls.every(call => String(call[0]).startsWith(own.root))).toBe(true); reads.mockClear();
|
|
await db.transaction(async tx => {
|
|
await notifyRetainedNativeSessionCleanup(tx, { companyId: own.companyId, runId, agentId: own.agentId });
|
|
await tx.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }).where(eq(heartbeatRuns.id, runId));
|
|
});
|
|
const [closed] = await db.select().from(issues).where(eq(issues.id, linked!.id));
|
|
const later = new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1;
|
|
await settled(later); reads.mockClear();
|
|
const cleanupScope = { companyId: own.companyId, agentId: own.agentId };
|
|
const observed = vi.fn(); const stop = onRetainedNativeSessionCleanupRelease(db, observed);
|
|
releaseRetainedNativeSessionCleanup(db, cleanupScope, { companyId: own.companyId, runId });
|
|
expect(observed).toHaveBeenCalledWith(cleanupScope, { companyId: own.companyId, runId }); stop();
|
|
await settled(later);
|
|
expect(reads.mock.calls.every(call => String(call[0]).startsWith(own.root))).toBe(true);
|
|
expect(reads).toHaveBeenCalled();
|
|
await issueService(db).update(linked!.id, { status: "todo" }); await settled();
|
|
} finally { reads.mockRestore(); }
|
|
expect(await exists(other.current)).toBe(true); await quiet();
|
|
});
|
|
|
|
it("ordinary lease release without a run uses its source identity instead of other retained roots", async () => {
|
|
const own = await seedSession({ leaseStatus: "retained" });
|
|
const other = await seedSession({ companyId: own.companyId, agentId: own.agentId, issueStatus: "todo" });
|
|
await startBackups(); await settled(); await quiet(60_000);
|
|
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.companyId, own.companyId));
|
|
const reads = vi.spyOn(fs, "readFile");
|
|
try {
|
|
await environmentService(db).releaseLease(lease!.id, "expired"); await settled();
|
|
expect(reads).toHaveBeenCalled();
|
|
expect(reads.mock.calls.every(call => String(call[0]).startsWith(own.root))).toBe(true);
|
|
} finally { reads.mockRestore(); }
|
|
expect(await exists(own.current)).toBe(false); expect(await exists(other.current)).toBe(true); await quiet();
|
|
});
|
|
|
|
it("retains the future deadline when a lease metadata edit removes its last backup reference", async () => {
|
|
const own = await seedSession({ leaseStatus: "retained", closedDaysAgo: 2 });
|
|
const other = await seedSession({ companyId: own.companyId, agentId: own.agentId, issueStatus: "todo" });
|
|
await startBackups(); await settled();
|
|
const [lease] = await db.select().from(environmentLeases).where(eq(environmentLeases.companyId, own.companyId));
|
|
const reads = vi.spyOn(fs, "readFile"); const due = now.getTime() + 5 * DAY_MS + 1;
|
|
try {
|
|
await environmentService(db).updateLeaseMetadata(lease!.id, {}); await settled(due);
|
|
expect(reads).toHaveBeenCalled(); expect(reads.mock.calls.every(call => String(call[0]).startsWith(own.root))).toBe(true);
|
|
} finally { reads.mockRestore(); }
|
|
await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(own.current)).toBe(false); expect(await exists(other.current)).toBe(true); await quiet();
|
|
});
|
|
|
|
it("allows an ordinary task reopen while backup byte counting is held outside the retirement lock", async () => {
|
|
const own = await seedSession(); const lstat = fs.lstat;
|
|
const entered = Promise.withResolvers<void>(), release = Promise.withResolvers<void>();
|
|
const held = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
|
if (String(args[0]) === path.join(own.current, "codex-home", "history.jsonl")) {
|
|
entered.resolve(); await release.promise;
|
|
}
|
|
return lstat(...args);
|
|
});
|
|
const startup = startBackups(); await entered.promise;
|
|
try {
|
|
await db.transaction(async tx => {
|
|
await tx.execute(sql`set local lock_timeout = '500ms'`);
|
|
await issueService(db).update(own.issueId, { status: "todo" }, tx);
|
|
});
|
|
} finally { release.resolve(); await startup; held.mockRestore(); }
|
|
await settled(); expect(await exists(own.current)).toBe(true); expect(backupErrors).toEqual([]); await quiet();
|
|
});
|
|
|
|
it.each(["EIO", "EACCES"])("retains a failed identity read as real retry debt (%s)", async code => {
|
|
const own = await seedSession(); const read = fs.readFile;
|
|
const fault = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
|
|
if (String(args[0]) === path.join(own.root, "control-plane", "control-plane-state.json")) {
|
|
throw Object.assign(new Error("controlled identity I/O failure"), { code });
|
|
}
|
|
return read(...args);
|
|
});
|
|
await startBackups(); expect(backupErrors).toHaveLength(1);
|
|
const retry = coordinator!.nextWakeAt()!; expect(retry - Date.now()).toBeGreaterThanOrEqual(29_000);
|
|
expect(await exists(own.current)).toBe(true); fault.mockRestore(); await quiet(5000);
|
|
await vi.advanceTimersByTimeAsync(retry - Date.now()); await settled();
|
|
expect(await exists(own.current)).toBe(false); expect(await taskSessionCount(own.agentId)).toBe(0); await quiet();
|
|
});
|
|
|
|
it.each(["EIO", "EACCES"])("retains expired-only cleanup after a directory read failure (%s)", async code => {
|
|
const session = await seedSession();
|
|
const backupRoot = path.dirname(session.current);
|
|
const expired = path.join(backupRoot, `.expired-${randomUUID()}`);
|
|
await fs.rename(session.current, expired);
|
|
const read = fs.readdir;
|
|
const fault = vi.spyOn(fs, "readdir").mockImplementation(async (...args) => {
|
|
if (String(args[0]) === backupRoot) throw Object.assign(new Error("controlled cleanup listing failure"), { code });
|
|
return read(...args);
|
|
});
|
|
await startBackups();
|
|
expect(backupErrors).toHaveLength(1);
|
|
const retry = coordinator!.nextWakeAt();
|
|
expect(retry).not.toBeNull();
|
|
expect(retry! - Date.now()).toBeGreaterThanOrEqual(29_000);
|
|
expect(await exists(session.current)).toBe(false);
|
|
expect(await exists(expired)).toBe(true);
|
|
fault.mockRestore();
|
|
await quiet(5000);
|
|
await vi.advanceTimersByTimeAsync(retry! - Date.now()); await settled();
|
|
expect(await fs.readdir(backupRoot)).toEqual([]);
|
|
await quiet();
|
|
});
|
|
|
|
it("reconstructs future retention without issue row locks", async () => {
|
|
await seedSession({ closedDaysAgo: 2 }); const queries: string[] = [];
|
|
const previous = db.$client.options.debug;
|
|
db.$client.options.debug = (_connection, query) => { queries.push(query); };
|
|
try {
|
|
await startBackups(); await settled(now.getTime() + 5 * DAY_MS + 1);
|
|
expect(queries.some(query => /for\s+share/i.test(query))).toBe(false);
|
|
} finally { db.$client.options.debug = previous; }
|
|
await quiet();
|
|
});
|
|
|
|
it("rejects destructive backup scopes outside the controller digest namespace", async () => {
|
|
await emptyStateBase(); const remove = vi.spyOn(fs, "rm");
|
|
await expect(service().expireBackup("../outside")).rejects.toThrow("Invalid native harness backup scope");
|
|
expect(remove).not.toHaveBeenCalled(); remove.mockRestore();
|
|
});
|
|
|
|
it.each([false, true])("finishes cleanup journals after ordinary agent deletion (restart %s)", async restart => {
|
|
const session = await seedSession(); const remove = fs.rm;
|
|
const fault = vi.spyOn(fs, "rm").mockImplementation(async (...args) => {
|
|
if (String(args[0]).includes(".expired-")) throw new Error("controlled orphan cleanup failure");
|
|
return remove(...args);
|
|
});
|
|
await startBackups();
|
|
expect(backupErrors).toHaveLength(1);
|
|
const retryAt = coordinator!.nextWakeAt()!;
|
|
const backupRoot = path.dirname(session.current);
|
|
expect((await fs.readdir(backupRoot)).some(name => name.startsWith(".expired-"))).toBe(true);
|
|
if (restart) { await backupWorker!.stop(); await coordinator!.stop(); }
|
|
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, session.agentId));
|
|
await agentService(db).remove(session.agentId);
|
|
expect(await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, session.runId))).toEqual([]);
|
|
fault.mockRestore();
|
|
if (restart) await startBackups(); else await vi.advanceTimersByTimeAsync(retryAt - Date.now());
|
|
await settled();
|
|
expect(await fs.readdir(backupRoot)).toEqual([]); expect(await taskSessionCount(session.agentId)).toBe(0);
|
|
await quiet();
|
|
});
|
|
|
|
it("restores an orphan retiring journal without expiring the recovered backup or unrelated names", async () => {
|
|
const session = await seedSession(); const backupRoot = path.dirname(session.current);
|
|
const retiring = path.join(backupRoot, `.retiring-${randomUUID()}`);
|
|
await fs.rename(session.current, retiring);
|
|
await fs.mkdir(path.join(backupRoot, ".expired-unrecognized"));
|
|
await db.update(agents).set({ status: "terminated", lifecycleState: "terminated" }).where(eq(agents.id, session.agentId));
|
|
await agentService(db).remove(session.agentId);
|
|
await fs.rm(path.join(session.root, "control-plane"), { recursive: true });
|
|
await startBackups(); await settled();
|
|
expect(await exists(session.current)).toBe(true); expect(await exists(retiring)).toBe(false);
|
|
expect(await exists(path.join(session.current, "manifest.json"))).toBe(true);
|
|
expect(await exists(path.join(backupRoot, ".expired-unrecognized"))).toBe(true);
|
|
await quiet();
|
|
});
|
|
|
|
it("retains live lease references and only removes the owning company's backup after lease release", async () => {
|
|
const first = await seedSession({ leaseStatus: "retained" });
|
|
const other = await seedSession({ leaseStatus: "retained" });
|
|
await startBackups(); await settled(); await quiet();
|
|
await writeEnvironmentCleanup(db, tx => tx.update(environmentLeases).set({ status: "expired" })
|
|
.where(eq(environmentLeases.companyId, first.companyId)), { companyId: first.companyId });
|
|
await settled(); expect(await exists(first.current)).toBe(false); expect(await exists(other.current)).toBe(true);
|
|
expect(await taskSessionCount(other.agentId)).toBe(1); await quiet();
|
|
});
|
|
|
|
it("retries a partial physical deletion from its tombstone without recreating a resume pointer", async () => {
|
|
const session = await seedSession(); const remove = fs.rm; let failed = false;
|
|
const fault = vi.spyOn(fs, "rm").mockImplementation(async (...args) => {
|
|
if (!failed && String(args[0]).includes(".expired-")) { failed = true; throw new Error("controlled filesystem delete failure"); }
|
|
return remove(...args);
|
|
});
|
|
await startBackups();
|
|
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(100); expect(backupErrors).toHaveLength(1); });
|
|
const retry = coordinator!.nextWakeAt()!; expect(retry - Date.now()).toBeGreaterThan(20_000);
|
|
expect(await taskSessionCount(session.agentId)).toBe(0); expect(await exists(session.current)).toBe(false);
|
|
fault.mockRestore(); await quiet(5000);
|
|
await vi.advanceTimersByTimeAsync(retry - Date.now()); await settled();
|
|
expect(await fs.readdir(path.join(session.root, "failover-backups"))).toEqual([]); await quiet();
|
|
});
|
|
|
|
it("retains a first ordinary close during drain and dispatches it after resume", async () => {
|
|
const session = await seedSession({ issueStatus: "todo" });
|
|
await startBackups(); await settled(); admitted = false;
|
|
const closed = await issueService(db).update(session.issueId, { status: "done" });
|
|
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); });
|
|
await quiet(60_000); admitted = true;
|
|
const due = new Date(closed!.completedAt!).getTime() + 7 * DAY_MS + 1;
|
|
await settled(due); await vi.advanceTimersByTimeAsync(due - Date.now()); await settled();
|
|
expect(await exists(session.current)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("honors outer rollback for nested ordinary close and activates only on committed work", async () => {
|
|
const session = await seedSession({ issueStatus: "todo" });
|
|
await startBackups(); await settled();
|
|
await expect(db.transaction(async outer => {
|
|
await outer.transaction(async nested => {
|
|
await issueService(db).update(session.issueId, { status: "done" }, nested);
|
|
});
|
|
expect(await exists(session.current)).toBe(true);
|
|
throw new Error("rollback outer close");
|
|
})).rejects.toThrow("rollback outer close");
|
|
await settled(); await quiet(8 * DAY_MS);
|
|
expect(await exists(session.current)).toBe(true);
|
|
const [saved] = await db.select().from(issues).where(eq(issues.id, session.issueId));
|
|
expect(saved!.status).toBe("todo");
|
|
});
|
|
|
|
it("pages multiple company backups, preserves a busy one and wakes from process release", async () => {
|
|
const sessions = [await seedSession(), await seedSession(), await seedSession()]; let busy = true;
|
|
await startBackups({ maxEntries: 1, maxRemovals: 1, isSessionBusy: digest => busy && digest === sessions[1]!.scope });
|
|
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000);
|
|
expect(await exists(sessions[0]!.current)).toBe(false); expect(await exists(sessions[2]!.current)).toBe(false);
|
|
expect(backupErrors.length).toBeGreaterThan(0);
|
|
});
|
|
expect(await exists(sessions[1]!.current)).toBe(true); busy = false;
|
|
noteNativeHarnessBackupChange(db, { companyId: sessions[1]!.companyId });
|
|
await settled(); expect(await exists(sessions[1]!.current)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("leaves empty evidence and retained backups quiet without invoking backup SQL", async () => {
|
|
const session = await seedSession();
|
|
const reads = vi.spyOn(db, "select"), writes = vi.spyOn(db, "transaction");
|
|
await startEvidence(); await settled(); expect(await exists(session.current)).toBe(true);
|
|
expect(reads).not.toHaveBeenCalled(); expect(writes).not.toHaveBeenCalled();
|
|
reads.mockRestore(); writes.mockRestore(); await quiet();
|
|
});
|
|
|
|
it("reconstructs and replaces the saved evidence deadline after a publication signal", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const first = await seedArchive(session.root, { stampedDaysAgo: 2 });
|
|
const firstDeadline = now.getTime() + 5 * DAY_MS + 1;
|
|
await startEvidence(); await settled(firstDeadline); await quiet();
|
|
const writtenAt = Date.now();
|
|
const next = await seedArchive(session.root, { name: `${writtenAt}-${randomUUID()}` });
|
|
noteNativeRunnerEvidence(writtenAt);
|
|
await settled(firstDeadline);
|
|
await vi.advanceTimersByTimeAsync(firstDeadline - Date.now());
|
|
await settled(writtenAt + 7 * DAY_MS + 1); expect(await exists(first)).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(writtenAt + 7 * DAY_MS + 1 - Date.now()); await settled();
|
|
expect(await exists(next)).toBe(false); expect(await exists(session.current)).toBe(true); await quiet();
|
|
});
|
|
|
|
it.each(["session root", "archive directory"])("preserves an evidence inspection failure at %s until its concrete retry succeeds", async location => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root);
|
|
const original = fs.lstat;
|
|
const denied = vi.spyOn(fs, "lstat").mockImplementation(async (...args) => {
|
|
if (String(args[0]) === (location === "session root" ? session.root : path.join(session.root, "continuity-breaks"))) throw Object.assign(new Error("unavailable"), { code: "EACCES" });
|
|
return original(...args);
|
|
});
|
|
await startEvidence(); expect(coordinator!.nextWakeAt()).not.toBeNull();
|
|
const retryAt = coordinator!.nextWakeAt()!; expect(await exists(archive)).toBe(true);
|
|
denied.mockRestore(); await quiet(5000);
|
|
await vi.advanceTimersByTimeAsync(retryAt - Date.now()); await settled();
|
|
expect(await exists(archive)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("retries a busy evidence obligation and disarms after the session releases it", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root); let busy = true;
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); vi.setSystemTime(now);
|
|
let inspectedAt: number | undefined;
|
|
await startEvidence({ isSessionBusy: () => { inspectedAt = Date.now(); return busy; } });
|
|
await vi.advanceTimersByTimeAsync(50);
|
|
const retryAt = coordinator!.nextWakeAt()!; expect(inspectedAt).toBeDefined();
|
|
expect(retryAt - inspectedAt!).toBeGreaterThanOrEqual(60 * 60_000);
|
|
await quiet(5000); busy = false;
|
|
await vi.advanceTimersByTimeAsync(retryAt - Date.now()); await settled();
|
|
expect(await exists(archive)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("retains the first publication during drain and cleans up on resume", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
await startEvidence(); await settled(); admitted = false;
|
|
const archive = await seedArchive(session.root);
|
|
noteNativeRunnerEvidence(now.getTime() - 10 * DAY_MS);
|
|
await vi.waitFor(async () => { await vi.advanceTimersByTimeAsync(1000); expect(idleWorkSnapshot().active).toBe(0); });
|
|
await quiet(60_000); expect(await exists(archive)).toBe(true);
|
|
admitted = true; await settled(); expect(await exists(archive)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("reconstructs the same retained deadline after restart", async () => {
|
|
const scope = await emptyStateBase(); await seedQuarantine(scope, { stampedDaysAgo: 6 });
|
|
const deadline = now.getTime() + DAY_MS + 1;
|
|
await startEvidence(); await settled(deadline);
|
|
worker!.unsubscribe(); await coordinator!.stop();
|
|
await startEvidence(); await settled(deadline); await quiet(60_000);
|
|
expect(coordinator!.nextWakeAt()).toBe(deadline);
|
|
});
|
|
|
|
it("backs off a failed state-base inspection and lets publication reactivate it", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root), read = fs.readdir;
|
|
vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); vi.setSystemTime(now);
|
|
let inspectedAt: number | undefined;
|
|
const denied = vi.spyOn(fs, "readdir").mockImplementation(async (...args) => {
|
|
if (args[0] === stateBase) {
|
|
inspectedAt = Date.now();
|
|
throw Object.assign(new Error("state base unavailable"), { code: "EACCES" });
|
|
}
|
|
return read(...args);
|
|
});
|
|
await startEvidence(); await vi.advanceTimersByTimeAsync(50);
|
|
expect(inspectedAt).toBeDefined();
|
|
expect(coordinator!.nextWakeAt()! - inspectedAt!).toBeGreaterThanOrEqual(60 * 60_000);
|
|
denied.mockRestore(); await quiet(5 * 60_000); expect(await exists(archive)).toBe(true);
|
|
noteNativeRunnerEvidence(now.getTime() - 10 * DAY_MS);
|
|
await settled(); expect(await exists(archive)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("lets healthy saved expiry preempt a long-busy evidence retry", async () => {
|
|
const busy = await seedSession({ issueStatus: "in_progress" }), healthy = await seedSession({ issueStatus: "in_progress" });
|
|
const held = await seedArchive(busy.root);
|
|
const dueAt = now.getTime() + 10_001;
|
|
const available = await seedArchive(healthy.root, { name: `${dueAt - 7 * DAY_MS - 1}-${randomUUID()}` });
|
|
await startEvidence({ isSessionBusy: scope => scope === busy.scope }); await settled(dueAt);
|
|
await vi.advanceTimersByTimeAsync(dueAt - Date.now());
|
|
await vi.waitFor(async () => {
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(await exists(available)).toBe(false);
|
|
expect(idleWorkSnapshot().active).toBe(0);
|
|
expect(coordinator!.nextWakeAt()! - Date.now()).toBeGreaterThan(59 * 60_000);
|
|
});
|
|
expect(await exists(held)).toBe(true);
|
|
await quiet(60_000);
|
|
});
|
|
|
|
it("reports reclaimed evidence bytes and dry-run mode", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root); const reported = vi.spyOn(logger, "info");
|
|
await startEvidence(); await settled();
|
|
expect(reported.mock.calls).toEqual(expect.arrayContaining([[expect.objectContaining({ evidenceCandidates: 1,
|
|
evidenceRemoved: 1, evidenceReclaimedBytes: expect.any(Number), dryRun: false }), "native runner evidence retention"]]));
|
|
expect(await exists(archive)).toBe(false); await quiet();
|
|
});
|
|
|
|
it("schedules publication at the first removable instant after its retention boundary", async () => {
|
|
const scope = await emptyStateBase(); await startEvidence(); await settled(); await quiet();
|
|
const writtenAt = Date.now();
|
|
await seedQuarantine(scope, { name: `${scope}.identity_indeterminate.${writtenAt}.4242.${randomUUID()}` });
|
|
noteNativeRunnerEvidence(writtenAt); await settled(writtenAt + 7 * DAY_MS + 1);
|
|
});
|
|
|
|
it("uses bounded continuations without recounting an evidence session forever", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archives = [await seedArchive(session.root), await seedArchive(session.root)];
|
|
const quarantine = await seedQuarantine(session.scope);
|
|
await startEvidence({ maxEntries: 1 }); await settled();
|
|
for (const archive of [...archives, quarantine]) expect(await exists(archive)).toBe(false);
|
|
await quiet();
|
|
});
|
|
|
|
it("removes continuity archives older than the retention window and keeps recent ones", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const old = await seedArchive(session.root, { stampedDaysAgo: 9 });
|
|
const recent = await seedArchive(session.root, { stampedDaysAgo: 2 });
|
|
|
|
const result = await evidenceService().tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 1, failed: 0 });
|
|
expect(result.evidenceReclaimedBytes).toBeGreaterThan(0);
|
|
expect(result.skipped.recent_evidence).toBe(1);
|
|
expect(await exists(old)).toBe(false);
|
|
expect(await exists(recent)).toBe(true);
|
|
expect(await exists(session.current)).toBe(true);
|
|
});
|
|
|
|
it("removes the continuity-breaks folder once its archives are gone", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
await seedArchive(session.root);
|
|
|
|
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(path.join(session.root, "continuity-breaks"))).toBe(false);
|
|
});
|
|
|
|
it("ages a continuity archive by its name and falls back to mtime for a future stamp", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const stampedRecently = await seedArchive(session.root, { stampedDaysAgo: 1, mtimeDaysAgo: 30 });
|
|
const futureStamp = await seedArchive(session.root, {
|
|
name: `${now.getTime() + DAY_MS}-${randomUUID()}`,
|
|
mtimeDaysAgo: 30,
|
|
});
|
|
const futureRecent = await seedArchive(session.root, {
|
|
name: `${now.getTime() + 2 * DAY_MS}-${randomUUID()}`,
|
|
mtimeDaysAgo: 1,
|
|
});
|
|
|
|
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(stampedRecently)).toBe(true);
|
|
expect(await exists(futureStamp)).toBe(false);
|
|
expect(await exists(futureRecent)).toBe(true);
|
|
});
|
|
|
|
it("ignores folders that are not continuity archives", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const other = await seedArchive(session.root, { name: "notes", stampedDaysAgo: 30 });
|
|
|
|
expect(await evidenceService().tick()).toMatchObject({ evidenceCandidates: 0 });
|
|
expect(await exists(other)).toBe(true);
|
|
});
|
|
|
|
it("keeps the evidence of a session that is executing in this process", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root);
|
|
const quarantine = await seedQuarantine(session.scope);
|
|
|
|
const result = await evidenceService({ isSessionBusy: (scope) => scope === session.scope }).tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
|
|
expect(result.skipped.session_active).toBe(3);
|
|
expect(await exists(archive)).toBe(true);
|
|
expect(await exists(quarantine)).toBe(true);
|
|
});
|
|
|
|
it("keeps evidence when its session starts while the removal is being prepared", async () => {
|
|
const scope = await emptyStateBase();
|
|
const root = path.join(stateBase, scope);
|
|
const archive = await seedArchive(root);
|
|
let checks = 0;
|
|
|
|
const result = await evidenceService({ isSessionBusy: () => ++checks > 1 }).tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 0 });
|
|
expect(result.skipped.session_active).toBe(1);
|
|
expect(await exists(archive)).toBe(true);
|
|
});
|
|
|
|
it("reports evidence without deleting it in dry run", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root);
|
|
const quarantine = await seedQuarantine(session.scope);
|
|
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
|
|
await fs.mkdir(leftover);
|
|
|
|
const result = await evidenceService({ dryRun: true }).tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 2, evidenceRemoved: 0, evidenceReclaimedBytes: 0 });
|
|
expect(result.evidenceCandidateBytes).toBeGreaterThan(0);
|
|
expect(await exists(archive)).toBe(true);
|
|
expect(await exists(quarantine)).toBe(true);
|
|
expect(await exists(leftover)).toBe(true);
|
|
});
|
|
|
|
it("skips symlinked evidence without following it", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-evidence-outside-"));
|
|
try {
|
|
await fs.writeFile(path.join(outside, "keep.txt"), "keep");
|
|
const stampedAt = now.getTime() - 10 * DAY_MS;
|
|
await fs.mkdir(path.join(session.root, "continuity-breaks"), { recursive: true });
|
|
const archiveLink = path.join(session.root, "continuity-breaks", `${stampedAt}-${randomUUID()}`);
|
|
await fs.symlink(outside, archiveLink);
|
|
await fs.mkdir(path.join(stateBase, "quarantine"), { recursive: true });
|
|
const quarantineLink = path.join(
|
|
stateBase,
|
|
"quarantine",
|
|
`${session.scope}.identity_indeterminate.${stampedAt}.4242.${randomUUID()}`,
|
|
);
|
|
await fs.symlink(outside, quarantineLink);
|
|
|
|
const result = await evidenceService().tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
|
|
expect(result.skipped.unsafe_path).toBe(2);
|
|
expect(await exists(archiveLink)).toBe(true);
|
|
expect(await exists(quarantineLink)).toBe(true);
|
|
expect(await exists(path.join(outside, "keep.txt"))).toBe(true);
|
|
} finally {
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("skips symlinked evidence folders without following them", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const outside = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-evidence-outside-"));
|
|
try {
|
|
const stampedAt = now.getTime() - 10 * DAY_MS;
|
|
const inside = path.join(outside, `${stampedAt}-${randomUUID()}`);
|
|
await fs.mkdir(inside);
|
|
await fs.utimes(inside, new Date(stampedAt), new Date(stampedAt));
|
|
await fs.symlink(outside, path.join(session.root, "continuity-breaks"));
|
|
await fs.symlink(outside, path.join(stateBase, "quarantine"));
|
|
|
|
const result = await evidenceService().tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 0, evidenceRemoved: 0 });
|
|
expect(result.skipped.unsafe_path).toBe(2);
|
|
expect(await exists(inside)).toBe(true);
|
|
} finally {
|
|
await fs.rm(outside, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("removes quarantine entries older than the retention window and keeps recent ones", async () => {
|
|
const scope = await emptyStateBase();
|
|
const old = await seedQuarantine(scope, { stampedDaysAgo: 9 });
|
|
const recent = await seedQuarantine(scope, { stampedDaysAgo: 2 });
|
|
|
|
const result = await evidenceService().tick();
|
|
|
|
expect(result).toMatchObject({ evidenceCandidates: 1, evidenceRemoved: 1, failed: 0 });
|
|
expect(result.skipped.recent_evidence).toBe(1);
|
|
expect(await exists(old)).toBe(false);
|
|
expect(await exists(recent)).toBe(true);
|
|
expect(await fs.readdir(path.join(stateBase, "quarantine"))).toEqual([path.basename(recent)]);
|
|
});
|
|
|
|
it("keeps cleanup source archives in quarantine", async () => {
|
|
const scope = await emptyStateBase();
|
|
const entry = await seedQuarantine(scope, { name: `${scope}.identity_indeterminate.cleanup.${randomUUID()}` });
|
|
const old = new Date(now.getTime() - 30 * DAY_MS);
|
|
await fs.utimes(entry, old, old);
|
|
|
|
const result = await evidenceService().tick();
|
|
|
|
expect(result.evidenceCandidates).toBe(0);
|
|
expect(result.skipped.cleanup_archive).toBe(1);
|
|
expect(await exists(entry)).toBe(true);
|
|
});
|
|
|
|
it("finishes removing evidence left over from an interrupted removal", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archive = await seedArchive(session.root, { stampedDaysAgo: 1 });
|
|
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
|
|
await fs.mkdir(path.join(leftover, "codex-home"), { recursive: true });
|
|
const quarantineLeftover = path.join(stateBase, "quarantine", `.expired-${randomUUID()}`);
|
|
await fs.mkdir(quarantineLeftover, { recursive: true });
|
|
|
|
await evidenceService().tick();
|
|
|
|
expect(await exists(leftover)).toBe(false);
|
|
expect(await exists(quarantineLeftover)).toBe(false);
|
|
expect(await exists(archive)).toBe(true);
|
|
});
|
|
|
|
it.each([false, true])("pages evidence removals across ticks (dry run %s)", async (dryRun) => {
|
|
const session = await seedSession({ issueStatus: "in_progress" });
|
|
const archives = [await seedArchive(session.root), await seedArchive(session.root), await seedArchive(session.root)];
|
|
const quarantine = await seedQuarantine(session.scope);
|
|
const retention = evidenceService({ dryRun, maxRemovals: 2 });
|
|
|
|
const results = [];
|
|
for (let i = 0; i < 4; i++) {
|
|
const result = await retention.tick();
|
|
results.push(result);
|
|
if (!result.truncated) break;
|
|
}
|
|
|
|
expect(results.map((result) => result.evidenceCandidates)).toEqual([2, 2]);
|
|
expect(results.map((result) => result.truncated)).toEqual([true, false]);
|
|
for (const archive of archives) expect(await exists(archive)).toBe(dryRun);
|
|
expect(await exists(quarantine)).toBe(dryRun);
|
|
});
|
|
|
|
it("does not recount a session's backup when its archives resume on the next tick", async () => {
|
|
const session = await seedSession();
|
|
await seedArchive(session.root);
|
|
await seedArchive(session.root);
|
|
const retention = evidenceService({ dryRun: true, maxRemovals: 2 });
|
|
|
|
const first = await retention.tick();
|
|
const second = await retention.tick();
|
|
|
|
expect(first).toMatchObject({ candidates: 1, evidenceCandidates: 1, truncated: true });
|
|
expect(second).toMatchObject({ candidates: 0, evidenceCandidates: 1, truncated: false });
|
|
});
|
|
it("removes old quarantine entries even when their session's issue is still open", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const entry = await seedQuarantine(session.scope);
|
|
await fs.cp(path.join(session.root, "control-plane"), path.join(entry, "control-plane"), { recursive: true });
|
|
|
|
expect(await evidenceService().tick()).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(entry)).toBe(false);
|
|
});
|
|
|
|
it("stays quiet until retained evidence is due and wakes when new evidence is written", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const recent = await seedArchive(session.root, { stampedDaysAgo: 2 });
|
|
const retention = evidenceService();
|
|
const at = (days: number) => ({ now: new Date(now.getTime() + days * DAY_MS) });
|
|
|
|
expect((await retention.tick()).skipped.recent_evidence).toBe(1);
|
|
expect((await retention.tick(at(4))).skipped.recent_evidence).toBeUndefined();
|
|
expect(await retention.tick(at(6))).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(recent)).toBe(false);
|
|
|
|
const written = await seedArchive(session.root, { stampedDaysAgo: 0 });
|
|
expect(await retention.tick(at(8))).toMatchObject({ evidenceCandidates: 0 });
|
|
retention.noteEvidence(now.getTime());
|
|
expect(await retention.tick(at(8))).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(written)).toBe(false);
|
|
});
|
|
|
|
it("retries evidence of a busy session on the next tick", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const archive = await seedArchive(session.root);
|
|
let busy = true;
|
|
const retention = evidenceService({ isSessionBusy: () => busy });
|
|
|
|
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 0 });
|
|
busy = false;
|
|
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(archive)).toBe(false);
|
|
});
|
|
|
|
it("reports dry-run evidence once until new evidence is written", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
await seedArchive(session.root);
|
|
const retention = evidenceService({ dryRun: true });
|
|
|
|
expect(await retention.tick()).toMatchObject({ evidenceCandidates: 1 });
|
|
expect(await retention.tick()).toMatchObject({ evidenceCandidates: 0 });
|
|
retention.noteEvidence(now.getTime());
|
|
expect(await retention.tick({ now: new Date(now.getTime() + 7 * DAY_MS) })).toMatchObject({ evidenceCandidates: 1 });
|
|
});
|
|
|
|
it("counts inspected archives and leftovers against the entry limit", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const archives = [
|
|
await seedArchive(session.root, { stampedDaysAgo: 1 }),
|
|
await seedArchive(session.root, { stampedDaysAgo: 2 }),
|
|
await seedArchive(session.root, { stampedDaysAgo: 3 }),
|
|
];
|
|
const leftovers = [randomUUID(), randomUUID()].map((id) => path.join(session.root, "continuity-breaks", `.expired-${id}`));
|
|
for (const leftover of leftovers) await fs.mkdir(leftover);
|
|
const retention = evidenceService({ maxEntries: 2 });
|
|
|
|
const first = await retention.tick();
|
|
expect(first).toMatchObject({ checked: 2, truncated: true });
|
|
expect(first.skipped.recent_evidence).toBeUndefined();
|
|
expect((await fs.readdir(path.join(session.root, "continuity-breaks"))).filter((name) => name.startsWith(".expired-"))).toHaveLength(1);
|
|
|
|
const results = [first];
|
|
for (let i = 0; i < 6 && results.at(-1)!.truncated; i++) results.push(await retention.tick());
|
|
expect(results.at(-1)!.truncated).toBe(false);
|
|
expect(results.every((result) => result.checked <= 2)).toBe(true);
|
|
expect(results.every((result) => (result.skipped.recent_evidence ?? 0) <= 2)).toBe(true);
|
|
expect(results.reduce((sum, result) => sum + (result.skipped.recent_evidence ?? 0), 0)).toBe(3);
|
|
for (const leftover of leftovers) expect(await exists(leftover)).toBe(false);
|
|
for (const archive of archives) expect(await exists(archive)).toBe(true);
|
|
});
|
|
|
|
it("stops removing leftovers once the sweep must stop", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
await fs.mkdir(path.join(session.root, "continuity-breaks"));
|
|
const leftover = path.join(session.root, "continuity-breaks", `.expired-${randomUUID()}`);
|
|
await fs.mkdir(leftover);
|
|
let calls = 0;
|
|
|
|
const retention = evidenceService({ shouldContinue: () => ++calls !== 2 });
|
|
|
|
await retention.tick();
|
|
expect(await exists(leftover)).toBe(true);
|
|
await retention.tick();
|
|
expect(await exists(leftover)).toBe(false);
|
|
});
|
|
|
|
it("keeps a due evidence pass pending when drain stops it before any session", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const archive = await seedArchive(session.root);
|
|
let draining = true;
|
|
const retention = evidenceService({ shouldContinue: () => !draining });
|
|
|
|
expect(await retention.tick()).toMatchObject({ checked: 0, truncated: true });
|
|
draining = false;
|
|
expect(await retention.tick()).toMatchObject({ evidenceRemoved: 1 });
|
|
expect(await exists(archive)).toBe(false);
|
|
});
|
|
|
|
it("finishes one-entry batches without recounting a resumed session", async () => {
|
|
const session = await seedSession({ issueStatus: "in_progress", backupAgeDays: 1 });
|
|
const archives = [await seedArchive(session.root), await seedArchive(session.root)];
|
|
const quarantine = await seedQuarantine(session.scope);
|
|
const retention = evidenceService({ maxEntries: 1 });
|
|
|
|
const results = [];
|
|
for (let i = 0; i < 8; i++) {
|
|
results.push(await retention.tick());
|
|
if (!results.at(-1)!.truncated) break;
|
|
}
|
|
|
|
expect(results.at(-1)!.truncated).toBe(false);
|
|
for (const archive of archives) expect(await exists(archive)).toBe(false);
|
|
expect(await exists(quarantine)).toBe(false);
|
|
});
|
|
});
|