diff --git a/loopx/control_plane/coordination/authority_backup.ts b/loopx/control_plane/coordination/authority_backup.ts index fa94e66bbb..ef12c15d79 100644 --- a/loopx/control_plane/coordination/authority_backup.ts +++ b/loopx/control_plane/coordination/authority_backup.ts @@ -159,6 +159,7 @@ export async function inspectAuthorityBackup(dataRoot: string, row: JsonObject): if (head === null) throw new Error("authority capture has no head"); const collection = canonicalTodoCollection(head, goalId, true); return {provider: archive.summary.source_provider, head_digest: archive.summary.projection_sha256, + provider_revision: archive.summary.source_provider_revision, cursor: archive.summary.commits, history_commits: Number(archive.summary.commits), todo_count: collection.projection.todo_ids.length, historical_lease_count: collection.leaseIndex?.lease_todo_ids.length ?? 0, capture_started_at: row.started_at, capture_completed_at: row.completed_at}; diff --git a/tests/control_plane_ts/authority_backup.test.ts b/tests/control_plane_ts/authority_backup.test.ts index 1340325b4f..75507ee6b5 100644 --- a/tests/control_plane_ts/authority_backup.test.ts +++ b/tests/control_plane_ts/authority_backup.test.ts @@ -1,23 +1,26 @@ import assert from "node:assert/strict"; import test from "node:test"; import {existsSync} from "node:fs"; -import {mkdir, mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; +import {mkdir, mkdtemp, readFile, rename, rm, writeFile} from "node:fs/promises"; import {tmpdir} from "node:os"; import {dirname, join} from "node:path"; -import {authorityBackupDirectory, captureAuthorityBackup} from "../../loopx/control_plane/coordination/authority_backup.ts"; +import {authorityBackupDirectory, captureAuthorityBackup, inspectAuthorityBackup} from "../../loopx/control_plane/coordination/authority_backup.ts"; import {verifyAuthorityArchive} from "../../loopx/control_plane/coordination/authority_archive.ts"; import {openLocalAuthorityStore, localAuthorityProviderPaths, selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import {legacyCoordinationWriterFencePath, COLD_SOURCE_IMPORT_WRITER_FENCE_SCHEMA} from "../../loopx/control_plane/coordination/legacy_writer_fence.ts"; import type {AuthorityStore} from "../../loopx/control_plane/coordination/authority_store.ts"; +import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; for (const provider of ["file", "sqlite"] as const) { - test(`${provider} owner capture keeps a complete prefix during an actual append`, async () => { + test(`${provider} owner capture retains its inspected checkpoint after a later append and source loss`, async () => { const root = await mkdtemp(join(tmpdir(), "loopx-authority-backup-")); try { - if (provider === "sqlite") await selectLocalSqliteAuthority(root, "goal-a", true); - const store = await openLocalAuthorityStore(root, "goal-a"); + const source = join(root, "source"); + if (provider === "sqlite") await selectLocalSqliteAuthority(source, "goal-a", true); + const store = await openLocalAuthorityStore(source, "goal-a"); + const projection = (value: number) => authorityProjectionFixture("goal-a", [], [], "native", {value}); const first = await store.commitAuthority({expected_provider_revision: null, operation_id: "first", - events: [], receipts: [{operation_id: "first", retained: true}], next_projection: {goal_id: "goal-a", value: 1}}); + events: [], receipts: [{operation_id: "first", retained: true}], next_projection: projection(1)}); assert.equal(first.status, "applied"); let appended = false; // Wrap only scheduling of a real provider read. Both commits and the @@ -28,7 +31,7 @@ for (const provider of ["file", "sqlite"] as const) { if (!appended) { appended = true; const second = await target.commitAuthority({expected_provider_revision: first.provider_revision!, - operation_id: "second", events: [], receipts: [], next_projection: {goal_id: "goal-a", value: 2}}); + operation_id: "second", events: [], receipts: [], next_projection: projection(2)}); assert.equal(second.status, "applied"); } return page; @@ -37,13 +40,28 @@ for (const provider of ["file", "sqlite"] as const) { return typeof value === "function" ? value.bind(target) : value; }}); const destination = join(root, "capture"); - const result = await captureAuthorityBackup({runtime_root: root, goal_id: "goal-a", destination}, + const result = await captureAuthorityBackup({runtime_root: source, goal_id: "goal-a", destination}, {createStore: () => interleaved}); assert.equal(result.state, "readable"); - const exported = await verifyAuthorityArchive(join(destination, authorityBackupDirectory("goal-a"), "authority.jsonl")); + const archivePath = join(destination, authorityBackupDirectory("goal-a"), "authority.jsonl"); + const exported = await verifyAuthorityArchive(archivePath); assert.equal(exported.commits, "1"); - assert.equal((await store.loadAuthority()).cursor, "2"); + const later = await store.loadAuthority(); + assert.equal(later.status, "loaded"); + assert.equal(later.cursor, "2"); assert.equal(appended, true); + await rename(source, join(root, "unavailable-source")); + const bytes = await readFile(archivePath); + const inspected = await inspectAuthorityBackup(destination, result); + assert.equal(inspected.provider_revision, first.provider_revision); + assert.equal(inspected.cursor, "1"); + assert.equal(inspected.history_commits, 1); + // A manifest's claimed position cannot replace the archive owner's + // verified source position, even when it names a real later revision. + await assert.rejects(() => inspectAuthorityBackup(destination, {...result, + archive: {...exported, source_provider_revision: later.provider_revision}}), /summary mismatch/); + assert.deepEqual(await readFile(archivePath), bytes); + assert.equal(existsSync(source), false); } finally { await rm(root, {recursive: true, force: true}); } }); } diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index 0eef9b61dd..b21a8f6fdb 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -299,6 +299,9 @@ def test_real_backup_recovers_readable_authority_after_original_sources_disappea assert code == 0, captured captured_position = captured["authority_read"] assert captured_position["cursor"] == "2" + [capture] = json.loads(manifest.read_text())["execution"]["authority_captures"] + assert capture["archive"]["source_provider_revision"] == captured_position["provider_revision"] + assert capture["archive"]["commits"] == captured_position["cursor"] # A later real source commit must not become this backup's recovery point. code, later = _cli(*source_args, "todo", "add", "--goal-id", "goal-a", "--role", "agent", "--claimed-by", "agent-a", "--operation-id", "after-backup",