From d33a6f1bb2b0d0edd6a1cce9223208ae7312319f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:04:38 +0800 Subject: [PATCH] fix: preserve acceptance bindings across validator revisions Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../reference/goal-acceptance-observations.md | 17 +++++- .../goals/acceptance_contract.ts | 55 ++++++++++++++++++- .../goal_acceptance_authority.test.ts | 29 ++++++++++ .../goal_acceptance_runtime.test.ts | 19 +++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 72d1402218..181e3bbebf 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -139,8 +139,15 @@ task arguments; this contract adds no bypass flags. Terminal observations, including `no_followup`, do not change the work digest: finishing a task must not stale the binding that just admitted its completion. -Text, validation requirements and unknown future work fields still invalidate -the association. Existing enabled contracts configured with a persisted +The v0 binding matcher also accepts a prior digest when the only intervening +changes are a valid append-only completion-validator revision history or added +successor links. It checks reconstructible prior states rather than rewriting +owner bindings, so existing ready contracts retain their stored digests. +Revised validators still undergo their own fresh completion check; Goal +acceptance criteria remain separately configured and checked. Text, whether +completion validation is required, repository/write-scope declarations, and +unknown future work fields still invalidate the association. Existing enabled +contracts configured with a persisted `no_followup` field under the earlier digest rule require owner inspection and reconfiguration; no historical receipt is rewritten or automatically accepted. Disabled/absent acceptance retains its existing behavior. @@ -280,6 +287,12 @@ status 同时在独立的 `run_history.goals[].artifact_lifecycle` 和 Markdown claim、lease/fence、权限和后续工作要求。既有验证回执或已确认的关联不能代替本次任务完成验证。 任务参数沿用 `loopx todo claim --help`、`loopx todo complete --help`,没有绕过门禁的新参数。 +终态观察不会让刚完成的任务关联过期。对既有 v0 绑定,若差异仅来自可校验的完成验证命令 +修订历史追加或后继任务链接追加,读出会比对可重建的旧状态并自动保留 `ready`,无需所有者 +重复确认,也不改写已保存的绑定摘要。修订后的命令仍须在完成时重新验证,Goal 验收条件 +也仍独立执行。任务文本、是否要求完成验证、仓库与写入范围等实质工作声明变化仍使关联 +过期;未知的新工作字段默认按实质变化处理。 + `loopx goal-acceptance verify --goal-id example-goal` 仅预览;加 `--execute` 执行全部配置条件, 再运行 inspect 读回。进入 **概览 → 交付与依据**,刷新并展开交付链下方的 **Goal 验收合同**。 区块仅在服务端 `acceptance.goal_acceptance_contract.enabled=true` 时显示,缺失或停用保持原界面与导出。 diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index a2e449cec1..a2f19adaa0 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -157,6 +157,59 @@ const NON_WORK_FIELDS = new Set([ export function goalAcceptanceTodoDigest(todo: JsonObject): string { return canonicalAuthoritySha256(Object.fromEntries(Object.entries(todo).filter(([key]) => !NON_WORK_FIELDS.has(key)))); } +/** Existing owner bindings persist the v0 digest, including fields later used + * for validator revision bookkeeping and successor links. Keep that digest + * format so previously ready bindings stay ready. When it differs, check only + * historical states that the current append-only metadata can reconstruct; + * changing the Todo's work declaration still requires owner confirmation. */ +function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolean { + if (goalAcceptanceTodoDigest(todo) === boundDigest) return true; + + const successors = todo.successor_todo_ids; + const successorVariants: JsonObject[] = [todo]; + if (Array.isArray(successors) && successors.length <= 32 && + successors.every(value => typeof value === "string")) { + for (let count = successors.length - 1; count >= 0; count--) { + successorVariants.push({...todo, successor_todo_ids: successors.slice(0, count)}); + } + const withoutSuccessors = {...todo}; + delete withoutSuccessors.successor_todo_ids; + successorVariants.push(withoutSuccessors); + } + + const revision = todo.completion_validation_revision; + const history = todo.completion_validation_revision_history; + let revisionPrefixes: number[] = []; + if (Number.isSafeInteger(revision) && Number(revision) >= 1 && Number(revision) <= 32 && + Array.isArray(history) && history.length === revision && + history.every((entry, index) => entry !== null && typeof entry === "object" && !Array.isArray(entry) && + (entry as JsonObject).schema_version === "loopx_todo_completion_validation_revision_receipt_v0" && + (entry as JsonObject).revision === index + 1 && + typeof (entry as JsonObject).previous_declaration_sha256 === "string" && + /^[a-f0-9]{64}$/.test((entry as JsonObject).previous_declaration_sha256 as string) && + typeof (entry as JsonObject).declaration_sha256 === "string" && + /^[a-f0-9]{64}$/.test((entry as JsonObject).declaration_sha256 as string)) && + history.every((entry, index) => index === 0 || + (entry as JsonObject).previous_declaration_sha256 === (history[index - 1] as JsonObject).declaration_sha256) && + (history.at(-1) as JsonObject).declaration_sha256 === todo.completion_validation_sha256) { + revisionPrefixes = Array.from({length: Number(revision)}, (_, index) => index); + } + + for (const successorVariant of successorVariants) { + if (successorVariant !== todo && goalAcceptanceTodoDigest(successorVariant) === boundDigest) return true; + for (const priorRevision of revisionPrefixes) { + const previous: JsonObject = {...successorVariant, completion_validation_revision: priorRevision, + completion_validation_revision_history: (history as JsonObject[]).slice(0, priorRevision)}; + if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + if (priorRevision === 0) { + delete previous.completion_validation_revision; + delete previous.completion_validation_revision_history; + if (goalAcceptanceTodoDigest(previous) === boundDigest) return true; + } + } + } + return false; +} function advancement(todo: JsonObject): boolean { return todo.role === "agent" && (todo.task_class == null || todo.task_class === "advancement_task"); } @@ -225,7 +278,7 @@ export function readGoalAcceptance(head: JsonObject, goalId: string): Acceptance export function acceptanceTask(todoId: string, todo: JsonObject | undefined, state: AcceptanceState): AcceptanceTask { const binding = state.bindings.find(item => item.todo_id === todoId); - const reason_code = !binding ? "goal_acceptance_unbound" : !todo || binding.todo_semantic_digest !== goalAcceptanceTodoDigest(todo) + const reason_code = !binding ? "goal_acceptance_unbound" : !todo || !acceptanceBindingMatches(todo, binding.todo_semantic_digest) ? "goal_acceptance_stale" : "goal_acceptance_ready"; return {todo_id: todoId, state: !binding ? "unbound" : reason_code === "goal_acceptance_stale" ? "stale" : "ready", criterion_ids: binding?.criterion_ids ?? [], reason_code, diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 38caee008a..8de8321c26 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -11,6 +11,7 @@ import type {AuthorityStore} from "../../loopx/control_plane/coordination/author import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_authority_store.ts"; import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {PostgreSqlAuthorityStore, installPostgreSqlAuthorityStoreSchema} from "../../loopx/control_plane/coordination/postgresql_authority_store.ts"; +import {canonicalAuthoritySha256} from "../../loopx/control_plane/coordination/authority_store_codec.ts"; import {prepareCoordinationProjectionCommit, validateCoordinationTodoReadModel} from "../../loopx/control_plane/coordination/coordination_projection.ts"; import {openLocalAuthorityStore, selectLocalSqliteAuthority} from "../../loopx/control_plane/coordination/local_authority_provider.ts"; import {sqliteRuntimeIdentity} from "../../loopx/control_plane/coordination/sqlite_runtime.ts"; @@ -54,6 +55,34 @@ test("terminal continuation observations preserve work while changed requirement assert.notEqual(goalAcceptanceTodoDigest({...completed, text: "Deliver different work"}), goalAcceptanceTodoDigest(work)); assert.notEqual(goalAcceptanceTodoDigest({...completed, completion_validation_required: true}), goalAcceptanceTodoDigest(work)); }); +test("validator revisions and successor links preserve an existing acceptance binding", () => { + const original = todo("todo_first", {completion_validation_required: true, + completion_validation_sha256: "a".repeat(64), completion_validation_revision: 0, + completion_validation_revision_history: [], successor_todo_ids: []}); + const contract = normalizeGoalAcceptanceDocument({...document(), + bindings: [{todo_id: "todo_first", criterion_ids: ["prerequisite"]}]}); + const state = {schema_version: "loopx_goal_acceptance_v0", enabled: true, revision: 1, + digest: canonicalAuthoritySha256(contract), document: contract, verification: null, + bindings: [{todo_id: "todo_first", todo_semantic_digest: goalAcceptanceTodoDigest(original), + revision: 1, criterion_ids: ["prerequisite"], confirmed_by: "owner"}]}; + const receipt = {schema_version: "loopx_todo_completion_validation_revision_receipt_v0", revision: 1, + operation_id: "revise", previous_declaration_sha256: "a".repeat(64), + declaration_sha256: "b".repeat(64), actor_agent_id: "agent-a", revised_at: "2026-09-23T00:00:00Z"}; + const revised = {...original, completion_validation_sha256: "b".repeat(64), + completion_validation_revision: 1, completion_validation_revision_history: [receipt], + successor_todo_ids: ["todo_followup"]}; + const guarded = (work: JsonObject) => acceptanceWorkGuard(authorityProjectionFixture(goal, + [work, todo("todo_followup")], [], "native", {goal_acceptance: state}), goal, "todo_first"); + assert.equal(guarded(original)?.state, "ready"); + assert.notEqual(goalAcceptanceTodoDigest(revised), goalAcceptanceTodoDigest(original), + "persisted v0 digests must remain compatible without rebinding every existing Todo"); + assert.equal(guarded(revised)?.state, "ready"); + assert.equal(guarded({...revised, text: "Different work"})?.state, "stale"); + assert.equal(guarded({...revised, required_write_scopes: ["private"]})?.state, "stale"); + assert.equal(guarded({...revised, completion_validation_required: false})?.state, "stale"); + assert.equal(guarded({...revised, completion_validation_revision_history: [{...receipt, + declaration_sha256: "c".repeat(64)}]})?.state, "stale"); +}); async function seed(store: AuthorityStore) { assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, events: [], receipts: [], next_projection: originalHead()})).status, "applied"); diff --git a/tests/control_plane_ts/goal_acceptance_runtime.test.ts b/tests/control_plane_ts/goal_acceptance_runtime.test.ts index dcb8b5dd5d..2462312153 100644 --- a/tests/control_plane_ts/goal_acceptance_runtime.test.ts +++ b/tests/control_plane_ts/goal_acceptance_runtime.test.ts @@ -123,6 +123,25 @@ for (const provider of ["file", ...(process.env.LOOPX_TEST_POSTGRES_URL ? ["post assert.equal((await executeCoordinationTodoTerminalLifecycle(store, terminal)).reason_code, "goal_acceptance_stale"); }); + test(`${provider}: a real validator revision leaves the bound Todo claimable`, async t => { + const previous = {validation_command: null, validation_command_argv: ["node", "old-check.mjs"], + validation_label: "focused check", validation_timeout_seconds: 10}; + const replacement = {...previous, validation_command_argv: ["node", "new-check.mjs"]}; + const {store} = await seeded(t, provider, "bound", {completion_validation_required: true, + completion_validation_sha256: canonicalAuthoritySha256(previous), + completion_validation_revision: 0, completion_validation_revision_history: []}); + const before = await loaded(store); + const changed = await executeCoordinationTodoUpdate(store, {goal_id: "goal-a", todo_id: "todo_work", + expected_role: "agent", actor_agent_id: "agent-a", registered_agents: ["agent-a"], + operation_id: "revise-validator", patch: {}, clear_fields: [], dry_run: false, now, + expected_provider_revision: before.provider_revision, + completion_validation_revision: {schema_version: "loopx_todo_completion_validation_revision_v0", + expected_declaration_sha256: canonicalAuthoritySha256(previous), declaration: replacement}}); + assert.equal(changed.status, "applied"); + assert.equal(acceptanceWorkGuard((await loaded(store)).head, "goal-a", "todo_work")?.state, "ready"); + assert.equal((await executeCoordinationTodoClaim(store, claim)).status, "applied"); + }); + test(`${provider}: acquisition replay checks current acceptance after current lease proof`, async t => { const {store} = await seeded(t, provider, "bound"); assert.equal((await executeCanonicalTaskLeaseAcquire(store, acquire)).status, "applied");