diff --git a/examples/bootstrap-command-pack-smoke.py b/examples/bootstrap-command-pack-smoke.py index fef295914..85a09a2e3 100644 --- a/examples/bootstrap-command-pack-smoke.py +++ b/examples/bootstrap-command-pack-smoke.py @@ -10,6 +10,12 @@ REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from loopx.control_plane.testing.continuation_verb_guard import ( # noqa: E402 + assert_no_continuation_verb, +) def run_json(*args: str, env: dict[str, str] | None = None) -> dict[str, object]: @@ -420,6 +426,220 @@ def test_start_goal_guided_previews_transaction_without_mutation() -> None: assert_fixture_unchanged(snapshot) +def test_start_goal_guided_blocks_orphaned_goal_state() -> None: + """A reset that deleted the registry entry must not reopen the same goal.""" + + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) / "reset-project" + project.mkdir() + goal_id = "reset-goal" + snapshot = write_connected_goal_fixture( + project, goal_id=goal_id, agent_id="codex-retired" + ) + state_file = project / ".codex" / "goals" / goal_id / "ACTIVE_GOAL_STATE.md" + registry = project / ".loopx" / "registry.json" + registry.write_text( + json.dumps({"schema_version": "0.1", "goals": []}, indent=2) + "\n", + encoding="utf-8", + ) + + payload = run_json( + "start-goal", + "--guided", + "--project", + str(project), + "--goal-id", + goal_id, + "--host-surface", + "codex-app", + "--goal-text", + "Continue the interrupted refactor", + ) + + connection = payload["project_connection"] + assert connection["connection_state"] == "orphaned_goal_state", connection + assert connection["goal_found"] is False, connection + assert connection["bootstrap_continuation_allowed"] is False, connection + assert connection["orphaned_goal_state"]["state_file_routes"] == [ + f".codex/goals/{goal_id}/ACTIVE_GOAL_STATE.md" + ], connection + + transaction = payload["guided_transaction"] + assert transaction["blocked_by"] == "orphaned_goal_state", transaction + assert [step["id"] for step in transaction["ordered_steps"]] == [ + "inspect_connection", + "resolve_orphaned_goal_state", + ], transaction + + gate = transaction["orphaned_goal_state_gate"] + assert gate["schema_version"] == "loopx_orphaned_goal_state_gate_v0", gate + assert gate["forbidden_until_resolved"] == [ + "bootstrap", + "agent_registration", + "todo_write", + "quota_spend", + "host_loop_activation", + ], gate + for route in gate["resolution_routes"]: + assert route["mutates"] is False, route + assert "--execute" not in route["command"], route + assert route["command"].splitlines()[-1].startswith("loopx "), route + + commands = payload["command_pack"]["commands"] + for key in ( + "goal_start_connect_if_needed", + "bootstrap_after_user_confirmation", + "goal_start_plan_prompt", + ): + assert commands[key] is None, key + + safety = payload["safety_contract"] + assert safety["force_bootstrap_allowed"] is False, safety + assert safety["writes_state_file"] is False, safety + assert safety["orphaned_goal_state_blocks_continuation"] is True, safety + assert_packet_summary_refs( + payload, + packet_kind="guided_start_goal", + compact_projection_default=True, + ) + assert_fixture_unchanged({registry: registry.read_text(), state_file: snapshot[state_file]}) + + +def test_start_goal_guided_fences_orphaned_state_for_every_absence_route() -> None: + """No registry authority plus surviving state must fence, however that came about. + + One real CLI run per absence shape: a deleted registry file, a registry that + declares no goal, and a registry that no longer parses each reach the fence + through a different return in the inspection. + """ + + for shape, registry_text, absence in ( + ("missing", None, "not_connected"), + ("empty", '{"schema_version": "0.1", "goals": []}\n', "registry_without_goal"), + ("invalid", '{"schema_version": "0.1", "goals": [', "registry_invalid"), + ): + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) / "reset-project" + state_file = project / ".codex" / "goals" / "reset-goal" / "ACTIVE_GOAL_STATE.md" + state_file.parent.mkdir(parents=True) + state_text = "# Orphaned goal state written by a retired lane\n" + state_file.write_text(state_text, encoding="utf-8") + if registry_text is not None: + registry = project / ".loopx" / "registry.json" + registry.parent.mkdir(parents=True) + registry.write_text(registry_text, encoding="utf-8") + + payload = run_json( + "start-goal", + "--guided", + "--project", + str(project), + "--goal-id", + "reset-goal", + "--host-surface", + "codex-app", + "--goal-text", + "Continue the interrupted refactor", + ) + + connection = payload["project_connection"] + assert connection["connection_state"] == "orphaned_goal_state", (shape, connection) + assert connection["absent_connection_state"] == absence, (shape, connection) + transaction = payload["guided_transaction"] + assert transaction["blocked_by"] == "orphaned_goal_state", (shape, transaction) + assert [step["id"] for step in transaction["ordered_steps"]] == [ + "inspect_connection", + "resolve_orphaned_goal_state", + ], (shape, transaction) + commands = payload["command_pack"]["commands"] + for key in ( + "goal_start_connect_if_needed", + "goal_start_refresh_state", + "goal_start_host_loop_activation", + "goal_start_quota_should_run", + "goal_start_plan_prompt", + ): + assert commands[key] is None, (shape, key) + assert state_file.read_text(encoding="utf-8") == state_text, shape + + +def test_unparseable_registry_without_orphaned_state_keeps_onboarding() -> None: + """A broken registry is its own repair action; the fence must not stand in for it.""" + + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) / "reset-project" + registry = project / ".loopx" / "registry.json" + registry.parent.mkdir(parents=True) + registry.write_text('{"schema_version": "0.1", "goals": [', encoding="utf-8") + + payload = run_json( + "start-goal", + "--guided", + "--project", + str(project), + "--goal-id", + "reset-goal", + "--host-surface", + "codex-app", + "--goal-text", + "Continue the interrupted refactor", + ) + + connection = payload["project_connection"] + assert connection["connection_state"] == "registry_invalid", connection + assert "orphaned_goal_state" not in connection, connection + assert connection["reason"], connection + assert payload["command_pack"]["commands"]["goal_start_connect_if_needed"], payload + + +def test_fenced_project_surfaces_offer_no_continuation() -> None: + """No real CLI surface over orphaned state may spell out a runnable mutation. + + The guided packet with its full command pack, and the standalone command pack + with its rendered message, are both executed by hosts. Each carried + ``register-agent --execute`` in nested fields the top-level fence never read. + """ + + with tempfile.TemporaryDirectory() as tmp: + project = Path(tmp) / "reset-project" + state_file = project / ".codex" / "goals" / "reset-goal" / "ACTIVE_GOAL_STATE.md" + state_file.parent.mkdir(parents=True) + state_file.write_text( + "# Orphaned goal state written by a retired lane\n", encoding="utf-8" + ) + guided = run_json( + "start-goal", + "--guided", + "--include-command-pack-detail", + "--project", + str(project), + "--goal-id", + "reset-goal", + "--host-surface", + "codex-app", + "--goal-text", + "Continue the interrupted refactor", + ) + standalone = run_json( + "bootstrap-command-pack", + "--project", + str(project), + "--goal-id", + "reset-goal", + "--host-surface", + "codex-app", + ) + + assert guided["project_connection"]["connection_state"] == "orphaned_goal_state" + assert standalone["project_connection"]["connection_state"] == "orphaned_goal_state" + assert_no_continuation_verb(guided, source="guided with command pack detail") + assert_no_continuation_verb(standalone, source="standalone command pack") + # Without a surviving read-only route the guard above could pass on a + # packet that tells the operator nothing at all. + assert guided["command_pack"]["commands"]["status"] + assert state_file.is_file() + + def test_start_goal_guided_requires_explicit_goal_for_multi_goal_project() -> None: with tempfile.TemporaryDirectory() as tmp: project = Path(tmp) / "multi-goal-project" @@ -816,6 +1036,10 @@ def main() -> int: test_missing_project_stops_before_mutation() test_goal_text_invocation_plans_ranked_todos_before_activation() test_start_goal_guided_previews_transaction_without_mutation() + test_start_goal_guided_blocks_orphaned_goal_state() + test_start_goal_guided_fences_orphaned_state_for_every_absence_route() + test_unparseable_registry_without_orphaned_state_keeps_onboarding() + test_fenced_project_surfaces_offer_no_continuation() test_start_goal_guided_requires_explicit_goal_for_multi_goal_project() test_connected_project_reuses_existing_state() test_linked_git_worktree_reuses_canonical_source_registry() diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index efc9b552e..c548df0c8 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -14,6 +14,14 @@ build_issue_fix_goal_command_templates, ) from .control_plane.effect_program import effect_program_from_ordered_steps +from .control_plane.goals.orphaned_goal_state import ( + ORPHANED_GOAL_STATE_CONNECTION, + absent_goal_connection, + fence_command_pack, + fenced_standalone_message, + guided_fence, + render_guided_lines, +) from .control_plane.goals.start_contract import ( build_goal_start_contract, build_goal_start_prompt, @@ -571,30 +579,24 @@ def inspect_bootstrap_connection( } if registry_error: - return { - **base_connection, - "registry_exists": registry_exists, - "goal_id": inferred_goal_id, - "goal_found": False, - "state_file": str(state_file), - "state_file_exists": state_file.exists(), - "connection_state": "registry_invalid", - "mutation_confirmation_required": True, - "reason": registry_error, - } + return absent_goal_connection( + base_connection=base_connection, + goal_id=inferred_goal_id, + state_file=state_file, + registry_exists=registry_exists, + absence_connection="registry_invalid", + absence_reason=registry_error, + ) if not registry: - return { - **base_connection, - "registry_exists": False, - "goal_id": inferred_goal_id, - "goal_found": False, - "state_file": str(state_file), - "state_file_exists": state_file.exists(), - "connection_state": "not_connected", - "mutation_confirmation_required": True, - "reason": "project-local .loopx/registry.json is missing", - } + return absent_goal_connection( + base_connection=base_connection, + goal_id=inferred_goal_id, + state_file=state_file, + registry_exists=False, + absence_connection="not_connected", + absence_reason="project-local .loopx/registry.json is missing", + ) goals = registry_goals(registry) selected_goal_id, selected_goal = _select_goal(goals, goal_id) @@ -608,18 +610,15 @@ def inspect_bootstrap_connection( state_file = goal_state_file or fallback_state_file if selected_goal is None: - return { - **base_connection, - "registry_exists": True, - "goal_id": resolved_goal_id, - "goal_found": False, - "known_goal_ids": [str(goal.get("id")) for goal in goals], - "state_file": str(state_file), - "state_file_exists": state_file.exists(), - "connection_state": "registry_without_goal", - "mutation_confirmation_required": True, - "reason": "registry exists but no matching goal entry was found", - } + return absent_goal_connection( + base_connection=base_connection, + goal_id=resolved_goal_id, + state_file=state_file, + registry_exists=True, + absence_connection="registry_without_goal", + absence_reason="registry exists but no matching goal entry was found", + known_goal_ids=[str(goal.get("id")) for goal in goals], + ) if not selected_goal.get("state_file"): return { @@ -1076,6 +1075,7 @@ def build_loopx_bootstrap_command_pack( "host_loop_activation_allowed": activation_allowed, }, } + fence_command_pack(payload, command_prefix=command_prefix) if normalized_thread_id: payload["thread_id"] = normalized_thread_id payload["thread_agent_binding"] = thread_binding_projection @@ -1709,6 +1709,10 @@ def rerun_start_goal(selected_agent_id: str) -> str: detail_command=detail_command, ) ) + orphaned_gate = command_pack.get("orphaned_goal_state") + if isinstance(orphaned_gate, dict): + guided_transaction.update(guided_fence(orphaned_gate)) + guided_transaction.pop("identity_selection_gate", None) payload = { "ok": True, "schema_version": GUIDED_START_SCHEMA_VERSION, @@ -1731,6 +1735,7 @@ def rerun_start_goal(selected_agent_id: str) -> str: "spends_quota": False, "mutation_commands_are_previewed": True, "force_bootstrap_allowed": False, + "orphaned_goal_state_blocks_continuation": isinstance(orphaned_gate, dict), }, } if command_pack.get("thread_id"): @@ -1887,6 +1892,7 @@ def actionable_shell_command(value: Any) -> str: + "\n".join(choices) + "\n" ) + orphan_gate_lines = render_guided_lines(transaction) host_gate = transaction.get("host_surface_selection_gate") host_gate = host_gate if isinstance(host_gate, dict) else {} host_gate_lines = "" @@ -1916,6 +1922,7 @@ def actionable_shell_command(value: Any) -> str: {chr(10).join(step_lines)} {host_gate_lines} {goal_gate_lines} +{orphan_gate_lines} {identity_gate_lines} ## Todo Preservation @@ -1927,6 +1934,8 @@ def actionable_shell_command(value: Any) -> str: def render_loopx_bootstrap_command_pack_message(payload: dict[str, Any]) -> str: connection = payload.get("project_connection") connection = connection if isinstance(connection, dict) else {} + if connection.get("connection_state") == ORPHANED_GOAL_STATE_CONNECTION: + return fenced_standalone_message(payload) commands = payload.get("commands") commands = commands if isinstance(commands, dict) else {} next_step = payload.get("recommended_next_step") diff --git a/loopx/control_plane/goals/orphaned_goal_state.py b/loopx/control_plane/goals/orphaned_goal_state.py new file mode 100644 index 000000000..3a09dc83e --- /dev/null +++ b/loopx/control_plane/goals/orphaned_goal_state.py @@ -0,0 +1,302 @@ +"""Guard the goal-start flow when Goal state outlives its registry entry. + +A project ``ACTIVE_GOAL_STATE.md`` for a goal id the project registry no longer +declares is not the ordinary "nothing to connect yet" case: continuing would hand +a fresh lane write authority over state an earlier lane left behind, under the +same human-readable id. The routes below are the project goal-state roots +``loopx.state_backup`` archives, so a reset that moved or kept one of them is +detected rather than silently reconnected. + +This module owns the whole fence so ``loopx.bootstrap_command_pack`` keeps only +its wiring: one entry point through which every absence route passes, the +operator-facing gate, the packet fields that must disappear over orphaned state, +and the guided transaction's blocking shape. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from ...project_prompt import shell_arg + +ACTIVE_GOAL_STATE_FILENAME = "ACTIVE_GOAL_STATE.md" + +ORPHANED_GOAL_STATE_CONNECTION = "orphaned_goal_state" + +ORPHANED_GOAL_STATE_GATE_SCHEMA_VERSION = "loopx_orphaned_goal_state_gate_v0" + +# Project-local goal state has been written under each of these roots, so a goal +# absent from the registry can still own durable state in any of them. Ordered +# current route first; every match is reported, never merged or copied. +GOAL_STATE_ROOTS: tuple[tuple[str, ...], ...] = ( + (".loopx", "goals"), + (".codex", "goals"), + (".claude", "goals"), + (".local", "goals"), +) + +ORPHANED_GOAL_STATE_REASON = ( + "the project registry has no entry for this goal id, but goal state written for " + "it still exists; connecting again would create a second authority over that state" +) + +FORBIDDEN_UNTIL_RESOLVED = ( + "bootstrap", + "agent_registration", + "todo_write", + "quota_spend", + "host_loop_activation", +) + +# Over orphaned state the packet is rebuilt from what is safe to run rather than +# from verbs to suppress: `status` is the only command-pack entry point left, and +# the resolution gate below is the only other source of runnable commands. A key +# a future builder adds to the pack is then withheld by default instead of having +# to be remembered here. +FENCED_COMMAND_KEYS = ("status",) + +# Subtrees whose fields carry a continuation verb -- registration, activation +# input and steps, the heartbeat prompt, and the planner's post-PR routes. They +# are dropped whole; every consumer reads them through an isinstance guard. +# `available_slash_commands` and `onboarding_hint` are dropped for the same +# reason as the rendered text: both spell out the verbs this fence withholds. +FENCED_SUBTREES = ( + "available_slash_commands", + "host_loop_activation", + "onboarding_hint", +) + +FENCED_CONTRACT_KEYS = ("activation", "domain_route_hints", "execution_invariants") + + +def orphaned_goal_state_routes(project: Path, goal_id: str) -> list[str]: + """List existing state files for ``goal_id`` as project-relative routes. + + Routes stay relative because this projection reaches host-facing packets; an + absolute path would carry the operator's filesystem into those artifacts. + """ + + return [ + path.relative_to(project).as_posix() + for path in ( + project.joinpath(*root).joinpath(goal_id, ACTIVE_GOAL_STATE_FILENAME) + for root in GOAL_STATE_ROOTS + ) + if path.is_file() + ] + + +def orphaned_goal_state_projection(project: Path, goal_id: str) -> dict[str, Any] | None: + """Return the orphaned-state fact for ``goal_id``, or ``None`` when nothing is orphaned.""" + + routes = orphaned_goal_state_routes(project, goal_id) + if not routes: + return None + return { + "schema_version": ORPHANED_GOAL_STATE_GATE_SCHEMA_VERSION, + "goal_id": goal_id, + "state_file_routes": routes, + "reason": ORPHANED_GOAL_STATE_REASON, + } + + +def absent_goal_connection( + *, + base_connection: dict[str, Any], + goal_id: str, + state_file: Path, + registry_exists: bool, + absence_connection: str, + absence_reason: str, + known_goal_ids: list[str] | None = None, +) -> dict[str, Any]: + """Project "no registry entry carries this goal", fenced if its state survives. + + This is the only way the goal-start flow states an absence, so an unparseable + registry file, a missing or empty one, and a readable registry with no + matching entry cannot drift apart on the safety question. The fields are the + ones the packet carried before this fence existed, so an ordinary absence -- + including a fresh project -- keeps its old onboarding continuation. + """ + + connection: dict[str, Any] = { + **base_connection, + "registry_exists": registry_exists, + "goal_id": goal_id, + "goal_found": False, + "state_file": str(state_file), + "state_file_exists": state_file.exists(), + "mutation_confirmation_required": True, + "connection_state": absence_connection, + "reason": absence_reason, + } + if known_goal_ids is not None: + connection["known_goal_ids"] = known_goal_ids + # An empty project would resolve the state roots against the interpreter's + # working directory, which is not a project this packet may speak for. + project = str(base_connection.get("project") or "") + projection = orphaned_goal_state_projection(Path(project), goal_id) if project else None + if projection is None: + return connection + return { + **connection, + "orphaned_goal_state": projection, + "connection_state": ORPHANED_GOAL_STATE_CONNECTION, + # Which absence produced the fence stays attributable: a registry that + # cannot be parsed is a different operator action from a deleted entry. + "absent_connection_state": absence_connection, + "absent_reason": absence_reason, + "bootstrap_continuation_allowed": False, + "reason": ORPHANED_GOAL_STATE_REASON, + } + + +def orphaned_goal_state_gate( + projection: dict[str, Any], + *, + project: str, + command_prefix: str, + status_command: str, +) -> dict[str, Any]: + """Project the next steps orphaned state allows: inspect, then back up.""" + + return { + **projection, + "resolution_routes": [ + { + "route": "inspect_registry_and_state", + "mutates": False, + "command": status_command, + }, + { + "route": "preview_state_backup", + "mutates": False, + "command": "\n".join( + [ + f"cd {shell_arg(project)}", + f"{command_prefix} backup-state --project . " + "--current-project-only", + ] + ), + }, + ], + "execution_boundary": ( + "backup-state writes nothing until the operator adds --execute, which is " + "the auditable archive the resolution depends on" + ), + "forbidden_until_resolved": list(FORBIDDEN_UNTIL_RESOLVED), + } + + +def fence_command_pack(command_pack: dict[str, Any], *, command_prefix: str) -> None: + """Leave a command pack over orphaned state with no mutation continuation.""" + + projection = command_pack["project_connection"].get("orphaned_goal_state") + if projection is None: + return + gate = orphaned_goal_state_gate( + projection, + project=str(command_pack.get("project") or ""), + command_prefix=command_prefix, + status_command=str(command_pack["commands"]["status"]), + ) + commands = command_pack["commands"] + for key in list(commands): + if key not in FENCED_COMMAND_KEYS: + commands[key] = None + for key in FENCED_SUBTREES: + command_pack[key] = None + contract = command_pack.get("goal_start_contract") + if isinstance(contract, dict): + for key in FENCED_CONTRACT_KEYS: + contract.pop(key, None) + safety = command_pack["safety_contract"] + safety["orphaned_goal_state_blocks_continuation"] = True + safety["mutation_requires_user_confirmation"] = True + safety["explicit_goal_start_may_write_project_local_state"] = False + safety["explicit_goal_start_must_activate_host_loop"] = False + safety["host_loop_activation_allowed"] = False + command_pack["orphaned_goal_state"] = gate + command_pack["recommended_next_step"] = { + "kind": "resolve_orphaned_goal_state", + "requires_user_confirmation": True, + "summary": ORPHANED_GOAL_STATE_REASON, + "orphaned_goal_state_gate": gate, + } + + +def guided_fence(gate: dict[str, Any]) -> dict[str, Any]: + """The guided-transaction fields that replace every continuation step.""" + + return { + "blocked_by": ORPHANED_GOAL_STATE_CONNECTION, + "orphaned_goal_state_gate": gate, + "ordered_steps": [ + { + "id": "inspect_connection", + "kind": "read_only", + "purpose": "resolve the requested project route and confirm the registry and orphaned state disagree", + }, + { + "id": "resolve_orphaned_goal_state", + "kind": "orphaned_goal_state_gate", + "resolution_routes": gate["resolution_routes"], + "forbidden_until_resolved": gate["forbidden_until_resolved"], + "purpose": ( + "inspect the orphaned state and preview its backup, then stop for " + "the operator's explicit resolution; do not bootstrap over it" + ), + }, + ], + } + + +def _preview_route_lines(gate: dict[str, Any]) -> str: + return "\n".join( + f"- `{route.get('route')}` (preview only): " + f"`{str(route.get('command')).splitlines()[-1]}`" + for route in gate.get("resolution_routes") or [] + if isinstance(route, dict) + ) + + +def fenced_standalone_message(command_pack: dict[str, Any]) -> str: + """Render the standalone command pack for an orphaned Goal. + + The shared renderer's body is connect, plan-write and activation guidance -- + exactly what this fence withholds -- so a fenced pack states its own two + read-only routes instead of that text with holes punched in it. + """ + + gate = command_pack.get("orphaned_goal_state") or {} + return ( + "# LoopX Bootstrap Command Pack\n\n" + f"- project: `{command_pack.get('project')}`\n" + f"- goal_id: `{command_pack.get('goal_id')}`\n" + f"- connection_state: `{ORPHANED_GOAL_STATE_CONNECTION}`\n\n" + f"{gate.get('reason')}\n\n" + f"- withheld until an operator resolves it: " + f"{', '.join(str(name) for name in gate.get('forbidden_until_resolved') or [])}\n" + f"- {gate.get('execution_boundary')}\n\n" + "## Read-only routes\n\n" + f"{_preview_route_lines(gate)}\n" + ) + + +def render_guided_lines(transaction: dict[str, Any]) -> str: + """Render the orphan gate section of the guided Markdown, or nothing.""" + + gate = transaction.get("orphaned_goal_state_gate") + if not isinstance(gate, dict): + return "" + routes = _preview_route_lines(gate) + return ( + "\n## Orphaned Goal State Gate\n\n" + f"{gate.get('reason')}\n\n" + f"- orphaned state: {', '.join(f'`{route}`' for route in gate.get('state_file_routes') or [])}\n" + f"- blocked until resolved: {', '.join(f'`{name}`' for name in gate.get('forbidden_until_resolved') or [])}\n" + f"- {gate.get('execution_boundary')}\n\n" + + routes + + "\n" + ) diff --git a/loopx/control_plane/testing/continuation_verb_guard.py b/loopx/control_plane/testing/continuation_verb_guard.py new file mode 100644 index 000000000..30dd8e455 --- /dev/null +++ b/loopx/control_plane/testing/continuation_verb_guard.py @@ -0,0 +1,77 @@ +"""One rule for which commands a blocked packet may spell out, shared by tests and smokes. + +A packet that withholds a continuation is only as safe as every surface that +carries it: the top-level command map, nested registration and activation +gates, the slash-command catalog, and the Markdown a host reads. This module +states the rule once so a unit test and a shipped CLI smoke cannot drift apart +on what counts as a runnable verb. +""" + +from __future__ import annotations + +import re +from typing import Any, Iterator + +COMMAND_FAMILY = re.compile(r"loopx\s+([a-z][a-z-]*)") + +# Every command family that can move Goal state, a lane, a lease, quota or a host +# loop. Read-only inspection stays out: `status`, `doctor`, +# `bootstrap-command-pack`, and a `backup-state` preview without `--execute`. +MUTATION_COMMAND_FAMILIES = frozenset( + { + "agent-onboard", + "bind-agent-thread", + "bootstrap", + "configure-goal", + "connect", + "heartbeat-prompt", + "issue-fix", + "quota", + "refresh-state", + "register-agent", + "start-goal", + "task-lease", + "todo", + } +) + + +def _strings(value: Any, path: str) -> Iterator[tuple[str, str]]: + if isinstance(value, dict): + for key, item in value.items(): + yield from _strings(item, f"{path}.{key}") + elif isinstance(value, list): + for index, item in enumerate(value): + yield from _strings(item, f"{path}[{index}]") + elif isinstance(value, str): + yield path, value + + +def continuation_verb_findings(payload: Any) -> list[tuple[str, str]]: + """Every line in ``payload`` that spells a runnable mutation or ``--execute``. + + Judged per line: a runnable command always carries its own ``--execute`` on + the same line, while prose that only explains a preview boundary mentions the + two apart. + """ + + return [ + (path, line[:120]) + for path, text in _strings(payload, "packet") + for line in text.splitlines() + if "loopx " in line + and ( + "--execute" in line + or any( + family in MUTATION_COMMAND_FAMILIES + for family in COMMAND_FAMILY.findall(line) + ) + ) + ] + + +def assert_no_continuation_verb(payload: Any, *, source: str = "packet") -> None: + """Fail on any command this packet's surfaces still offer for mutation.""" + + findings = continuation_verb_findings(payload) + assert findings == [], (source, findings) diff --git a/tests/control_plane/test_start_goal_orphaned_goal_state.py b/tests/control_plane/test_start_goal_orphaned_goal_state.py new file mode 100644 index 000000000..f9c60dbf9 --- /dev/null +++ b/tests/control_plane/test_start_goal_orphaned_goal_state.py @@ -0,0 +1,404 @@ +"""Guard the guided flow when Goal state outlives its registry entry (#4801). + +A reset that removes a Goal from ``.loopx/registry.json`` but leaves its +``ACTIVE_GOAL_STATE.md`` behind is not ordinary absence. Continuing with the +normal bootstrap, agent-registration, Todo, quota, or host-activation +continuation would let a fresh lane write over the orphaned state under the same +human-readable id, and ``diagnose`` would then report a healthy Goal. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from loopx.bootstrap_command_pack import ( + build_loopx_bootstrap_command_pack, + build_start_goal_guided_packet, + inspect_bootstrap_connection, + render_loopx_bootstrap_command_pack_message, +) +from loopx.control_plane.goals.orphaned_goal_state import ( + GOAL_STATE_ROOTS, + ORPHANED_GOAL_STATE_CONNECTION, + orphaned_goal_state_routes, +) +from loopx.control_plane.testing.continuation_verb_guard import assert_no_continuation_verb +from loopx.control_plane.testing.onboarding_model_behavior_qualification import ( + onboarding_entry_contract_violations, + onboarding_entry_semantic_contract, +) + +ORPHANED_GOAL_ID = "reset-goal" +REGISTERED_GOAL_ID = "live-goal" +GOAL_TEXT = "Continue the interrupted refactor." + + +def _project( + root: Path, + *, + orphaned_state_dirs: tuple[str, ...] = (), + orphaned_goal_id: str = ORPHANED_GOAL_ID, + registry: str = "registered", +) -> Path: + """Build a project carrying an orphaned state file for ``orphaned_goal_id``. + + ``registry`` selects how far the reset got: ``registered`` keeps the other + Goal's entry, ``empty`` keeps the file but declares no Goal, ``missing`` + removes the project registry entirely, and ``invalid`` leaves a file that no + longer parses. + """ + + project = root / "project" + registry_path = project / ".loopx" / "registry.json" + registry_path.parent.mkdir(parents=True) + entries = ( + [ + { + "id": REGISTERED_GOAL_ID, + "status": "active", + "repo": str(project), + "state_file": f".codex/goals/{REGISTERED_GOAL_ID}/ACTIVE_GOAL_STATE.md", + "coordination": { + "agent_model": "peer_v1", + "registered_agents": ["codex-live"], + }, + } + ] + if registry == "registered" + else [] + ) + if registry == "invalid": + registry_path.write_text('{"schema_version": "0.1", "goals": [', encoding="utf-8") + elif registry != "missing": + registry_path.write_text( + json.dumps({"schema_version": "0.1", "goals": entries}, indent=2) + "\n", + encoding="utf-8", + ) + registered_state = project / ".codex" / "goals" / REGISTERED_GOAL_ID + registered_state.mkdir(parents=True) + (registered_state / "ACTIVE_GOAL_STATE.md").write_text( + "# Live goal state\n", encoding="utf-8" + ) + for state_dir in orphaned_state_dirs: + orphan = project / state_dir / orphaned_goal_id + orphan.mkdir(parents=True) + (orphan / "ACTIVE_GOAL_STATE.md").write_text( + "# Orphaned goal state written by a retired lane\n", encoding="utf-8" + ) + return project + + +def _guided(project: Path, goal_id: str = ORPHANED_GOAL_ID) -> dict[str, Any]: + return build_start_goal_guided_packet( + project=project, + goal_id=goal_id, + agent_id=None, + cli_bin="loopx", + host_surface="codex-app", + goal_text=GOAL_TEXT, + available_capabilities=["network"], + ) + + +def _command_pack(project: Path, goal_id: str = ORPHANED_GOAL_ID) -> dict[str, Any]: + return build_loopx_bootstrap_command_pack( + project=project, + goal_id=goal_id, + agent_id=None, + cli_bin="loopx", + host_surface="codex-app", + goal_text=GOAL_TEXT, + available_capabilities=["network"], + ) + + +# ---- the detected fact ------------------------------------------------------- + + +def test_orphan_detection_covers_current_and_legacy_routes(tmp_path: Path) -> None: + roots = tuple("/".join(root) for root in GOAL_STATE_ROOTS) + project = _project(tmp_path, orphaned_state_dirs=roots) + + assert orphaned_goal_state_routes(project, ORPHANED_GOAL_ID) == [ + f"{root}/{ORPHANED_GOAL_ID}/ACTIVE_GOAL_STATE.md" for root in roots + ] + + +def test_inspection_separates_orphaned_state_from_plain_absence( + tmp_path: Path, +) -> None: + orphaned = inspect_bootstrap_connection( + _project(tmp_path / "orphaned", orphaned_state_dirs=(".codex/goals",)), + goal_id=ORPHANED_GOAL_ID, + ) + assert orphaned["connection_state"] == ORPHANED_GOAL_STATE_CONNECTION + assert orphaned["goal_found"] is False + assert orphaned["bootstrap_continuation_allowed"] is False + assert orphaned["orphaned_goal_state"]["state_file_routes"] == [ + f".codex/goals/{ORPHANED_GOAL_ID}/ACTIVE_GOAL_STATE.md" + ] + + absent = inspect_bootstrap_connection( + _project(tmp_path / "absent"), + goal_id=ORPHANED_GOAL_ID, + ) + assert absent["connection_state"] == "registry_without_goal" + assert "orphaned_goal_state" not in absent + + +def test_candidate_matching_is_scoped_to_the_requested_goal(tmp_path: Path) -> None: + # Only ``live-goal`` has state, and it is registered, so nothing is orphaned. + project = _project(tmp_path) + + assert orphaned_goal_state_routes(project, ORPHANED_GOAL_ID) == [] + assert ( + inspect_bootstrap_connection(project, goal_id=ORPHANED_GOAL_ID)[ + "connection_state" + ] + == "registry_without_goal" + ) + assert ( + inspect_bootstrap_connection(project, goal_id=REGISTERED_GOAL_ID)[ + "connection_state" + ] + == "connected" + ) + + +# ---- the fence: no activation path over orphaned state ----------------------- + + +def test_guided_packet_offers_only_previews_over_orphaned_state( + tmp_path: Path, +) -> None: + payload = _guided(_project(tmp_path, orphaned_state_dirs=(".codex/goals",))) + transaction = payload["guided_transaction"] + + assert transaction["blocked_by"] == ORPHANED_GOAL_STATE_CONNECTION + assert transaction["writes_now"] is False + assert transaction["spends_quota_now"] is False + assert [step["id"] for step in transaction["ordered_steps"]] == [ + "inspect_connection", + "resolve_orphaned_goal_state", + ] + gate = transaction["orphaned_goal_state_gate"] + assert gate["schema_version"] == "loopx_orphaned_goal_state_gate_v0" + assert gate["forbidden_until_resolved"] == [ + "bootstrap", + "agent_registration", + "todo_write", + "quota_spend", + "host_loop_activation", + ] + assert [route["route"] for route in gate["resolution_routes"]] == [ + "inspect_registry_and_state", + "preview_state_backup", + ] + for route in gate["resolution_routes"]: + assert route["mutates"] is False + assert "--execute" not in route["command"] + assert route["command"].splitlines()[-1].startswith("loopx "), route + contract = payload["safety_contract"] + assert contract["writes_state_file"] is False + assert contract["spends_quota"] is False + assert contract["force_bootstrap_allowed"] is False + assert contract["mutation_commands_are_previewed"] is True + + +def test_guided_packet_carries_no_bootstrap_or_todo_authoring_continuation( + tmp_path: Path, +) -> None: + project = _project(tmp_path, orphaned_state_dirs=(".codex/goals",)) + payload = _guided(project) + commands = payload["command_pack"]["commands"] + + assert commands["goal_start_connect_if_needed"] is None + assert commands["bootstrap_after_user_confirmation"] is None + assert commands["goal_start_plan_prompt"] is None + assert payload["recommended_next_step"]["kind"] == "resolve_orphaned_goal_state" + assert payload["recommended_next_step"]["requires_user_confirmation"] is True + assert "identity_selection_gate" not in payload["guided_transaction"] + + message = payload["message"] + assert "Orphaned Goal State Gate" in message + assert "todo add" not in message + assert not [ + line + for line in message.splitlines() + if line.lstrip().startswith("`loopx ") and "--execute" in line + ], message + # The orphan routes stay project-relative; a projected absolute path would + # carry the operator's filesystem into host-facing artifacts. The preview + # commands still `cd` into the resolved project, as every packet command does. + assert str(project) not in json.dumps( + payload["guided_transaction"]["orphaned_goal_state_gate"]["state_file_routes"] + ) + + +def test_command_pack_fence_matches_the_guided_packet(tmp_path: Path) -> None: + payload = _command_pack(_project(tmp_path, orphaned_state_dirs=(".claude/goals",))) + + assert payload["orphaned_goal_state"]["state_file_routes"] == [ + f".claude/goals/{ORPHANED_GOAL_ID}/ACTIVE_GOAL_STATE.md" + ] + contract = payload["safety_contract"] + assert contract["orphaned_goal_state_blocks_continuation"] is True + assert contract["explicit_goal_start_may_write_project_local_state"] is False + assert contract["host_loop_activation_allowed"] is False + assert contract["mutation_requires_user_confirmation"] is True + + +# ---- the fence did not widen: untouched routes keep their old behavior ------- + + +def test_plain_absence_keeps_the_connect_continuation(tmp_path: Path) -> None: + payload = _guided(_project(tmp_path)) + transaction = payload["guided_transaction"] + + assert transaction.get("blocked_by") != ORPHANED_GOAL_STATE_CONNECTION + step_ids = [step["id"] for step in transaction["ordered_steps"]] + assert step_ids[:2] == ["inspect_connection", "connect_if_needed"] + assert payload["command_pack"]["commands"]["goal_start_connect_if_needed"] + assert payload["safety_contract"]["orphaned_goal_state_blocks_continuation"] is False + + +def test_connected_goal_packet_is_unchanged(tmp_path: Path) -> None: + payload = _guided( + _project(tmp_path, orphaned_state_dirs=(".codex/goals",)), + goal_id=REGISTERED_GOAL_ID, + ) + + assert payload["project_connection"]["connection_state"] == "connected" + assert "orphaned_goal_state_gate" not in payload["guided_transaction"] + assert [step["id"] for step in payload["guided_transaction"]["ordered_steps"]][0] == ( + "inspect_connection" + ) + + +# ---- the shipped onboarding qualifier classifies the fence as a stop -------- + + +def test_obeying_agent_has_no_actionable_command_at_the_fence(tmp_path: Path) -> None: + payload = _guided(_project(tmp_path / "fence", orphaned_state_dirs=(".codex/goals",))) + contract = onboarding_entry_semantic_contract(payload) + + assert contract["route"] == "stop" + assert contract["action_command_ids"] == [] + assert contract["writes_now"] is False + assert contract["spends_quota_now"] is False + assert onboarding_entry_contract_violations(contract) == [] + + unblocked = onboarding_entry_semantic_contract( + _guided(_project(tmp_path / "clear")) + ) + assert unblocked["route"] == "select_agent_identity" + assert unblocked["action_command_ids"] + + +# ---- no consumption surface may keep a continuation verb --------------------- + + +def test_every_fenced_consumption_surface_is_read_only(tmp_path: Path) -> None: + project = _project(tmp_path, orphaned_state_dirs=(".codex/goals",)) + + guided = _guided(project) + command_pack = _command_pack(project) + + assert_no_continuation_verb(guided, source="guided_packet") + assert_no_continuation_verb(command_pack, source="standalone_command_pack") + assert_no_continuation_verb( + render_loopx_bootstrap_command_pack_message(command_pack), + source="standalone_rendered_message", + ) + # The guard above must not be able to pass on a packet that has nothing left + # to tell the operator: the read-only half survives the rebuild. + assert command_pack["commands"]["status"] + assert [ + route["route"] + for route in guided["guided_transaction"]["orphaned_goal_state_gate"]["resolution_routes"] + ] == ["inspect_registry_and_state", "preview_state_backup"] + + +# ---- the same invariant holds whatever shape the reset left the registry in -- + + +def _assert_fenced(payload: dict[str, Any]) -> None: + transaction = payload["guided_transaction"] + + assert transaction["blocked_by"] == ORPHANED_GOAL_STATE_CONNECTION, transaction + assert [step["id"] for step in transaction["ordered_steps"]] == [ + "inspect_connection", + "resolve_orphaned_goal_state", + ], transaction + commands = payload["command_pack"]["commands"] + for key in ( + "goal_start_connect_if_needed", + "goal_start_refresh_state", + "goal_start_host_loop_activation", + "goal_start_quota_should_run", + "goal_start_plan_prompt", + ): + assert commands[key] is None, key + assert onboarding_entry_semantic_contract(payload)["action_command_ids"] == [] + + +@pytest.mark.parametrize( + ("registry", "absence_connection"), + [ + ("missing", "not_connected"), + ("empty", "registry_without_goal"), + ("invalid", "registry_invalid"), + ], +) +def test_reset_without_a_registry_still_fences_surviving_state( + tmp_path: Path, registry: str, absence_connection: str +) -> None: + project = _project( + tmp_path / registry, + orphaned_state_dirs=(".codex/goals",), + registry=registry, + ) + + connection = inspect_bootstrap_connection(project, goal_id=ORPHANED_GOAL_ID) + assert connection["connection_state"] == ORPHANED_GOAL_STATE_CONNECTION, connection + assert connection["orphaned_goal_state"]["state_file_routes"] == [ + f".codex/goals/{ORPHANED_GOAL_ID}/ACTIVE_GOAL_STATE.md" + ], connection + # The fence must not erase which absence it was reached through: a registry + # that cannot be parsed is a different operator action from a deleted entry. + assert connection["absent_connection_state"] == absence_connection, connection + assert connection["absent_reason"], connection + assert connection["reason"] != connection["absent_reason"], connection + _assert_fenced(_guided(project)) + + +@pytest.mark.parametrize( + ("registry", "absence_connection"), + [ + ("missing", "not_connected"), + ("empty", "registry_without_goal"), + ("invalid", "registry_invalid"), + ], +) +def test_reset_without_orphaned_state_is_still_ordinary_onboarding( + tmp_path: Path, registry: str, absence_connection: str +) -> None: + project = _project(tmp_path / f"clear-{registry}", registry=registry) + + connection = inspect_bootstrap_connection(project, goal_id=ORPHANED_GOAL_ID) + assert connection["connection_state"] == absence_connection, connection + assert connection["registry_exists"] is (registry != "missing"), connection + assert "orphaned_goal_state" not in connection, connection + + payload = _guided(project) + transaction = payload["guided_transaction"] + assert transaction.get("blocked_by") != ORPHANED_GOAL_STATE_CONNECTION, transaction + assert "orphaned_goal_state_gate" not in transaction, transaction + assert [step["id"] for step in transaction["ordered_steps"]][1] == ( + "connect_if_needed" + ) + assert payload["command_pack"]["commands"]["goal_start_connect_if_needed"]