diff --git a/docs/quota-allocation.md b/docs/quota-allocation.md index 93657babcd..354b3d65fc 100644 --- a/docs/quota-allocation.md +++ b/docs/quota-allocation.md @@ -1283,7 +1283,25 @@ Post-turn accounting protocol: `refresh-state` must run from a separate registry checkout, pass `--delivery-workspace-path `; the path is validated locally and omitted from persisted history. Do not point this option at the canonical - checkout for peer work. + checkout for peer work. In a guarded peer lane the two halves of one + writeback therefore run from different checkouts, because the delivery + worktree carries no project registry state and the spend guard reads the + current workspace: + + ```sh + # 1. from the canonical checkout, attributing the work to the delivery worktree + loopx refresh-state --goal-id --agent-id \ + --delivery-workspace-path ... + + # 2. from the delivery worktree itself + cd + loopx quota spend-slot --goal-id --agent-id ... --execute + ``` + + `refresh-state` run inside the worktree has no active state to refresh, and a + spend run from the canonical checkout is refused with + `return_to_delivery_worktree`. Moving between the two checkouts is the + documented flow, not a workaround. - delivery attribution is not synonymous with Git. A registered single-agent goal whose project has no Git origin records a path-free `local_goal` workspace identity (`loopx:`) when refresh runs inside that diff --git a/docs/reference/protocols/goal-vision-replan-contract-v0.md b/docs/reference/protocols/goal-vision-replan-contract-v0.md index d033131e0b..f414ef1342 100644 --- a/docs/reference/protocols/goal-vision-replan-contract-v0.md +++ b/docs/reference/protocols/goal-vision-replan-contract-v0.md @@ -107,24 +107,33 @@ other's active vision. ### Path Delta -A machine-generated vision packet may include one optional -`goal_path_delta_v0`. It makes a bounded loop's look-back explicit without -adding more inline CLI flags or expanding the heartbeat prompt. The packet is -written through the existing `--agent-vision-json` boundary and is retained in -the same agent-scoped run-history and shared-runtime vision projection: +A machine-generated vision packet may include one optional path delta. It +makes a bounded loop's look-back explicit without adding more inline CLI flags +or expanding the heartbeat prompt. The packet is written through the existing +`--agent-vision-json` boundary and is retained in the same agent-scoped +run-history and shared-runtime vision projection. + +The delta is a nested object of the vision packet: `goal_path_delta_v0` is its +`schema_version`, not the key that carries it. The enclosing field is +`path_delta`, as in the packet below. Nesting the delta under its own schema +name instead of `path_delta` is rejected by the write path rather than +silently dropped. ```json { - "schema_version": "goal_path_delta_v0", - "outcome": "replan", - "prior_assumption": "The current monitor lane would produce acceptance evidence.", - "observed_reality": "Two bounded polls produced no material transition.", - "retained": ["Keep the verified monitor target and evidence refs."], - "changed": ["Create one runnable advancement successor."], - "stopped": ["Stop treating future polling as completion evidence."], - "unresolved_questions": ["Which successor can falsify the new path?"], - "reentry_condition": "Resume the monitor-only wait after successor evidence lands.", - "evidence_refs": ["evidence:monitor-poll-02", "todo:successor-01"] + "agent_id": "", + "path_delta": { + "schema_version": "goal_path_delta_v0", + "outcome": "replan", + "prior_assumption": "The current monitor lane would produce acceptance evidence.", + "observed_reality": "Two bounded polls produced no material transition.", + "retained": ["Keep the verified monitor target and evidence refs."], + "changed": ["Create one runnable advancement successor."], + "stopped": ["Stop treating future polling as completion evidence."], + "unresolved_questions": ["Which successor can falsify the new path?"], + "reentry_condition": "Resume the monitor-only wait after successor evidence lands.", + "evidence_refs": ["evidence:monitor-poll-02", "todo:successor-01"] + } } ``` diff --git a/examples/project/goal-vision-path-delta-smoke.py b/examples/project/goal-vision-path-delta-smoke.py index 3243d8d1f7..60b964c108 100644 --- a/examples/project/goal-vision-path-delta-smoke.py +++ b/examples/project/goal-vision-path-delta-smoke.py @@ -16,6 +16,7 @@ ) from loopx.control_plane.goals.goal_vision import ( # noqa: E402 compact_goal_vision_packet, + misplaced_goal_path_delta_field, normalize_goal_vision_packet, ) from loopx.control_plane.runtime.shared_runtime_refresh_projection import ( # noqa: E402 @@ -115,6 +116,34 @@ def main() -> int: else: raise AssertionError("over-item path delta should fail") + # The delta is nested under `path_delta`; `goal_path_delta_v0` is only its + # schema_version. A packet that nests it under the schema name would drop + # the delta silently, so the write path has to name the accepted key. + assert misplaced_goal_path_delta_field(packet()) is None + assert misplaced_goal_path_delta_field({"path_delta": {"outcome": "wait"}}) is None + assert misplaced_goal_path_delta_field("not-a-packet") is None + misfiled = dict(packet()) + misfiled["goal_path_delta_v0"] = misfiled.pop("path_delta") + assert misplaced_goal_path_delta_field(misfiled) == ( + "goal_path_delta_v0", + "goal_path_delta_v0", + ), misfiled + # A read-path compaction of the misfiled packet loses the delta, which is + # exactly the silent drop the write-path guard exists to prevent. + misfiled_compact = compact_goal_vision_packet(misfiled) + assert "path_delta" not in (misfiled_compact or {}), misfiled_compact + # Placing both keys is not a misfiling: the read path still finds the delta. + both_keys = dict(packet()) + both_keys["goal_path_delta_v0"] = {"schema_version": "goal_path_delta_v0"} + assert misplaced_goal_path_delta_field(both_keys) is None + # One shared key is not enough to call an unrelated object a path delta. + assert ( + misplaced_goal_path_delta_field({"telemetry": {"outcome": "ok"}}) is None + ) + assert misplaced_goal_path_delta_field( + {"telemetry": {"outcome": "ok", "evidence_refs": ["evidence:x"]}} + ) == ("telemetry", "goal_path_delta_v0") + print("goal-vision-path-delta-smoke ok") return 0 diff --git a/loopx/cli_commands/project_lifecycle_refresh_state.py b/loopx/cli_commands/project_lifecycle_refresh_state.py index 62e0941ad1..3985772c84 100644 --- a/loopx/cli_commands/project_lifecycle_refresh_state.py +++ b/loopx/cli_commands/project_lifecycle_refresh_state.py @@ -21,6 +21,11 @@ runtime_capabilities_for_cli_projection, ) from ..control_plane.capability_hooks import PostWritebackHookRegistration +from ..control_plane.goals.goal_vision import ( + GOAL_PATH_DELTA_SCHEMA_VERSION, + GOAL_VISION_PATH_DELTA_FIELD, + misplaced_goal_path_delta_field, +) from ..control_plane.goals.goal_vision_policy import ( GOAL_VISION_ADVANCEMENT_POLICY_CHOICES, ) @@ -384,6 +389,16 @@ def handle_refresh_state_command( agent_vision_packet = json.loads( Path(args.agent_vision_json).expanduser().read_text(encoding="utf-8") ) + misplaced_path_delta = misplaced_goal_path_delta_field(agent_vision_packet) + if misplaced_path_delta: + foreign_field, _schema_version = misplaced_path_delta + raise ValueError( + f"agent vision packet nests {GOAL_PATH_DELTA_SCHEMA_VERSION} under " + f"{foreign_field!r}, so the write path would drop it. Nest the path " + f"delta under {GOAL_VISION_PATH_DELTA_FIELD!r} in the same packet, " + f'e.g. {{"{GOAL_VISION_PATH_DELTA_FIELD}": ' + f'{{"schema_version": "{GOAL_PATH_DELTA_SCHEMA_VERSION}", ...}}}}.' + ) elif inline_vision_packet: agent_vision_packet = inline_vision_packet merge_agent_vision_patch = True diff --git a/loopx/control_plane/goals/goal_vision.py b/loopx/control_plane/goals/goal_vision.py index 1a2f720789..f9cfd76a81 100644 --- a/loopx/control_plane/goals/goal_vision.py +++ b/loopx/control_plane/goals/goal_vision.py @@ -11,6 +11,8 @@ GOAL_VISION_REPLAN_SCHEMA_VERSION = "goal_vision_replan_contract_v0" GOAL_PATH_DELTA_SCHEMA_VERSION = "goal_path_delta_v0" +# The goal-vision packet field that carries the `goal_path_delta_v0` object. +GOAL_VISION_PATH_DELTA_FIELD = "path_delta" GOAL_VISION_FIELD_LIMITS: dict[str, int] = { @@ -114,6 +116,48 @@ def _compact_fallback_declarations(value: Any) -> list[dict[str, str]]: return declarations +def misplaced_goal_path_delta_field(value: Any) -> tuple[str, str] | None: + """Return the foreign key holding a `goal_path_delta_v0` object, if any. + + The path delta is an optional *nested* object of the goal-vision packet, and + the read path only looks under ``path_delta``. A caller that nests it under + the delta's own schema name loses it silently, and the autonomous replan + writeback then rejects the turn with a message that never names the field. + The write path calls this before accepting a packet so that mismatch fails + with the accepted key instead of a generic "no accepted surface" refusal. + + A foreign key counts as a misfiled delta when its value is either labelled + with the delta's schema version or carries at least two of the delta's own + field names. Requiring two fields keeps an unrelated object that happens to + have one shared key from being reported as a path delta. + """ + + if not isinstance(value, dict): + return None + if isinstance(value.get(GOAL_VISION_PATH_DELTA_FIELD), dict): + return None + for field, candidate in value.items(): + if field == GOAL_VISION_PATH_DELTA_FIELD: + continue + if not isinstance(candidate, dict): + continue + if _looks_like_goal_path_delta(candidate): + return str(field), GOAL_PATH_DELTA_SCHEMA_VERSION + return None + + +def _looks_like_goal_path_delta(candidate: dict[str, Any]) -> bool: + if str(candidate.get("schema_version") or "") == GOAL_PATH_DELTA_SCHEMA_VERSION: + return True + # `outcome` lives beside the two limit maps rather than inside them. + delta_fields = ( + {"outcome"} + | set(GOAL_PATH_DELTA_SCALAR_LIMITS) + | set(GOAL_PATH_DELTA_LIST_LIMITS) + ) + return len(delta_fields.intersection(candidate)) >= 2 + + def compact_goal_vision_packet(value: Any) -> dict[str, Any] | None: """Return the public read-path shape of an agent goal-vision packet."""