mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
fix(server): expire native backups from retention deadlines (#18380)
Reviewed and verified at 5b137b5cda.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
453f5fbb78
commit
75fa37f30f
17 files changed
+1015
-144
No files matched your search
@@ -0,0 +1,63 @@
|
||||
# Backup expiration without idle polling
|
||||
|
||||
The backup expiration duty retains its existing minimum age and safety policy.
|
||||
It uses the shared scoped deadline coordinator. The archived runner evidence and
|
||||
checkpoint expiration duties keep their separate registrations.
|
||||
|
||||
The durable source is each controller-owned `failover-backups/current` directory,
|
||||
its control-plane session identity, and the associated run, issue, finalization,
|
||||
and environment lease rows. The next eligible instant is one millisecond after
|
||||
the later of the backup mtime and every related issue's close time, plus the
|
||||
configured retention duration. An open issue, live run or lease, unsettled
|
||||
finalization, or cleanup activation has no time-derived expiry; its producer
|
||||
must invalidate the source when it changes. A busy process or failed I/O leaves
|
||||
real unresolved cleanup and uses the shared retry backoff. Dry-run reports an
|
||||
eligible source once and waits for source invalidation or restart.
|
||||
|
||||
Startup pages existing disk scopes and indexes each backup's actual session,
|
||||
run, coalesced native-task/task, agent and lease references, including protected
|
||||
backups. Ordinary task, run, lease and process-release notifications retain
|
||||
these identities and inspect only affected roots. New run or lease references
|
||||
resolve through the owner's database relationships. Local publication retains
|
||||
its exact digest. Explicit company or instance configuration changes can still
|
||||
reconstruct their whole affected scope. Roots without backup or cleanup tombstones
|
||||
perform no backup SQL. After discovery and all obligations settle, there is no
|
||||
recurring SQL or filesystem scan. Startup reconstructs future work; inspecting a
|
||||
source never changes its business timestamps. Runtime task close/reopen and
|
||||
retention timestamp changes, native environment and retained session writes,
|
||||
local backup publication, and process release invalidate the company or digest.
|
||||
Signals from database producers register before writes inside their owning
|
||||
transaction, including caller-owned transactions and nested savepoints.
|
||||
|
||||
The controller remains the only supported writer of its local backup files.
|
||||
An independent process writing those files must use the same producer ingress;
|
||||
manual disk edits require reconstruction on restart. Database writer ingress
|
||||
continues to use the existing transaction-aware work signals. This change adds
|
||||
no permanent database listener and does not add a polling table or migration.
|
||||
|
||||
Read-only reconstruction does not lock issue rows. Transient identity I/O errors
|
||||
retain a retry obligation; missing or malformed state remains unrecognized.
|
||||
Byte accounting occurs before the retirement transaction. The current backup
|
||||
age is rechecked inside the transaction before the rename.
|
||||
|
||||
The existing issue share locks now remain held through the retirement rename.
|
||||
This fences task reopen while the backup becomes unavailable. A failed database
|
||||
transaction restores `.retiring-*`; a process interruption is recovered from
|
||||
that existing file journal at startup. Journal reconciliation runs before
|
||||
identity or owner lookup, so an agent purge cannot strand expired physical
|
||||
cleanup or prevent restoration of a retiring backup. After database settlement, `.expired-*`
|
||||
records outstanding physical removal so a partial delete retries idempotently.
|
||||
Ownership, session identity, live run, lease and local execution safety checks
|
||||
remain in the backup owner. No host-sleep blocker or expiry-on-use rule changes.
|
||||
An in-process deadline does not register a Cloud wake alarm.
|
||||
|
||||
Acceptance uses isolated real PostgreSQL for service writes, transaction signals,
|
||||
safety checks, resume pointer deletion, and restart reconstruction. A temporary
|
||||
filesystem is the controlled storage boundary. These tests do not claim cloud
|
||||
object storage behavior, deployed sleep, or fleet savings.
|
||||
|
||||
Review validation also covers expired-only directories whose listing fails with
|
||||
EIO or EACCES. Only ENOENT means absent; other listing failures retain retry debt.
|
||||
Both controls failed before the fix; all 104 expiration tests pass afterward.
|
||||
Executor release tests assert both the preserved broad cleanup scope and the
|
||||
precise source identity used for backup invalidation.
|
||||
@@ -45,9 +45,9 @@ reader.on('line', line => {
|
||||
if(request.method==='initialize') {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:1,agentInfo:{name:'antigravity-acp',version:'1.4.3'},agentCapabilities:{_meta:{antigravity:{paperclipAdmission:1}}},authMethods:[{id:'oauth-personal'}]}})+'\\n');
|
||||
} else if(request.method==='authenticate') {
|
||||
const url = mode==='bad-url' ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
const url = mode.startsWith('bad-url') ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
const message='Open the following link to authenticate the ACP server: '+url+'\\n';
|
||||
process.stderr.write(message.slice(0,42));process.stderr.write(message.slice(42));
|
||||
if (!mode.endsWith('url-after-result')) {process.stderr.write(message.slice(0,42));process.stderr.write(message.slice(42));}
|
||||
if(mode==='blocked' || mode==='bad-url' || mode==='stubborn' || mode==='retained') return;
|
||||
if(mode==='error') {process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,error:{code:-32000,message:'do not expose fixture-refresh-token'}})+'\\n');return;}
|
||||
fs.mkdirSync(path.join(home,'antigravity-acp'),{mode:0o700});
|
||||
@@ -55,7 +55,14 @@ reader.on('line', line => {
|
||||
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{}})+'\\n');
|
||||
} else {throw new Error('inference/session request forbidden');}
|
||||
});
|
||||
reader.on('close',()=>mode==='stubborn'||mode==='retained'?setInterval(()=>{},1000):process.exit(0));
|
||||
reader.on('close',()=> {
|
||||
if (mode.endsWith('url-after-result')) {
|
||||
const url = mode.startsWith('bad-url') ? 'https://example.invalid/steal' : ${JSON.stringify(authorizationUrl())};
|
||||
// EOF proves the client received the authenticate result before this stderr.
|
||||
process.stderr.write('Open the following link to authenticate the ACP server: '+url+'\\n', () => process.exit(0));
|
||||
} else if(mode==='stubborn'||mode==='retained') setInterval(()=>{},1000);
|
||||
else process.exit(0);
|
||||
});
|
||||
`);
|
||||
setup.verify.mockResolvedValue({ openCommand: async () => ({ spawn: setup.spawn, close: setup.close }) });
|
||||
setup.spawn.mockImplementation((_args, options) => {
|
||||
@@ -95,8 +102,8 @@ describe("Antigravity setup-only Google authentication", () => {
|
||||
|
||||
// The login predicate keeps its five-second bound. Allow ten more seconds
|
||||
// for fixture startup and the durable mocked credential checks on a busy CI worker.
|
||||
it("performs native authenticate, retires it, verifies identity, and preserves refresh rotation without repeated status network calls", async () => {
|
||||
const home = await fixture(), fetch = googleIdentity(); vi.stubGlobal("fetch", fetch);
|
||||
it.each(["success", "url-after-result"])("performs native authenticate, retires it, verifies identity, and preserves refresh rotation without repeated status network calls (%s)", async mode => {
|
||||
const home = await fixture(mode), fetch = googleIdentity(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("success"), { timeout: 5_000 });
|
||||
expect(login.authorizationUrl).toBe(authorizationUrl());
|
||||
@@ -118,13 +125,13 @@ describe("Antigravity setup-only Google authentication", () => {
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
}, 15_000);
|
||||
|
||||
it.each(["error", "bad-url"])("rejects %s without leaking provider text or calling Google", async mode => {
|
||||
it.each(["error", "bad-url", "bad-url-after-result"])("rejects %s without leaking provider text or calling Google", async mode => {
|
||||
const home = await fixture(mode), fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
|
||||
const login = startAntigravityBrowserLogin(home);
|
||||
await vi.waitFor(() => expect(login.outcome).toBe("failure"), { timeout: 5_000 });
|
||||
await vi.waitFor(() => expect(setup.close).toHaveBeenCalledOnce(), { timeout: 5_000 });
|
||||
expect(login.error).not.toContain("fixture");
|
||||
if (mode === "bad-url") expect(login.authorizationUrl).toBeUndefined();
|
||||
if (mode.startsWith("bad-url")) expect(login.authorizationUrl).toBeUndefined();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
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";
|
||||
@@ -8,13 +17,15 @@ 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 } from "drizzle-orm";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
agentTaskSessions,
|
||||
companies,
|
||||
createDb,
|
||||
environmentLeases,
|
||||
environments,
|
||||
sandboxCreationOperations,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
nativeRunFinalizations,
|
||||
@@ -55,7 +66,10 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
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);
|
||||
@@ -75,6 +89,7 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
});
|
||||
|
||||
async function seedSession(input: {
|
||||
companyId?: string; agentId?: string;
|
||||
issueStatus?: string;
|
||||
closedDaysAgo?: number;
|
||||
runStatus?: string;
|
||||
@@ -84,9 +99,10 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
cleanupActive?: boolean;
|
||||
} = {}) {
|
||||
if (!stateBase) stateBase = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-durable-sessions-"));
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Backups", issuePrefix: `B${companyId.slice(0, 6)}` });
|
||||
const [agent] = await db.insert(agents).values({ companyId, name: "Coder" }).returning();
|
||||
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({
|
||||
@@ -567,6 +583,481 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
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 = [];
|
||||
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");
|
||||
@@ -608,8 +1099,12 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
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;
|
||||
await startEvidence({ isSessionBusy: () => busy });
|
||||
const retryAt = coordinator!.nextWakeAt()!; expect(retryAt - Date.now()).toBeGreaterThanOrEqual(60 * 60_000);
|
||||
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();
|
||||
@@ -637,11 +1132,18 @@ describeEmbeddedPostgres("native harness backup expiration", () => {
|
||||
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) throw Object.assign(new Error("state base unavailable"), { code: "EACCES" });
|
||||
if (args[0] === stateBase) {
|
||||
inspectedAt = Date.now();
|
||||
throw Object.assign(new Error("state base unavailable"), { code: "EACCES" });
|
||||
}
|
||||
return read(...args);
|
||||
});
|
||||
await startEvidence(); expect(coordinator!.nextWakeAt()! - Date.now()).toBeGreaterThanOrEqual(60 * 60_000);
|
||||
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();
|
||||
|
||||
@@ -592,6 +592,8 @@ vi.mock("../services/native-runtime/native-harness-backup-expiration.js", () =>
|
||||
tick: vi.fn(async () => ({ checked: 0, candidates: 0, evidenceCandidates: 0, failed: 0, truncated: false })),
|
||||
noteEvidence: vi.fn(), nextEvidenceAt: vi.fn(() => null),
|
||||
tickCheckpoints: vi.fn(), registerCheckpointExpiration: vi.fn(),
|
||||
backupScopes: vi.fn(async () => ({ names: [], complete: true })),
|
||||
reconcileBackup: vi.fn(async () => null), expireBackup: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -767,9 +769,12 @@ describe("startServer feedback export wiring", () => {
|
||||
loadConfigMock.mockReturnValue(buildTestConfig({ nativeHarnessBackupExpirationEnabled: enabled }));
|
||||
await startServer(); await settleStartupHousekeeping();
|
||||
expect(deliveryWorkRegisterMock.mock.calls.filter(([queue]) => queue === DELIVERY_QUEUES.nativeRunnerEvidenceRetention)).toHaveLength(enabled ? 1 : 0);
|
||||
expect(vi.mocked(nativeHarnessBackupExpirationService).mock.calls.map(([, options]) => options?.duty)).toEqual(enabled ? ["backups", "evidence"] : ["backups"]);
|
||||
expect(vi.mocked(nativeHarnessBackupExpirationService).mock.calls.map(([, options]) => options?.duty)).toEqual(enabled ? ["backups", "backups", "evidence"] : ["backups"]);
|
||||
const backups = vi.mocked(nativeHarnessBackupExpirationService).mock.results[0]!.value;
|
||||
expect(backups.registerCheckpointExpiration).toHaveBeenCalledTimes(enabled ? 1 : 0);
|
||||
expect(deliveryWorkRegisterMock.mock.calls.filter(([queue]) => queue === DELIVERY_QUEUES.nativeHarnessBackupExpiration)).toHaveLength(enabled ? 1 : 0);
|
||||
expect(backups.tick).not.toHaveBeenCalled();
|
||||
expect(deliveryWorkErrors.filter(error => error.queue === DELIVERY_QUEUES.nativeHarnessBackupExpiration)).toEqual([]);
|
||||
});
|
||||
|
||||
it("tracks held evidence reconstruction after HTTP readiness", async () => {
|
||||
@@ -777,6 +782,9 @@ describe("startServer feedback export wiring", () => {
|
||||
const pending = Promise.withResolvers<{ truncated: boolean }>();
|
||||
vi.mocked(nativeHarnessBackupExpirationService).mockReturnValueOnce({ tick: vi.fn(), noteEvidence: vi.fn(), nextEvidenceAt: () => null,
|
||||
tickCheckpoints: vi.fn(), registerCheckpointExpiration: vi.fn() } as never);
|
||||
vi.mocked(nativeHarnessBackupExpirationService).mockReturnValueOnce({
|
||||
backupScopes: vi.fn(async () => ({ names: [], complete: true })), reconcileBackup: vi.fn(), expireBackup: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(nativeHarnessBackupExpirationService).mockReturnValueOnce({ tick: vi.fn(() => pending.promise), noteEvidence: vi.fn(), nextEvidenceAt: () => null,
|
||||
tickCheckpoints: vi.fn(), registerCheckpointExpiration: vi.fn() } as never);
|
||||
try {
|
||||
|
||||
+7
-29
@@ -1,3 +1,4 @@
|
||||
import { registerNativeHarnessBackupExpirationWork } from "./services/native-runtime/native-harness-backup-expiration-work.js";
|
||||
import { registerMergeConfirmationWork } from "./services/merged-pr-confirmation-work.js";
|
||||
import { registerRoutineExecutionStatusWork } from "./services/routine-execution-status-work.js";
|
||||
import { routineExecutionStatusService } from "./services/routine-run-status.js";
|
||||
@@ -36,8 +37,6 @@ import {
|
||||
skillVersionRetentionService,
|
||||
} from "./services/skill-version-retention.js";
|
||||
import {
|
||||
NATIVE_HARNESS_BACKUP_EXPIRATION_INTERVAL_MS,
|
||||
NATIVE_HARNESS_BACKUP_EXPIRATION_STARTUP_DELAY_MS,
|
||||
nativeHarnessBackupExpirationService,
|
||||
} from "./services/native-runtime/native-harness-backup-expiration.js";
|
||||
import { registerNativeRunnerEvidenceRetentionWork } from "./services/native-runtime/native-runner-evidence-retention-work.js";
|
||||
@@ -1293,30 +1292,6 @@ async function startServerWithDatabaseTeardown(
|
||||
evidenceRetentionDays: config.nativeRunnerEvidenceRetentionDays,
|
||||
shouldContinue: () => !heartbeatSchedulerStopped && !isIdleTaskDrainActive() && !isWarmStandby(),
|
||||
});
|
||||
let nativeHarnessBackupExpirationInFlight = false;
|
||||
let nativeHarnessBackupExpirationContinuation: ReturnType<typeof setTimeout> | undefined;
|
||||
const sweepNativeHarnessBackups = () => {
|
||||
if (!config.nativeHarnessBackupExpirationEnabled || nativeHarnessBackupExpirationInFlight || isIdleTaskDrainActive() || heartbeatSchedulerStopped || isWarmStandby()) return;
|
||||
nativeHarnessBackupExpirationInFlight = true;
|
||||
trackHeartbeatSchedulerWork(nativeHarnessBackupExpiration.tick()
|
||||
.then((result) => {
|
||||
if (result.candidates > 0 || result.evidenceCandidates > 0 || result.failed > 0) {
|
||||
logger.info({ ...result, dryRun: config.nativeHarnessBackupExpirationDryRun }, "native harness backup expiration");
|
||||
}
|
||||
if (result.truncated && !heartbeatSchedulerStopped) {
|
||||
nativeHarnessBackupExpirationContinuation = setTimeout(sweepNativeHarnessBackups, 1000);
|
||||
nativeHarnessBackupExpirationContinuation.unref?.();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "native harness backup expiration failed");
|
||||
})
|
||||
.finally(() => { nativeHarnessBackupExpirationInFlight = false; }));
|
||||
};
|
||||
const nativeHarnessBackupExpirationInterval = setInterval(sweepNativeHarnessBackups, NATIVE_HARNESS_BACKUP_EXPIRATION_INTERVAL_MS);
|
||||
nativeHarnessBackupExpirationInterval.unref?.();
|
||||
const nativeHarnessBackupExpirationStartup = setTimeout(sweepNativeHarnessBackups, NATIVE_HARNESS_BACKUP_EXPIRATION_STARTUP_DELAY_MS);
|
||||
nativeHarnessBackupExpirationStartup.unref?.();
|
||||
const externalObjects = externalObjectService(db as any, {
|
||||
pluginWorkerManager,
|
||||
enabled: async () => (await instanceSettingsService(db).getExperimental()).enableExternalObjects === true,
|
||||
@@ -1351,6 +1326,12 @@ async function startServerWithDatabaseTeardown(
|
||||
() => !heartbeatSchedulerStopped && !isIdleTaskDrainActive() && !isWarmStandby());
|
||||
if (config.nativeHarnessBackupExpirationEnabled) {
|
||||
nativeHarnessBackupExpiration.registerCheckpointExpiration(deliveryWork);
|
||||
const backupExpirationWork = registerNativeHarnessBackupExpirationWork(deliveryWork, db as any, {
|
||||
dryRun: config.nativeHarnessBackupExpirationDryRun,
|
||||
closedForDays: config.nativeHarnessBackupExpirationDays,
|
||||
}, () => !heartbeatSchedulerStopped && !isIdleTaskDrainActive() && !isWarmStandby());
|
||||
trackHeartbeatSchedulerWork(backupExpirationWork.ready);
|
||||
server.on("close", backupExpirationWork.stop);
|
||||
}
|
||||
const taskDrainExpiryWork = registerTaskDrainExpiryWork(deliveryWork, () => {
|
||||
if (heartbeatSchedulerStopped || isWarmStandby()) return;
|
||||
@@ -2093,9 +2074,6 @@ async function startServerWithDatabaseTeardown(
|
||||
heartbeatSchedulerStopped = true;
|
||||
skillVersionRetentionWorker?.unsubscribe();
|
||||
await taskDrainExpiryWork.stop();
|
||||
clearInterval(nativeHarnessBackupExpirationInterval);
|
||||
clearTimeout(nativeHarnessBackupExpirationStartup);
|
||||
clearTimeout(nativeHarnessBackupExpirationContinuation);
|
||||
nativeRunnerEvidenceRetentionWorker?.unsubscribe();
|
||||
await skillSourceSync.stop();
|
||||
unsubscribeChatCompletions();
|
||||
|
||||
@@ -134,6 +134,7 @@ export const DELIVERY_QUEUES = {
|
||||
issueMonitors: "issue-monitors",
|
||||
agentTimers: "agent-timers",
|
||||
caseHandoffs: "case-handoffs",
|
||||
nativeHarnessBackupExpiration: "native-harness-backup-expiration",
|
||||
nativeCheckpointExpiration: "native-checkpoint-expiration",
|
||||
} as const;
|
||||
export type DeliveryQueue = typeof DELIVERY_QUEUES[keyof typeof DELIVERY_QUEUES];
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function attachSandboxCreation(db: Db, id: string, scope: { company
|
||||
}
|
||||
if (!operation || operation.state !== "pending") throw new Error("Sandbox creation ownership has already transferred");
|
||||
const lease = await attach(tx as unknown as Db);
|
||||
await notifyEnvironmentCleanup(tx, { companyId: scope.companyId });
|
||||
await notifyEnvironmentCleanup(tx, { companyId: scope.companyId, runId: operation.runId });
|
||||
await tx.update(sandboxCreationOperations).set({ state: "attached", leaseId: lease.id, updatedAt: new Date() })
|
||||
.where(eq(sandboxCreationOperations.id, id));
|
||||
return lease;
|
||||
@@ -1705,7 +1705,7 @@ export function environmentService(db: Db) {
|
||||
creationOperationId: sql<string | null>`${environmentLeases.metadata}->>'creationOperationId'`,
|
||||
}).from(environmentLeases).where(condition).for("update");
|
||||
if (!previous) return null;
|
||||
await notifyEnvironmentCleanup(tx, { companyId: previous.companyId, runId: previous.heartbeatRunId ?? undefined });
|
||||
await notifyEnvironmentCleanup(tx, { companyId: previous.companyId, runId: previous.heartbeatRunId ?? undefined, leaseId: id });
|
||||
if (previous.heartbeatRunId && (previous.status !== status ||
|
||||
(options?.cleanupStatus !== undefined && previous.cleanupStatus !== options.cleanupStatus) ||
|
||||
(status !== "retained" && !previous.releasedAt))) {
|
||||
@@ -1830,7 +1830,7 @@ export function environmentService(db: Db) {
|
||||
const [lease] = await tx.select({ companyId: environmentLeases.companyId, heartbeatRunId: environmentLeases.heartbeatRunId, metadata: environmentLeases.metadata })
|
||||
.from(environmentLeases).where(eq(environmentLeases.id, id)).for("update");
|
||||
if (!lease) return null;
|
||||
await notifyEnvironmentCleanup(tx, { companyId: lease.companyId, runId: lease.heartbeatRunId ?? undefined });
|
||||
await notifyEnvironmentCleanup(tx, { companyId: lease.companyId, runId: lease.heartbeatRunId ?? undefined, leaseId: id });
|
||||
return tx.update(environmentLeases)
|
||||
.set({
|
||||
metadata: preserveReusableLeaseHandoff(lease.metadata, withoutCleanupVerification(metadata)),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyNativeHarnessBackupExpiration } from "./native-harness-backup-expiration-notifications.js";
|
||||
import { notifyExternalObjectRefresh } from "./external-object-refresh-notifications.js";
|
||||
import { notifyMergeConfirmationWork } from "./merged-pr-confirmation-notifications.js";
|
||||
import { notifyRoutineExecutionStatus } from "./routine-execution-status-notifications.js";
|
||||
@@ -11540,6 +11541,9 @@ export function issueService(db: Db) {
|
||||
(blockedByIssueIds !== undefined || issueData.unblockDescriptor !== undefined)))) {
|
||||
patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number;
|
||||
}
|
||||
if (patch.status !== undefined || patch.completedAt !== undefined || patch.cancelledAt !== undefined) {
|
||||
await notifyNativeHarnessBackupExpiration(tx, { companyId: receiptExisting.companyId, issueId: id });
|
||||
}
|
||||
await notifyStatusCardIssueChange(tx, receiptExisting.companyId);
|
||||
if ((patch.status !== undefined && patch.status !== receiptExisting.status)
|
||||
|| patch.completedAt !== undefined || patch.cancelledAt !== undefined
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { notifyNativeHarnessBackupExpiration } from "./native-harness-backup-expiration-notifications.js";
|
||||
import { notifySkillVersionRetention } from "./skill-version-retention-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type NativeEnvironmentSettlementScope = { companyId: string; runId?: string; issueId?: string };
|
||||
export type NativeEnvironmentSettlementScope = { companyId: string; runId?: string; issueId?: string; leaseId?: string };
|
||||
export async function notifyNativeEnvironmentSettlement(tx: object, scope?: NativeEnvironmentSettlementScope) {
|
||||
await notifyNativeHarnessBackupExpiration(tx, scope);
|
||||
if (scope) await notifySkillVersionRetention(tx, scope.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeEnvironmentSettlement,
|
||||
scope ? JSON.stringify({ companyId: scope.companyId, runId: scope.runId, issueId: scope.issueId }) : "*");
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
export type NativeHarnessBackupExpirationScope = {
|
||||
companyId: string; scopeDigest?: string; leaseId?: string; runId?: string; issueId?: string; agentId?: string; sessionId?: string;
|
||||
};
|
||||
|
||||
export function nativeHarnessBackupScopeHint(scope: NativeHarnessBackupExpirationScope) {
|
||||
return JSON.stringify({ companyId: scope.companyId, scopeDigest: scope.scopeDigest, leaseId: scope.leaseId,
|
||||
runId: scope.runId, issueId: scope.issueId, agentId: scope.agentId, sessionId: scope.sessionId });
|
||||
}
|
||||
|
||||
export function notifyNativeHarnessBackupExpiration(tx: object, scope?: NativeHarnessBackupExpirationScope) {
|
||||
return notifyDeliveryWork(tx, DELIVERY_QUEUES.nativeHarnessBackupExpiration, scope ? nativeHarnessBackupScopeHint(scope) : "*");
|
||||
}
|
||||
|
||||
const listeners = new WeakMap<object, Set<(scope: NativeHarnessBackupExpirationScope) => void>>();
|
||||
export function onNativeHarnessBackupChange(db: object, listener: (scope: NativeHarnessBackupExpirationScope) => void) {
|
||||
let owned = listeners.get(db);
|
||||
if (!owned) { owned = new Set(); listeners.set(db, owned); }
|
||||
owned.add(listener);
|
||||
return () => { owned.delete(listener); if (!owned.size) listeners.delete(db); };
|
||||
}
|
||||
|
||||
export function noteNativeHarnessBackupChange(db: object, scope: NativeHarnessBackupExpirationScope) {
|
||||
for (const listener of listeners.get(db) ?? []) listener(scope);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { logger } from "../../middleware/logger.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import { nativeHarnessBackupScopeHint, onNativeHarnessBackupChange, type NativeHarnessBackupExpirationScope } from "../native-harness-backup-expiration-notifications.js";
|
||||
import { onRetainedNativeSessionCleanupRelease } from "../retained-native-session-cleanup-notifications.js";
|
||||
import { registerScopedDeadlineWork } from "../scoped-deadline-work.js";
|
||||
import { nativeHarnessBackupExpirationService, type NativeHarnessBackupDependencies, type NativeHarnessBackupExpirationOptions } from "./native-harness-backup-expiration.js";
|
||||
|
||||
type Dependencies = { companyId: string; agentId: string; sessionId: string; runIds: Set<string>; issueIds: Set<string>; leaseIds: Set<string> };
|
||||
type Entry = { id: string; companyId: string; agentId: string; dueAt: number; dependencies: Dependencies };
|
||||
type Reference = "company" | "agent" | "session" | "run" | "issue" | "lease";
|
||||
|
||||
export function registerNativeHarnessBackupExpirationWork(
|
||||
coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, db: Db,
|
||||
options: NativeHarnessBackupExpirationOptions, canRun: () => boolean,
|
||||
) {
|
||||
const service = nativeHarnessBackupExpirationService(db, { ...options, duty: "backups" });
|
||||
const cursors = new Map<string, string>();
|
||||
const sources = new Map<string, Dependencies>();
|
||||
const index = new Map<string, Set<string>>();
|
||||
const key = (companyId: string, kind: Reference, id = "") => JSON.stringify([companyId, kind, id]);
|
||||
const sessionKey = (agentId: string, sessionId: string) => JSON.stringify([agentId, sessionId]);
|
||||
function references(source: Dependencies) {
|
||||
return [key(source.companyId, "company"), key(source.companyId, "agent", source.agentId),
|
||||
key(source.companyId, "session", sessionKey(source.agentId, source.sessionId)),
|
||||
...[...source.runIds].map(id => key(source.companyId, "run", id)),
|
||||
...[...source.issueIds].map(id => key(source.companyId, "issue", id)),
|
||||
...[...source.leaseIds].map(id => key(source.companyId, "lease", id))];
|
||||
}
|
||||
function add(reference: string, id: string) {
|
||||
let roots = index.get(reference);
|
||||
if (!roots) { roots = new Set(); index.set(reference, roots); }
|
||||
roots.add(id);
|
||||
}
|
||||
function remember(id: string, current?: NativeHarnessBackupDependencies) {
|
||||
const previous = sources.get(id);
|
||||
if (previous) for (const reference of references(previous)) {
|
||||
const roots = index.get(reference)!;
|
||||
roots.delete(id); if (!roots.size) index.delete(reference);
|
||||
}
|
||||
if (!current) { sources.delete(id); return undefined; }
|
||||
const source = { companyId: current.companyId, agentId: current.agentId,
|
||||
sessionId: current.sessionId, runIds: new Set(current.runIds), issueIds: new Set(current.issueIds), leaseIds: new Set(current.leaseIds) };
|
||||
sources.set(id, source);
|
||||
for (const reference of references(source)) add(reference, id);
|
||||
return source;
|
||||
}
|
||||
function selected(scope: NativeHarnessBackupExpirationScope): Set<string> {
|
||||
if (scope.scopeDigest) return new Set([scope.scopeDigest]);
|
||||
const reference = scope.leaseId ? key(scope.companyId, "lease", scope.leaseId)
|
||||
: scope.runId ? key(scope.companyId, "run", scope.runId)
|
||||
: scope.sessionId && scope.agentId ? key(scope.companyId, "session", sessionKey(scope.agentId, scope.sessionId))
|
||||
: scope.issueId ? key(scope.companyId, "issue", scope.issueId)
|
||||
: scope.agentId ? key(scope.companyId, "agent", scope.agentId) : key(scope.companyId, "company");
|
||||
return new Set(index.get(reference) ?? []);
|
||||
}
|
||||
async function discoverReferences(scope: NativeHarnessBackupExpirationScope) {
|
||||
if (scope.scopeDigest || (!scope.runId && !scope.leaseId)) return;
|
||||
const resolved = await service.resolveBackupScope(scope);
|
||||
const roots = selected(scope);
|
||||
if (resolved.scopeDigest) roots.add(resolved.scopeDigest);
|
||||
const related = resolved.sessionId && resolved.agentId
|
||||
? selected({ companyId: scope.companyId, sessionId: resolved.sessionId, agentId: resolved.agentId })
|
||||
: resolved.issueId ? selected({ companyId: scope.companyId, issueId: resolved.issueId }) : [];
|
||||
for (const id of related) roots.add(id);
|
||||
for (const id of roots) {
|
||||
const source = sources.get(id);
|
||||
if (!source || source.companyId !== scope.companyId) continue;
|
||||
if (scope.runId) { source.runIds.add(scope.runId); add(key(scope.companyId, "run", scope.runId), id); }
|
||||
if (scope.leaseId) { source.leaseIds.add(scope.leaseId); add(key(scope.companyId, "lease", scope.leaseId), id); }
|
||||
}
|
||||
}
|
||||
async function reconcile(id: string): Promise<Entry | null> {
|
||||
const source = await service.reconcileBackup(id);
|
||||
const dependencies = remember(id, source?.dependencies);
|
||||
return source && dependencies && source.dueAt !== null ? { ...source, dependencies, dueAt: source.dueAt } : null;
|
||||
}
|
||||
function matches(entry: Entry, scope?: NativeHarnessBackupExpirationScope) {
|
||||
if (!scope) return true;
|
||||
if (entry.companyId !== scope.companyId) return false;
|
||||
if (scope.scopeDigest) return entry.id === scope.scopeDigest;
|
||||
if (scope.leaseId) return entry.dependencies.leaseIds.has(scope.leaseId);
|
||||
if (scope.runId) return entry.dependencies.runIds.has(scope.runId);
|
||||
if (scope.sessionId) return entry.dependencies.sessionId === scope.sessionId
|
||||
&& (!scope.agentId || entry.agentId === scope.agentId);
|
||||
if (scope.issueId) return entry.dependencies.issueIds.has(scope.issueId);
|
||||
return !scope.agentId || entry.agentId === scope.agentId;
|
||||
}
|
||||
const worker = registerScopedDeadlineWork(coordinator, {
|
||||
queue: DELIVERY_QUEUES.nativeHarnessBackupExpiration, batchSize: options.maxRemovals ?? 25,
|
||||
decodeScope: (hint: string): NativeHarnessBackupExpirationScope | undefined => hint === "*" ? undefined : JSON.parse(hint),
|
||||
matches,
|
||||
load: async (scope, signal) => {
|
||||
const hint = scope ? nativeHarnessBackupScopeHint(scope) : "*";
|
||||
if (scope && !cursors.has(hint)) await discoverReferences(scope);
|
||||
const limit = options.maxEntries ?? 100;
|
||||
const indexed = scope ? [...selected(scope)].filter(id => !cursors.get(hint) || id > cursors.get(hint)!).sort() : null;
|
||||
const page = indexed ? { names: indexed.slice(0, limit), complete: indexed.length <= limit }
|
||||
: await service.backupScopes(cursors.get(hint), limit);
|
||||
const rows: Entry[] = [];
|
||||
for (const name of page.names) {
|
||||
if (signal?.aborted || !canRun()) throw new Error("Backup expiration discovery paused");
|
||||
const entry = await reconcile(name);
|
||||
if (entry && (!scope || entry.companyId === scope.companyId)) rows.push(entry);
|
||||
}
|
||||
if (page.complete) cursors.delete(hint); else cursors.set(hint, page.names.at(-1)!);
|
||||
return { rows, complete: page.complete };
|
||||
},
|
||||
check: async (entry, signal) => {
|
||||
if (signal?.aborted || !canRun()) throw new Error("Backup expiration paused");
|
||||
const current = await reconcile(entry.id);
|
||||
if (!current || current.companyId !== entry.companyId) return null;
|
||||
if (current.dueAt > Date.now()) return current;
|
||||
const result = await service.expireBackup(entry.id);
|
||||
if (result.candidates || result.failed) logger.info({ ...result, dryRun: options.dryRun ?? false }, "native harness backup expiration");
|
||||
if (options.dryRun) return null;
|
||||
const remaining = await reconcile(entry.id);
|
||||
if (!remaining) return null;
|
||||
if (result.skipped.session_active) throw new Error("Backup session is active");
|
||||
return remaining;
|
||||
},
|
||||
}, canRun);
|
||||
const unsubscribe = onNativeHarnessBackupChange(db, scope => worker.invalidate(nativeHarnessBackupScopeHint(scope)));
|
||||
const release = onRetainedNativeSessionCleanupRelease(db, (_scope, source) => worker.invalidate(nativeHarnessBackupScopeHint(source)));
|
||||
return { ...worker, async stop() { unsubscribe(); release(); await worker.stop(); } };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { nativeHarnessStateBase } from "./native-harness-backup-stamp.js";
|
||||
import { nativeSessionScopeDigestIsBusy } from "./native-session-executor.js";
|
||||
import type { createDeliveryWorkCoordinator } from "../delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "../delivery-work-notifications.js";
|
||||
import type { NativeHarnessBackupExpirationScope } from "../native-harness-backup-expiration-notifications.js";
|
||||
|
||||
export const NATIVE_HARNESS_BACKUP_EXPIRATION_INTERVAL_MS = 60 * 60 * 1000;
|
||||
export const NATIVE_HARNESS_BACKUP_EXPIRATION_STARTUP_DELAY_MS = 2 * 60 * 1000;
|
||||
@@ -146,7 +147,9 @@ async function readSessionIdentity(root: string): Promise<SessionIdentity | null
|
||||
const stat = await fs.lstat(statePath);
|
||||
if (!stat.isFile() || stat.size > MAX_CONTROL_PLANE_STATE_BYTES) return null;
|
||||
const state = JSON.parse(await fs.readFile(statePath, "utf8")) as { identity?: Record<string, unknown> };
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) return null;
|
||||
const identity = state.identity ?? {};
|
||||
if (!identity || typeof identity !== "object" || Array.isArray(identity)) return null;
|
||||
const runId = identity.runId;
|
||||
const normalizedSessionId = identity.normalizedSessionId;
|
||||
const environmentLeaseId = identity.environmentLeaseId;
|
||||
@@ -160,17 +163,22 @@ async function readSessionIdentity(root: string): Promise<SessionIdentity | null
|
||||
? environmentLeaseId
|
||||
: null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError || ["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeHarnessBackupDependencies = {
|
||||
companyId: string; agentId: string; sessionId: string; runIds: string[]; issueIds: string[]; leaseIds: string[];
|
||||
};
|
||||
|
||||
type Tx = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
|
||||
async function sessionRetentionBlocker(
|
||||
tx: Tx,
|
||||
input: { scopeDigest: string | null; identity: SessionIdentity; closedBefore: Date; checkpoint?: boolean },
|
||||
): Promise<{ reason: string } | { companyId: string; agentId: string; runIds: string[] }> {
|
||||
input: { scopeDigest: string | null; identity: SessionIdentity; closedBefore: Date; checkpoint?: boolean; lock?: boolean },
|
||||
): Promise<{ reason: string } | { companyId: string; agentId: string; runIds: string[]; issueIds: string[]; closedAt: number }> {
|
||||
const [owner] = await tx
|
||||
.select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId })
|
||||
.from(heartbeatRuns)
|
||||
@@ -200,7 +208,7 @@ async function sessionRetentionBlocker(
|
||||
if (input.checkpoint && runs.some(run => run.finishedAt! >= input.closedBefore)) return { reason: "recent_run" };
|
||||
const issueIds = [...new Set(runs.map((run) => run.issueId).filter((id): id is string => Boolean(id)))];
|
||||
if (issueIds.length === 0) return { reason: "issue_unknown" };
|
||||
const issueRows = await tx
|
||||
const issueQuery = tx
|
||||
.select({
|
||||
id: issues.id,
|
||||
status: issues.status,
|
||||
@@ -208,8 +216,8 @@ async function sessionRetentionBlocker(
|
||||
closedAt: sql<Date | null>`coalesce(${issues.completedAt}, ${issues.cancelledAt})`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, owner.companyId), inArray(issues.id, issueIds)))
|
||||
.for("share");
|
||||
.where(and(eq(issues.companyId, owner.companyId), inArray(issues.id, issueIds)));
|
||||
const issueRows = await (input.lock === false ? issueQuery : issueQuery.for("share"));
|
||||
if (issueRows.length !== issueIds.length) return { reason: "issue_unknown" };
|
||||
for (const issue of issueRows) {
|
||||
if (!CLOSED_ISSUE_STATUSES.includes(issue.status)) return { reason: "issue_open" };
|
||||
@@ -249,7 +257,7 @@ async function sessionRetentionBlocker(
|
||||
.where(and(inArray(environmentLeases.status, LIVE_LEASE_STATUSES), or(...leaseReferences)))
|
||||
.limit(1);
|
||||
if (lease) return { reason: "live_lease" };
|
||||
return { ...owner, runIds };
|
||||
return { ...owner, runIds, issueIds, closedAt: issueRows.reduce((latest, issue) => Math.max(latest, new Date(issue.closedAt!).getTime()), Number.NEGATIVE_INFINITY) };
|
||||
}
|
||||
|
||||
async function hasLiveRun(db: Db, owner: { companyId: string; agentId: string }, sessionId: string) {
|
||||
@@ -347,7 +355,7 @@ export function nativeHarnessBackupExpirationService(
|
||||
|
||||
async function settleLeftovers(backupRoot: string) {
|
||||
const current = path.join(backupRoot, "current");
|
||||
for (const name of await fs.readdir(backupRoot).catch(() => [] as string[])) {
|
||||
for (const name of (await fs.readdir(backupRoot).catch(missingPath)) ?? []) {
|
||||
const target = path.join(backupRoot, name);
|
||||
if (name.startsWith(RETIRING_PREFIX) && UUID_PATTERN.test(name.slice(RETIRING_PREFIX.length))) {
|
||||
if (!(await isRealDirectory(current))) await fs.rename(target, current);
|
||||
@@ -382,54 +390,49 @@ export function nativeHarnessBackupExpirationService(
|
||||
const identity = await readSessionIdentity(root);
|
||||
if (!identity) return skip("identity_unknown");
|
||||
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
const blocker = await sessionRetentionBlocker(tx, {
|
||||
scopeDigest,
|
||||
identity,
|
||||
closedBefore: options.closedBefore,
|
||||
});
|
||||
if ("reason" in blocker) return blocker;
|
||||
if (options.dryRun) return { owner: blocker, cleared: 0 };
|
||||
await notifyExternalConversationSettlement(tx, { companyId: blocker.companyId });
|
||||
const cleared = await tx
|
||||
.delete(agentTaskSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(agentTaskSessions.companyId, blocker.companyId),
|
||||
eq(agentTaskSessions.agentId, blocker.agentId),
|
||||
sql`${agentTaskSessions.sessionParamsJson}->>'sessionId' = ${identity.normalizedSessionId}`,
|
||||
),
|
||||
)
|
||||
.returning({ id: agentTaskSessions.id });
|
||||
return { owner: blocker, cleared: cleared.length };
|
||||
});
|
||||
if ("reason" in decision) return skip(decision.reason);
|
||||
|
||||
const bytes = await allocatedBytes(current);
|
||||
result.candidates++;
|
||||
result.candidateBytes += bytes;
|
||||
if (options.dryRun) return;
|
||||
result.clearedTaskSessions += decision.cleared;
|
||||
if (options.isSessionBusy(scopeDigest)) return skip("session_active");
|
||||
const id = randomUUID();
|
||||
const retiring = path.join(backupRoot, `${RETIRING_PREFIX}${id}`);
|
||||
const trash = path.join(backupRoot, `${EXPIRED_PREFIX}${id}`);
|
||||
await fs.rename(current, retiring);
|
||||
let liveRun: boolean;
|
||||
let retired = false;
|
||||
let decision: { reason: string } | { cleared: number; bytes: number };
|
||||
try {
|
||||
liveRun = await hasLiveRun(db, decision.owner, identity.normalizedSessionId);
|
||||
} catch (err) {
|
||||
await fs.rename(retiring, current);
|
||||
throw err;
|
||||
}
|
||||
if (liveRun) {
|
||||
await fs.rename(retiring, current);
|
||||
return skip("live_run");
|
||||
decision = await db.transaction(async (tx) => {
|
||||
const blocker = await sessionRetentionBlocker(tx, {
|
||||
scopeDigest, identity, closedBefore: options.closedBefore,
|
||||
});
|
||||
if ("reason" in blocker) return blocker;
|
||||
if ((await fs.stat(current)).mtime >= options.closedBefore) return { reason: "recent_backup" };
|
||||
if (options.dryRun) return { cleared: 0, bytes };
|
||||
if (options.isSessionBusy(scopeDigest)) return { reason: "session_active" };
|
||||
await notifyExternalConversationSettlement(tx, { companyId: blocker.companyId });
|
||||
await fs.rename(current, retiring);
|
||||
retired = true;
|
||||
if (await hasLiveRun(tx as unknown as Db, blocker, identity.normalizedSessionId)) {
|
||||
await fs.rename(retiring, current);
|
||||
retired = false;
|
||||
return { reason: "live_run" };
|
||||
}
|
||||
const cleared = await tx.delete(agentTaskSessions).where(and(
|
||||
eq(agentTaskSessions.companyId, blocker.companyId),
|
||||
eq(agentTaskSessions.agentId, blocker.agentId),
|
||||
sql`${agentTaskSessions.sessionParamsJson}->>'sessionId' = ${identity.normalizedSessionId}`,
|
||||
)).returning({ id: agentTaskSessions.id });
|
||||
return { cleared: cleared.length, bytes };
|
||||
});
|
||||
} catch (error) {
|
||||
if (retired) await fs.rename(retiring, current);
|
||||
throw error;
|
||||
}
|
||||
if ("reason" in decision) return skip(decision.reason);
|
||||
result.candidates++;
|
||||
result.candidateBytes += decision.bytes;
|
||||
if (options.dryRun) return;
|
||||
result.clearedTaskSessions += decision.cleared;
|
||||
await fs.rename(retiring, trash);
|
||||
await removeBackupTree(trash);
|
||||
result.expired++;
|
||||
result.reclaimedBytes += bytes;
|
||||
result.reclaimedBytes += decision.bytes;
|
||||
}
|
||||
|
||||
async function removeExpiredLeftovers(parent: string, context: EvidenceContext, result: NativeHarnessBackupExpirationResult) {
|
||||
@@ -748,5 +751,91 @@ export function nativeHarnessBackupExpirationService(
|
||||
});
|
||||
}
|
||||
|
||||
return { tick, noteEvidence, nextEvidenceAt, tickCheckpoints, registerCheckpointExpiration };
|
||||
async function backupDependencies(scopeDigest: string, identity: SessionIdentity): Promise<NativeHarnessBackupDependencies | null> {
|
||||
const [owner] = await db.select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId,
|
||||
issueId: sql<string | null>`coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId})`,
|
||||
}).from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId));
|
||||
if (!owner) return null;
|
||||
const runs = await db.select({ id: heartbeatRuns.id,
|
||||
issueId: sql<string | null>`coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId})`,
|
||||
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, owner.companyId),
|
||||
eq(heartbeatRuns.agentId, owner.agentId), eq(heartbeatRuns.nativeSessionId, identity.normalizedSessionId)));
|
||||
const runIds = [...new Set([identity.runId, ...runs.map(run => run.id)])];
|
||||
const issueIds = [...new Set([owner.issueId, ...runs.map(run => run.issueId)].filter((id): id is string => Boolean(id)))];
|
||||
const leases = await db.select({ id: environmentLeases.id }).from(environmentLeases).where(and(
|
||||
eq(environmentLeases.companyId, owner.companyId), or(inArray(environmentLeases.heartbeatRunId, runIds),
|
||||
sql`${environmentLeases.metadata}->'nativeHarnessBackup'->>'sessionScopeSha256' = ${scopeDigest}`,
|
||||
identity.environmentLeaseId ? eq(environmentLeases.id, identity.environmentLeaseId) : undefined)));
|
||||
return { companyId: owner.companyId, agentId: owner.agentId, sessionId: identity.normalizedSessionId,
|
||||
runIds, issueIds, leaseIds: leases.map(lease => lease.id) };
|
||||
}
|
||||
|
||||
async function resolveBackupScope(scope: NativeHarnessBackupExpirationScope) {
|
||||
let runId = scope.runId;
|
||||
let issueId = scope.issueId;
|
||||
let scopeDigest = scope.scopeDigest;
|
||||
if (scope.leaseId) {
|
||||
const [lease] = await db.select({ runId: environmentLeases.heartbeatRunId, issueId: environmentLeases.issueId,
|
||||
scopeDigest: sql<string | null>`${environmentLeases.metadata}->'nativeHarnessBackup'->>'sessionScopeSha256'`,
|
||||
}).from(environmentLeases).where(and(eq(environmentLeases.companyId, scope.companyId), eq(environmentLeases.id, scope.leaseId)));
|
||||
runId = lease?.runId ?? runId; issueId = lease?.issueId ?? issueId;
|
||||
scopeDigest = lease?.scopeDigest && SCOPE_PATTERN.test(lease.scopeDigest) ? lease.scopeDigest : scopeDigest;
|
||||
}
|
||||
const [run] = runId ? await db.select({ agentId: heartbeatRuns.agentId, sessionId: heartbeatRuns.nativeSessionId,
|
||||
issueId: sql<string | null>`coalesce(${heartbeatRuns.nativeIssueId}, ${heartbeatRuns.issueId})`,
|
||||
}).from(heartbeatRuns).where(and(eq(heartbeatRuns.companyId, scope.companyId), eq(heartbeatRuns.id, runId))) : [];
|
||||
return { runId, issueId: run?.issueId ?? issueId, agentId: run?.agentId ?? scope.agentId,
|
||||
sessionId: run?.sessionId ?? scope.sessionId, scopeDigest };
|
||||
}
|
||||
|
||||
async function reconcileBackup(scopeDigest: string) {
|
||||
const base = path.resolve(defaults.stateBase ?? nativeHarnessStateBase());
|
||||
const root = path.join(base, scopeDigest);
|
||||
if (!SCOPE_PATTERN.test(scopeDigest) || !(await isRealDirectory(root))) return null;
|
||||
const backupRoot = path.join(root, "failover-backups");
|
||||
if (!(await isRealDirectory(backupRoot))) return null;
|
||||
if (!defaults.dryRun) await settleLeftovers(backupRoot);
|
||||
const current = path.join(backupRoot, "current");
|
||||
const stat = await fs.lstat(current).catch(missingPath);
|
||||
if (!stat?.isDirectory() || stat.isSymbolicLink()) return null;
|
||||
const identity = await readSessionIdentity(root);
|
||||
if (!identity) return null;
|
||||
const dependencies = await backupDependencies(scopeDigest, identity);
|
||||
if (!dependencies) return null;
|
||||
const source = { id: scopeDigest, companyId: dependencies.companyId, agentId: dependencies.agentId, dependencies };
|
||||
if (await fs.lstat(path.join(root, "cleanup-activation.json")).then(() => true, () => false)) {
|
||||
return { ...source, dueAt: null };
|
||||
}
|
||||
const blocker = await db.transaction(tx => sessionRetentionBlocker(tx, {
|
||||
scopeDigest, identity, closedBefore: new Date(8_000_000_000_000_000), lock: false,
|
||||
}));
|
||||
if ("reason" in blocker) return { ...source, dueAt: null };
|
||||
const retentionMs = Math.max(1, defaults.closedForDays ?? NATIVE_HARNESS_BACKUP_EXPIRATION_DEFAULT_DAYS) * DAY_MS;
|
||||
return { ...source, dueAt: Math.max(stat!.mtime.getTime(), blocker.closedAt) + retentionMs + 1 };
|
||||
}
|
||||
|
||||
async function backupScopes(after?: string, limit = 100) {
|
||||
const base = path.resolve(defaults.stateBase ?? nativeHarnessStateBase());
|
||||
const names = (await fs.readdir(base).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === "ENOENT") return [] as string[];
|
||||
throw error;
|
||||
})).filter(name => SCOPE_PATTERN.test(name) && (!after || name > after)).sort();
|
||||
return { names: names.slice(0, limit), complete: names.length <= limit };
|
||||
}
|
||||
|
||||
async function expireBackup(scopeDigest: string) {
|
||||
if (!SCOPE_PATTERN.test(scopeDigest)) throw new Error("Invalid native harness backup scope");
|
||||
const result = emptyResult();
|
||||
const root = path.join(path.resolve(defaults.stateBase ?? nativeHarnessStateBase()), scopeDigest);
|
||||
const dryRun = defaults.dryRun ?? false;
|
||||
await expireSession(root, scopeDigest, {
|
||||
dryRun,
|
||||
isSessionBusy: defaults.isSessionBusy ?? nativeSessionScopeDigestIsBusy,
|
||||
closedBefore: new Date(Date.now() - Math.max(1, defaults.closedForDays ?? NATIVE_HARNESS_BACKUP_EXPIRATION_DEFAULT_DAYS) * DAY_MS),
|
||||
}, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return { tick, noteEvidence, nextEvidenceAt, tickCheckpoints, registerCheckpointExpiration,
|
||||
reconcileBackup, backupScopes, expireBackup, resolveBackupScope };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { onNativeHarnessBackupChange } from "../native-harness-backup-expiration-notifications.js";
|
||||
import { publishNativeHarnessBackup } from "./native-harness-backup-publication.js";
|
||||
|
||||
describe("native harness backup publication", () => {
|
||||
let backupRoot = "";
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
afterEach(async () => {
|
||||
unsubscribe?.(); unsubscribe = undefined;
|
||||
if (backupRoot) await fs.rm(backupRoot, { recursive: true, force: true });
|
||||
});
|
||||
async function fixture() {
|
||||
backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), "native-backup-publication-"));
|
||||
const current = path.join(backupRoot, "current"), pendingRoot = path.join(backupRoot, "pending");
|
||||
await fs.mkdir(current); await fs.mkdir(pendingRoot);
|
||||
await fs.writeFile(path.join(current, "manifest.json"), "saved");
|
||||
await fs.writeFile(path.join(pendingRoot, "manifest.json"), "replacement");
|
||||
return { db: {}, companyId: "company", scopeDigest: "scope", backupRoot, pendingRoot, current };
|
||||
}
|
||||
it("restores the existing backup when its lease stamp fails without publishing a false change", async () => {
|
||||
const input = await fixture(); const changes: unknown[] = [];
|
||||
unsubscribe = onNativeHarnessBackupChange(input.db, change => changes.push(change));
|
||||
await expect(publishNativeHarnessBackup({ ...input, stamp: async current => {
|
||||
expect(await fs.readFile(path.join(current, "manifest.json"), "utf8")).toBe("replacement");
|
||||
throw new Error("stamp transaction rejected");
|
||||
} })).rejects.toThrow("stamp transaction rejected");
|
||||
expect(await fs.readFile(path.join(input.current, "manifest.json"), "utf8")).toBe("saved");
|
||||
expect(await fs.readdir(backupRoot)).toEqual(["current"]); expect(changes).toEqual([]);
|
||||
});
|
||||
it("publishes the completed replacement once and removes its predecessor", async () => {
|
||||
const input = await fixture(); const changes: unknown[] = [];
|
||||
unsubscribe = onNativeHarnessBackupChange(input.db, change => changes.push(change));
|
||||
await publishNativeHarnessBackup({ ...input, stamp: async current => {
|
||||
expect(changes).toEqual([]);
|
||||
expect(await fs.readFile(path.join(current, "manifest.json"), "utf8")).toBe("replacement");
|
||||
} });
|
||||
expect(await fs.readFile(path.join(input.current, "manifest.json"), "utf8")).toBe("replacement");
|
||||
expect(await fs.readdir(backupRoot)).toEqual(["current"]);
|
||||
expect(changes).toEqual([{ companyId: input.companyId, scopeDigest: input.scopeDigest }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { existsSync, renameSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { noteNativeHarnessBackupChange } from "../native-harness-backup-expiration-notifications.js";
|
||||
import { removeNativeHarnessBackup } from "./native-harness-backup-cleanup.js";
|
||||
|
||||
export async function publishNativeHarnessBackup(input: {
|
||||
db: object; companyId: string; scopeDigest: string; backupRoot: string; pendingRoot: string;
|
||||
stamp?: (currentRoot: string) => Promise<void>;
|
||||
}) {
|
||||
const currentRoot = resolve(input.backupRoot, "current");
|
||||
const previousRoot = resolve(input.backupRoot, "previous");
|
||||
removeNativeHarnessBackup(previousRoot);
|
||||
let movedCurrent = false;
|
||||
if (existsSync(currentRoot)) {
|
||||
renameSync(currentRoot, previousRoot);
|
||||
movedCurrent = true;
|
||||
}
|
||||
try {
|
||||
renameSync(input.pendingRoot, currentRoot);
|
||||
await input.stamp?.(currentRoot);
|
||||
} catch (error) {
|
||||
if (existsSync(currentRoot)) removeNativeHarnessBackup(currentRoot);
|
||||
if (movedCurrent && existsSync(previousRoot) && !existsSync(currentRoot)) renameSync(previousRoot, currentRoot);
|
||||
throw error;
|
||||
}
|
||||
removeNativeHarnessBackup(previousRoot);
|
||||
noteNativeHarnessBackupChange(input.db, { companyId: input.companyId, scopeDigest: input.scopeDigest });
|
||||
}
|
||||
@@ -5304,7 +5304,10 @@ describe("retained native cleanup activation", () => {
|
||||
}
|
||||
const outcome = await pendingOutcome;
|
||||
if (mode === "legacy_busy_copy") {
|
||||
expect(maintenanceReleased).toHaveBeenCalledExactlyOnceWith({ companyId: run.companyId, runId: run.id });
|
||||
expect(maintenanceReleased).toHaveBeenCalledExactlyOnceWith(
|
||||
{ companyId: run.companyId, runId: run.id },
|
||||
{ companyId: run.companyId, runId: run.id },
|
||||
);
|
||||
unsubscribeMaintenance?.();
|
||||
}
|
||||
if (canonicalSource) {
|
||||
@@ -6626,7 +6629,10 @@ describe("native startup cancellation fence", () => {
|
||||
})).rejects.toThrow("native_cancellation_pending_recovery");
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(submit).not.toHaveBeenCalled();
|
||||
expect(released).toHaveBeenCalledWith({ companyId: execution.binding.companyId, agentId: execution.binding.agentId });
|
||||
expect(released).toHaveBeenCalledWith(
|
||||
{ companyId: execution.binding.companyId, agentId: execution.binding.agentId },
|
||||
{ companyId: execution.binding.companyId, runId: execution.binding.runId, issueId: execution.binding.issueId },
|
||||
);
|
||||
} finally { unsubscribe(); }
|
||||
});
|
||||
});
|
||||
@@ -10264,9 +10270,10 @@ describe("native warm session supervision", () => {
|
||||
);
|
||||
expect(released).not.toHaveBeenCalled();
|
||||
releaseClose();
|
||||
await vi.waitFor(() => expect(released).toHaveBeenCalledExactlyOnceWith({
|
||||
companyId: base.binding.companyId, agentId: base.binding.agentId,
|
||||
}));
|
||||
await vi.waitFor(() => expect(released).toHaveBeenCalledExactlyOnceWith(
|
||||
{ companyId: base.binding.companyId, agentId: base.binding.agentId },
|
||||
{ companyId: base.binding.companyId, runId: base.binding.runId, issueId: base.binding.issueId },
|
||||
));
|
||||
} finally { releaseClose(); unsubscribe(); }
|
||||
});
|
||||
|
||||
@@ -12915,7 +12922,10 @@ describe("runnerd provider runtime wiring", () => {
|
||||
releaseEnvironment();
|
||||
releaseAssigned();
|
||||
const result = await preparation;
|
||||
expect(released).toHaveBeenCalledExactlyOnceWith({ companyId: execution.binding.companyId, agentId: execution.binding.agentId });
|
||||
expect(released).toHaveBeenCalledExactlyOnceWith(
|
||||
{ companyId: execution.binding.companyId, agentId: execution.binding.agentId },
|
||||
{ companyId: execution.binding.companyId, runId: execution.binding.runId, issueId: execution.binding.issueId },
|
||||
);
|
||||
if (failure !== "success") {
|
||||
expect(result).toMatchObject({ error: { message: failure === "assigned" ? "assigned failed" : "environment failed" } });
|
||||
expect(state.createBackend).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { publishNativeHarnessBackup } from "./native-harness-backup-publication.js";
|
||||
import { rehydratePinnedSkillAssets } from "./runtime-context.js";
|
||||
import { createNativeUsageDelivery } from "./native-usage-delivery.js";
|
||||
import { createNativeRecoveryUsageDelivery } from "./native-recovery-usage-delivery.js";
|
||||
@@ -8250,7 +8251,8 @@ export async function executePaperclipNativeSession(input: {
|
||||
}
|
||||
releaseRetainedNativeSessionCleanup(input.db, {
|
||||
companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
|
||||
});
|
||||
}, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId,
|
||||
issueId: input.execution.binding.issueId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9693,7 +9695,8 @@ async function executePaperclipNativeSessionWithinScope(
|
||||
sessionCheckpointPersisted: runnerdBackend?.sessionCheckpointPersisted,
|
||||
onReleased: () => releaseRetainedNativeSessionCleanup(input.db, {
|
||||
companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
|
||||
}),
|
||||
}, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId,
|
||||
issueId: input.execution.binding.issueId }),
|
||||
pluginTaskId: usesPluginTask && input.runnerExecutionTarget?.kind === "remote" && input.runnerExecutionTarget.transport === "sandbox"
|
||||
? input.runnerExecutionTarget.sandboxLeaseAcquisition?.providerLeaseId : undefined,
|
||||
runId: input.execution.binding.runId,
|
||||
@@ -12254,7 +12257,8 @@ export async function createRunnerdBackend(input: {
|
||||
initializingSessionToolAuthorities.delete(sessionScopeId);
|
||||
releaseRetainedNativeSessionCleanup(input.db, {
|
||||
companyId: input.execution.binding.companyId, agentId: input.execution.binding.agentId,
|
||||
});
|
||||
}, { companyId: input.execution.binding.companyId, runId: input.execution.binding.runId,
|
||||
issueId: input.execution.binding.issueId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13212,7 +13216,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
})
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.returning({ id: environmentLeases.id })
|
||||
.then((rows) => rows[0] ?? null), { companyId: leaseRow.companyId, runId: leaseRow.heartbeatRunId ?? undefined });
|
||||
.then((rows) => rows[0] ?? null), { companyId: leaseRow.companyId, runId: leaseRow.heartbeatRunId ?? undefined, leaseId });
|
||||
if (!updated) throw new Error("runner_harness_backup_lease_missing");
|
||||
};
|
||||
|
||||
@@ -13644,40 +13648,15 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
});
|
||||
renameSync(temporaryManifest, manifestPath);
|
||||
|
||||
const currentRoot = resolve(backupRoot, "current");
|
||||
const previousRoot = resolve(backupRoot, "previous");
|
||||
removeNativeHarnessBackup(previousRoot);
|
||||
let movedCurrent = false;
|
||||
if (existsSync(currentRoot)) {
|
||||
renameSync(currentRoot, previousRoot);
|
||||
movedCurrent = true;
|
||||
}
|
||||
try {
|
||||
renameSync(pendingRoot, currentRoot);
|
||||
if (
|
||||
remoteTarget?.transport === "sandbox" &&
|
||||
remoteTarget.leaseId
|
||||
) {
|
||||
await publishNativeHarnessBackup({
|
||||
db: input.db, companyId: input.execution.binding.companyId,
|
||||
scopeDigest: createHash("sha256").update(sessionScopeId).digest("hex"), backupRoot, pendingRoot,
|
||||
stamp: remoteTarget?.transport === "sandbox" && remoteTarget.leaseId ? async currentRoot => {
|
||||
await recordHarnessBackupStampForCurrentLease({
|
||||
root: currentRoot,
|
||||
manifest,
|
||||
bytes: backupSpanAttributes.bytesTransferred,
|
||||
root: currentRoot, manifest, bytes: backupSpanAttributes.bytesTransferred,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (existsSync(currentRoot)) {
|
||||
removeNativeHarnessBackup(currentRoot);
|
||||
}
|
||||
if (
|
||||
movedCurrent &&
|
||||
existsSync(previousRoot) &&
|
||||
!existsSync(currentRoot)
|
||||
) {
|
||||
renameSync(previousRoot, currentRoot);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
removeNativeHarnessBackup(previousRoot);
|
||||
} : undefined,
|
||||
});
|
||||
} finally {
|
||||
removeNativeHarnessBackup(pendingRoot);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { notifyNativeHarnessBackupExpiration } from "./native-harness-backup-expiration-notifications.js";
|
||||
import { notifySkillVersionRetention } from "./skill-version-retention-notifications.js";
|
||||
import { DELIVERY_QUEUES, notifyDeliveryWork } from "./delivery-work-notifications.js";
|
||||
|
||||
@@ -9,14 +10,15 @@ export type RetainedNativeSessionCleanupScope = {
|
||||
};
|
||||
|
||||
export async function notifyRetainedNativeSessionCleanup(tx: object, scope: RetainedNativeSessionCleanupScope) {
|
||||
await notifyNativeHarnessBackupExpiration(tx, scope);
|
||||
await notifySkillVersionRetention(tx, scope.companyId);
|
||||
await notifyDeliveryWork(tx, DELIVERY_QUEUES.retainedNativeSessionCleanup, JSON.stringify({ companyId: scope.companyId, runId: scope.runId, issueId: scope.issueId, agentId: scope.agentId }));
|
||||
}
|
||||
|
||||
|
||||
|
||||
const listeners = new WeakMap<object, Set<(scope: RetainedNativeSessionCleanupScope) => void>>();
|
||||
export function onRetainedNativeSessionCleanupRelease(db: object, listener: (scope: RetainedNativeSessionCleanupScope) => void) {
|
||||
const listeners = new WeakMap<object, Set<(scope: RetainedNativeSessionCleanupScope, source: RetainedNativeSessionCleanupScope) => void>>();
|
||||
export function onRetainedNativeSessionCleanupRelease(db: object, listener: (scope: RetainedNativeSessionCleanupScope, source: RetainedNativeSessionCleanupScope) => void) {
|
||||
let owned = listeners.get(db);
|
||||
if (!owned) { owned = new Set(); listeners.set(db, owned); }
|
||||
owned.add(listener);
|
||||
@@ -25,6 +27,7 @@ export function onRetainedNativeSessionCleanupRelease(db: object, listener: (sco
|
||||
if (!owned.size && listeners.get(db) === owned) listeners.delete(db);
|
||||
};
|
||||
}
|
||||
export function releaseRetainedNativeSessionCleanup(db: object, scope: RetainedNativeSessionCleanupScope) {
|
||||
for (const listener of listeners.get(db) ?? []) listener(scope);
|
||||
export function releaseRetainedNativeSessionCleanup(db: object, scope: RetainedNativeSessionCleanupScope,
|
||||
source: RetainedNativeSessionCleanupScope = scope) {
|
||||
for (const listener of listeners.get(db) ?? []) listener(scope, source);
|
||||
}
|
||||
Reference in new issue
Block a user