From ddde4ff6d43a88419ef4b977b85f451d4a2801e8 Mon Sep 17 00:00:00 2001 From: aihop <4408344+hhyykk@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:10:52 +0800 Subject: [PATCH] fix(goal): retain objective adoption for later registered peers Signed-off-by: aihop <4408344+hhyykk@users.noreply.github.com> --- .../rfcs/goal-outcome-refinement-v0.md | 11 +++++ .../rfcs/goal-outcome-refinement-v0.zh-CN.md | 8 ++++ loopx/control_plane/goals/acceptance.py | 4 +- .../goals/acceptance_authority.ts | 6 ++- .../goals/acceptance_contract.ts | 8 ++-- loopx/control_plane/goals/goal_objective.ts | 10 +++-- .../test_goal_acceptance_source_route.py | 26 ++++++++++++ .../test_goal_objective_revision.py | 33 +++++++++++++++ tests/control_plane_ts/goal_objective.test.ts | 40 ++++++++++++++++++- 9 files changed, 134 insertions(+), 12 deletions(-) diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.md index 6060ec873f..8142405c48 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.md @@ -127,6 +127,17 @@ cannot acknowledge a later revision. Old configured verification becomes stale against a changed objective. A successor lifetime cannot inherit its predecessor's objective implicitly. +`goal-acceptance inspect` and the Goal settings acceptance read include current +Agent registration from the resolved source registry. The existing TS objective +projector joins those observations with the retained revision cohort and adoption +records. A later registered peer appears pending before it adopts. Inspection +does not persist registration or adoption, change the objective digest/provider +revision, or authorize execution. Provider-only reads without current registration +facts retain their historical projection. +Registered `@` aliases use the same pending/adopted lifecycle. Once an objective +record contains such an alias, a code rollback must retain a compatible ID reader; +do not delete revision or adoption history to make an older reader accept it. + Revisions are exceptional direction changes, not per-Turn maintenance. Ordinary progress, Todo/route changes, diagnostic feedback and wording polish do not justify a new shared objective. Agents reuse a pending proposal for the same diff --git a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md index 7752379ec8..b99d0b185a 100644 --- a/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md +++ b/docs/architecture/rfcs/goal-outcome-refinement-v0.zh-CN.md @@ -91,6 +91,14 @@ lease acquisition 会拒绝其新执行。在途 Turn 的恢复、续租和释 丢失响应恢复原回执,旧采用操作重放不能确认新版本。旧的配置检查结果在目标 变化后转为 stale;后继 Goal 实例不能自动继承旧目标。 +`goal-acceptance inspect` 和 Goal 设置中的 acceptance 读取,会携带解析后的源 +registry 当前登记成员。既有 TS objective 投影 owner 将这些观察与保留的修订成员、 +采用记录合并。后来登记的 Agent 在采用前显示为待采用。读取不保存成员或采用状态, +不修改目标摘要或 provider revision,也不授予执行权限。没有当前登记事实的 +provider-only 读取保留原有历史投影。 +已登记的 `@` 别名使用相同的待采用/已采用流程。目标记录包含此类别名后,回退代码 +必须保留兼容的 ID reader;不能删除修订或采用历史来迁就旧 reader。 + 修订只用于必要的方向变化,不是每轮维护。普通进展、Todo/路线调整、诊断反馈和 措辞润色不应生成共享目标新版本。相同目标的待审提案复用,不反复打扰 owner 或轮询。 owner 判断语义必要性;运行时拒绝相同或仅空白不同的目标,不伪装成语义等价检测器。 diff --git a/loopx/control_plane/goals/acceptance.py b/loopx/control_plane/goals/acceptance.py index 15b77b477a..cbc43b3ec7 100644 --- a/loopx/control_plane/goals/acceptance.py +++ b/loopx/control_plane/goals/acceptance.py @@ -199,10 +199,10 @@ def inspect_goal_acceptance( validation_workspace_path: Path | None = None, ) -> dict[str, Any]: """Read one canonical basis; command declarations stay inside the host.""" - registry_path, _, route = _routing(registry_path, goal_id, runtime_root, agent_id) + registry_path, goal, route = _routing(registry_path, goal_id, runtime_root, agent_id) basis = _result( _INSPECT_METHOD, - {**route, + {**route, "registered_agents": registered_agent_ids_for_goal(goal), **({"agent_id": agent_id} if agent_id is not None else {}), **({"todo_id": todo_id} if todo_id is not None else {}), **({"include_work_details": False} if not include_work_details else {})}, diff --git a/loopx/control_plane/goals/acceptance_authority.ts b/loopx/control_plane/goals/acceptance_authority.ts index f7ec67e073..995137180d 100644 --- a/loopx/control_plane/goals/acceptance_authority.ts +++ b/loopx/control_plane/goals/acceptance_authority.ts @@ -560,6 +560,7 @@ export async function inspectGoalAcceptance( goalRef?: WireExactGoalRef, includeWorkDetails = true, agentId?: string, + registeredAgents?: unknown, ): Promise { const head = await store.loadAuthority(); if (head.status !== "loaded") return source(store, {...head}); @@ -587,7 +588,7 @@ export async function inspectGoalAcceptance( completion_requirements: acceptanceCompletionRequirements(head.head, goalId, todoId)}), ...(agentId === undefined || currentGoalObjective(head.head) === null ? {} : {objective_work_digest: goalObjectiveWorkDigest(head.head, agentId), goal_objective_guard: goalObjectiveWorkGuard(head.head, agentId)}), - goal_acceptance_contract: projectGoalAcceptance(head.head, goalId)}); + goal_acceptance_contract: projectGoalAcceptance(head.head, goalId, registeredAgents)}); } async function local(value: unknown, kind: "inspect" | "configure" | "verify" | "lifecycle" | "refine" | "objective"): Promise { @@ -606,7 +607,8 @@ async function local(value: unknown, kind: "inspect" | "configure" | "verify" | request.todo_id == null ? undefined : requireAuthorityStoreId(request.todo_id, "todo_id"), request.goal_ref === undefined ? undefined : parseWireExactGoalRef(request.goal_ref, "acceptance request goal_ref"), request.include_work_details !== false, - request.agent_id === undefined ? undefined : requireAuthorityStoreId(request.agent_id, "agent_id")); + request.agent_id === undefined ? undefined : requireAuthorityStoreId(request.agent_id, "agent_id"), + request.registered_agents); } if (kind === "configure") return configureGoalAcceptance(store, request); if (kind === "refine") return refineGoalAcceptanceOutcome(store, request); diff --git a/loopx/control_plane/goals/acceptance_contract.ts b/loopx/control_plane/goals/acceptance_contract.ts index 9fcceb0e8a..85557506e9 100644 --- a/loopx/control_plane/goals/acceptance_contract.ts +++ b/loopx/control_plane/goals/acceptance_contract.ts @@ -588,10 +588,10 @@ export class GoalAcceptanceRead { contract_digest: receipt.contract_digest, todo_id: receipt.todo_id, results: receipt.results} : null}; } - contract(): JsonObject { + contract(registeredAgents?: unknown): JsonObject { const checks = structuredClone(this.checks()); const outcomes = projectGoalOutcomes(this.head, this.goalId, checks); - const objective = projectGoalObjective(this.head); + const objective = projectGoalObjective(this.head, registeredAgents); return {...checks, ...(outcomes === null ? {} : {outcome_requirements: outcomes}), ...(objective === null ? {} : {goal_objective: objective})}; } @@ -619,8 +619,8 @@ export class GoalAcceptanceRead { /** Outcomes extend the existing acceptance read, even before checks are configured. * No declaration preserves the original projection byte-for-byte. */ -export function projectGoalAcceptance(head: JsonObject, goalId: string): JsonObject { - return new GoalAcceptanceRead(head, goalId).contract(); +export function projectGoalAcceptance(head: JsonObject, goalId: string, registeredAgents?: unknown): JsonObject { + return new GoalAcceptanceRead(head, goalId).contract(registeredAgents); } /** Explicit selection and confirmed bindings survive mutable work fields. diff --git a/loopx/control_plane/goals/goal_objective.ts b/loopx/control_plane/goals/goal_objective.ts index 9614753416..fff134c4bd 100644 --- a/loopx/control_plane/goals/goal_objective.ts +++ b/loopx/control_plane/goals/goal_objective.ts @@ -5,6 +5,7 @@ import {canonicalAuthorityObject, canonicalAuthoritySha256, AuthorityStoreProtoc import {indexCoordinationProjectionTodos} from "../coordination/coordination_projection.ts"; import {BARE_SHA256_PATTERN} from "../content_digest.ts"; import {readGoalAcceptanceLifecycle, sameExactGoalRef, parseWireExactGoalRef, type WireExactGoalRef} from "./acceptance_lifecycle.ts"; +import {normalizeRegisteredTodoAgents} from "../coordination/todo_agents.ts"; export const GOAL_OBJECTIVE_SCHEMA = "goal_objective_revision_v0"; export const GOAL_OBJECTIVE_REVIEW_REQUIRED = "goal_objective_review_required"; @@ -28,7 +29,8 @@ function keys(row: JsonObject, names: string[]): void { function agents(value: unknown): string[] { requireValue(Array.isArray(value) && value.length <= 128, "registered Agents must be a bounded list"); const result = value.map(v => text(v, "Agent", 128)); - requireValue(result.every(v => /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(v)) && new Set(result).size === result.length, "Agent identities must be unique safe identifiers"); + // Retain historical ID forms while accepting the registry's @ aliases. + requireValue(result.every(v => /^[A-Za-z0-9][A-Za-z0-9_.:@-]*$/.test(v)) && new Set(result).size === result.length, "Agent identities must be unique safe identifiers"); return result; } export function readGoalObjective(head: JsonObject): GoalObjectiveState | null { @@ -83,11 +85,13 @@ export function goalObjectiveWorkGuard(head: JsonObject, agent: string): JsonObj revision: state.revisions.length, digest: state.revisions.at(-1)!.digest, read_action: `loopx --format json goal-acceptance inspect --goal-id ${head.goal_id} --agent-id ${agent}`}; } -export function projectGoalObjective(head: JsonObject): JsonObject | null { +export function projectGoalObjective(head: JsonObject, registeredAgents?: unknown): JsonObject | null { + requireValue(registeredAgents === undefined || Array.isArray(registeredAgents), "registered_agents must be an array"); + const currentPeers = registeredAgents === undefined ? [] : normalizeRegisteredTodoAgents(registeredAgents); const state = currentGoalObjective(head); if (!state) return null; const current = state.revisions.at(-1)!; - const peers = [...new Set([...state.registered_agents, ...Object.keys(state.adoptions)])]; + const peers = [...new Set([...state.registered_agents, ...Object.keys(state.adoptions), ...currentPeers])]; return {...current, original_objective: state.original_objective, peer_adoption: peers.map(agent => ({agent_id: agent, revision: state.adoptions[agent]?.revision ?? null, current: !objectiveAdoptionRequired(state, agent)})), acceptance_gaps: peers.filter(agent => objectiveAdoptionRequired(state, agent)).map(agent => ({ diff --git a/tests/control_plane/test_goal_acceptance_source_route.py b/tests/control_plane/test_goal_acceptance_source_route.py index d764dd1658..53be57e9d0 100644 --- a/tests/control_plane/test_goal_acceptance_source_route.py +++ b/tests/control_plane/test_goal_acceptance_source_route.py @@ -70,6 +70,32 @@ def global_cli(*arguments, override=None): server.server_close() +def test_objective_inspection_projects_later_source_peer_through_global_app(source_app): + project, _, source, global_cli, request, source_registry, _ = source_app + document = project / "objective.json" + document.write_text(json.dumps({"objective": "Maintain the original export and recovery", + "reason": "The export is now maintained", "source_ref": "message:owner"})) + code, revised = source("revise-objective", "--document", str(document), + "--operation-id", "source-objective", "--expected-provider-revision", + source("inspect")[1]["provider_revision"], "--execute") + assert code == 0, revised + payload = json.loads(source_registry.read_text()) + payload["goals"][0]["coordination"]["registered_agents"].append("later-source-peer") + source_registry.write_text(json.dumps(payload)) + global_before = (project.parent / "global.json").read_bytes() + + code, inspected = global_cli("inspect") + assert code == 0, inspected + status, app = request("GET", f"/api/chat/goal-acceptance?goal_id={GOAL}") + assert status == 200, app + assert app["provider_revision"] == inspected["provider_revision"] == revised["provider_revision"] + objective = app["goal_acceptance_contract"]["goal_objective"] + assert objective == inspected["goal_acceptance_contract"]["goal_objective"] + assert {"agent_id": "later-source-peer", "revision": None, "current": False} in objective["peer_adoption"] + assert source("inspect", "--agent-id", "later-source-peer")[1]["goal_objective_guard"]["allowed"] is False + assert (project.parent / "global.json").read_bytes() == global_before + + def test_source_read_configure_verify_and_explicit_cli_override(source_app): project, document, source, global_cli, request, _, shared = source_app before = global_cli("inspect", override=shared)[1] diff --git a/tests/control_plane/test_goal_objective_revision.py b/tests/control_plane/test_goal_objective_revision.py index 0b0a23144b..525297165a 100644 --- a/tests/control_plane/test_goal_objective_revision.py +++ b/tests/control_plane/test_goal_objective_revision.py @@ -32,6 +32,39 @@ def apply_objective(project, cli, objective): assert service.apply(prepared["proposal"]["proposal_id"])["proposal"]["status"] == "applied" +@pytest.mark.parametrize("peer_id", ["later-peer", "later@host"]) +def test_later_registered_peer_is_visible_and_requires_its_own_adoption(acceptance_goal, peer_id): + project, _, cli, _ = acceptance_goal + prepare_source(project) + apply_objective(project, cli, "Maintain the accepted export") + before = cli("inspect")[1] + registry_path = project.parent / "registry.json" + registry = json.loads(registry_path.read_text()) + registry["goals"][0]["coordination"]["registered_agents"].append(peer_id) + registry_path.write_text(json.dumps(registry)) + + code, inspected = cli("inspect", "--agent-id", peer_id) + assert code == 0, inspected + assert inspected["provider_revision"] == before["provider_revision"], "inspection cannot persist membership or adoption" + assert inspected["goal_objective_guard"]["allowed"] is False + objective = inspected["goal_acceptance_contract"]["goal_objective"] + assert objective["digest"] == before["goal_acceptance_contract"]["goal_objective"]["digest"] + peer = next((row for row in objective["peer_adoption"] if row["agent_id"] == peer_id), None) + assert peer == {"agent_id": peer_id, "revision": None, "current": False} + assert any(row["agent_id"] == peer_id for row in objective["acceptance_gaps"]) + assert cli("inspect")[1]["goal_acceptance_contract"]["goal_objective"]["peer_adoption"] == objective["peer_adoption"] + adoption = project / "later-adoption.json" + adoption.write_text(json.dumps({"revision": objective["revision"], "digest": objective["digest"], + "work_digest": inspected["objective_work_digest"], "review_summary": "Reviewed my current work against the shared objective."})) + code, adopted = cli("adopt-objective", "--agent-id", peer_id, "--document", str(adoption), + "--operation-id", "later-peer-adoption", "--expected-provider-revision", inspected["provider_revision"], "--execute") + assert code == 0, adopted + current = cli("inspect", "--agent-id", peer_id)[1] + assert current["goal_objective_guard"] is None + assert next(row for row in current["goal_acceptance_contract"]["goal_objective"]["peer_adoption"] + if row["agent_id"] == peer_id)["current"] is True + + @pytest.mark.parametrize("acceptance_kind", ["configured-checks", "cumulative-outcome"]) def test_objective_and_acceptance_guidance_share_real_inline_goal_read(acceptance_goal, acceptance_kind): project, acceptance_file, cli, run = acceptance_goal diff --git a/tests/control_plane_ts/goal_objective.test.ts b/tests/control_plane_ts/goal_objective.test.ts index 4b1d06a15f..1e60acd39d 100644 --- a/tests/control_plane_ts/goal_objective.test.ts +++ b/tests/control_plane_ts/goal_objective.test.ts @@ -7,7 +7,7 @@ import {FileAuthorityStore} from "../../loopx/control_plane/coordination/file_au import {SqliteAuthorityStore} from "../../loopx/control_plane/coordination/sqlite_authority_store.ts"; import {authorityProjectionFixture} from "./authority_projection_fixture.ts"; import {updateGoalObjective, inspectGoalAcceptance} from "../../loopx/control_plane/goals/acceptance_authority.ts"; -import {goalObjectiveWorkGuard, goalObjectiveWorkDigest, readGoalObjective, reviseGoalObjective} from "../../loopx/control_plane/goals/goal_objective.ts"; +import {goalObjectiveWorkGuard, goalObjectiveWorkDigest, readGoalObjective, reviseGoalObjective, projectGoalObjective, adoptGoalObjective} from "../../loopx/control_plane/goals/goal_objective.ts"; import {projectCoreGoal} from "../../loopx/control_plane/work_items/replan_context.ts"; import {projectGoalAcceptance, goalAcceptanceWorkDigest} from "../../loopx/control_plane/goals/acceptance_contract.ts"; import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; @@ -19,6 +19,44 @@ const document = {objective: "Deliver a reliable maintained service", reason: "T const request = {goal_id: goal, actor_agent_id: null, operation: "revise-objective", operation_id: "revision-1", document, source_objective: {registry_objective: "Bootstrap the project"}, registered_agents: ["agent-a", "agent-b"]}; +test("current membership is read-only evidence and preserves historical peers and adoption", () => { + const head = fixture(); + head.goal_objective = reviseGoalObjective(head, null, "Bootstrap the project", request.registered_agents, document); + const revision = readGoalObjective(head)!.revisions.at(-1)!; + head.goal_objective = adoptGoalObjective(head, "agent-a", {revision: revision.revision, digest: revision.digest, + work_digest: goalObjectiveWorkDigest(head, "agent-a"), review_summary: "Reviewed the existing work."}); + const before = structuredClone(head), retained = projectGoalObjective(head); + const current = projectGoalObjective(head, ["later-peer"])!; + assert.deepEqual(current.peer_adoption, [ + {agent_id: "agent-a", revision: 1, current: true}, + {agent_id: "agent-b", revision: null, current: false}, + {agent_id: "later-peer", revision: null, current: false}, + ]); + assert.deepEqual(projectGoalObjective(head, []), retained); + assert.deepEqual(projectGoalObjective(head), retained, "callers without live membership keep the retained projection"); + assert.equal(goalObjectiveWorkGuard(head, "later-peer")?.allowed, false); + const roster = Array.from({length: 130}, (_, index) => `peer-${index}`); + assert.equal((projectGoalObjective(head, roster)!.peer_adoption as unknown[]).length, 132); + assert.deepEqual(head, before, "reads cannot create revisions, membership or adoption"); +}); + +test("current registration does not normalize or erase historical objective IDs", () => { + const head = fixture(); + head.goal_objective = reviseGoalObjective(head, null, "Bootstrap the project", ["1Legacy", "Legacy.Owner"], document); + const projected = projectGoalObjective(head, ["agent-a"])!; + assert.deepEqual((projected.peer_adoption as JsonObject[]).map(peer => peer.agent_id), + ["1Legacy", "Legacy.Owner", "agent-a"]); + assert.deepEqual(readGoalObjective(head)!.registered_agents, ["1Legacy", "Legacy.Owner"]); +}); + +for (const roster of [null, "agent-a", [1], ["invalid/peer"], ["agent-a", "agent-a"]]) { + test(`objective membership rejects malformed source facts: ${JSON.stringify(roster)}`, () => { + const head = fixture(); + head.goal_objective = reviseGoalObjective(head, null, "Bootstrap the project", request.registered_agents, document); + assert.throws(() => projectGoalObjective(head, roster), /registered_agents|public-safe agent/); + }); +} + for (const provider of ["file", "sqlite"] as const) test(`${provider}: shared revisions, competing writers, peer adoption and restart`, async t => { const root = await mkdtemp(join(tmpdir(), "goal-objective-")); t.after(() => rm(root, {recursive: true, force: true})); const open = () => provider === "file" ? new FileAuthorityStore(root, goal) : new SqliteAuthorityStore(root, goal);