diff --git a/docs/reference/goal-acceptance-observations.md b/docs/reference/goal-acceptance-observations.md index 181e3bbebf..e852732652 100644 --- a/docs/reference/goal-acceptance-observations.md +++ b/docs/reference/goal-acceptance-observations.md @@ -141,14 +141,26 @@ Terminal observations, including `no_followup`, do not change the work digest: finishing a task must not stale the binding that just admitted its completion. 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 +successor links, or when a previously absent `resume_when` scheduling condition +is added. 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 +unknown future work fields still invalidate the association. Replacing an +existing `resume_when` is not reconstructible from the latest Todo and remains +`stale`. When the agent's applicable Todo is genuinely stale, the existing +Goal frontier projects its exact Todo ID as an agent-scoped replan trigger if +no advancement Todo is selectable. The agent inspects the binding and work +delta, restores an unintended edit or records an evidence-linked path change. +The original Turn/Todo identity remains intact; a successor has its own identity. +The agent cannot rebind +owner-confirmed criteria, complete held work, or settle a different Todo under +the old Turn; a true change to owner-owned criteria or scope still needs owner +review. Only an evidence-linked runnable successor or concrete blocker receipt +for this stale-binding trigger, recorded after the Todo update, quiets repeated +wakeups until another material change. 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. @@ -288,10 +300,17 @@ claim、lease/fence、权限和后续工作要求。既有验证回执或已确 任务参数沿用 `loopx todo claim --help`、`loopx todo complete --help`,没有绕过门禁的新参数。 终态观察不会让刚完成的任务关联过期。对既有 v0 绑定,若差异仅来自可校验的完成验证命令 -修订历史追加或后继任务链接追加,读出会比对可重建的旧状态并自动保留 `ready`,无需所有者 +修订历史追加、后继任务链接追加,或此前不存在的 `resume_when` 调度条件新增,读出会比对 +可重建的旧状态并自动保留 `ready`,无需所有者 重复确认,也不改写已保存的绑定摘要。修订后的命令仍须在完成时重新验证,Goal 验收条件 也仍独立执行。任务文本、是否要求完成验证、仓库与写入范围等实质工作声明变化仍使关联 -过期;未知的新工作字段默认按实质变化处理。 +过期;未知的新工作字段默认按实质变化处理。已有 `resume_when` 被替换时,当前 Todo +无法证明旧值,仍保持 `stale`。适用的 Agent Todo 确实过期、且无可选推进任务时,现有 +Goal frontier 以原 Todo ID 产生 Agent 范围的重规划触发。Agent 核查关联与工作变化, +恢复误改或记录有依据的路径变化。原 Turn/Todo 身份保持不变,后继有独立身份;Agent 不能自行重绑所有者确认 +的条件、完成受阻任务,或用原 Turn 结算另一条 Todo。真正改变所有者验收条件或范围的 +情况仍交所有者审阅。只有绑定该过期触发项、发生于 Todo 更新之后,并证明有依据的可运行 +后继或具体阻塞的回执,才会消解重复唤醒,直到再次出现实质变化。 `loopx goal-acceptance verify --goal-id example-goal` 仅预览;加 `--execute` 执行全部配置条件, 再运行 inspect 读回。进入 **概览 → 交付与依据**,刷新并展开交付链下方的 **Goal 验收合同**。 diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index a2f19adaa0..2751f8aaff 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -165,16 +165,15 @@ export function goalAcceptanceTodoDigest(todo: JsonObject): string { 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); + // Adding a wait condition changes when existing work can resume, not which + // owner-confirmed Goal criterion it serves. Only the absent -> present case + // is reconstructible from the current Todo; changing an existing condition + // remains stale because its previous value cannot be proven here. + const scheduleVariants: JsonObject[] = [todo]; + if (Object.hasOwn(todo, "resume_when")) { + const withoutResume = {...todo}; + delete withoutResume.resume_when; + scheduleVariants.push(withoutResume); } const revision = todo.completion_validation_revision; @@ -195,16 +194,29 @@ function acceptanceBindingMatches(todo: JsonObject, boundDigest: string): boolea 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; + for (const scheduleVariant of scheduleVariants) { + const successors = scheduleVariant.successor_todo_ids; + const successorVariants: JsonObject[] = [scheduleVariant]; + if (Array.isArray(successors) && successors.length <= 32 && + successors.every(value => typeof value === "string")) { + for (let count = successors.length - 1; count >= 0; count--) { + successorVariants.push({...scheduleVariant, successor_todo_ids: successors.slice(0, count)}); + } + const withoutSuccessors = {...scheduleVariant}; + delete withoutSuccessors.successor_todo_ids; + successorVariants.push(withoutSuccessors); + } + 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; + } } } } diff --git a/loopx/control_plane/goals/goal_frontier/__init__.py b/loopx/control_plane/goals/goal_frontier/__init__.py index 65540e0180..c65ea75609 100644 --- a/loopx/control_plane/goals/goal_frontier/__init__.py +++ b/loopx/control_plane/goals/goal_frontier/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json from typing import Any from ...agents.agent_scope import ( @@ -104,6 +106,7 @@ FRONTIER_EXHAUSTED_MONITOR_TRIGGER = "frontier_exhausted_monitor_lane" MONITOR_NO_CHANGE_STREAK_TRIGGER = "monitor_no_change_streak" VISION_PROFILE_MISSING_TRIGGER = "required_agent_vision_missing" +GOAL_ACCEPTANCE_STALE_TRIGGER = "goal_acceptance_stale" TODO_SUCCESSION_GAP_TRIGGER = TODO_SUCCESSION_WARNING_REASON_CODE @@ -365,6 +368,70 @@ def acceptance_gaps_from_agent_profile_requirement( ] +def acceptance_gaps_from_stale_goal_binding( + agent_todo_summary: dict[str, Any] | None, + source_items: list[dict[str, Any]] | None, + *, + agent_id: str | None, +) -> list[dict[str, Any]]: + """Route this agent's held semantic drift through the existing replan lane. + + This is a read-only trigger, not an acceptance rebind. Other runnable work + remains selectable; the frontier rule schedules a replan only when no + advancement Todo can be selected for this agent. + """ + + if not agent_id or not isinstance(agent_todo_summary, dict): + return [] + contract = agent_todo_summary.get("goal_acceptance_contract") + if not isinstance(contract, dict) or contract.get("enabled") is not True: + return [] + stale_ids = { + task.get("todo_id") + for task in contract.get("tasks", []) + if isinstance(task, dict) + and task.get("state") == "stale" + and task.get("applicable") is True + } + gaps: list[dict[str, Any]] = [] + for item in source_items or []: + if ( + not isinstance(item, dict) + or item.get("todo_id") not in stale_ids + or item.get("role") != "agent" + or item.get("status") not in {"open", "blocked"} + or not agent_scope_item_claimed_by_agent_or_unclaimed(item, agent_id=agent_id) + ): + continue + todo_id = str(item["todo_id"]) + frontier_revision = hashlib.sha256(json.dumps( + [todo_id, item.get("updated_at"), contract.get("digest")], + ensure_ascii=True, separators=(",", ":"), default=str, + ).encode("utf-8")).hexdigest() + gap = { + "kind": GOAL_ACCEPTANCE_STALE_TRIGGER, + "source": "goal_acceptance_contract", + "agent_id": agent_id, + "reason_code": GOAL_ACCEPTANCE_STALE_TRIGGER, + "vision_todo_ids": [todo_id], + "frontier_revision": frontier_revision, + "replan_trigger_summary": f"The acceptance association for {todo_id} is stale after a work change.", + "acceptance_summary": "Preserve the owner-confirmed criteria and the original Turn identity.", + "resolution_hint": ( + f"Inspect {todo_id} and its acceptance binding; restore an unintended edit, " + "or record an evidence-linked path delta and continue via an eligible " + "successor. Escalate only a real change to owner-owned criteria or scope; " + "never rebind or settle a different Todo under the original Turn." + ), + } + if isinstance(item.get("updated_at"), str): + gap["generated_at"] = item["updated_at"] + gaps.append(gap) + if len(gaps) == 3: + break + return gaps + + def build_vision_continuation_audit( *, goal_id: str | None = None, @@ -921,6 +988,51 @@ def _vision_gap_acknowledged( if not acceptance_gaps or not isinstance(latest_replan_ack, dict): return False + # The vision-patch shortcut below covers gaps authored by that same Turn. + # A persisted Goal Acceptance drift is an independent Todo event: an older + # vision patch cannot acknowledge a later semantic edit. + stale_bindings = [ + gap for gap in acceptance_gaps + if gap.get("kind") == GOAL_ACCEPTANCE_STALE_TRIGGER + ] + if stale_bindings: + semantic_delta = latest_replan_ack.get("semantic_delta") + satisfying_outcomes = ( + semantic_delta.get("satisfying_outcomes") + if isinstance(semantic_delta, dict) + and isinstance(semantic_delta.get("satisfying_outcomes"), list) + else [] + ) + recorded_checkpoints = ( + semantic_delta.get("trigger_checkpoints") + if isinstance(semantic_delta, dict) + and isinstance(semantic_delta.get("trigger_checkpoints"), list) + else [] + ) + exact_stale_checkpoints = all( + any( + isinstance(checkpoint, dict) + and checkpoint.get("kind") == GOAL_ACCEPTANCE_STALE_TRIGGER + and checkpoint.get("frontier_revision") == gap.get("frontier_revision") + for checkpoint in recorded_checkpoints + ) + for gap in stale_bindings + ) + if ( + not _replan_evidence_acknowledged( + stale_bindings, latest_replan_ack, time_key="generated_at", + ) + or not isinstance(semantic_delta, dict) + or latest_replan_ack.get("recorded") is not True + or semantic_delta.get("accepted") is not True + or GOAL_ACCEPTANCE_STALE_TRIGGER not in (semantic_delta.get("trigger_kinds") or []) + or not exact_stale_checkpoints + or not any( + outcome in {"new_runnable_successor", "new_concrete_blocker"} + for outcome in satisfying_outcomes if isinstance(outcome, str) + ) + ): + return False delta_contract = latest_replan_ack.get("delta_contract") delta_kinds = ( delta_contract.get("delta_kinds") @@ -1182,6 +1294,8 @@ def derive_goal_frontier_replan_obligation_from_summaries( "completed_todo_count", "completed_todo_threshold", "completed_todo_ids", + "vision_todo_ids", + "frontier_revision", "reason_code", "component_checks", "resolution_hint", @@ -1225,6 +1339,11 @@ def derive_goal_frontier_replan_obligation_from_summaries( "record no-follow-up" ), rearmed_after_obligation_id=rearmed_after_obligation_id, + extra_fields=( + {"satisfying_semantic_outcomes": ["new_runnable_successor", "new_concrete_blocker"]} + if any(gap.get("kind") == GOAL_ACCEPTANCE_STALE_TRIGGER for gap in compact_acceptance_gaps) + else None + ), ) if replan_rule.rule is GoalFrontierReplanRule.LONG_TODO_CHAIN: assert long_chain_observation is not None @@ -1522,6 +1641,9 @@ def build_goal_frontier_projection_context_from_status( (project_asset or {}).get("execution_profile") ), ) + + acceptance_gaps_from_stale_goal_binding( + agent_todo_summary, agent_todo_source_items, agent_id=agent_id, + ) ) if _terminal_no_followup_resolves_vision_checkpoint( user_todo_summary=user_todo_summary, @@ -1558,7 +1680,10 @@ def build_goal_frontier_projection_context_from_status( + frontier_counts["unclaimed_advancement_count"] ), ) - acceptance_gaps = [] if vision_wait_state else source_acceptance_gaps + acceptance_gaps = ( + [gap for gap in source_acceptance_gaps if gap.get("kind") == GOAL_ACCEPTANCE_STALE_TRIGGER] + if vision_wait_state else source_acceptance_gaps + ) declared_fallback_gaps = [ gap for gap in ( diff --git a/loopx/control_plane/work_items/replan_semantics.ts b/loopx/control_plane/work_items/replan_semantics.ts index 18d97011cb..4bd4f2fe9a 100644 --- a/loopx/control_plane/work_items/replan_semantics.ts +++ b/loopx/control_plane/work_items/replan_semantics.ts @@ -20,7 +20,7 @@ const KNOWN_OUTCOMES: ReadonlySet = new Set([...PROGRESS_OUTCOMES, ...VI const PROGRESS_IDENTITY_OUTCOMES: ReadonlySet = new Set(["new_surface", "new_hypothesis", "new_probe_family"]); const VISION_TRIGGERS = new Set([ "vision_acceptance_gap", "vision_checkpoint_missing", "vision_outcome_checkpoint_required", - "vision_successor_required", "required_agent_vision_missing", + "vision_successor_required", "required_agent_vision_missing", "goal_acceptance_stale", ]); const EXTERNAL_REVIEW_TRIGGERS = new Set(["external_progress_review_drift"]); const FRESH_PATH_DISPOSITIONS = new Set(["continue", "no_change", "replan"]); diff --git a/tests/control_plane/test_goal_acceptance_cli.py b/tests/control_plane/test_goal_acceptance_cli.py index 3a2e094fd6..375751b9bd 100644 --- a/tests/control_plane/test_goal_acceptance_cli.py +++ b/tests/control_plane/test_goal_acceptance_cli.py @@ -332,10 +332,15 @@ def test_bound_work_cannot_be_closed_by_editing_its_way_out_of_the_gate( terminal = ("todo", "supersede", *common, "--reason", "pivot") code, refused = run(*terminal) assert code == 1, refused - assert refused["reason_code"] in { - "goal_acceptance_validation_required", - "goal_acceptance_stale", - }, refused + if escape and "--resume-when" in escape: + # A newly added scheduling wait preserves the binding, but the fresh + # completion validator must still reject the missing artifact. + assert refused["reason_code"] == "goal_acceptance_validation_rejected", refused + else: + assert refused["reason_code"] in { + "goal_acceptance_validation_required", + "goal_acceptance_stale", + }, refused assert "validation_argv" not in json.dumps(refused) # The refusal must be a refusal, not a report: the work stays open. contract = cli("inspect")[1]["goal_acceptance_contract"] @@ -343,6 +348,30 @@ def test_bound_work_cannot_be_closed_by_editing_its_way_out_of_the_gate( assert contract["verification"] is None +def test_real_cli_stale_binding_is_projected_for_agent_replan(acceptance_goal): + _, document, cli, run = acceptance_goal + basis = cli("inspect")[1]["provider_revision"] + code, configured = cli( + "configure", "--document", str(document), + "--expected-provider-revision", basis, "--execute", + ) + assert code == 0, configured + code, updated = run( + "todo", "update", "--todo-id", "todo_export", "--goal-id", "goal-acceptance", + "--agent-id", "agent-a", "--text", "Write the export artifact and checksum", + ) + assert code == 0, updated + assert cli("inspect")[1]["goal_acceptance_contract"]["tasks"][0]["state"] == "stale" + code, projected = run("quota", "should-run", "--goal-id", "goal-acceptance", "--agent-id", "agent-a") + assert code == 0, projected + assert projected["goal_frontier_projection"]["acceptance_gaps"][0]["kind"] == "goal_acceptance_stale" + assert "autonomous_replan_obligation" in projected, sorted(projected) + obligation = projected["autonomous_replan_obligation"] + assert obligation["triggers"][0]["kind"] == "goal_acceptance_stale" + assert obligation["triggers"][0]["vision_todo_ids"] == ["todo_export"] + assert len(obligation["triggers"][0]["frontier_revision"]) == 64 + + def test_preview_discloses_the_criteria_the_real_call_will_run(acceptance_goal): """A preview that hid this showed an unconditional close the real call gates.""" _, document, cli, run = acceptance_goal diff --git a/tests/control_plane/test_goal_acceptance_stale_replan.py b/tests/control_plane/test_goal_acceptance_stale_replan.py new file mode 100644 index 0000000000..00505e4a4a --- /dev/null +++ b/tests/control_plane/test_goal_acceptance_stale_replan.py @@ -0,0 +1,126 @@ +"""A stale Goal Acceptance binding is agent-scoped replan evidence, not a silent rebind.""" + +from loopx.control_plane.goals.goal_frontier import ( + acceptance_gaps_from_stale_goal_binding, + build_goal_frontier_projection_context_from_status, + derive_goal_frontier_replan_obligation_from_summaries, +) + + +def _summary(*, ready_alternative: bool = False): + executable = ( + [{"todo_id": "todo_ready", "role": "agent", "status": "open", + "task_class": "advancement_task", "claimed_by": "agent-a"}] + if ready_alternative else [] + ) + return { + "open_count": 1 + len(executable), + "current_agent_claimed_advancement_count": 1 + len(executable), + "executable_backlog_items": executable, + "first_executable_items": executable, + "unclaimed_priority_open_items": [], + "claim_scope": {"other_agent_claimed_items": []}, + "goal_acceptance_contract": { + "enabled": True, + "tasks": [ + {"todo_id": "todo_stale", "state": "stale", "applicable": True}, + {"todo_id": "todo_unbound", "state": "unbound", "applicable": True}, + ], + }, + } + + +def _source(): + return [ + {"todo_id": "todo_stale", "role": "agent", "status": "open", + "task_class": "advancement_task", "claimed_by": "agent-a", + "updated_at": "2026-09-23T08:02:52Z"}, + {"todo_id": "todo_unbound", "role": "agent", "status": "open", + "task_class": "advancement_task", "claimed_by": "agent-a"}, + ] + + +def test_stale_binding_routes_to_bounded_replan_only_when_frontier_is_empty(): + summary = _summary() + gaps = acceptance_gaps_from_stale_goal_binding(summary, _source(), agent_id="agent-a") + assert [gap["vision_todo_ids"] for gap in gaps] == [["todo_stale"]] + assert gaps[0]["generated_at"] == "2026-09-23T08:02:52Z" + assert len(gaps[0]["frontier_revision"]) == 64 + assert "todo_unbound" not in gaps[0]["resolution_hint"] + obligation = derive_goal_frontier_replan_obligation_from_summaries( + user_todo_summary={"open_count": 0}, agent_todo_summary=summary, + work_lane_contract=None, agent_id="agent-a", existing_replan_obligation=None, + acceptance_gaps=gaps, + ) + assert obligation is not None + assert obligation["triggers"][0]["kind"] == "goal_acceptance_stale" + assert "todo_stale" in obligation["recommended_action"] + assert obligation["satisfying_semantic_outcomes"] == [ + "new_runnable_successor", "new_concrete_blocker", + ] + + with_alternative = _summary(ready_alternative=True) + assert derive_goal_frontier_replan_obligation_from_summaries( + user_todo_summary={"open_count": 0}, agent_todo_summary=with_alternative, + work_lane_contract=None, agent_id="agent-a", existing_replan_obligation=None, + acceptance_gaps=acceptance_gaps_from_stale_goal_binding( + with_alternative, _source(), agent_id="agent-a"), + ) is None + + +def test_stale_binding_cannot_replan_another_agents_work_or_disabled_contract(): + assert acceptance_gaps_from_stale_goal_binding(_summary(), _source(), agent_id="agent-b") == [] + disabled = _summary() + disabled["goal_acceptance_contract"]["enabled"] = False + assert acceptance_gaps_from_stale_goal_binding(disabled, _source(), agent_id="agent-a") == [] + + +def test_stale_binding_reaches_quota_frontier_projection(): + context = build_goal_frontier_projection_context_from_status( + goal_id="goal-a", agent_id="agent-a", status_payload={}, item={}, + project_asset=None, user_todo_summary={"open_count": 0}, + agent_todo_summary=_summary(), agent_todo_source_items=_source(), + work_lane_contract={"lane": "advancement_task", "must_attempt_work": True}, + neutral_replan_ack_classifications=set(), + ) + obligation = context["replan_obligation"] + assert obligation is not None + assert obligation["triggers"][0]["kind"] == "goal_acceptance_stale" + assert obligation["triggers"][0]["vision_todo_ids"] == ["todo_stale"] + assert obligation["triggers"][0]["frontier_revision"] + + +def test_older_vision_ack_cannot_suppress_newer_stale_binding(): + gaps = acceptance_gaps_from_stale_goal_binding(_summary(), _source(), agent_id="agent-a") + old_ack = { + "generated_at": "2026-09-23T08:00:00Z", + "recorded": True, + "delta_contract": {"delta_kinds": ["goal_vision_patch"]}, + "semantic_delta": { + "accepted": True, + "trigger_kinds": ["goal_acceptance_stale"], + "trigger_checkpoints": [{ + "kind": "goal_acceptance_stale", + "frontier_revision": gaps[0]["frontier_revision"], + }], + "satisfying_outcomes": ["new_runnable_successor"], + }, + } + + def derive(ack): + return derive_goal_frontier_replan_obligation_from_summaries( + user_todo_summary={"open_count": 0}, agent_todo_summary=_summary(), + work_lane_contract=None, agent_id="agent-a", + existing_replan_obligation=None, acceptance_gaps=gaps, + latest_replan_ack=ack, + ) + + assert derive(old_ack) is not None + assert derive({**old_ack, "generated_at": "2026-09-23T08:03:00Z", + "semantic_delta": {**old_ack["semantic_delta"], + "trigger_kinds": ["vision_acceptance_gap"]}}) is not None + assert derive({**old_ack, "generated_at": "2026-09-23T08:03:00Z", + "semantic_delta": {**old_ack["semantic_delta"], + "trigger_checkpoints": [{"kind": "goal_acceptance_stale", + "frontier_revision": "other-todo"}]}}) is not None + assert derive({**old_ack, "generated_at": "2026-09-23T08:03:00Z"}) is None diff --git a/tests/control_plane_ts/goal_acceptance_authority.test.ts b/tests/control_plane_ts/goal_acceptance_authority.test.ts index 8de8321c26..a742126ef9 100644 --- a/tests/control_plane_ts/goal_acceptance_authority.test.ts +++ b/tests/control_plane_ts/goal_acceptance_authority.test.ts @@ -77,11 +77,19 @@ test("validator revisions and successor links preserve an existing acceptance bi 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, resume_when: "monitor_changed:todo_followup"})?.state, "ready", + "an added scheduling wait cannot alter the confirmed acceptance association"); 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"); + const boundWithWait = {...state, bindings: [{...state.bindings[0], + todo_semantic_digest: goalAcceptanceTodoDigest({...original, resume_when: "monitor_changed:todo_first"})}]}; + const changedWait = acceptanceWorkGuard(authorityProjectionFixture(goal, + [{...original, resume_when: "monitor_changed:todo_followup"}, todo("todo_followup")], [], "native", + {goal_acceptance: boundWithWait}), goal, "todo_first"); + assert.equal(changedWait?.state, "stale", "the matcher cannot reconstruct a replaced prior wait condition"); }); async function seed(store: AuthorityStore) { assert.equal((await store.commitAuthority({operation_id: "seed", expected_provider_revision: null, diff --git a/tests/control_plane_ts/replan_semantics.test.ts b/tests/control_plane_ts/replan_semantics.test.ts index 96e6ad94dc..accf89c035 100644 --- a/tests/control_plane_ts/replan_semantics.test.ts +++ b/tests/control_plane_ts/replan_semantics.test.ts @@ -66,6 +66,19 @@ test("explicit outcome restriction remains authoritative; trigger prose is not", observation_delta: {delta_kinds: ["new_surface"]}}).accepted, true); }); +test("stale Goal Acceptance cannot be discharged by unrelated progress or a vision patch", () => { + const stale = {triggers: [{kind: "goal_acceptance_stale", vision_todo_ids: ["todo_stale"]}], + satisfying_semantic_outcomes: ["new_runnable_successor", "new_concrete_blocker"]}; + assert.deepEqual(requiredSemanticOutcomes(stale), ["new_runnable_successor", "new_concrete_blocker"]); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: stale, + observation_delta: {delta_kinds: ["new_surface"]}}).accepted, false); + assert.equal(projectReplanSemantics({operation: "qualify", obligation: stale, agent_vision: vision}).accepted, false); + for (const outcome of ["new_runnable_successor", "new_concrete_blocker"]) { + assert.equal(projectReplanSemantics({operation: "qualify", obligation: stale, + observation_delta: {delta_kinds: [outcome]}}).accepted, true); + } +}); + test("no-followup cannot hide an inconsistent vision behind another accepted outcome", () => { const request = {operation: "qualify", obligation, observation_delta: {delta_kinds: ["coverage_backed_no_followup", "new_concrete_blocker"]}};