mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
fix(runtime): use execution start for archival lifecycle proofs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7729bccb80
commit
d71dc913f7
4 files changed
+29
-11
No files matched your search
@@ -148,6 +148,11 @@ executable recovery authority. Foreign, invalid and active sources remain blocke
|
|||||||
For a finished failed source with a stale observed coordinator, evidence archival
|
For a finished failed source with a stale observed coordinator, evidence archival
|
||||||
also requires an expired controller lease, no accepted result or retry deadline,
|
also requires an expired controller lease, no accepted result or retry deadline,
|
||||||
and no current task ownership. Every remote lease must carry the exact host-owned
|
and no current task ownership. Every remote lease must carry the exact host-owned
|
||||||
termination receipt, confirmed after the failed finish. Recheck those receipts
|
termination receipt, confirmed after execution started. Recheck those receipts
|
||||||
before publication. Preserve the coordinator as recorded; this proof permits
|
before publication. Preserve the coordinator as recorded; this proof permits
|
||||||
evidence storage, not execution or a claim that the coordinator has settled.
|
evidence storage, not execution or a claim that the coordinator has settled.
|
||||||
|
|
||||||
|
Compare retained turn completion to the follow-up execution start, not its queue
|
||||||
|
creation time. A queued follow-up can predate the prior turn completion. A remote
|
||||||
|
stop can also predate a later logical failure caused by recovery timeout. These
|
||||||
|
proofs use the recorded execution start and never rewrite lifecycle timestamps.
|
||||||
@@ -22,9 +22,9 @@ import { legacyNativeControllerArchive } from "./native-controller-archive.js";
|
|||||||
import { restoreDurableNativeHarnessBackup } from "./native-harness-backup-storage.js";
|
import { restoreDurableNativeHarnessBackup } from "./native-harness-backup-storage.js";
|
||||||
|
|
||||||
describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable_failed",
|
describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable_failed",
|
||||||
"stale_confirmed", "stale_foreign", "stale_changed", "stale_success", "stale_busy", "stale_durable",
|
"stale_confirmed", "stale_foreign", "stale_changed", "stale_queued", "stale_overlap", "stale_success", "stale_busy", "stale_durable",
|
||||||
"controller_confirmed", "controller_foreign", "controller_thread", "controller_runner", "controller_success", "controller_busy", "controller_invalid", "controller_durable",
|
"controller_confirmed", "controller_foreign", "controller_thread", "controller_runner", "controller_success", "controller_busy", "controller_invalid", "controller_durable",
|
||||||
"controller_observed_stopped", "controller_observed_missing", "controller_observed_foreign",
|
"controller_observed_stopped", "controller_observed_stop_before_finish", "controller_observed_missing", "controller_observed_foreign",
|
||||||
"controller_observed_live", "controller_observed_result", "controller_observed_retry", "controller_observed_task", "controller_observed_old_receipt", "controller_observed_revoked"])("offline native controller migration (%s)", scenario => {
|
"controller_observed_live", "controller_observed_result", "controller_observed_retry", "controller_observed_task", "controller_observed_old_receipt", "controller_observed_revoked"])("offline native controller migration (%s)", scenario => {
|
||||||
const environment = nativeRestartFixture();
|
const environment = nativeRestartFixture();
|
||||||
afterEach(() => vi.unstubAllEnvs());
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
@@ -253,6 +253,12 @@ describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable
|
|||||||
nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId, runnerInstanceId: f.native.runnerInstanceId,
|
nativeIssueId: f.issueId, nativeSessionId: f.native.normalizedSessionId, runnerInstanceId: f.native.runnerInstanceId,
|
||||||
runtimeMode: "native", status: "succeeded", createdAt: new Date(Date.now() - 60_000),
|
runtimeMode: "native", status: "succeeded", createdAt: new Date(Date.now() - 60_000),
|
||||||
finishedAt: new Date(Date.now() - 50_000), runnerProfileJson: priorProfile });
|
finishedAt: new Date(Date.now() - 50_000), runnerProfileJson: priorProfile });
|
||||||
|
if (scenario === "stale_queued" || scenario === "stale_overlap") {
|
||||||
|
const now = Date.now();
|
||||||
|
await f.db.update(heartbeatRuns).set({ createdAt: new Date(now - 45_000),
|
||||||
|
startedAt: new Date(now - (scenario === "stale_queued" ? 30_000 : 43_000)) }).where(eq(heartbeatRuns.id, f.runId));
|
||||||
|
await f.db.update(heartbeatRuns).set({ finishedAt: new Date(now - 40_000) }).where(eq(heartbeatRuns.id, priorId));
|
||||||
|
}
|
||||||
const supervisor = JSON.stringify({ schema: "paperclip.native-session-supervisor.v1",
|
const supervisor = JSON.stringify({ schema: "paperclip.native-session-supervisor.v1",
|
||||||
configDigest: f.paths.configDigest, snapshot: priorSnapshot });
|
configDigest: f.paths.configDigest, snapshot: priorSnapshot });
|
||||||
await mkdir(dirname(f.paths.supervisorPath), { recursive: true });
|
await mkdir(dirname(f.paths.supervisorPath), { recursive: true });
|
||||||
@@ -274,7 +280,7 @@ describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable
|
|||||||
const [originalOwner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
const [originalOwner] = await f.db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, f.runId));
|
||||||
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
const storage = createStorageService(createLocalDiskStorageProvider(join(f.runtimeRoot, "durable-objects")));
|
||||||
const options = { storage, writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
const options = { storage, writersStopped: true as const, signal: AbortSignal.timeout(30_000) };
|
||||||
if (scenario !== "stale_confirmed") {
|
if (!["stale_confirmed", "stale_queued"].includes(scenario)) {
|
||||||
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
||||||
expect(await f.db.select().from(nativeHarnessBackups)).toHaveLength(0);
|
expect(await f.db.select().from(nativeHarnessBackups)).toHaveLength(0);
|
||||||
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(true);
|
expect(await hasLocalNativeControllerObligations(f.db, storage)).toBe(true);
|
||||||
@@ -344,6 +350,13 @@ describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable
|
|||||||
const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.runId, issueId: f.issueId,
|
const lease = { id: randomUUID(), companyId: f.companyId, heartbeatRunId: f.runId, issueId: f.issueId,
|
||||||
provider: "daytona", providerLeaseId: randomUUID(), status: "released", cleanupStatus: "success", releasedAt: new Date() };
|
provider: "daytona", providerLeaseId: randomUUID(), status: "released", cleanupStatus: "success", releasedAt: new Date() };
|
||||||
const receipt = remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" })!;
|
const receipt = remoteTerminationReceipt(lease, { providerLeaseId: lease.providerLeaseId, state: "stopped" })!;
|
||||||
|
if (scenario === "controller_observed_stop_before_finish") {
|
||||||
|
const now = Date.now();
|
||||||
|
await f.db.update(heartbeatRuns).set({ createdAt: new Date(now - 40_000), startedAt: new Date(now - 30_000) })
|
||||||
|
.where(eq(heartbeatRuns.id, f.runId));
|
||||||
|
receipt.confirmedAt = new Date(now - 10_000).toISOString();
|
||||||
|
lease.releasedAt = new Date(now - 10_000);
|
||||||
|
}
|
||||||
if (scenario === "controller_observed_foreign") receipt.runId = randomUUID();
|
if (scenario === "controller_observed_foreign") receipt.runId = randomUUID();
|
||||||
if (scenario === "controller_observed_old_receipt") receipt.confirmedAt = new Date(0).toISOString();
|
if (scenario === "controller_observed_old_receipt") receipt.confirmedAt = new Date(0).toISOString();
|
||||||
await f.db.insert(environmentLeases).values({ ...lease, metadata: { remoteExecutionTermination: receipt } });
|
await f.db.insert(environmentLeases).values({ ...lease, metadata: { remoteExecutionTermination: receipt } });
|
||||||
@@ -363,7 +376,7 @@ describeEmbeddedPostgres.each(["import", "archive", "success", "batch", "durable
|
|||||||
}
|
}
|
||||||
if (scenario === "controller_durable") {
|
if (scenario === "controller_durable") {
|
||||||
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ unchanged: 1, archivedFailed: 0 });
|
expect(await migrateNativeControllers(f.db, options)).toMatchObject({ unchanged: 1, archivedFailed: 0 });
|
||||||
} else if (!["controller_confirmed", "controller_observed_stopped"].includes(scenario)) {
|
} else if (!["controller_confirmed", "controller_observed_stopped", "controller_observed_stop_before_finish"].includes(scenario)) {
|
||||||
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
await expect(migrateNativeControllers(f.db, options)).rejects.toThrow();
|
||||||
const archives = await f.db.select().from(nativeHarnessBackups);
|
const archives = await f.db.select().from(nativeHarnessBackups);
|
||||||
if (scenario === "controller_observed_revoked") {
|
if (scenario === "controller_observed_revoked") {
|
||||||
|
|||||||
@@ -43,15 +43,15 @@ async function confirmedOlderSuccessfulSnapshot(db: Db, selected: Selected,
|
|||||||
eq(heartbeatRuns.agentId, selected.agentId), eq(heartbeatRuns.nativeIssueId, selected.issueId),
|
eq(heartbeatRuns.agentId, selected.agentId), eq(heartbeatRuns.nativeIssueId, selected.issueId),
|
||||||
eq(heartbeatRuns.nativeSessionId, selected.sessionId), eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.status, "succeeded")));
|
eq(heartbeatRuns.nativeSessionId, selected.sessionId), eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.status, "succeeded")));
|
||||||
const priorSnapshot = record(prior?.snapshot), priorIdentity = record(priorSnapshot.identity);
|
const priorSnapshot = record(prior?.snapshot), priorIdentity = record(priorSnapshot.identity);
|
||||||
if (!prior?.finishedAt || prior.createdAt >= source.createdAt || prior.finishedAt > source.createdAt
|
if (!prior?.finishedAt || prior.createdAt >= source.createdAt
|
||||||
|| !source.runnerInstanceId || prior.runnerInstanceId !== source.runnerInstanceId
|
|| !source.runnerInstanceId || prior.runnerInstanceId !== source.runnerInstanceId
|
||||||
|| priorIdentity.runId !== olderRunId || Object.entries(scope).some(([key, value]) => priorIdentity[key] !== value)
|
|| priorIdentity.runId !== olderRunId || Object.entries(scope).some(([key, value]) => priorIdentity[key] !== value)
|
||||||
|| priorSnapshot.sessionId !== snapshot.sessionId || priorSnapshot.backendKind !== snapshot.backendKind) return null;
|
|| priorSnapshot.sessionId !== snapshot.sessionId || priorSnapshot.backendKind !== snapshot.backendKind) return null;
|
||||||
const [failed] = await db.select({ finishedAt: heartbeatRuns.finishedAt }).from(heartbeatRuns).where(and(
|
const [failed] = await db.select({ finishedAt: heartbeatRuns.finishedAt, startedAt: heartbeatRuns.startedAt }).from(heartbeatRuns).where(and(
|
||||||
eq(heartbeatRuns.id, source.id), eq(heartbeatRuns.companyId, selected.companyId), eq(heartbeatRuns.agentId, selected.agentId),
|
eq(heartbeatRuns.id, source.id), eq(heartbeatRuns.companyId, selected.companyId), eq(heartbeatRuns.agentId, selected.agentId),
|
||||||
eq(heartbeatRuns.nativeIssueId, selected.issueId), eq(heartbeatRuns.nativeSessionId, selected.sessionId),
|
eq(heartbeatRuns.nativeIssueId, selected.issueId), eq(heartbeatRuns.nativeSessionId, selected.sessionId),
|
||||||
eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.status, "failed")));
|
eq(heartbeatRuns.runtimeMode, "native"), eq(heartbeatRuns.status, "failed")));
|
||||||
return failed?.finishedAt ? priorSnapshot : null;
|
return failed?.finishedAt && prior.finishedAt <= (failed.startedAt ?? source.createdAt) ? priorSnapshot : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function hasConfirmedOlderSupervisor(db: Db, selected: Selected,
|
async function hasConfirmedOlderSupervisor(db: Db, selected: Selected,
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner:
|
|||||||
if (!owner.writersStopped) fail("offline_writers_required");
|
if (!owner.writersStopped) fail("offline_writers_required");
|
||||||
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(offlineMigrationExecutionWhere()).limit(1);
|
const [active] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(offlineMigrationExecutionWhere()).limit(1);
|
||||||
if (active) fail("offline_writers_required");
|
if (active) fail("offline_writers_required");
|
||||||
const [run] = await tx.select({ finishedAt: heartbeatRuns.finishedAt, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
|
const [run] = await tx.select({ finishedAt: heartbeatRuns.finishedAt, startedAt: heartbeatRuns.startedAt, createdAt: heartbeatRuns.createdAt, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
|
||||||
eq(heartbeatRuns.id, owner.runId), eq(heartbeatRuns.companyId, owner.companyId),
|
eq(heartbeatRuns.id, owner.runId), eq(heartbeatRuns.companyId, owner.companyId),
|
||||||
eq(heartbeatRuns.nativeIssueId, owner.issueId), eq(heartbeatRuns.runtimeMode, "native"))).for("share");
|
eq(heartbeatRuns.nativeIssueId, owner.issueId), eq(heartbeatRuns.runtimeMode, "native"))).for("share");
|
||||||
const [coordinator] = await tx.select({ leaseOwner: nativeRunFinalizations.leaseOwner,
|
const [coordinator] = await tx.select({ leaseOwner: nativeRunFinalizations.leaseOwner,
|
||||||
@@ -116,7 +116,7 @@ async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner:
|
|||||||
if (coordinator.phase !== "terminal_failure") {
|
if (coordinator.phase !== "terminal_failure") {
|
||||||
// Evidence archival does not settle or resume this stale coordinator.
|
// Evidence archival does not settle or resume this stale coordinator.
|
||||||
// Lease expiry alone is insufficient: every remote writer needs its
|
// Lease expiry alone is insufficient: every remote writer needs its
|
||||||
// exact host-owned stop receipt, recorded after the failed finish.
|
// exact host-owned stop receipt, recorded after execution began.
|
||||||
if (coordinator.phase !== "observed" || coordinator.resultId || coordinator.nextAttemptAt
|
if (coordinator.phase !== "observed" || coordinator.resultId || coordinator.nextAttemptAt
|
||||||
|| task.executionRunId === owner.runId) fail("offline_owner_unsettled");
|
|| task.executionRunId === owner.runId) fail("offline_owner_unsettled");
|
||||||
const leases = await tx.select().from(environmentLeases).where(and(
|
const leases = await tx.select().from(environmentLeases).where(and(
|
||||||
@@ -125,7 +125,7 @@ async function fence(tx: Parameters<Parameters<Db["transaction"]>[0]>[0], owner:
|
|||||||
const receipt = lease.metadata?.remoteExecutionTermination as { confirmedAt?: unknown } | undefined;
|
const receipt = lease.metadata?.remoteExecutionTermination as { confirmedAt?: unknown } | undefined;
|
||||||
const confirmedAt = Date.parse(String(receipt?.confirmedAt));
|
const confirmedAt = Date.parse(String(receipt?.confirmedAt));
|
||||||
return lease.issueId !== owner.issueId || !hasRemoteTerminationReceipt(lease)
|
return lease.issueId !== owner.issueId || !hasRemoteTerminationReceipt(lease)
|
||||||
|| !Number.isFinite(confirmedAt) || confirmedAt < run.finishedAt!.getTime() || confirmedAt > new Date(clock.now).getTime();
|
|| !Number.isFinite(confirmedAt) || confirmedAt < (run.startedAt ?? run.createdAt).getTime() || confirmedAt > new Date(clock.now).getTime();
|
||||||
})) fail("offline_owner_unsettled");
|
})) fail("offline_owner_unsettled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user