From 6554f13f4681cd8d6a7af9cb7251f4759d35f05c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:42:46 +0800 Subject: [PATCH 1/3] fix(todos): checkpoint completion phase identity for post-writeback hooks Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/post_writeback.py | 2 + loopx/cli_commands/todo.py | 4 +- .../coordination/coordination_projection.ts | 1 + .../coordination_state_contract.generated.ts | 1 + .../coordination_state_contract_generated.py | 1 + .../coordination_state_contract_v0.json | 1 + .../coordination/todo_terminal_lifecycle.ts | 1 + .../post_writeback_hook_transaction.ts | 46 ++++++++++++++----- .../todos/completion_transaction.py | 12 ++++- .../todos/completion_transaction.ts | 9 ++++ loopx/control_plane/todos/contract.py | 2 + loopx/control_plane/todos/field_update.ts | 6 ++- loopx/control_plane/todos/line_update.py | 1 + .../todos/provider_terminal_lifecycle.py | 5 +- .../project_registry_io_manifest_v1.json | 4 +- 15 files changed, 78 insertions(+), 18 deletions(-) diff --git a/loopx/cli_commands/post_writeback.py b/loopx/cli_commands/post_writeback.py index 438e0292c7..010a48f067 100644 --- a/loopx/cli_commands/post_writeback.py +++ b/loopx/cli_commands/post_writeback.py @@ -142,6 +142,7 @@ def dispatch_committed_cli_post_writeback_hooks( committed_at: str, hooks: Sequence[PostWritebackHookRegistration], projection_builder: PostWritebackProjectionBuilder | None, + receipt_id: str | None = None, ) -> dict[str, Any]: """Bridge one committed CLI mutation into the TS-owned hook lifecycle. @@ -216,6 +217,7 @@ def dispatch_committed_cli_post_writeback_hooks( "effect_id": str(identity.get("effect_id") or ""), }, "state_version": state_version, + **({"receipt_id": receipt_id} if receipt_id is not None else {}), "committed_at": committed_at, "projection": projection, }, diff --git a/loopx/cli_commands/todo.py b/loopx/cli_commands/todo.py index bb7ef902da..d5eb8ff4b2 100644 --- a/loopx/cli_commands/todo.py +++ b/loopx/cli_commands/todo.py @@ -767,6 +767,7 @@ def handle_todo_command( ): identity = settlement_identity.as_dict() committed_at = str(payload.get("updated_at") or "").strip() + receipt_id = payload.get("completion_receipt_id") if committed_at: # Capability evidence comes only from a Turn journal the TS # journal owner validated against this completion's full @@ -789,7 +790,8 @@ def handle_todo_command( goal_id=args.goal_id, event_kind="todo_complete", identity=identity, - state_version=committed_at, + state_version=receipt_id or committed_at, + receipt_id=receipt_id, committed_at=committed_at, hooks=post_writeback_hooks, projection_builder=post_writeback_projection_builder, diff --git a/loopx/control_plane/coordination/coordination_projection.ts b/loopx/control_plane/coordination/coordination_projection.ts index 4b621ef9fc..18da16c4ac 100644 --- a/loopx/control_plane/coordination/coordination_projection.ts +++ b/loopx/control_plane/coordination/coordination_projection.ts @@ -90,6 +90,7 @@ export interface CoordinationProjectionCommitInput { const TODO_CONTRACT_REVISION_FIELDS: readonly (readonly string[])[] = [ ["completion_validation_revision", "completion_validation_revision_history"], ["completion_result"], + ["completion_receipt_id"], ]; interface HistoricalTodoContract { diff --git a/loopx/control_plane/coordination/coordination_state_contract.generated.ts b/loopx/control_plane/coordination/coordination_state_contract.generated.ts index 8a927e3d64..33bef8bdbe 100644 --- a/loopx/control_plane/coordination/coordination_state_contract.generated.ts +++ b/loopx/control_plane/coordination/coordination_state_contract.generated.ts @@ -170,6 +170,7 @@ export const COORDINATION_STATE_CONTRACT = deepFreeze({ "successor_todo_ids", "completion_continuation", "completion_recovery", + "completion_receipt_id", "replan_obligation_id", "target_key", "cadence", diff --git a/loopx/control_plane/coordination/coordination_state_contract_generated.py b/loopx/control_plane/coordination/coordination_state_contract_generated.py index 2f3d69ed90..f939d2483b 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_generated.py +++ b/loopx/control_plane/coordination/coordination_state_contract_generated.py @@ -58,6 +58,7 @@ def _freeze(value: Any) -> Any: 'successor_todo_ids', 'completion_continuation', 'completion_recovery', + 'completion_receipt_id', 'replan_obligation_id', 'target_key', 'cadence', diff --git a/loopx/control_plane/coordination/coordination_state_contract_v0.json b/loopx/control_plane/coordination/coordination_state_contract_v0.json index 190ca5723f..8b0de9886d 100644 --- a/loopx/control_plane/coordination/coordination_state_contract_v0.json +++ b/loopx/control_plane/coordination/coordination_state_contract_v0.json @@ -47,6 +47,7 @@ "successor_todo_ids", "completion_continuation", "completion_recovery", + "completion_receipt_id", "replan_obligation_id", "target_key", "cadence", diff --git a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts index f77a034a4a..84d5fe9288 100644 --- a/loopx/control_plane/coordination/todo_terminal_lifecycle.ts +++ b/loopx/control_plane/coordination/todo_terminal_lifecycle.ts @@ -1604,6 +1604,7 @@ export async function executeCoordinationTodoTerminalLifecycle( completion_identity_source: completion === null ? null : completion.completion_identity_source, completed_at: target.todo.completed_at, + completion_receipt_id: target.todo.completion_receipt_id ?? null, ...(completionResult === null ? {} : {completion_result: completionResult}), ...(acceptanceEvidence === null ? {} : {goal_acceptance_completion: acceptanceEvidence}), // A preview that omits this would show an unconditional close for work the diff --git a/loopx/control_plane/post_writeback_hook_transaction.ts b/loopx/control_plane/post_writeback_hook_transaction.ts index 8187eb9b33..0057863072 100644 --- a/loopx/control_plane/post_writeback_hook_transaction.ts +++ b/loopx/control_plane/post_writeback_hook_transaction.ts @@ -80,6 +80,7 @@ interface PostWritebackSource extends JsonObject { durable: boolean; identity: JsonObject & { goal_id: string; todo_id: string | null }; state_version: string; + receipt_id?: string | null; committed_at: string; projection: JsonObject; } @@ -375,6 +376,7 @@ function decodeSourceFields(value: unknown): PostWritebackSource { "durable", "identity", "state_version", + ...(Object.hasOwn(source, "receipt_id") ? ["receipt_id"] : []), "committed_at", "projection", ], @@ -421,6 +423,9 @@ function decodeSourceFields(value: unknown): PostWritebackSource { source.state_version, "source.state_version", ), + ...(Object.hasOwn(source, "receipt_id") + ? {receipt_id: optionalPythonStrippedString(source.receipt_id, "source.receipt_id")} + : {}), committed_at: pythonStrippedString( source.committed_at, "source.committed_at", @@ -596,7 +601,11 @@ function sourceHookInput(source: PostWritebackSource, readScope: string[]): Json event_kind: source.event_kind, identity: source.identity, state_version: source.state_version, - committed_at: source.committed_at, + // Old sources retain their byte-for-byte identity. New lifecycle receipts + // carry an immutable committed id; their clock is diagnostic, not a version. + ...(source.receipt_id == null + ? {committed_at: source.committed_at} + : {receipt_id: source.receipt_id}), }; const eventId = `pwr_${sha256(pythonCanonicalJson(receiptFacts)).slice(0, 24)}`; const projection = Object.fromEntries( @@ -738,6 +747,25 @@ function intentKey(intent: unknown): string | null { : null; } +function validateStoredReceipt( + request: TransactionRequest, + admitted: AdmittedHook, + receipt: JsonObject, +): JsonObject { + // An explicit primary receipt id binds the dispatch independently of a + // later projection timestamp. Validate and return the stored clock rather + // than rewriting history; legacy timestamp identities remain exact. + const hookInput = request.source?.receipt_id != null + ? {...admitted.hook_input, receipt: { + ...requireJsonObject(admitted.hook_input.receipt, "hook_input.receipt"), + recorded_at: receipt.recorded_at, + }} + : admitted.hook_input; + return validatePostWritebackHookReceipt({ + registration: admitted.contract, hook_input: hookInput, receipt, + }); +} + function recordReceiptConflict( inspection: Inspection, admitted: AdmittedHook, @@ -859,11 +887,7 @@ async function inspectTransaction(request: TransactionRequest): Promise bool: and (state.get("recovery") is None or state.get("recovery") in _RECOVERIES) and isinstance(updates, Mapping) and all( - key in {"completion_continuation", "completion_recovery"} + key in {"completion_continuation", "completion_recovery", "completion_receipt_id"} and isinstance(value, str) for key, value in updates.items() ) and updates.get("completion_continuation") == state.get("continuation") and updates.get("completion_recovery") == state.get("recovery") + and ( + updates.get("completion_receipt_id") is None + or ( + isinstance(updates["completion_receipt_id"], str) + and updates["completion_receipt_id"].startswith("tcw_") + and BARE_SHA256_PATTERN.fullmatch(updates["completion_receipt_id"][4:]) + is not None + ) + ) and (receipt is None or _valid_receipt(receipt)) ) diff --git a/loopx/control_plane/todos/completion_transaction.ts b/loopx/control_plane/todos/completion_transaction.ts index d0df3c296b..830f2636f0 100644 --- a/loopx/control_plane/todos/completion_transaction.ts +++ b/loopx/control_plane/todos/completion_transaction.ts @@ -502,6 +502,15 @@ export function reduceTodoCompletionTransaction( metadataResult.updates, "completion metadata updates", ); + // Checkpoint the committed phase, not its clock tick. Persisting this id + // lets crash recovery and later reads retain the original hook identity. + updates.completion_receipt_id = `tcw_${canonicalAuthoritySha256({ + goal_id: request.goal_id, + todo_id: request.todo_id, + completion_identity_key: identity.key, + continuation: completionStateResult.continuation, + recovery: completionStateResult.recovery, + })}`; const completionPolicy = evaluateCompletionPolicy( request.completion_policy_request, ); diff --git a/loopx/control_plane/todos/contract.py b/loopx/control_plane/todos/contract.py index 49c431fcc4..63b3241210 100644 --- a/loopx/control_plane/todos/contract.py +++ b/loopx/control_plane/todos/contract.py @@ -1135,6 +1135,7 @@ def _metadata_value_is_present(value: Any) -> bool: "completed_at", "updated_at", "completion_turn_key", + "completion_receipt_id", "validation_command", "validation_command_argv", "validation_label", @@ -1268,6 +1269,7 @@ def format_todo_metadata_line( successor_todo_ids: Any = None, completion_continuation: str | None = None, completion_recovery: str | None = None, + completion_receipt_id: str | None = None, replan_obligation_id: str | None = None, resume_when: str | None = None, resume_monitor_generation: int | str | None = None, diff --git a/loopx/control_plane/todos/field_update.ts b/loopx/control_plane/todos/field_update.ts index 17e40b7369..c28470d7a6 100644 --- a/loopx/control_plane/todos/field_update.ts +++ b/loopx/control_plane/todos/field_update.ts @@ -118,9 +118,13 @@ function completionUpdates(block: JsonObject, intent: JsonObject, targetStatus: if (present(intent.completion_metadata_updates_override)) { const updates = requireJsonObject(intent.completion_metadata_updates_override, "completion metadata override"); if (Object.entries(updates).some(([key, value]) => - !["completion_continuation", "completion_recovery"].includes(key) || typeof value !== "string")) { + !["completion_continuation", "completion_recovery", "completion_receipt_id"].includes(key) || typeof value !== "string")) { throw new EffectRuntimeRequestError("TypeScript Todo completion metadata updates shape mismatch"); } + if (updates.completion_receipt_id !== undefined && + !/^tcw_[0-9a-f]{64}$/u.test(String(updates.completion_receipt_id))) { + throw new EffectRuntimeRequestError("completion_receipt_id is invalid"); + } return {...updates}; } const result = buildTodoCompletionMetadataUpdates({ diff --git a/loopx/control_plane/todos/line_update.py b/loopx/control_plane/todos/line_update.py index b3210decbf..3e05ae6386 100644 --- a/loopx/control_plane/todos/line_update.py +++ b/loopx/control_plane/todos/line_update.py @@ -442,6 +442,7 @@ def apply_todo_update_to_lines( "completion_recovery": normalize_todo_completion_recovery( effective_metadata.get("completion_recovery") ), + "completion_receipt_id": effective_metadata.get("completion_receipt_id"), "resume_when": normalize_todo_resume_when( effective_metadata.get("resume_when") ), diff --git a/loopx/control_plane/todos/provider_terminal_lifecycle.py b/loopx/control_plane/todos/provider_terminal_lifecycle.py index 831dbf6a56..7f8b143f57 100644 --- a/loopx/control_plane/todos/provider_terminal_lifecycle.py +++ b/loopx/control_plane/todos/provider_terminal_lifecycle.py @@ -531,7 +531,10 @@ def terminal_canonical_todo_if_promoted( "idempotent_replay": idempotent_replay, "state_file": str(state_file) if state_file is not None else None, "project": str(project) if project is not None else None, - "updated_at": payload.get("completed_at") if payload.get("changed") else None, + # A receipt replay must also recover an uncheckpointed optional hook. + "updated_at": payload.get("completed_at") if command == "complete" else ( + payload.get("completed_at") if payload.get("changed") else None + ), "next_todos": payload.get("generated_successors") or [], "mutation_authority": terminal_decision, "task_lease_fence": terminal_decision, diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 709413835b..94f98cb627 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -727,7 +727,7 @@ }, { "site": "loopx/cli_commands/post_writeback.py::.dispatch_committed_cli_post_writeback_hooks::codec_read:load_registry#1", - "line": 158, + "line": 159, "column": 13, "kind": "codec_read", "api": "load_registry", @@ -927,7 +927,7 @@ }, { "site": "loopx/cli_commands/todo.py::.handle_todo_command::codec_read:load_registry#7", - "line": 778, + "line": 779, "column": 38, "kind": "codec_read", "api": "load_registry", From d3a55157070c80537e91e62cfc656ea1544422c8 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:42:46 +0800 Subject: [PATCH 2/3] test(todos): qualify same-clock closeout and hook recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../test_quota_settlement_cli.py | 62 +++++++++++++++++++ .../coordination_projection.test.ts | 31 ++++++++++ .../post_writeback_hook_transaction.test.ts | 30 +++++++++ .../todo_completion_transaction.test.ts | 28 +++++++++ 4 files changed, 151 insertions(+) diff --git a/tests/control_plane/test_quota_settlement_cli.py b/tests/control_plane/test_quota_settlement_cli.py index acc0f8eab0..5034c35904 100644 --- a/tests/control_plane/test_quota_settlement_cli.py +++ b/tests/control_plane/test_quota_settlement_cli.py @@ -6042,9 +6042,29 @@ def test_same_turn_receipt_replay_defers_newly_due_higher_priority_monitor( assert resumed_turn["selected_todo"]["todo_id"] == DUE_MONITOR_TODO_ID +@pytest.mark.parametrize("provider", ["legacy", "file", "sqlite"]) def test_read_only_settlement_omits_non_causal_delivery_workspace( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + provider: str, ) -> None: + # The two legal completion phases may commit within one clock tick. Keep + # the real CLI, authority and hook journal; freeze only their commit clock. + run = subprocess.run + + def frozen_completion_clock(argv, *args, **kwargs): + if isinstance(argv, list) and argv[1:3] == ["-m", "loopx.cli"]: + program = ( + "import loopx.todos; " + "import loopx.control_plane.todos.provider_terminal_lifecycle as native; " + "clock=lambda:'2026-09-02T12:00:00+00:00'; " + "loopx.todos.now_local=clock; native.now_local=clock; " + "from loopx.cli import main; raise SystemExit(main())" + ) + argv = [argv[0], "-c", program, *argv[3:]] + return run(argv, *args, **kwargs) + + monkeypatch.setattr(subprocess, "run", frozen_completion_clock) project, runtime, registry_path = _write_fixture(tmp_path) registry = json.loads(registry_path.read_text(encoding="utf-8")) registry["goals"][0]["control_plane"] = { @@ -6067,6 +6087,26 @@ def test_read_only_settlement_omits_non_causal_delivery_workspace( ), encoding="utf-8", ) + if provider != "legacy": + from canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, + ) + from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, + ) + + if provider == "sqlite": + isolate_sqlite_runtime(tmp_path, monkeypatch) + todos = parse_active_state_todos(state_path.read_text(), item_limit=None) + initialize_canonical_authority( + runtime, GOAL_ID, + build_todo_runtime_shadow_projection( + goal_id=GOAL_ID, todos=todos["agent_todos"]["items"], + handoff_mode="soft_claim", + ), + state_path=state_path, provider=provider, + ) binding = ( "--agent-id", AGENT_ID, @@ -6232,6 +6272,8 @@ def test_read_only_settlement_omits_non_causal_delivery_workspace( assert complete["changed"] is True assert complete["completion_continuation"] == "no_followup" assert complete["completion_recovery"] == "same_turn_terminal_closeout" + assert ordinary["updated_at"] == complete["updated_at"] + assert ordinary["completion_receipt_id"] != complete["completion_receipt_id"] assert complete["post_writeback_hooks"]["intent_count"] == 1 trigger_intent = complete["post_writeback_hooks"]["intents"][0] assert trigger_intent["intent_kind"] == "periodic_report.trigger_evaluation" @@ -6248,6 +6290,25 @@ def test_read_only_settlement_omits_non_causal_delivery_workspace( ] assert "no_followup=true" in state_path.read_text(encoding="utf-8") + # Model primary commit surviving a crash before the optional checkpoint. + # Only this disposable fixture's sidecar is removed; the Todo, original + # completion receipt, Turn journal and single quota debit remain intact. + sidecars = runtime / "goals" / GOAL_ID / "post_writeback_hooks" + terminal_sidecars = [ + path for path in sidecars.glob("*.json") + if json.loads(path.read_text())["source_receipt_id"] + == trigger_intent["source_receipt_id"] + ] + assert len(terminal_sidecars) == 1 + terminal_sidecars[0].unlink() + recovered_rc, recovered = _run_cli(registry_path, runtime, *terminal_args) + assert recovered_rc == 0, recovered + assert recovered["changed"] is False + assert recovered["completion_receipt_id"] == complete["completion_receipt_id"] + assert recovered["post_writeback_hooks"]["invoked_count"] == 1 + assert recovered["post_writeback_hooks"]["intents"] == [trigger_intent] + assert _spend_run_count(runtime) == 1 + event_log = runtime / "goals" / GOAL_ID / "rollout-event-log.jsonl" completion_events = [ event @@ -6276,6 +6337,7 @@ def test_read_only_settlement_omits_non_causal_delivery_workspace( assert complete_replay_rc == 0, complete_replay assert complete_replay["idempotent_replay"] is True assert complete_replay["changed"] is False + assert complete_replay["completion_receipt_id"] == complete["completion_receipt_id"] assert complete_replay["post_writeback_hooks"]["invoked_count"] == 0 assert complete_replay["post_writeback_hooks"]["replayed_hooks"] == [ "periodic_report.runtime_trigger" diff --git a/tests/control_plane_ts/coordination_projection.test.ts b/tests/control_plane_ts/coordination_projection.test.ts index aba4fa4899..ef225065c8 100644 --- a/tests/control_plane_ts/coordination_projection.test.ts +++ b/tests/control_plane_ts/coordination_projection.test.ts @@ -482,6 +482,37 @@ for (const native of [false, true]) { }); } +// Reconstruct the exact released manifest from the frozen pre-revision +// fixture and its registered additions, independently of today's field list. +const preReceiptFields = [...previousReleaseFields]; +preReceiptFields.splice(preReceiptFields.indexOf("completion_turn_key") + 1, 0, "completion_result"); +for (const native of [false, true]) { + test(`pre-receipt ${native ? "domain" : "canonical"} head remains readable and upgrades on commit`, () => { + const fields = preReceiptFields.filter(field => + !native || !historicalFields.projection_metadata_fields.includes(field)); + const todo = {schema_version: native ? TODO_DOMAIN_ITEM_SCHEMA : "todo_item_v0", + todo_id: "todo_receipt", role: "agent", status: "done", done: true, + text: "Retain the committed completion", archive_state: "active", + ...(native ? {} : {source_section: "Agent Todo"})}; + const head = {goal_id: "receipt-goal", todos: [todo], leases: [], todo_read_model: { + schema_version: native ? TODO_DOMAIN_READ_RECORD_SCHEMA : TODO_CANONICAL_READ_RECORD_SCHEMA, + todo_count: 1, records_sha256: canonicalAuthoritySha256([todo]), contract_fields: fields}}; + const before = structuredClone(head); + validateCoordinationTodoReadModel(head, head.goal_id); + assert.deepEqual(head, before); + const updated = {...todo, completion_receipt_id: `tcw_${"a".repeat(64)}`}; + assert.throws(() => validateCoordinationTodoReadModel({...head, todos: [updated], + todo_read_model: {...head.todo_read_model, records_sha256: canonicalAuthoritySha256([updated])}}, head.goal_id), + /exceeds its historical field contract/); + const commit = prepareCoordinationProjectionCommit({goal_id: head.goal_id, + operation_id: "receipt-upgrade", expected_provider_revision: "file:old", projection: head, + mutations: [{kind: "todo_upsert", todo: updated}]}); + validateCoordinationTodoReadModel(commit.next_projection, head.goal_id); + assert.equal(((commit.next_projection.todo_read_model as JsonObject).contract_fields as string[]) + .includes("completion_receipt_id"), true); + }); +} + for (const native of [false, true]) { test(`read-model order uses unique Unicode identities without weakening ${native ? "domain" : "canonical"} content validation`, () => { // U+E000 precedes U+10000 in persisted Unicode code-point order, but not diff --git a/tests/control_plane_ts/post_writeback_hook_transaction.test.ts b/tests/control_plane_ts/post_writeback_hook_transaction.test.ts index 176d37fa0f..375ffd0b69 100644 --- a/tests/control_plane_ts/post_writeback_hook_transaction.test.ts +++ b/tests/control_plane_ts/post_writeback_hook_transaction.test.ts @@ -1273,6 +1273,36 @@ test("finalize rejects a transaction id from different source facts", async () = ); }); +test("committed receipt ids separate same-clock phases and recover the original intent", async (t) => { + const root = await mkdtemp(join(tmpdir(), "loopx-hook-receipt-")); + t.after(() => rm(root, {recursive: true, force: true})); + const ordinarySource = {...source(), receipt_id: "completion-ordinary", state_version: "completion-ordinary"}; + const ordinary = await evaluatePostWritebackHookTransaction(request(root, {source: ordinarySource})); + const ordinaryPlan = (ordinary.provider_plan as Record[])[0]; + await evaluatePostWritebackHookTransaction(request(root, { + phase: "finalize", source: ordinarySource, transaction_id: ordinary.transaction_id, + provider_outcomes: [returnedOutcome(ordinaryPlan, notApplicableResult(ordinaryPlan))], + })); + const closeoutSource = {...ordinarySource, receipt_id: "completion-closeout", state_version: "completion-closeout"}; + const closeout = await evaluatePostWritebackHookTransaction(request(root, {source: closeoutSource})); + const closeoutPlan = (closeout.provider_plan as Record[])[0]; + assert.notEqual(closeoutPlan.dispatch_id, ordinaryPlan.dispatch_id); + const recorded = await evaluatePostWritebackHookTransaction(request(root, { + phase: "finalize", source: closeoutSource, transaction_id: closeout.transaction_id, + provider_outcomes: [returnedOutcome(closeoutPlan)], + })); + const recordedDispatch = recorded.dispatch as Record; + assert.equal(recordedDispatch.intent_count, 1); + // Later read-model clock changes cannot redefine a committed receipt. + const replay = await evaluatePostWritebackHookTransaction(request(root, { + source: {...closeoutSource, committed_at: "2026-09-03T12:00:00Z"}, + })); + assert.deepEqual(replay.provider_plan, []); + const replayDispatch = replay.dispatch as Record; + assert.deepEqual(replayDispatch.intents, recordedDispatch.intents); + assert.deepEqual(replayDispatch.replayed_hooks, ["periodic_report.stage_completion"]); +}); + test("transaction request rejects unknown top-level fields", async () => { await assert.rejects( evaluatePostWritebackHookTransaction( diff --git a/tests/control_plane_ts/todo_completion_transaction.test.ts b/tests/control_plane_ts/todo_completion_transaction.test.ts index 1b71830cca..ec83df607a 100644 --- a/tests/control_plane_ts/todo_completion_transaction.test.ts +++ b/tests/control_plane_ts/todo_completion_transaction.test.ts @@ -73,7 +73,34 @@ test("open completion commits in one reduction with a stable local identity", () }); assert.deepEqual(result.metadata_updates, { completion_continuation: "active_goal", + completion_receipt_id: result.metadata_updates.completion_receipt_id, }); + assert.match(String(result.metadata_updates.completion_receipt_id), /^tcw_[0-9a-f]{64}$/); +}); + +test("committed completion receipt ids separate phases and retain stable scope", () => { + const ordinaryRequest = request({requested_completion_turn_key: "turn-a", + requested_completion_identity_source: "turn_settlement"}); + const ordinary = reduceTodoCompletionTransaction(ordinaryRequest); + const repeated = reduceTodoCompletionTransaction(ordinaryRequest); + const closeout = reduceTodoCompletionTransaction(request({ + todo: {...baseTodo, status: "done", completion_continuation: "active_goal", + completion_turn_key: "turn-a"}, + requested_completion_turn_key: "turn-a", + requested_completion_identity_source: "turn_settlement", + requested_no_followup: true, + })); + const anotherTurn = reduceTodoCompletionTransaction({...ordinaryRequest, + requested_completion_turn_key: "turn-b"}); + const anotherGoal = reduceTodoCompletionTransaction({...ordinaryRequest, goal_id: "goal-b"}); + for (const result of [ordinary, repeated, closeout, anotherTurn, anotherGoal]) { + assert.equal(result.decision, "commit"); + assert.match(String(result.metadata_updates.completion_receipt_id), /^tcw_[0-9a-f]{64}$/); + } + assert.equal(ordinary.metadata_updates.completion_receipt_id, + repeated.metadata_updates.completion_receipt_id); + assert.equal(new Set([ordinary, closeout, anotherTurn, anotherGoal].map( + result => result.metadata_updates.completion_receipt_id)).size, 4); }); test("declared validation is one external effect between two reductions", () => { @@ -290,6 +317,7 @@ test("lifecycle reentry returns the explicit terminal recovery state", () => { assert.deepEqual(result.metadata_updates, { completion_continuation: "no_followup", completion_recovery: "lifecycle_reentry_terminal_closeout", + completion_receipt_id: result.metadata_updates.completion_receipt_id, }); }); From b2023af4cb9b080cbf6a34d6393bb35afd27ad95 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 4 Oct 2026 20:42:46 +0800 Subject: [PATCH 3/3] docs: explain completion receipt identity and recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- docs/reference/protocols/periodic-report-v0.md | 11 ++++++++++- .../loopx-self-repair/references/repair-patterns.md | 2 +- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/reference/protocols/periodic-report-v0.md b/docs/reference/protocols/periodic-report-v0.md index d400a5cb80..b75c14f3b9 100644 --- a/docs/reference/protocols/periodic-report-v0.md +++ b/docs/reference/protocols/periodic-report-v0.md @@ -536,12 +536,21 @@ The optional automatic path uses the provider-neutral TypeScript `post_writeback` capability-hook contract. The CLI composition root registers `periodic_report.runtime_trigger` only when the Goal's local control-plane configuration explicitly enables a periodic-report profile. Core dispatches -only after the primary `refresh-state` durable writeback and exact settlement +only after the primary `refresh-state` or `todo complete` durable writeback and exact settlement readback have succeeded with complete Goal, Agent, Turn, and effect identity. Todo-bound settlements carry a non-empty Todo id; Todo-less autonomous replans carry an explicit `null` Todo id rather than inventing a Todo identity. The best-effort rollout-event log is not dispatch authority. +New Todo completions checkpoint a TypeScript-owned `completion_receipt_id` in +the primary transaction. Ordinary completion and same-Turn terminal closeout +have distinct ids even when their timestamps are equal. Hook dispatch and +composition recovery use that committed id, so a later timestamp change cannot +create a duplicate intent. Existing completions without the field retain their +original timestamp-derived identity; replay does not migrate or rewrite them. +Canonical receipt replay also recovers the optional hook after primary commit +if its sidecar was not checkpointed. These ids grant no additional authority. + The hook input contains only the committed receipt identity, stable state revision, and derived `periodic_report_stage_completion_receipt_v0`. Its result is an idempotent `periodic_report.trigger_evaluation` intent with an empty write diff --git a/skills/loopx-self-repair/references/repair-patterns.md b/skills/loopx-self-repair/references/repair-patterns.md index e13d27ca46..aed2c9c686 100644 --- a/skills/loopx-self-repair/references/repair-patterns.md +++ b/skills/loopx-self-repair/references/repair-patterns.md @@ -35,7 +35,7 @@ teaches a reusable control-plane lesson. | `periodic_report_todo_log_editorial_gap` | A stage report is generated and frozen, but the artifact is an English Todo chronology, report-building work displaces business findings, or the page lacks a clear overview-to-depth analysis mainline. | Frozen artifact and language, projected completed/open Todo facts, report-meta action kinds, editorial source and section order, cause/boundary details, fact references, and approval-gate creation time. | The governed consumer treated durable Todo prose as audience-ready copy and let a renderer infer narrative structure from timestamps or generic content kinds. Generation correctness therefore proved persistence and authority, but not editorial quality. | Freeze a compact public-safe fact request for the exact intent, require Agent-authored Chinese editorial with a typed `overview -> problem map -> causal analysis -> coverage/actions -> next actions` contract, validate language density and fact lineage in the consumer, and create no frozen artifact or approval gate until the editorial response passes. Keep report-meta work outside the business narrative and retain publication as a later exact-payload authority gate. | | `periodic_report_stage_boundary_premature_promotion` | A periodic report is generated after setup work, several ordinary Todo completions, or a replan even though the intended analysis or delivery vision remains open. | Goal-vision revision, material outcome checkpoint and evidence refs, replan trigger kind, accepted semantic delta, successor frontier ownership or terminal Goal, report trigger receipt, and rendered-artifact count. | Runtime aggregation treated Todo volume or any replan as proof of a meaningful stage boundary instead of distinguishing success-path vision closure from recovery and continuation planning. | Derive stage completion as the strict successful sibling of autonomous replan: require an evidence-linked closed vision plus a durably settled successor frontier or terminal Goal, deduplicate by closed-vision/frontier identity, and reject ordinary completion plus blocker, stall, long-chain, and monitor replans. Keep generation, publication, and announcement as separate authority layers. | | `periodic_report_post_writeback_hook_authority_gap` | A periodic-report producer can be called manually, or generic quota/readback code imports report policy, but a committed writeback does not produce a replay-safe typed intent through the capability lifecycle. | Primary durable receipt and settlement identity, composition-root activation, TS hook registration/input/result/sidecar receipts, provider invocation count on replay, intent write scope, and external-effect count. | Capability policy and generic control-plane lifecycle were coupled, or the automatic path relied on a best-effort diagnostic event without a typed post-writeback checkpoint. | Keep registration, admission, receipt validation, deterministic ordering, failure isolation, and sidecar replay in the core-owned TS hook boundary; keep stage policy and trigger evaluation in the capability; register only from an explicitly enabled profile at the composition root; emit an idempotent effect-free intent and require separate governed generation and publication authority. | -| `periodic_report_terminal_closeout_hook_gap` | Quota or status later proves validated no-follow-up, but no periodic-report candidate exists and monitor heartbeats remain quiet. | Last material refresh, final Todo completion receipt and settlement identity, post-writeback hook event kinds, terminal frontier projection, sidecar receipts, and external-effect count. | The hook ran only on the pre-completion refresh; the durable Todo completion transaction formed the terminal frontier afterward but did not dispatch post-writeback capability hooks. | Admit durable `todo_complete` as a typed post-writeback event, dispatch only after completion validation, persistence, and settlement readback succeed, rebuild the final frontier from committed state, and preserve replay-safe effect-free intent receipts. Keep ordinary completion and watch-only monitoring insufficient without validated stage closure. | +| `periodic_report_terminal_closeout_hook_gap` | Quota or status later proves validated no-follow-up, but no periodic-report candidate exists and monitor heartbeats remain quiet. | Last material refresh, final Todo completion receipt and settlement identity, post-writeback hook event kinds, terminal frontier projection, same-clock ordinary/closeout commits, sidecar receipts, and external-effect count. | The final completion did not dispatch the hook, or timestamp-derived identity replayed the ordinary completion's not-applicable receipt instead of dispatching terminal closeout. | Admit durable `todo_complete` after validation, persistence, and settlement readback; checkpoint the typed completion-phase receipt id with the primary mutation and preserve historical identities on replay. Rebuild the committed frontier and qualify same-clock phases, missing-sidecar recovery, and duplicate suppression without external effects. Keep ordinary completion and watch-only monitoring insufficient without validated stage closure. | | `pending_capability_intent_projection_gap` | A durable post-writeback sidecar says `intent_recorded`, but quota remains `monitor_quiet_skip` or `terminal_no_followup`; no governed executor wakes, so no local artifact or exact approval Todo appears. | Exact Goal/Agent sidecar, intent schema and authority, consumption receipt, quota interaction contract, generated-artifact count, approval-Todo count, and external-effect count. | The producer journal was durable but no read model projected unconsumed capability intents into quota arbitration. Terminal or quiet lifecycle state therefore hid required capability work forever. | Add a provider-neutral TypeScript-validated pending-intent interaction slot, let the opted-in capability read only exact eligible sidecars, and give the pending action precedence over quiet/terminal routes. Consume it through an idempotent local executor that freezes checked artifacts and one digest-bound user gate; keep external delivery unauthorized and suppress consumed intents on replay. | | `approved_capability_delivery_successor_gap` | A governed report or other capability payload is frozen and its exact user gate is approved, but quota returns quiet terminal/monitor state and no provider action runs. A later caller may also improvise a destination or default sender because the approval receipt contains no executable route. | Frozen generation/consumption receipt, completed gate decision and scope, linked agent Todo, required decision scopes before/after approval, Goal Channel binding, selected Todo, provider effect and exact sender/destination readback. | The consumer created only a user gate. Approval recorded authority but had no explicitly linked, typed agent successor for Todo/quota selection, so the external effect existed only in chat memory or caller convention. | Before the gate, create one blocked agent successor bound to the frozen generation, exact decision scope, provider capability, and safe write scope; link the gate with `unblocks_todo_id`. Approval must atomically consume only that scope and resume the successor. The provider then resolves route and sender from the durable project binding, rejects caller overrides/default fallbacks, and records success only after native effect readback. | | `host_closeout_presentation_lookup_gap` | A prior heartbeat remains in closeout recovery after a successful blocked/deferred/completed Todo writeback; adding unrelated Todos changes recovery. | Receipt-bound Todo id, exact `todo list --todo-id` readback, compact quota visibility lanes, same-Turn retry and quota-spend count. | Recovery treated absence from a bounded presentation list as absence of a durable lifecycle transition. | Resolve the exact receipt-bound Todo through the existing provider-aware read path before evaluating closeout; preserve provider errors, identity binding and no-spend recovery. Cover crowded versus small inventories and real legacy/File/SQLite CLI writes; never raise display limits or fabricate settlement receipts. |