From 31be2b5840f1809dbf74ed750bda231d70323ff8 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 02:09:31 +0800 Subject: [PATCH 1/3] fix(coordination): drop orphaned leases from the candidate head Archiving a Todo that still holds a released task-lease record left that lease in the candidate head while the source projection dropped it, so bounded qualification reported shadow_projection_drift and refused to qualify. Reproduced on main 9231d5b3d with the public CLI: a leased complete followed by archive-completed drifts, while an unleased control stays matched. The source projection models only the current Todo graph and treats the lease directory as append-retained history. Capture now applies the same rule in two places: the lease partition drops a lease whose Todo is absent from the supplied graph, and the Todo-partition fold drops leases that left the graph with it. A missing graph keeps the strict pre-existing behavior. Refs #4315 Signed-off-by: song --- .../coordination/local_authority_shadow.ts | 8 +++++ .../local_authority_shadow_outbox.ts | 29 +++++++++++++++-- .../work_items/task_lease_acquire.ts | 1 + .../work_items/task_lease_lifecycle.ts | 5 +++ .../coordination_runtime_shadow.test.ts | 23 ++++++++++++++ .../local_authority_shadow_outbox.test.ts | 31 ++++++++++++++++++- 6 files changed, 94 insertions(+), 3 deletions(-) diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 37b9fe7864..200d1a4c9b 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -711,6 +711,14 @@ export function composeLocalAuthorityShadowHead( if (entry.partition === "todos") { handoffMode = String(projection.handoff_mode); todos = structuredClone(projection.todos as JsonObject[]); + // The Todo partition is the current-graph authority. When a Todo leaves + // that graph (archived, superseded or removed), its retained lease file + // becomes an orphan edge that the source projection never emits. Retain + // only leases whose Todo is still part of the graph the projection just + // published, so the candidate head cannot accumulate an orphan that the + // next qualification reports as `shadow_projection_drift`. + const graphTodoIds = new Set(todos.map((item) => String(item.todo_id))); + leases = leases.filter((lease) => graphTodoIds.has(String(lease.todo_id))); } else { leases = structuredClone(projection.leases as JsonObject[]); } diff --git a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts index 998a56943a..d6686152e2 100644 --- a/loopx/control_plane/coordination/local_authority_shadow_outbox.ts +++ b/loopx/control_plane/coordination/local_authority_shadow_outbox.ts @@ -160,10 +160,25 @@ async function nextSeq(directory: string, runtimeRoot: string, goalId: string, l return highest + 1; } +/** + * Read the lease partition for one capture. + * + * The legacy lease directory is append-retained history: archiving a Todo + * leaves its released lease file on disk. The source projection models only + * the current Todo graph, so it drops a lease whose Todo is no longer part of + * that graph (see `build_todo_runtime_shadow_projection`). Capture must apply + * the same rule, or archiving a Todo after a released lease writes an orphan + * edge into the candidate head and parity reports `shadow_projection_drift`. + * + * `activeTodoIds` is the current Todo graph supplied by the caller; `null` + * means the caller could not read it, and the capture stays strict rather + * than guessing a projection. + */ async function readLeasePartition( leaseDirectory: string, plannedStem: string, plannedLease: JsonObject | null, + activeTodoIds: ReadonlySet | null, ): Promise { const records = new Map(); let names: string[] = []; @@ -176,6 +191,7 @@ async function readLeasePartition( if (!LEASE_FILE.test(name) || name.startsWith(".")) continue; const stem = name.slice(0, -".json".length); if (stem === plannedStem) continue; + if (activeTodoIds !== null && !activeTodoIds.has(stem)) continue; const raw: unknown = JSON.parse(await readFile(join(leaseDirectory, name), "utf8")); if (raw !== null && typeof raw === "object" && !Array.isArray(raw)) { records.set(stem, raw as JsonObject); @@ -208,6 +224,12 @@ export interface LeaseOutboxCaptureInput { operation_id: string | null; previous_lease: JsonObject | null; planned_lease: JsonObject; + /** + * Current Todo graph for the goal, used to drop leases orphaned by an + * archived Todo exactly as the source projection does. `null` keeps the + * strict pre-existing behavior. + */ + active_todo_ids: readonly string[] | null; } export interface LeaseOutboxCapture { @@ -257,8 +279,11 @@ export async function beginLeaseOutboxEntry( canonicalAuthorityBytes(input.previous_lease).equals(canonicalAuthorityBytes(input.planned_lease))) { return { ...inert, skipped_reason: "partition_unchanged" }; } - const projection = { leases: await readLeasePartition(input.lease_directory, plannedStem, input.planned_lease) }; - const previousRecords = await readLeasePartition(input.lease_directory, plannedStem, input.previous_lease); + const activeTodoIds = input.active_todo_ids === null + ? null + : new Set(input.active_todo_ids); + const projection = { leases: await readLeasePartition(input.lease_directory, plannedStem, input.planned_lease, activeTodoIds) }; + const previousRecords = await readLeasePartition(input.lease_directory, plannedStem, input.previous_lease, activeTodoIds); for (const item of [...previousRecords, ...projection.leases]) { const record = item.record as JsonObject; if (record.goal_id !== input.goal_id || record.todo_id !== item.file_stem) { diff --git a/loopx/control_plane/work_items/task_lease_acquire.ts b/loopx/control_plane/work_items/task_lease_acquire.ts index 9157f2af37..f7f5684b41 100644 --- a/loopx/control_plane/work_items/task_lease_acquire.ts +++ b/loopx/control_plane/work_items/task_lease_acquire.ts @@ -1353,6 +1353,7 @@ async function commitAcquire( operation_id: request.idempotency_key, previous_lease: existing, planned_lease: lease, + active_todo_ids: [...request.authority.todos.keys()], }); if (shadowCapture?.failure && await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) !== null) { throw new ShadowManagementError("shadow_capture_prepare_failed", "durable shadow preparation failed; the primary lease was not changed"); diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index 36e816ba8f..fafd756518 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -518,6 +518,11 @@ async function captureLeaseWrite( operation_id: request.idempotency_key ?? request.fence_operation_id, previous_lease: previous, planned_lease: next, + // Lifecycle requests may omit authority facts; absent authority keeps the + // strict pre-existing capture instead of guessing a Todo graph. + active_todo_ids: request.authority === null + ? null + : [...request.authority.todos.keys()], }); if (capture.failure && await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) !== null) { throw new ShadowManagementError("shadow_capture_prepare_failed", "durable shadow preparation failed; the primary lease was not changed"); diff --git a/tests/control_plane_ts/coordination_runtime_shadow.test.ts b/tests/control_plane_ts/coordination_runtime_shadow.test.ts index 36668e3703..1e05d013ba 100644 --- a/tests/control_plane_ts/coordination_runtime_shadow.test.ts +++ b/tests/control_plane_ts/coordination_runtime_shadow.test.ts @@ -6,6 +6,7 @@ import type { JsonObject } from "../../loopx/control_plane/effect_program.ts"; import { canonicalAuthoritySha256 } from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import * as schemas from "../../loopx/control_plane/coordination/coordination_state_contract.generated.ts"; import { commitLocalAuthorityShadowEntry, readLocalAuthorityShadow } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; +import { composeLocalAuthorityShadowHead } from "../../loopx/control_plane/coordination/local_authority_shadow.ts"; import { bootstrapCoordinationRuntimeShadow, commitCoordinationRuntimeShadow, inspectCoordinationRuntimeShadow, qualifyCoordinationRuntimeShadow, readCoordinationRuntimeShadowTodoCandidate, rollbackCoordinationRuntimeShadow } from "../../loopx/control_plane/coordination/runtime_shadow.ts"; import { fixture, pendingEntry, projection, settleFiles, sourceRequest, todo, type ShadowFixture } from "./shadow_file_fixture.ts"; @@ -167,3 +168,25 @@ test("rollback can archive invalid cursor and pending entries without reading or assert.equal(result.status, "applied"); assert.deepEqual(await readFile(f.statePath), primary); assert.equal((await f.store.loadAuthority()).status, "missing"); }); + +test("archiving a Todo drops its retained lease from the candidate head", async (t) => { + const f = await fixture(t); + const lease = { schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_one", owner: "agent-a", + idempotency_key: "k1", version: 1, lease_epoch: 1, status: "released", updated_at: "2026-09-06T00:00:00Z" }; + const base = { ...projection([todo("todo_one")]), leases: [lease] }; + // A lease whose Todo left the graph would become an orphan edge the source + // projection never emits, so the Todo-partition fold must drop it. + const archived = composeLocalAuthorityShadowHead(base, "goal-a", + { partition: "todos", seq: 2 }, projection([]), "sha256:archived"); + assert.deepEqual(archived.todos, []); + assert.deepEqual(archived.leases, []); + // A lease whose Todo is still in the graph survives the same fold. + const retained = composeLocalAuthorityShadowHead(base, "goal-a", + { partition: "todos", seq: 2 }, projection([todo("todo_one")]), "sha256:retained"); + assert.deepEqual(retained.leases, [lease]); + // The lease partition remains authoritative for its own writes. + const leased = composeLocalAuthorityShadowHead(base, "goal-a", + { partition: "leases", seq: 3 }, { leases: [] }, "sha256:leased"); + assert.deepEqual(leased.leases, []); + assert.deepEqual(leased.todos, [todo("todo_one")]); +}); diff --git a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts index 491a8cde5c..19176332cf 100644 --- a/tests/control_plane_ts/local_authority_shadow_outbox.test.ts +++ b/tests/control_plane_ts/local_authority_shadow_outbox.test.ts @@ -135,12 +135,41 @@ test("a lease writer with a missing cursor obtains its next sequence from proved await unlink(join(directory, "drain-cursor.json")); const capture = await beginLeaseOutboxEntry({ runtime_root: f.root, goal_id: "goal-a", lease_directory: join(f.root, "goals", "goal-a", "task-leases"), write_class: "task_lease_renew", - operation_id: null, previous_lease: lease, planned_lease: { ...lease, version: 2 } }); + operation_id: null, previous_lease: lease, planned_lease: { ...lease, version: 2 }, + active_todo_ids: null }); assert.equal(capture.failure, null); assert.equal(capture.seq, 2); await assert.rejects(readFile(join(directory, "drain-cursor.json")), { code: "ENOENT" }); }); +test("lease capture omits a lease whose Todo left the current graph", async (t) => { + const f = await fixture(t); + const leaseDirectory = join(f.root, "goals", "goal-a", "task-leases"); + const archivedLease = { schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_gone", + owner: "agent-a", version: 1, lease_epoch: 1, status: "released", updated_at: "2026-09-06T00:00:00Z" }; + await writeFile(join(leaseDirectory, "todo_gone.json"), JSON.stringify(archivedLease)); + const planned = { schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_one", + owner: "agent-a", version: 1, lease_epoch: 1, status: "active", updated_at: "2026-09-06T00:00:00Z" }; + // `todo_gone` is absent from the graph: the capture must not project it. + const filtered = await beginLeaseOutboxEntry({ runtime_root: f.root, goal_id: "goal-a", + lease_directory: leaseDirectory, write_class: "task_lease_acquire", operation_id: "op-1", + previous_lease: null, planned_lease: planned, active_todo_ids: ["todo_one"] }); + assert.equal(filtered.failure, null); + const prepared = JSON.parse(await readFile( + join(f.root, "authority-shadow", "outbox", "goal-a", "leases", + `0000000001-${filtered.entry_id}.prepared.json`), "utf8")); + assert.deepEqual(prepared.projection.leases.map((item: JsonObject) => item.file_stem), ["todo_one"]); + // A graph that still contains the Todo retains it: the rule drops orphans only. + const retained = await beginLeaseOutboxEntry({ runtime_root: f.root, goal_id: "goal-a", + lease_directory: leaseDirectory, write_class: "task_lease_acquire", operation_id: "op-2", + previous_lease: null, planned_lease: planned, active_todo_ids: ["todo_one", "todo_gone"] }); + assert.equal(retained.failure, null); + const second = JSON.parse(await readFile( + join(f.root, "authority-shadow", "outbox", "goal-a", "leases", + `0000000002-${retained.entry_id}.prepared.json`), "utf8")); + assert.deepEqual(second.projection.leases.map((item: JsonObject) => item.file_stem), ["todo_gone", "todo_one"]); +}); + for (const [marker, resolution, expected] of [ [true, "committed", "delivered"], [true, "abandoned", "failed"], From 2edda6666cc5368e8e3882e89b3d6cc1de0b2904 Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 17:00:54 +0800 Subject: [PATCH 2/3] test(coordination): make the archive-after-lease parity row executable The Stage 2C2 gap `s2c2.archive_after_leased_completion_parity` was a declaration: `todo archive-completed` on a Todo holding a released lease record left that lease in the candidate head while the source projection dropped it, so bounded qualification reported `shadow_projection_drift`. Two rules had to agree with the source, not one: * The Todo-partition fold now derives the current graph from `archive_state === "active"`. A published Todo partition also retains archived rows for audit, so the raw record list is not the graph; folding against it re-admitted the lease the archive had just orphaned. * The lease-partition capture filters the retained lease directory against the caller's already-decoded current Todo graph, so a later lease write cannot inherit the archived Todo's retained lease. The declaration is replaced by a deterministic real-CLI row that drives `todo add -> task-lease acquire -> todo complete (fenced) -> add -> todo archive-completed -> add -> task-lease acquire` and asserts `matched` parity, no drift reason, a bounded qualification requiring the archive event kind, a qualified candidate read, and both retained lease files. Removing either rule fails the row. Verified: the row fails on the unfixed tree and passes with the fix; the TS suite is 30/30 on the two shadow files; the ladder file is 23 passed, 8 skipped; Ruff findings are unchanged from the upstream baseline. Signed-off-by: song --- ...shared-goal-authority-state-provider-v0.md | 14 +-- ...-goal-authority-state-provider-v0.zh-CN.md | 11 ++- examples/shared-goal-authority-e2e/README.md | 20 ++-- .../shared-goal-authority-e2e/correctness.md | 8 +- .../coordination/local_authority_shadow.ts | 31 ++++-- .../testing/authority_e2e_ladder.py | 17 ++-- .../testing/authority_e2e_rows_stage2c2.py | 96 ++++++++++++++++++- .../test_shared_goal_authority_e2e.py | 3 +- .../coordination_runtime_shadow.test.ts | 11 ++- 9 files changed, 163 insertions(+), 48 deletions(-) diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md index f87de35fb2..0c983001b9 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.md @@ -2122,12 +2122,14 @@ relaxes. Delivery boundary: test-only. No production entry point constructs any store; the ladder adds no product path and reads the candidate only through the -retained TypeScript store. The Stage 2C parity half executes through the ten -`s2c2.*` rows above; two declarations stay pending. -`s2c2.archive_after_leased_completion_parity` records a capture gap the parity -row exposed: `todo archive-completed` on a Todo holding a released lease record -keeps that lease in the candidate head while the source projection drops the -orphaned lease, so bounded qualification reports `shadow_projection_drift`. +retained TypeScript store. The Stage 2C parity half executes through the eleven +`s2c2.*` rows above; one declaration stays pending. +`s2c2.archive_after_leased_completion_parity` was declared from the capture gap +the parity row exposed and is now an executable deterministic row: the parity +half folds the Todo partition against the same current-graph rule the source +projection applies, so archiving a Todo that holds a released lease record +keeps the candidate head matched instead of reporting +`shadow_projection_drift`. `s2c2.sustained_parity_soak` is the >=10-day synthetic-goal soak owned by Section 7.2 and lane L, and bounded qualification keeps reporting `sustained_parity_verdict=not_evaluated`. This subsection records executable diff --git a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md index 4a55cf8251..acfe1b6415 100644 --- a/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md +++ b/docs/architecture/rfcs/shared-goal-authority-state-provider-v0.zh-CN.md @@ -1688,11 +1688,12 @@ Live 行按环境门控(`LOOPX_TEST_POSTGRES_URL`;`NOKV_COORDINATION_LIVE=1` `summary.privacy_violations` 阻止 green 退出,任何开关都不能放宽。 交付边界:test-only。没有任何生产入口构造任何 store;ladder 不新增产品路径, -只经保留的 TypeScript store 读取候选。Stage 2C parity 后半段由上述十个 -`s2c2.*` 行执行;仍有两条声明保持 pending。`s2c2.archive_after_leased_completion_parity` -记录 parity 行暴露的一个 capture 缺口:对持有已释放 lease 记录的 Todo 执行 -`todo archive-completed` 后,候选 head 仍保留该 lease,而 source 投影会丢弃这条 -已成孤儿的 lease,于是有界 qualification 报告 `shadow_projection_drift`。 +只经保留的 TypeScript store 读取候选。Stage 2C parity 后半段由上述十一个 +`s2c2.*` 行执行;只有一条声明保持 pending。`s2c2.archive_after_leased_completion_parity` +原先是 parity 行暴露的 capture 缺口声明,现已成为可执行的确定性行:parity 后半段 +在对 Todo 分区做折叠时套用与 source 投影同一条当前图规则,因此对持有已释放 lease +记录的 Todo 执行 `todo archive-completed` 后,候选 head 仍保持 matched,而不再报告 +`shadow_projection_drift`。 `s2c2.sustained_parity_soak` 是由 7.2 节与车道 L 负责的 >=10 天合成 goal soak, 有界 qualification 继续报告 `sustained_parity_verdict=not_evaluated`。本小节记录 的是上述阶段的可执行证据;它不晋升任何 provider,也不完成 Stage 2C promotion。 diff --git a/examples/shared-goal-authority-e2e/README.md b/examples/shared-goal-authority-e2e/README.md index b70b032213..0857a3c61a 100644 --- a/examples/shared-goal-authority-e2e/README.md +++ b/examples/shared-goal-authority-e2e/README.md @@ -54,20 +54,22 @@ suites rather than in the pytest shards. | `s2c2.event_only_todo_source_holds` | 2c2 | real_cli | deterministic | an event-only Todo appended to the goal's state event log makes `inspect`, `qualify` and `read-candidate` fail closed with `event_log_writer_not_bound`, `status` stays readable, a Markdown write still commits with its capture held, the event log is untouched; removing the event source does not requalify, and rollback plus rebootstrap recovers | | `s2c2.migration_seeds_and_drains` | 2c2 | real_cli | deterministic | `migrate-state` previews an actively captured goal without writing, refuses `--execute` with `shadow_source_replacement_requires_rebootstrap` (also when capture is merely disabled), and executes only after `rollback`; the migrated goal carries its disabled capture configuration, plans no observation seed, requires its own `bootstrap`, then captures a write to cursor `2` and qualifies on it while the legacy archive is retained | | `s2c2.growth_measurement_gate` | 2c2 | real_cli | deterministic | ten fixed-size `todo add` writes: the cursor advances by one each time, `store_bytes` grows monotonically, the per-transaction delta accelerates by at most 2048 bytes (one live record), every retained transaction carries its complete projection, `retention_pressure` stays false; the report carries final and cumulative publication bytes and claims no capacity horizon (`capacity_verdict=not_evaluated`) | +| `s2c2.archive_after_leased_completion_parity` | 2c2 | real_cli | deterministic | a leased Todo is completed through its fence and then archived by `todo archive-completed`: the archive retires the Todo from the current graph while its released lease file stays on disk as audit history, and `inspect` still reports `matched` with `parity_matches=true`, no drift reason, a qualified bounded read of a co-resident open Todo, and a `qualify` that requires the archive event kind; the released lease file remains on disk | Pending rows are declared in the report as `pending`, never counted as pass, -and they block a green exit unless `--allow-pending` is passed. Two -declarations remain. `s2c2.archive_after_leased_completion_parity` records a -capture gap found while building the parity row: `todo archive-completed` on a -Todo that holds a released lease record leaves that lease in the candidate -head while the source projection drops the now-orphaned lease, so `inspect` -reports `shadow_projection_drift`; the parity row therefore archives nothing -and the gap stays visible until the archive writer captures the lease it -orphans. `s2c2.sustained_parity_soak` is the >=10-day synthetic-goal soak of -the selected local profile owned by RFC Section 7.2 (lane L). Bounded +and they block a green exit unless `--allow-pending` is passed. One +declaration remains: `s2c2.sustained_parity_soak`, the >=10-day synthetic-goal +soak of the selected local profile owned by RFC Section 7.2 (lane L). Bounded qualification reports `sustained_parity_verdict=not_evaluated`, and no `s2c2.*` row promotes a provider or completes the Stage 2C promotion. +The former `s2c2.archive_after_leased_completion_parity` declaration is now an +executable row. The gap it recorded is closed at the fold: a Todo partition +carries the published Todo read records, and the candidate head now keeps a +lease edge only for Todos still in the current graph +(`archive_state === "active"`), matching the rule the source projection and the +TypeScript source verification already apply. + The `s2c2.*` rows use two scheduling-only seams outside every product decision: holding the stable maintenance lock, which makes a writer report `drain_deferred/drain_lock_busy` and leave its committed entry pending, and a diff --git a/examples/shared-goal-authority-e2e/correctness.md b/examples/shared-goal-authority-e2e/correctness.md index fb61354eb9..1b28b3d9c9 100644 --- a/examples/shared-goal-authority-e2e/correctness.md +++ b/examples/shared-goal-authority-e2e/correctness.md @@ -314,10 +314,10 @@ Python/TS/JSON provenance, and reads back through an independent native process. The mandatory repair set must have zero failures, skips, pending, or unverified cases. Broader ladder rows retain their declared pending/environment gates: -`s2c2.archive_after_leased_completion_parity` stays pending until the -archive-completed writer captures the released lease it orphans, and -`s2c2.sustained_parity_soak` until the Section 7.2 soak exists; these tests -grant neither production promotion nor a completed Stage 2C claim. +`s2c2.archive_after_leased_completion_parity` is now an executable +deterministic row rather than a declaration, and `s2c2.sustained_parity_soak` +stays pending until the Section 7.2 soak exists; these tests grant neither +production promotion nor a completed Stage 2C claim. For a caller comparison, run both `test_shadow_observable*_e2e.py` files with `LOOPX_SHADOW_COMPARISON_SOURCE` set to an immutable baseline checkout, then to diff --git a/loopx/control_plane/coordination/local_authority_shadow.ts b/loopx/control_plane/coordination/local_authority_shadow.ts index 200d1a4c9b..e99408ad0e 100644 --- a/loopx/control_plane/coordination/local_authority_shadow.ts +++ b/loopx/control_plane/coordination/local_authority_shadow.ts @@ -688,6 +688,24 @@ function partitionsOf(head: JsonObject | null): JsonObject { return partitions; } +/** + * Todo ids still present in the current Todo graph. + * + * A published Todo partition also retains archived rows for audit, so the raw + * record list is not the graph. The source projection + * (`build_todo_runtime_shadow_projection`) and the TypeScript source + * verification both define the graph as `archive_state === "active"`; capture + * and fold must apply that same typed membership rule or the candidate head + * keeps a lease edge the source never emits. + */ +export function currentGraphTodoIds(todos: readonly JsonObject[]): Set { + return new Set( + todos + .filter((item) => item.archive_state === "active") + .map((item) => String(item.todo_id)), + ); +} + /** * Fold one partition into the candidate head. A v0 head (whole-snapshot * observation) is accepted as the starting point with no partition markers. @@ -711,13 +729,12 @@ export function composeLocalAuthorityShadowHead( if (entry.partition === "todos") { handoffMode = String(projection.handoff_mode); todos = structuredClone(projection.todos as JsonObject[]); - // The Todo partition is the current-graph authority. When a Todo leaves - // that graph (archived, superseded or removed), its retained lease file - // becomes an orphan edge that the source projection never emits. Retain - // only leases whose Todo is still part of the graph the projection just - // published, so the candidate head cannot accumulate an orphan that the - // next qualification reports as `shadow_projection_drift`. - const graphTodoIds = new Set(todos.map((item) => String(item.todo_id))); + // The Todo partition carries the published Todo read records, including + // archived rows retained for audit. The candidate head, like the source + // projection, keeps live lease edges only for Todos that are still in the + // current graph (`archive_state === "active"`); a retained archived row + // must not re-admit the lease its archive just orphaned. + const graphTodoIds = currentGraphTodoIds(todos); leases = leases.filter((lease) => graphTodoIds.has(String(lease.todo_id))); } else { leases = structuredClone(projection.leases as JsonObject[]); diff --git a/loopx/control_plane/testing/authority_e2e_ladder.py b/loopx/control_plane/testing/authority_e2e_ladder.py index 29fbd15d07..559cc02fd2 100644 --- a/loopx/control_plane/testing/authority_e2e_ladder.py +++ b/loopx/control_plane/testing/authority_e2e_ladder.py @@ -50,6 +50,7 @@ row_migration_seeds_new_lineage, ) from .authority_e2e_rows_stage2c2 import ( + row_archive_after_leased_completion_parity, row_drain_idempotent, row_event_only_todo_source_holds, row_growth_measurement_gate, @@ -750,16 +751,18 @@ def _row_postgresql_conformance_live(context: RowContext) -> RowOutcome: posix_only=False, run=row_growth_measurement_gate, ), + LadderRow( + id="s2c2.archive_after_leased_completion_parity", + stage="2c2", + title="archiving a Todo whose released lease stays on disk keeps the candidate head matched and qualifiable", + product_path="real_cli", + gate="deterministic", + posix_only=False, + run=row_archive_after_leased_completion_parity, + ), ) PENDING_ROWS: tuple[PendingRow, ...] = ( - PendingRow( - "s2c2.archive_after_leased_completion_parity", - "2c2", - "the archive-completed writer captures the released lease it orphans: archiving a Todo " - "that holds a released lease record leaves that lease in the candidate head while the " - "source projection drops it, so bounded qualification reports shadow_projection_drift", - ), PendingRow( "s2c2.sustained_parity_soak", "2c2", diff --git a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py index 13f965f12d..ff3c9f1855 100644 --- a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py +++ b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py @@ -63,11 +63,10 @@ ) DRAIN_CRASH_WINDOWS: tuple[str, ...] = ("before_commit", "after_commit", "after_cursor", "between_unlinks") PARITY_CYCLES = 3 -# ``todo archive-completed`` is deliberately absent: archiving a Todo that holds -# a released lease record orphans that lease in the source projection while the -# candidate head keeps it, so the bounded qualification drifts. The ladder -# declares that gap as ``s2c2.archive_after_leased_completion_parity`` instead -# of hiding it inside a passing row. +# ``todo archive-completed`` stays out of this shared cross-writer parity set +# because it is a Python-only lifecycle writer with no TypeScript counterpart to +# interleave; the archive-after-lease fold is pinned by its own deterministic row +# ``s2c2.archive_after_leased_completion_parity`` instead. PARITY_REQUIRED_WRITE_CLASSES: tuple[str, ...] = ( "todo_add", "todo_update", @@ -80,6 +79,19 @@ "task_lease_fence_close", ) GROWTH_TRANSACTIONS = 10 +# The archive-after-lease row delivers six transactions: the add, the lease +# acquire, the fenced complete (which also closes the lease fence), the anchor +# add, and the archive that retires the completed Todo from the graph. +ARCHIVE_PARITY_OPERATIONS = 6 +# The row's own coverage: the Todo add, the lease acquire, the fenced complete +# with its lease fence close, and the archive-completed writer. +ARCHIVE_PARITY_REQUIRED_WRITE_CLASSES: tuple[str, ...] = ( + "todo_add", + "todo_complete", + "todo_archive_completed", + "task_lease_acquire", + "task_lease_fence_close", +) GROWTH_TEXT_TEMPLATE = "Growth workload todo %02d " + "x" * 160 # Each file-v0 transaction retains the complete projection, so the per-transaction # byte delta may grow by about one Todo record per transaction. A larger jump @@ -1121,6 +1133,79 @@ def row_growth_measurement_gate(context: RowContext) -> RowOutcome: ) +def row_archive_after_leased_completion_parity(context: RowContext) -> RowOutcome: + """Archiving a Todo whose released lease stays on disk keeps the candidate head matched and qualifiable.""" + + workspace = capture_workspace(context, "ladder-archive-leased") + leased = add_todo(workspace, "Leased todo archived after its completion is captured.") + leased_todo_id = str(leased["todo_id"]) + delivered(leased, label="todo add (leased)") + acquired = acquire_lease(workspace, todo_id=leased_todo_id, owner=AGENT_A, idempotency_key="ladder-archive-leased-a") + delivered(acquired, label="task-lease acquire") + completed = goal_cli( + workspace, "todo", "complete", "--todo-id", leased_todo_id, "--agent-id", AGENT_A, + "--task-lease-idempotency-key", "ladder-archive-leased-a", + "--task-lease-expected-version", lease_version(acquired, label="acquire"), + "--evidence", "validation://ladder-archive-leased", "--no-follow-up", + ) + delivered(completed, label="todo complete") + anchor = add_todo(workspace, "Anchor todo that stays open across the archive write.") + anchor_todo_id = str(anchor["todo_id"]) + delivered(anchor, label="todo add (anchor)") + qualified(qualify(workspace), label="baseline") + archived = goal_cli(workspace, "todo", "archive-completed", "--role", "agent", "--max-active-done", "0", "--execute") + expect(archived.get("changed") is True and archived.get("moved_count") == 1, "archive-completed must move exactly the completed todo") + inspection = _object(inspect(workspace).get("inspection"), "post-archive inspection") + expect( + inspection.get("status") == "matched" and inspection.get("parity_matches") is True, + "archiving a Todo whose released lease remains on disk must not orphan that lease in the candidate head", + ) + expect(inspection.get("reason_code") is None, "a matched archive must report no drift reason") + # Require exactly the event kinds this row delivers, including the archive + # that retires the Todo from the graph while its released lease file stays + # on disk as audit history. Reusing the mixed-writer parity set here would + # demand writers this row never drives. + flags = ["--minimum-operations", str(ARCHIVE_PARITY_OPERATIONS)] + for write_class in ARCHIVE_PARITY_REQUIRED_WRITE_CLASSES: + flags.extend(["--require-event-kind", write_class]) + qualification = qualified(qualify(workspace, *flags), label="post-archive") + read = read_candidate(workspace, anchor_todo_id) + expect(read.get("ok") is True, "a qualified read must remain available after the archive write") + lease_dir = workspace.runtime_root / "goals" / workspace.goal_id / "task-leases" + lease_names = sorted(path.name for path in lease_dir.glob("*.json")) + expect(f"{leased_todo_id}.json" in lease_names, "the released lease file must stay on disk as audit history") + + # The second boundary the same rule covers: a later lease write must not + # re-read the retained lease of the archived Todo into its own partition. + successor = add_todo(workspace, "Successor todo that takes a fresh lease after the archive.") + successor_todo_id = str(successor["todo_id"]) + delivered(successor, label="todo add (successor)") + successor_lease = acquire_lease( + workspace, todo_id=successor_todo_id, owner=AGENT_A, idempotency_key="ladder-archive-leased-b", + ) + delivered(successor_lease, label="task-lease acquire (successor)") + after_successor = _object(inspect(workspace).get("inspection"), "post-successor inspection") + expect( + after_successor.get("status") == "matched" and after_successor.get("parity_matches") is True, + "a lease write after the archive must not inherit the retained lease of the archived Todo", + ) + final_lease_names = sorted(path.name for path in lease_dir.glob("*.json")) + expect( + {f"{leased_todo_id}.json", f"{successor_todo_id}.json"} <= set(final_lease_names), + "both the archived Todo's audit lease and the successor's live lease must remain on disk", + ) + return passed( + archived_todo=leased_todo_id, + retained_lease_files=len(lease_names), + parity_status="matched", + parity_reason=None, + qualification_cursor=str(qualification.get("cursor")), + anchor_read_qualified=True, + successor_todo=successor_todo_id, + post_successor_parity="matched", + final_lease_files=len(final_lease_names), + ) + __all__ = [ "CRASH_WORKER", "DRAIN_CRASH_WINDOWS", @@ -1129,6 +1214,7 @@ def row_growth_measurement_gate(context: RowContext) -> RowOutcome: "PARITY_CYCLES", "PARITY_REQUIRED_WRITE_CLASSES", "PRIMARY_CRASH_WINDOWS", + "row_archive_after_leased_completion_parity", "row_drain_idempotent", "row_event_only_todo_source_holds", "row_growth_measurement_gate", diff --git a/tests/control_plane/test_shared_goal_authority_e2e.py b/tests/control_plane/test_shared_goal_authority_e2e.py index b61f982a21..1b9564d298 100644 --- a/tests/control_plane/test_shared_goal_authority_e2e.py +++ b/tests/control_plane/test_shared_goal_authority_e2e.py @@ -32,7 +32,7 @@ "s2b.postgresql_conformance_live", ) PENDING_ONLY_ROW_ID = "s2c2.sustained_parity_soak" -PENDING_ROW_IDS = ("s2c2.archive_after_leased_completion_parity", PENDING_ONLY_ROW_ID) +PENDING_ROW_IDS = (PENDING_ONLY_ROW_ID,) CHEAP_DETERMINISTIC_ROW_ID = "s0.file_matrix_twelve_rows" FULL_LADDER_VARIABLE = "LOOPX_LADDER_FULL" # Rows whose assertions the in-repo CLI E2E suite already pins through the same @@ -80,6 +80,7 @@ "s2c2.event_only_todo_source_holds", "s2c2.migration_seeds_and_drains", "s2c2.growth_measurement_gate", + "s2c2.archive_after_leased_completion_parity", ) STAGE_2C2_POSIX_ONLY_ROW_IDS = ( "s2c2.sigkill_between_primary_write_and_drain", diff --git a/tests/control_plane_ts/coordination_runtime_shadow.test.ts b/tests/control_plane_ts/coordination_runtime_shadow.test.ts index 1e05d013ba..e2b1fc0342 100644 --- a/tests/control_plane_ts/coordination_runtime_shadow.test.ts +++ b/tests/control_plane_ts/coordination_runtime_shadow.test.ts @@ -174,11 +174,14 @@ test("archiving a Todo drops its retained lease from the candidate head", async const lease = { schema_version: "task_lease_v0", goal_id: "goal-a", todo_id: "todo_one", owner: "agent-a", idempotency_key: "k1", version: 1, lease_epoch: 1, status: "released", updated_at: "2026-09-06T00:00:00Z" }; const base = { ...projection([todo("todo_one")]), leases: [lease] }; - // A lease whose Todo left the graph would become an orphan edge the source - // projection never emits, so the Todo-partition fold must drop it. + // The published Todo partition retains the archived row for audit; the graph + // it represents no longer contains `todo_one`. The fold must key off + // `archive_state === "active"` like the source projection does, or the + // retained archived row re-admits the lease its archive just orphaned. + const archivedTodo = { ...todo("todo_one", "done"), archive_state: "archive" }; const archived = composeLocalAuthorityShadowHead(base, "goal-a", - { partition: "todos", seq: 2 }, projection([]), "sha256:archived"); - assert.deepEqual(archived.todos, []); + { partition: "todos", seq: 2 }, projection([archivedTodo]), "sha256:archived"); + assert.deepEqual(archived.todos, [archivedTodo]); assert.deepEqual(archived.leases, []); // A lease whose Todo is still in the graph survives the same fold. const retained = composeLocalAuthorityShadowHead(base, "goal-a", From 86640c16d11ffe390d3c9ebd8db80c6a284cf69f Mon Sep 17 00:00:00 2001 From: song Date: Mon, 14 Sep 2026 20:43:59 +0800 Subject: [PATCH 3/3] fix(coordination): bind fence-close capture to the current Todo graph Signed-off-by: song --- .../testing/authority_e2e_rows_stage2c2.py | 15 +++++++++++++++ .../work_items/task_lease_acquire_adapter.py | 8 ++++++++ .../work_items/task_lease_lifecycle.ts | 3 +++ 3 files changed, 26 insertions(+) diff --git a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py index ff3c9f1855..b9e0f8cb0d 100644 --- a/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py +++ b/loopx/control_plane/testing/authority_e2e_rows_stage2c2.py @@ -1189,6 +1189,21 @@ def row_archive_after_leased_completion_parity(context: RowContext) -> RowOutcom after_successor.get("status") == "matched" and after_successor.get("parity_matches") is True, "a lease write after the archive must not inherit the retained lease of the archived Todo", ) + # Completion closes the lease fence in a separate native request. It must + # retain the same current-graph rule as acquire, not reintroduce audit leases. + successor_completed = goal_cli( + workspace, "todo", "complete", "--todo-id", successor_todo_id, "--agent-id", AGENT_A, + "--task-lease-idempotency-key", "ladder-archive-leased-b", + "--task-lease-expected-version", lease_version(successor_lease, label="successor acquire"), + "--evidence", "validation://ladder-successor-complete", "--no-follow-up", + ) + delivered(successor_completed, label="todo complete (successor)") + drained = drain(workspace) + expect(drained.get("ok") is True and drained.get("pending_after") == 0, + "successor fence-close must leave no unprovable lease partition") + qualified(qualify(workspace), label="post-successor completion") + expect(read_candidate(workspace, anchor_todo_id).get("ok") is True, + "candidate reads must survive successor fence-close") final_lease_names = sorted(path.name for path in lease_dir.glob("*.json")) expect( {f"{leased_todo_id}.json", f"{successor_todo_id}.json"} <= set(final_lease_names), diff --git a/loopx/control_plane/work_items/task_lease_acquire_adapter.py b/loopx/control_plane/work_items/task_lease_acquire_adapter.py index 074a6b01c8..a038e9d606 100644 --- a/loopx/control_plane/work_items/task_lease_acquire_adapter.py +++ b/loopx/control_plane/work_items/task_lease_acquire_adapter.py @@ -698,6 +698,14 @@ def execute_native_task_lease_lifecycle( "schema_version": LOCAL_AUTHORITY_SHADOW_BINDING_SCHEMA, "provider": "file_v0", } + if normalized_operation == "fence_close" and committed and release_lease: + # The preceding Todo write may have changed the source. + # Capture the current graph, not the terminal-verify snapshot + # or the append-retained lease directory. Default-off cleanup + # still needs no authority read. + request["authority"] = task_lease_acquire_authority_facts( + registry_path=registry_path, goal_id=goal_id, todo_id=todo_id, + ) compacted_todo = _compact_lifecycle_todo(todo, todo_id=str(todo_id)) if compacted_todo is not None and not canonical_renew: request["todo"] = compacted_todo diff --git a/loopx/control_plane/work_items/task_lease_lifecycle.ts b/loopx/control_plane/work_items/task_lease_lifecycle.ts index fafd756518..f9d65fc868 100644 --- a/loopx/control_plane/work_items/task_lease_lifecycle.ts +++ b/loopx/control_plane/work_items/task_lease_lifecycle.ts @@ -510,6 +510,9 @@ async function captureLeaseWrite( ): Promise> | null> { if (request.runtime_shadow === null && await requireShadowPrimaryWriteAllowed(request.runtime_root, request.goal_id) === null) return null; + if (request.operation === "fence_close" && request.authority !== null) { + await revalidateAuthoritySources(request.authority.source_receipts); + } const capture = await beginLeaseOutboxEntry({ runtime_root: request.runtime_root, goal_id: request.goal_id,