Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions docs/reference/goal-acceptance-observations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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` 时显示,缺失或停用保持原界面与导出。
Expand Down
55 changes: 54 additions & 1 deletion loopx/control_plane/goals/acceptance_contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions tests/control_plane_ts/goal_acceptance_authority.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");
Expand Down
19 changes: 19 additions & 0 deletions tests/control_plane_ts/goal_acceptance_runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading