From 1d51ec511ea65652549ef7bd19a3c2a53d625a5b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:35:33 +0800 Subject: [PATCH 1/3] fix(acceptance): restore exact confirmed work without an execution lease Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../coordination/task_lease_state.ts | 8 +-- .../todo_acceptance_restoration.ts | 50 +++++++++++++++++++ .../coordination/todo_deferred_reopen.ts | 4 +- .../coordination/todo_update_admission.ts | 6 +++ .../goals/goal_frontier/acceptance.py | 4 +- 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 loopx/control_plane/coordination/todo_acceptance_restoration.ts diff --git a/loopx/control_plane/coordination/task_lease_state.ts b/loopx/control_plane/coordination/task_lease_state.ts index ca1225db36..1d44325797 100644 --- a/loopx/control_plane/coordination/task_lease_state.ts +++ b/loopx/control_plane/coordination/task_lease_state.ts @@ -5,11 +5,11 @@ import {indexCoordinationProjection} from "./coordination_projection.ts"; import {normalizeTodoAgent} from "./todo_agents.ts"; import {leaseOwnerRejection} from "../work_items/task_lease_eligibility.ts"; import {leaseVersion, leaseEpoch, leaseInteger, leaseIsActive, normalizeOwner, - normalizeIdempotencyKey, type LeaseRecord, type TodoFact} from "../work_items/task_lease_acquire.ts"; + normalizeIdempotencyKey, TASK_LEASE_SCHEMA_VERSION, type LeaseRecord, type TodoFact} from "../work_items/task_lease_acquire.ts"; import type {AcquireDecisionInput} from "../work_items/task_lease_acquire_decision.ts"; export function canonicalTaskLease(value: JsonObject, goalId: string, todoId: string): LeaseRecord { - if ((value.schema_version !== undefined && value.schema_version !== "task_lease_v0") || + if ((value.schema_version !== undefined && value.schema_version !== TASK_LEASE_SCHEMA_VERSION) || (value.goal_id !== undefined && value.goal_id !== goalId) || value.todo_id !== todoId || (value.status !== "active" && value.status !== "released")) { throw new AuthorityStoreProtocolError("canonical lease identity or schema is invalid"); @@ -23,7 +23,9 @@ export function canonicalTaskLease(value: JsonObject, goalId: string, todoId: st value.write_scopes.some(scope => typeof scope !== "string"))) { throw new AuthorityStoreProtocolError("canonical lease write_scopes must be strings"); } - return value; + // Older canonical records may omit the wire tag. Normalize once before + // shared lease rules inspect it, without rewriting the persisted record. + return {...value, schema_version: TASK_LEASE_SCHEMA_VERSION}; } export function canonicalLeaseTodoFact(todo: JsonObject | undefined): TodoFact | null { diff --git a/loopx/control_plane/coordination/todo_acceptance_restoration.ts b/loopx/control_plane/coordination/todo_acceptance_restoration.ts new file mode 100644 index 0000000000..0d4df4ce2f --- /dev/null +++ b/loopx/control_plane/coordination/todo_acceptance_restoration.ts @@ -0,0 +1,50 @@ +/** Exact rollback of an owner-confirmed declaration grants no execution lease. + * A stale binding can prevent lease acquisition; restoration must consequently + * prove the old declaration without requiring a lease over the changed work. */ +import type {JsonObject} from "../effect_program.ts"; +import {acceptanceTask, goalAcceptanceTodoDigest, readGoalAcceptance} from "../goals/acceptance_contract.ts"; +import {canonicalTaskLease} from "./task_lease_state.ts"; +import {leaseIsActive} from "../work_items/task_lease_acquire.ts"; +import {prepareUpdatedTodo, type CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; + +export type AcceptanceRestoration = + | {kind: "exact_restoration"} + | {kind: "unavailable"; reason: string}; + +/** Runs only after actor/claim/exclusion admission and failed execution proof. + * The caller's existing reviewed-update revision is checked by the transaction; + * its operation receipt handles retries before current-state admission. */ +export function acceptanceRestoration( + head: JsonObject, todo: JsonObject, lease: JsonObject | undefined, + input: CoordinationTodoUpdateInput, +): AcceptanceRestoration | null { + const state = readGoalAcceptance(head, input.goal_id); + if (!state?.enabled || acceptanceTask(input.todo_id, todo, state).state !== "stale") return null; + const unavailable = (reason: string): AcceptanceRestoration => ({kind: "unavailable", reason}); + if (todo.role !== "agent" || !input.actor_agent_id || todo.claimed_by !== input.actor_agent_id) { + return unavailable("Only the same claimed Agent may restore this declaration; ask the owner to review and rebind the Todo"); + } + if (input.lease_idempotency_key != null || input.lease_expected_version != null) { + return unavailable("Restoration cannot consume stale execution proof; release any active lease and retry without lease proof"); + } + if (lease !== undefined && leaseIsActive(canonicalTaskLease(lease, input.goal_id, input.todo_id), input.now)) { + return unavailable("Release the active execution lease before restoring the owner-confirmed declaration"); + } + if (input.expected_provider_revision === undefined) { + return unavailable("Inspect the current provider revision, then restore the exact prior text/wait with --update-operation-id and --update-expected-provider-revision; if the prior declaration is unknown, ask the owner to review and rebind this Todo"); + } + // Restoration is deliberately narrower than ordinary planning. It cannot + // change lifecycle, ownership, validator, effects, or acceptance criteria. + if (input.clear_fields.length || Object.keys(input.patch).some(key => key !== "text") || + Object.keys(input.planning_intent ?? {}).some(key => !["resume_when", "clear_resume_when"].includes(key)) || + input.completion !== undefined || input.monitor_observation !== undefined || + input.completion_validation_revision !== undefined) { + return unavailable("Exact restoration supports only the prior text/wait; ask the owner to review and rebind other work changes"); + } + const candidate = prepareUpdatedTodo(todo, input, head).next; + const binding = state.bindings.find(item => item.todo_id === input.todo_id)!; + if (goalAcceptanceTodoDigest(candidate) !== binding.todo_semantic_digest) { + return unavailable("The supplied text/wait does not reconstruct the exact owner-confirmed declaration; ask the owner to review and rebind this Todo"); + } + return {kind: "exact_restoration"}; +} diff --git a/loopx/control_plane/coordination/todo_deferred_reopen.ts b/loopx/control_plane/coordination/todo_deferred_reopen.ts index 800de85d78..3422c121a0 100644 --- a/loopx/control_plane/coordination/todo_deferred_reopen.ts +++ b/loopx/control_plane/coordination/todo_deferred_reopen.ts @@ -4,7 +4,7 @@ import type {JsonObject} from "../effect_program.ts"; import type {CoordinationProjectionMutation} from "./coordination_projection.ts"; import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; -import {leaseIsActive, leaseVersion, leaseEpoch, TASK_LEASE_SCHEMA_VERSION} from "../work_items/task_lease_acquire.ts"; +import {leaseIsActive, leaseVersion, leaseEpoch} from "../work_items/task_lease_acquire.ts"; import {releasedTaskLeaseRecord} from "../work_items/task_lease_lifecycle_decision.ts"; const REOPEN_FIELDS = new Set(["status", "clear_resume_when", "reason"]); @@ -36,7 +36,7 @@ export function deferredReopenRejection(input: { } if (input.lease === undefined) return null; const lease = canonicalTaskLease(input.lease, input.goal_id, input.todo_id); - if (leaseIsActive({...lease, schema_version: TASK_LEASE_SCHEMA_VERSION}, input.now)) { + if (leaseIsActive(lease, input.now)) { return {code: "deferred_resume_active_lease", reason: "Release the active execution lease before resuming deferred work"}; } diff --git a/loopx/control_plane/coordination/todo_update_admission.ts b/loopx/control_plane/coordination/todo_update_admission.ts index 9b2f2cddf5..c81c0f4754 100644 --- a/loopx/control_plane/coordination/todo_update_admission.ts +++ b/loopx/control_plane/coordination/todo_update_admission.ts @@ -1,6 +1,7 @@ /** Admission for Todo edits; terminal completion retains its own lease proof. * Grants may cross a claim owner; * exclusions, bindings and execution lineage remain independent restrictions. */ +import {acceptanceRestoration} from "./todo_acceptance_restoration.ts"; import {monitorMutationRejection} from "./todo_monitor_cycle.ts"; import type {JsonObject} from "../effect_program.ts"; import type {CoordinationTodoUpdateInput} from "./todo_update_intent.ts"; @@ -131,6 +132,11 @@ export function todoUpdateAdmissionRejection( lease_idempotency_key: input.lease_idempotency_key ?? null, lease_expected_version: input.lease_expected_version ?? null, now: input.now}); if (fence.outcome !== "apply") { + const restoration = acceptanceRestoration(head, todo, lease, input); + if (restoration?.kind === "exact_restoration") return null; + if (restoration?.kind === "unavailable") { + return reject("goal_acceptance_restoration_unavailable", restoration.reason); + } return reject(String(fence.code), "Todo update requires the current active lease execution proof"); } if (lease !== undefined && todo.claimed_by !== input.actor_agent_id) { diff --git a/loopx/control_plane/goals/goal_frontier/acceptance.py b/loopx/control_plane/goals/goal_frontier/acceptance.py index fda0e836b5..e636109973 100644 --- a/loopx/control_plane/goals/goal_frontier/acceptance.py +++ b/loopx/control_plane/goals/goal_frontier/acceptance.py @@ -70,7 +70,9 @@ def acceptance_gaps_from_held_goal_binding( f"Inspect {todo_id}, its acceptance binding and the owner's contract scope; " "a local validation contract must not unintentionally hold independent work. " "Prepare a scope/binding correction for the authorized owner when needed; " - + ("restore an unintended edit, " if state == "stale" else + + ("restore the exact prior text/wait using todo update with --update-operation-id " + "and --update-expected-provider-revision after releasing any active lease; " + "if the prior declaration is unknown or cannot match, request owner rebind, " if state == "stale" else "prepare the missing association for owner review, ") + "or record an evidence-linked path delta and continue via an eligible " "successor, or record a concrete blocker for the required owner confirmation. " From d32a5d1ab8705bb8c10d7a7c9c3116f4ea2f886f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:36:37 +0800 Subject: [PATCH 2/3] test(acceptance): qualify restoration, retry and lease isolation Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...2026-09-24-exact-acceptance-restoration.md | 26 +++++ ...9-24-exact-acceptance-restoration.zh-CN.md | 20 ++++ .../reference/goal-acceptance-observations.md | 48 ++++++++- .../test_acceptance_restoration.py | 99 +++++++++++++++++++ .../goal_acceptance_authority.test.ts | 70 +++++++++++++ tests/control_plane_ts/todo_update.test.ts | 13 +++ 6 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md create mode 100644 docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md create mode 100644 tests/control_plane/test_acceptance_restoration.py diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md new file mode 100644 index 0000000000..2bae7aac8b --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md @@ -0,0 +1,26 @@ +# Exact acceptance restoration without a lease cycle + +Source: #4971. An Agent could clear an owner-confirmed Todo's existing wait while +holding a hard lease, release it, and then be unable to restore it: stale +acceptance prohibited acquire, while update demanded an active lease. + +The existing TS reviewed-update boundary now admits a narrow restoration after +ordinary registered actor / claim / exclusion checks. With no active lease or +supplied execution proof, the same claimed Agent may restore prior text/wait +only when the candidate's complete work digest equals the current owner binding. +The provider revision CAS and existing operation receipt guard the transaction; +criteria, binding, lifecycle and lease generation are unchanged. Historical +schema-less lease records are normalized once at the shared TS read boundary +so activity checks cannot mistake a live lease for an inactive one; deferred +reopen reuses that normalization instead of injecting the tag itself. Execution still +requires a new ordinary acquire. Unknown prior values or other scope changes +project an explicit owner-review/rebind requirement. + +This advances native long-horizon recovery, not provider default selection or +D3 promotion. Real File/SQLite CLI regressions exercise acquire, stale edit, +release, rejected reacquire, restoration and fresh acquire; provider tests also +exercise a disposable PostgreSQL server. Managed frontier projection drops the +stale hold after restoration. #5000 remains separately owned by Turn settlement +and its retry policy; restoring acceptance does not settle a Turn. + +See the [caller contract](../../../../reference/goal-acceptance-observations.md#restore-an-unintended-textwait-edit-after-lease-release). diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md new file mode 100644 index 0000000000..54bd21fde0 --- /dev/null +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md @@ -0,0 +1,20 @@ +# 避免租约循环依赖的精确验收声明恢复 + +来源:#4971。Agent 持有硬租约时误清 owner 已确认 Todo 的原有等待条件,释放租约后 +会陷入死锁:stale 验收阻止获取租约,而 update 又要求活动租约。 + +修复放在现有 TS reviewed-update 边界,保留注册 actor、claim 和排除条件检查。 +没有活动租约、也不提交旧执行证明时,同一 claimed Agent 可以带 provider revision +恢复原文本/等待条件,但候选完整工作摘要必须严格等于当前 owner 绑定的摘要。 +现有 CAS 与操作回执保证并发和重试;验收标准、绑定、生命周期和租约代次均不改变。 +旧的无 schema 租约在共享 TS 读取边界统一补全格式,避免活动性检查误把有效租约当作 +非活动租约;deferred reopen 也复用这条规则,不再自行补字段。 +执行工作仍须正常获取新租约。不知道原值或涉及其他范围变更时,明确要求 owner +审核并重新绑定,不能猜测恢复。 + +此项推进原生长程恢复,不改变默认 provider 或 D3 晋升结论。真实 File/SQLite CLI +覆盖获取租约、误改、释放、拒绝重取、恢复、再次获取;provider 用例还覆盖隔离的 +真实 PostgreSQL。恢复后 managed frontier 不再投影 stale hold。#5000 的 Turn +结算及重试策略仍属独立边界;恢复验收声明不等于结算 Turn。 + +参见[调用方合同](../../../../reference/goal-acceptance-observations.md#restore-an-unintended-textwait-edit-after-lease-release)。 diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 777a01e32c..f01d2aa0b7 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -197,8 +197,9 @@ existing `resume_when` is not reconstructible from the latest Todo and remains and stale associations enter the existing agent-scoped recovery lane. Recovery preserves the original Turn/Todo identity and does not authorize execution of held work. Inspect a missing association and prepare it for owner confirmation; -for a stale association, inspect the work delta and restore an unintended edit -or propose the changed association. An already eligible successor remains a +for a stale association, inspect the work delta and use the +[exact text/wait restoration](#restore-an-unintended-textwait-edit-after-lease-release) +when possible, or propose the changed association for owner review. An already eligible successor remains a separate execution identity. Do not create another unbound repair Todo and mistake its existence for a runnable successor. @@ -396,3 +397,46 @@ Lark 呈现、远端合同编辑、语义意图保持证明与通用共享 amend 不宣称任一 RFC 已完成。合同浏览器检查用 `npm run smoke:goal-acceptance-contract-browser`; 前端集成打包后,`npm run smoke:goal-acceptance-contract-packaged` 对已发布资源跑同一项检查。 Python renderer 测试和 API/export smoke 覆盖缺失、停用、过期、失败及通过的区别。 + + +## Restore an unintended text/wait edit after lease release + +A hard-lease Todo may become stale after its claimed Agent accidentally changes +its text or clears an existing `resume_when`, then releases the execution lease. +The same claimed Agent can use a reviewed update to restore the **exact original +work declaration**, without first acquiring a lease over stale work: + +```sh +loopx goal-acceptance inspect --goal-id example +loopx todo update --goal-id example --todo-id todo_artifact --agent-id agent-a \ + --resume-when 'resume_at:2026-01-01T00:00:00Z' \ + --update-operation-id restore-original-wait \ + --update-expected-provider-revision '' +``` + +Supply the actual original wait/text, not the example value. TS compares the +entire candidate work digest with the existing owner-confirmed binding. It +rejects a different scope, wrong revision, foreign/excluded actor, active lease, +stale execution proof, or a bundled lifecycle/validator/ownership edit. The +restoration writes neither an owner rebind nor an execution grant. Acquire a +fresh lease through the usual command before executing work; an exact update +retry reads the old operation receipt and cannot alter the new lease generation. +It does not complete a Todo or settle/spend a Turn. + +This is not a general history rollback. If the previous declaration is unknown, +other work fields changed, or compatible subsequent revisions prevent an exact +match, prepare the current intent for owner review and explicit rebind. The CLI +and managed replan guidance name that route instead of prescribing a lease / +restore loop. Existing ready, unbound and acceptance-disabled work keeps its +ordinary admission rules. Frontend and Lark consume the resulting canonical +state; this introduces no separate editor or authority owner. + +硬租约 Todo 因误改文本或原有等待条件而 stale、且租约已释放时,同一 claimed Agent +可带当前 provider revision 和稳定 operation id,通过原来的 `todo update` 精确还原。 +TS 校验整个候选工作声明的摘要必须等于 owner 当初确认的摘要;不会把任意修改当成 +无害变化,也不修改验收标准、owner 绑定或租约。恢复后仍须正常获取新租约才能执行。 +重复请求仅恢复旧回执,不会重复结算 Turn 或改变新一代租约。 + +必须提供真实的原文本/等待条件。若不知道原声明、改动涉及其他字段,或后续兼容修订 +使完整摘要无法精确匹配,应请 owner 审核并显式重新绑定。不能猜测旧值、伪造完成, +也不能先取得 stale 工作的租约来绕过这条边界。 diff --git a/tests/control_plane/test_acceptance_restoration.py b/tests/control_plane/test_acceptance_restoration.py new file mode 100644 index 0000000000..650da6f4d7 --- /dev/null +++ b/tests/control_plane/test_acceptance_restoration.py @@ -0,0 +1,99 @@ +"""An exact reviewed rollback must recover a released stale Todo, without execution.""" +from __future__ import annotations + +import json +import subprocess +import sys + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, read_canonical_todo_fields_if_promoted, +) +from loopx.control_plane.goals.goal_frontier.acceptance import acceptance_gaps_from_held_goal_binding + + +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_cli_restore_then_reacquire_preserves_acceptance_and_exact_retry(tmp_path, monkeypatch, provider): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" + goal, target = "acceptance-restoration", "todo_artifact" + state.write_text("# Goal\n\n## Agent Todo\n") + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": goal, "repo": str(tmp_path), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}, + }]})) + wait = "resume_at:2020-01-01T00:00:00Z" + todo = {"schema_version": "todo_item_v0", "todo_id": target, "role": "agent", "status": "open", + "done": False, "text": "Deliver the artifact", "archive_state": "active", + "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task", + "claimed_by": "agent-a", "resume_when": wait} + projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", todos=[todo]) + initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) + document = tmp_path / "acceptance.json" + document.write_text(json.dumps({ + "scope": {"kind": "selected_work", "todo_ids": [target]}, "objective": "Deliver the artifact", + "non_goals": [], "criteria": [{"id": "artifact", "description": "Artifact is verified", + "validation_argv": [sys.executable, "-c", "pass"]}], + "bindings": [{"todo_id": target, "criterion_ids": ["artifact"]}], + })) + + def cli(*args, expected=0): + proc = subprocess.run([sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--format", "json", *args, "--goal-id", goal], + capture_output=True, text=True, timeout=60) + assert proc.returncode == expected, proc.stdout + proc.stderr + return json.loads(proc.stdout) + + def inspect(): + return cli("goal-acceptance", "inspect") + + initial = inspect() + cli("goal-acceptance", "configure", "--document", str(document), "--expected-provider-revision", + initial["provider_revision"], "--operation-id", "confirm-owner", "--execute") + confirmed = inspect()["goal_acceptance_contract"] + lease_args = ["--todo-id", target, "--owner", "agent-a", "--idempotency-key", "execution-one"] + first = cli("task-lease", "acquire", *lease_args, "--expected-version", "0", "--ttl-seconds", "600") + assert first["acquired"] + cli("todo", "update", "--todo-id", target, "--agent-id", "agent-a", "--clear-resume-when", + "--task-lease-idempotency-key", "execution-one", "--task-lease-expected-version", "1") + assert inspect()["goal_acceptance_contract"]["tasks"][0]["state"] == "stale" + cli("task-lease", "release", *lease_args, "--expected-version", "1") + stale = inspect() + def turn_holds(): + fields = read_canonical_todo_fields_if_promoted(runtime_root=runtime, goal_id=goal) + source = read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal) + return acceptance_gaps_from_held_goal_binding(fields["agent_todos"], source["todos"], agent_id="agent-a") + assert len(turn_holds()) == 1 + held = cli("task-lease", "acquire", "--todo-id", target, "--owner", "agent-a", + "--idempotency-key", "execution-two", "--expected-version", "1", expected=1) + assert "stale" in json.dumps(held) + restore = ["todo", "update", "--todo-id", target, "--agent-id", "agent-a", + "--resume-when", wait, "--update-operation-id", "restore-original", + "--update-expected-provider-revision", stale["provider_revision"]] + unknown = restore.copy() + unknown[unknown.index(wait)] = "resume_at:2021-01-01T00:00:00Z" + refused = cli(*unknown, expected=1) + assert "owner" in json.dumps(refused) and "rebind" in json.dumps(refused) + assert inspect() == stale + assert cli(*restore)["status"] == "applied" + restored = inspect() + assert turn_holds() == [] # Managed-Turn frontier no longer projects the stale hold. + assert restored["goal_acceptance_contract"]["tasks"][0]["state"] == "ready" + for field in ("revision", "digest", "criteria"): + assert restored["goal_acceptance_contract"][field] == confirmed[field] + lease = cli("task-lease", "inspect", "--todo-id", target) + assert not lease["active"] and lease["lease"]["version"] == 1 + assert cli(*restore)["status"] == "replayed" + assert inspect() == restored + next_lease = cli("task-lease", "acquire", "--todo-id", target, "--owner", "agent-a", + "--idempotency-key", "execution-two", "--expected-version", "1", "--ttl-seconds", "600") + assert next_lease["acquired"] and next_lease["lease"]["version"] == 2 + # Recovery replay remains historical after a fresh execution starts; it + # cannot release or acquire another generation, complete work or spend quota. + after_acquire = inspect() + assert cli(*restore)["status"] == "replayed" + assert inspect() == after_acquire + assert cli("task-lease", "inspect", "--todo-id", target)["lease"] == next_lease["lease"] + assert cli("todo", "list")["todos"][0]["status"] == "open" diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index f5f5643fb3..477bb7a6db 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -591,3 +591,73 @@ test("legacy omitted scope preserves document bytes, bindings and global admissi assert.equal(acceptanceWorkGuard(value, goal, "todo_first")?.allowed, true); assert.equal(acceptanceWorkGuard(value, goal, "todo_new")?.state, "unbound"); }); + +for (const provider of providers) { + test(`${provider}: exact stale-declaration restoration preserves owner authority and lease lineage`, + {skip: provider === "postgresql" && !process.env.LOOPX_TEST_POSTGRES_URL || provider === "sqlite" && !sqliteQualified}, async t => { + const {executeCoordinationTodoUpdate} = await import("../../loopx/control_plane/coordination/todo_update.ts"); + const store = await fixture(t, provider); + const wait = "resume_at:2020-01-01T00:00:00Z"; + const original = todo("todo_first", {claimed_by: "agent-a", resume_when: wait}); + const lease = {todo_id: "todo_first", goal_id: goal, owner: "agent-a", status: "released", + idempotency_key: "previous-execution", version: 1, lease_epoch: 1, + expires_at: "2020-01-01T00:00:00Z", write_scopes: []}; + await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, events: [], receipts: [], + next_projection: authorityProjectionFixture(goal, [original], [lease], "native", {handoff_mode: "hard_lease"})}); + await configureGoalAcceptance(store, await configureRequest(store, {document: {...document(), + bindings: [{todo_id: "todo_first", criterion_ids: ["outcome"]}]}})); + const confirmed = (await head(store)).head.goal_acceptance; + const current = await head(store); + const changedTodo = {...(current.head.todos as JsonObject[])[0]}; delete changedTodo.resume_when; + await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, operation_id: "prior-unintended-edit", + expected_provider_revision: current.provider_revision, projection: current.head, + mutations: [{kind: "todo_upsert", todo: changedTodo, clear_fields: ["resume_when"]}]})); + const stale = await head(store); + assert.equal(acceptanceWorkGuard(stale.head, goal, "todo_first")?.state, "stale"); + const request = {goal_id: goal, todo_id: "todo_first", expected_role: "agent", actor_agent_id: "agent-a", + registered_agents: ["agent-a", "agent-b"], operation_id: "restore-exact-declaration", + expected_provider_revision: stale.provider_revision, patch: {}, clear_fields: [], + planning_intent: {resume_when: wait}, dry_run: false, now: new Date("2030-01-01T00:00:00Z")}; + for (const change of [ + {actor_agent_id: "agent-b"}, {expected_provider_revision: undefined}, + {expected_provider_revision: "stale"}, {planning_intent: {resume_when: "resume_at:2021-01-01T00:00:00Z"}}, + {patch: {text: "Different scope"}}, {planning_intent: {resume_when: wait, status: "done"}}, + {lease_idempotency_key: "previous-execution", lease_expected_version: 1}, + ]) { + const refused = await executeCoordinationTodoUpdate(store, {...request, ...change}); + assert.equal(refused.status, "failed", JSON.stringify(refused)); + assert.deepEqual(await head(store), stale); + } + // Exercise the full admission path against rejected current facts too. + for (const [name, patch, activeLease] of [ + ["excluded", {excluded_agents: ["agent-a"]}, false], + ["active-lease", {}, true], + ] as const) { + const basis = await head(store); + await store.commitAuthority(prepareCoordinationProjectionCommit({goal_id: goal, operation_id: `facts-${name}`, + expected_provider_revision: basis.provider_revision, projection: basis.head, + mutations: [{kind: "todo_upsert", todo: {...changedTodo, ...patch}}, + {kind: "lease_upsert", lease: activeLease ? {...lease, status: "active", expires_at: "2031-01-01T00:00:00Z"} : lease}]})); + const held = await head(store); + const denied = await executeCoordinationTodoUpdate(store, {...request, + expected_provider_revision: held.provider_revision}); + assert.equal(denied.status, "failed", name); + assert.deepEqual(await head(store), held); + await store.commitAuthority({operation_id: `reset-${name}`, expected_provider_revision: held.provider_revision, + next_projection: stale.head, events: [], receipts: []}); + } + request.expected_provider_revision = (await head(store)).provider_revision; + const readyToRestore = await head(store); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, dry_run: true})).status, "planned"); + assert.deepEqual(await head(store), readyToRestore); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "applied"); + const restored = await head(store); + assert.equal(acceptanceWorkGuard(restored.head, goal, "todo_first")?.state, "ready"); + assert.deepEqual(restored.head.goal_acceptance, confirmed); + assert.deepEqual(restored.head.leases, stale.head.leases); + assert.equal((await executeCoordinationTodoUpdate(store, request)).status, "replayed"); + assert.deepEqual(await head(store), restored); + assert.equal((await executeCoordinationTodoUpdate(store, {...request, patch: {text: "Changed replay"}})).status, "failed"); + assert.deepEqual(await head(store), restored); + }); +} diff --git a/tests/control_plane_ts/todo_update.test.ts b/tests/control_plane_ts/todo_update.test.ts index 0dc5463305..ac46f2ab83 100644 --- a/tests/control_plane_ts/todo_update.test.ts +++ b/tests/control_plane_ts/todo_update.test.ts @@ -576,3 +576,16 @@ for (const [label, leaseChange, todoChange, reason] of [ assert.equal((await store.readReceipt(request.operation_id)).status, "missing"); }); } + + +test("schema-less historical canonical leases retain active execution semantics without mutation", async () => { + const {canonicalTaskLease} = await import("../../loopx/control_plane/coordination/task_lease_state.ts"); + const {leaseIsActive} = await import("../../loopx/control_plane/work_items/task_lease_acquire.ts"); + const original = {todo_id: "todo_a", owner: "agent-a", idempotency_key: "execution-a", + status: "active", version: 1, lease_epoch: 1, expires_at: "2031-01-01T00:00:00Z"}; + const normalized = canonicalTaskLease(original, "goal-a", "todo_a"); + assert.equal(normalized.schema_version, "task_lease_v0"); + assert.equal(leaseIsActive(normalized, new Date("2030-01-01T00:00:00Z")), true); + assert.equal(Object.hasOwn(original, "schema_version"), false); + assert.throws(() => canonicalTaskLease({...original, schema_version: "unknown"}, "goal-a", "todo_a"), /schema/); +}); From 4d280ba7bb375cea1aff19955cd79f40ef14c60e Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:43:17 +0800 Subject: [PATCH 3/3] test(acceptance): complete and settle the restored managed Turn once Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- ...2026-09-24-exact-acceptance-restoration.md | 4 ++- ...9-24-exact-acceptance-restoration.zh-CN.md | 3 +- .../test_acceptance_restoration.py | 32 +++++++++++++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md index 2bae7aac8b..85f7c58d8f 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.md @@ -20,7 +20,9 @@ This advances native long-horizon recovery, not provider default selection or D3 promotion. Real File/SQLite CLI regressions exercise acquire, stale edit, release, rejected reacquire, restoration and fresh acquire; provider tests also exercise a disposable PostgreSQL server. Managed frontier projection drops the -stale hold after restoration. #5000 remains separately owned by Turn settlement +stale hold after restoration. The original managed Turn then completes its Todo, +writes back and settles; replaying restoration and spend consumes quota once. +#5000 remains separately owned by Turn settlement and its retry policy; restoring acceptance does not settle a Turn. See the [caller contract](../../../../reference/goal-acceptance-observations.md#restore-an-unintended-textwait-edit-after-lease-release). diff --git a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md index 54bd21fde0..e3eadb68c5 100644 --- a/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md +++ b/docs/architecture/rfcs/ledger/shared-goal-authority-state-provider-v0/2026-09-24-exact-acceptance-restoration.zh-CN.md @@ -14,7 +14,8 @@ 此项推进原生长程恢复,不改变默认 provider 或 D3 晋升结论。真实 File/SQLite CLI 覆盖获取租约、误改、释放、拒绝重取、恢复、再次获取;provider 用例还覆盖隔离的 -真实 PostgreSQL。恢复后 managed frontier 不再投影 stale hold。#5000 的 Turn +真实 PostgreSQL。恢复后 managed frontier 不再投影 stale hold;原 managed Turn 随后完成 Todo、写回与结算,重复恢复和结算只消费一次 +配额。#5000 的 Turn 结算及重试策略仍属独立边界;恢复验收声明不等于结算 Turn。 参见[调用方合同](../../../../reference/goal-acceptance-observations.md#restore-an-unintended-textwait-edit-after-lease-release)。 diff --git a/tests/control_plane/test_acceptance_restoration.py b/tests/control_plane/test_acceptance_restoration.py index 650da6f4d7..08a371cb16 100644 --- a/tests/control_plane/test_acceptance_restoration.py +++ b/tests/control_plane/test_acceptance_restoration.py @@ -19,16 +19,19 @@ def test_cli_restore_then_reacquire_preserves_acceptance_and_exact_retry(tmp_pat isolate_sqlite_runtime(tmp_path, monkeypatch) runtime, state, registry = tmp_path / "runtime", tmp_path / "state.md", tmp_path / "registry.json" goal, target = "acceptance-restoration", "todo_artifact" - state.write_text("# Goal\n\n## Agent Todo\n") + state.write_text("---\nstatus: active-read-only\nowner_mode: goal\nobjective: Validate a bounded artifact\n---\n# Goal\n\n## Agent Todo\n") registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ "id": goal, "repo": str(tmp_path), "state_file": state.name, + "domain": "acceptance-restoration", "status": "active-read-only", + "adapter": {"kind": "read_only_project_map_v0", "status": "connected-read-only"}, + "quota": {"compute": 1.0, "window_hours": 24, "allowed_slots": 2}, "coordination": {"registered_agents": ["agent-a", "agent-b"]}, }]})) wait = "resume_at:2020-01-01T00:00:00Z" todo = {"schema_version": "todo_item_v0", "todo_id": target, "role": "agent", "status": "open", "done": False, "text": "Deliver the artifact", "archive_state": "active", "source_section": "Agent Todo", "index": 1, "task_class": "advancement_task", - "claimed_by": "agent-a", "resume_when": wait} + "claimed_by": "agent-a", "resume_when": wait, "action_kind": "validate"} projection = build_todo_runtime_shadow_projection(goal_id=goal, handoff_mode="hard_lease", todos=[todo]) initialize_canonical_authority(runtime, goal, projection, state_path=state, provider=provider) document = tmp_path / "acceptance.json" @@ -56,6 +59,10 @@ def inspect(): lease_args = ["--todo-id", target, "--owner", "agent-a", "--idempotency-key", "execution-one"] first = cli("task-lease", "acquire", *lease_args, "--expected-version", "0", "--ttl-seconds", "600") assert first["acquired"] + turn_binding = ["--agent-id", "agent-a", "--todo-id", target, + "--turn-instance-id", "turn-restore-acceptance"] + guard = cli("quota", "should-run", "--codex-app", *turn_binding, "--scan-path", str(tmp_path)) + assert guard["heartbeat_receipt"]["settlement_identity"]["todo_id"] == target cli("todo", "update", "--todo-id", target, "--agent-id", "agent-a", "--clear-resume-when", "--task-lease-idempotency-key", "execution-one", "--task-lease-expected-version", "1") assert inspect()["goal_acceptance_contract"]["tasks"][0]["state"] == "stale" @@ -97,3 +104,24 @@ def turn_holds(): assert inspect() == after_acquire assert cli("task-lease", "inspect", "--todo-id", target)["lease"] == next_lease["lease"] assert cli("todo", "list")["todos"][0]["status"] == "open" + + completed = cli("todo", "complete", *turn_binding, "--task-lease-idempotency-key", "execution-two", + "--task-lease-expected-version", "2", "--evidence", "fixture:restoration-check", + "--next-agent-todo", "Validate the next artifact", "--next-claimed-by", "agent-a", + "--next-action-kind", "validate") + assert completed["ok"] + refresh = cli("refresh-state", *turn_binding, "--classification", "validated_recovery", + "--delivery-batch-scale", "single_surface", "--delivery-outcome", "outcome_progress", + "--no-global-sync", "--suppress-external-sinks") + assert refresh["ok"] + spend_args = ["quota", "spend-slot", *turn_binding, "--slots", "1", "--source", "heartbeat", + "--execute", "--scan-path", str(tmp_path)] + first_spend = cli(*spend_args) + assert first_spend["settlement_result"]["ok"] + assert cli(*restore)["status"] == "replayed" + repeated_spend = cli(*spend_args) + assert repeated_spend["settlement_result"]["ok"] + runs = runtime / "goals" / goal / "runs/index.jsonl" + assert sum(json.loads(line).get("classification") == "quota_slot_spent" + for line in runs.read_text().splitlines()) == 1 + assert next(todo for todo in cli("todo", "list")["todos"] if todo["todo_id"] == target)["status"] == "done"